diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 444d38919fb623..cd76271f0de939 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -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...` | diff --git a/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts b/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts index c1bf1b3df063a7..c9f9cf1aeb3a31 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts @@ -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, diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index bcdb99d2d24c2e..844316dc380141 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -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`. + const fetchUrl = repoContext.remoteFetchUrls[0]; + if (fetchUrl) { + out.add(fetchUrl); + } return out; } diff --git a/extensions/copilot/src/platform/git/test/node/gitService.spec.ts b/extensions/copilot/src/platform/git/test/node/gitService.spec.ts index 9f9b84cc760eb2..a6d476f5c4eed1 100644 --- a/extensions/copilot/src/platform/git/test/node/gitService.spec.ts +++ b/extensions/copilot/src/platform/git/test/node/gitService.spec.ts @@ -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); @@ -15,6 +15,49 @@ function assertGitIdEquals(a: GithubRepoId | undefined, b: { org: string; repo: } } +function createRepoContext(remotes: string[], remoteFetchUrls: Array, 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( diff --git a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts index 298dd0882f0b3e..464ecef1e20854 100644 --- a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts +++ b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts @@ -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}`); diff --git a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts index cdbd773cf4154e..506ba51288554c 100644 --- a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts +++ b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts @@ -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`). */ diff --git a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts index 00e93ab2766299..f64cc64e73c1f6 100644 --- a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts @@ -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', () => { @@ -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(); + }); }); diff --git a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts index 81e46aa16b4bba..b4d1356fec2a43 100644 --- a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts +++ b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts @@ -5,13 +5,28 @@ import { URI } from '../../../util/vs/base/common/uri'; import { isEqualOrParent, relativePath } from '../../../util/vs/base/common/resources'; -import { getOrderedRepoInfosFromContext, type IGitService, normalizeFetchUrl, type RepoContext } from '../../git/common/gitService'; +import { getGithubRepoIdFromFetchUrl, getOrderedRemoteUrlsFromContext, getOrderedRepoInfosFromContext, type IGitService, normalizeFetchUrl, parseRemoteUrl, type RepoContext } from '../../git/common/gitService'; import { CopilotChatAttr, GitHubCopilotAttr } from './genAiAttributes'; export interface WorkspaceOTelMetadata { readonly headBranchName?: string; readonly headCommitHash?: string; + /** + * Normalized remote fetch URL for OTel attributes, reported for any host so that a + * repository is never silently dropped. Exported only to the user-configured OTel + * pipeline. + */ readonly remoteUrl?: string; + /** + * Normalized remote fetch URL restricted to remotes that resolve to a repo id + * (github.com, `*.ghe.com`, Azure DevOps) — the set that was reportable before + * host-agnostic reporting was introduced. + * + * Consumers that forward the remote to a channel other than the user's own OTel + * exporter (GitHub telemetry events) must use this field, so that broadening OTel + * coverage does not silently widen what those channels collect. + */ + readonly recognizedRemoteUrl?: string; readonly fileRelativePath?: string; } @@ -31,11 +46,8 @@ export function resolveWorkspaceOTelMetadata( } function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): WorkspaceOTelMetadata { - let remoteUrl: string | undefined; - const repoInfo = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; - if (repoInfo?.fetchUrl) { - remoteUrl = normalizeFetchUrl(repoInfo.fetchUrl); - } + const recognizedRemoteUrl = pickRecognizedRemoteUrl(repoContext); + const remoteUrl = recognizedRemoteUrl ?? pickFallbackRemoteUrl(repoContext); let fileRelativePath: string | undefined; if (fileUri && isEqualOrParent(fileUri, repoContext.rootUri)) { @@ -46,10 +58,67 @@ function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): Worksp headBranchName: repoContext.headBranchName, headCommitHash: repoContext.headCommitHash, remoteUrl, + recognizedRemoteUrl, fileRelativePath, }; } +/** + * Picks the normalized remote fetch URL of the highest-priority remote that resolves to a + * repo id (github.com, `*.ghe.com`, Azure DevOps). + * + * This is the set of remotes that was reportable before host-agnostic reporting existed, so + * preferring it keeps every repository that already reported a URL reporting the same one. + */ +function pickRecognizedRemoteUrl(repoContext: RepoContext): string | undefined { + const resolved = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; + return resolved?.fetchUrl ? normalizeFetchUrl(resolved.fetchUrl) : undefined; +} + +/** + * Picks the normalized remote fetch URL of the highest-priority remote on any host, used + * only when no remote resolves to a repo id. The branch and commit attributes are read + * straight off the repo context with no host check, so the repository must not be silently + * dropped for self-hosted GitHub Enterprise Server on a custom domain, GitLab, Bitbucket + * Server, or a plain git server. + * + * Remotes are walked in the same priority order as {@link pickRecognizedRemoteUrl} (a lone + * remote first, then the upstream remote, then `origin`, then the rest). + * + * Local remotes are excluded so a user's filesystem layout is never reported: `parseRemoteUrl` + * admits only ssh, https, and http, which rejects `file://` and bare paths, and loopback + * hosts are rejected on top of that because scp-style syntax can smuggle a local path through + * an accepted scheme (`git@localhost:/Users/alice/dev/repo.git`). + */ +function pickFallbackRemoteUrl(repoContext: RepoContext): string | undefined { + for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) { + // `getOrderedRemoteUrlsFromContext` types its result as `Iterable` but reads + // from `remoteFetchUrls: Array`, so guard against empty entries. + if (!remoteUrl) { + continue; + } + const parsed = parseRemoteUrl(remoteUrl); + if (!parsed || isLoopbackHost(parsed.rawHost)) { + continue; + } + return normalizeFetchUrl(remoteUrl); + } + return undefined; +} + +/** + * Whether a host refers to the local machine. `parseRemoteUrl` has already lowercased the + * host and stripped any port. + */ +function isLoopbackHost(rawHost: string): boolean { + const host = rawHost.replace(/^\[|\]$/g, ''); + return host === 'localhost' + || host.endsWith('.localhost') + || host === '::1' + || host === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(host); +} + /** * Convert workspace metadata to OTel attributes, omitting undefined values. * Emits both the legacy `copilot_chat.repo.*` namespace and the canonical @@ -85,12 +154,15 @@ export function workspaceMetadataToOTelAttributes( } /** - * Extract the `owner` segment from a normalized GitHub remote URL. - * Returns undefined for non-GitHub hosts or malformed inputs. + * Extract the `owner` segment from a remote URL that resolves to a GitHub repository. + * + * Unlike the remote URL itself, this stays scoped to 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`). Returns undefined for every other host, which notably includes + * self-hosted GitHub Enterprise Server on a custom domain: telling that apart from an + * arbitrary git server needs the configured `github-enterprise.uri`, which this module has + * no access to. */ function extractGitHubOrg(remoteUrl: string): string | undefined { - // Match `(https://|git@)[:/]/` — normalizeFetchUrl already - // strips credentials and `.git` suffixes. - const m = remoteUrl.match(/github\.com[/:]([^/]+)\/[^/]+\/?$/i); - return m?.[1]; + return getGithubRepoIdFromFetchUrl(remoteUrl)?.org; }