add extension IDs (#11377)

This commit is contained in:
Jacob MacDonald
2025-10-17 13:29:07 -07:00
committed by GitHub
parent 77886ac255
commit 8dd7ffdbb9
5 changed files with 265 additions and 72 deletions
@@ -11,7 +11,7 @@ import {
extractFile,
findReleaseAsset,
fetchReleaseFromGithub,
parseGitHubRepoForReleases,
tryParseGithubUrl,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
@@ -348,61 +348,62 @@ describe('git extension helpers', () => {
describe('parseGitHubRepoForReleases', () => {
it('should parse owner and repo from a full GitHub URL', () => {
const source = 'https://github.com/owner/repo.git';
const { owner, repo } = parseGitHubRepoForReleases(source)!;
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL without .git', () => {
const source = 'https://github.com/owner/repo';
const { owner, repo } = parseGitHubRepoForReleases(source)!;
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should parse owner and repo from a full GitHub URL with a trailing slash', () => {
const source = 'https://github.com/owner/repo/';
const { owner, repo } = parseGitHubRepoForReleases(source)!;
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should fail on a GitHub SSH URL', () => {
it('should parse owner and repo from a GitHub SSH URL', () => {
const source = 'git@github.com:owner/repo.git';
expect(() => parseGitHubRepoForReleases(source)).toThrow(
'GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.',
);
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(parseGitHubRepoForReleases(source)).toBe(null);
expect(tryParseGithubUrl(source)).toBe(null);
});
it('should parse owner and repo from a shorthand string', () => {
const source = 'owner/repo';
const { owner, repo } = parseGitHubRepoForReleases(source)!;
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should handle .git suffix in repo name', () => {
const source = 'owner/repo.git';
const { owner, repo } = parseGitHubRepoForReleases(source)!;
const { owner, repo } = tryParseGithubUrl(source)!;
expect(owner).toBe('owner');
expect(repo).toBe('repo');
});
it('should throw error for invalid source format', () => {
const source = 'invalid-format';
expect(() => parseGitHubRepoForReleases(source)).toThrow(
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: invalid-format. Expected "owner/repo" or a github repo uri.',
);
});
it('should throw error for source with too many parts', () => {
const source = 'https://github.com/owner/repo/extra';
expect(() => parseGitHubRepoForReleases(source)).toThrow(
expect(() => tryParseGithubUrl(source)).toThrow(
'Invalid GitHub repository source: https://github.com/owner/repo/extra. Expected "owner/repo" or a github repo uri.',
);
});
+41 -42
View File
@@ -76,24 +76,30 @@ export async function cloneFromGit(
}
}
export function parseGitHubRepoForReleases(source: string): {
export interface GithubRepoInfo {
owner: string;
repo: string;
} | null {
}
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
// First step in normalizing a github ssh URI to the https form.
if (source.startsWith('git@github.com:')) {
source = source.replace('git@github.com:', '');
}
// Default to a github repo path, so `source` can be just an org/repo
const parsedUrl = URL.parse(source, 'https://github.com');
if (!parsedUrl) {
throw new Error(`Invalid repo URL: ${source}`);
}
// The pathname should be "/owner/repo".
const parts = parsedUrl?.pathname
.substring(1)
.split('/')
// Remove the empty segments, fixes trailing slashes
.filter((part) => part !== '');
if (parsedUrl?.host !== 'github.com') {
return null;
}
// The pathname should be "/owner/repo".
const parts = parsedUrl?.pathname
.split('/')
// Remove the empty segments, fixes trailing and leading slashes
.filter((part) => part !== '');
if (parts?.length !== 2) {
throw new Error(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
@@ -102,13 +108,10 @@ export function parseGitHubRepoForReleases(source: string): {
const owner = parts[0];
const repo = parts[1].replace('.git', '');
if (owner.startsWith('git@github.com')) {
throw new Error(
`GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`,
);
}
return { owner, repo };
return {
owner,
repo,
};
}
export async function fetchReleaseFromGithub(
@@ -217,7 +220,7 @@ export async function checkForExtensionUpdate(
console.error(`No "source" provided for extension.`);
return ExtensionUpdateState.ERROR;
}
const repoInfo = parseGitHubRepoForReleases(source);
const repoInfo = tryParseGithubUrl(source);
if (!repoInfo) {
console.error(
`Source is not a valid GitHub repository for release checks: ${source}`,
@@ -248,39 +251,35 @@ export async function checkForExtensionUpdate(
}
}
export interface GitHubDownloadResult {
tagName?: string;
type: 'git' | 'github-release';
success: boolean;
failureReason?:
| 'failed to fetch release data'
| 'no release data'
| 'no release asset found'
| 'failed to download asset'
| 'failed to extract asset'
| 'unknown';
errorMessage?: string;
}
export type GitHubDownloadResult =
| {
tagName?: string;
type: 'git' | 'github-release';
success: false;
failureReason:
| 'failed to fetch release data'
| 'no release data'
| 'no release asset found'
| 'failed to download asset'
| 'failed to extract asset'
| 'unknown';
errorMessage: string;
}
| {
tagName?: string;
type: 'git' | 'github-release';
success: true;
};
export async function downloadFromGitHubRelease(
installMetadata: ExtensionInstallMetadata,
destination: string,
githubRepoInfo: GithubRepoInfo,
): Promise<GitHubDownloadResult> {
const { source, ref, allowPreRelease: preRelease } = installMetadata;
const { ref, allowPreRelease: preRelease } = installMetadata;
const { owner, repo } = githubRepoInfo;
let releaseData: GithubReleaseData | null = null;
try {
const parts = parseGitHubRepoForReleases(source);
if (!parts) {
return {
failureReason: 'no release data',
success: false,
type: 'github-release',
errorMessage: `Not a github repo: ${source}`,
};
}
const { owner, repo } = parts;
try {
releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
if (!releaseData) {