Skip to content

Commit a036586

Browse files
authored
cloud: patches for GHE (#4235)
* feat: add GHE (GitHub Enterprise) support for cloud agent - Add host field to GithubRepoId (defaults to github.com) - Add rawHost to parseRemoteUrl for SSH alias resolution - Add toGithubWebUrl helper for constructing host-aware URLs - Use ICAPIClientService.dotcomAPIURL in GithubRepositoryService instead of hardcoded https://api.github.com - Update cloud session error messages and UI links to use repo host - Support GHE URLs in _normalizeGitUri, remote agent icons, and github repo search tool - Add comprehensive tests for GHE URL parsing and host propagation * fix: make PR fetch resilient in provideChatSessionItems Wrap individual getPullRequestFromGlobalId calls in try-catch so a single failure (e.g. PermissiveAuthRequiredError on GHE) doesn't prevent all other sessions from loading. Log warnings for failures. * fix: refresh cloud sessions when CAPI URL changes for GHE Listen to IDomainService.onDidChangeDomains so that when the GHE Copilot token arrives and updates the CAPI base URL, the sessions provider clears caches and re-fetches against the correct endpoint. Previously, the initial session fetch raced with token minting and hit the default api.githubcopilot.com with a GHE token, getting 401. Also cleaned up diagnostic retry logic in getAllSessions and kept useful debug logging for session fetch diagnostics. * fix: clear chatSessionItemsPromise on refresh to prevent stale results refresh() was clearing cachedSessionItems but not chatSessionItemsPromise, so when the CAPI URL changed and triggered a refresh, the old in-flight promise (which hit the wrong URL) was still returned by subsequent provideChatSessionItems calls, preventing the re-fetch. * tidy
1 parent 914a6d8 commit a036586

8 files changed

Lines changed: 423 additions & 41 deletions

File tree

src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as pathLib from 'path';
88
import * as vscode from 'vscode';
99
import { l10n, Uri } from 'vscode';
1010
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
11+
import { IDomainService } from '../../../platform/endpoint/common/domainService';
1112
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
1213
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
1314
import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService';
@@ -222,10 +223,21 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
222223
@IGithubRepositoryService private readonly _githubRepositoryService: IGithubRepositoryService,
223224
@IChatDelegationSummaryService private readonly _chatDelegationSummaryService: IChatDelegationSummaryService,
224225
@IExperimentationService private readonly _experimentationService: IExperimentationService,
226+
@IDomainService private readonly _domainService: IDomainService,
225227
) {
226228
super();
227229
this.registerCommands();
228230

231+
// Refresh when CAPI URL changes (e.g., when GHE Copilot token arrives and updates the base URL)
232+
this._register(this._domainService.onDidChangeDomains(e => {
233+
if (e.capiUrlChanged) {
234+
this.logService.debug('copilotCloudSessionsProvider: CAPI URL changed, refreshing sessions');
235+
this.clearOptionsCaches();
236+
this.refresh();
237+
this._onDidChangeChatSessionProviderOptions.fire();
238+
}
239+
}));
240+
229241
// Background refresh
230242
getRepoId(this._gitService).then(async repoIds => {
231243
const telemetryObj: {
@@ -477,6 +489,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
477489

478490
public refresh(): void {
479491
this.cachedSessionItems = undefined;
492+
this.chatSessionItemsPromise = undefined;
480493
this.activeSessionIds.clear();
481494
this.stopActiveSessionPolling();
482495
// Note: _ccaEnabledCache and _optionsCache are TTL-based and NOT cleared on refresh.
@@ -536,15 +549,16 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
536549
* @param result The CCAEnabledResult to get message for
537550
* @returns User-friendly error message
538551
*/
539-
private getCCADisabledMessage(result: CCAEnabledResult): string {
552+
private getCCADisabledMessage(result: CCAEnabledResult, host: string = 'github.com'): string {
540553
if (result.statusCode === 422) {
541554
return vscode.l10n.t('Cloud agent is unable to create pull requests in this repository. Please verify repository rules allow this operation.');
542555
}
543556
if (result.statusCode === 401) {
544557
return vscode.l10n.t('Cloud agent is not authorized to run on this repository. This may be because the Copilot coding agent is disabled for your organization, or your active GitHub account does not have push access to the target repository.');
545558
}
546559
// Default to 403 'disabled' message
547-
return vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', 'https://github.com/settings/copilot/coding_agent');
560+
const settingsUrl = `https://${host}/settings/copilot/coding_agent`;
561+
return vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', settingsUrl);
548562
}
549563

550564
private stopActiveSessionPolling(): void {
@@ -965,9 +979,11 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
965979
}
966980
this.chatSessionItemsPromise = (async () => {
967981
const repoIds = await getRepoId(this._gitService);
982+
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: repoIds=${JSON.stringify(repoIds?.map(r => ({ org: r.org, repo: r.repo, host: r.host })))}, isAgentSessionsWorkspace=${vscode.workspace.isAgentSessionsWorkspace}`);
968983
// Make sure if it's not a github repo we don't show any sessions
969984
// (unless we're in an agent sessions workspace)
970985
if (!vscode.workspace.isAgentSessionsWorkspace && !this.isGitHubRepoOrEmpty(repoIds)) {
986+
this.logService.debug('copilotCloudSessionsProvider#provideChatSessionItems: not a GitHub repo, returning empty');
971987
return [];
972988
}
973989
let sessions = [];
@@ -976,6 +992,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
976992
} else {
977993
sessions = (await Promise.all(repoIds.map(repo => this._octoKitService.getAllSessions(`${repo.org}/${repo.repo}`, true, { createIfNone: false })))).flat();
978994
}
995+
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: fetched ${sessions.length} sessions`);
979996
this.cachedSessionsSize = sessions.length;
980997

981998
// Group sessions by resource_id and keep only the latest per resource_id
@@ -1006,11 +1023,17 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
10061023
// Fetch PRs for all unique resource_global_ids in parallel
10071024
const uniqueGlobalIds = new Set(Array.from(latestSessionsMap.values()).map(s => s.resource_global_id));
10081025
const prFetches = Array.from(uniqueGlobalIds).map(async globalId => {
1009-
const pr = await this._octoKitService.getPullRequestFromGlobalId(globalId, { createIfNone: false });
1010-
return { globalId, pr };
1026+
try {
1027+
const pr = await this._octoKitService.getPullRequestFromGlobalId(globalId, { createIfNone: false });
1028+
return { globalId, pr };
1029+
} catch (e) {
1030+
this.logService.warn(`Failed to fetch PR for global ID ${globalId}: ${e instanceof Error ? e.message : String(e)}`);
1031+
return { globalId, pr: null };
1032+
}
10111033
});
10121034
const prResults = await Promise.all(prFetches);
10131035
const prMap = new Map(prResults.filter(r => r.pr).map(r => [r.globalId, r.pr!]));
1036+
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: resolved ${prMap.size}/${uniqueGlobalIds.size} PRs from global IDs`);
10141037

10151038
const validateISOTimestamp = (date: string | undefined): number | undefined => {
10161039
try {
@@ -1083,6 +1106,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
10831106
});
10841107

10851108
vscode.commands.executeCommand('setContext', 'github.copilot.chat.cloudSessionsEmpty', filteredSessions.length === 0);
1109+
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: returning ${filteredSessions.length} sessions (${sessionItems.length - filteredSessions.length} filtered out)`);
10861110

10871111
// Cache the results
10881112
this.cachedSessionItems = filteredSessions;
@@ -1365,8 +1389,10 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
13651389
// Repository and date
13661390
const date = new Date(pr.createdAt);
13671391
const ownerName = `${pr.repository.owner.login}/${pr.repository.name}`;
1392+
// Derive repo URL from the PR URL to support both github.com and GHE
1393+
const repoUrl = pr.url.replace(/\/pull\/\d+$/, '');
13681394
markdown.appendMarkdown(
1369-
`[${ownerName}](https://github.com/${ownerName}) on ${date.toLocaleString('default', {
1395+
`[${ownerName}](${repoUrl}) on ${date.toLocaleString('default', {
13701396
day: 'numeric',
13711397
month: 'short',
13721398
year: 'numeric',
@@ -1705,11 +1731,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
17051731
let repoId = repoIds[0];
17061732
if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {
17071733
const [selectedOrg, selectedRepo] = selectedRepository.split('/');
1708-
repoId = {
1709-
org: selectedOrg,
1710-
repo: selectedRepo,
1711-
type: 'github'
1712-
};
1734+
const matchingRepoId = repoIds.find(id => id.org === selectedOrg && id.repo === selectedRepo);
1735+
repoId = matchingRepoId ?? new GithubRepoId(selectedOrg, selectedRepo);
17131736
}
17141737

17151738
const { baseRef, repository, remoteName } = await this.gitOperationsManager.repoInfo();
@@ -2372,27 +2395,33 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
23722395
private async invokeRemoteAgent(prompt: string, problemContext: string, token: vscode.CancellationToken, stream: vscode.ChatResponseStream, base_ref: string, head_ref?: string, customAgentName?: string, modelName?: string, partnerAgentName?: string, selectedRepository?: string): Promise<{ number: number; sessionId: string }> {
23732396
const title = extractTitle(prompt, problemContext);
23742397
const { problemStatement, isTruncated } = truncatePrompt(this.logService, prompt, problemContext);
2398+
const repoIds = await getRepoId(this._gitService);
23752399

23762400
let repoOwner: string;
23772401
let repoName: string;
2402+
let repoHost: string = 'github.com';
23782403
if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {
23792404
const [owner, repo] = selectedRepository.split('/');
23802405
repoOwner = owner;
23812406
repoName = repo;
2407+
const matchingRepoId = repoIds?.find(id => id.org === owner && id.repo === repo);
2408+
if (matchingRepoId) {
2409+
repoHost = matchingRepoId.host;
2410+
}
23822411
} else {
2383-
const repoIds = await getRepoId(this._gitService);
23842412
const repoId = repoIds?.[0];
23852413
if (!repoId) {
23862414
throw new Error(vscode.l10n.t('Unable to determine repository information. Please ensure you are working within a Git repository.'));
23872415
}
23882416
repoOwner = repoId.org;
23892417
repoName = repoId.repo;
2418+
repoHost = repoId.host;
23902419
}
23912420

23922421
// Check if CCA is enabled before posting job
23932422
const ccaEnabled = await this.checkCCAEnabled(repoOwner, repoName);
23942423
if (ccaEnabled.enabled === false) {
2395-
throw new Error(this.getCCADisabledMessage(ccaEnabled));
2424+
throw new Error(this.getCCADisabledMessage(ccaEnabled, repoHost));
23962425
}
23972426

23982427
if (isTruncated) {
@@ -2463,7 +2492,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
24632492
case 401:
24642493
throw new Error(vscode.l10n.t('Cloud agent is not authorized to run on this repository. This may be because the Copilot coding agent is disabled for your organization, or your active GitHub account does not have push access to the target repository.'));
24652494
case 403:
2466-
throw new Error(vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', 'https://github.com/settings/copilot/coding_agent'));
2495+
throw new Error(vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', `https://${repoHost}/settings/copilot/coding_agent`));
24672496
case 404:
24682497
throw new Error(vscode.l10n.t('The repository `{0}/{1}` was not found or you do not have access to it.', repoOwner, repoName));
24692498
case 422:

src/extension/chatSessions/vscode/chatSessionsUriHandler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,10 @@ export class ChatSessionsUriHandler extends Disposable implements CustomUriHandl
284284
private _normalizeGitUri(uri: string): string {
285285
return uri.toLowerCase()
286286
.replace(/\.git$/, '')
287-
.replace(/^git@github\.com:/, 'https://github.com/')
288-
.replace(/^https:\/\/github\.com\//, '')
287+
// Normalize SSH shorthand to HTTPS for both github.com and ghe.com
288+
.replace(/^[\w\-]+@([\w.\-]+):/, 'https://$1/')
289+
// Strip the host prefix for github.com and ghe.com to get just owner/repo
290+
.replace(/^https:\/\/(?:[\w\-]+\.)*(?:github\.com|ghe\.com)\//, '')
289291
.replace(/\/$/, '');
290292
}
291293
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { beforeEach, describe, expect, it } from 'vitest';
7+
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
8+
import { observableValue } from '../../../../util/vs/base/common/observableInternal/observables/observableValue';
9+
import { URI } from '../../../../util/vs/base/common/uri';
10+
import { getRepoId } from '../copilotCodingAgentUtils';
11+
12+
function makeRepoContext(overrides: Partial<RepoContext>): RepoContext {
13+
return {
14+
rootUri: URI.parse('file:///repo'),
15+
kind: 'repository',
16+
remotes: ['origin'],
17+
remoteFetchUrls: [],
18+
headBranchName: 'main',
19+
headCommitHash: 'abc123',
20+
changes: { mergeChanges: [], indexChanges: [], workingTree: [], untrackedChanges: [] },
21+
...overrides,
22+
} as RepoContext;
23+
}
24+
25+
/**
26+
* Minimal mock of IGitService for testing getRepoId.
27+
* Only `initialize()`, `repositories`, and `activeRepository` are used.
28+
*/
29+
class TestGitService {
30+
repositories: RepoContext[] = [];
31+
activeRepository = observableValue<RepoContext | undefined>('test-active-repo', undefined);
32+
async initialize(): Promise<void> { }
33+
}
34+
35+
describe('getRepoId', () => {
36+
let gitService: TestGitService;
37+
38+
beforeEach(() => {
39+
gitService = new TestGitService();
40+
});
41+
42+
it('should return GithubRepoId with host=github.com for github.com repos', async () => {
43+
const repo = makeRepoContext({
44+
remoteFetchUrls: ['https://github.com/myorg/myrepo.git'],
45+
});
46+
gitService.activeRepository.set(repo, undefined);
47+
48+
const result = await getRepoId(gitService as unknown as IGitService);
49+
expect(result).toBeDefined();
50+
expect(result!.length).toBe(1);
51+
expect(result![0].org).toBe('myorg');
52+
expect(result![0].repo).toBe('myrepo');
53+
expect(result![0].host).toBe('github.com');
54+
});
55+
56+
it('should return GithubRepoId with GHE host for ghe.com repos', async () => {
57+
const repo = makeRepoContext({
58+
remoteFetchUrls: ['https://myco.ghe.com/org/repo.git'],
59+
});
60+
gitService.activeRepository.set(repo, undefined);
61+
62+
const result = await getRepoId(gitService as unknown as IGitService);
63+
expect(result).toBeDefined();
64+
expect(result!.length).toBe(1);
65+
expect(result![0].org).toBe('org');
66+
expect(result![0].repo).toBe('repo');
67+
expect(result![0].host).toBe('myco.ghe.com');
68+
});
69+
70+
it('should return GithubRepoId with GHE host for SSH shorthand ghe.com repos', async () => {
71+
const repo = makeRepoContext({
72+
remoteFetchUrls: ['msdemo-eu@msdemo-eu.ghe.com:sandbox/repo.git'],
73+
});
74+
gitService.activeRepository.set(repo, undefined);
75+
76+
const result = await getRepoId(gitService as unknown as IGitService);
77+
expect(result).toBeDefined();
78+
expect(result!.length).toBe(1);
79+
expect(result![0].org).toBe('sandbox');
80+
expect(result![0].repo).toBe('repo');
81+
expect(result![0].host).toBe('msdemo-eu.ghe.com');
82+
});
83+
84+
it('should return empty array for non-GitHub/GHE repos', async () => {
85+
const repo = makeRepoContext({
86+
remoteFetchUrls: ['https://gitlab.com/org/repo.git'],
87+
});
88+
gitService.activeRepository.set(repo, undefined);
89+
90+
const result = await getRepoId(gitService as unknown as IGitService);
91+
expect(result).toBeDefined();
92+
expect(result!.length).toBe(0);
93+
});
94+
95+
it('should return empty array when no remote URLs', async () => {
96+
const repo = makeRepoContext({
97+
remoteFetchUrls: [],
98+
});
99+
gitService.activeRepository.set(repo, undefined);
100+
101+
const result = await getRepoId(gitService as unknown as IGitService);
102+
expect(result).toBeDefined();
103+
expect(result!.length).toBe(0);
104+
});
105+
106+
it('should handle multi-root workspaces with mixed hosts', async () => {
107+
const repo1 = makeRepoContext({
108+
rootUri: URI.parse('file:///repo1'),
109+
remoteFetchUrls: ['https://github.com/org1/repo1.git'],
110+
kind: 'repository',
111+
});
112+
const repo2 = makeRepoContext({
113+
rootUri: URI.parse('file:///repo2'),
114+
remoteFetchUrls: ['https://myco.ghe.com/org2/repo2.git'],
115+
kind: 'repository',
116+
});
117+
gitService.repositories = [repo1, repo2];
118+
119+
const result = await getRepoId(gitService as unknown as IGitService);
120+
expect(result).toBeDefined();
121+
expect(result!.length).toBe(2);
122+
expect(result![0].host).toBe('github.com');
123+
expect(result![0].org).toBe('org1');
124+
expect(result![1].host).toBe('myco.ghe.com');
125+
expect(result![1].org).toBe('org2');
126+
});
127+
128+
it('should skip worktree repos in multi-root', async () => {
129+
const repo1 = makeRepoContext({
130+
rootUri: URI.parse('file:///repo1'),
131+
remoteFetchUrls: ['https://github.com/org1/repo1.git'],
132+
kind: 'repository',
133+
});
134+
const worktree = makeRepoContext({
135+
rootUri: URI.parse('file:///worktree'),
136+
remoteFetchUrls: ['https://github.com/org1/repo1.git'],
137+
kind: 'worktree',
138+
});
139+
gitService.repositories = [repo1, worktree];
140+
141+
const result = await getRepoId(gitService as unknown as IGitService);
142+
expect(result).toBeDefined();
143+
expect(result!.length).toBe(1);
144+
expect(result![0].org).toBe('org1');
145+
});
146+
});

0 commit comments

Comments
 (0)