Skip to content

Commit 58d3906

Browse files
committed
Parse git urls
1 parent cd9f90c commit 58d3906

File tree

10 files changed

+164
-31
lines changed

10 files changed

+164
-31
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
"@backstage/plugin-catalog-common": "^1.0.21",
3232
"@backstage/plugin-catalog-node": "^1.7.2",
3333
"@types/express": "*",
34+
"@types/git-url-parse": "^9.0.3",
3435
"express": "^4.17.1",
3536
"express-promise-router": "^4.1.0",
37+
"git-url-parse": "^14.0.0",
3638
"winston": "^3.2.1",
3739
"yn": "^4.0.0"
3840
},

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: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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';
@@ -89,7 +90,12 @@ export class DevcontainersProcessor implements CatalogProcessor {
8990
try {
9091
const jsonUrl = await this.findDevcontainerJson(rootUrl, entityLogger);
9192
entityLogger.info('Found devcontainer config', { url: jsonUrl });
92-
return this.addTag(entity, this.options.tagName, entityLogger);
93+
return this.addMetadata(
94+
entity,
95+
this.options.tagName,
96+
location,
97+
entityLogger,
98+
);
9399
} catch (error) {
94100
if (!isError(error) || error.name !== 'NotFoundError') {
95101
emit(
@@ -115,16 +121,25 @@ export class DevcontainersProcessor implements CatalogProcessor {
115121
return entity;
116122
}
117123

118-
private addTag(entity: Entity, newTag: string, logger: Logger): Entity {
124+
private addMetadata(
125+
entity: Entity,
126+
newTag: string,
127+
location: LocationSpec,
128+
logger: Logger,
129+
): Entity {
119130
if (entity.metadata.tags?.includes(newTag)) {
120131
return entity;
121132
}
122133

123-
logger.info(`Adding "${newTag}" tag to component`);
134+
logger.info(`Adding VS Code URL and "${newTag}" tag to component`);
124135
return {
125136
...entity,
126137
metadata: {
127138
...entity.metadata,
139+
annotations: {
140+
...(entity.metadata.annotations ?? {}),
141+
vsCodeUrl: serializeVsCodeUrl(location.target),
142+
},
128143
tags: [...(entity.metadata?.tags ?? []), newTag],
129144
},
130145
};
@@ -185,3 +200,15 @@ export class DevcontainersProcessor implements CatalogProcessor {
185200
return url;
186201
}
187202
}
203+
204+
/**
205+
* Current implementation for generating the URL will likely need to change as
206+
* we flesh out the backend plugin. For example, it would be nice if there was
207+
* a way to specify the branch instead of always checking out the default.
208+
*/
209+
function serializeVsCodeUrl(repoUrl: string): string {
210+
const cleaners: readonly RegExp[] = [/^url: */];
211+
const cleanedUrl = cleaners.reduce((str, re) => str.replace(re, ''), repoUrl);
212+
const rootUrl = parseGitUrl(cleanedUrl);
213+
return `vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=${rootUrl}`;
214+
}
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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ _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
18+
launch URI inside your repo entities, and exposing that URI for opening the
19+
repo in VS Code
1820

1921
### When combined with the backend plugin
2022

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
23+
- Provides an end-to-end solution for automatically adding/removing Dev
24+
Containers metadata in your Backstage installation (including tags and the VS
25+
Code launch URI), while letting you read them from custom hooks and components
2226

2327
## Setup
2428

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: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useDevcontainersConfig } from '../components/DevcontainersProvider';
22
import { useEntity } from '@backstage/plugin-catalog-react';
3-
import { ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
43

54
export type UseDevcontainersResult = Readonly<
65
{
@@ -38,8 +37,8 @@ export function useDevcontainers(): UseDevcontainersResult {
3837
};
3938
}
4039

41-
const repoUrl = entity.metadata.annotations?.[ANNOTATION_SOURCE_LOCATION];
42-
if (!repoUrl) {
40+
const vsCodeUrl = entity.metadata.annotations?.vsCodeUrl;
41+
if (!vsCodeUrl) {
4342
return {
4443
tagName,
4544
hasUrl: false,
@@ -50,20 +49,6 @@ export function useDevcontainers(): UseDevcontainersResult {
5049
return {
5150
tagName,
5251
hasUrl: true,
53-
vsCodeUrl: serializeVsCodeUrl(repoUrl),
52+
vsCodeUrl,
5453
};
5554
}
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)