diff --git a/packages/experimental/forge-github/README.i18n.yaml b/packages/experimental/forge-github/README.i18n.yaml index aa17a574ec..1da77a0a83 100644 --- a/packages/experimental/forge-github/README.i18n.yaml +++ b/packages/experimental/forge-github/README.i18n.yaml @@ -30,8 +30,8 @@ en: 26b946af4b742e17 zh: 00b34f0721ac72f6 /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work: - en: 441928a7cc59da3b - zh: 96496453ce349cad + en: eb7d8000ca55baca + zh: 27ba101ffa91175b /deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/forge-github/README.md b/packages/experimental/forge-github/README.md index 0e95b7844a..8eab8d204b 100644 --- a/packages/experimental/forge-github/README.md +++ b/packages/experimental/forge-github/README.md @@ -84,7 +84,7 @@ None. Provider output reaches only host-side watchers. -- **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read. +- **GitHub CLI on the host** — `gh` must be installed and signed in for each configured host; tokens in environment variables are not forwarded. Hosts missing from `hosts` are never read, including references retained across configuration changes. A status fetch returns `unavailable` for those references; a head lookup rejects before starting `gh`. - **First 100 checks and review threads** — `checksTruncated` marks a head commit with more checks; unresolved threads after the first 100 are not counted. - **No fork discovery** — head lookups exclude pull requests whose head is in another repository. - **Rate limits** — every poll costs one GraphQL query per host against the signed-in account's rate limit. diff --git a/packages/experimental/forge-github/README.zh.md b/packages/experimental/forge-github/README.zh.md index a8e1aa7e75..6ff9b9ea6b 100644 --- a/packages/experimental/forge-github/README.zh.md +++ b/packages/experimental/forge-github/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" -- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取。 +- **宿主需要 GitHub CLI**——必须安装 `gh` 并为每个已配置主机登录;环境变量中的令牌不会被转发。不在 `hosts` 中的主机永远不会被读取,包括配置变更后保留的引用。状态读取为这些引用返回 `unavailable`;head 查找会在启动 `gh` 前拒绝。 - **前 100 个检查与评审讨论**——head 提交的检查超过上限时以 `checksTruncated` 标记;前 100 个之后的未解决讨论不计入。 - **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。 - **速率限制**——每次轮询都会以已登录账号的速率配额为每个主机消耗一次 GraphQL 查询。 diff --git a/packages/experimental/forge-github/src/provider.ts b/packages/experimental/forge-github/src/provider.ts index b5607eb44d..982b08dbb4 100644 --- a/packages/experimental/forge-github/src/provider.ts +++ b/packages/experimental/forge-github/src/provider.ts @@ -45,12 +45,17 @@ export class GitHubProvider implements ForgeProvider { signal: AbortSignal, ): Promise> { const byHost = new Map() + const results = new Map() for (const ref of refs) { + const failure = this.hostFailure(ref.repository.host) + if (failure !== undefined) { + results.set(changeRequestKey(ref), failure) + continue + } const group = byHost.get(ref.repository.host) if (group === undefined) byHost.set(ref.repository.host, [ref]) else group.push(ref) } - const results = new Map() for (const [host, group] of byHost) { for (const [key, result] of await this.fetchHost(host, group, signal)) results.set(key, result) } @@ -58,6 +63,8 @@ export class GitHubProvider implements ForgeProvider { } async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise { + const failure = this.hostFailure(repository.host) + if (failure !== undefined) throw new Error(failure.message) const output = await this.gh([ 'api', 'graphql', '--hostname', repository.host, '-f', `query=${HEAD_QUERY}`, '-f', `owner=${repository.owner}`, '-f', `name=${repository.name}`, '-f', `branch=${branch}`, @@ -67,6 +74,12 @@ export class GitHubProvider implements ForgeProvider { return mapHeadResponse(body, repository, branch) } + private hostFailure(host: string): ForgeFailure | undefined { + return this.hosts.has(host.toLowerCase()) ? undefined : { + code: 'unavailable', message: `forge-github: host ${JSON.stringify(host)} is not configured`, + } + } + private async fetchHost( host: string, refs: readonly ChangeRequestRef[], diff --git a/packages/experimental/forge-github/tests/provider.spec.ts b/packages/experimental/forge-github/tests/provider.spec.ts index 86fa5db463..e16f972a3b 100644 --- a/packages/experimental/forge-github/tests/provider.spec.ts +++ b/packages/experimental/forge-github/tests/provider.spec.ts @@ -45,6 +45,25 @@ describe('GitHub provider addressing', () => { }) describe('GitHub provider fetch', () => { + it('rejects unconfigured hosts without querying them and still fetches allowed hosts', async () => { + const gh = fakeGh(() => output({ data: { r0: { p0: pullRequestNode(1) } } })) + const denied = pull(2, { ...enterprise, host: 'unconfigured.example' }) + const results = await provider(gh.run).fetch([denied, pull(1)], signal) + expect(gh.calls.map(call => call[3])).toEqual(['github.com']) + expect(results.get(changeRequestKey(denied))).toEqual({ code: 'unavailable', message: 'forge-github: host "unconfigured.example" is not configured' }) + expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' }) + }) + + it('rejects references retained after their host was removed from configuration', async () => { + const gh = fakeGh(() => output('')) + const retained = provider(gh.run).changeRequestFromUrl('https://ghe.corp.example/team/svc/pull/2')! + const replacement = new GitHubProvider(gh.run, ['github.com']) + const results = await replacement.fetch([retained], signal) + await expect(replacement.findByHead(retained.repository, 'topic', signal)).rejects.toThrow('is not configured') + expect(results.get(changeRequestKey(retained))).toMatchObject({ code: 'unavailable' }) + expect(gh.calls).toEqual([]) + }) + it('runs one GraphQL request per host and merges the results', async () => { const gh = fakeGh((args) => { const host = args[3]