feat(forge): mark change requests ready and merge them from the panel
An expanded card now marks a draft ready for review and merges a change request that the host would merge now: - The forge seam gains provider actions `markReady` and `merge`, which return a typed `ForgeActionFailure`; the registry polls a watched change request at once after either settles. Snapshots carry `mergeReady` and the `mergeMethods` the repository allows, the user's usual one first. - GitHub marks ready with `gh pr ready` and merges through the REST merge endpoint with the head SHA the card showed, so new commits refuse the merge; it never enables auto-merge, joins a merge queue, or deletes the head branch. - The panel Remote acts only on visible bindings of the Session, and the card offers 标记为可评审 for drafts and a 合并 menu with the allowed methods and a confirmation, reporting each outcome in a toast. - The forge panel e2e replaces the worktree branch before the ARIA capture normalizes durations, so a random suffix no longer breaks it.
This commit is contained in:
1 parent
06d1616224
commit
80254034c7
48 files changed
+1230
-93
No files matched your search
@@ -9,14 +9,14 @@
|
||||
en: 344bcc7e9825bbc8
|
||||
zh: c1d0f4b54f5b3786
|
||||
/agent-note-change-request-panel-and-worktree-sessions/decision:
|
||||
en: b1e4e9e5ac5f6c5f
|
||||
zh: 641f238458e18764
|
||||
en: 737b8ff68c2912ed
|
||||
zh: 1dc646f01d4fc102
|
||||
/agent-note-change-request-panel-and-worktree-sessions/deferred:
|
||||
en: 715eea526e6d7905
|
||||
zh: 96151efa9cd01d0d
|
||||
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
|
||||
en: edb609bf47c2a6df
|
||||
zh: d29cca26cef7bab4
|
||||
en: c558f2096fe1f2aa
|
||||
zh: 4e21843885cdf85d
|
||||
/agent-note-change-request-panel-and-worktree-sessions/consequences:
|
||||
en: e8d3761238bff641
|
||||
zh: 7743dffb20066e54
|
||||
en: 7354db76e8cf18f1
|
||||
zh: 234277570935e97d
|
||||
@@ -16,6 +16,8 @@ The Remote inspects the checkout through the Session's subprocess provider under
|
||||
|
||||
Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default.
|
||||
|
||||
An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session.
|
||||
|
||||
A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message enters the Session, so the message runs there and a stopped creation leaves it unsent: the panel's listener on the [`agent-controller/admission` stage](../architecture/2026-10-17-agent-controller-message-delivery.md) holds the first message while it creates and enters the worktree as Agent maintenance, then lets the message enter, and the New Session composer shows the message as sending meanwhile. The creation's stage streams to a card above the composer, where Cancel stops it and returns the message to editing. A failure also returns the message to editing, and the card keeps the failure: sending again starts a new attempt with a new branch name, and Continue without a worktree withdraws the request. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because other senders reach the Agent without admission; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The Session header names the branch the Session works on.
|
||||
|
||||
The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and admission checks again for every message of a worktree Session, refusing it while the directory is missing so the message stays in the composer. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout.
|
||||
@@ -35,6 +37,10 @@ A left-navigation review page next to Plugins and Scheduled tasks, listing the u
|
||||
|
||||
**Per-host nouns.** Calling items pull requests on GitHub and merge requests on GitLab would need each provider to declare its noun and every string to take it; GitHub is the only provider, and one term keeps the copy fixed.
|
||||
|
||||
**Merging through `gh pr merge`.** It enables auto-merge while required checks are pending and joins a merge queue when the base requires one, so a click could schedule a later merge instead of merging. The REST merge endpoint merges at once or refuses.
|
||||
|
||||
**Deleting the head branch after a merge.** The Session's worktree can be on that branch.
|
||||
|
||||
**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists.
|
||||
|
||||
**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot.
|
||||
@@ -59,4 +65,4 @@ A left-navigation review page next to Plugins and Scheduled tasks, listing the u
|
||||
|
||||
## Consequences
|
||||
|
||||
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to seven short Git processes in the Session's execution world. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. Only the Web Session Remote admits messages so far; other senders create the worktree within the first turn and send into a removed worktree. Restoring loses changes that were never committed. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service.
|
||||
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to seven short Git processes in the Session's execution world. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. Only the Web Session Remote admits messages so far; other senders create the worktree within the first turn and send into a removed worktree. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service.
|
||||
@@ -16,6 +16,8 @@ Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略
|
||||
|
||||
状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。
|
||||
|
||||
展开的卡片可以把草稿标记为可评审,也可以合并主机现在就会合并的变更请求。forge seam 把两者作为提供方操作承载,失败时返回带类型的结果;每个快照都说明主机现在是否会为已登录用户合并该变更请求、仓库允许哪些合并方式,因此面板只提供能够成功的操作。合并带上卡片显示的 head 提交,有新提交到达后主机会拒绝。面板只操作绑定到该 Session 的变更请求。
|
||||
|
||||
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息进入 Session 之前就存在,这样消息才会在其中运行,停止创建时消息也不会进入:面板在 [`agent-controller/admission` 阶段](../architecture/2026-10-17-agent-controller-message-delivery.zh.md)上的监听器暂留第一条消息,同时以 Agent 维护任务创建并进入 worktree,之后再放行消息;此期间新会话页的输入框把消息显示为正在发送。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止创建并让消息回到可编辑状态。失败时消息同样回到可编辑状态,卡片保留失败信息:再次发送会用新分支名开始新的尝试,“不使用工作树”撤回请求。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为其他发送方不经过接纳就能把消息交给 Agent;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。Session 标题栏显示 Session 所在的分支。
|
||||
|
||||
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;在 worktree 中工作的 Session 的每条消息在接纳时也会再确认一次,目录缺失时拒绝消息,让它留在输入框里。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。
|
||||
@@ -35,6 +37,10 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
|
||||
|
||||
**按平台使用不同名称。** 在 GitHub 上称 pull request、在 GitLab 上称 merge request,需要每个提供方声明自己的名称,所有文案也要随之变化;而 GitHub 是唯一的提供方,统一的术语让文案保持不变。
|
||||
|
||||
**通过 `gh pr merge` 合并。** 必需检查尚未完成时它会启用自动合并,基准分支要求合并队列时它会加入队列,因此一次点击可能安排了一次以后的合并而不是立即合并。REST 合并接口要么立即合并,要么拒绝。
|
||||
|
||||
**合并后删除 head 分支。** Session 的 worktree 可能就在这个分支上。
|
||||
|
||||
**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。
|
||||
|
||||
**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。
|
||||
@@ -59,4 +65,4 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
|
||||
|
||||
## Consequences
|
||||
|
||||
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。每次检查最多在 Session 的执行环境中启动七个短暂的 Git 进程。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。目前只有 Web Session Remote 接纳消息;其他发送方在第一轮之内创建 worktree,也会向已删除的 worktree 发送消息。恢复会丢失从未提交的修改。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。
|
||||
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。每次检查最多在 Session 的执行环境中启动七个短暂的 Git 进程。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。目前只有 Web Session Remote 接纳消息;其他发送方在第一轮之内创建 worktree,也会向已删除的 worktree 发送消息。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu:
|
||||
- text: "将 #9 合并到 main"
|
||||
- menuitem "压缩合并 把全部提交压缩成一个提交"
|
||||
- menuitem "创建合并提交 保留全部提交,并新增一个合并提交"
|
||||
- menuitem "变基合并 把提交逐个变基到 main"
|
||||
- separator
|
||||
- menuitem "确认合并"
|
||||
@@ -22,21 +22,43 @@ const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/s
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const PULL = 'https://github.com/octo/demo/pull/7'
|
||||
/** A draft change request that the double lets the panel mark ready and merge. */
|
||||
const DRAFT = 'https://github.com/octo/demo/pull/9'
|
||||
/** Message sent after the worktree is removed, which the composer keeps. */
|
||||
const FOLLOW_UP = 'Summarize the failed check'
|
||||
const run = promisify(execFile)
|
||||
|
||||
/**
|
||||
* GitHub CLI double: signed in to github.com; GraphQL serves pull request 7 of
|
||||
* octo/demo with one failed and one passed check run, one unresolved thread,
|
||||
* and a required review. Every invocation is appended to `calls.jsonl`.
|
||||
* GitHub CLI double: signed in to github.com, with octo/demo allowing every
|
||||
* merge method and the viewer's usual one squash. GraphQL serves pull request
|
||||
* 7 with one failed and one passed check run, one unresolved thread, and a
|
||||
* required review, and pull request 9, an approved draft whose checks passed.
|
||||
* `gh pr ready 9` and the REST merge of pull request 9 change what later
|
||||
* queries report, through `state.json`. Every invocation is appended to
|
||||
* `calls.jsonl`.
|
||||
*/
|
||||
const GH = `#!/usr/bin/env node
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const args = process.argv.slice(2)
|
||||
appendFileSync(join(dirname(fileURLToPath(import.meta.url)), 'calls.jsonl'), JSON.stringify(args) + '\\n')
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n')
|
||||
const statePath = join(here, 'state.json')
|
||||
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null }
|
||||
const save = () => { writeFileSync(statePath, JSON.stringify(state)) }
|
||||
if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') {
|
||||
state.ready = true
|
||||
save()
|
||||
process.stderr.write('Pull request octo/demo#9 is marked as "ready for review"\\n')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') {
|
||||
state.mergedAt = new Date().toISOString()
|
||||
save()
|
||||
process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
process.stdout.write('github.com\\n Logged in to github.com account octo\\n')
|
||||
process.exit(0)
|
||||
@@ -48,21 +70,35 @@ if (args[0] === 'api' && args[1] === 'graphql') {
|
||||
detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt,
|
||||
checkSuite: { workflowRun: { workflow: { name: 'CI' } } },
|
||||
})
|
||||
const pull = number => number !== 7 ? null : {
|
||||
number, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
|
||||
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40),
|
||||
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', reviewDecision: 'REVIEW_REQUIRED',
|
||||
reviewThreads: { nodes: [{ isResolved: false }] },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')],
|
||||
} } } }] },
|
||||
const pulls = {
|
||||
7: {
|
||||
number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
|
||||
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40),
|
||||
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED',
|
||||
reviewThreads: { nodes: [{ isResolved: false }] },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')],
|
||||
} } } }] },
|
||||
},
|
||||
9: {
|
||||
number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
|
||||
isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' },
|
||||
headRefName: 'feat/merge', headRefOid: 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'SUCCESS', contexts: {
|
||||
pageInfo: { hasNextPage: false }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')],
|
||||
} } } }] },
|
||||
},
|
||||
}
|
||||
const settings = {
|
||||
squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH',
|
||||
}
|
||||
const data = {}
|
||||
let repository
|
||||
for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) {
|
||||
if (match[1] !== undefined) data[repository = match[1]] = {}
|
||||
else data[repository][match[2]] = pull(Number(match[3]))
|
||||
if (match[1] !== undefined) data[repository = match[1]] = { ...settings }
|
||||
else data[repository][match[2]] = pulls[Number(match[3])] ?? null
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ data }))
|
||||
process.exit(0)
|
||||
@@ -76,8 +112,8 @@ async function git(cwd: string, ...args: string[]): Promise<string> {
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and a fresh Session whose worktree option chose `release`. */
|
||||
interface ReleaseRequest {
|
||||
/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and the New Session screen of that workspace. */
|
||||
interface PanelLaunch {
|
||||
readonly scaffold: WebScaffold
|
||||
readonly page: Page
|
||||
/** Temporary directory of the GitHub CLI double and its call log. */
|
||||
@@ -88,11 +124,11 @@ interface ReleaseRequest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the bundle against a GitHub checkout of `main` and `release`, open a fresh Session, and choose the worktree option from `release`.
|
||||
* Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace.
|
||||
* @param failureShot - name of the screenshot saved when the test fails.
|
||||
* @returns the scaffold, page, and checkout.
|
||||
*/
|
||||
async function requestReleaseWorktree(failureShot: string): Promise<ReleaseRequest> {
|
||||
async function launchPanel(failureShot: string): Promise<PanelLaunch> {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-'))
|
||||
onTestFinished(async () => {
|
||||
@@ -136,14 +172,24 @@ async function requestReleaseWorktree(failureShot: string): Promise<ReleaseReque
|
||||
onTestFailed(async () => { await saveFailureShot(page, failureShot) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
return { scaffold, page, root, project, tripwire }
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the panel and choose the worktree option of the fresh Session from `release`.
|
||||
* @param failureShot - name of the screenshot saved when the test fails.
|
||||
* @returns the scaffold, page, and checkout.
|
||||
*/
|
||||
async function requestReleaseWorktree(failureShot: string): Promise<PanelLaunch> {
|
||||
const launched = await launchPanel(failureShot)
|
||||
const { page } = launched
|
||||
const option = page.locator('[data-forge-worktree]')
|
||||
await option.waitFor({ timeout: 20_000 })
|
||||
await option.getByRole('checkbox', { name: '工作树', exact: true }).check()
|
||||
await option.getByRole('button', { name: '基于 main', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'release', exact: true }).click()
|
||||
await option.getByRole('button', { name: '基于 release', exact: true }).waitFor()
|
||||
return { scaffold, page, root, project, tripwire }
|
||||
return launched
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,8 +270,9 @@ it.skipIf(MODE === 'record')('starts a Session in a worktree before its first me
|
||||
await title.waitFor({ timeout: 20_000 })
|
||||
// The only open change request starts expanded.
|
||||
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
|
||||
// The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations.
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'),
|
||||
(await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd)).replaceAll(branch, 'dsh-{{worktree}}'), MODE)
|
||||
await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
|
||||
const checksRow = change.getByRole('button', { name: /^检查/ })
|
||||
await checksRow.click()
|
||||
const checks = page.getByRole('menu')
|
||||
@@ -329,3 +376,41 @@ it.skipIf(MODE === 'record')('returns the first message to the New Session compo
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('marks a draft change request ready for review and merges it with the usual method', async () => {
|
||||
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-panel-merge')
|
||||
const { settled } = await sendPrompt(page, scaffold)
|
||||
const sessionId = await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const session = scaffold.ctx.sessions.get(sessionId)
|
||||
if (session === undefined) throw new Error('The Session must stay loaded')
|
||||
|
||||
scaffold.ctx.forgeSession.track(session, DRAFT)
|
||||
const header = page.locator('[data-forge-header]')
|
||||
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求')
|
||||
await header.click()
|
||||
const change = page.locator(`[data-forge-change="${DRAFT}"]`)
|
||||
await change.getByRole('link', { name: 'Add the merge button', exact: true }).waitFor({ timeout: 20_000 })
|
||||
|
||||
// The only open change request starts expanded, so its action is in view.
|
||||
await change.getByRole('button', { name: '标记为可评审', exact: true }).click()
|
||||
await page.getByRole('alert').filter({ hasText: '已将 #9 标记为可评审' }).waitFor()
|
||||
const merge = change.getByRole('button', { name: '合并', exact: true })
|
||||
await merge.waitFor({ timeout: 20_000 })
|
||||
await merge.click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.getByText('将 #9 合并到 main', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'merge-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||||
await menu.getByRole('menuitem', { name: '确认合并', exact: true }).click()
|
||||
await page.getByRole('alert').filter({ hasText: '已合并 #9' }).waitFor()
|
||||
// Merged, the change request is no longer open, so its card collapses to the merge line.
|
||||
await change.getByText(/^已合并到 main · /).waitFor({ timeout: 20_000 })
|
||||
|
||||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||||
expect(calls).toContainEqual(['pr', 'ready', '9', '--repo', 'github.com/octo/demo'])
|
||||
expect(calls).toContainEqual([
|
||||
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/9/merge', '-f', 'merge_method=squash', '-f', `sha=${'b'.repeat(40)}`,
|
||||
])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
@@ -1086,7 +1086,7 @@ export type Config = CuaConfig
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-forge`
|
||||
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:195`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
|
||||
@@ -1088,7 +1088,7 @@ export type Config = CuaConfig
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-forge`
|
||||
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:195`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
/forges/status-polling:
|
||||
en: 26ccfa2e342e3e67
|
||||
zh: fe6cc2a622757c48
|
||||
/forges/change-request-actions:
|
||||
en: 00e4490e416099d2
|
||||
zh: 5724d08b6ae230f7
|
||||
/forges/session-bindings:
|
||||
en: ad28bdce5c5d4d49
|
||||
zh: 1acd042350634970
|
||||
/forges/panel:
|
||||
en: 76bc9afcf36e3df2
|
||||
zh: 673b61562b150c95
|
||||
en: 74cd0e6c843a608c
|
||||
zh: ff44433e118b4007
|
||||
@@ -55,6 +55,10 @@ interface ForgeProvider {
|
||||
findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
/** Report whether the provider can reach `host` with current credentials. */
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
/** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */
|
||||
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -99,6 +103,13 @@ interface ChangeRequestSnapshot {
|
||||
readonly deletions: number
|
||||
/** Merge conflict status; `unknown` while the host computes it. */
|
||||
readonly mergeability: Mergeability
|
||||
/**
|
||||
* Whether the host would merge the change request now for the signed-in
|
||||
* user: open, not a draft, and meeting the base branch's merge rules.
|
||||
*/
|
||||
readonly mergeReady: boolean
|
||||
/** Merge methods the repository accepts, the signed-in user's usual one first. */
|
||||
readonly mergeMethods: readonly MergeMethod[]
|
||||
/** Aggregate review outcome. */
|
||||
readonly reviewDecision: ReviewDecision
|
||||
/** Checks on the head commit in host order. */
|
||||
@@ -140,6 +151,33 @@ type ChangeRequestUpdate = {
|
||||
type ChangeRequestListener = (update: ChangeRequestUpdate) => void
|
||||
```
|
||||
|
||||
## Change request actions
|
||||
|
||||
`ctx.forge.markReady` marks a draft ready for review, and `ctx.forge.merge` merges a change request only while its head is still the commit the user confirmed. Both resolve to a `ForgeActionFailure` instead of throwing when the host refuses, and poll a watched change request at once afterwards. A snapshot's `mergeReady` and `mergeMethods` tell a consumer when to offer a merge and with which methods; the [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub answers both.
|
||||
|
||||
```ts type-equiv
|
||||
/** A merge the user confirmed for the head commit they saw. */
|
||||
interface MergeRequest {
|
||||
/** How the commits enter the base branch. */
|
||||
readonly method: MergeMethod
|
||||
/** Commit object name the head must still have; the merge is refused once the head moves. */
|
||||
readonly headSha: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Why a provider could not change a change request. */
|
||||
interface ForgeActionFailure {
|
||||
/**
|
||||
* `unauthenticated`: credentials are missing or rejected; `stale`: the head
|
||||
* moved past the confirmed commit; `failed`: the host refused or could not be reached.
|
||||
*/
|
||||
readonly code: 'unauthenticated' | 'stale' | 'failed'
|
||||
/** Human-readable cause, usually the host's own message. */
|
||||
readonly message: string
|
||||
}
|
||||
```
|
||||
|
||||
## Session bindings
|
||||
|
||||
`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection stays on the Host. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules.
|
||||
@@ -188,7 +226,7 @@ type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequ
|
||||
|
||||
## Panel
|
||||
|
||||
`ctx.forgePanel` is the Host Remote of the [forge panel](../../packages/experimental/client-ui-forge/README.md#use-this-package). Following a Session streams a `ForgePanelState`: a read-only Git inspection of the Session's current directory in its execution world, the code host recognized from the upstream remote or `origin`, the sign-in state of that host, the Session's bindings with their polled status and last snapshot, and its worktree request. Its actions list base branches, bind and hide URLs, refresh, request or withdraw a worktree, stop a running creation, and restore a removed worktree or leave it for the source checkout. A request is a `plugin:client-ui-forge/worktree` record. The panel listens to [Agent admission](../../packages/core/agent/README.md): it holds the first message of a Session with an open request while it creates the worktree through `ctx.worktrees`, refuses messages while the worktree the Session works in is gone, and keeps the last failed creation in the panel state. Each refusal carries a `ForgeAdmissionRefusal` reason, and the panel presents it while it follows the Session. The first step proposal creates the worktree for a message that entered without admission. Requests after the first turn starts are ignored. Changes to a Session's checkout run as maintenance of its Agent, so a message from another client waits for them.
|
||||
`ctx.forgePanel` is the Host Remote of the [forge panel](../../packages/experimental/client-ui-forge/README.md#use-this-package). Following a Session streams a `ForgePanelState`: a read-only Git inspection of the Session's current directory in its execution world, the code host recognized from the upstream remote or `origin`, the sign-in state of that host, the Session's bindings with their polled status and last snapshot, and its worktree request. Its actions list base branches, bind and hide URLs, refresh, mark a bound draft ready or merge a bound change request, request or withdraw a worktree, stop a running creation, and restore a removed worktree or leave it for the source checkout. A request is a `plugin:client-ui-forge/worktree` record. The panel listens to [Agent admission](../../packages/core/agent/README.md): it holds the first message of a Session with an open request while it creates the worktree through `ctx.worktrees`, refuses messages while the worktree the Session works in is gone, and keeps the last failed creation in the panel state. Each refusal carries a `ForgeAdmissionRefusal` reason, and the panel presents it while it follows the Session. The first step proposal creates the worktree for a message that entered without admission. Requests after the first turn starts are ignored. Changes to a Session's checkout run as maintenance of its Agent, so a message from another client waits for them.
|
||||
|
||||
```ts type-equiv
|
||||
/** Git facts for the checkout that contains the Session's current directory. */
|
||||
@@ -438,6 +476,26 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna
|
||||
*/
|
||||
async readiness(repository: ForgeRepository, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
|
||||
/**
|
||||
* Mark a draft change request ready for review, then poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host accepted the change, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
|
||||
/**
|
||||
* Merge a change request while its head is still `request.headSha`, then
|
||||
* poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param request - merge method and the head commit the user confirmed.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host merged it, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
|
||||
/**
|
||||
* Watch one change request as an effect on the calling fiber. The first
|
||||
* watcher starts polling at once; listeners receive every later result,
|
||||
@@ -518,6 +576,31 @@ Serves the forge panel. Following a Session inspects the checkout of its current
|
||||
*/
|
||||
@Remote async refresh(session: Session, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Mark a draft change request of a Session ready for review; the change
|
||||
* request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
|
||||
*/
|
||||
@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Merge a change request of a Session while its head is still `headSha`;
|
||||
* the change request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param method - how the commits enter the base branch.
|
||||
* @param headSha - head commit the user confirmed.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
|
||||
* reason when the host refuses otherwise or cannot be reached.
|
||||
*/
|
||||
@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Ask for a new worktree before the Session's first turn, replacing an open
|
||||
* request and forgetting a failed attempt. Admission of the next message, or
|
||||
|
||||
@@ -55,6 +55,10 @@ interface ForgeProvider {
|
||||
findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
/** Report whether the provider can reach `host` with current credentials. */
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
/** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */
|
||||
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -99,6 +103,13 @@ interface ChangeRequestSnapshot {
|
||||
readonly deletions: number
|
||||
/** Merge conflict status; `unknown` while the host computes it. */
|
||||
readonly mergeability: Mergeability
|
||||
/**
|
||||
* Whether the host would merge the change request now for the signed-in
|
||||
* user: open, not a draft, and meeting the base branch's merge rules.
|
||||
*/
|
||||
readonly mergeReady: boolean
|
||||
/** Merge methods the repository accepts, the signed-in user's usual one first. */
|
||||
readonly mergeMethods: readonly MergeMethod[]
|
||||
/** Aggregate review outcome. */
|
||||
readonly reviewDecision: ReviewDecision
|
||||
/** Checks on the head commit in host order. */
|
||||
@@ -140,6 +151,33 @@ type ChangeRequestUpdate = {
|
||||
type ChangeRequestListener = (update: ChangeRequestUpdate) => void
|
||||
```
|
||||
|
||||
## 变更请求操作
|
||||
|
||||
`ctx.forge.markReady` 把草稿标记为可评审,`ctx.forge.merge` 只在 head 仍是用户确认的提交时合并变更请求。主机拒绝时,两者都 resolve 为 `ForgeActionFailure` 而不是抛错,并在结束后立即轮询被监听的变更请求。快照的 `mergeReady` 与 `mergeMethods` 告诉消费方何时提供合并、可用哪些合并方式;GitHub 如何给出这两项由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。
|
||||
|
||||
```ts type-equiv
|
||||
/** A merge the user confirmed for the head commit they saw. */
|
||||
interface MergeRequest {
|
||||
/** How the commits enter the base branch. */
|
||||
readonly method: MergeMethod
|
||||
/** Commit object name the head must still have; the merge is refused once the head moves. */
|
||||
readonly headSha: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Why a provider could not change a change request. */
|
||||
interface ForgeActionFailure {
|
||||
/**
|
||||
* `unauthenticated`: credentials are missing or rejected; `stale`: the head
|
||||
* moved past the confirmed commit; `failed`: the host refused or could not be reached.
|
||||
*/
|
||||
readonly code: 'unauthenticated' | 'stale' | 'failed'
|
||||
/** Human-readable cause, usually the host's own message. */
|
||||
readonly message: string
|
||||
}
|
||||
```
|
||||
|
||||
## Session 绑定
|
||||
|
||||
`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影只位于宿主侧。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。
|
||||
@@ -188,7 +226,7 @@ type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequ
|
||||
|
||||
## 面板
|
||||
|
||||
`ctx.forgePanel` 是 [forge 面板](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package)的宿主 Remote。跟随一个 Session 时,它以流的形式推送 `ForgePanelState`:在 Session 执行环境中对其当前目录进行的只读 Git 检查、从上游远端或 `origin` 识别出的代码托管平台、该平台的登录状态、Session 的绑定及其轮询状态与最近的快照,以及它的 worktree 请求。它的操作可以列出基准分支、绑定和隐藏 URL、刷新、请求或撤回 worktree、停止正在进行的创建,以及恢复已删除的 worktree 或离开它回到源检出。请求是一条 `plugin:client-ui-forge/worktree` 记录。面板监听 [Agent 消息接纳](../../packages/core/agent/README.zh.md):Session 有未完成的请求时,它在通过 `ctx.worktrees` 创建 worktree 期间暂留第一条消息;Session 所在的 worktree 已被删除时拒绝消息;最近一次失败的创建保留在面板状态中。每次拒绝都带有一个 `ForgeAdmissionRefusal` 原因,面板跟随该 Session 时由它自行呈现。对于未经接纳就进入的消息,由第一个步骤提议创建 worktree。第一轮开始之后的请求会被忽略。对 Session 检出的改动以其 Agent 的维护任务运行,因此其他客户端发来的消息会等它们完成。
|
||||
`ctx.forgePanel` 是 [forge 面板](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package)的宿主 Remote。跟随一个 Session 时,它以流的形式推送 `ForgePanelState`:在 Session 执行环境中对其当前目录进行的只读 Git 检查、从上游远端或 `origin` 识别出的代码托管平台、该平台的登录状态、Session 的绑定及其轮询状态与最近的快照,以及它的 worktree 请求。它的操作可以列出基准分支、绑定和隐藏 URL、刷新、把已绑定的草稿标记为可评审或合并已绑定的变更请求、请求或撤回 worktree、停止正在进行的创建,以及恢复已删除的 worktree 或离开它回到源检出。请求是一条 `plugin:client-ui-forge/worktree` 记录。面板监听 [Agent 消息接纳](../../packages/core/agent/README.zh.md):Session 有未完成的请求时,它在通过 `ctx.worktrees` 创建 worktree 期间暂留第一条消息;Session 所在的 worktree 已被删除时拒绝消息;最近一次失败的创建保留在面板状态中。每次拒绝都带有一个 `ForgeAdmissionRefusal` 原因,面板跟随该 Session 时由它自行呈现。对于未经接纳就进入的消息,由第一个步骤提议创建 worktree。第一轮开始之后的请求会被忽略。对 Session 检出的改动以其 Agent 的维护任务运行,因此其他客户端发来的消息会等它们完成。
|
||||
|
||||
```ts type-equiv
|
||||
/** Git facts for the checkout that contains the Session's current directory. */
|
||||
@@ -438,6 +476,26 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna
|
||||
*/
|
||||
async readiness(repository: ForgeRepository, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
|
||||
/**
|
||||
* Mark a draft change request ready for review, then poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host accepted the change, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
|
||||
/**
|
||||
* Merge a change request while its head is still `request.headSha`, then
|
||||
* poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param request - merge method and the head commit the user confirmed.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host merged it, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
|
||||
/**
|
||||
* Watch one change request as an effect on the calling fiber. The first
|
||||
* watcher starts polling at once; listeners receive every later result,
|
||||
@@ -518,6 +576,31 @@ Serves the forge panel. Following a Session inspects the checkout of its current
|
||||
*/
|
||||
@Remote async refresh(session: Session, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Mark a draft change request of a Session ready for review; the change
|
||||
* request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
|
||||
*/
|
||||
@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Merge a change request of a Session while its head is still `headSha`;
|
||||
* the change request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param method - how the commits enter the base branch.
|
||||
* @param headSha - head commit the user confirmed.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
|
||||
* reason when the host refuses otherwise or cannot be reached.
|
||||
*/
|
||||
@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void>
|
||||
|
||||
/**
|
||||
* Ask for a new worktree before the Session's first turn, replacing an open
|
||||
* request and forgetting a failed attempt. Admission of the next message, or
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/use-this-package:
|
||||
en: 739cff9dfe8f7ca8
|
||||
zh: 42f90d0e593d10fc
|
||||
en: 937f7bf02214a879
|
||||
zh: 607a4c1c484ce5a6
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
|
||||
en: 614b851471fe2bb8
|
||||
zh: 53b1980c15257322
|
||||
en: c83e02ad583c2c2e
|
||||
zh: 7fe667249eb88f10
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
|
||||
en: fa4d8c579ffc49ff
|
||||
zh: 5856ed7469e4585b
|
||||
|
||||
@@ -32,7 +32,7 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com
|
||||
- **Branch label** — the Session header shows the branch the Session works on, or the abbreviated commit on a detached HEAD; its tooltip names the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory, and its path with the Host home shortened to `~`.
|
||||
- **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and the Host returns each message to the composer until the worktree is restored or left. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; Continue in the project moves the Session to the source checkout. The branch label shows the warning tone meanwhile.
|
||||
- **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts.
|
||||
- **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, and the kind of checkout as the branch label does; for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches; then, for an open change request, the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines. It offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again. A banner shows the `gh auth login` command when sign-in is missing.
|
||||
- **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, and the kind of checkout as the branch label does; for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches; then, for an open change request, the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines. It offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and merges the head the card shows after Confirm merge. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again. A banner shows the `gh auth login` command when sign-in is missing.
|
||||
|
||||
`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, and `worktreePrefix` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge).
|
||||
|
||||
@@ -48,7 +48,7 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com
|
||||
|
||||
Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=<maxBranches>`. None reads the working tree. Every command runs under the Session's read-only sandbox policy with optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches disabled and the inherited Git selectors and secret-bearing variables removed, within its own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level, Git, and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records; a checkout whose Git directory differs from the common one is a linked worktree. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy.
|
||||
|
||||
A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` and `cancelWorktree` refuse with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; both forget a failed attempt. An `agent-controller/admission` listener holds a message of a Session whose request is open while one attempt with a new branch name runs as maintenance of the Session's Agent, so a message from another client waits for it; it publishes the running stage in the panel state with `held` set, records `created` with the source repository root, and then lets the message enter. A failed attempt stays in the panel state as `failure`, with its branch, base, failed step, and reason, until another attempt starts or the request changes, and the message is refused with `forge/worktree-creation-failed`. `stopCreation` and the Agent's cancellation refuse it with `forge/worktree-creation-stopped`, and the sender's cancellation passes through. Each message of a Session that works in its created worktree is refused with `forge/worktree-missing` while the worktree is missing; a check that cannot answer lets it pass. A refusal is marked `presented` while a panel follows the Session. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message that entered without admission; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` also run as maintenance. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`.
|
||||
A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` and `cancelWorktree` refuse with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; both forget a failed attempt. An `agent-controller/admission` listener holds a message of a Session whose request is open while one attempt with a new branch name runs as maintenance of the Session's Agent, so a message from another client waits for it; it publishes the running stage in the panel state with `held` set, records `created` with the source repository root, and then lets the message enter. A failed attempt stays in the panel state as `failure`, with its branch, base, failed step, and reason, until another attempt starts or the request changes, and the message is refused with `forge/worktree-creation-failed`. `stopCreation` and the Agent's cancellation refuse it with `forge/worktree-creation-stopped`, and the sender's cancellation passes through. Each message of a Session that works in its created worktree is refused with `forge/worktree-missing` while the worktree is missing; a check that cannot answer lets it pass. A refusal is marked `presented` while a panel follows the Session. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message that entered without admission; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` also run as maintenance. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. `markReady` and `merge` act only on a visible binding of the Session that a provider serves, else they refuse with `forgePanel/not-bound`; `merge` passes the head commit the card showed, and a moved head refuses with `forgePanel/stale-head`, while any other refusal is `forgePanel/action-failed` with the host's reason.
|
||||
|
||||
Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ kind: "package-reference"
|
||||
- **分支标签**——Session 标题栏显示 Session 所在的分支,分离 HEAD 时显示缩写提交;它的提示说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录)以及路径,宿主主目录缩写为 `~`。
|
||||
- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;在恢复或离开该 worktree 之前,宿主会把每条消息退回输入框。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支。“在项目目录继续”把 Session 移回源检出。此期间分支标签显示警示色。
|
||||
- **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。
|
||||
- **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),并像分支标签一样说明检出的类型;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支;打开的变更请求接着显示检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是改动行数。卡片提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求。缺少登录时,横幅显示 `gh auth login` 命令。
|
||||
- **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),并像分支标签一样说明检出的类型;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支;打开的变更请求接着显示检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是改动行数。卡片提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,点“确认合并”后合并卡片显示的那个 head。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求。缺少登录时,横幅显示 `gh auth login` 命令。
|
||||
|
||||
`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches` 与 `worktreePrefix` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。
|
||||
|
||||
@@ -48,7 +48,7 @@ kind: "package-reference"
|
||||
|
||||
检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=<maxBranches>`。这些命令都不读取工作树。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。
|
||||
|
||||
worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 与 `cancelWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;两者都会忘掉失败的尝试。一个 `agent-controller/admission` 监听器在 Session 有未完成请求时暂留消息,同时以 Session 所属 Agent 的维护任务运行一次尝试,使用新分支名,因此其他客户端此时发来的消息会等它完成;它在面板状态中发布正在进行的阶段并设置 `held`,记录带源仓库根目录的 `created`,然后放行消息。失败的尝试以 `failure` 留在面板状态中,带有分支、基准、失败步骤与原因,直到下一次尝试开始或请求变更,消息则以 `forge/worktree-creation-failed` 被拒绝。`stopCreation` 与 Agent 的取消以 `forge/worktree-creation-stopped` 拒绝消息,发送方的取消原样传出。在所创建 worktree 中工作的 Session,其每条消息在 worktree 缺失时以 `forge/worktree-missing` 被拒绝;检查无法给出结果时放行。有面板跟随该 Session 时,拒绝标记为 `presented`。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖未经接纳就进入的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。`restoreWorktree` 与 `continueInProject` 同样以维护任务运行。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。
|
||||
worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 与 `cancelWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;两者都会忘掉失败的尝试。一个 `agent-controller/admission` 监听器在 Session 有未完成请求时暂留消息,同时以 Session 所属 Agent 的维护任务运行一次尝试,使用新分支名,因此其他客户端此时发来的消息会等它完成;它在面板状态中发布正在进行的阶段并设置 `held`,记录带源仓库根目录的 `created`,然后放行消息。失败的尝试以 `failure` 留在面板状态中,带有分支、基准、失败步骤与原因,直到下一次尝试开始或请求变更,消息则以 `forge/worktree-creation-failed` 被拒绝。`stopCreation` 与 Agent 的取消以 `forge/worktree-creation-stopped` 拒绝消息,发送方的取消原样传出。在所创建 worktree 中工作的 Session,其每条消息在 worktree 缺失时以 `forge/worktree-missing` 被拒绝;检查无法给出结果时放行。有面板跟随该 Session 时,拒绝标记为 `presented`。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖未经接纳就进入的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。`restoreWorktree` 与 `continueInProject` 同样以维护任务运行。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。`markReady` 与 `merge` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;`merge` 带上卡片显示的 head 提交,head 已变化时以 `forgePanel/stale-head` 拒绝,其他拒绝都是带主机原因的 `forgePanel/action-failed`。
|
||||
|
||||
每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* One change request as a card: its title links to the host, a one-line
|
||||
* summary shows while collapsed, and the checks, review, merge state, and
|
||||
* diff size show while expanded.
|
||||
* summary shows while collapsed, and the checks, review, merge state, diff
|
||||
* size, and the ready-for-review or merge action show while expanded.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Menu, MenuItemButton, StateDot, Tooltip, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChangeRequestCheck, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type {
|
||||
ChangeRequestCheck, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, MergeMethod, MergeRequest,
|
||||
} from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { ForgePanelChangeRequest } from '../types.ts'
|
||||
import { formatAgo, formatDuration } from './format.ts'
|
||||
import { ChangeRequestGlyph, CheckGlyph } from './glyphs.tsx'
|
||||
@@ -21,7 +23,7 @@ import css from './ForgeTab.module.css'
|
||||
|
||||
type Translate = PropsLocale<typeof NS>['t']
|
||||
|
||||
/** What a card can do; a surface without a Session binding omits `remove`. */
|
||||
/** What a card can do; a surface without a Session binding omits `remove`, `markReady`, and `merge`. */
|
||||
export interface ChangeRequestActions {
|
||||
/** Open a URL outside the app. */
|
||||
readonly openLink: (url: string) => void
|
||||
@@ -31,6 +33,10 @@ export interface ChangeRequestActions {
|
||||
readonly retry: () => void
|
||||
/** Remove the change request from its Session. */
|
||||
readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined
|
||||
/** Mark a draft ready for review; settles once the outcome is reported. */
|
||||
readonly markReady?: ((entry: ForgePanelChangeRequest) => Promise<void>) | undefined
|
||||
/** Merge the change request as `request` describes; settles once the outcome is reported. */
|
||||
readonly merge?: ((entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>) | undefined
|
||||
}
|
||||
|
||||
/** Props of one change request card. */
|
||||
@@ -215,6 +221,100 @@ function ChecksRow({ snapshot, openLink, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** The merge button and its menu: a title row, one row per method with the chosen one checked, and the confirmation. */
|
||||
function MergeMenu({ snapshot, name, usual, busy, onMerge, t }: {
|
||||
readonly snapshot: ChangeRequestSnapshot
|
||||
readonly name: string
|
||||
readonly usual: MergeMethod
|
||||
readonly busy: boolean
|
||||
readonly onMerge: (method: MergeMethod) => void
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [chosen, setChosen] = useState<string>(usual)
|
||||
// The methods can change between polls; a choice the repository no longer allows falls back to the usual one.
|
||||
const method = snapshot.mergeMethods.find(entry => entry === chosen) ?? usual
|
||||
const close = (): void => { setOpen(false) }
|
||||
const base = snapshot.baseBranch
|
||||
const items: MenuEntry[] = [
|
||||
{ type: 'label', id: 'title', text: t('change.mergeTitle', { name, base }) },
|
||||
...snapshot.mergeMethods.map(entry => ({
|
||||
id: entry,
|
||||
label: (
|
||||
<span className={css.method}>
|
||||
<span>{t(`change.mergeMethod.${entry}`)}</span>
|
||||
<span className={css.methodNote}>{t(`change.mergeNote.${entry}`, { base })}</span>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={close}
|
||||
portal
|
||||
align="end"
|
||||
anchor={(
|
||||
<Button size="sm" variant="primary" aria-haspopup="menu" aria-expanded={open} disabled={busy} onClick={() => { setOpen(!open) }}>
|
||||
{t('change.merge')}
|
||||
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</Button>
|
||||
)}
|
||||
items={items}
|
||||
selectedId={method}
|
||||
onSelect={setChosen}
|
||||
>
|
||||
<MenuItemButton
|
||||
separatorBefore
|
||||
onSelect={() => {
|
||||
close()
|
||||
onMerge(method)
|
||||
}}
|
||||
>
|
||||
{t('change.mergeConfirm')}
|
||||
</MenuItemButton>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
/** The footer action of an open change request: mark a draft ready, or merge one the host would merge now. */
|
||||
function Primary({ entry, snapshot, name, actions, t }: {
|
||||
readonly entry: ForgePanelChangeRequest
|
||||
readonly snapshot: ChangeRequestSnapshot
|
||||
readonly name: string
|
||||
readonly actions: ChangeRequestActions
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
// One request at a time: the action stays disabled until the previous one settles.
|
||||
const [busy, setBusy] = useState(false)
|
||||
const run = (action: () => Promise<void>): void => {
|
||||
setBusy(true)
|
||||
void action().finally(() => { setBusy(false) })
|
||||
}
|
||||
const { markReady, merge } = actions
|
||||
if (snapshot.state !== 'open') return null
|
||||
if (snapshot.draft) {
|
||||
if (markReady === undefined) return null
|
||||
return (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => { run(() => markReady(entry)) }}>
|
||||
{t('change.markReady')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const [usual] = snapshot.mergeMethods
|
||||
if (!snapshot.mergeReady || usual === undefined || merge === undefined) return null
|
||||
return (
|
||||
<MergeMenu
|
||||
snapshot={snapshot}
|
||||
name={name}
|
||||
usual={usual}
|
||||
busy={busy}
|
||||
onMerge={(method) => { run(() => merge(entry, { method, headSha: snapshot.headSha })) }}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Details({ snapshot, now, actions, t }: {
|
||||
readonly snapshot: ChangeRequestSnapshot
|
||||
readonly now: number
|
||||
@@ -336,6 +436,7 @@ export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t }
|
||||
<IconCopyOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{snapshot !== null && <Primary entry={entry} snapshot={snapshot} name={name} actions={actions} t={t} />}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -489,6 +489,19 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* A merge method row: its name with the note on what it does below. */
|
||||
.method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.methodNote {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.titleIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { MergeRequest } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type { ForgePanelChangeRequest, ForgePanelState } from '../types.ts'
|
||||
@@ -26,6 +27,10 @@ export interface ForgeTabInjected {
|
||||
readonly refresh: () => void
|
||||
/** Remove a change request from the Session, offering to undo it. */
|
||||
readonly remove: (entry: ForgePanelChangeRequest) => void
|
||||
/** Mark a draft ready for review; settles once the outcome is reported. */
|
||||
readonly markReady: (entry: ForgePanelChangeRequest) => Promise<void>
|
||||
/** Merge a change request as `request` describes; settles once the outcome is reported. */
|
||||
readonly merge: (entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>
|
||||
/** Check the removed worktree of the Session out again, reporting the outcome. */
|
||||
readonly restore: () => void
|
||||
/** Open a URL outside the app. */
|
||||
@@ -116,7 +121,7 @@ function SignIn({ state, copyText, t }: {
|
||||
* @returns the tab body.
|
||||
*/
|
||||
export function ForgeTab({
|
||||
useTabInfo, usePanel, useHost, reconnect, refresh, remove, restore, openLink, copyText, reportVisible, t,
|
||||
useTabInfo, usePanel, useHost, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, reportVisible, t,
|
||||
}: ForgeTabProps): ReactNode {
|
||||
const snapshot = usePanel(current => current)
|
||||
const home = useHost(facts => facts.home)
|
||||
@@ -134,7 +139,7 @@ export function ForgeTab({
|
||||
</div>
|
||||
)
|
||||
if (state === null) return <div className={css.centered}>{disconnected || <StateDot state="ongoing" />}</div>
|
||||
const actions: ChangeRequestActions = { openLink, copyLink: copyText, retry: refresh, remove }
|
||||
const actions: ChangeRequestActions = { openLink, copyLink: copyText, retry: refresh, remove, markReady, merge }
|
||||
const expanded = chosen ?? defaultExpanded(state.changeRequests)
|
||||
const toggle = (url: string): void => {
|
||||
const next = new Set(expanded)
|
||||
|
||||
@@ -69,6 +69,16 @@ export const zh = {
|
||||
'change.merge.conflicting': '与 {base} 有冲突',
|
||||
'change.merge.unknown': '正在检查冲突',
|
||||
'change.mergedTo': '已合并到 {base}',
|
||||
'change.markReady': '标记为可评审',
|
||||
'change.merge': '合并',
|
||||
'change.mergeTitle': '将 {name} 合并到 {base}',
|
||||
'change.mergeMethod.squash': '压缩合并',
|
||||
'change.mergeMethod.merge': '创建合并提交',
|
||||
'change.mergeMethod.rebase': '变基合并',
|
||||
'change.mergeNote.squash': '把全部提交压缩成一个提交',
|
||||
'change.mergeNote.merge': '保留全部提交,并新增一个合并提交',
|
||||
'change.mergeNote.rebase': '把提交逐个变基到 {base}',
|
||||
'change.mergeConfirm': '确认合并',
|
||||
'time.now': '刚刚',
|
||||
'time.minutes': '{n} 分钟前',
|
||||
'time.hours': '{n} 小时前',
|
||||
@@ -122,6 +132,11 @@ export const zh = {
|
||||
'toast.restored': '工作树已恢复',
|
||||
'toast.restoreFailed': '无法恢复工作树:{message}',
|
||||
'toast.leaveFailed': '无法切换到项目目录,请稍后重试',
|
||||
'toast.markedReady': '已将 {name} 标记为可评审',
|
||||
'toast.markReadyFailed': '标记失败:{message}',
|
||||
'toast.merged': '已合并 {name}',
|
||||
'toast.mergeFailed': '合并失败:{message}',
|
||||
'toast.mergeStale': '{name} 有新的提交,确认后再合并',
|
||||
} as const
|
||||
|
||||
/** Key of the forge panel dictionaries. */
|
||||
@@ -193,6 +208,16 @@ export const en: Record<ForgePanelKey, string> = {
|
||||
'change.merge.conflicting': 'Conflicts with {base}',
|
||||
'change.merge.unknown': 'Checking for conflicts',
|
||||
'change.mergedTo': 'Merged into {base}',
|
||||
'change.markReady': 'Ready for review',
|
||||
'change.merge': 'Merge',
|
||||
'change.mergeTitle': 'Merge {name} into {base}',
|
||||
'change.mergeMethod.squash': 'Squash and merge',
|
||||
'change.mergeMethod.merge': 'Create a merge commit',
|
||||
'change.mergeMethod.rebase': 'Rebase and merge',
|
||||
'change.mergeNote.squash': 'Combine every commit into one',
|
||||
'change.mergeNote.merge': 'Keep every commit and add a merge commit',
|
||||
'change.mergeNote.rebase': 'Replay each commit onto {base}',
|
||||
'change.mergeConfirm': 'Confirm merge',
|
||||
'time.now': 'just now',
|
||||
'time.minutes': '{n} min ago',
|
||||
'time.hours': '{n} h ago',
|
||||
@@ -246,6 +271,11 @@ export const en: Record<ForgePanelKey, string> = {
|
||||
'toast.restored': 'Worktree restored',
|
||||
'toast.restoreFailed': 'The worktree could not be restored: {message}',
|
||||
'toast.leaveFailed': 'Could not switch to the project directory; try again',
|
||||
'toast.markedReady': 'Marked {name} ready for review',
|
||||
'toast.markReadyFailed': 'Could not mark it ready: {message}',
|
||||
'toast.merged': 'Merged {name}',
|
||||
'toast.mergeFailed': 'Could not merge: {message}',
|
||||
'toast.mergeStale': '{name} has new commits; review them before merging',
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
@@ -83,13 +83,17 @@ function registerUi(ctx: Context): void {
|
||||
() => { notices.report('toast.copyFailed') },
|
||||
)
|
||||
}
|
||||
/** How toasts name a change request: its number, else its title, else its URL. */
|
||||
const nameOf = (entry: ForgePanelChangeRequest): string => {
|
||||
const number = numberOf(entry)
|
||||
return number === null ? entry.snapshot?.title ?? entry.url : t('change.number', { number })
|
||||
}
|
||||
const remove = (sessionId: SessionId, entry: ForgePanelChangeRequest): void => {
|
||||
void ctx.remote.forgePanel.dismiss(sessionId, entry.url).then((result) => {
|
||||
if (!settled(result, 'toast.removeFailed')) return
|
||||
const number = numberOf(entry)
|
||||
notices.report('toast.removed', {
|
||||
tone: 'success',
|
||||
params: { name: number === null ? entry.snapshot?.title ?? entry.url : t('change.number', { number }) },
|
||||
params: { name: nameOf(entry) },
|
||||
// Restoring needs a provider that recognizes the URL; an unrecognized one is removed without undo.
|
||||
...entry.ref === null ? {} : {
|
||||
undo: () => {
|
||||
@@ -118,6 +122,17 @@ function registerUi(ctx: Context): void {
|
||||
void ctx.remote.forgePanel.refresh(sessionId).then(result => settled(result, 'toast.refreshFailed'))
|
||||
},
|
||||
remove: (entry) => { remove(sessionId, entry) },
|
||||
markReady: async (entry) => {
|
||||
const result = await ctx.remote.forgePanel.markReady(sessionId, entry.url)
|
||||
if (result.ok) notices.report('toast.markedReady', { tone: 'success', params: { name: nameOf(entry) } })
|
||||
else notices.report('toast.markReadyFailed', { params: { message: result.error.message } })
|
||||
},
|
||||
merge: async (entry, request) => {
|
||||
const result = await ctx.remote.forgePanel.merge(sessionId, entry.url, request.method, request.headSha)
|
||||
if (result.ok) notices.report('toast.merged', { tone: 'success', params: { name: nameOf(entry) } })
|
||||
else if (result.error.code === 'forgePanel/stale-head') notices.report('toast.mergeStale', { params: { name: nameOf(entry) } })
|
||||
else notices.report('toast.mergeFailed', { params: { message: result.error.message } })
|
||||
},
|
||||
restore: () => {
|
||||
void restore(sessionId).then((message) => {
|
||||
if (message !== undefined) notices.report('toast.restoreFailed', { params: { message } })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AdmissionDecision } from '@deepseek-ai/dsh-agent-controller'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type { ChangeRequestRef, ForgeActionFailure, MergeMethod } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-worktree'
|
||||
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
|
||||
@@ -175,6 +175,41 @@ export class ForgePanelController extends TypertRemoteService {
|
||||
this.panels.get(session)?.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a draft change request of a Session ready for review; the change
|
||||
* request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
|
||||
*/
|
||||
@Remote
|
||||
async markReady(session: Session, url: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
const failure = await this.home.forge.markReady(this.boundRef(session, url), signal)
|
||||
if (failure !== undefined) throw actionError(failure)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a change request of a Session while its head is still `headSha`;
|
||||
* the change request is polled again once the host answers.
|
||||
* @param session - Session the change request is bound to.
|
||||
* @param url - binding URL of the change request.
|
||||
* @param method - how the commits enter the base branch.
|
||||
* @param headSha - head commit the user confirmed.
|
||||
* @param signal - request cancellation.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
|
||||
* reason when the host refuses otherwise or cannot be reached.
|
||||
*/
|
||||
@Remote
|
||||
async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
const failure = await this.home.forge.merge(this.boundRef(session, url), { method, headSha }, signal)
|
||||
if (failure !== undefined) throw actionError(failure)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a new worktree before the Session's first turn, replacing an open
|
||||
* request and forgetting a failed attempt. Admission of the next message, or
|
||||
@@ -306,6 +341,13 @@ export class ForgePanelController extends TypertRemoteService {
|
||||
: refusal('forge/worktree-missing', 'The worktree this Session works in was removed; restore it before sending', presented)
|
||||
}
|
||||
|
||||
/** The provider reference of a visible binding of the Session, which actions are limited to. */
|
||||
private boundRef(session: Session, url: string): ChangeRequestRef {
|
||||
const ref = this.home.forgeSession.changeRequests(session).find(entry => entry.url === url)?.ref ?? null
|
||||
if (ref === null) throw new RemoteError('forgePanel/not-bound', 'The change request is not bound to this Session or no provider serves it', {})
|
||||
return ref
|
||||
}
|
||||
|
||||
private worktreeState(session: Session): ForgeWorktreeState {
|
||||
return this.worktreeStates.stateOf(session)
|
||||
}
|
||||
@@ -357,6 +399,15 @@ export class ForgePanelController extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed change request action, separating a head that moved since the user confirmed it.
|
||||
* @param failure - the provider's failure.
|
||||
* @returns the Remote error to throw.
|
||||
*/
|
||||
function actionError(failure: ForgeActionFailure): RemoteError {
|
||||
return new RemoteError(failure.code === 'stale' ? 'forgePanel/stale-head' : 'forgePanel/action-failed', failure.message, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `prefix` followed by hexadecimal digits is a branch name that Git
|
||||
* accepts: no leading `-` or `/`, no empty component, no component that
|
||||
|
||||
@@ -215,6 +215,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
'forgePanel/session-busy': Record<string, never>
|
||||
/** The removed worktree could not be checked out again; the message gives the reason. */
|
||||
'forgePanel/restore-failed': Record<string, never>
|
||||
/** The URL is not a visible binding of the Session that a provider serves, so the panel does not act on it. */
|
||||
'forgePanel/not-bound': Record<string, never>
|
||||
/** The change request has new commits since the head the user confirmed, so it was not merged. */
|
||||
'forgePanel/stale-head': Record<string, never>
|
||||
/** The code host refused the change or could not be reached; the message gives the host's reason. */
|
||||
'forgePanel/action-failed': Record<string, never>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/** One change request card: state glyph, title link, collapsed summary, read notices, and expanded details and actions. */
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ForgeFailure } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { ForgeFailure, MergeRequest } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import { ChangeRequestCard, type ChangeRequestActions } from '../src/client/ChangeRequestCard.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import css from '../src/client/ForgeTab.module.css'
|
||||
@@ -368,3 +368,124 @@ describe('expanded ChangeRequestCard', () => {
|
||||
expect(within(b.head).getByRole('button', { name: en['change.view'] })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
/** A promise that settles when the test says so. */
|
||||
function deferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {
|
||||
let resolve = (): void => {}
|
||||
const promise = new Promise<void>((settle) => { resolve = settle })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
/** Each row of the open merge menu, and whether it carries the selection check. */
|
||||
function mergeRows(): string[] {
|
||||
return screen.getAllByRole('menuitem').map(row => `${row.textContent ?? ''}${row.querySelector('svg') === null ? '' : ' ✓'}`)
|
||||
}
|
||||
|
||||
describe('ChangeRequestCard actions', () => {
|
||||
const head = 'b'.repeat(40)
|
||||
|
||||
it('marks a draft ready for review, refusing a second request until the first settles', async () => {
|
||||
const pending = deferred()
|
||||
const markReady = vi.fn<(entry: ForgePanelChangeRequest) => Promise<void>>(() => pending.promise)
|
||||
const value = entry(7, snapshot(7, { draft: true, mergeReady: true }))
|
||||
const b = card(value, { expanded: true, actions: { markReady } })
|
||||
const button = within(b.item).getByRole('button', { name: en['change.markReady'] })
|
||||
expect(within(b.item).queryByRole('button', { name: en['change.merge'] })).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(markReady).toHaveBeenCalledOnce()
|
||||
expect(markReady).toHaveBeenCalledWith(value)
|
||||
expect(button.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve()
|
||||
await pending.promise
|
||||
})
|
||||
expect(button.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('lists the methods the repository allows with the usual one chosen, and merges the confirmed head with the chosen one', async () => {
|
||||
const pending = deferred()
|
||||
const merge = vi.fn<(entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>>(() => pending.promise)
|
||||
const value = entry(7, snapshot(7, { mergeReady: true, mergeMethods: ['merge', 'squash', 'rebase'], headSha: head }))
|
||||
const b = card(value, { expanded: true, actions: { merge } })
|
||||
const button = within(b.item).getByRole('button', { name: en['change.merge'] })
|
||||
expect(within(b.item).queryByRole('button', { name: en['change.markReady'] })).toBeNull()
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(within(screen.getByRole('menu')).getByText('Merge #7 into main')).toBeTruthy()
|
||||
expect(mergeRows()).toEqual([
|
||||
'Create a merge commitKeep every commit and add a merge commit ✓',
|
||||
'Squash and mergeCombine every commit into one',
|
||||
'Rebase and mergeReplay each commit onto main',
|
||||
'Confirm merge',
|
||||
])
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^Rebase and merge/ }))
|
||||
expect(mergeRows().map(row => row.endsWith(' ✓'))).toEqual([false, false, true, false])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Confirm merge' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(merge).toHaveBeenCalledWith(value, { method: 'rebase', headSha: head })
|
||||
expect(button.hasAttribute('disabled')).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve()
|
||||
await pending.promise
|
||||
})
|
||||
expect(button.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(merge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to the usual method once the repository stops allowing the chosen one', async () => {
|
||||
const merge = vi.fn<(entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>>(async () => {})
|
||||
const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), merge }
|
||||
const view = (methods: MergeRequest['method'][]) => (
|
||||
<ul>
|
||||
<ChangeRequestCard
|
||||
entry={entry(7, snapshot(7, { mergeReady: true, mergeMethods: methods, headSha: head }))}
|
||||
expanded
|
||||
onToggle={vi.fn()}
|
||||
now={NOW}
|
||||
actions={actions}
|
||||
t={t}
|
||||
/>
|
||||
</ul>
|
||||
)
|
||||
const rendered = render(view(['squash', 'rebase']))
|
||||
fireEvent.click(within(rendered.container).getByRole('button', { name: en['change.merge'] }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /^Rebase and merge/ }))
|
||||
rendered.rerender(view(['squash']))
|
||||
|
||||
expect(mergeRows()).toEqual(['Squash and mergeCombine every commit into one ✓', 'Confirm merge'])
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Confirm merge' }))
|
||||
})
|
||||
expect(merge).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://github.com/octo/demo/pull/7' }), { method: 'squash', headSha: head })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the host would not merge it now', { mergeReady: false }],
|
||||
['the repository allows no merge method', { mergeReady: true, mergeMethods: [] }],
|
||||
['it is merged', { state: 'merged', mergeReady: true }],
|
||||
['it is a closed draft', { state: 'closed', draft: true }],
|
||||
] as const)('offers neither action when %s', (_label, overrides) => {
|
||||
const b = card(entry(7, snapshot(7, overrides)), { expanded: true })
|
||||
expect(within(b.item).queryByRole('button', { name: en['change.markReady'] })).toBeNull()
|
||||
expect(within(b.item).queryByRole('button', { name: en['change.merge'] })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers neither action on a surface without a Session binding', () => {
|
||||
const actions = { markReady: undefined, merge: undefined }
|
||||
for (const value of [entry(7, snapshot(7, { draft: true })), entry(8, snapshot(8, { mergeReady: true }))]) {
|
||||
const b = card(value, { expanded: true, actions })
|
||||
expect(within(b.item).queryAllByRole('button', { name: new RegExp(`^(${en['change.markReady']}|${en['change.merge']})$`) })).toEqual([])
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,10 @@ export function ref(number: number): ChangeRequestRef {
|
||||
return { repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number }
|
||||
}
|
||||
|
||||
/** Open, mergeable change request from `feat` into `main` that adds 12 lines, deletes 3, and lists no checks. */
|
||||
/**
|
||||
* Open change request from `feat` into `main` without conflicts that adds 12
|
||||
* lines, deletes 3, lists no checks, and that the host would not merge yet.
|
||||
*/
|
||||
export function snapshot(number: number, overrides: Partial<ChangeRequestSnapshot> = {}): ChangeRequestSnapshot {
|
||||
return {
|
||||
ref: ref(number),
|
||||
@@ -29,6 +32,8 @@ export function snapshot(number: number, overrides: Partial<ChangeRequestSnapsho
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
mergeReady: false,
|
||||
mergeMethods: ['squash', 'merge', 'rebase'],
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
|
||||
@@ -1128,6 +1128,48 @@ describe('forgePanel actions', () => {
|
||||
expect(opened.provider.probes).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a bound change request ready and merges it through its provider, reporting what the host refused', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
||||
const follower = reader(opened)
|
||||
await follower.until(state => ready(state) && polled(state, pullUrl(7)))
|
||||
const head = 'a'.repeat(40)
|
||||
|
||||
await opened.ctx.forgePanel.markReady(opened.session, pullUrl(7), signal)
|
||||
await opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'squash', head, signal)
|
||||
opened.provider.actionResult = { code: 'stale', message: 'Head branch was modified' }
|
||||
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'rebase', head, signal))
|
||||
.rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'Head branch was modified' })
|
||||
opened.provider.actionResult = { code: 'unauthenticated', message: 'Bad credentials' }
|
||||
await expect(opened.ctx.forgePanel.markReady(opened.session, pullUrl(7), signal))
|
||||
.rejects.toMatchObject({ code: 'forgePanel/action-failed', message: 'Bad credentials' })
|
||||
opened.provider.actionResult = { code: 'failed', message: 'Pull Request is not mergeable' }
|
||||
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(7), 'merge', head, signal))
|
||||
.rejects.toMatchObject({ code: 'forgePanel/action-failed', message: 'Pull Request is not mergeable' })
|
||||
|
||||
expect(opened.provider.actions).toEqual(['ready 7', `merge 7 squash ${head}`, `merge 7 rebase ${head}`, 'ready 7', `merge 7 merge ${head}`])
|
||||
})
|
||||
|
||||
it('acts only on change requests visibly bound to the Session that a provider serves', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
shellTurn(opened.session, 1, [
|
||||
{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` },
|
||||
{ callId: 'other', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' },
|
||||
])
|
||||
const follower = reader(opened)
|
||||
await follower.until(state => ready(state) && polled(state, pullUrl(7)))
|
||||
await opened.ctx.forgePanel.dismiss(opened.session, pullUrl(7), signal)
|
||||
const head = 'a'.repeat(40)
|
||||
|
||||
for (const url of [pullUrl(7), pullUrl(8), 'https://unserved.test/a/b/pull/3']) {
|
||||
await expect(opened.ctx.forgePanel.markReady(opened.session, url, signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
||||
await expect(opened.ctx.forgePanel.merge(opened.session, url, 'merge', head, signal)).rejects.toMatchObject({ code: 'forgePanel/not-bound' })
|
||||
}
|
||||
expect(opened.provider.actions).toEqual([])
|
||||
})
|
||||
|
||||
it('lists the base branches of the Session\'s current checkout, at most maxBranches', async () => {
|
||||
const { repo } = await project()
|
||||
await commitAt(repo, 'main.txt', '2026-03-01T00:00:00Z')
|
||||
@@ -1156,6 +1198,8 @@ describe('forgePanel actions', () => {
|
||||
await expect(opened.ctx.forgePanel.track(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.dismiss(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.refresh(opened.session, controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.markReady(opened.session, pullUrl(1), controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.merge(opened.session, pullUrl(1), 'merge', 'a'.repeat(40), controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.branches(opened.session, controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.requestWorktree(opened.session, null, controller.signal)).rejects.toThrow('closed')
|
||||
await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, controller.signal)).rejects.toThrow('closed')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Forge tab body: connection states, the checkout row, the sign-in banner, the change request list, and tab visibility. */
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { MergeRequest } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { PaneId, SidebarRightTabInfo, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -44,6 +45,8 @@ function tab(state: ForgePanelState | null, options: { disconnected?: boolean; v
|
||||
const reconnect = vi.fn<() => void>()
|
||||
const refresh = vi.fn<() => void>()
|
||||
const remove = vi.fn<(entry: ForgePanelChangeRequest) => void>()
|
||||
const markReady = vi.fn<(entry: ForgePanelChangeRequest) => Promise<void>>(async () => {})
|
||||
const merge = vi.fn<(entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>>(async () => {})
|
||||
const restore = vi.fn<() => void>()
|
||||
const openLink = vi.fn<(url: string) => void>()
|
||||
const copyText = vi.fn<(text: string) => void>()
|
||||
@@ -52,11 +55,11 @@ function tab(state: ForgePanelState | null, options: { disconnected?: boolean; v
|
||||
useTabInfo: () => tabInfo(useVisibility(current => current)),
|
||||
usePanel: bindSnapshotSelector(panel),
|
||||
useHost: bindSnapshotSelector(host),
|
||||
reconnect, refresh, remove, restore, openLink, copyText, reportVisible,
|
||||
reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, reportVisible,
|
||||
t,
|
||||
}
|
||||
const view = render(<ForgeTab {...props} />)
|
||||
return { view, panel, visibility, reconnect, refresh, remove, restore, openLink, copyText, reportVisible }
|
||||
return { view, panel, visibility, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, reportVisible }
|
||||
}
|
||||
|
||||
/** The checkout row, or null when the tab shows none. */
|
||||
@@ -256,6 +259,25 @@ describe('ForgeTab change requests', () => {
|
||||
expect(b.refresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('wires ready for review and merge to the tab actions', async () => {
|
||||
const draft = entry(7, snapshot(7, { draft: true }))
|
||||
const mergeable = entry(8, snapshot(8, { mergeReady: true, headSha: 'b'.repeat(40) }))
|
||||
const b = tab(ready({ changeRequests: [draft, mergeable] }))
|
||||
// Two open change requests both start collapsed.
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Expand #7' }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Expand #8' }))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['change.markReady'] }))
|
||||
})
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['change.merge'] }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en['change.mergeConfirm'] }))
|
||||
})
|
||||
expect(b.markReady).toHaveBeenCalledWith(draft)
|
||||
expect(b.merge).toHaveBeenCalledWith(mergeable, { method: 'squash', headSha: 'b'.repeat(40) })
|
||||
})
|
||||
|
||||
it('dates a finished change request from the current time', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: Date.parse('2026-10-09T12:00:00Z') })
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,8 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import ForgeService, { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness,
|
||||
ForgeRepository, MergeRequest,
|
||||
} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import ForgeSessionService from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import WorktreeService from '@deepseek-ai/dsh-experimental-worktree'
|
||||
@@ -224,6 +225,8 @@ export function snapshot(ref: ChangeRequestRef): ChangeRequestSnapshot {
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
mergeReady: true,
|
||||
mergeMethods: ['squash', 'merge', 'rebase'],
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
@@ -242,6 +245,10 @@ export class FakeProvider implements ForgeProvider {
|
||||
/** Polling failures returned instead of snapshots, by change request number. */
|
||||
readonly failures = new Map<number, ForgeFailure>()
|
||||
readinessImpl: (host: string, signal: AbortSignal) => Promise<ForgeReadiness> = () => Promise.resolve({ state: 'ready', message: null })
|
||||
/** Every action in call order, such as `ready 7` or `merge 7 squash <sha>`. */
|
||||
readonly actions: string[] = []
|
||||
/** Outcome of the next actions; undefined reports success. */
|
||||
actionResult: ForgeActionFailure | undefined
|
||||
|
||||
repositoryFromRemote(url: string): ForgeRepository | undefined {
|
||||
const [, host = '', owner = '', name = ''] = /^https:\/\/([a-z.]+\.test)\/([^/]+)\/([^/]+?)\.git$/.exec(url) ?? []
|
||||
@@ -270,6 +277,16 @@ export class FakeProvider implements ForgeProvider {
|
||||
this.probes.push(host)
|
||||
return this.readinessImpl(host, signal)
|
||||
}
|
||||
|
||||
markReady(ref: ChangeRequestRef): Promise<ForgeActionFailure | undefined> {
|
||||
this.actions.push(`ready ${ref.number}`)
|
||||
return Promise.resolve(this.actionResult)
|
||||
}
|
||||
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest): Promise<ForgeActionFailure | undefined> {
|
||||
this.actions.push(`merge ${ref.number} ${request.method} ${request.headSha}`)
|
||||
return Promise.resolve(this.actionResult)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,6 +63,8 @@ const REFUSED = {
|
||||
busy: { ok: false, error: new RemoteError('forgePanel/session-busy', 'busy', {}) },
|
||||
restore: { ok: false, error: new RemoteError('forgePanel/restore-failed', 'fatal: \'/work/repo-dsh\' already exists', {}) },
|
||||
failed: { ok: false, error: new RemoteError('gateway/bad-request', 'failed', {}) },
|
||||
stale: { ok: false, error: new RemoteError('forgePanel/stale-head', 'Head branch was modified', {}) },
|
||||
refused: { ok: false, error: new RemoteError('forgePanel/action-failed', 'Pull Request is not mergeable', {}) },
|
||||
} satisfies Record<string, RemoteResult<never>>
|
||||
|
||||
function isTabFace(value: Record<string, unknown>): value is Record<string, unknown> & ForgeTabInjected {
|
||||
@@ -116,6 +118,8 @@ async function base() {
|
||||
}),
|
||||
track: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
|
||||
dismiss: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
|
||||
markReady: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
|
||||
merge: vi.fn(async (_sessionId: SessionId, _url: string, _method: string, _headSha: string): Promise<RemoteResult<void>> => OK),
|
||||
refresh: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
requestWorktree: vi.fn(async (_sessionId: SessionId, _from: string | null): Promise<RemoteResult<void>> => OK),
|
||||
cancelWorktree: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
@@ -429,6 +433,33 @@ describe('mountForgePanel removal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel change request actions', () => {
|
||||
it.each([
|
||||
['confirms it', OK, { key: 'toast.markedReady', tone: 'success', params: { name: '#7' } }],
|
||||
['reports why the host refused', REFUSED.refused, { key: 'toast.markReadyFailed', tone: 'failure', params: { message: 'Pull Request is not mergeable' } }],
|
||||
])('marks a draft ready through the Remote and %s', async (_label, result, notice) => {
|
||||
const f = await fixture()
|
||||
f.forgePanel.markReady.mockResolvedValueOnce(result)
|
||||
await f.tab(S1).markReady(entry(7, snapshot(7, { draft: true })))
|
||||
expect(f.forgePanel.markReady).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7')
|
||||
expect(f.notice()).toMatchObject(notice)
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['confirms it', OK, { key: 'toast.merged', tone: 'success', params: { name: '#7' } }],
|
||||
['says a moved head needs another look', REFUSED.stale, { key: 'toast.mergeStale', tone: 'failure', params: { name: '#7' } }],
|
||||
['reports why the host refused', REFUSED.refused, { key: 'toast.mergeFailed', tone: 'failure', params: { message: 'Pull Request is not mergeable' } }],
|
||||
])('merges through the Remote with the confirmed head and %s', async (_label, result, notice) => {
|
||||
const f = await fixture()
|
||||
f.forgePanel.merge.mockResolvedValueOnce(result)
|
||||
await f.tab(S1).merge(entry(7, snapshot(7)), { method: 'rebase', headSha: 'b'.repeat(40) })
|
||||
expect(f.forgePanel.merge).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7', 'rebase', 'b'.repeat(40))
|
||||
expect(f.notice()).toMatchObject(notice)
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel worktree actions', () => {
|
||||
it('requests and withdraws a worktree through the Remote', async () => {
|
||||
const f = await fixture()
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge-github/use-this-package:
|
||||
en: 20589262c0245cc0
|
||||
zh: 1573f236a9bf840c
|
||||
en: 5e4ee251ce79172f
|
||||
zh: f613dd87e0acd9ec
|
||||
/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation:
|
||||
en: 194951c8b5093f7d
|
||||
zh: 9c5ffaa9e531bd1f
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 26b946af4b742e17
|
||||
zh: 00b34f0721ac72f6
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work:
|
||||
en: 441928a7cc59da3b
|
||||
zh: 96496453ce349cad
|
||||
en: b69656c7c346cba7
|
||||
zh: d96e72e6c16dd828
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -40,6 +40,8 @@ The provider serves a remote URL in `https`, `http`, `ssh`, or `git` form, or in
|
||||
|
||||
Check runs map to `pending` until completed, then to `success`, `failure` (failure, timed out, startup failure, or action required), `cancelled`, `skipped`, or `neutral`. Check runs keep their start and completion times. Commit status contexts map `SUCCESS` to `success`, `FAILURE` and `ERROR` to `failure`, and every other state to `pending`, and carry no times. A GraphQL `NOT_FOUND` error becomes a `not-found` failure. When `gh` prints no JSON, a diagnostic that mentions authentication or `gh auth login` becomes `unauthenticated` and any other diagnostic becomes `unavailable`.
|
||||
|
||||
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`.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -88,6 +90,7 @@ None. Provider output reaches only host-side watchers.
|
||||
- **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.
|
||||
- **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>
|
||||
### Dev Note
|
||||
|
||||
@@ -40,6 +40,8 @@ kind: "package-reference"
|
||||
|
||||
检查运行(check run)在完成前映射为 `pending`,完成后映射为 `success`、`failure`(失败、超时、启动失败或需要操作)、`cancelled`、`skipped` 或 `neutral`。检查运行保留其开始与完成时间。提交状态上下文把 `SUCCESS` 映射为 `success`,把 `FAILURE` 与 `ERROR` 映射为 `failure`,其余状态均映射为 `pending`,且不带时间。GraphQL 的 `NOT_FOUND` 错误转为 `not-found` 失败。`gh` 没有输出 JSON 时,提到身份验证或 `gh auth login` 的诊断转为 `unauthenticated`,其他诊断转为 `unavailable`。
|
||||
|
||||
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`。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -88,6 +90,7 @@ kind: "package-reference"
|
||||
- **前 100 个检查与评审讨论**——head 提交的检查超过上限时以 `checksTruncated` 标记;前 100 个之后的未解决讨论不计入。
|
||||
- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。
|
||||
- **速率限制**——每次轮询都会以已登录账号的速率配额为每个主机消耗一次 GraphQL 查询。
|
||||
- **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {
|
||||
ChangeRequestCheck, ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, ForgeRepository,
|
||||
Mergeability, ReviewDecision,
|
||||
Mergeability, MergeMethod, ReviewDecision,
|
||||
} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { z } from 'zod'
|
||||
import { isSegment } from './urls.ts'
|
||||
import { checkedRepository } from './urls.ts'
|
||||
|
||||
const PULL_REQUEST_FIELDS = `number url title state isDraft updatedAt closedAt
|
||||
author { login }
|
||||
headRefName headRefOid baseRefName additions deletions mergeable reviewDecision
|
||||
headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision
|
||||
reviewThreads(first: 100) { nodes { isResolved } }
|
||||
commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 100) {
|
||||
pageInfo { hasNextPage }
|
||||
@@ -21,6 +21,8 @@ const PULL_REQUEST_FIELDS = `number url title state isDraft updatedAt closedAt
|
||||
}
|
||||
} } } } }`
|
||||
|
||||
const REPOSITORY_FIELDS = 'squashMergeAllowed mergeCommitAllowed rebaseMergeAllowed viewerPermission viewerDefaultMergeMethod'
|
||||
|
||||
/** Head-branch lookup; variables `owner`, `name`, and `branch` are passed as strings. */
|
||||
export const HEAD_QUERY = `query($owner: String!, $name: String!, $branch: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
@@ -63,6 +65,7 @@ const pullRequestSchema = z.object({
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
mergeable: z.string(),
|
||||
mergeStateStatus: z.string(),
|
||||
reviewDecision: z.string().nullable(),
|
||||
reviewThreads: z.object({ nodes: z.array(z.object({ isResolved: z.boolean() })) }),
|
||||
commits: z.object({
|
||||
@@ -77,6 +80,14 @@ const pullRequestSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
const repositorySchema = z.object({
|
||||
squashMergeAllowed: z.boolean(),
|
||||
mergeCommitAllowed: z.boolean(),
|
||||
rebaseMergeAllowed: z.boolean(),
|
||||
viewerPermission: z.string().nullable(),
|
||||
viewerDefaultMergeMethod: z.string(),
|
||||
})
|
||||
|
||||
const responseSchema = z.object({
|
||||
data: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).nullable().optional(),
|
||||
errors: z.array(z.object({
|
||||
@@ -96,6 +107,24 @@ const headSchema = z.object({
|
||||
})
|
||||
|
||||
type PullRequestNode = z.infer<typeof pullRequestSchema>
|
||||
type RepositoryNode = z.infer<typeof repositorySchema>
|
||||
|
||||
/** GitHub merge states in which the merge button works: mergeable, with passing or non-required failing checks. */
|
||||
const MERGEABLE_NOW: ReadonlySet<string> = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE'])
|
||||
|
||||
/** Repository permissions that may merge. */
|
||||
const MERGERS: ReadonlySet<string> = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
|
||||
|
||||
/** Merge methods in menu order, with the repository setting that allows each and GitHub's name for it. */
|
||||
const METHODS: readonly {
|
||||
readonly method: MergeMethod
|
||||
readonly allowed: 'squashMergeAllowed' | 'mergeCommitAllowed' | 'rebaseMergeAllowed'
|
||||
readonly name: string
|
||||
}[] = [
|
||||
{ method: 'squash', allowed: 'squashMergeAllowed', name: 'SQUASH' },
|
||||
{ method: 'merge', allowed: 'mergeCommitAllowed', name: 'MERGE' },
|
||||
{ method: 'rebase', allowed: 'rebaseMergeAllowed', name: 'REBASE' },
|
||||
]
|
||||
|
||||
/** One batched snapshot query and the change request behind each alias. */
|
||||
export interface SnapshotQuery {
|
||||
@@ -115,9 +144,7 @@ export interface SnapshotQuery {
|
||||
export function snapshotQuery(refs: readonly ChangeRequestRef[]): SnapshotQuery {
|
||||
const repositories = new Map<string, { repository: ForgeRepository; numbers: Map<number, ChangeRequestRef> }>()
|
||||
for (const ref of refs) {
|
||||
const { owner, name } = ref.repository
|
||||
if (!isSegment(owner) || !isSegment(name)) throw new Error(`forge-github: invalid repository ${JSON.stringify(`${owner}/${name}`)}`)
|
||||
if (!Number.isSafeInteger(ref.number) || ref.number <= 0) throw new Error(`forge-github: invalid pull request number ${ref.number}`)
|
||||
const { owner, name } = checkedRepository(ref)
|
||||
const id = `${owner}/${name}`.toLowerCase()
|
||||
let entry = repositories.get(id)
|
||||
if (entry === undefined) {
|
||||
@@ -134,7 +161,8 @@ export function snapshotQuery(refs: readonly ChangeRequestRef[]): SnapshotQuery
|
||||
pulls.push(`p${p}: pullRequest(number: ${number}) { ...ChangeRequestFields }`)
|
||||
targets.set(`r${r}.p${p}`, ref)
|
||||
}
|
||||
fields.push(`r${r}: repository(owner: ${JSON.stringify(repository.owner)}, name: ${JSON.stringify(repository.name)}) { ${pulls.join(' ')} }`)
|
||||
const owner = JSON.stringify(repository.owner)
|
||||
fields.push(`r${r}: repository(owner: ${owner}, name: ${JSON.stringify(repository.name)}) { ${REPOSITORY_FIELDS} ${pulls.join(' ')} }`)
|
||||
}
|
||||
return { document: `query { ${fields.join(' ')} }\nfragment ChangeRequestFields on PullRequest { ${PULL_REQUEST_FIELDS} }`, targets }
|
||||
}
|
||||
@@ -142,7 +170,8 @@ export function snapshotQuery(refs: readonly ChangeRequestRef[]): SnapshotQuery
|
||||
/**
|
||||
* Map a snapshot response, including GitHub's partial-error form, into one
|
||||
* result per alias target. Path-scoped errors apply to their repository or
|
||||
* pull request; errors without a path apply to every unresolved target.
|
||||
* pull request; errors without a path apply to every unresolved target. A
|
||||
* repository without readable merge settings offers no merge.
|
||||
* @param body - parsed JSON printed by `gh api graphql`.
|
||||
* @param query - the query that produced the response.
|
||||
* @returns a snapshot or failure for every target.
|
||||
@@ -162,7 +191,8 @@ export function mapSnapshotResponse(body: unknown, query: SnapshotQuery): Map<Ch
|
||||
?? errors.find(entry => entry.path === undefined)
|
||||
const raw = parsed.data.data?.[repositoryAlias]?.[pullAlias]
|
||||
const node = pullRequestSchema.safeParse(raw)
|
||||
if (node.success) results.set(key, toSnapshot(ref, node.data))
|
||||
const settings = repositorySchema.safeParse(parsed.data.data?.[repositoryAlias])
|
||||
if (node.success) results.set(key, toSnapshot(ref, node.data, settings.success ? settings.data : undefined))
|
||||
else if (error !== undefined) results.set(key, { code: error.type === 'NOT_FOUND' ? 'not-found' : 'unavailable', message: error.message })
|
||||
else if (raw === null || raw === undefined) results.set(key, { code: 'not-found', message: 'pull request not found' })
|
||||
else results.set(key, { code: 'unavailable', message: 'unexpected pull request fields' })
|
||||
@@ -187,7 +217,7 @@ export function mapHeadResponse(body: unknown, repository: ForgeRepository, bran
|
||||
return found.pullRequests.nodes.filter(node => !node.isCrossRepository).map(node => ({ repository, number: node.number }))
|
||||
}
|
||||
|
||||
function toSnapshot(ref: ChangeRequestRef, node: PullRequestNode): ChangeRequestSnapshot {
|
||||
function toSnapshot(ref: ChangeRequestRef, node: PullRequestNode, settings: RepositoryNode | undefined): ChangeRequestSnapshot {
|
||||
const rollup = node.commits.nodes[0]?.commit.statusCheckRollup ?? null
|
||||
const checks: ChangeRequestCheck[] = []
|
||||
for (const context of rollup?.contexts.nodes ?? []) {
|
||||
@@ -228,6 +258,9 @@ function toSnapshot(ref: ChangeRequestRef, node: PullRequestNode): ChangeRequest
|
||||
additions: node.additions,
|
||||
deletions: node.deletions,
|
||||
mergeability: mergeability(node.mergeable),
|
||||
mergeReady: settings !== undefined && MERGERS.has(settings.viewerPermission ?? '') && node.state === 'OPEN' && !node.isDraft
|
||||
&& MERGEABLE_NOW.has(node.mergeStateStatus),
|
||||
mergeMethods: settings === undefined ? [] : mergeMethods(settings),
|
||||
reviewDecision: reviewDecision(node.reviewDecision),
|
||||
checks,
|
||||
checksRollup: checksRollup(rollup?.state),
|
||||
@@ -238,6 +271,13 @@ function toSnapshot(ref: ChangeRequestRef, node: PullRequestNode): ChangeRequest
|
||||
}
|
||||
}
|
||||
|
||||
/** The methods the repository allows, the viewer's default first. */
|
||||
function mergeMethods(settings: RepositoryNode): MergeMethod[] {
|
||||
const allowed = METHODS.filter(entry => settings[entry.allowed])
|
||||
const usual = allowed.filter(entry => entry.name === settings.viewerDefaultMergeMethod)
|
||||
return [...usual, ...allowed.filter(entry => !usual.includes(entry))].map(entry => entry.method)
|
||||
}
|
||||
|
||||
function changeRequestState(state: PullRequestNode['state']): ChangeRequestSnapshot['state'] {
|
||||
switch (state) {
|
||||
case 'OPEN': return 'open'
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness,
|
||||
ForgeRepository, MergeRequest,
|
||||
} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { GhUnavailableError } from './gh.ts'
|
||||
import type { GhRunner } from './gh.ts'
|
||||
import { HEAD_QUERY, mapHeadResponse, mapSnapshotResponse, snapshotQuery } from './graphql.ts'
|
||||
import { parsePullRequestUrl, parseRemoteUrl } from './urls.ts'
|
||||
import { checkedRepository, parsePullRequestUrl, parseRemoteUrl } from './urls.ts'
|
||||
|
||||
const AUTHENTICATION = /gh auth login|authentication|bad credentials|HTTP 401/i
|
||||
|
||||
/** Status code `gh api` appends to the message of a failed request. */
|
||||
const HTTP_STATUS = /\(HTTP (\d{3})\)/
|
||||
|
||||
/** A full SHA-1 or SHA-256 object name. */
|
||||
const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
|
||||
|
||||
/** Reads GitHub and GitHub Enterprise pull requests through a `gh` runner. */
|
||||
export class GitHubProvider implements ForgeProvider {
|
||||
readonly forge = 'github'
|
||||
@@ -68,6 +75,30 @@ export class GitHubProvider implements ForgeProvider {
|
||||
return mapHeadResponse(body, repository, branch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a draft pull request ready for review with `gh pr ready`; one that
|
||||
* is already ready stays ready.
|
||||
*/
|
||||
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
|
||||
const { host, owner, name } = checkedRepository(ref)
|
||||
return await this.act(['pr', 'ready', String(ref.number), '--repo', `${host}/${owner}/${name}`], signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a pull request through the REST merge endpoint with the expected
|
||||
* head commit, so GitHub refuses with 409 once new commits arrived. Unlike
|
||||
* `gh pr merge`, it never enables auto-merge, never joins a merge queue, and
|
||||
* never deletes the head branch.
|
||||
*/
|
||||
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
|
||||
const { host, owner, name } = checkedRepository(ref)
|
||||
if (!COMMIT.test(request.headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(request.headSha)}`)
|
||||
return await this.act([
|
||||
'api', '--hostname', host, '--method', 'PUT', `repos/${owner}/${name}/pulls/${ref.number}/merge`,
|
||||
'-f', `merge_method=${request.method}`, '-f', `sha=${request.headSha}`,
|
||||
], signal)
|
||||
}
|
||||
|
||||
async readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness> {
|
||||
try {
|
||||
const output = await this.gh(['auth', 'status', '--hostname', host], signal)
|
||||
@@ -79,6 +110,23 @@ export class GitHubProvider implements ForgeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one changing `gh` command; a refusal or a `gh` that cannot start becomes a failure. */
|
||||
private async act(args: readonly string[], signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
|
||||
let output
|
||||
try {
|
||||
output = await this.gh(args, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof GhUnavailableError) return { code: 'failed', message: error.message }
|
||||
throw error
|
||||
}
|
||||
if (output.exitCode === 0) return undefined
|
||||
const message = hostMessage(output.stdout) ?? firstLine(output.stderr) ?? `gh exited with ${output.exitCode}`
|
||||
const status = HTTP_STATUS.exec(output.stderr)?.[1]
|
||||
if (status === '409') return { code: 'stale', message }
|
||||
if (AUTHENTICATION.test(output.stderr)) return { code: 'unauthenticated', message }
|
||||
return { code: 'failed', message }
|
||||
}
|
||||
|
||||
private async fetchHost(
|
||||
host: string,
|
||||
refs: readonly ChangeRequestRef[],
|
||||
@@ -96,6 +144,13 @@ export class GitHubProvider implements ForgeProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/** The `message` of the JSON error body `gh api` prints for a failed request. */
|
||||
function hostMessage(stdout: string): string | undefined {
|
||||
const body = parseJson(stdout)
|
||||
if (typeof body !== 'object' || body === null || !('message' in body) || typeof body.message !== 'string') return undefined
|
||||
return body.message.trim().length > 0 ? body.message : undefined
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown {
|
||||
let value: unknown
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** GitHub remote URL and pull request URL parsing. */
|
||||
/** GitHub remote URL and pull request URL parsing, and validation of the parts inlined into requests. */
|
||||
|
||||
import type { ChangeRequestRef, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge'
|
||||
|
||||
/** Characters GitHub permits in owner and repository names. */
|
||||
const SEGMENT = /^[A-Za-z0-9_.-]+$/
|
||||
@@ -22,6 +24,20 @@ export function isSegment(value: string): boolean {
|
||||
return SEGMENT.test(value) && value !== '.' && value !== '..'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a pull request before its owner, name, and number are inlined into
|
||||
* a GraphQL document, a `gh` argument, or an API path.
|
||||
* @param ref - pull request on a GitHub host.
|
||||
* @returns its repository.
|
||||
* @throws when the owner or name is not a permitted segment or the number is not a positive integer.
|
||||
*/
|
||||
export function checkedRepository(ref: ChangeRequestRef): ForgeRepository {
|
||||
const { owner, name } = ref.repository
|
||||
if (!isSegment(owner) || !isSegment(name)) throw new Error(`forge-github: invalid repository ${JSON.stringify(`${owner}/${name}`)}`)
|
||||
if (!Number.isSafeInteger(ref.number) || ref.number <= 0) throw new Error(`forge-github: invalid pull request number ${ref.number}`)
|
||||
return ref.repository
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Git remote URL. Accepts `http(s)://[user@]host[:port]/owner/name`,
|
||||
* `ssh://[user@]host[:port]/owner/name`, `git://host/owner/name`, and the
|
||||
|
||||
@@ -27,6 +27,18 @@ export function statusContext(context: string, state: string): Record<string, un
|
||||
return { __typename: 'StatusContext', context, state, targetUrl: null }
|
||||
}
|
||||
|
||||
/** Merge settings of a repository that allows squash and merge commits and whose viewer may merge, usually with a merge commit. */
|
||||
export function repositorySettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
squashMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerPermission: 'WRITE',
|
||||
viewerDefaultMergeMethod: 'MERGE',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A pull request node with the fields the snapshot query selects. */
|
||||
export function pullRequestNode(number: number, overrides: Record<string, unknown> = {}, contexts: unknown[] = [], rollupState: string | null = 'SUCCESS'): Record<string, unknown> {
|
||||
return {
|
||||
@@ -44,6 +56,7 @@ export function pullRequestNode(number: number, overrides: Record<string, unknow
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'CLEAN',
|
||||
reviewDecision: null,
|
||||
reviewThreads: { nodes: [{ isResolved: true }, { isResolved: false }, { isResolved: false }] },
|
||||
commits: {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type { ChangeRequestSnapshot } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mapHeadResponse, mapSnapshotResponse, snapshotQuery } from '../src/graphql.ts'
|
||||
import { checkRun, demo, pull, pullRequestNode, statusContext } from './fixtures.ts'
|
||||
import { checkRun, demo, pull, pullRequestNode, repositorySettings, statusContext } from './fixtures.ts'
|
||||
|
||||
const other = { ...demo, name: 'other' }
|
||||
|
||||
function single(node: unknown): ChangeRequestSnapshot {
|
||||
/** Map one pull request node of a repository with `settings`, or without readable merge settings when it is null. */
|
||||
function single(node: unknown, settings: Record<string, unknown> | null = repositorySettings()): ChangeRequestSnapshot {
|
||||
const query = snapshotQuery([pull(1)])
|
||||
const result = mapSnapshotResponse({ data: { r0: { p0: node } } }, query).get(changeRequestKey(pull(1)))
|
||||
const result = mapSnapshotResponse({ data: { r0: { ...settings, p0: node } } }, query).get(changeRequestKey(pull(1)))
|
||||
if (result === undefined || 'code' in result) throw new Error(`expected a snapshot, received ${JSON.stringify(result)}`)
|
||||
return result
|
||||
}
|
||||
@@ -19,8 +20,9 @@ describe('snapshot query', () => {
|
||||
const query = snapshotQuery([pull(1), pull(2, upper), pull(1, upper), pull(7, other)])
|
||||
|
||||
expect([...query.targets]).toEqual([['r0.p0', pull(1)], ['r0.p1', pull(2, upper)], ['r1.p0', pull(7, other)]])
|
||||
expect(query.document).toContain('r0: repository(owner: "octo", name: "demo") { p0: pullRequest(number: 1) { ...ChangeRequestFields } p1: pullRequest(number: 2) { ...ChangeRequestFields } }')
|
||||
expect(query.document).toContain('r1: repository(owner: "octo", name: "other") { p0: pullRequest(number: 7) { ...ChangeRequestFields } }')
|
||||
const settings = 'squashMergeAllowed mergeCommitAllowed rebaseMergeAllowed viewerPermission viewerDefaultMergeMethod'
|
||||
expect(query.document).toContain(`r0: repository(owner: "octo", name: "demo") { ${settings} p0: pullRequest(number: 1) { ...ChangeRequestFields } p1: pullRequest(number: 2) { ...ChangeRequestFields } }`)
|
||||
expect(query.document).toContain(`r1: repository(owner: "octo", name: "other") { ${settings} p0: pullRequest(number: 7) { ...ChangeRequestFields } }`)
|
||||
expect(query.document).toContain('fragment ChangeRequestFields on PullRequest {')
|
||||
})
|
||||
|
||||
@@ -46,6 +48,8 @@ describe('snapshot mapping', () => {
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeability: 'mergeable',
|
||||
mergeReady: true,
|
||||
mergeMethods: ['merge', 'squash'],
|
||||
reviewDecision: 'none',
|
||||
checks: [{
|
||||
name: 'lint',
|
||||
@@ -147,6 +151,40 @@ describe('snapshot mapping', () => {
|
||||
expect(snapshot.checks.map(check => [check.name, check.workflow])).toEqual([['a', null], ['b', null]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['clean', {}, {}, true],
|
||||
['mergeable with hooks', { mergeStateStatus: 'HAS_HOOKS' }, {}, true],
|
||||
['mergeable with failing checks that are not required', { mergeStateStatus: 'UNSTABLE' }, {}, true],
|
||||
['blocked by a rule', { mergeStateStatus: 'BLOCKED' }, {}, false],
|
||||
['behind its base', { mergeStateStatus: 'BEHIND' }, {}, false],
|
||||
['conflicting', { mergeStateStatus: 'DIRTY' }, {}, false],
|
||||
['still computing', { mergeStateStatus: 'UNKNOWN' }, {}, false],
|
||||
['a draft', { isDraft: true }, {}, false],
|
||||
['merged', { state: 'MERGED' }, {}, false],
|
||||
['closed', { state: 'CLOSED' }, {}, false],
|
||||
['for a maintainer', {}, { viewerPermission: 'MAINTAIN' }, true],
|
||||
['for an administrator', {}, { viewerPermission: 'ADMIN' }, true],
|
||||
['for a triager', {}, { viewerPermission: 'TRIAGE' }, false],
|
||||
['for a reader', {}, { viewerPermission: 'READ' }, false],
|
||||
['for a viewer without a permission', {}, { viewerPermission: null }, false],
|
||||
])('offers a merge of a pull request %s only when GitHub would merge it now', (_label, node, settings, ready) => {
|
||||
expect(single(pullRequestNode(1, node), repositorySettings(settings)).mergeReady).toBe(ready)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['puts the viewer\'s usual method first', { rebaseMergeAllowed: true, viewerDefaultMergeMethod: 'REBASE' }, ['rebase', 'squash', 'merge']],
|
||||
['keeps the menu order when the usual method is squash', { rebaseMergeAllowed: true, viewerDefaultMergeMethod: 'SQUASH' }, ['squash', 'merge', 'rebase']],
|
||||
['skips a usual method the repository disallows', { mergeCommitAllowed: false, rebaseMergeAllowed: true }, ['squash', 'rebase']],
|
||||
['lists nothing when the repository allows no method', { squashMergeAllowed: false, mergeCommitAllowed: false }, []],
|
||||
])('lists the merge methods the repository allows: %s', (_label, settings, methods) => {
|
||||
expect(single(pullRequestNode(1), repositorySettings(settings)).mergeMethods).toEqual(methods)
|
||||
})
|
||||
|
||||
it('offers no merge in a repository whose merge settings are unreadable', () => {
|
||||
expect(single(pullRequestNode(1), null)).toMatchObject({ mergeReady: false, mergeMethods: [] })
|
||||
expect(single(pullRequestNode(1), { squashMergeAllowed: 'yes' })).toMatchObject({ mergeReady: false, mergeMethods: [] })
|
||||
})
|
||||
|
||||
it('marks truncated checks, a hidden author, drafts, and a commit without rollup', () => {
|
||||
const truncated = pullRequestNode(1, {
|
||||
isDraft: true,
|
||||
|
||||
@@ -141,3 +141,60 @@ describe('GitHub provider readiness', () => {
|
||||
await expect(provider(() => Promise.reject(new Error('timed out'))).readiness('github.com', signal)).rejects.toThrow('timed out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitHub provider actions', () => {
|
||||
const head = 'c'.repeat(40)
|
||||
|
||||
it('marks a pull request ready for review through gh pr ready on its host', async () => {
|
||||
const gh = fakeGh(() => output(''))
|
||||
|
||||
await expect(provider(gh.run).markReady(pull(7, enterprise), signal)).resolves.toBeUndefined()
|
||||
expect(gh.calls).toEqual([['pr', 'ready', '7', '--repo', 'ghe.corp.example/team/svc']])
|
||||
})
|
||||
|
||||
it('merges a pull request through the REST merge endpoint with the confirmed head', async () => {
|
||||
const gh = fakeGh(() => output({ sha: 'd'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
|
||||
|
||||
await expect(provider(gh.run).merge(pull(7), { method: 'squash', headSha: head }, signal)).resolves.toBeUndefined()
|
||||
expect(gh.calls).toEqual([[
|
||||
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/7/merge', '-f', 'merge_method=squash', '-f', `sha=${head}`,
|
||||
]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a moved head', { message: 'Head branch was modified. Review and try the merge again.' }, 'gh: Head branch was modified. Review and try the merge again. (HTTP 409)',
|
||||
{ code: 'stale', message: 'Head branch was modified. Review and try the merge again.' }],
|
||||
['an unmet merge rule', { message: 'Pull Request is not mergeable' }, 'gh: Pull Request is not mergeable (HTTP 405)',
|
||||
{ code: 'failed', message: 'Pull Request is not mergeable' }],
|
||||
['rejected credentials', { message: 'Bad credentials' }, 'gh: Bad credentials (HTTP 401)', { code: 'unauthenticated', message: 'Bad credentials' }],
|
||||
['an error body without a message', { errors: [] }, 'gh: Validation Failed (HTTP 422)', { code: 'failed', message: 'gh: Validation Failed (HTTP 422)' }],
|
||||
['an empty message', { message: ' ' }, 'gh: Not Found (HTTP 404)', { code: 'failed', message: 'gh: Not Found (HTTP 404)' }],
|
||||
['no output', '', '', { code: 'failed', message: 'gh exited with 1' }],
|
||||
])('reports a merge refused for %s', async (_label, body, stderr, failure) => {
|
||||
await expect(provider(fakeGh(() => output(body, 1, stderr)).run).merge(pull(7), { method: 'merge', headSha: head }, signal)).resolves.toEqual(failure)
|
||||
})
|
||||
|
||||
it('reports a failed ready mark by the first diagnostic line, recognizing a missing sign-in', async () => {
|
||||
await expect(provider(fakeGh(() => output('', 1, '\nGraphQL: Resource not accessible by integration (markPullRequestReadyForReview)\n')).run)
|
||||
.markReady(pull(7), signal)).resolves.toEqual({ code: 'failed', message: 'GraphQL: Resource not accessible by integration (markPullRequestReadyForReview)' })
|
||||
await expect(provider(fakeGh(() => output('', 4, 'To get started with GitHub CLI, please run: gh auth login')).run).markReady(pull(7), signal))
|
||||
.resolves.toEqual({ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' })
|
||||
})
|
||||
|
||||
it('reports an action as failed when gh cannot start and rethrows other failures', async () => {
|
||||
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT'))).markReady(pull(7), signal))
|
||||
.resolves.toEqual({ code: 'failed', message: 'gh could not start: spawn gh ENOENT' })
|
||||
await expect(provider(() => Promise.reject(new Error('aborted'))).merge(pull(7), { method: 'rebase', headSha: head }, signal)).rejects.toThrow('aborted')
|
||||
})
|
||||
|
||||
it('refuses to address a pull request or head commit that cannot be inlined', async () => {
|
||||
const gh = fakeGh(() => output(''))
|
||||
const subject = provider(gh.run)
|
||||
|
||||
await expect(subject.markReady(pull(7, { ...demo, owner: 'oc/to' }), signal)).rejects.toThrow('forge-github: invalid repository "oc/to/demo"')
|
||||
await expect(subject.merge(pull(0), { method: 'merge', headSha: head }, signal)).rejects.toThrow('forge-github: invalid pull request number 0')
|
||||
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'HEAD' }, signal)).rejects.toThrow('forge-github: invalid head commit "HEAD"')
|
||||
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'e'.repeat(64) }, signal)).resolves.toBeUndefined()
|
||||
expect(gh.calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import ForgeService, { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness,
|
||||
ForgeRepository,
|
||||
} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { createAssistantMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -38,6 +39,8 @@ export function snapshot(ref: ChangeRequestRef, overrides: Partial<ChangeRequest
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
mergeReady: true,
|
||||
mergeMethods: ['squash', 'merge', 'rebase'],
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
@@ -89,6 +92,14 @@ export class FakeProvider implements ForgeProvider {
|
||||
readiness(): Promise<ForgeReadiness> {
|
||||
return Promise.resolve({ state: 'ready', message: null })
|
||||
}
|
||||
|
||||
markReady(): Promise<ForgeActionFailure | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
merge(): Promise<ForgeActionFailure | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** Live services and one store-created Session. */
|
||||
|
||||
@@ -61,6 +61,7 @@ const node = (number) => ({
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: number === 7 ? 'BLOCKED' : 'CLEAN',
|
||||
reviewDecision: null,
|
||||
reviewThreads: { nodes: [] },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: {
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
en: cf24b0ee1e2732e8
|
||||
zh: bb4e76d2b69b84d8
|
||||
/deepseek-ai-dsh-experimental-forge/summary:
|
||||
en: f516c1e7fd1461da
|
||||
zh: 59f18eafedc85452
|
||||
en: 874a019607b0e78f
|
||||
zh: b791cc579fa4c467
|
||||
/deepseek-ai-dsh-experimental-forge/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge/use-this-package:
|
||||
en: f14f0d3e02872d83
|
||||
zh: d0fc927d80a3aeef
|
||||
en: 26d2455d7958b55f
|
||||
zh: f810e190cef11279
|
||||
/deepseek-ai-dsh-experimental-forge/understand-the-implementation:
|
||||
en: e193cb547c47069b
|
||||
zh: faa517522402ea41
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Read pull requests and merge requests without coupling consumers to one code host. A provider maps remote and change request URLs to provider-neutral references, finds change requests by head branch, and reads their status in batches. Watchers of one change request share a single polling loop whose cadence follows check, mergeability, and lifecycle state.
|
||||
Read and act on pull requests and merge requests without coupling consumers to one code host. A provider maps remote and change request URLs to provider-neutral references, finds change requests by head branch, reads their status in batches, and marks drafts ready or merges on request. Watchers of one change request share a single polling loop whose cadence follows check, mergeability, and lifecycle state.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -34,6 +34,8 @@ Mount the registry with at least one provider package, such as the GitHub provid
|
||||
|
||||
`ctx.forge.register(provider)` is an effect on the calling fiber and returns its disposer. One provider serves one forge family; registering a second provider for the same family throws. `repositoryFromRemote(url)` and `changeRequestFromUrl(url)` ask providers in registration order and return the first match. `changeRequestUrl(ref)`, `findByHead(repository, branch, signal)`, and `readiness(repository, signal)` delegate to the provider of the reference's forge and throw when none is registered; `readiness` reports whether the provider can reach the host with current credentials.
|
||||
|
||||
`markReady(ref, signal)` marks a draft ready for review, and `merge(ref, { method, headSha }, signal)` merges a change request only while its head is still `headSha`. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to undefined once the host accepted the change or to a `ForgeActionFailure`: `unauthenticated`, `stale` when the head moved past `headSha`, or `failed` with the host's reason. After either settles, a watched change request is polled at once. Snapshots carry `mergeReady`, whether the host would merge the change request now for the signed-in user, and `mergeMethods`, the methods the repository accepts with that user's usual one first.
|
||||
|
||||
`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. `refresh(ref)` polls a watched change request at once, including after a terminal state; a fetch already in flight for its forge serves the request. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`. A fetch that exceeds `fetchTimeoutMs` settles as an `unavailable` failure whose message names `FORGE_FETCH_TIMEOUT`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults.
|
||||
|
||||
-----
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
读取 pull request 与 merge request,而不把消费方绑定到某一个代码托管平台。提供方把远端 URL 和变更请求(change request,即 pull request 或 merge request)URL 映射为提供方无关的引用,按 head 分支查找变更请求,并批量读取其状态。同一变更请求的所有监听方共享一个轮询循环,轮询节奏随检查、可合并性与生命周期状态变化。
|
||||
读取并操作 pull request 与 merge request,而不把消费方绑定到某一个代码托管平台。提供方把远端 URL 和变更请求(change request,即 pull request 或 merge request)URL 映射为提供方无关的引用,按 head 分支查找变更请求,批量读取其状态,并按请求把草稿标记为可评审或合并变更请求。同一变更请求的所有监听方共享一个轮询循环,轮询节奏随检查、可合并性与生命周期状态变化。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -34,6 +34,8 @@ kind: "package-reference"
|
||||
|
||||
`ctx.forge.register(provider)` 是调用方 fiber 上的 effect,返回对应的 disposer。一个提供方服务一个 forge 家族;为同一家族注册第二个提供方会抛错。`repositoryFromRemote(url)` 与 `changeRequestFromUrl(url)` 按注册顺序询问提供方并返回第一个匹配结果。`changeRequestUrl(ref)`、`findByHead(repository, branch, signal)` 与 `readiness(repository, signal)` 委托给引用所属 forge 的提供方,没有注册提供方时抛错;`readiness` 报告提供方能否用当前凭据访问该主机。
|
||||
|
||||
`markReady(ref, signal)` 把草稿标记为可评审;`merge(ref, { method, headSha }, signal)` 只在 head 仍是 `headSha` 时合并变更请求。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错;主机接受时 resolve 为 undefined,否则为 `ForgeActionFailure`:`unauthenticated`、head 已越过 `headSha` 时的 `stale`,或带主机原因的 `failed`。两者结束后,被监听的变更请求会立即重新轮询。快照带有 `mergeReady`(主机现在是否会为已登录用户合并该变更请求)与 `mergeMethods`(仓库接受的合并方式,该用户常用的方式排在最前)。
|
||||
|
||||
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,由这次读取满足请求。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`。读取超过 `fetchTimeoutMs` 时,以消息中包含 `FORGE_FETCH_TIMEOUT` 的 `unavailable` 失败结束。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
|
||||
|
||||
-----
|
||||
|
||||
@@ -7,7 +7,7 @@ import { changeRequestKey } from './keys.ts'
|
||||
import { nextPollDelay } from './schedule.ts'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot, ChangeRequestUpdate, Config,
|
||||
ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository, MergeRequest,
|
||||
} from './types.ts'
|
||||
|
||||
export { changeRequestKey } from './keys.ts'
|
||||
@@ -161,6 +161,34 @@ export class ForgeService extends Service {
|
||||
return await this.provider(repository.forge).readiness(repository.host, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a draft change request ready for review, then poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host accepted the change, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
|
||||
const failure = await this.provider(ref.repository.forge).markReady(ref, signal)
|
||||
this.refresh(ref)
|
||||
return failure
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a change request while its head is still `request.headSha`, then
|
||||
* poll it at once when it is watched.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param request - merge method and the head commit the user confirmed.
|
||||
* @param signal - cancellation of the request.
|
||||
* @returns undefined once the host merged it, or why it did not.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined> {
|
||||
const failure = await this.provider(ref.repository.forge).merge(ref, request, signal)
|
||||
this.refresh(ref)
|
||||
return failure
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch one change request as an effect on the calling fiber. The first
|
||||
* watcher starts polling at once; listeners receive every later result,
|
||||
|
||||
@@ -56,6 +56,9 @@ export type Mergeability = 'mergeable' | 'conflicting' | 'unknown'
|
||||
/** Aggregate review outcome required by the host's merge rules. */
|
||||
export type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none'
|
||||
|
||||
/** How a merge brings the commits of a change request into its base branch. */
|
||||
export type MergeMethod = 'merge' | 'squash' | 'rebase'
|
||||
|
||||
/** Status of one change request at the time its provider read it. */
|
||||
export interface ChangeRequestSnapshot {
|
||||
/** Change request the snapshot describes. */
|
||||
@@ -82,6 +85,13 @@ export interface ChangeRequestSnapshot {
|
||||
readonly deletions: number
|
||||
/** Merge conflict status; `unknown` while the host computes it. */
|
||||
readonly mergeability: Mergeability
|
||||
/**
|
||||
* Whether the host would merge the change request now for the signed-in
|
||||
* user: open, not a draft, and meeting the base branch's merge rules.
|
||||
*/
|
||||
readonly mergeReady: boolean
|
||||
/** Merge methods the repository accepts, the signed-in user's usual one first. */
|
||||
readonly mergeMethods: readonly MergeMethod[]
|
||||
/** Aggregate review outcome. */
|
||||
readonly reviewDecision: ReviewDecision
|
||||
/** Checks on the head commit in host order. */
|
||||
@@ -120,6 +130,25 @@ export type ChangeRequestUpdate = {
|
||||
readonly failure: ForgeFailure
|
||||
})
|
||||
|
||||
/** A merge the user confirmed for the head commit they saw. */
|
||||
export interface MergeRequest {
|
||||
/** How the commits enter the base branch. */
|
||||
readonly method: MergeMethod
|
||||
/** Commit object name the head must still have; the merge is refused once the head moves. */
|
||||
readonly headSha: string
|
||||
}
|
||||
|
||||
/** Why a provider could not change a change request. */
|
||||
export interface ForgeActionFailure {
|
||||
/**
|
||||
* `unauthenticated`: credentials are missing or rejected; `stale`: the head
|
||||
* moved past the confirmed commit; `failed`: the host refused or could not be reached.
|
||||
*/
|
||||
readonly code: 'unauthenticated' | 'stale' | 'failed'
|
||||
/** Human-readable cause, usually the host's own message. */
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Whether a provider can reach one host with the current credentials. */
|
||||
export interface ForgeReadiness {
|
||||
/** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */
|
||||
@@ -150,6 +179,10 @@ export interface ForgeProvider {
|
||||
findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
/** Report whether the provider can reach `host` with current credentials. */
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
/** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */
|
||||
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import ForgeService, { changeRequestKey } from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeActionFailure, ForgeFailure, ForgeProvider, ForgeReadiness,
|
||||
ForgeRepository, MergeRequest,
|
||||
} from '../src/types.ts'
|
||||
|
||||
type FetchResult = ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>
|
||||
@@ -31,6 +32,8 @@ export function snapshot(target: ChangeRequestRef, overrides: Partial<ChangeRequ
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
mergeReady: true,
|
||||
mergeMethods: ['squash', 'merge', 'rebase'],
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
@@ -51,6 +54,9 @@ export class FakeProvider implements ForgeProvider {
|
||||
readonly heads = new Map<string, readonly ChangeRequestRef[]>()
|
||||
fetchImpl: ((refs: readonly ChangeRequestRef[], signal: AbortSignal) => Promise<FetchResult>) | undefined
|
||||
readinessResult: ForgeReadiness = { state: 'ready', message: null }
|
||||
/** Every action in call order, such as `ready 7` or `merge 7 squash <sha>`. */
|
||||
readonly actions: string[] = []
|
||||
actionResult: ForgeActionFailure | undefined
|
||||
|
||||
constructor(forge = 'fake') {
|
||||
this.forge = forge
|
||||
@@ -91,6 +97,16 @@ export class FakeProvider implements ForgeProvider {
|
||||
async readiness(): Promise<ForgeReadiness> {
|
||||
return this.readinessResult
|
||||
}
|
||||
|
||||
async markReady(target: ChangeRequestRef): Promise<ForgeActionFailure | undefined> {
|
||||
this.actions.push(`ready ${target.number}`)
|
||||
return this.actionResult
|
||||
}
|
||||
|
||||
async merge(target: ChangeRequestRef, request: MergeRequest): Promise<ForgeActionFailure | undefined> {
|
||||
this.actions.push(`merge ${target.number} ${request.method} ${request.headSha}`)
|
||||
return this.actionResult
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot a context with only the forge service. */
|
||||
|
||||
@@ -43,6 +43,22 @@ describe('forge registry', () => {
|
||||
expect(() => ctx!.forge.changeRequestUrl(ref(1, elsewhere))).toThrow('forge: no provider is registered for "missing"')
|
||||
await expect(ctx.forge.findByHead(elsewhere, 'topic', signal)).rejects.toThrow('forge: no provider is registered for "missing"')
|
||||
await expect(ctx.forge.readiness(elsewhere, signal)).rejects.toThrow('forge: no provider is registered for "missing"')
|
||||
await expect(ctx.forge.markReady(ref(1, elsewhere), signal)).rejects.toThrow('forge: no provider is registered for "missing"')
|
||||
await expect(ctx.forge.merge(ref(1, elsewhere), { method: 'squash', headSha: 'a'.repeat(40) }, signal))
|
||||
.rejects.toThrow('forge: no provider is registered for "missing"')
|
||||
})
|
||||
|
||||
it('delegates change request actions to the provider of their forge and returns its outcome', async () => {
|
||||
ctx = await forgeContext()
|
||||
const provider = new FakeProvider()
|
||||
await provide(ctx, provider)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(ctx.forge.markReady(ref(3), signal)).resolves.toBeUndefined()
|
||||
provider.actionResult = { code: 'stale', message: 'Head branch was modified' }
|
||||
await expect(ctx.forge.merge(ref(3), { method: 'rebase', headSha: 'b'.repeat(40) }, signal))
|
||||
.resolves.toEqual({ code: 'stale', message: 'Head branch was modified' })
|
||||
expect(provider.actions).toEqual(['ready 3', `merge 3 rebase ${'b'.repeat(40)}`])
|
||||
})
|
||||
|
||||
it('refuses a second provider for the same forge', async () => {
|
||||
|
||||
@@ -202,6 +202,24 @@ describe('forge watch', () => {
|
||||
expect(provider.fetches).toEqual([[ref(1)], [ref(1)]])
|
||||
})
|
||||
|
||||
it('polls a watched change request at once after an action settles, whatever its outcome', async () => {
|
||||
const provider = new FakeProvider()
|
||||
provider.results.set(key, snapshot(ref(1), { draft: true }))
|
||||
const context = await setup(provider)
|
||||
collect(context)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(provider.fetches).toHaveLength(1)
|
||||
|
||||
await context.forge.markReady(ref(1), new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(provider.fetches).toHaveLength(2)
|
||||
|
||||
provider.actionResult = { code: 'failed', message: 'Pull Request is not mergeable' }
|
||||
await context.forge.merge(ref(1), { method: 'merge', headSha: 'a'.repeat(40) }, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(provider.fetches).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('lets a fetch already in flight serve a refresh', async () => {
|
||||
const provider = new FakeProvider()
|
||||
const release = held(provider, target => snapshot(target))
|
||||
|
||||
@@ -1239,6 +1239,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'the provider\'s readiness for the host.',
|
||||
throws: ['when no provider serves the repository\'s forge.'],
|
||||
},
|
||||
{
|
||||
signature: 'async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>',
|
||||
description: 'Mark a draft change request ready for review, then poll it at once when it is watched.',
|
||||
parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'signal', description: 'cancellation of the request.' }],
|
||||
returns: 'undefined once the host accepted the change, or why it did not.',
|
||||
throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'],
|
||||
},
|
||||
{
|
||||
signature: 'async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>',
|
||||
description: 'Merge a change request while its head is still `request.headSha`, then poll it at once when it is watched.',
|
||||
parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'request', description: 'merge method and the head commit the user confirmed.' }, { name: 'signal', description: 'cancellation of the request.' }],
|
||||
returns: 'undefined once the host merged it, or why it did not.',
|
||||
throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'],
|
||||
},
|
||||
{
|
||||
signature: 'watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void',
|
||||
description: 'Watch one change request as an effect on the calling fiber. The first watcher starts polling at once; listeners receive every later result, and `latest` returns the current one. Polling stops when the last watcher leaves. Listeners must not throw; a thrown error is logged and skipped.',
|
||||
@@ -1292,6 +1306,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
description: 'Reinspect a followed Session\'s checkout, probe sign-in again, and poll its change requests at once; an unfollowed Session is unchanged.',
|
||||
parameters: [{ name: 'session', description: 'Session whose panel refreshes.' }, { name: 'signal', description: 'request cancellation.' }],
|
||||
},
|
||||
{
|
||||
signature: '@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise<void>',
|
||||
description: 'Mark a draft change request of a Session ready for review; the change request is polled again once the host answers.',
|
||||
parameters: [{ name: 'session', description: 'Session the change request is bound to.' }, { name: 'url', description: 'binding URL of the change request.' }, { name: 'signal', description: 'request cancellation.' }],
|
||||
throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/action-failed` with the host\'s reason when the host refuses or cannot be reached.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void>',
|
||||
description: 'Merge a change request of a Session while its head is still `headSha`; the change request is polled again once the host answers.',
|
||||
parameters: [{ name: 'session', description: 'Session the change request is bound to.' }, { name: 'url', description: 'binding URL of the change request.' }, { name: 'method', description: 'how the commits enter the base branch.' }, { name: 'headSha', description: 'head commit the user confirmed.' }, { name: 'signal', description: 'request cancellation.' }],
|
||||
throws: ['`forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host\'s reason when the host refuses otherwise or cannot be reached.'],
|
||||
},
|
||||
{
|
||||
signature: '@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise<void>',
|
||||
description: 'Ask for a new worktree before the Session\'s first turn, replacing an open request and forgetting a failed attempt. Admission of the next message, or else the first step proposal, creates a branch from `from`, or from HEAD of the current checkout when it is null, and enters its checkout.',
|
||||
@@ -5355,7 +5381,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ChangeRequestSnapshot',
|
||||
declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}',
|
||||
declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChangeRequestState',
|
||||
@@ -5941,6 +5967,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'FinishReasonMap',
|
||||
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'ForgeActionFailure',
|
||||
declaration: 'export interface ForgeActionFailure {\n readonly code: \'unauthenticated\' | \'stale\' | \'failed\';\n readonly message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ForgeBranches',
|
||||
declaration: 'export interface ForgeBranches {\n readonly current: string | null;\n readonly branches: readonly string[];\n}',
|
||||
@@ -5975,7 +6005,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ForgeProvider',
|
||||
declaration: 'export interface ForgeProvider {\n readonly forge: string;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>;\n readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>;\n}',
|
||||
declaration: 'export interface ForgeProvider {\n readonly forge: string;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>;\n readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ForgeReadiness',
|
||||
@@ -6617,6 +6647,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'Mergeability',
|
||||
declaration: 'export type Mergeability = \'mergeable\' | \'conflicting\' | \'unknown\';',
|
||||
},
|
||||
{
|
||||
name: 'MergeMethod',
|
||||
declaration: 'export type MergeMethod = \'merge\' | \'squash\' | \'rebase\';',
|
||||
},
|
||||
{
|
||||
name: 'MergeRequest',
|
||||
declaration: 'export interface MergeRequest {\n readonly method: MergeMethod;\n readonly headSha: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'Message',
|
||||
declaration: 'export type Message = MessageRoleMap[keyof MessageRoleMap];',
|
||||
|
||||
@@ -372,11 +372,14 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ChangeRequestListener: 'forge.md',
|
||||
ChangeRequestRef: 'forge.md',
|
||||
ChangeRequestUpdate: 'forge.md',
|
||||
ForgeActionFailure: 'forge.md',
|
||||
ForgeBranches: 'forge.md',
|
||||
ForgePanelState: 'forge.md',
|
||||
ForgeProvider: 'forge.md',
|
||||
ForgeReadiness: 'forge.md',
|
||||
ForgeRepository: 'forge.md',
|
||||
MergeMethod: 'forge.md',
|
||||
MergeRequest: 'forge.md',
|
||||
SessionChangeRequest: 'forge.md',
|
||||
SessionChangeRequestsListener: 'forge.md',
|
||||
GenerateOptions: 'llm-streaming.md',
|
||||
|
||||
@@ -2612,6 +2612,16 @@
|
||||
"symbol": "ForgeReadiness",
|
||||
"source": "packages/experimental/forge/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/forge.md",
|
||||
"symbol": "ForgeActionFailure",
|
||||
"source": "packages/experimental/forge/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/forge.md",
|
||||
"symbol": "MergeRequest",
|
||||
"source": "packages/experimental/forge/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/forge.md",
|
||||
"symbol": "ForgeCheckout",
|
||||
|
||||
Reference in new issue
Block a user