Set the correct edit_uri or repo_url for documentation pages that are hosted on GitHub and GitLab

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-08-10 15:09:03 +02:00
parent 91ff484f8e
commit 8b0f6f860f
10 changed files with 213 additions and 185 deletions
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/techdocs-common': minor
---
Set the correct `edit_uri` or `repo_url` for documentation pages that are hosted on GitHub and GitLab.
The constructor of the `TechDocsGenerator` changed.
Prefer the use of `TechdocsGenerator.fromConfig(…)` instead:
```diff
- const techdocsGenerator = new TechdocsGenerator({
+ const techdocsGenerator = TechdocsGenerator.fromConfig(config, {
logger,
containerRunner,
- config,
});
```
+3 -1
View File
@@ -230,10 +230,12 @@ export class TechdocsGenerator implements GeneratorBase {
logger,
containerRunner,
config,
scmIntegrations,
}: {
logger: Logger_2;
containerRunner: ContainerRunner;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
});
// (undocumented)
static fromConfig(
@@ -245,7 +247,7 @@ export class TechdocsGenerator implements GeneratorBase {
containerRunner: ContainerRunner;
logger: Logger_2;
},
): Promise<TechdocsGenerator>;
): TechdocsGenerator;
// (undocumented)
run({
inputDir,
+1
View File
@@ -49,6 +49,7 @@
"aws-sdk": "^2.840.0",
"express": "^4.17.1",
"fs-extra": "9.1.0",
"git-url-parse": "~11.4.4",
"js-yaml": "^4.0.0",
"json5": "^2.1.3",
"mime-types": "^2.1.27",
@@ -0,0 +1,4 @@
site_name: Test site name
site_description: Test site description
edit_uri: https://github.com/backstage/backstage/edit/main/docs
@@ -44,10 +44,9 @@ describe('generators', () => {
it('should return correct registered generator', async () => {
const generators = new Generators();
const techdocs = new TechdocsGenerator({
const techdocs = TechdocsGenerator.fromConfig(new ConfigReader({}), {
logger,
containerRunner,
config: new ConfigReader({}),
});
generators.register('techdocs', techdocs);
@@ -38,10 +38,9 @@ export class Generators implements GeneratorBuilder {
): Promise<GeneratorBuilder> {
const generators = new Generators();
const techdocsGenerator = new TechdocsGenerator({
const techdocsGenerator = TechdocsGenerator.fromConfig(config, {
logger,
containerRunner,
config,
});
generators.register('techdocs', techdocsGenerator);
@@ -13,19 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import os from 'os';
import path, { resolve as resolvePath } from 'path';
import { ParsedLocationAnnotation } from '../../helpers';
import { RemoteProtocol } from '../prepare/types';
import {
addBuildTimestampMetadata,
getGeneratorKey,
getMkdocsYml,
getRepoUrlFromLocationAnnotation,
isValidRepoUrlForMkdocs,
patchMkdocsYmlPreBuild,
storeEtagMetadata,
validateMkdocsYaml,
@@ -48,6 +49,9 @@ const mkdocsYmlWithExtensions = fs.readFileSync(
const mkdocsYmlWithRepoUrl = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_repo_url.yml'),
);
const mkdocsYmlWithEditUri = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_edit_uri.yml'),
);
const mkdocsYmlWithValidDocDir = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_valid_doc_dir.yml'),
);
@@ -60,6 +64,8 @@ const mkdocsYmlWithInvalidDocDir2 = fs.readFileSync(
const mockLogger = getVoidLogger();
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const scmIntegrations = ScmIntegrations.fromConfig(new ConfigReader({}));
describe('helpers', () => {
describe('getGeneratorKey', () => {
it('should return techdocs as the only generator key', () => {
@@ -68,120 +74,85 @@ describe('helpers', () => {
});
});
describe('isValidRepoUrlForMkdocs', () => {
it('should return true for valid repo_url values for mkdocs', () => {
const validRepoUrls = [
'https://github.com/org/repo',
'https://github.com/backstage/backstage/',
'https://github.com/org123/repo1-2-3/',
'http://github.com/insecureOrg/insecureRepo',
'https://gitlab.com/org/repo',
'https://gitlab.com/backstage/backstage/',
'https://gitlab.com/org123/repo1-2-3/',
'http://gitlab.com/insecureOrg/insecureRepo',
];
const validRemoteProtocols = ['github', 'gitlab'];
validRepoUrls.forEach(url => {
validRemoteProtocols.forEach(targetType => {
expect(
isValidRepoUrlForMkdocs(url, targetType as RemoteProtocol),
).toBe(true);
});
});
});
it('should return false for invalid repo_urls values for mkdocs', () => {
const invalidRepoUrls = [
'git@github.com:org/repo',
'https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend',
];
invalidRepoUrls.forEach(url => {
expect(isValidRepoUrlForMkdocs(url, 'github')).toBe(false);
});
});
it('should return false for unsupported remote protocols', () => {
const validRepoUrl = 'https://github.com/backstage/backstage';
const unsupportedRemoteProtocols = ['dir', 'file', 'url'];
unsupportedRemoteProtocols.forEach(targetType => {
expect(
isValidRepoUrlForMkdocs(validRepoUrl, targetType as RemoteProtocol),
).toBe(false);
});
});
});
describe('getRepoUrlFromLocationAnnotation', () => {
it('should return undefined for unsupported location type', () => {
const parsedLocationAnnotation1: ParsedLocationAnnotation = {
it.each`
url | repo_url | edit_uri
${'https://github.com/backstage/backstage'} | ${'https://github.com/backstage/backstage'} | ${undefined}
${'https://github.com/backstage/backstage/tree/main/examples/techdocs/'} | ${undefined} | ${'https://github.com/backstage/backstage/edit/main/examples/techdocs/docs'}
${'https://github.com/backstage/backstage/tree/main/'} | ${undefined} | ${'https://github.com/backstage/backstage/edit/main/docs'}
${'https://gitlab.com/backstage/backstage'} | ${'https://gitlab.com/backstage/backstage'} | ${undefined}
${'https://gitlab.com/backstage/backstage/-/blob/main/examples/techdocs/'} | ${undefined} | ${'https://gitlab.com/backstage/backstage/-/edit/main/examples/techdocs/docs'}
${'https://gitlab.com/backstage/backstage/-/blob/main/'} | ${undefined} | ${'https://gitlab.com/backstage/backstage/-/edit/main/docs'}
`('should convert $url', ({ url: target, repo_url, edit_uri }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({ repo_url, edit_uri });
});
it.each`
url | edit_uri
${'https://github.com/backstage/backstage/tree/main/examples/techdocs/'} | ${'https://github.com/backstage/backstage/edit/main/examples/techdocs/custom/folder'}
${'https://github.com/backstage/backstage/tree/main/'} | ${'https://github.com/backstage/backstage/edit/main/custom/folder'}
${'https://gitlab.com/backstage/backstage/-/blob/main/examples/techdocs/'} | ${'https://gitlab.com/backstage/backstage/-/edit/main/examples/techdocs/custom/folder'}
${'https://gitlab.com/backstage/backstage/-/blob/main/'} | ${'https://gitlab.com/backstage/backstage/-/edit/main/custom/folder'}
`(
'should convert $url with custom docsFolder',
({ url: target, edit_uri }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
'./custom/folder',
),
).toEqual({ edit_uri });
},
);
it.each`
url
${'https://bitbucket.org/backstage/backstage/src/master/examples/techdocs/'}
${'https://bitbucket.org/backstage/backstage/src/master/'}
${'https://dev.azure.com/organization/project/_git/repository?path=%2Fexamples%2Ftechdocs'}
${'https://dev.azure.com/organization/project/_git/repository?path=%2F'}
`('should ignore $url', ({ url: target }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({});
});
it('should ignore unsupported location type', () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'dir',
target: '/home/user/workspace/docs-repository',
};
const parsedLocationAnnotation2: ParsedLocationAnnotation = {
type: 'file' as RemoteProtocol,
target: '/home/user/workspace/docs-repository/catalog-info.yaml',
};
const parsedLocationAnnotation3: ParsedLocationAnnotation = {
type: 'url',
target: 'https://my-website.com/storage/this/docs/repository',
};
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation1)).toBe(
undefined,
);
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation2)).toBe(
undefined,
);
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation3)).toBe(
undefined,
);
});
it('should return correct target url for supported hosts', () => {
const parsedLocationAnnotation1: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
target: 'https://github.com/backstage/backstage.git',
};
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation1)).toBe(
'https://github.com/backstage/backstage',
);
const parsedLocationAnnotation2: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
target: 'https://github.com/org/repo',
};
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation2)).toBe(
'https://github.com/org/repo',
);
const parsedLocationAnnotation3: ParsedLocationAnnotation = {
type: 'gitlab' as RemoteProtocol,
target: 'https://gitlab.com/org/repo',
};
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation3)).toBe(
'https://gitlab.com/org/repo',
);
const parsedLocationAnnotation4: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
target:
'github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component',
};
expect(getRepoUrlFromLocationAnnotation(parsedLocationAnnotation4)).toBe(
'github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component',
);
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({});
});
});
@@ -190,6 +161,7 @@ describe('helpers', () => {
mockFs({
'/mkdocs.yml': mkdocsYml,
'/mkdocs_with_repo_url.yml': mkdocsYmlWithRepoUrl,
'/mkdocs_with_edit_uri.yml': mkdocsYmlWithEditUri,
'/mkdocs_with_extensions.yml': mkdocsYmlWithExtensions,
});
});
@@ -198,9 +170,9 @@ describe('helpers', () => {
mockFs.restore();
});
it('should add repo_url to mkdocs.yml', async () => {
it('should add edit_uri to mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
type: 'url',
target: 'https://github.com/backstage/backstage',
};
@@ -208,6 +180,7 @@ describe('helpers', () => {
'/mkdocs.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs.yml');
@@ -219,7 +192,7 @@ describe('helpers', () => {
it('should add repo_url to mkdocs.yml that contains custom yaml tags', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
type: 'url',
target: 'https://github.com/backstage/backstage',
};
@@ -227,6 +200,7 @@ describe('helpers', () => {
'/mkdocs_with_extensions.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_extensions.yml');
@@ -241,7 +215,7 @@ describe('helpers', () => {
it('should not override existing repo_url in mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'github' as RemoteProtocol,
type: 'url',
target: 'https://github.com/neworg/newrepo',
};
@@ -249,6 +223,7 @@ describe('helpers', () => {
'/mkdocs_with_repo_url.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_repo_url.yml');
@@ -260,6 +235,29 @@ describe('helpers', () => {
'repo_url: https://github.com/neworg/newrepo',
);
});
it('should not override existing edit_uri in mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target: 'https://github.com/neworg/newrepo',
};
await patchMkdocsYmlPreBuild(
'/mkdocs_with_edit_uri.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_edit_uri.yml');
expect(updatedMkdocsYml.toString()).toContain(
'edit_uri: https://github.com/backstage/backstage/edit/main/docs',
);
expect(updatedMkdocsYml.toString()).not.toContain(
'https://github.com/neworg/newrepo',
);
});
});
describe('addBuildTimestampMetadata', () => {
@@ -14,10 +14,12 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { isChildPath } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { spawn } from 'child_process';
import fs from 'fs-extra';
import gitUrlParse from 'git-url-parse';
import yaml, { DEFAULT_SCHEMA, Type } from 'js-yaml';
import path, { resolve as resolvePath } from 'path';
import { PassThrough, Writable } from 'stream';
@@ -80,63 +82,42 @@ export const runCommand = async ({
};
/**
* Return true if mkdocs can compile docs with provided repo_url
*
* Valid repo_url examples in mkdocs.yml
* - https://github.com/backstage/backstage
* - https://gitlab.com/org/repo/
* - http://github.com/backstage/backstage
* - A http(s) protocol URL to the root of the repository
*
* Invalid repo_url examples in mkdocs.yml
* - https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component
* - (anything that is not valid as described above)
*
* @param {string} repoUrl URL supposed to be used as repo_url in mkdocs.yml
* @param {string} locationType Type of source code host - github, gitlab, dir, url, etc.
* @returns {boolean}
*/
export const isValidRepoUrlForMkdocs = (
repoUrl: string,
locationType: string,
): boolean => {
// Trim trailing slash
const cleanRepoUrl = repoUrl.replace(/\/$/, '');
if (locationType === 'github' || locationType === 'gitlab') {
// A valid repoUrl to the root of the repository will be split into 5 strings if split using the / delimiter.
// We do not want URLs which have more than that number of forward slashes since they will signify a non-root location
// Note: This is not the best possible implementation but will work most of the times.. Feel free to improve or
// highlight edge cases.
return cleanRepoUrl.split('/').length === 5;
}
return false;
};
/**
* Return a valid URL of the repository used in backstage.io/techdocs-ref annotation.
* Return undefined if the `target` is not valid in context of repo_url in mkdocs.yml
* Alter URL so that it is a valid repo_url config in mkdocs.yml
* Return the source url for MkDocs based on the backstage.io/techdocs-ref annotation.
* Depending on the type of target, it can either return a repo_url, an edit_uri, both, or none.
*
* @param {ParsedLocationAnnotation} parsedLocationAnnotation Object with location url and type
* @returns {string | undefined}
* @param {ScmIntegrationRegistry} scmIntegrations the scmIntegration to do url transformations
* @param {string} docsFolder the configured docs folder in the mkdocs.yml (defaults to 'docs')
* @returns the settings for the mkdocs.yml
*/
export const getRepoUrlFromLocationAnnotation = (
parsedLocationAnnotation: ParsedLocationAnnotation,
): string | undefined => {
scmIntegrations: ScmIntegrationRegistry,
docsFolder: string = 'docs',
): { repo_url?: string; edit_uri?: string } => {
const { type: locationType, target } = parsedLocationAnnotation;
// Add more options from the RemoteProtocol type of parsedLocationAnnotation.type here
// when TechDocs supports more hosts and if mkdocs can generated an Edit URL for them.
const supportedHosts = ['github', 'gitlab'];
if (locationType === 'url') {
const integration = scmIntegrations.byUrl(target);
if (supportedHosts.includes(locationType)) {
// Trim .git or .git/ from the end of repository url
return target.replace(/.git\/*$/, '');
// We only support it for github and gitlab for now as the edit_uri
// is not properly supported for others yet.
if (integration && ['github', 'gitlab'].includes(integration.type)) {
// handle the case where a user manually writes url:https://github.com/backstage/backstage i.e. without /blob/...
const { filepathtype } = gitUrlParse(target);
if (filepathtype === '') {
return { repo_url: target };
}
const sourceFolder = integration.resolveUrl({
url: `./${docsFolder}`,
base: target,
});
return { edit_uri: integration.resolveEditUrl(sourceFolder) };
}
}
return undefined;
return {};
};
class UnknownTag {
@@ -217,7 +198,7 @@ export const validateMkdocsYaml = async (
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add repo_url if it does not exists
* - Add repo_url or edit_uri if it does not exists
* If mkdocs.yml has a repo_url, the generated docs site gets an Edit button on the pages by default.
* If repo_url is missing in mkdocs.yml, we will use techdocs annotation of the entity to possibly get
* the repository URL.
@@ -228,11 +209,13 @@ export const validateMkdocsYaml = async (
* @param {string} mkdocsYmlPath Absolute path to mkdocs.yml or equivalent of a docs site
* @param {Logger} logger
* @param {ParsedLocationAnnotation} parsedLocationAnnotation Object with location url and type
* @param {ScmIntegrationRegistry} scmIntegrations the scmIntegration to do url transformations
*/
export const patchMkdocsYmlPreBuild = async (
mkdocsYmlPath: string,
logger: Logger,
parsedLocationAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
) => {
let mkdocsYmlFileString;
try {
@@ -260,16 +243,25 @@ export const patchMkdocsYmlPreBuild = async (
return;
}
// Add repo_url to mkdocs.yml if it is missing. This will enable the Page edit button generated by MkDocs.
if (!('repo_url' in mkdocsYml)) {
const repoUrl = getRepoUrlFromLocationAnnotation(parsedLocationAnnotation);
if (repoUrl !== undefined) {
// mkdocs.yml will not build with invalid repo_url. So, make sure it is valid.
// TODO: this is no longer working/meaningful because annotation type is
// now only ever "url" or "dir." Should be re-implemented!
if (isValidRepoUrlForMkdocs(repoUrl, parsedLocationAnnotation.type)) {
mkdocsYml.repo_url = repoUrl;
}
// Add edit_uri and/or repo_url to mkdocs.yml if it is missing.
// This will enable the Page edit button generated by MkDocs.
// If the either has been set, keep the original value
if (!('repo_url' in mkdocsYml) && !('edit_uri' in mkdocsYml)) {
const result = getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
mkdocsYml.docs_dir,
);
if (result.repo_url || result.edit_uri) {
mkdocsYml.repo_url = result.repo_url;
mkdocsYml.edit_uri = result.edit_uri;
logger.info(
`Set ${JSON.stringify(
result,
)}. You can disable this feature by manually setting 'repo_url' or 'edit_uri' according to the MkDocs documentation at https://www.mkdocs.org/user-guide/configuration/#repo_url`,
);
}
}
@@ -18,6 +18,10 @@ import { ContainerRunner } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import path from 'path';
import { Logger } from 'winston';
import {
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import {
addBuildTimestampMetadata,
getMkdocsYml,
@@ -39,29 +43,39 @@ export class TechdocsGenerator implements GeneratorBase {
private readonly logger: Logger;
private readonly containerRunner: ContainerRunner;
private readonly options: GeneratorConfig;
private readonly scmIntegrations: ScmIntegrationRegistry;
static async fromConfig(
static fromConfig(
config: Config,
{
containerRunner,
logger,
}: { containerRunner: ContainerRunner; logger: Logger },
) {
return new TechdocsGenerator({ logger, containerRunner, config });
const scmIntegrations = ScmIntegrations.fromConfig(config);
return new TechdocsGenerator({
logger,
containerRunner,
config,
scmIntegrations,
});
}
constructor({
logger,
containerRunner,
config,
scmIntegrations,
}: {
logger: Logger;
containerRunner: ContainerRunner;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
}) {
this.logger = logger;
this.options = readGeneratorConfig(config, logger);
this.containerRunner = containerRunner;
this.scmIntegrations = scmIntegrations;
}
public async run({
@@ -74,16 +88,19 @@ export class TechdocsGenerator implements GeneratorBase {
}: GeneratorRunOptions): Promise<void> {
// Do some updates to mkdocs.yml before generating docs e.g. adding repo_url
const { path: mkdocsYmlPath, content } = await getMkdocsYml(inputDir);
// validate the docs_dir first
await validateMkdocsYaml(inputDir, content);
if (parsedLocationAnnotation) {
await patchMkdocsYmlPreBuild(
mkdocsYmlPath,
childLogger,
parsedLocationAnnotation,
this.scmIntegrations,
);
}
await validateMkdocsYaml(inputDir, content);
// Directories to bind on container
const mountDirs = {
[inputDir]: '/input',
@@ -70,10 +70,9 @@ export async function startStandaloneServer(
const containerRunner = new DockerContainerRunner({ dockerClient });
const generators = new Generators();
const techdocsGenerator = new TechdocsGenerator({
const techdocsGenerator = TechdocsGenerator.fromConfig(config, {
logger,
containerRunner,
config,
});
generators.register('techdocs', techdocsGenerator);