Skip to content

Commit 84c8fbd

Browse files
authored
fix(ai-rules-plugin-backend): support Azure DevOps repository URLs (#212)
2 parents d63d5e9 + a3a9b7f commit 84c8fbd

8 files changed

Lines changed: 172 additions & 24 deletions

File tree

plugins/ai-rules-plugin-backend/src/service/AgentConfigsService.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LoggerService, UrlReaderService } from '@backstage/backend-plugin-api';
2+
import { buildFileUrl } from './gitUrls';
23

34
export interface AgentConfig {
45
agent: string;
@@ -66,10 +67,7 @@ export class AgentConfigsService {
6667
}
6768

6869
private async fetchFileContent(gitUrl: string, filePath: string): Promise<string> {
69-
const cleanGitUrl = gitUrl.replace(/\/+$/, '');
70-
const fileUrl = cleanGitUrl.includes('github.com')
71-
? `${cleanGitUrl}/raw/main/${filePath}`
72-
: `${cleanGitUrl}/blob/HEAD/${filePath}`;
70+
const fileUrl = buildFileUrl(gitUrl, filePath);
7371

7472
const response = await this.urlReader.readUrl(fileUrl);
7573
const buffer = await response.buffer();

plugins/ai-rules-plugin-backend/src/service/AiRulesService.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,38 @@ This is a test cursor rule.`;
5959
expect(result.rules[0].content).toContain('Test Rule Content');
6060
});
6161

62+
it('should fetch rules from an Azure DevOps repository', async () => {
63+
const agentsContent = `# Agent Instructions
64+
65+
This is an AGENTS.md file.`;
66+
const requestedUrls: string[] = [];
67+
68+
mockUrlReader.readUrl.mockImplementation(async (url: string) => {
69+
requestedUrls.push(url);
70+
if (url.includes('AGENTS.md')) {
71+
return {
72+
buffer: async () => Buffer.from(agentsContent),
73+
};
74+
}
75+
throw new Error('Not found');
76+
});
77+
mockUrlReader.readTree.mockRejectedValue(new Error('Not found'));
78+
79+
const result = await service.getAiRules(
80+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2F&version=GBmain',
81+
['codex'],
82+
);
83+
84+
expect(result.rules).toHaveLength(1);
85+
expect(result.rules[0].content).toContain('Agent Instructions');
86+
expect(requestedUrls).toContain(
87+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2FAGENTS.md&version=GBmain',
88+
);
89+
expect(requestedUrls.every(url => !url.includes('/blob/HEAD/'))).toBe(
90+
true,
91+
);
92+
});
93+
6294
it('should fetch copilot rules when instructions file exists', async () => {
6395
const copilotContent = `# Copilot Instructions
6496

plugins/ai-rules-plugin-backend/src/service/AiRulesService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { LoggerService, DiscoveryService, UrlReaderService } from '@backstage/backend-plugin-api';
22
import { Config } from '@backstage/config';
3+
import { buildFileUrl, buildTreeUrl } from './gitUrls';
34

45
const matter = require('gray-matter');
56

@@ -798,7 +799,7 @@ export class AiRulesService {
798799
try {
799800
return await this.retryWithBackoff(
800801
async () => {
801-
const directoryUrl = `${gitUrl}/tree/HEAD/${path}`;
802+
const directoryUrl = buildTreeUrl(gitUrl, path);
802803
const treeResponse = await this.urlReader.readTree(directoryUrl);
803804
const files: string[] = [];
804805
const filesArray = await treeResponse.files();
@@ -823,7 +824,7 @@ export class AiRulesService {
823824
private async fetchFileContent(gitUrl: string, filePath: string): Promise<string> {
824825
return await this.retryWithBackoff(
825826
async () => {
826-
const fileUrl = `${gitUrl}/blob/HEAD/${filePath}`;
827+
const fileUrl = buildFileUrl(gitUrl, filePath);
827828
const response = await this.urlReader.readUrl(fileUrl);
828829
const buffer = await response.buffer();
829830
return buffer.toString('utf-8');

plugins/ai-rules-plugin-backend/src/service/IgnoreFilesService.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LoggerService, UrlReaderService } from '@backstage/backend-plugin-api';
2+
import { buildFileUrl } from './gitUrls';
23

34
export interface IgnoreFile {
45
agent: string;
@@ -56,10 +57,7 @@ export class IgnoreFilesService {
5657
}
5758

5859
private async fetchFileContent(gitUrl: string, filePath: string): Promise<string> {
59-
const cleanGitUrl = gitUrl.replace(/\/+$/, '');
60-
const fileUrl = cleanGitUrl.includes('github.com')
61-
? `${cleanGitUrl}/raw/main/${filePath}`
62-
: `${cleanGitUrl}/blob/HEAD/${filePath}`;
60+
const fileUrl = buildFileUrl(gitUrl, filePath);
6361

6462
const response = await this.urlReader.readUrl(fileUrl);
6563
const buffer = await response.buffer();

plugins/ai-rules-plugin-backend/src/service/MCPService.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { LoggerService, UrlReaderService } from '@backstage/backend-plugin-api';
22
import { MCPConfig, MCPServerInfo, MCPServersResponse } from './types';
3+
import { buildFileUrl } from './gitUrls';
34

45
export interface MCPServiceOptions {
56
logger: LoggerService;
@@ -65,14 +66,8 @@ export class MCPService {
6566

6667
private async fetchMCPConfig(gitUrl: string, filePath: string): Promise<MCPConfig | null> {
6768
try {
68-
// Remove any trailing slashes from the gitUrl
69-
const cleanGitUrl = gitUrl.replace(/\/+$/, '');
70-
71-
// For GitHub URLs, we need to use the raw content URL
72-
const fileUrl = cleanGitUrl.includes('github.com')
73-
? `${cleanGitUrl}/raw/main/${filePath}`
74-
: `${cleanGitUrl}/blob/HEAD/${filePath}`;
75-
69+
const fileUrl = buildFileUrl(gitUrl, filePath);
70+
7671
this.logger.info(`Fetching MCP config from ${fileUrl}`);
7772
const response = await this.urlReader.readUrl(fileUrl);
7873
const content = await response.buffer();

plugins/ai-rules-plugin-backend/src/service/SkillsService.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LoggerService, UrlReaderService } from '@backstage/backend-plugin-api';
2+
import { buildFileUrl, buildTreeUrl } from './gitUrls';
23

34
const matter = require('gray-matter');
45

@@ -153,7 +154,7 @@ export class SkillsService {
153154

154155
private async listSubdirectories(gitUrl: string, path: string): Promise<string[]> {
155156
try {
156-
const directoryUrl = `${gitUrl}/tree/HEAD/${path}`;
157+
const directoryUrl = buildTreeUrl(gitUrl, path);
157158
const treeResponse = await this.urlReader.readTree(directoryUrl);
158159
const filesArray = await treeResponse.files();
159160

@@ -175,7 +176,7 @@ export class SkillsService {
175176

176177
private async listDirectoryFiles(gitUrl: string, path: string): Promise<string[]> {
177178
try {
178-
const directoryUrl = `${gitUrl}/tree/HEAD/${path}`;
179+
const directoryUrl = buildTreeUrl(gitUrl, path);
179180
const treeResponse = await this.urlReader.readTree(directoryUrl);
180181
const filesArray = await treeResponse.files();
181182
return filesArray
@@ -187,10 +188,7 @@ export class SkillsService {
187188
}
188189

189190
private async fetchFileContent(gitUrl: string, filePath: string): Promise<string> {
190-
const cleanGitUrl = gitUrl.replace(/\/+$/, '');
191-
const fileUrl = cleanGitUrl.includes('github.com')
192-
? `${cleanGitUrl}/raw/main/${filePath}`
193-
: `${cleanGitUrl}/blob/HEAD/${filePath}`;
191+
const fileUrl = buildFileUrl(gitUrl, filePath);
194192

195193
const response = await this.urlReader.readUrl(fileUrl);
196194
const buffer = await response.buffer();
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { buildFileUrl, buildTreeUrl } from './gitUrls';
2+
3+
describe('buildFileUrl', () => {
4+
it('addresses Azure DevOps files through the path query parameter', () => {
5+
expect(
6+
buildFileUrl(
7+
'https://dev.azure.com/my-org/my-project/_git/my-repo',
8+
'AGENTS.md',
9+
),
10+
).toBe(
11+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2FAGENTS.md',
12+
);
13+
});
14+
15+
it('keeps the Azure DevOps branch or tag the caller was given', () => {
16+
expect(
17+
buildFileUrl(
18+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2F&version=GBmain',
19+
'.cursor/rules/my-rule.mdc',
20+
),
21+
).toBe(
22+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2F.cursor%2Frules%2Fmy-rule.mdc&version=GBmain',
23+
);
24+
});
25+
26+
it('supports the legacy visualstudio.com host', () => {
27+
expect(
28+
buildFileUrl(
29+
'https://my-org.visualstudio.com/my-project/_git/my-repo?version=GBmaster',
30+
'AGENTS.md',
31+
),
32+
).toBe(
33+
'https://my-org.visualstudio.com/my-project/_git/my-repo?version=GBmaster&path=%2FAGENTS.md',
34+
);
35+
});
36+
37+
it('addresses other providers through path segments', () => {
38+
expect(buildFileUrl('https://github.com/my-org/my-repo', 'AGENTS.md')).toBe(
39+
'https://github.com/my-org/my-repo/blob/HEAD/AGENTS.md',
40+
);
41+
expect(
42+
buildFileUrl('https://gitlab.com/my-org/my-repo', 'CLAUDE.md'),
43+
).toBe('https://gitlab.com/my-org/my-repo/blob/HEAD/CLAUDE.md');
44+
});
45+
46+
it('tolerates trailing slashes', () => {
47+
expect(
48+
buildFileUrl('https://github.com/my-org/my-repo//', 'AGENTS.md'),
49+
).toBe('https://github.com/my-org/my-repo/blob/HEAD/AGENTS.md');
50+
});
51+
});
52+
53+
describe('buildTreeUrl', () => {
54+
it('addresses Azure DevOps directories through the path query parameter', () => {
55+
expect(
56+
buildTreeUrl(
57+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2F&version=GBmain',
58+
'.agents/skills',
59+
),
60+
).toBe(
61+
'https://dev.azure.com/my-org/my-project/_git/my-repo?path=%2F.agents%2Fskills&version=GBmain',
62+
);
63+
});
64+
65+
it('addresses other providers through path segments', () => {
66+
expect(
67+
buildTreeUrl('https://github.com/my-org/my-repo', '.cursor/rules'),
68+
).toBe('https://github.com/my-org/my-repo/tree/HEAD/.cursor/rules');
69+
});
70+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Helpers for turning a repository URL plus a path inside that repository into
3+
* a URL the Backstage UrlReader can resolve.
4+
*
5+
* Most providers address files through path segments, but Azure DevOps takes
6+
* the path from the `path` query parameter, so appending segments there
7+
* produces a URL that either 404s or fails `AzureUrl` validation outright.
8+
*/
9+
10+
/**
11+
* Azure DevOps repository URLs look like
12+
* `https://dev.azure.com/{organization}/{project}/_git/{repository}` or
13+
* `https://{organization}.visualstudio.com/{project}/_git/{repository}`.
14+
*/
15+
function isAzureDevOpsUrl(url: URL): boolean {
16+
return (
17+
url.hostname === 'dev.azure.com' ||
18+
url.hostname.endsWith('.visualstudio.com')
19+
);
20+
}
21+
22+
/**
23+
* Rewrites the `path` query parameter, preserving `version` so that reads stay
24+
* on the branch or tag the caller was given.
25+
*/
26+
function azureUrlForPath(url: URL, path: string): string {
27+
const target = new URL(url.toString());
28+
target.searchParams.set('path', `/${path.replace(/^\/+/, '')}`);
29+
return target.toString();
30+
}
31+
32+
function trimTrailingSlashes(gitUrl: string): string {
33+
return gitUrl.replace(/\/+$/, '');
34+
}
35+
36+
/**
37+
* Builds a URL for reading a single file from a repository.
38+
*/
39+
export function buildFileUrl(gitUrl: string, filePath: string): string {
40+
const url = new URL(gitUrl);
41+
if (isAzureDevOpsUrl(url)) {
42+
return azureUrlForPath(url, filePath);
43+
}
44+
return `${trimTrailingSlashes(gitUrl)}/blob/HEAD/${filePath}`;
45+
}
46+
47+
/**
48+
* Builds a URL for reading a directory tree from a repository.
49+
*/
50+
export function buildTreeUrl(gitUrl: string, path: string): string {
51+
const url = new URL(gitUrl);
52+
if (isAzureDevOpsUrl(url)) {
53+
return azureUrlForPath(url, path);
54+
}
55+
return `${trimTrailingSlashes(gitUrl)}/tree/HEAD/${path}`;
56+
}

0 commit comments

Comments
 (0)