Skip to content

Commit 2fd0959

Browse files
authored
Parse git urls (#127)
1 parent a4f3749 commit 2fd0959

File tree

11 files changed

+177
-32
lines changed

11 files changed

+177
-32
lines changed

plugins/backstage-plugin-devcontainers-backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@
3333
"@types/express": "*",
3434
"express": "^4.17.1",
3535
"express-promise-router": "^4.1.0",
36+
"git-url-parse": "^14.0.0",
3637
"winston": "^3.2.1",
3738
"yn": "^4.0.0"
3839
},
3940
"devDependencies": {
4041
"@backstage/cli": "^0.25.1",
42+
"@types/git-url-parse": "^9.0.3",
4143
"@types/supertest": "^2.0.12",
4244
"msw": "^1.0.0",
4345
"supertest": "^6.2.4"
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export * from './service/router';
2-
export { DevcontainersProcessor } from './processors/DevcontainersProcessor';
2+
export {
3+
DevcontainersProcessor,
4+
type VsCodeUrlKey,
5+
} from './processors/DevcontainersProcessor';

plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ describe(`${DevcontainersProcessor.name}`, () => {
196196
expect(inputEntity).toEqual(inputSnapshot);
197197

198198
const metadataCompare = structuredClone(inputSnapshot.metadata);
199+
metadataCompare.annotations = {
200+
...(metadataCompare.annotations ?? {}),
201+
vsCodeUrl:
202+
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
203+
};
199204
delete metadataCompare.tags;
200205

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

228233
const metadataCompare = structuredClone(inputSnapshot.metadata);
234+
metadataCompare.annotations = {
235+
...(metadataCompare.annotations ?? {}),
236+
vsCodeUrl:
237+
'vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/example-company/example-repo',
238+
};
229239
delete metadataCompare.tags;
230240

231241
expect(outputEntity).toEqual(

plugins/backstage-plugin-devcontainers-backend/src/processors/DevcontainersProcessor.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ import { type Config } from '@backstage/config';
99
import { isError, NotFoundError } from '@backstage/errors';
1010
import { type UrlReader, UrlReaders } from '@backstage/backend-common';
1111
import { type Logger } from 'winston';
12+
import { parseGitUrl } from '../utils/git';
1213

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

17+
const vsCodeUrlKey = 'vsCodeUrl';
18+
19+
// We export this type instead of the actual constant so we can validate the
20+
// constant on the frontend at compile-time instead of making the backend plugin
21+
// a run-time dependency, so it can continue to run standalone.
22+
export type VsCodeUrlKey = typeof vsCodeUrlKey;
23+
1624
type ProcessorOptions = Readonly<{
1725
tagName: string;
1826
logger: Logger;
@@ -89,7 +97,12 @@ export class DevcontainersProcessor implements CatalogProcessor {
8997
try {
9098
const jsonUrl = await this.findDevcontainerJson(rootUrl, entityLogger);
9199
entityLogger.info('Found devcontainer config', { url: jsonUrl });
92-
return this.addTag(entity, this.options.tagName, entityLogger);
100+
return this.addMetadata(
101+
entity,
102+
this.options.tagName,
103+
location,
104+
entityLogger,
105+
);
93106
} catch (error) {
94107
if (!isError(error) || error.name !== 'NotFoundError') {
95108
emit(
@@ -115,16 +128,25 @@ export class DevcontainersProcessor implements CatalogProcessor {
115128
return entity;
116129
}
117130

118-
private addTag(entity: Entity, newTag: string, logger: Logger): Entity {
131+
private addMetadata(
132+
entity: Entity,
133+
newTag: string,
134+
location: LocationSpec,
135+
logger: Logger,
136+
): Entity {
119137
if (entity.metadata.tags?.includes(newTag)) {
120138
return entity;
121139
}
122140

123-
logger.info(`Adding "${newTag}" tag to component`);
141+
logger.info(`Adding VS Code URL and "${newTag}" tag to component`);
124142
return {
125143
...entity,
126144
metadata: {
127145
...entity.metadata,
146+
annotations: {
147+
...(entity.metadata.annotations ?? {}),
148+
[vsCodeUrlKey]: serializeVsCodeUrl(location.target),
149+
},
128150
tags: [...(entity.metadata?.tags ?? []), newTag],
129151
},
130152
};
@@ -185,3 +207,15 @@ export class DevcontainersProcessor implements CatalogProcessor {
185207
return url;
186208
}
187209
}
210+
211+
/**
212+
* Current implementation for generating the URL will likely need to change as
213+
* we flesh out the backend plugin. For example, it would be nice if there was
214+
* a way to specify the branch instead of always checking out the default.
215+
*/
216+
function serializeVsCodeUrl(repoUrl: string): string {
217+
const cleaners: readonly RegExp[] = [/^url: */];
218+
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
219+
const rootUrl = parseGitUrl(cleanedUrl);
220+
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${rootUrl}`;
221+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { parseGitUrl } from './git';
2+
3+
describe('git', () => {
4+
it('parses urls', () => {
5+
// List of forges and the various ways URLs can be formed.
6+
const forges = {
7+
github: {
8+
saas: 'github.com',
9+
paths: [
10+
'/tree/foo',
11+
'/blob/foo',
12+
'/tree/foo/dir',
13+
'/blob/foo/dir/file.ts',
14+
],
15+
},
16+
gitlab: {
17+
saas: 'gitlab.com',
18+
paths: [
19+
'/-/tree/foo',
20+
'/-/blob/foo',
21+
'/-/tree/foo/dir?ref_type=heads',
22+
'/-/blob/foo/dir/file.ts?ref_type=heads',
23+
],
24+
},
25+
bitbucket: {
26+
saas: 'bitbucket.org',
27+
paths: [
28+
'/src/hashOrTag',
29+
'/src/hashOrTag?at=foo',
30+
'/src/hashOrTag/dir',
31+
'/src/hashOrTag/dir?at=foo',
32+
'/src/hashOrTag/dir/file.ts',
33+
'/src/hashOrTag/dir/file.ts?at=foo',
34+
],
35+
},
36+
};
37+
38+
for (const [forge, test] of Object.entries(forges)) {
39+
// These are URLs that point to the root of the repository. To these we
40+
// append the above paths to test that the original root URL is extracted.
41+
const baseUrls = [
42+
// Most common format.
43+
`https://${test.saas}/coder/backstage-plugins`,
44+
// GitLab lets you have a sub-group.
45+
`https://${test.saas}/coder/group/backstage-plugins`,
46+
// Self-hosted.
47+
`https://${forge}.coder.com/coder/backstage-plugins`,
48+
// Self-hosted at a port.
49+
`https://${forge}.coder.com:9999/coder/backstage-plugins`,
50+
// Self-hosted at base path.
51+
`https://${forge}.coder.com/base/path/coder/backstage-plugins`,
52+
// Self-hosted without the forge anywhere in the domain.
53+
'https://coder.com/coder/backstage-plugins',
54+
];
55+
for (const baseUrl of baseUrls) {
56+
expect(parseGitUrl(baseUrl)).toEqual(baseUrl);
57+
for (const path of test.paths) {
58+
const url = `${baseUrl}${path}`;
59+
expect(parseGitUrl(url)).toEqual(baseUrl);
60+
}
61+
}
62+
}
63+
});
64+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import parse from 'git-url-parse';
2+
3+
/**
4+
* Given a repository URL, figure out the base repository.
5+
*/
6+
export function parseGitUrl(url: string): String {
7+
const parsed = parse(url);
8+
// Although it seems to have a `host` property, it is not on the types, so we
9+
// will have to reconstruct it.
10+
const host = parsed.resource + (parsed.port ? `:${parsed.port}` : '');
11+
return `${parsed.protocol}://${host}/${parsed.full_name}`;
12+
}

plugins/backstage-plugin-devcontainers-react/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ _Note: While this plugin can be used standalone, it has been designed to be a fr
1414

1515
### Standalone features
1616

17-
- 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
17+
- 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
1818

1919
### When combined with the backend plugin
2020

21-
- 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
21+
- 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
2222

2323
## Setup
2424

plugins/backstage-plugin-devcontainers-react/src/components/ExampleDevcontainersComponent/ExampleDevcontainersComponent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const ExampleDevcontainersComponent = () => {
2323
return (
2424
<InfoCard title="Devcontainers plugin">
2525
<p>
26-
Searched component entity for tag:{' '}
26+
Searched component entity for VS Code URL and tag:{' '}
2727
<span className={styles.tagName}>{state.tagName}</span>
2828
</p>
2929

plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useDevcontainers } from './useDevcontainers';
44
import { type DevcontainersConfig, DevcontainersProvider } from '../plugin';
55
import { wrapInTestApp } from '@backstage/test-utils';
66
import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react';
7-
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
87

98
const mockTagName = 'devcontainers-test';
109
const mockUrlRoot = 'https://www.github.com/example-company/example-repo';
@@ -17,7 +16,7 @@ const baseEntity: BackstageEntity = {
1716
name: 'metadata',
1817
tags: [mockTagName, 'other', 'random', 'values'],
1918
annotations: {
20-
[ANNOTATION_SOURCE_LOCATION]: `${mockUrlRoot}/tree/main`,
19+
vsCodeUrl: `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,
2120
},
2221
},
2322
};
@@ -61,7 +60,7 @@ describe(`${useDevcontainers.name}`, () => {
6160
expect(result2.current.vsCodeUrl).toBe(undefined);
6261
});
6362

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

76-
it('Provides a VS Code-formatted link when the current entity has a designated devcontainers tag', async () => {
75+
it('Exposes the link when the entity has both the tag and link', async () => {
7776
const { result } = await render(mockTagName, baseEntity);
7877
expect(result.current.vsCodeUrl).toEqual(
7978
`vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${mockUrlRoot}`,

plugins/backstage-plugin-devcontainers-react/src/hooks/useDevcontainers.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useDevcontainersConfig } from '../components/DevcontainersProvider';
22
import { useEntity } from '@backstage/plugin-catalog-react';
3-
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
3+
import type { VsCodeUrlKey } from '@coder/backstage-plugin-devcontainers-backend';
4+
5+
// We avoid importing the actual constant to prevent making the backend plugin a
6+
// run-time dependency, but we can use the type at compile-time to validate the
7+
// string is the same.
8+
const vsCodeUrlKey: VsCodeUrlKey = 'vsCodeUrl';
49

510
export type UseDevcontainersResult = Readonly<
611
{
@@ -38,8 +43,8 @@ export function useDevcontainers(): UseDevcontainersResult {
3843
};
3944
}
4045

41-
const repoUrl = entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION];
42-
if (!repoUrl) {
46+
const vsCodeUrl = entity.metadata.annotations?.[vsCodeUrlKey];
47+
if (!vsCodeUrl) {
4348
return {
4449
tagName,
4550
hasUrl: false,
@@ -50,20 +55,6 @@ export function useDevcontainers(): UseDevcontainersResult {
5055
return {
5156
tagName,
5257
hasUrl: true,
53-
vsCodeUrl: serializeVsCodeUrl(repoUrl),
58+
vsCodeUrl,
5459
};
5560
}
56-
57-
/**
58-
* Current implementation for generating the URL will likely need to change as
59-
* we flesh out the backend plugin.
60-
*
61-
* It might make more sense to add the direct VSCode link to the entity data
62-
* from the backend plugin via an annotation field, and remove the need for data
63-
* cleaning here in this function
64-
*/
65-
function serializeVsCodeUrl(repoUrl: string): string {
66-
const cleaners: readonly RegExp[] = [/^url: */, /\/tree\/main\/?$/];
67-
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
68-
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${cleanedUrl}`;
69-
}

yarn.lock

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8419,6 +8419,11 @@
84198419
"@types/qs" "*"
84208420
"@types/serve-static" "*"
84218421

8422+
"@types/git-url-parse@^9.0.3":
8423+
version "9.0.3"
8424+
resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.3.tgz#7ee022f8fa06ea74148aa28521cbff85915ac09d"
8425+
integrity sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==
8426+
84228427
"@types/graceful-fs@^4.1.3":
84238428
version "4.1.9"
84248429
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
@@ -21913,7 +21918,16 @@ string-length@^4.0.1:
2191321918
char-regex "^1.0.2"
2191421919
strip-ansi "^6.0.0"
2191521920

21916-
"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:
21921+
"string-width-cjs@npm:string-width@^4.2.0":
21922+
version "4.2.3"
21923+
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
21924+
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
21925+
dependencies:
21926+
emoji-regex "^8.0.0"
21927+
is-fullwidth-code-point "^3.0.0"
21928+
strip-ansi "^6.0.1"
21929+
21930+
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
2191721931
version "4.2.3"
2191821932
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
2191921933
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -21987,7 +22001,7 @@ string_decoder@~1.1.1:
2198722001
dependencies:
2198822002
safe-buffer "~5.1.0"
2198922003

21990-
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
22004+
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
2199122005
version "6.0.1"
2199222006
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
2199322007
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -22001,6 +22015,13 @@ strip-ansi@5.2.0:
2200122015
dependencies:
2200222016
ansi-regex "^4.1.0"
2200322017

22018+
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
22019+
version "6.0.1"
22020+
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
22021+
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
22022+
dependencies:
22023+
ansi-regex "^5.0.1"
22024+
2200422025
strip-ansi@^7.0.1:
2200522026
version "7.1.0"
2200622027
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -23809,7 +23830,7 @@ wordwrap@^1.0.0:
2380923830
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
2381023831
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
2381123832

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

23851+
wrap-ansi@^7.0.0:
23852+
version "7.0.0"
23853+
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
23854+
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
23855+
dependencies:
23856+
ansi-styles "^4.0.0"
23857+
string-width "^4.1.0"
23858+
strip-ansi "^6.0.0"
23859+
2383023860
wrap-ansi@^8.1.0:
2383123861
version "8.1.0"
2383223862
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"

0 commit comments

Comments
 (0)