feat(forge): bind change requests from gh pr view/edit/checkout and the checkout's branch
- forge-session recognizes `gh pr view`, `gh pr edit`, and `gh pr checkout` as binding evidence with the new `referenced` origin. It binds the URL given as the subcommand's first argument and the URL the output prints for that change request: gh's `url:` field, a URL alone on a line before a description, or a JSON `url` member. Links in a description do not bind. Pending calls carry this evidence, and the projection's state version moves to 2, so existing Sessions fold their logs again. - forge-session adds `discoverBranch(session, remote, branch)`, the lookup that follows a push, for a branch that a caller reports. - The panel reports the checkout's upstream branch when a provider recognizes its remote, once per remote and branch until a refresh, so a Session on a branch with an open change request shows it without a push. - The empty panel names every way a change request becomes linked.
This commit is contained in:
1 parent
891ea9bd33
commit
e99272d04e
32 files changed
+419
-139
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: 32ef8af85f4d2f66
|
||||
zh: b82b67f94346399e
|
||||
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
|
||||
en: 84ce6192c70768d0
|
||||
zh: 8a1946be8a9a4258
|
||||
en: 6fa6a8ac5f9a78fc
|
||||
zh: b23497995b3f2281
|
||||
/agent-note-change-request-panel-and-worktree-sessions/consequences:
|
||||
en: eb021d0ea7c440aa
|
||||
zh: 61c906b1528986fb
|
||||
@@ -65,7 +65,7 @@ The changes view offers no scope for one commit or for the last turn yet; both w
|
||||
|
||||
**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen.
|
||||
|
||||
**A Session-row hover section and manual URL tracking.** Both duplicated the tab. Bindings come from `gh pr create` and pushes, and `track` remains for Undo.
|
||||
**A Session-row hover section and manual URL tracking.** Both duplicated the tab. Bindings come from `gh pr create`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
|
||||
|
||||
**Parsing patches in the browser.** The Remote would carry unbounded patch text, and the browser would need a patch parser; the Host bounds the read and parses it once.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
|
||||
|
||||
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
|
||||
|
||||
**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create` 与推送,`track` 保留给“撤销”使用。
|
||||
**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
|
||||
|
||||
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
|
||||
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: e736740546fb8219
|
||||
zh: 46a16a7a633716ec
|
||||
/agent-note-session-change-request-bindings/decision:
|
||||
en: f785279cfb4e8709
|
||||
zh: 32525080dc8efe93
|
||||
en: 2bbd058f4edc8bb5
|
||||
zh: 5cc12bb0aa0864e2
|
||||
/agent-note-session-change-request-bindings/alternatives-considered:
|
||||
en: 15fdaef4e432fc72
|
||||
zh: 8d36c859bd89f403
|
||||
/agent-note-session-change-request-bindings/consequences:
|
||||
en: 932453846bdb0c0a
|
||||
zh: 14068a18104ece4d
|
||||
en: a2960e50ec52c9b6
|
||||
zh: fd23f4fe1137787d
|
||||
@@ -12,9 +12,9 @@ A Session that opens or updates pull requests leaves them only in its shell outp
|
||||
|
||||
The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions.
|
||||
|
||||
Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated.
|
||||
Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results record the branches they updated. Those three commands act on one change request, so only the URL given as their first argument and the URL their output prints for that change request count; links in a description name other change requests. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated.
|
||||
|
||||
After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status.
|
||||
After each push result, and when a caller such as the forge panel reports the branch a Session's checkout tracks, the service looks up open change requests for that branch through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,4 +28,4 @@ After each push result, the service looks up open change requests for the pushed
|
||||
|
||||
## Consequences
|
||||
|
||||
Bindings appear without model cooperation for the common `gh pr create` and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere appears only after another push or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
|
||||
Bindings appear without model cooperation for the common `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere for a branch appears only after another push, another report of that branch, or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
|
||||
@@ -12,9 +12,9 @@ Status: implemented
|
||||
|
||||
实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。
|
||||
|
||||
绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。
|
||||
绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果记录其更新的分支。这三个命令只操作一个变更请求,因此只计入作为第一个参数给出的 URL 和输出为该变更请求打印的 URL;描述正文里的链接指向的是其他变更请求。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。
|
||||
|
||||
每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。
|
||||
每次推送结果之后,以及 forge 面板等调用方报告 Session 检出所跟踪的分支时,服务通过提供方为该分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,4 +28,4 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
在常见的 `gh pr create` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处打开的变更请求要等到下一次推送或 `track` 才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
|
||||
在常见的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处为某个分支打开的变更请求,要等到下一次推送、再次报告该分支或 `track` 之后才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
|
||||
@@ -1138,7 +1138,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-experimental-forge-session`
|
||||
|
||||
- `inject`: `forge` · `sessionProjections`
|
||||
- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge-session/src/types.ts:88`](../packages/experimental/forge-session/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment settings of `forgeSession`. */
|
||||
|
||||
@@ -1140,7 +1140,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-experimental-forge-session`
|
||||
|
||||
- `inject`: `forge` · `sessionProjections`
|
||||
- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge-session/src/types.ts:88`](../packages/experimental/forge-session/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment settings of `forgeSession`. */
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 00e4490e416099d2
|
||||
zh: 5724d08b6ae230f7
|
||||
/forges/session-bindings:
|
||||
en: ad28bdce5c5d4d49
|
||||
zh: 1acd042350634970
|
||||
en: 4d65f16562d67181
|
||||
zh: 676e411b32bc7615
|
||||
/forges/panel:
|
||||
en: 84401533ab4bd1c4
|
||||
zh: 783907da58393818
|
||||
@@ -200,22 +200,23 @@ interface ForgeActionFailure {
|
||||
|
||||
## 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.
|
||||
`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results trigger head-branch discovery through `ctx.forge`; the panel also reports the branch its checkout tracks for the same discovery. 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.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* How a change request became bound to a Session: `created` when a
|
||||
* `gh pr create` result printed its URL, `discovered` when a lookup after a
|
||||
* `git push` found it for the pushed branch, `tracked` when a caller bound it
|
||||
* by URL.
|
||||
* `gh pr create` result printed its URL, `referenced` when a `gh pr view`,
|
||||
* `gh pr edit`, or `gh pr checkout` call named it or its result printed its
|
||||
* own URL, `discovered` when a lookup found it for a branch the Session pushed
|
||||
* or works on, `tracked` when a caller bound it by URL.
|
||||
*/
|
||||
type BindingOrigin = 'created' | 'discovered' | 'tracked'
|
||||
type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Payload of the `plugin:forge-session/binding` record. */
|
||||
interface BindingRecord {
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
|
||||
readonly action: 'track' | 'dismiss' | 'discover'
|
||||
/** Web URL of the change request. */
|
||||
readonly url: string
|
||||
@@ -896,7 +897,7 @@ Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/ex
|
||||
|
||||
### `ctx.forgeSession` — `ForgeSessionService`
|
||||
|
||||
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
|
||||
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -916,6 +917,18 @@ changeRequests(session: Session): SessionChangeRequest[]
|
||||
*/
|
||||
track(session: Session, url: string): void
|
||||
|
||||
/**
|
||||
* Look up open change requests whose head is a branch the Session works on,
|
||||
* and record the unbound ones as discovered, as after a push of that branch.
|
||||
* A remote that no provider recognizes changes nothing; a failed lookup is
|
||||
* logged and changes nothing.
|
||||
* @param session - Session that receives the bindings.
|
||||
* @param remote - URL of the remote that holds the branch.
|
||||
* @param branch - branch name on that remote.
|
||||
* @returns resolves once the lookup has recorded its bindings or failed.
|
||||
*/
|
||||
discoverBranch(session: Session, remote: string, branch: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Hide a visible binding. Appends a `dismiss` record; a URL that is not
|
||||
* visibly bound leaves the log unchanged. Discovery never restores a
|
||||
|
||||
@@ -200,22 +200,23 @@ interface ForgeActionFailure {
|
||||
|
||||
## 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)。
|
||||
`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现;面板也会报告其检出所跟踪的分支,触发同样的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影只位于宿主侧。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* How a change request became bound to a Session: `created` when a
|
||||
* `gh pr create` result printed its URL, `discovered` when a lookup after a
|
||||
* `git push` found it for the pushed branch, `tracked` when a caller bound it
|
||||
* by URL.
|
||||
* `gh pr create` result printed its URL, `referenced` when a `gh pr view`,
|
||||
* `gh pr edit`, or `gh pr checkout` call named it or its result printed its
|
||||
* own URL, `discovered` when a lookup found it for a branch the Session pushed
|
||||
* or works on, `tracked` when a caller bound it by URL.
|
||||
*/
|
||||
type BindingOrigin = 'created' | 'discovered' | 'tracked'
|
||||
type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Payload of the `plugin:forge-session/binding` record. */
|
||||
interface BindingRecord {
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
|
||||
readonly action: 'track' | 'dismiss' | 'discover'
|
||||
/** Web URL of the change request. */
|
||||
readonly url: string
|
||||
@@ -896,7 +897,7 @@ Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/ex
|
||||
|
||||
### `ctx.forgeSession` — `ForgeSessionService`
|
||||
|
||||
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
|
||||
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -916,6 +917,18 @@ changeRequests(session: Session): SessionChangeRequest[]
|
||||
*/
|
||||
track(session: Session, url: string): void
|
||||
|
||||
/**
|
||||
* Look up open change requests whose head is a branch the Session works on,
|
||||
* and record the unbound ones as discovered, as after a push of that branch.
|
||||
* A remote that no provider recognizes changes nothing; a failed lookup is
|
||||
* logged and changes nothing.
|
||||
* @param session - Session that receives the bindings.
|
||||
* @param remote - URL of the remote that holds the branch.
|
||||
* @param branch - branch name on that remote.
|
||||
* @returns resolves once the lookup has recorded its bindings or failed.
|
||||
*/
|
||||
discoverBranch(session: Session, remote: string, branch: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Hide a visible binding. Appends a `dismiss` record; a URL that is not
|
||||
* visibly bound leaves the log unchanged. Discovery never restores a
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 916377479b92f24c
|
||||
zh: c871a8bd755758fd
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
|
||||
en: 266f46bc39a860a3
|
||||
zh: 603c47e413dd92dd
|
||||
en: 6e3e7410a6708422
|
||||
zh: da77bdd69bed2793
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
|
||||
en: fa4d8c579ffc49ff
|
||||
zh: 5856ed7469e4585b
|
||||
|
||||
@@ -46,7 +46,7 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
|
||||
`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
|
||||
|
||||
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>`. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it, and lists untracked files. 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.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。
|
||||
`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。
|
||||
|
||||
检查运行 `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>`。之后,检查会为变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,并列出未跟踪文件。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const zh = {
|
||||
'checkout.failed': '无法读取 Git 信息',
|
||||
'changes.title': '本会话的变更请求 · {count}',
|
||||
'changes.empty': '本会话还没有变更请求',
|
||||
'changes.emptyHint': '本会话创建的变更请求,或推送分支后找到的已有变更请求,会显示在这里',
|
||||
'changes.emptyHint': '本会话关联的变更请求(创建、查看、编辑或检出的,以及所在分支或推送分支上的)会显示在这里',
|
||||
'changes.local.title': '变更',
|
||||
'changes.local.refs': '{head} → {base}',
|
||||
'changes.local.files': '{count} 个文件',
|
||||
@@ -214,7 +214,7 @@ export const en: Record<ForgePanelKey, string> = {
|
||||
'checkout.failed': 'Git information could not be read',
|
||||
'changes.title': 'Change requests of this Session · {count}',
|
||||
'changes.empty': 'This Session has no change requests yet',
|
||||
'changes.emptyHint': 'Change requests this Session creates, or existing ones found after a push, appear here',
|
||||
'changes.emptyHint': 'Change requests linked to this Session (created, viewed, edited, or checked out, or open on its current or pushed branches) appear here',
|
||||
'changes.local.title': 'Changes',
|
||||
'changes.local.refs': '{head} → {base}',
|
||||
'changes.local.files': '{count} files',
|
||||
|
||||
@@ -15,7 +15,7 @@ import { hasBranch, inspectCheckout } from './git.ts'
|
||||
import type { GitConfig, Inspection } from './git.ts'
|
||||
import { localChanges } from './local-diff.ts'
|
||||
import type { DiffConfig } from './local-diff.ts'
|
||||
import { remoteOrder } from './parse.ts'
|
||||
import { remoteOrder, upstreamBranch } from './parse.ts'
|
||||
import type {
|
||||
ForgeCheckoutState, ForgeLocalChanges, ForgeMissingWorktree, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree,
|
||||
ForgeSessionWorktree, ForgeWorktreeBlocker, ForgeWorktreeState,
|
||||
@@ -29,6 +29,14 @@ export interface PanelConfig extends DiffConfig {
|
||||
readonly readinessTimeoutMs: number
|
||||
}
|
||||
|
||||
/** The checkout's upstream branch and the remote URL its change requests are looked up at. */
|
||||
interface Upstream {
|
||||
/** URL of the upstream's remote that a provider recognizes. */
|
||||
readonly remote: string
|
||||
/** Branch name on that remote. */
|
||||
readonly branch: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state of one Session. Construction starts the first checkout
|
||||
* inspection and watches the Session's change requests. Inspections run one at
|
||||
@@ -36,7 +44,10 @@ export interface PanelConfig extends DiffConfig {
|
||||
* result for a directory the Session has since left is dropped. A sign-in
|
||||
* probe runs when an inspection recognizes a new code host and on `refresh`;
|
||||
* a probe result for a host the panel no longer shows is dropped. Each change
|
||||
* request keeps the latest snapshot a poll returned while later polls fail. The
|
||||
* request keeps the latest snapshot a poll returned while later polls fail.
|
||||
* When an inspection finds an upstream branch on a remote a provider
|
||||
* recognizes, the open change requests of that branch are bound as discovered,
|
||||
* once per remote and branch until a refresh. The
|
||||
* worktree facts follow the `forgeWorktree` projection, the live creation, and the last failed one;
|
||||
* each inspection also decides whether the Session's sandbox can write the
|
||||
* checkout and its shared Git directory, which a new worktree needs, whether
|
||||
@@ -54,6 +65,8 @@ export class SessionPanel {
|
||||
private missing: ForgeMissingWorktree | null = null
|
||||
private inspection: Promise<void> | null = null
|
||||
private rerun: Promise<void> | null = null
|
||||
/** Remote URL and branch of the upstream that the last branch lookup covered, until a refresh. */
|
||||
private discovered: string | null = null
|
||||
|
||||
/**
|
||||
* @param ctx - service context that provides the forge, Session, directory, and process services.
|
||||
@@ -170,6 +183,7 @@ export class SessionPanel {
|
||||
* @returns settles once the inspection and the sign-in probe have ended; each poll publishes its result when it finishes.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
this.discovered = null
|
||||
const inspection = this.inspect()
|
||||
const probe = this.current.repository === null ? undefined : this.probe(this.current.repository)
|
||||
for (const entry of this.current.changeRequests) {
|
||||
@@ -244,6 +258,7 @@ export class SessionPanel {
|
||||
let repository: ForgeRepository | null = null
|
||||
let blocker: ForgeWorktreeBlocker | null = null
|
||||
let local: ForgeLocalChanges | null = null
|
||||
let upstream: Upstream | null = null
|
||||
try {
|
||||
const inspection = await inspectCheckout(this.ctx, this.config, this.session, directory, this.lifetime.signal)
|
||||
if (inspection === undefined) {
|
||||
@@ -252,6 +267,7 @@ export class SessionPanel {
|
||||
} else {
|
||||
checkout = { state: 'ready', checkout: inspection.checkout }
|
||||
repository = this.recognize(inspection)
|
||||
upstream = this.upstreamOf(inspection)
|
||||
blocker = await this.blockerOf(inspection)
|
||||
local = await this.localOf(inspection)
|
||||
}
|
||||
@@ -272,6 +288,22 @@ export class SessionPanel {
|
||||
const readiness = sameHost ? this.current.readiness : null
|
||||
this.publish({ ...this.current, checkout, repository, readiness, worktree: this.worktree(), local })
|
||||
if (repository !== null && !sameHost) void this.probe(repository)
|
||||
if (upstream !== null) this.discover(upstream)
|
||||
}
|
||||
|
||||
/** The upstream branch with the first URL of its remote that a provider recognizes. */
|
||||
private upstreamOf(inspection: Inspection): Upstream | null {
|
||||
const tracked = upstreamBranch(inspection.upstream, inspection.remotes)
|
||||
const remote = tracked?.remote.urls.find(url => this.ctx.forge.repositoryFromRemote(url) !== undefined)
|
||||
return tracked === undefined || remote === undefined ? null : { remote, branch: tracked.branch }
|
||||
}
|
||||
|
||||
/** Bind the open change requests of the upstream branch, once per remote and branch until a refresh. */
|
||||
private discover(upstream: Upstream): void {
|
||||
const key = `${upstream.remote}\n${upstream.branch}`
|
||||
if (this.discovered === key) return
|
||||
this.discovered = key
|
||||
void this.run(this.ctx.forgeSession.discoverBranch(this.session, upstream.remote, upstream.branch))
|
||||
}
|
||||
|
||||
/** Decide whether the Session's sandbox lets the worktree service write the checkout and its shared Git directory. */
|
||||
|
||||
@@ -65,23 +65,37 @@ export function parseRemotes(output: string): GitRemote[] {
|
||||
return [...remotes].map(([name, urls]) => ({ name, urls }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an upstream branch such as `origin/feature/x` into its remote and the
|
||||
* branch on that remote. The remote is the longest remote name followed by `/`
|
||||
* at the start of the upstream branch.
|
||||
* @param upstream - upstream branch such as `origin/main`, or null.
|
||||
* @param remotes - remotes in listed order.
|
||||
* @returns the remote and branch, or undefined without an upstream or a remote it starts with.
|
||||
*/
|
||||
export function upstreamBranch(
|
||||
upstream: string | null,
|
||||
remotes: readonly GitRemote[],
|
||||
): { readonly remote: GitRemote; readonly branch: string } | undefined {
|
||||
if (upstream === null) return undefined
|
||||
const remote = remotes
|
||||
.filter(candidate => upstream.startsWith(`${candidate.name}/`))
|
||||
.reduce<GitRemote | undefined>(
|
||||
(longest, candidate) => longest === undefined || candidate.name.length > longest.name.length ? candidate : longest,
|
||||
undefined,
|
||||
)
|
||||
return remote === undefined ? undefined : { remote, branch: upstream.slice(remote.name.length + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Order remotes for code host recognition: the upstream's remote first, then
|
||||
* `origin`, then the rest in listed order. The upstream's remote is the
|
||||
* longest remote name followed by `/` at the start of the upstream branch.
|
||||
* `origin`, then the rest in listed order.
|
||||
* @param upstream - upstream branch such as `origin/main`, or null.
|
||||
* @param remotes - remotes in listed order.
|
||||
* @returns the same remotes in recognition order.
|
||||
*/
|
||||
export function remoteOrder(upstream: string | null, remotes: readonly GitRemote[]): GitRemote[] {
|
||||
const tracked = upstream === null
|
||||
? undefined
|
||||
: remotes
|
||||
.filter(remote => upstream.startsWith(`${remote.name}/`))
|
||||
.reduce<GitRemote | undefined>(
|
||||
(longest, remote) => longest === undefined || remote.name.length > longest.name.length ? remote : longest,
|
||||
undefined,
|
||||
)
|
||||
const tracked = upstreamBranch(upstream, remotes)?.remote
|
||||
const rank = (remote: GitRemote): number => remote === tracked ? 0 : remote.name === 'origin' ? 1 : 2
|
||||
return [...remotes].sort((left, right) => rank(left) - rank(right))
|
||||
}
|
||||
@@ -173,6 +173,45 @@ describe('forgePanel.follow', () => {
|
||||
expect(opened.provider.probes).toEqual(['fake.test'])
|
||||
})
|
||||
|
||||
it('binds the open change requests of the upstream branch once per remote and branch, and looks again on refresh', async () => {
|
||||
const { repo } = await project()
|
||||
await git(repo, 'checkout', '-b', 'feat')
|
||||
await git(repo, 'update-ref', 'refs/remotes/origin/feat', 'HEAD')
|
||||
await git(repo, 'branch', '--set-upstream-to=origin/feat')
|
||||
const opened = await open(repo)
|
||||
opened.provider.heads.set('feat', [7])
|
||||
const follower = reader(opened)
|
||||
const on = (name: string) => (state: ForgePanelState): boolean => state.checkout.state === 'ready' && state.checkout.checkout.branch === name
|
||||
|
||||
const bound = await follower.until(state => state.changeRequests.some(entry => entry.url === pullUrl(7)))
|
||||
expect(bound.changeRequests).toMatchObject([{ url: pullUrl(7), origin: 'discovered' }])
|
||||
expect(opened.provider.lookups).toEqual(['octo/demo feat'])
|
||||
|
||||
// Leaving for a branch without an upstream and coming back looks up nothing more.
|
||||
await git(repo, 'checkout', '-b', 'other')
|
||||
shellTurn(opened.session, 1, [{ callId: 'leave', command: 'git checkout -b other', output: '' }])
|
||||
await follower.next(on('other'))
|
||||
await git(repo, 'checkout', 'feat')
|
||||
shellTurn(opened.session, 2, [{ callId: 'back', command: 'git checkout feat', output: '' }])
|
||||
await follower.next(on('feat'))
|
||||
expect(opened.provider.lookups).toEqual(['octo/demo feat'])
|
||||
|
||||
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
||||
await vi.waitFor(() => { expect(opened.provider.lookups).toEqual(['octo/demo feat', 'octo/demo feat']) })
|
||||
})
|
||||
|
||||
it('looks up nothing for an upstream on a remote that no provider recognizes', async () => {
|
||||
const { repo } = await project()
|
||||
await git(repo, 'remote', 'add', 'mirror', 'https://example.invalid/octo/demo.git')
|
||||
await git(repo, 'update-ref', 'refs/remotes/mirror/main', 'HEAD')
|
||||
await git(repo, 'branch', '--set-upstream-to=mirror/main')
|
||||
const opened = await open(repo)
|
||||
opened.provider.heads.set('main', [7])
|
||||
|
||||
await reader(opened).until(ready)
|
||||
expect(opened.provider.lookups).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the latest snapshot of a change request while later polls fail', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
|
||||
@@ -287,6 +287,10 @@ export class FakeProvider implements ForgeProvider {
|
||||
readonly diffs: number[] = []
|
||||
/** File texts or failures by `<commit>:<path>`; others return a not-found failure. */
|
||||
readonly texts = new Map<string, string | ForgeFailure>()
|
||||
/** Open change request numbers that a head lookup returns, by head branch. */
|
||||
readonly heads = new Map<string, readonly number[]>()
|
||||
/** Head lookups in call order, as `<owner>/<name> <branch>`. */
|
||||
readonly lookups: string[] = []
|
||||
|
||||
repositoryFromRemote(url: string): ForgeRepository | undefined {
|
||||
const [, host = '', owner = '', name = ''] = /^https:\/\/([a-z.]+\.test)\/([^/]+)\/([^/]+?)\.git$/.exec(url) ?? []
|
||||
@@ -309,8 +313,9 @@ export class FakeProvider implements ForgeProvider {
|
||||
])))
|
||||
}
|
||||
|
||||
findByHead(): Promise<readonly ChangeRequestRef[]> {
|
||||
return Promise.resolve([])
|
||||
findByHead(repository: ForgeRepository, branch: string): Promise<readonly ChangeRequestRef[]> {
|
||||
this.lookups.push(`${repository.owner}/${repository.name} ${branch}`)
|
||||
return Promise.resolve((this.heads.get(branch) ?? []).map(number => ({ repository, number })))
|
||||
}
|
||||
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { baseBranches, parseName, parseRemotes, parseTopLevel, remoteOrder } from '../src/parse.ts'
|
||||
import { baseBranches, parseName, parseRemotes, parseTopLevel, remoteOrder, upstreamBranch } from '../src/parse.ts'
|
||||
import type { GitRemote } from '../src/parse.ts'
|
||||
|
||||
describe('parseTopLevel', () => {
|
||||
@@ -68,6 +68,22 @@ describe('parseRemotes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('upstreamBranch', () => {
|
||||
const remote = (name: string): GitRemote => ({ name, urls: [`https://example.test/${name}.git`] })
|
||||
|
||||
it('splits the upstream at the longest remote name it starts with', () => {
|
||||
const remotes = [remote('origin'), remote('team'), remote('team/mirror')]
|
||||
|
||||
expect(upstreamBranch('team/mirror/feat/x', remotes)).toEqual({ remote: remote('team/mirror'), branch: 'feat/x' })
|
||||
expect(upstreamBranch('origin/main', remotes)).toEqual({ remote: remote('origin'), branch: 'main' })
|
||||
})
|
||||
|
||||
it('names no branch without an upstream or a remote it starts with', () => {
|
||||
expect(upstreamBranch(null, [remote('origin')])).toBeUndefined()
|
||||
expect(upstreamBranch('elsewhere/main', [remote('origin')])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('remoteOrder', () => {
|
||||
const remote = (name: string): GitRemote => ({ name, urls: [`https://example.test/${name}.git`] })
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
en: b8c7e5c40e3c21a4
|
||||
zh: 2360132d865b9d08
|
||||
/deepseek-ai-dsh-experimental-forge-session/summary:
|
||||
en: 7f14cf7961681399
|
||||
zh: d1aa66296dd2c77e
|
||||
en: bd32d5fdeb3d26b1
|
||||
zh: 69398f65b123305d
|
||||
/deepseek-ai-dsh-experimental-forge-session/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge-session/use-this-package:
|
||||
en: 23898b4f5a21b3b5
|
||||
zh: 6d1251b9b862fad8
|
||||
en: 8f9cd15f34cd4836
|
||||
zh: ccd89a61c42f7bb0
|
||||
/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation:
|
||||
en: ccc7ad69f5837528
|
||||
zh: 369c08bf4f7e6e4d
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 1bdaeb7d102c23d8
|
||||
zh: 37a3f0bd58f68363
|
||||
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work:
|
||||
en: 190402afd2f29fa9
|
||||
zh: a343e75d7eebdbb4
|
||||
en: 3136750ace3c3bf9
|
||||
zh: d76a83eaf4de6fee
|
||||
/deepseek-ai-dsh-experimental-forge-session/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
List the pull requests a Session is working on without a separate registry. A host-only projection derives bindings from the Session's own `gh pr create` and `git push` shell results and from binding records. After each push, the service looks up open change requests for the pushed branches and records the unbound ones. Watching a Session polls its bound change requests through `ctx.forge`.
|
||||
List the pull requests a Session is working on without a separate registry. A host-only projection derives bindings from the Session's own `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` shell results and from binding records. After each push, and when a caller such as the forge panel reports the branch a Session works on, the service looks up open change requests for that branch and records the unbound ones. Watching a Session polls its bound change requests through `ctx.forge`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -34,9 +34,9 @@ Mount the service with the forge registry, a provider, and the Session projectio
|
||||
- name: '@deepseek-ai/dsh-experimental-forge-session'
|
||||
```
|
||||
|
||||
`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged.
|
||||
`ctx.forgeSession.changeRequests(session)` returns the visible bindings in first-recorded order. Each entry carries the URL, its `origin` (`created`, `referenced`, `discovered`, or `tracked`), the provider reference or `null` when no provider serves the URL, and the latest polling result or `null`. `watch(session, listener)` is an effect on the calling fiber: while a Session has a watcher, every visible binding that a provider serves is polled, and listeners receive the complete list after each later binding change or polling result. `track(session, url)` binds a URL under the provider's canonical form, or restores a dismissed binding, and throws when no provider recognizes the URL. `dismiss(session, url)` hides a visible binding; other URLs leave the log unchanged. `discoverBranch(session, remote, branch)` runs the lookup that follows a push for a branch on the remote at `remote` and resolves once it has recorded its bindings or failed; a remote that no provider serves changes nothing.
|
||||
|
||||
A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/<number>` URL in its text output. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
|
||||
A `bash` or `pwsh` call binds evidence only from its first result, and only when the tool execution itself succeeded; the command's exit status does not matter. A command containing `gh pr create` or `gh pr new` binds every `/pull/<number>` URL in its text output. A command containing `gh pr view`, `gh pr edit`, or `gh pr checkout` binds, as `referenced`, a `/pull/<number>` URL given as the subcommand's first argument and the URL its output prints for its own change request: gh's `url:` field or a URL alone on a line before the `--` line that starts a description, and JSON `url` members. Links in a description or in other fields do not bind, and a command that also contains `gh pr create` binds its URLs as `created`. A command containing `git push`, optionally with leading `-C`, `-c`, or long Git options, records each branch listed under a `To <remote>` line as created, updated, forced, or already up to date; rejected, deleted, tag, and non-branch refs are skipped. For each pushed branch whose remote a provider serves, the service runs `ctx.forge.findByHead` within `discoveryTimeoutMs` and appends a `discover` record for every returned change request that is not bound, including dismissed ones. Every push repeats the lookup; failures are logged as warnings. URLs that differ only in scheme, letter case, query, fragment, or trailing slashes name the same binding.
|
||||
|
||||
Binding actions are `plugin:forge-session/binding` records listed in the [experimental persistence catalog](../../../docs/experimental-persistence-catalog.md); fork and resume carry them with the shell events, so a fork starts with its parent's bindings. `discoveryTimeoutMs` is listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge-session).
|
||||
|
||||
@@ -84,8 +84,8 @@ None. Binding records are ignorable plugin records outside the model-visible con
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Textual command recognition** — only `bash` and `pwsh` commands containing `gh pr create`, `gh pr new`, or `git push` count, and only `/pull/<number>` URLs bind. Scripts, aliases, `gh api` calls, other CLIs such as `glab`, and `git push --porcelain` output are not recognized; `track` binds such change requests.
|
||||
- **Push-triggered discovery** — a change request opened elsewhere after the latest push appears only after another push or a `track`.
|
||||
- **Textual command recognition** — only `bash` and `pwsh` commands containing `gh pr create`, `gh pr new`, `gh pr view`, `gh pr edit`, `gh pr checkout`, or `git push` count, and only `/pull/<number>` URLs bind. A view, edit, or checkout that names its change request by number or branch binds only when the output prints its URL, which `gh pr checkout` never does. Scripts, aliases, `gh api` calls, other CLIs such as `glab`, and `git push --porcelain` output are not recognized; `track` binds such change requests.
|
||||
- **Discovery timing** — a change request opened elsewhere for a branch appears after the next push of that branch, after a caller such as the forge panel reports the branch again, or after a `track`.
|
||||
- **No model tool** — the model cannot read or change bindings yet.
|
||||
- **Per-Session bindings** — subagent Sessions keep their own bindings; a parent Session does not aggregate them.
|
||||
- **Silent lookup failures** — lookup failures reach the host log only; neither the Session nor its watchers see them.
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
无需单独的登记簿,即可列出一个 Session 正在处理的 pull request。一个仅宿主侧的投影从 Session 自己的 `gh pr create` 与 `git push` shell 结果以及绑定记录派生绑定。每次推送后,服务为被推送的分支查找打开状态的变更请求(change request,即 pull request 或 merge request),并记录尚未绑定的那些。监听一个 Session 时,会通过 `ctx.forge` 轮询其已绑定的变更请求。
|
||||
无需单独的登记簿,即可列出一个 Session 正在处理的 pull request。一个仅宿主侧的投影从 Session 自己的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` shell 结果以及绑定记录派生绑定。每次推送后,以及 forge 面板等调用方报告 Session 所在的分支时,服务为该分支查找打开状态的变更请求(change request,即 pull request 或 merge request),并记录尚未绑定的那些。监听一个 Session 时,会通过 `ctx.forge` 轮询其已绑定的变更请求。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -34,9 +34,9 @@ kind: "package-reference"
|
||||
- name: '@deepseek-ai/dsh-experimental-forge-session'
|
||||
```
|
||||
|
||||
`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。
|
||||
`ctx.forgeSession.changeRequests(session)` 按首次记录的顺序返回可见的绑定。每一项包含 URL、其 `origin`(`created`、`referenced`、`discovered` 或 `tracked`)、提供方引用(没有提供方服务该 URL 时为 `null`)以及最近一次轮询结果(尚无结果时为 `null`)。`watch(session, listener)` 是调用方 fiber 上的 effect:Session 有监听方期间,提供方服务的每个可见绑定都会被轮询;此后每次绑定变化或轮询结果到达时,监听方都会收到完整列表。`track(session, url)` 以提供方的规范形式绑定 URL,或恢复已隐藏的绑定;没有提供方识别该 URL 时抛错。`dismiss(session, url)` 隐藏一个可见绑定;其他 URL 不会改变日志。`discoverBranch(session, remote, branch)` 为位于 `remote` 远端的分支执行推送之后的那次查找,记录完绑定或失败后 resolve;没有提供方服务的远端不会带来任何变化。
|
||||
|
||||
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
|
||||
`bash` 或 `pwsh` 调用只从其第一个结果中提取证据,且仅当工具执行本身成功时才提取;命令的退出状态不影响结果。包含 `gh pr create` 或 `gh pr new` 的命令会绑定其文本输出中的每个 `/pull/<number>` URL。包含 `gh pr view`、`gh pr edit` 或 `gh pr checkout` 的命令以 `referenced` 绑定:作为该子命令第一个参数给出的 `/pull/<number>` URL,以及输出为它所操作的变更请求本身打印的 URL,即 gh 的 `url:` 字段、开始描述正文的 `--` 行之前单独成行的 URL,以及 JSON 的 `url` 成员。描述正文或其他字段里的链接不会绑定;同一命令里还包含 `gh pr create` 时,其 URL 以 `created` 绑定。包含 `git push` 的命令(可带前置的 `-C`、`-c` 或 Git 长选项)会记录 `To <remote>` 行下列出的每个已创建、已更新、已强制推送或已是最新的分支;被拒绝、已删除、标签以及非分支的引用会被跳过。对远端由提供方服务的每个被推送分支,服务在 `discoveryTimeoutMs` 内调用 `ctx.forge.findByHead`,并为返回的每个尚未绑定(包括已隐藏)的变更请求追加一条 `discover` 记录。每次推送都会重新查找;失败记录为警告日志。仅在协议、大小写、查询、片段或末尾斜杠上不同的 URL 指向同一个绑定。
|
||||
|
||||
绑定操作是 `plugin:forge-session/binding` 插件记录,列于[实验性持久化目录](../../../docs/experimental-persistence-catalog.zh.md);fork 与恢复会把它们与 shell 事件一同带上,因此 fork 出的 Session 从父 Session 的绑定开始。`discoveryTimeoutMs` 见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge-session)。
|
||||
|
||||
@@ -84,8 +84,8 @@ kind: "package-reference"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **按文本识别命令**——只有包含 `gh pr create`、`gh pr new` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/<number>` URL 会被绑定。脚本、别名、`gh api` 调用、`glab` 等其他 CLI 以及 `git push --porcelain` 输出都无法识别;这类变更请求需用 `track` 绑定。
|
||||
- **仅由推送触发发现**——最近一次推送之后在别处打开的变更请求,要等到下一次推送或 `track` 才会出现。
|
||||
- **按文本识别命令**——只有包含 `gh pr create`、`gh pr new`、`gh pr view`、`gh pr edit`、`gh pr checkout` 或 `git push` 的 `bash` 与 `pwsh` 命令计入,也只有 `/pull/<number>` URL 会被绑定。以编号或分支指定变更请求的 view、edit 或 checkout,只有输出打印了它的 URL 时才会绑定,而 `gh pr checkout` 从不打印。脚本、别名、`gh api` 调用、`glab` 等其他 CLI 以及 `git push --porcelain` 输出都无法识别;这类变更请求需用 `track` 绑定。
|
||||
- **发现时机**——在别处为某个分支打开的变更请求,要等到该分支的下一次推送、forge 面板等调用方再次报告该分支,或 `track` 之后才会出现。
|
||||
- **没有模型工具**——模型目前还不能读取或修改绑定。
|
||||
- **按 Session 绑定**——subagent 的 Session 保有自己的绑定;父 Session 不会汇总它们。
|
||||
- **查找失败不可见**——查找失败只写入宿主日志;Session 与其监听方都看不到。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Recognition of shell commands and output that bind change requests to a Session. */
|
||||
|
||||
import type { CommandKind } from './types.ts'
|
||||
import type { CommandEvidence } from './types.ts'
|
||||
|
||||
/** One branch a `git push` output reported as updated. */
|
||||
export interface PushedRef {
|
||||
@@ -11,6 +11,10 @@ export interface PushedRef {
|
||||
}
|
||||
|
||||
const CREATE_COMMAND = /\bgh\s+pr\s+(?:create|new)\b/
|
||||
const REFERENCE_COMMAND = /\bgh\s+pr\s+(?:view|edit|checkout)\b/
|
||||
const NAMED_CHANGE_REQUEST = /\bgh\s+pr\s+(?:view|edit|checkout)\s+['"]?(https?:\/\/[^\s"'<>]+?\/pull\/\d+)(?![\w-])/g
|
||||
const OWN_URL_LINE = /^(?:url:\s+)?(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?$/
|
||||
const OWN_URL_MEMBER = /"url"\s*:\s*"(https?:\/\/[^\s"'<>]+?\/pull\/\d+)\/?"/g
|
||||
const PUSH_COMMAND = /\bgit(?:\s+(?:-[Cc]\s+\S+|--[\w-]+(?:=\S+)?))*\s+push\b/
|
||||
const CHANGE_REQUEST_URL = /\bhttps?:\/\/[^\s"'<>]+?\/pull\/\d+(?![\w-])/g
|
||||
const PUSH_TARGET_LINE = /^To (\S+)$/
|
||||
@@ -35,19 +39,24 @@ export function commandOf(argumentsJson: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the binding evidence a command's output can carry. `gh pr create`
|
||||
* (or its alias `gh pr new`) prints the change request URL; `git push`, with
|
||||
* or without leading `-C`, `-c`, and long options, prints the updated refs.
|
||||
* Classify the binding evidence a command and its output can carry.
|
||||
* `gh pr create` (or its alias `gh pr new`) prints the change request URL;
|
||||
* `gh pr view`, `gh pr edit`, and `gh pr checkout` act on one change request,
|
||||
* which their first argument can name and their output can print; `git push`,
|
||||
* with or without leading `-C`, `-c`, and long options, prints the updated
|
||||
* refs. When a command does both, the URLs a result prints bind as created.
|
||||
* The match is textual, so quoted mentions also classify; the result parsers
|
||||
* then find no evidence in their output.
|
||||
* @param command - shell command text.
|
||||
* @returns the evidence kind, or undefined when the command has neither form.
|
||||
* @returns the evidence, or undefined when the command has none of these forms.
|
||||
*/
|
||||
export function commandKind(command: string): CommandKind | undefined {
|
||||
export function commandEvidence(command: string): CommandEvidence | undefined {
|
||||
const create = CREATE_COMMAND.test(command)
|
||||
const push = PUSH_COMMAND.test(command)
|
||||
if (create) return push ? 'both' : 'create'
|
||||
return push ? 'push' : undefined
|
||||
const reference = REFERENCE_COMMAND.test(command)
|
||||
const pushes = PUSH_COMMAND.test(command)
|
||||
if (!create && !reference && !pushes) return undefined
|
||||
const named = reference ? [...new Set(Array.from(command.matchAll(NAMED_CHANGE_REQUEST), ([, url = '']) => url))] : []
|
||||
return { urls: create ? 'created' : reference ? 'referenced' : null, pushes, named }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +68,25 @@ export function changeRequestUrls(text: string): string[] {
|
||||
return [...new Set(text.match(CHANGE_REQUEST_URL))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the URLs that `gh pr view`, `gh pr edit`, or `gh pr checkout` output
|
||||
* prints for the change request it acts on: gh's `url:` field or a URL alone
|
||||
* on a line before the `--` line that starts a description, and JSON `url`
|
||||
* members. Links in a description or in other fields name other pages.
|
||||
* @param text - rendered shell result.
|
||||
* @returns distinct URLs in order of first appearance.
|
||||
*/
|
||||
export function ownChangeRequestUrls(text: string): string[] {
|
||||
const urls: string[] = []
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line === '--') break
|
||||
const [, own] = OWN_URL_LINE.exec(line.trim()) ?? []
|
||||
if (own !== undefined) urls.push(own)
|
||||
}
|
||||
for (const [, member = ''] of text.matchAll(OWN_URL_MEMBER)) urls.push(member)
|
||||
return [...new Set(urls)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find branches that `git push` output reports as created, updated, forced,
|
||||
* or already up to date. Each ref line belongs to the nearest preceding
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { sameChangeRequestUrl } from './commands.ts'
|
||||
import { BINDING_RECORD, forgeChangeRequestsProjection } from './projection.ts'
|
||||
import type { BindingState, Config, ForgeChangeRequestsState, PushedBranch, SessionChangeRequest, SessionChangeRequestsListener } from './types.ts'
|
||||
import type { BindingState, Config, ForgeChangeRequestsState, SessionChangeRequest, SessionChangeRequestsListener } from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
@@ -30,11 +30,13 @@ interface SessionWatch {
|
||||
|
||||
/**
|
||||
* Session-scoped change request bindings. The `forgeChangeRequests`
|
||||
* projection derives them from `gh pr create` and `git push` results and from
|
||||
* `plugin:forge-session/binding` records. After each push result the service
|
||||
* looks up open change requests whose head is a pushed branch and records the
|
||||
* unbound ones as discovered. Watching a Session polls its visible change
|
||||
* requests through `ctx.forge`.
|
||||
* projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`,
|
||||
* `gh pr checkout`, and `git push` results and from
|
||||
* `plugin:forge-session/binding` records. After each push result, and when a
|
||||
* caller reports the branch a Session works on, the service looks up open
|
||||
* change requests whose head is that branch and records the unbound ones as
|
||||
* discovered. Watching a Session polls its visible change requests through
|
||||
* `ctx.forge`.
|
||||
*/
|
||||
export class ForgeSessionService extends Service {
|
||||
static inject = ['forge', 'sessionProjections']
|
||||
@@ -89,6 +91,20 @@ export class ForgeSessionService extends Service {
|
||||
if (existing === undefined || existing.dismissed) appendPluginRecord(session, BINDING_RECORD, { action: 'track', url: canonical })
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up open change requests whose head is a branch the Session works on,
|
||||
* and record the unbound ones as discovered, as after a push of that branch.
|
||||
* A remote that no provider recognizes changes nothing; a failed lookup is
|
||||
* logged and changes nothing.
|
||||
* @param session - Session that receives the bindings.
|
||||
* @param remote - URL of the remote that holds the branch.
|
||||
* @param branch - branch name on that remote.
|
||||
* @returns resolves once the lookup has recorded its bindings or failed.
|
||||
*/
|
||||
discoverBranch(session: Session, remote: string, branch: string): Promise<void> {
|
||||
return this.discover(session, remote, branch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a visible binding. Appends a `dismiss` record; a URL that is not
|
||||
* visibly bound leaves the log unchanged. Discovery never restores a
|
||||
@@ -161,7 +177,7 @@ export class ForgeSessionService extends Service {
|
||||
if (event.type !== 'tool/result' && pluginRecordOf(event)?.type !== BINDING_RECORD) return
|
||||
const state = this.state(session)
|
||||
for (const push of state.pushes) {
|
||||
if (push.seq === event.seq) this.discover(session, push)
|
||||
if (push.seq === event.seq) void this.discover(session, push.remote, push.branch)
|
||||
}
|
||||
const watch = this.watches.get(session)
|
||||
if (watch === undefined || watch.bindings === state.bindings) return
|
||||
@@ -197,15 +213,16 @@ export class ForgeSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private discover(session: Session, push: PushedBranch): void {
|
||||
const repository = this.home.forge.repositoryFromRemote(push.remote)
|
||||
if (repository === undefined) return
|
||||
const task = this.lookup(session, repository, push.branch).catch((error: unknown) => {
|
||||
private discover(session: Session, remote: string, branch: string): Promise<void> {
|
||||
const repository = this.home.forge.repositoryFromRemote(remote)
|
||||
if (repository === undefined) return Promise.resolve()
|
||||
const task = this.lookup(session, repository, branch).catch((error: unknown) => {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
this.home.logger.warn(`forge-session: change request lookup for ${push.branch} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
this.home.logger.warn(`forge-session: change request lookup for ${branch} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
})
|
||||
this.discoveries.add(task)
|
||||
void task.then(() => this.discoveries.delete(task))
|
||||
return task
|
||||
}
|
||||
|
||||
private async lookup(session: Session, repository: ForgeRepository, branch: string): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pluginRecordOf, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { changeRequestUrls, commandKind, commandOf, pushedBranches, sameChangeRequestUrl } from './commands.ts'
|
||||
import { changeRequestUrls, commandEvidence, commandOf, ownChangeRequestUrls, pushedBranches, sameChangeRequestUrl } from './commands.ts'
|
||||
import type { PushedRef } from './commands.ts'
|
||||
import type { BindingOrigin, BindingRecord, BindingState, ForgeChangeRequestsState, PushedBranch } from './types.ts'
|
||||
|
||||
@@ -27,10 +27,15 @@ const bindingRecordSchema: z.ZodType<BindingRecord> = z.object({
|
||||
})
|
||||
|
||||
const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
|
||||
pending: z.array(z.object({ callId: z.string(), kind: z.enum(['create', 'push', 'both']) }).strict()),
|
||||
pending: z.array(z.object({
|
||||
callId: z.string(),
|
||||
urls: z.enum(['created', 'referenced']).nullable(),
|
||||
pushes: z.boolean(),
|
||||
named: z.array(z.string()),
|
||||
}).strict()),
|
||||
bindings: z.array(z.object({
|
||||
url: z.string(),
|
||||
origin: z.enum(['created', 'discovered', 'tracked']),
|
||||
origin: z.enum(['created', 'referenced', 'discovered', 'tracked']),
|
||||
dismissed: z.boolean(),
|
||||
}).strict()),
|
||||
pushes: z.array(z.object({
|
||||
@@ -42,16 +47,18 @@ const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
|
||||
|
||||
/**
|
||||
* The `forgeChangeRequests` unit. A `bash` or `pwsh` call whose command is
|
||||
* `gh pr create` or `git push` stays pending until its first result; a
|
||||
* successful result binds the pull request URLs it prints and records the
|
||||
* branches it reports as pushed. Failed tool executions bind nothing; a
|
||||
* nonzero exit status does not matter, because the printed URL or ref line
|
||||
* is the evidence. `turn/end` drops calls that never received a result.
|
||||
* Binding records bind, restore, and hide URLs.
|
||||
* `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, or
|
||||
* `git push` stays pending until its first result. A successful result binds
|
||||
* every pull request URL a `gh pr create` result prints; for the other `gh pr`
|
||||
* commands it binds the URL the command names and the URL the output prints
|
||||
* for its own change request; and it records the branches it reports as
|
||||
* pushed. Failed tool executions bind nothing; a nonzero exit status does not
|
||||
* matter, because the URL or ref line is the evidence. `turn/end` drops calls
|
||||
* that never received a result. Binding records bind, restore, and hide URLs.
|
||||
*/
|
||||
export const forgeChangeRequestsProjection = {
|
||||
key: 'forgeChangeRequests',
|
||||
stateVersion: 1,
|
||||
stateVersion: 2,
|
||||
stateSchema,
|
||||
init: () => EMPTY,
|
||||
apply,
|
||||
@@ -74,9 +81,9 @@ function apply(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChang
|
||||
function applyCall(state: ForgeChangeRequestsState, call: SessionEventMap['tool/call']): ForgeChangeRequestsState {
|
||||
if (!SHELL_TOOLS.has(call.name)) return state
|
||||
const command = commandOf(call.arguments)
|
||||
const kind = command === undefined ? undefined : commandKind(command)
|
||||
if (kind === undefined) return state
|
||||
return { ...state, pending: [...state.pending, { callId: call.callId, kind }] }
|
||||
const evidence = command === undefined ? undefined : commandEvidence(command)
|
||||
if (evidence === undefined) return state
|
||||
return { ...state, pending: [...state.pending, { callId: call.callId, ...evidence }] }
|
||||
}
|
||||
|
||||
function applyResult(state: ForgeChangeRequestsState, result: SessionEventMap['tool/result'], seq: SessionSeq): ForgeChangeRequestsState {
|
||||
@@ -86,10 +93,11 @@ function applyResult(state: ForgeChangeRequestsState, result: SessionEventMap['t
|
||||
const rest = state.pending.filter(entry => entry !== pending)
|
||||
if (result.message.isError === true) return { ...state, pending: rest }
|
||||
const text = result.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
const printed = pending.urls === 'created' ? changeRequestUrls(text) : ownChangeRequestUrls(text)
|
||||
return {
|
||||
pending: rest,
|
||||
bindings: pending.kind === 'push' ? state.bindings : bind(state.bindings, changeRequestUrls(text), 'created'),
|
||||
pushes: pending.kind === 'create' ? state.pushes : recordPushes(state.pushes, pushedBranches(text), seq),
|
||||
bindings: pending.urls === null ? state.bindings : bind(state.bindings, [...pending.named, ...printed], pending.urls),
|
||||
pushes: pending.pushes ? recordPushes(state.pushes, pushedBranches(text), seq) : state.pushes,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,29 +5,35 @@ import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/**
|
||||
* How a change request became bound to a Session: `created` when a
|
||||
* `gh pr create` result printed its URL, `discovered` when a lookup after a
|
||||
* `git push` found it for the pushed branch, `tracked` when a caller bound it
|
||||
* by URL.
|
||||
* `gh pr create` result printed its URL, `referenced` when a `gh pr view`,
|
||||
* `gh pr edit`, or `gh pr checkout` call named it or its result printed its
|
||||
* own URL, `discovered` when a lookup found it for a branch the Session pushed
|
||||
* or works on, `tracked` when a caller bound it by URL.
|
||||
*/
|
||||
export type BindingOrigin = 'created' | 'discovered' | 'tracked'
|
||||
export type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
|
||||
|
||||
/** Which binding evidence a shell command can print: change request URLs, pushed refs, or both. */
|
||||
export type CommandKind = 'create' | 'push' | 'both'
|
||||
/** Binding evidence that a shell command and its result can carry. */
|
||||
export interface CommandEvidence {
|
||||
/** Origin of the change request URLs the result prints, or null when the command prints none of its own. */
|
||||
readonly urls: 'created' | 'referenced' | null
|
||||
/** Whether the result can report pushed refs. */
|
||||
readonly pushes: boolean
|
||||
/** Change request URLs the command passes as the first argument of `gh pr view`, `gh pr edit`, or `gh pr checkout`. */
|
||||
readonly named: readonly string[]
|
||||
}
|
||||
|
||||
/** Payload of the `plugin:forge-session/binding` record. */
|
||||
export interface BindingRecord {
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */
|
||||
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a branch the Session pushed or works on. */
|
||||
readonly action: 'track' | 'dismiss' | 'discover'
|
||||
/** Web URL of the change request. */
|
||||
readonly url: string
|
||||
}
|
||||
|
||||
/** One shell call whose result the projection still awaits. */
|
||||
export interface PendingCommand {
|
||||
/** One shell call whose result the projection still awaits, with the evidence the call and its result carry. */
|
||||
export interface PendingCommand extends CommandEvidence {
|
||||
/** Tool call id that pairs the call with its result. */
|
||||
readonly callId: string
|
||||
/** Evidence the result may carry. */
|
||||
readonly kind: CommandKind
|
||||
}
|
||||
|
||||
/** One change request remembered by the projection, including hidden ones. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { changeRequestUrls, commandKind, commandOf, pushedBranches, sameChangeRequestUrl } from '../src/commands.ts'
|
||||
import { changeRequestUrls, commandEvidence, commandOf, ownChangeRequestUrls, pushedBranches, sameChangeRequestUrl } from '../src/commands.ts'
|
||||
|
||||
describe('commandOf', () => {
|
||||
it('reads the string command argument', () => {
|
||||
@@ -17,28 +17,56 @@ describe('commandOf', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('commandKind', () => {
|
||||
describe('commandEvidence', () => {
|
||||
const url = 'https://github.com/octo/demo/pull/12'
|
||||
const created = { urls: 'created', pushes: false, named: [] }
|
||||
const pushed = { urls: null, pushes: true, named: [] }
|
||||
|
||||
it.each([
|
||||
['gh pr create --fill', 'create'],
|
||||
['gh pr new -t title -b body', 'create'],
|
||||
['git push', 'push'],
|
||||
['git push -u origin HEAD', 'push'],
|
||||
['git -C ../checkout push origin feat', 'push'],
|
||||
['git -c credential.helper= push', 'push'],
|
||||
['git --no-pager push --force-with-lease', 'push'],
|
||||
['cd repo && git push && gh pr create --fill', 'both'],
|
||||
])('classifies %j as %s', (command, kind) => {
|
||||
expect(commandKind(command)).toBe(kind)
|
||||
['gh pr create --fill', created],
|
||||
['gh pr new -t title -b body', created],
|
||||
['git push', pushed],
|
||||
['git push -u origin HEAD', pushed],
|
||||
['git -C ../checkout push origin feat', pushed],
|
||||
['git -c credential.helper= push', pushed],
|
||||
['git --no-pager push --force-with-lease', pushed],
|
||||
['cd repo && git push && gh pr create --fill', { ...created, pushes: true }],
|
||||
['gh pr view 12 --json url', { urls: 'referenced', pushes: false, named: [] }],
|
||||
[`gh pr view '${url}' --json title`, { urls: 'referenced', pushes: false, named: [url] }],
|
||||
[`gh pr edit ${url}/files --add-label bug`, { urls: 'referenced', pushes: false, named: [url] }],
|
||||
[`gh pr checkout ${url} && git push`, { urls: 'referenced', pushes: true, named: [url] }],
|
||||
[`gh pr view --json url ${url}`, { urls: 'referenced', pushes: false, named: [] }],
|
||||
[`gh pr create --fill && gh pr view ${url}`, { ...created, named: [url] }],
|
||||
])('classifies %j', (command, evidence) => {
|
||||
expect(commandEvidence(command)).toEqual(evidence)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'git commit -m "push the fix"',
|
||||
'git stash push -m wip',
|
||||
'gh pr view 12',
|
||||
'gh pr list --head feat',
|
||||
'gh pr status',
|
||||
'git-push origin',
|
||||
])('ignores %j', (command) => {
|
||||
expect(commandKind(command)).toBeUndefined()
|
||||
expect(commandEvidence(command)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ownChangeRequestUrls', () => {
|
||||
const pull = (number: number): string => `https://github.com/octo/demo/pull/${number}`
|
||||
|
||||
it('reads the url field and a URL alone on a line before a description, and JSON url members', () => {
|
||||
const view = ['title:\tAdd it', 'state:\tOPEN', `url:\t${pull(7)}`, '--', `Stacked on ${pull(8)}`, pull(9)].join('\n')
|
||||
expect(ownChangeRequestUrls(view)).toEqual([pull(7)])
|
||||
expect(ownChangeRequestUrls(`${pull(5)}\n`)).toEqual([pull(5)])
|
||||
const members = JSON.stringify({ number: 6, url: pull(6), body: `see ${pull(8)}`, comments: [{ url: `${pull(6)}#issuecomment-1` }] })
|
||||
expect(ownChangeRequestUrls(members)).toEqual([pull(6)])
|
||||
expect(ownChangeRequestUrls(`{\n "url": "${pull(4)}/"\n}`)).toEqual([pull(4)])
|
||||
})
|
||||
|
||||
it('finds nothing in output that names no change request of its own', () => {
|
||||
expect(ownChangeRequestUrls("Switched to branch 'feat'\n")).toEqual([])
|
||||
expect(ownChangeRequestUrls(`see ${pull(3)} for details`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory'
|
||||
import ForgeSessionService from '../src/index.ts'
|
||||
import type { SessionChangeRequest, SessionChangeRequestsListener } from '../src/index.ts'
|
||||
|
||||
/** GitHub CLI fixture: `pr create` prints PR 8; `api graphql` answers the head lookup and snapshot queries. */
|
||||
/**
|
||||
* GitHub CLI fixture: `pr create` prints PR 8; `pr view` prints PR 9, whose description links PR 6;
|
||||
* `api graphql` answers the head lookup and snapshot queries.
|
||||
*/
|
||||
const FAKE_GH = String.raw`
|
||||
import { appendFileSync } from 'node:fs'
|
||||
const args = process.argv.slice(2)
|
||||
@@ -38,6 +41,10 @@ if (args[0] === 'pr' && args[1] === 'create') {
|
||||
process.stderr.write('\nCreating pull request for other into main in octo/demo\n\n')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
process.stdout.write('title:\tChange 9\nstate:\tOPEN\nurl:\thttps://github.com/octo/demo/pull/9\n--\nStacked on https://github.com/octo/demo/pull/6\n')
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] !== 'api' || args[1] !== 'graphql') {
|
||||
process.stderr.write('unexpected gh invocation\n')
|
||||
process.exit(2)
|
||||
@@ -126,7 +133,7 @@ afterEach(async () => {
|
||||
const suite = process.platform === 'win32' ? describe.skip : describe
|
||||
|
||||
suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
it('binds the pull requests that bash pushes discover and creates, and polls them through the GitHub provider', async () => {
|
||||
it('binds the pull requests that bash pushes discover, creates, and views, and polls them through the GitHub provider', async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-forge-session-loader-')))
|
||||
const bin = join(root, 'bin')
|
||||
await mkdir(bin)
|
||||
@@ -178,7 +185,7 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
for (const entry of ctx.loader.entries()) await entry.fiber?.await()
|
||||
|
||||
const path = `PATH=${JSON.stringify(`${bin}:`)}"$PATH"`
|
||||
const model = new ScriptedModel([`${path} git push -u origin feat`, `${path} gh pr create --fill --head other`])
|
||||
const model = new ScriptedModel([`${path} git push -u origin feat`, `${path} gh pr create --fill --head other`, `${path} gh pr view 9`])
|
||||
ctx.llm.registerAdapter(['fixture'], model)
|
||||
const agent = await ctx.agentLoop.create(SessionId('forge-session-loader'), { provider: 'fixture', model: 'fixture' }, { cwd: root })
|
||||
const idle: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
@@ -189,7 +196,7 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
await idle.promise
|
||||
stop()
|
||||
|
||||
expect(model.requests).toHaveLength(3)
|
||||
expect(model.requests).toHaveLength(4)
|
||||
const github = (number: number) => ({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number })
|
||||
const records = () => agent.session.snapshotEvents().flatMap((event) => {
|
||||
const record = pluginRecordOf(event)
|
||||
@@ -202,6 +209,7 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
expect(byUrl(ctx.forgeSession.changeRequests(agent.session))).toEqual([
|
||||
{ url: 'https://github.com/octo/demo/pull/7', origin: 'discovered', ref: github(7), update: null },
|
||||
{ url: 'https://github.com/octo/demo/pull/8', origin: 'created', ref: github(8), update: null },
|
||||
{ url: 'https://github.com/octo/demo/pull/9', origin: 'referenced', ref: github(9), update: null },
|
||||
])
|
||||
const lookups = (await readFile(join(root, 'gh.log'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||||
expect(lookups.find(args => args.includes('branch=feat'))).toEqual(expect.arrayContaining(['--hostname', 'github.com', 'owner=octo', 'name=demo']))
|
||||
@@ -210,7 +218,7 @@ suite('forge-session through a real cordis.yml Loader composition', () => {
|
||||
ctx.forgeSession.watch(agent.session, listener)
|
||||
await vi.waitFor(() => { expect(byUrl(listener.mock.lastCall?.[0] ?? []).map(entry => entry.update !== null && 'snapshot' in entry.update
|
||||
? [entry.update.snapshot.title, entry.update.snapshot.checksRollup]
|
||||
: null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success']]) })
|
||||
: null)).toEqual([['Change 7', 'pending'], ['Change 8', 'success'], ['Change 9', 'success']]) })
|
||||
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.name === 'cordis:@deepseek-ai/dsh-experimental-forge-session')
|
||||
await entry?.fiber?.dispose()
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('forgeChangeRequests projection', () => {
|
||||
const session = Session.create(SessionId('failed'))
|
||||
shellTurn(session, [
|
||||
{ callId: 'failed', command: 'gh pr create --fill', output: created(1), isError: true },
|
||||
{ callId: 'view', command: 'gh pr view 2', output: pullUrl(2) },
|
||||
{ callId: 'list', command: 'gh pr list --json url', output: pullUrl(2) },
|
||||
])
|
||||
|
||||
expect(fold(session)).toEqual({ pending: [], bindings: [], pushes: [] })
|
||||
@@ -119,6 +119,40 @@ describe('forgeChangeRequests projection', () => {
|
||||
expect(state.pushes.map(push => push.branch)).toEqual(['feat'])
|
||||
})
|
||||
|
||||
it('binds the change request a view, edit, or checkout names or prints as its own, and no other links', () => {
|
||||
const session = Session.create(SessionId('referenced'))
|
||||
shellTurn(session, [
|
||||
{ callId: 'view', command: 'gh pr view 7', output: ['title:\tAdd it', `url:\t${pullUrl(7)}`, '--', `Stacked on ${pullUrl(8)}`].join('\n') },
|
||||
{ callId: 'json', command: 'gh pr view 6 --json number,url,body', output: JSON.stringify({ number: 6, url: pullUrl(6), body: `see ${pullUrl(8)}` }) },
|
||||
{ callId: 'checkout', command: `gh pr checkout ${pullUrl(5)}`, output: "Switched to branch 'feat'" },
|
||||
{ callId: 'edit', command: 'gh pr edit 4 --add-label bug', output: `${pullUrl(4)}\n` },
|
||||
])
|
||||
|
||||
expect(fold(session).bindings).toEqual([
|
||||
{ url: pullUrl(7), origin: 'referenced', dismissed: false },
|
||||
{ url: pullUrl(6), origin: 'referenced', dismissed: false },
|
||||
{ url: pullUrl(5), origin: 'referenced', dismissed: false },
|
||||
{ url: pullUrl(4), origin: 'referenced', dismissed: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a dismissed change request hidden from later references, binds as created alongside a create, and binds nothing from a failed execution', () => {
|
||||
const session = Session.create(SessionId('referenced-rules'))
|
||||
shellTurn(session, [{ callId: 'view', command: `gh pr view ${pullUrl(1)}`, output: `url:\t${pullUrl(1)}` }])
|
||||
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'dismiss', url: pullUrl(1) })
|
||||
shellTurn(session, [
|
||||
{ callId: 'again', command: `gh pr view ${pullUrl(1)}`, output: `url:\t${pullUrl(1)}` },
|
||||
{ callId: 'both', command: `gh pr create --fill && gh pr view ${pullUrl(2)}`, output: `${pullUrl(2)}\n${pullUrl(3)}` },
|
||||
{ callId: 'failed', command: `gh pr checkout ${pullUrl(9)}`, output: 'boom', isError: true },
|
||||
])
|
||||
|
||||
expect(fold(session).bindings).toEqual([
|
||||
{ url: pullUrl(1), origin: 'referenced', dismissed: true },
|
||||
{ url: pullUrl(2), origin: 'created', dismissed: false },
|
||||
{ url: pullUrl(3), origin: 'created', dismissed: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('folds only the first result of a call and drops calls without results at turn end', () => {
|
||||
const session = Session.create(SessionId('pending'))
|
||||
shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: created(1) }])
|
||||
@@ -145,7 +179,7 @@ describe('forgeChangeRequests projection', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 2, step: 1, callId: ToolCallId('orphan'), name: 'bash', arguments: '{"command":"git push"}' })
|
||||
const open = fold(session)
|
||||
expect(open.pending).toEqual([{ callId: 'orphan', kind: 'push' }])
|
||||
expect(open.pending).toEqual([{ callId: 'orphan', urls: null, pushes: true, named: [] }])
|
||||
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
@@ -89,6 +89,19 @@ describe('forgeSession discovery', () => {
|
||||
expect(records(session)).toEqual([])
|
||||
})
|
||||
|
||||
it('records the open change requests of a branch a caller reports, and nothing for a remote no provider serves', async () => {
|
||||
const { ctx, provider, session } = await open()
|
||||
provider.heads.set('feat', [pull(7), pull(8)])
|
||||
create(session, 'create', pullUrl(8))
|
||||
|
||||
await ctx.forgeSession.discoverBranch(session, 'fake.test:octo/demo.git', 'feat')
|
||||
expect(provider.lookups.map(lookup => [lookup.repository, lookup.branch])).toEqual([[demo, 'feat']])
|
||||
expect(records(session)).toEqual([{ action: 'discover', url: pullUrl(7) }])
|
||||
|
||||
await ctx.forgeSession.discoverBranch(session, 'github.com:octo/demo.git', 'feat')
|
||||
expect(provider.lookups).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('looks up again on every push of a branch', async () => {
|
||||
const { provider, session } = await open()
|
||||
push(session, 'first', 'feat')
|
||||
|
||||
@@ -1402,7 +1402,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'forgeSession',
|
||||
summary: 'Session-scoped change request bindings.',
|
||||
description: 'Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.',
|
||||
description: 'Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'changeRequests(session: Session): SessionChangeRequest[]',
|
||||
@@ -1416,6 +1416,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'session', description: 'Session that receives the binding.' }, { name: 'url', description: 'web URL of the change request.' }],
|
||||
throws: ['when no registered provider recognizes the URL.'],
|
||||
},
|
||||
{
|
||||
signature: 'discoverBranch(session: Session, remote: string, branch: string): Promise<void>',
|
||||
description: 'Look up open change requests whose head is a branch the Session works on, and record the unbound ones as discovered, as after a push of that branch. A remote that no provider recognizes changes nothing; a failed lookup is logged and changes nothing.',
|
||||
parameters: [{ name: 'session', description: 'Session that receives the bindings.' }, { name: 'remote', description: 'URL of the remote that holds the branch.' }, { name: 'branch', description: 'branch name on that remote.' }],
|
||||
returns: 'resolves once the lookup has recorded its bindings or failed.',
|
||||
},
|
||||
{
|
||||
signature: 'dismiss(session: Session, url: string): void',
|
||||
description: 'Hide a visible binding. Appends a `dismiss` record; a URL that is not visibly bound leaves the log unchanged. Discovery never restores a dismissed binding; `track` does.',
|
||||
@@ -5379,7 +5385,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'BindingOrigin',
|
||||
declaration: 'export type BindingOrigin = \'created\' | \'discovered\' | \'tracked\';',
|
||||
declaration: 'export type BindingOrigin = \'created\' | \'referenced\' | \'discovered\' | \'tracked\';',
|
||||
},
|
||||
{
|
||||
name: 'BoxProps',
|
||||
|
||||
Reference in new issue
Block a user