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
2 changes: 1 addition & 1 deletion extensions/copilot/docs/monitoring/agent_monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t
| `github.copilot.git.repository` | When in a repo | `https://github.com/microsoft/vscode.git` |
| `github.copilot.git.branch` | When in a repo | `main` |
| `github.copilot.git.commit_sha` | When in a repo | `deadbeef...` |
| `github.copilot.github.org` | GitHub remotes only | `microsoft` |
| `github.copilot.github.org` | Recognized GitHub hosts only — github.com, `*.ghe.com`, and ssh host aliases normalizing to either (e.g. `alias-github.com`); absent for custom-domain GitHub Enterprise Server | `microsoft` |
| `copilot_chat.repo.remote_url` | **Legacy** — prefer `github.copilot.git.repository` | `https://github.com/...` |
| `copilot_chat.repo.head_branch_name` | **Legacy** — prefer `github.copilot.git.branch` | `main` |
| `copilot_chat.repo.head_commit_hash` | **Legacy** — prefer `github.copilot.git.commit_sha` | `deadbeef...` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,9 @@ function reportInlineEditSurvivalEvent(res: EditSurvivalResult, sharedProps: Tel
...sharedProps,
headBranchName: res.workspace?.headBranchName,
headCommitHash: res.workspace?.headCommitHash,
remoteUrl: res.workspace?.remoteUrl,
// Restricted to recognized hosts: this channel is not the user's own OTel exporter,
// so it must keep collecting exactly the remotes it collected before.
remoteUrl: res.workspace?.recognizedRemoteUrl,
fileRelativePath: res.workspace?.fileRelativePath,
}, {
...sharedMeasures,
Expand Down
7 changes: 6 additions & 1 deletion extensions/copilot/src/platform/git/common/gitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ export function getOrderedRemoteUrlsFromContext(repoContext: RepoContext): Itera

// Strategy 1: If there's only one remote, use that
if (repoContext.remoteFetchUrls?.length === 1) {
out.add(repoContext.remoteFetchUrls[0]!);
// A remote can be push-only and carry no fetch URL, so this entry may be undefined
// even though the declared return type is `Iterable<string>`.
const fetchUrl = repoContext.remoteFetchUrls[0];
if (fetchUrl) {
out.add(fetchUrl);
}
return out;
}

Expand Down
45 changes: 44 additions & 1 deletion extensions/copilot/src/platform/git/test/node/gitService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import assert from 'assert';
import { suite, test } from 'vitest';
import { AdoRepoId, getAdoRepoIdFromFetchUrl, getGithubRepoIdFromFetchUrl, GithubRepoId, normalizeFetchUrl, parseRemoteUrl, toGithubWebUrl } from '../../common/gitService';
import { AdoRepoId, getAdoRepoIdFromFetchUrl, getGithubRepoIdFromFetchUrl, GithubRepoId, getOrderedRemoteUrlsFromContext, getOrderedRepoInfosFromContext, normalizeFetchUrl, parseRemoteUrl, type RepoContext, toGithubWebUrl } from '../../common/gitService';

function assertGitIdEquals(a: GithubRepoId | undefined, b: { org: string; repo: string; host?: string } | undefined, message?: string) {
assert.strictEqual(a?.org, b?.org, message);
Expand All @@ -15,6 +15,49 @@ function assertGitIdEquals(a: GithubRepoId | undefined, b: { org: string; repo:
}
}

function createRepoContext(remotes: string[], remoteFetchUrls: Array<string | undefined>, upstreamRemote?: string): RepoContext {
return { remotes, remoteFetchUrls, upstreamRemote } as RepoContext;
}

suite('getOrderedRemoteUrlsFromContext', () => {
test('Should skip a lone remote that has no fetch URL', () => {
assert.deepStrictEqual(
Array.from(getOrderedRemoteUrlsFromContext(createRepoContext(['origin'], [undefined]))),
[]);
});

test('Should return a lone remote with a fetch URL', () => {
assert.deepStrictEqual(
Array.from(getOrderedRemoteUrlsFromContext(createRepoContext(['origin'], ['https://github.com/microsoft/vscode.git']))),
['https://github.com/microsoft/vscode.git']);
});

test('Should order upstream before origin', () => {
assert.deepStrictEqual(
Array.from(getOrderedRemoteUrlsFromContext(createRepoContext(
['origin', 'upstream'],
['https://github.com/fork/vscode.git', 'https://github.com/microsoft/vscode.git'],
'upstream'))),
['https://github.com/microsoft/vscode.git', 'https://github.com/fork/vscode.git']);
});
});

suite('getOrderedRepoInfosFromContext', () => {
test('Should not throw for a lone remote that has no fetch URL', () => {
assert.deepStrictEqual(
Array.from(getOrderedRepoInfosFromContext(createRepoContext(['origin'], [undefined]))),
[]);
});

test('Should skip remotes on unrecognized hosts', () => {
const infos = Array.from(getOrderedRepoInfosFromContext(createRepoContext(
['origin', 'github'],
['https://git.mycompany.com/owner/repo.git', 'https://github.com/microsoft/vscode.git'])));
assert.strictEqual(infos.length, 1);
assert.strictEqual(infos[0].fetchUrl, 'https://github.com/microsoft/vscode.git');
});
});

suite('parseRemoteUrl', () => {
test('Should handle basic https', () => {
assert.deepStrictEqual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ export class MultiFileEditInternalTelemetryService extends Disposable implements
messageId: edit.chatRequestId,
headBranchName: workspace.headBranchName,
headCommitHash: workspace.headCommitHash,
remoteUrl: workspace.remoteUrl,
// Restricted to recognized hosts: this channel is not the user's own OTel
// exporter, so it must keep collecting exactly the remotes it collected before.
remoteUrl: workspace.recognizedRemoteUrl,
fileRelativePath: workspace.fileRelativePath,
}).then(gitHubEnhancedTelemetryProperties => this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/editOutcome', gitHubEnhancedTelemetryProperties)).catch(() => { /* best-effort telemetry */ });
this.logService.debug(`Sent telemetry for ${uri.toString()} with request ID ${edit.chatRequestId}, SD request ID ${edit.speculationRequestId}, and outcome ${outcome}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,18 @@ export const GitHubCopilotAttr = {
/** Agent type classifier: `builtin` | `plugin` | `custom`. */
AGENT_TYPE: 'github.copilot.agent.type',

/** Git remote URL (normalized). Dual of `copilot_chat.repo.remote_url`. */
/** Git remote URL (normalized, any host). Dual of `copilot_chat.repo.remote_url`. */
GIT_REPOSITORY: 'github.copilot.git.repository',
/** Git HEAD branch. Dual of `copilot_chat.repo.head_branch_name`. */
GIT_BRANCH: 'github.copilot.git.branch',
/** Git HEAD commit. Dual of `copilot_chat.repo.head_commit_hash`. */
GIT_COMMIT_SHA: 'github.copilot.git.commit_sha',
/** GitHub `owner` segment derived from the remote URL (gated like the URL itself). */
/**
* GitHub `owner` segment derived from the remote URL. Only emitted for hosts recognized as
* GitHub: github.com, `*.ghe.com`, and ssh host aliases that normalize to either (for
* example `alias-github.com` or `github.com-alias`). Absent for custom-domain GitHub
* Enterprise Server, which is indistinguishable from an arbitrary git host here.
*/
GITHUB_ORG: 'github.copilot.github.org',

/** Hook decision result (`block` | `approve` | `non_blocking_error` | `pass`). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,180 @@ describe('resolveWorkspaceOTelMetadata', () => {
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBeDefined();
expect(result.remoteUrl).toContain('github.com');
expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git');
});

it('resolves remote URL for self-hosted GitHub Enterprise Server on a custom domain', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git');
});

it('normalizes an scp-style remote on a custom domain', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['git@git.mycompany.com:owner/repo.git'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git');
});

it('resolves remote URL for GitLab', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['https://gitlab.com/org/repo.git'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://gitlab.com/org/repo.git');
});

it('resolves remote URL for Bitbucket Server, stripping the /scm/ prefix', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['https://bitbucket.mycorp.com/scm/proj/repo.git'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://bitbucket.mycorp.com/proj/repo.git');
});

it('resolves remote URL for Azure DevOps', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['https://dev.azure.com/org/project/_git/repo'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://dev.azure.com/org/project/_git/repo');
});

it('ignores local remotes so filesystem paths never reach telemetry', () => {
for (const localRemote of ['file:///Users/someone/dev/repo', '/Users/someone/dev/repo', '../sibling-repo']) {
const gitService = createMockGitService({
remoteFetchUrls: [localRemote],
remotes: ['origin'],
});
expect(resolveWorkspaceOTelMetadata(gitService).remoteUrl).toBeUndefined();
}
});

it('ignores loopback remotes that smuggle a filesystem path through scp syntax', () => {
const loopbackRemotes = [
'git@localhost:/Users/alice/dev/repo.git',
'ssh://git@localhost/Users/alice/dev/repo.git',
'git@127.0.0.1:/Users/alice/dev/repo.git',
'git@dev.localhost:/Users/alice/dev/repo.git',
'https://localhost:8443/Users/alice/dev/repo.git',
'ssh://git@[::1]/Users/alice/dev/repo.git',
];
for (const loopbackRemote of loopbackRemotes) {
const gitService = createMockGitService({
remoteFetchUrls: [loopbackRemote],
remotes: ['origin'],
});
expect(resolveWorkspaceOTelMetadata(gitService).remoteUrl, loopbackRemote).toBeUndefined();
}
});

it('still reports a non-loopback host that serves repositories from an absolute path', () => {
const gitService = createMockGitService({
remoteFetchUrls: ['git@git.mycompany.com:/srv/git/repo.git'],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
// An absolute path on a routable host is the server's layout, not the user's, so it is
// reported. The doubled slash is existing `normalizeFetchUrl` behaviour for scp-style
// remotes whose path is absolute.
expect(result.remoteUrl).toBe('https://git.mycompany.com//srv/git/repo.git');
});

it('skips a loopback remote but still reports a later routable one', () => {
const gitService = createMockGitService({
remotes: ['origin', 'mirror'],
remoteFetchUrls: ['git@localhost:/Users/alice/dev/repo.git', 'https://git.mycompany.com/owner/repo.git'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git');
});

it('exposes recognizedRemoteUrl only for remotes that resolve to a repo id', () => {
const recognized = resolveWorkspaceOTelMetadata(createMockGitService({
remoteFetchUrls: ['https://github.com/microsoft/vscode.git'],
remotes: ['origin'],
}));
expect(recognized.recognizedRemoteUrl).toBe('https://github.com/microsoft/vscode.git');
expect(recognized.remoteUrl).toBe('https://github.com/microsoft/vscode.git');

const ado = resolveWorkspaceOTelMetadata(createMockGitService({
remoteFetchUrls: ['https://dev.azure.com/org/project/_git/repo'],
remotes: ['origin'],
}));
expect(ado.recognizedRemoteUrl).toBe('https://dev.azure.com/org/project/_git/repo');

// The whole point of the split: non-OTel telemetry channels must not widen.
const unrecognized = resolveWorkspaceOTelMetadata(createMockGitService({
remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git'],
remotes: ['origin'],
}));
expect(unrecognized.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git');
expect(unrecognized.recognizedRemoteUrl).toBeUndefined();
});

it('keeps recognizedRemoteUrl on the resolvable remote in a mixed-remote repository', () => {
const gitService = createMockGitService({
remotes: ['origin', 'github'],
remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://github.com/microsoft/vscode.git'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.recognizedRemoteUrl).toBe('https://github.com/microsoft/vscode.git');
expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git');
});

it('handles a remote with no fetch URL', () => {
const gitService = createMockGitService({
remoteFetchUrls: [undefined],
remotes: ['origin'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBeUndefined();
});

it('prefers a resolvable GitHub remote over a higher-priority unrecognized one', () => {
const gitService = createMockGitService({
remotes: ['origin', 'github'],
remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://github.com/microsoft/vscode.git'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git');
});

it('prefers a resolvable Azure DevOps remote over a higher-priority unrecognized one', () => {
const gitService = createMockGitService({
remotes: ['origin', 'ado'],
remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://dev.azure.com/org/project/_git/repo'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://dev.azure.com/org/project/_git/repo');
});

it('falls back to an unrecognized remote only when no remote resolves', () => {
const gitService = createMockGitService({
remotes: ['origin', 'mirror'],
remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git', 'https://gitlab.com/org/repo.git'],
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git');
});

it('prefers the upstream remote over origin for unrecognized hosts', () => {
const gitService = createMockGitService({
remotes: ['origin', 'upstream'],
remoteFetchUrls: ['https://git.mycompany.com/fork/repo.git', 'https://git.mycompany.com/upstream/repo.git'],
upstreamRemote: 'upstream',
});
const result = resolveWorkspaceOTelMetadata(gitService);
expect(result.remoteUrl).toBe('https://git.mycompany.com/upstream/repo.git');
});

it('computes relative file path from repo root', () => {
Expand Down Expand Up @@ -130,4 +302,38 @@ describe('workspaceMetadataToOTelAttributes', () => {
expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://gitlab.com/org/repo.git');
expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined();
});

it('derives github.org for GitHub Enterprise Cloud remotes', () => {
const attrs = workspaceMetadataToOTelAttributes({
remoteUrl: 'https://myco.ghe.com/acme/repo.git',
});
expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://myco.ghe.com/acme/repo.git');
expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBe('acme');
});

it('derives github.org through an ssh host alias', () => {
const attrs = workspaceMetadataToOTelAttributes({
remoteUrl: 'https://alias-github.com/microsoft/vscode.git',
});
expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBe('microsoft');
});

it('emits the repository URL but no github.org for custom-domain GitHub Enterprise Server', () => {
const attrs = workspaceMetadataToOTelAttributes({
remoteUrl: 'https://git.mycompany.com/owner/repo.git',
});
expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://git.mycompany.com/owner/repo.git');
expect(attrs[CopilotChatAttr.REPO_REMOTE_URL]).toBe('https://git.mycompany.com/owner/repo.git');
// Distinguishing a custom GHES domain from an arbitrary git host needs the configured
// `github-enterprise.uri`, which this module cannot read.
expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined();
});

it('emits the repository URL but no github.org for Bitbucket Server', () => {
const attrs = workspaceMetadataToOTelAttributes({
remoteUrl: 'https://bitbucket.mycorp.com/proj/repo.git',
});
expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://bitbucket.mycorp.com/proj/repo.git');
expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined();
});
});
Loading