perf(forge): compare from the recorded base while confirming the head
Status snapshots carry the base commit the host recorded with the head (baseSha). ForgeService.diff passes it to the provider when the latest status names the requested head, and forge-github starts the compare read from it while its GraphQL read confirms the head; a stale head or another base cancels and joins the early read.
This commit is contained in:
1 parent
d8d6081f44
commit
9b456b4e93
18 files changed
+152
-39
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge-github/use-this-package:
|
||||
en: 0f8041e0380036c9
|
||||
zh: 63cbdc82c9f438d4
|
||||
en: 7cc428526bce20c2
|
||||
zh: e58b72d47ad8da87
|
||||
/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation:
|
||||
en: f98e77171cba809f
|
||||
zh: 1b330cb342d69efe
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 26b946af4b742e17
|
||||
zh: 00b34f0721ac72f6
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work:
|
||||
en: 9f13ae6d928e4ef7
|
||||
zh: 09a549b1655f4de3
|
||||
en: 8290820d0ea4122e
|
||||
zh: f400491b8cb02453
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co
|
||||
|
||||
A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready <number> --repo <host>/<owner>/<name>`. Merging calls `PUT repos/<owner>/<name>/pulls/<number>/merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`.
|
||||
|
||||
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
|
||||
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
|
||||
|
||||
Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints.
|
||||
|
||||
@@ -100,7 +100,7 @@ None. Provider output reaches only host-side watchers.
|
||||
- **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads.
|
||||
- **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated.
|
||||
- **No fork discovery** — head lookups exclude pull requests whose head is in another repository.
|
||||
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit.
|
||||
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request, and a second REST request when the base commit changed after the latest status. All count against the signed-in account’s rate limit.
|
||||
- **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
|
||||
pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready <number> --repo <host>/<owner>/<name>`。合并经 `gh api` 调用 `PUT repos/<owner>/<name>/pulls/<number>/merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。
|
||||
|
||||
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
|
||||
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带;拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
|
||||
|
||||
提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。
|
||||
|
||||
@@ -100,7 +100,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为
|
||||
- **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。
|
||||
- **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。
|
||||
- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。
|
||||
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。
|
||||
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求;最新状态之后基准提交有变化时再多一次 REST 请求。这些都计入已登录账号的速率配额。
|
||||
- **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -32,7 +32,7 @@ const THREAD_FIELDS = 'pageInfo { hasNextPage endCursor } nodes { isResolved }'
|
||||
function pullRequestFields(threads: number): string {
|
||||
return `id number url title state isDraft updatedAt closedAt
|
||||
author { login }
|
||||
headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision
|
||||
headRefName headRefOid baseRefName baseRefOid additions deletions mergeable mergeStateStatus reviewDecision
|
||||
reviewThreads(first: ${threads}) { ${THREAD_FIELDS} }
|
||||
commits(last: 1) { nodes { commit { statusCheckRollup { id state contexts(first: ${PAGE_SIZE}) {
|
||||
${CHECK_FIELDS}
|
||||
@@ -132,6 +132,7 @@ const pullRequestSchema = z.object({
|
||||
headRefName: z.string(),
|
||||
headRefOid: z.string(),
|
||||
baseRefName: z.string(),
|
||||
baseRefOid: z.string().refine(isCommit),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
mergeable: z.string(),
|
||||
@@ -466,6 +467,7 @@ function toSnapshot(
|
||||
headBranch: node.headRefName,
|
||||
headSha: node.headRefOid,
|
||||
baseBranch: node.baseRefName,
|
||||
baseSha: node.baseRefOid,
|
||||
additions: node.additions,
|
||||
deletions: node.deletions,
|
||||
mergeability: mergeability(node.mergeable),
|
||||
|
||||
@@ -159,24 +159,44 @@ export class GitHubProvider implements ForgeProvider {
|
||||
* current head and the base commit GitHub recorded with it; a head other
|
||||
* than `headSha` returns `stale`. Otherwise the compare endpoint reads the
|
||||
* patch from the merge base of that base commit and `headSha`, as the pull
|
||||
* request's own diff does, with both ends fixed.
|
||||
* request's own diff does, with both ends fixed. Given the `base` the latest
|
||||
* status recorded with `headSha`, the comparison from it runs while the
|
||||
* GraphQL read confirms the head; a stale head or another base commit
|
||||
* cancels that comparison and waits for it to end before the read returns.
|
||||
*/
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure> {
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure> {
|
||||
const { host, owner, name } = checkedRepository(ref)
|
||||
const failure = this.hostFailure(host)
|
||||
if (failure !== undefined) return failure
|
||||
if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`)
|
||||
const output = await this.read([
|
||||
'api', 'graphql', '--hostname', host,
|
||||
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
|
||||
], signal)
|
||||
if (typeof output !== 'string') return output
|
||||
const commits = mapPullRequestCommitsResponse(parseJson(output))
|
||||
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
|
||||
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
|
||||
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
|
||||
if (base !== undefined && !isCommit(base)) throw new Error(`forge-github: invalid base commit ${JSON.stringify(base)}`)
|
||||
const compare = async (from: string, operation: AbortSignal): Promise<string | ForgeFailure> =>
|
||||
await readComparison(async (args, inner) => await this.read(args, inner), ref.repository, from, headSha, operation)
|
||||
const speculation = new AbortController()
|
||||
const early = base === undefined ? undefined : { base, patch: compare(base, AbortSignal.any([signal, speculation.signal])) }
|
||||
// The early comparison is observed here, so a cancelled or unused one never rejects unhandled.
|
||||
const settled = early?.patch.then(() => undefined, () => undefined)
|
||||
const discard = async (): Promise<void> => {
|
||||
speculation.abort()
|
||||
await settled
|
||||
}
|
||||
try {
|
||||
const output = await this.read([
|
||||
'api', 'graphql', '--hostname', host,
|
||||
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
|
||||
], signal)
|
||||
if (typeof output !== 'string') return output
|
||||
const commits = mapPullRequestCommitsResponse(parseJson(output))
|
||||
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
|
||||
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
|
||||
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
|
||||
}
|
||||
if (early?.base.toLowerCase() === commits.base.toLowerCase()) return await early.patch
|
||||
await discard()
|
||||
return await compare(commits.base, signal)
|
||||
} finally {
|
||||
await discard()
|
||||
}
|
||||
return await readComparison(async (args, operation) => await this.read(args, operation), ref.repository, commits.base, headSha, signal)
|
||||
}
|
||||
|
||||
/** Read one file at a commit through the contents API in its raw form. */
|
||||
|
||||
@@ -54,6 +54,7 @@ export function pullRequestNode(number: number, overrides: Record<string, unknow
|
||||
headRefName: `topic-${number}`,
|
||||
headRefOid: 'f'.repeat(40),
|
||||
baseRefName: 'master',
|
||||
baseRefOid: 'b'.repeat(40),
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeable: 'MERGEABLE',
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('snapshot mapping', () => {
|
||||
headBranch: 'topic-1',
|
||||
headSha: 'f'.repeat(40),
|
||||
baseBranch: 'master',
|
||||
baseSha: 'b'.repeat(40),
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeability: 'mergeable',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts'
|
||||
import type { GhOutput, GhRunner } from '../src/gh.ts'
|
||||
import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts'
|
||||
@@ -446,6 +446,55 @@ describe('GitHub provider reads', () => {
|
||||
expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`)
|
||||
})
|
||||
|
||||
it('starts the comparison from the base the status recorded while it confirms the head', async () => {
|
||||
const confirmed = Promise.withResolvers<GhOutput>()
|
||||
const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)
|
||||
? confirmed.promise : output('diff --git a/x b/x\n'))
|
||||
|
||||
const read = provider(gh.run).diff(pull(7), head, signal, base)
|
||||
await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) })
|
||||
expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`)
|
||||
confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } }))
|
||||
|
||||
await expect(read).resolves.toBe('diff --git a/x b/x\n')
|
||||
expect(gh.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => {
|
||||
const moved = 'e'.repeat(40)
|
||||
for (const [answer, expected, compares] of [
|
||||
[output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }),
|
||||
{ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]],
|
||||
[output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]],
|
||||
[output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]],
|
||||
] as const) {
|
||||
const calls: string[][] = []
|
||||
const cancelled: string[] = []
|
||||
const run: GhRunner = async (args, operation) => {
|
||||
calls.push([...args])
|
||||
if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer
|
||||
const range = String(args.at(-1))
|
||||
if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n')
|
||||
// The early comparison runs until the provider cancels it.
|
||||
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
|
||||
cancelled.push(range)
|
||||
throw operation.reason
|
||||
}
|
||||
|
||||
await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected)
|
||||
expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/'))
|
||||
.map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares)
|
||||
expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`])
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a base that is not a full commit object name before starting gh', async () => {
|
||||
const gh = pinned()
|
||||
|
||||
await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"')
|
||||
expect(gh.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a head that moved past the requested commit as stale without reading the diff', async () => {
|
||||
const gh = pinned({ head: pushed, base })
|
||||
|
||||
|
||||
Reference in new issue
Block a user