Skip to content

Commit

Permalink
Parse git urls (#127)
Browse files Browse the repository at this point in the history
  • Loading branch information
code-asher authored May 14, 2024
1 parent a4f3749 commit 2fd0959
Show file tree
Hide file tree
Showing 11 changed files with 177 additions and 32 deletions.
2 changes: 2 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"git-url-parse": "^14.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.25.1",
"@types/git-url-parse": "^9.0.3",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
Expand Down
5 changes: 4 additions & 1 deletion plugins/backstage-plugin-devcontainers-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export * from './service/router';
export { DevcontainersProcessor } from './processors/DevcontainersProcessor';
export {
DevcontainersProcessor,
type VsCodeUrlKey,
} from './processors/DevcontainersProcessor';
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ describe(`${DevcontainersProcessor.name}`, () => {
expect(inputEntity).toEqual(inputSnapshot);

const metadataCompare = structuredClone(inputSnapshot.metadata);
metadataCompare.annotations = {
...(metadataCompare.annotations ?? {}),
vsCodeUrl:
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
};
delete metadataCompare.tags;

expect(outputEntity).toEqual(
Expand Down Expand Up @@ -226,6 +231,11 @@ describe(`${DevcontainersProcessor.name}`, () => {
expect(inputEntity).toEqual(inputSnapshot);

const metadataCompare = structuredClone(inputSnapshot.metadata);
metadataCompare.annotations = {
...(metadataCompare.annotations ?? {}),
vsCodeUrl:
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
};
delete metadataCompare.tags;

expect(outputEntity).toEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,18 @@ import { type Config } from '@backstage/config';
import { isError, NotFoundError } from '@backstage/errors';
import { type UrlReader, UrlReaders } from '@backstage/backend-common';
import { type Logger } from 'winston';
import { parseGitUrl } from '../utils/git';

export const DEFAULT_TAG_NAME = 'devcontainers';
export const PROCESSOR_NAME_PREFIX = 'backstage-plugin-devcontainers-backend';

const vsCodeUrlKey = 'vsCodeUrl';

// We export this type instead of the actual constant so we can validate the
// constant on the frontend at compile-time instead of making the backend plugin
// a run-time dependency, so it can continue to run standalone.
export type VsCodeUrlKey = typeof vsCodeUrlKey;

type ProcessorOptions = Readonly<{
tagName: string;
logger: Logger;
Expand Down Expand Up @@ -89,7 +97,12 @@ export class DevcontainersProcessor implements CatalogProcessor {
try {
const jsonUrl = await this.findDevcontainerJson(rootUrl, entityLogger);
entityLogger.info('Found devcontainer config', { url: jsonUrl });
return this.addTag(entity, this.options.tagName, entityLogger);
return this.addMetadata(
entity,
this.options.tagName,
location,
entityLogger,
);
} catch (error) {
if (!isError(error) || error.name !== 'NotFoundError') {
emit(
Expand All @@ -115,16 +128,25 @@ export class DevcontainersProcessor implements CatalogProcessor {
return entity;
}

private addTag(entity: Entity, newTag: string, logger: Logger): Entity {
private addMetadata(
entity: Entity,
newTag: string,
location: LocationSpec,
logger: Logger,
): Entity {
if (entity.metadata.tags?.includes(newTag)) {
return entity;
}

logger.info(`Adding "${newTag}" tag to component`);
logger.info(`Adding VS Code URL and "${newTag}" tag to component`);
return {
...entity,
metadata: {
...entity.metadata,
annotations: {
...(entity.metadata.annotations ?? {}),
[vsCodeUrlKey]: serializeVsCodeUrl(location.target),
},
tags: [...(entity.metadata?.tags ?? []), newTag],
},
};
Expand Down Expand Up @@ -185,3 +207,15 @@ export class DevcontainersProcessor implements CatalogProcessor {
return url;
}
}

/**
* Current implementation for generating the URL will likely need to change as
* we flesh out the backend plugin. For example, it would be nice if there was
* a way to specify the branch instead of always checking out the default.
*/
function serializeVsCodeUrl(repoUrl: string): string {
const cleaners: readonly RegExp[] = [/^url: */];
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
const rootUrl = parseGitUrl(cleanedUrl);
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${rootUrl}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { parseGitUrl } from './git';

describe('git', () => {
it('parses urls', () => {
// List of forges and the various ways URLs can be formed.
const forges = {
github: {
saas: 'github.com',
paths: [
'/tree/foo',
'/blob/foo',
'/tree/foo/dir',
'/blob/foo/dir/file.ts',
],
},
gitlab: {
saas: 'gitlab.com',
paths: [
'/-/tree/foo',
'/-/blob/foo',
'/-/tree/foo/dir?ref_type=heads',
'/-/blob/foo/dir/file.ts?ref_type=heads',
],
},
bitbucket: {
saas: 'bitbucket.org',
paths: [
'/src/hashOrTag',
'/src/hashOrTag?at=foo',
'/src/hashOrTag/dir',
'/src/hashOrTag/dir?at=foo',
'/src/hashOrTag/dir/file.ts',
'/src/hashOrTag/dir/file.ts?at=foo',
],
},
};

for (const [forge, test] of Object.entries(forges)) {
// These are URLs that point to the root of the repository. To these we
// append the above paths to test that the original root URL is extracted.
const baseUrls = [
// Most common format.
`https://${test.saas}/coder/backstage-plugins`,
// GitLab lets you have a sub-group.
`https://${test.saas}/coder/group/backstage-plugins`,
// Self-hosted.
`https://${forge}.coder.com/coder/backstage-plugins`,
// Self-hosted at a port.
`https://${forge}.coder.com:9999/coder/backstage-plugins`,
// Self-hosted at base path.
`https://${forge}.coder.com/base/path/coder/backstage-plugins`,
// Self-hosted without the forge anywhere in the domain.
'https://coder.com/coder/backstage-plugins',
];
for (const baseUrl of baseUrls) {
expect(parseGitUrl(baseUrl)).toEqual(baseUrl);
for (const path of test.paths) {
const url = `${baseUrl}${path}`;
expect(parseGitUrl(url)).toEqual(baseUrl);
}
}
}
});
});
12 changes: 12 additions & 0 deletions plugins/backstage-plugin-devcontainers-backend/src/utils/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import parse from 'git-url-parse';

/**
* Given a repository URL, figure out the base repository.
*/
export function parseGitUrl(url: string): String {
const parsed = parse(url);
// Although it seems to have a `host` property, it is not on the types, so we
// will have to reconstruct it.
const host = parsed.resource + (parsed.port ? `:${parsed.port}` : '');
return `${parsed.protocol}://${host}/${parsed.full_name}`;
}
4 changes: 2 additions & 2 deletions plugins/backstage-plugin-devcontainers-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ _Note: While this plugin can be used standalone, it has been designed to be a fr

### Standalone features

- Custom hooks for reading your special Dev Container metadata tag inside your repo entities, and providing ready-made links to opening that repo in VS Code
- Custom hooks for reading your special Dev Container metadata tag and VS Code launch URI inside your repo entities, and exposing that URI for opening the repo in VS Code

### When combined with the backend plugin

- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation, while letting you read them from custom hooks and components
- Provides an end-to-end solution for automatically adding/removing Dev Containers metadata in your Backstage installation (including tags and the VS Code launch URI), while letting you read them from custom hooks and components

## Setup

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ExampleDevcontainersComponent = () => {
return (
<InfoCard title="Devcontainers plugin">
<p>
Searched component entity for tag:{' '}
Searched component entity for VS Code URL and tag:{' '}
<span className={styles.tagName}>{state.tagName}</span>
</p>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useDevcontainers } from './useDevcontainers';
import { type DevcontainersConfig, DevcontainersProvider } from '../plugin';
import { wrapInTestApp } from '@backstage/test-utils';
import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react';
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';

const mockTagName = 'devcontainers-test';
const mockUrlRoot = 'https://www.github.com/example-company/example-repo';
Expand All @@ -17,7 +16,7 @@ const baseEntity: BackstageEntity = {
name: 'metadata',
tags: [mockTagName, 'other', 'random', 'values'],
annotations: {
[ANNOTATION_SOURCE_LOCATION]: `${mockUrlRoot}/tree/main`,
vsCodeUrl: `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,
},
},
};
Expand Down Expand Up @@ -61,7 +60,7 @@ describe(`${useDevcontainers.name}`, () => {
expect(result2.current.vsCodeUrl).toBe(undefined);
});

it('Does not expose a link when the entity lacks a repo URL', async () => {
it('Does not expose a link when the entity lacks one', async () => {
const { result } = await render(mockTagName, {
...baseEntity,
metadata: {
Expand All @@ -73,7 +72,7 @@ describe(`${useDevcontainers.name}`, () => {
expect(result.current.vsCodeUrl).toBe(undefined);
});

it('Provides a VS Code-formatted link when the current entity has a designated devcontainers tag', async () => {
it('Exposes the link when the entity has both the tag and link', async () => {
const { result } = await render(mockTagName, baseEntity);
expect(result.current.vsCodeUrl).toEqual(
`vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { useDevcontainersConfig } from '../components/DevcontainersProvider';
import { useEntity } from '@backstage/plugin-catalog-react';
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
import type { VsCodeUrlKey } from '@coder/backstage-plugin-devcontainers-backend';

// We avoid importing the actual constant to prevent making the backend plugin a
// run-time dependency, but we can use the type at compile-time to validate the
// string is the same.
const vsCodeUrlKey: VsCodeUrlKey = 'vsCodeUrl';

export type UseDevcontainersResult = Readonly<
{
Expand Down Expand Up @@ -38,8 +43,8 @@ export function useDevcontainers(): UseDevcontainersResult {
};
}

const repoUrl = entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION];
if (!repoUrl) {
const vsCodeUrl = entity.metadata.annotations?.[vsCodeUrlKey];
if (!vsCodeUrl) {
return {
tagName,
hasUrl: false,
Expand All @@ -50,20 +55,6 @@ export function useDevcontainers(): UseDevcontainersResult {
return {
tagName,
hasUrl: true,
vsCodeUrl: serializeVsCodeUrl(repoUrl),
vsCodeUrl,
};
}

/**
* Current implementation for generating the URL will likely need to change as
* we flesh out the backend plugin.
*
* It might make more sense to add the direct VSCode link to the entity data
* from the backend plugin via an annotation field, and remove the need for data
* cleaning here in this function
*/
function serializeVsCodeUrl(repoUrl: string): string {
const cleaners: readonly RegExp[] = [/^url: */, /\/tree\/main\/?$/];
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${cleanedUrl}`;
}
36 changes: 33 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8419,6 +8419,11 @@
"@types/qs" "*"
"@types/serve-static" "*"

"@types/git-url-parse@^9.0.3":
version "9.0.3"
resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.3.tgz#7ee022f8fa06ea74148aa28521cbff85915ac09d"
integrity sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==

"@types/graceful-fs@^4.1.3":
version "4.1.9"
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
Expand Down Expand Up @@ -21913,7 +21918,16 @@ string-length@^4.0.1:
char-regex "^1.0.2"
strip-ansi "^6.0.0"

"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -21987,7 +22001,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand All @@ -22001,6 +22015,13 @@ [email protected]:
dependencies:
ansi-regex "^4.1.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -23809,7 +23830,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -23827,6 +23848,15 @@ wrap-ansi@^6.0.1:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down

0 comments on commit 2fd0959

Please sign in to comment.