Skip to content

Commit b4e12dd

Browse files
committed
feat: Download content exclusions from GitHub
1 parent b2dc3ff commit b4e12dd

5 files changed

Lines changed: 168 additions & 0 deletions

File tree

src/lib/exclusionUtils.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { warn } from 'node:console';
2+
import { writeFile } from 'node:fs/promises';
3+
import { homedir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { isNativeError } from 'node:util/types';
6+
7+
import vscode from 'vscode';
8+
9+
import fetchGHExclusions from './fetchGHExclusions';
10+
11+
const exclusionPath = join(homedir(), '.appmap', 'navie', 'global-ignore');
12+
13+
export async function downloadContentExclusions(githubToken: string) {
14+
const exclusions = await fetchGHExclusions(githubToken, 'all');
15+
const paths = exclusions.flatMap(({ rules }) => rules.flatMap(({ paths }) => paths));
16+
await writeFile(exclusionPath, paths.join('\n'));
17+
exclusionsDownloaded = true;
18+
}
19+
20+
let exclusionsDownloaded = false;
21+
22+
export async function ensureExclusionsDownloaded() {
23+
if (exclusionsDownloaded) return;
24+
try {
25+
const githubToken = await getGHToken();
26+
if (!githubToken) throw new Error('Failed to obtain GitHub token');
27+
await downloadContentExclusions(githubToken);
28+
} catch (e) {
29+
exclusionDownloadError(e);
30+
throw e;
31+
}
32+
}
33+
34+
export async function tryDownloadingExclusions() {
35+
if (exclusionsDownloaded) return;
36+
const githubToken = await getGHToken(true);
37+
if (githubToken)
38+
try {
39+
downloadContentExclusions(githubToken);
40+
} catch {
41+
// pass, it will be retried
42+
}
43+
}
44+
45+
export async function getGHToken(silent = false): Promise<string | undefined> {
46+
const provider = vscode.workspace
47+
.getConfiguration()
48+
.get('github.copilot.advanced.authProvider', 'github');
49+
const session = await vscode.authentication.getSession(provider, [], {
50+
silent,
51+
createIfNone: !silent,
52+
});
53+
if (session) return session.accessToken;
54+
}
55+
56+
function exclusionDownloadError(error: unknown) {
57+
const title = 'Failed to download content exclusion policy from GitHub';
58+
const errorText = isNativeError(error) ? error.message : String(error);
59+
60+
warn(`Failed to download content exclusions: ${errorText}`);
61+
if (exclusionDownloadError.wait) return;
62+
63+
const showDetails = () => {
64+
vscode.window.showErrorMessage(title, {
65+
detail: `${errorText}\nPlease try again later.`,
66+
modal: true,
67+
});
68+
};
69+
if (exclusionDownloadError.wait === null) showDetails(); // first time always show the details
70+
else vscode.window.showErrorMessage(title, {}, 'More...').then((x) => x && showDetails());
71+
72+
exclusionDownloadError.wait = true;
73+
setTimeout(() => (exclusionDownloadError.wait = false), 5000).unref();
74+
}
75+
exclusionDownloadError.wait = null as boolean | null;

src/lib/fetchGHExclusions.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import fetch from 'node-fetch';
2+
3+
export interface GitHubContentExclusion {
4+
rules: Rule[];
5+
last_updated_at: string;
6+
scope: Scope;
7+
}
8+
9+
interface Rule {
10+
source: {
11+
name: string;
12+
type: string;
13+
};
14+
paths: string[];
15+
}
16+
17+
type Scope = 'all' | 'repo';
18+
19+
export default async function fetchGHExclusions(
20+
githubToken: string,
21+
scope: Scope = 'all',
22+
repos?: string[]
23+
): Promise<GitHubContentExclusion[]> {
24+
const url = new URL('https://api.github.com/copilot_internal/content_exclusion');
25+
url.searchParams.set('scope', scope);
26+
if (repos) url.searchParams.set('repos', repos.join(','));
27+
const response = await fetch(url.toString(), {
28+
headers: {
29+
Accept: 'application/json',
30+
Authorization: `Bearer ${githubToken}`,
31+
},
32+
});
33+
if (response.status === 404) return [];
34+
if (!response.ok) throw new Error(`Failed to fetch exclusions: ${response.statusText}`);
35+
36+
return response.json();
37+
}

src/services/chatCompletion.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import vscode, {
1616
} from 'vscode';
1717

1818
import ExtensionSettings from '../configuration/extensionSettings';
19+
import { ensureExclusionsDownloaded, tryDownloadingExclusions } from '../lib/exclusionUtils';
1920
import once from '../lib/once';
2021

2122
const debug = debuglog('appmap-vscode:chat-completion');
@@ -35,6 +36,7 @@ export default class ChatCompletion implements Disposable {
3536
public readonly key = randomKey(),
3637
public readonly host = '127.0.0.1'
3738
) {
39+
tryDownloadingExclusions();
3840
this.server = createServer(async (req, res) => {
3941
try {
4042
await this.handleRequest(req, res);
@@ -134,6 +136,8 @@ export default class ChatCompletion implements Disposable {
134136
}
135137

136138
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
139+
await ensureExclusionsDownloaded();
140+
137141
if (req.method !== 'POST') {
138142
res.writeHead(405);
139143
res.end();
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect } from 'chai';
2+
import nock from 'nock';
3+
4+
import fetchGHExclusions, { GitHubContentExclusion } from '../../../src/lib/fetchGHExclusions';
5+
6+
describe('fetchGHExclusions', () => {
7+
const githubToken = 'test-token';
8+
const apiUrl = 'https://api.github.com';
9+
10+
afterEach(() => {
11+
nock.cleanAll();
12+
});
13+
14+
it('should fetch exclusions successfully', async () => {
15+
const mockResponse: GitHubContentExclusion[] = [
16+
{
17+
rules: [{ source: { name: 'test', type: 'type' }, paths: ['path1', 'path2'] }],
18+
last_updated_at: '2023-01-01T00:00:00Z',
19+
scope: 'all',
20+
},
21+
];
22+
23+
nock(apiUrl)
24+
.get('/copilot_internal/content_exclusion')
25+
.query({ scope: 'all' })
26+
.reply(200, mockResponse);
27+
28+
const exclusions = await fetchGHExclusions(githubToken, 'all');
29+
expect(exclusions).to.deep.equal(mockResponse);
30+
});
31+
32+
it('should return an empty array for 404 response', async () => {
33+
nock(apiUrl).get('/copilot_internal/content_exclusion').query({ scope: 'all' }).reply(404);
34+
35+
const exclusions = await fetchGHExclusions(githubToken, 'all');
36+
expect(exclusions).to.deep.equal([]);
37+
});
38+
39+
it('should throw an error for non-200 and non-404 responses', async () => {
40+
nock(apiUrl).get('/copilot_internal/content_exclusion').query({ scope: 'all' }).reply(500);
41+
42+
try {
43+
await fetchGHExclusions(githubToken, 'all');
44+
throw new Error('Expected fetchGHExclusions to throw an error');
45+
} catch (err) {
46+
expect(err).to.be.an.instanceOf(Error);
47+
expect((err as Error).message).to.equal('Failed to fetch exclusions: Internal Server Error');
48+
}
49+
});
50+
});

test/unit/services/chatCompletion.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { workspace } from 'vscode';
1515
import ChatCompletion from '../../../src/services/chatCompletion';
1616
import MockExtensionContext from '../../mocks/mockExtensionContext';
1717
import { addMockChatModel, resetModelMocks } from '../mock/vscode/lm';
18+
import * as ExclusionUtils from '../../../src/lib/exclusionUtils';
1819

1920
const mockModel: LanguageModelChat = {
2021
id: 'test-model',
@@ -34,6 +35,7 @@ describe('ChatCompletion', () => {
3435

3536
beforeEach(async () => {
3637
addMockChatModel(mockModel);
38+
sinon.stub(ExclusionUtils, 'ensureExclusionsDownloaded').resolves();
3739

3840
await ChatCompletion.refreshModels();
3941
});

0 commit comments

Comments
 (0)