Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/fix-github-slash-branches-multiext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@asyncapi/cli': patch
---

fix: resolve GitHub URLs with slash-based branches and multi-dot filenames

- Replace single-candidate URL conversion with retry-on-404 logic that tries
all possible branch/path splits for GitHub blob URLs. Branch names such as
`feature/my-fix` are now handled correctly without requiring any API
pre-checks.
- Fix file extension detection in `fileExists` to use `path.extname()` instead
of `name.split('.')[1]`, which returned the wrong segment for filenames with
multiple dots (e.g. `my.asyncapi.yaml`).

Fixes #1940.
2 changes: 1 addition & 1 deletion src/domains/models/SpecificationFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export async function fileExists(name: string): Promise<boolean> {
return true;
}

const extension = name.split('.')[1];
const extension = path.extname(name).slice(1).toLowerCase();

const allowedExtenstion = ['yml', 'yaml', 'json'];

Expand Down
58 changes: 41 additions & 17 deletions src/domains/services/validation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,33 @@
};

/**
* Convert GitHub web URL to API URL
* Generate all candidate GitHub API URLs for a blob URL by trying every possible
* branch/path split. GitHub branch names may contain slashes (e.g. feature/my-fix),
* so the split point is ambiguous without querying the API.
*
* Returns candidates ordered from shortest branch (most common case) to longest.
*/
const convertGitHubWebUrl = (url: string): string => {
// Remove fragment from URL before processing
const generateGitHubApiCandidates = (url: string): string[] => {
const urlWithoutFragment = url.split('#')[0];

// Handle GitHub web URLs like: https://github.com/owner/repo/blob/branch/path
// eslint-disable-next-line no-useless-escape
const githubWebPattern = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+)$/;
const match = urlWithoutFragment.match(githubWebPattern);

if (match) {
const [, owner, repo, branch, filePath] = match;
return `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`;
const match = urlWithoutFragment.match(
/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/(.+)$/

Check warning on line 75 in src/domains/services/validation.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unnecessary escape character: \/.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ5N21ZZc5Lloj-DwSd-&open=AZ5N21ZZc5Lloj-DwSd-&pullRequest=2209

Check warning on line 75 in src/domains/services/validation.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unnecessary escape character: \/.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ5N21ZZc5Lloj-DwSd_&open=AZ5N21ZZc5Lloj-DwSd_&pullRequest=2209
);
if (!match) return [];

const [, owner, repo, blobPath] = match;
const segments = blobPath.split('/');
if (segments.length < 2) return [];

const candidates: string[] = [];
for (let i = 0; i < segments.length - 1; i++) {
const branch = segments.slice(0, i + 1).join('/');
const filePath = segments.slice(i + 1).join('/');
candidates.push(
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`
);
}

return url;
return candidates;
};

/**
Expand Down Expand Up @@ -142,15 +152,29 @@

const authInfo = await ConfigService.getAuthForUrl(url);

if (isValidGitHubBlobUrl(url)) {
url = convertGitHubWebUrl(url);
}

if (authInfo) {
headers['Authorization'] = `${authInfo.authType} ${authInfo.token}`;
Object.assign(headers, authInfo.headers); // merge custom headers
}

if (isValidGitHubBlobUrl(url)) {
const candidates = generateGitHubApiCandidates(url);
let lastError: Error | undefined;
for (const candidate of candidates) {
try {
return await fetchGitHubApiContent(candidate, headers);
} catch (err: any) {
// Only retry on 404 — other errors (auth, server) should propagate immediately
if (/\b(404|Not Found)\b/.test(err?.message ?? '')) {
lastError = err;
continue;
}
throw err;
}
}
throw lastError ?? new Error(`Failed to resolve GitHub URL: ${url}`);
}

if (url.includes('api.github.com')) {
return await fetchGitHubApiContent(url, headers);
}
Expand Down
Loading