opencode-forgekeeper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +387 -0
  2. package/dist/index.js +2319 -0
  3. package/dist/tui.js +1529 -0
  4. package/package.json +49 -0
package/README.md ADDED
@@ -0,0 +1,387 @@
1
+ # opencode-forgekeeper
2
+
3
+ An [OpenCode](https://opencode.ai) plugin that tracks the issues or PRs/MRs a session works on.
4
+ It prefixes the session title with their references, shows the PR/MR status in the prompt
5
+ footer, and tells the agent about new review feedback. It supports GitLab and GitHub. The status
6
+ and review notifications are optional; see [Keep only session titles](#keep-only-session-titles).
7
+
8
+ Requires OpenCode 2. It replaces
9
+ [`opencode-forge-session-title`](https://www.npmjs.com/package/opencode-forge-session-title); see
10
+ [Migrating from opencode-forge-session-title](#migrating-from-opencode-forge-session-title).
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ opencode plugin add opencode-forgekeeper
16
+ ```
17
+
18
+ This adds the plugin to your `opencode.json`:
19
+
20
+ ```json
21
+ {
22
+ "$schema": "https://opencode.ai/config.json",
23
+ "plugins": ["opencode-forgekeeper"]
24
+ }
25
+ ```
26
+
27
+ The package has a server entry point and a CLI entry point, which OpenCode loads automatically.
28
+ The server owns session targets, title prefixes, forge lookups, polling, and notification
29
+ delivery. The CLI holds leases on the sessions it has shown, their latest snapshots, and each
30
+ directory's settings. It renders the status that the server serves and shows its notices as
31
+ toasts.
32
+
33
+ ### Requirements
34
+
35
+ - [`glab`](https://gitlab.com/gitlab-org/cli) for GitLab and [`gh`](https://cli.github.com/)
36
+ for GitHub, on the `PATH` of the OpenCode server. Each is only needed for its forge.
37
+ - Each CLI logged in to every host it's used for: `glab auth login --hostname <host>` and
38
+ `gh auth login --hostname <host>`. The plugin uses their credentials and has none of its
39
+ own.
40
+ - For self-managed GitLab hosts not named `gitlab.*`, and for GitHub Enterprise, the
41
+ `hosts` and `githubHosts` [options](#options).
42
+
43
+ Without a forge CLI, titles still follow targets and branches, and `/forge-status` reports
44
+ what's missing, such as `glab is not installed`.
45
+
46
+ ## Session targets
47
+
48
+ The server registers the `set_session_target` tool and adds instructions to the agent's
49
+ context. When you establish or change the primary issue, PR, or MR, the agent sets its full
50
+ URL as the session target. Background references, comparisons, and dependencies keep the
51
+ current target. After the agent creates a PR/MR for the current task, it sets the new PR/MR
52
+ as the target, passing the current issue as `issue_url`.
53
+
54
+ For example, if the title starts with `[#123, !45]` and you ask the agent to review MR `!456`,
55
+ the prefix becomes `[!456]`. Without `issue_url`, the server reads the PR/MR's source branch
56
+ and takes the issue number from its name, so reviewing an MR from `321-fix-timeout` produces
57
+ `[#321, !456]`.
58
+
59
+ Targets persist per session across plugin reloads. Calling `set_session_target` with
60
+ `target: "branch"` returns to branch-based naming. Child sessions and untitled sessions are
61
+ skipped.
62
+
63
+ ### Several targets
64
+
65
+ A session can work on up to 50 issues and PRs/MRs at once, such as a set of related MRs
66
+ under review. Pass their full URLs in `targets` instead of `target`, and choose how they
67
+ change the current targets with `operation`:
68
+
69
+ | `operation` | Effect |
70
+ | ------------------- | ------------------------------------------------------------------------------ |
71
+ | `replace` (default) | Sets exactly the given targets. |
72
+ | `add` | Appends the given targets and keeps the current ones. |
73
+ | `remove` | Drops the given targets. Removing the last one returns to branch-based naming. |
74
+
75
+ ```json
76
+ {
77
+ "targets": [
78
+ "https://gitlab.com/group/project/-/merge_requests/101",
79
+ "https://gitlab.com/group/other/-/merge_requests/102"
80
+ ],
81
+ "operation": "add"
82
+ }
83
+ ```
84
+
85
+ The server normalizes and deduplicates the URLs, which can come from different projects.
86
+
87
+ With `targets`, `issue_url` relates every listed target to the same issue, such as a set of
88
+ MRs for one issue. Every target must then be a PR/MR from the issue's forge. With
89
+ `operation: "add"`, only the added targets get the issue.
90
+
91
+ The agent guidance is generic. When you ask the agent to work on several issues or PRs/MRs,
92
+ it sets all of them as targets. If you describe them instead of linking them, the agent
93
+ finds them first, then sets them. After it creates a PR/MR for a task with several targets,
94
+ it adds the new one instead of replacing the others.
95
+
96
+ With several targets, the title prefix lists their own references, such as `[!101, !102]`.
97
+ When every target has the same related issue, from `issue_url` or from its source branch
98
+ name, the issue comes first, such as `[#12, !101, !102]`. More than four targets list the
99
+ first three and count the rest, such as `[!101, !102, !103, +3]`.
100
+
101
+ Target URLs can name a GitLab MR, issue, or work item on any host, or a GitHub PR or issue.
102
+ GitHub URLs are accepted on any host that isn't recognizably GitLab, so GitHub Enterprise
103
+ targets work.
104
+
105
+ ## Branch-based naming
106
+
107
+ Without an explicit target, the title follows the checked-out branch:
108
+
109
+ 1. The prefix starts with the issue number from the branch name, or the branch name
110
+ without one.
111
+ 2. It adds the newest open PR/MR from the branch, or `!N/A` (GitLab) or `#N/A` (GitHub)
112
+ until one exists.
113
+
114
+ The title is reconciled after target changes, title changes, and successful agent runs.
115
+ Text you add to the title, including your own bracketed prefixes, is kept.
116
+
117
+ Branch names longer than 40 characters are shortened with an ellipsis in the prefix. Titles
118
+ stay within 100 characters by shortening the text after the prefix, never the prefix
119
+ itself, so the plugin can still find and replace its prefix later.
120
+
121
+ The default branch and branches named `main`, `master`, `develop`, or `HEAD` get no prefix.
122
+
123
+ The branch's remote, pushed branch name, and forge come from the same repository discovery
124
+ as the status footer. That discovery uses the branch's push or upstream remote, recognizes
125
+ forks, and classifies hosts with the `hosts` and `githubHosts` options.
126
+
127
+ | Pattern | Example branch | Issue |
128
+ | --------------------------------- | ----------------------- | ----- |
129
+ | `<prefix>/<number>-<description>` | `feature/123-add-login` | `123` |
130
+ | `<number>-<description>` | `123-fix-typo` | `123` |
131
+ | `<description>-<number>` | `fix-typo-123` | `123` |
132
+ | `<prefix>/<number>/<description>` | `user/123/some-work` | `123` |
133
+ | `issue-<number>`, `gh-<number>` | `gh-42-improve-perf` | `42` |
134
+ | `fix-<number>`, `feat-<number>` | `fix-99` | `99` |
135
+
136
+ ## Status footer
137
+
138
+ The server picks each session's PRs/MRs in this order:
139
+
140
+ 1. The explicit targets.
141
+ 1. A PR/MR number in a title prefix that you wrote, such as `!456` in `[!456] Review`.
142
+ 1. The checked-out branch's open PRs/MRs.
143
+
144
+ A prefix that the plugin wrote from the branch doesn't count as a title reference, so the
145
+ footer follows the branch when its PR/MR closes and another opens. The title prefix itself
146
+ reuses a branch's PR/MR number for up to 10 minutes.
147
+
148
+ Explicit targets that aren't PRs/MRs, such as issues, show no status. Unresolved explicit
149
+ PRs/MRs never fall back to the branch.
150
+
151
+ The server runs at most four `glab` or `gh` commands at once for status and review
152
+ feedback, across all sessions. When some target lookups fail, the footer shows the others
153
+ and counts the failures as `N unavailable`. A target whose lookup failed with an error
154
+ keeps its previous status until a later poll succeeds. A GitLab response that reports an
155
+ error counts as a failed lookup, never as a missing MR, so it's retried with backoff.
156
+
157
+ When the CLI can't reach the server, such as while the server plugin reloads, the footer
158
+ keeps the last status and marks it `stale`. The CLI never looks PRs/MRs up itself, so it
159
+ can't show another project's PR/MR with the same number as a target.
160
+
161
+ With several PR/MR targets, the footer shows counts instead of one PR/MR's status, such as
162
+ `5 MRs · 🤖 2 reviewing · 1 CI failed · 1 conflict · 1 merged`. Clicking it opens the
163
+ status dialog, which lists each PR/MR.
164
+
165
+ The footer shows checks or the pipeline, unresolved threads, conflicts, approval, and a
166
+ running GitLab Duo review. GitHub also shows a requested change. Unknown mergeability stays
167
+ unknown rather than appearing conflict-free.
168
+
169
+ The footer shows `approved` only when GitLab reports that approval requirements are met,
170
+ at least one person approved, and every human reviewer approved. Bot approvals, such as
171
+ Duo's, don't count. While human reviewers haven't approved, it shows `awaiting @username`,
172
+ or `awaiting N reviewers` for more than two.
173
+
174
+ Clicking the PR/MR number or pipeline opens it in the browser. Clicking any other indicator
175
+ opens the status dialog. These commands are also available from the command palette:
176
+
177
+ - `/forge-status` opens a dialog with full status and a refresh action. Aliases:
178
+ `/mr-status` and `/pr-status`.
179
+ - `/forge-open` opens the PR/MR in the browser. Aliases: `/mr-open` and `/pr-open`.
180
+
181
+ The server caches each session's status and polls every 2 minutes while a CLI shows an open
182
+ PR/MR, or every 30 seconds while an automated review runs. Each change reaches the CLIs as
183
+ an RPC event.
184
+
185
+ ### Which sessions the server watches
186
+
187
+ The server watches only sessions that a CLI has shown since it started. The CLI renews a
188
+ lease on each of them every minute, and a lease that isn't renewed for 3 minutes ends, such
189
+ as after the CLI exits. A session keeps polling while it's on screen in any CLI. A hidden
190
+ session with a lease keeps polling while it has a running automated review, human feedback
191
+ to watch, or a notification to retry.
192
+
193
+ A hidden session that the server hasn't looked up yet, such as after the server plugin
194
+ reloads, is looked up once to find out whether it has reviews to watch. A target change
195
+ also looks up a hidden session that isn't polling.
196
+
197
+ Each checkout's server plugin instance watches only the sessions in that checkout. When a
198
+ session moves to another worktree, the CLI moves its lease to that checkout's instance. The
199
+ old instance stops watching the session, even if another CLI still leases it there. Before
200
+ each notification, the server reads the session again, so a session that moved while a
201
+ lookup or fetch ran is left to its new checkout. A review that finishes during the move can
202
+ go unannounced, because the new instance never saw it running. If the session can't be
203
+ read, the notification is retried later instead of being dropped.
204
+
205
+ After that read, the server checks the targets again and writes the message for the
206
+ PRs/MRs that are still targets. A target removed in the meantime isn't mentioned.
207
+
208
+ When the server plugin stops, such as on a reload, no new lookup, fetch, notification,
209
+ title update, or target change starts, and queued `glab` and `gh` commands don't run. A
210
+ title update that is still looking up its branch doesn't rename the session or store
211
+ anything, because the replacement instance owns the session's state by then. Notifications
212
+ that were already being sent can finish and store their outcome, so the replacement instance
213
+ doesn't send them again. Cleanup waits up to 5 seconds for them. One that takes longer may be
214
+ sent again.
215
+
216
+ ## Review notifications
217
+
218
+ For each explicit target that is an open PR/MR, the server tells the agent about reviews.
219
+ A target that merges or closes gets no more notifications, and a notification waiting for a
220
+ retry is dropped.
221
+ Each notification is a queued synthetic message, followed by a toast in the CLIs that have
222
+ shown the session. The message resumes the session unless `resumeSession` is `false`.
223
+ Messages include the full URL of each PR/MR. PRs/MRs from the title or the branch don't get
224
+ notifications.
225
+
226
+ Before it sends a notification, the server checks that the PR/MR is still one of the
227
+ session's targets. A lookup that started before the targets changed is discarded, so a
228
+ removed target never triggers a notification.
229
+
230
+ | Notification | GitLab | GitHub |
231
+ | ------------------------------------------ | ----------- | ------------- |
232
+ | An automated review finished with feedback | GitLab Duo | Not supported |
233
+ | New human review feedback | MR comments | Not supported |
234
+
235
+ - **Automated reviews**: Duo's final state of `REVIEWED` or `REQUESTED_CHANGES` asks the
236
+ agent to read its comments. Other final states, such as `APPROVED`, send nothing. The
237
+ server notifies only when it sees a review go from running to finished, so a review that
238
+ finished before the server first saw it running isn't announced. Reviews that finish in
239
+ the same poll share one message.
240
+ - **Human reviews**: new comments and replies from people other than you and bots are
241
+ announced once none of their unresolved comments has changed for 5 minutes. Expect one
242
+ message 5 to 7 minutes after the review goes quiet. Resolved threads are skipped.
243
+
244
+ The first look at a PR/MR records its newest comment as a baseline, so older comments never
245
+ count as new. Announced comments are stored per session and PR/MR, so a restart replays
246
+ nothing but still catches comments posted in the meantime.
247
+
248
+ A failed send shows an error toast and is retried after 10 minutes. A failed automated
249
+ review notification stays pending in the server's storage, so it's retried even after a
250
+ restart, as long as the review still has feedback and the PR/MR is still an open target.
251
+
252
+ Every checkout's server plugin instance shares the plugin's storage, so each baseline and
253
+ each pending notification has its own storage key. Instances never overwrite each other's
254
+ records. A failed storage write shows an error toast once.
255
+
256
+ On GitLab, the server reads the newest 2,000 comments. On busier MRs, edits to older
257
+ comments don't count as activity.
258
+
259
+ ## Options
260
+
261
+ Set options with the object form of the `plugins` entry in `opencode.json`:
262
+
263
+ ```json
264
+ {
265
+ "plugins": [{ "package": "opencode-forgekeeper", "options": { "notifyHumanReviews": false } }]
266
+ }
267
+ ```
268
+
269
+ | Option | Default | Description |
270
+ | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
271
+ | `reviewStatus` | `true` | Show the PR/MR status and notify the agent about reviews. Set to `false` to keep only session targets and titles. |
272
+ | `hosts` | `["gitlab.com"]` | GitLab hosts whose remotes are recognized, besides hosts named `gitlab.*`. |
273
+ | `githubHosts` | `["github.com"]` | GitHub hosts, including GitHub Enterprise hosts. |
274
+ | `pollSeconds` | `120` | Normal polling interval. Running automated reviews poll at 30 seconds or this value, whichever is lower. |
275
+ | `notifyAutomatedReviews` | `true` | Tell the agent when an automated review finishes with feedback. The former name, `notifyDuoReview`, still works. |
276
+ | `notifyHumanReviews` | `true` | Tell the agent about new human review feedback. |
277
+ | `resumeSession` | `true` | Resume the session when telling the agent about a review. Set to `false` to queue the message for the session's next turn instead. |
278
+
279
+ The server entry point reads every option, so changing one takes effect when the server
280
+ plugin reloads. The CLI has no options of its own. Each checkout's server reads the
281
+ options for its location, so the CLI can show status in one project and not in another.
282
+
283
+ ### Keep only session titles
284
+
285
+ To keep the behavior of `opencode-forge-session-title` alone, turn off review status:
286
+
287
+ ```json
288
+ {
289
+ "plugins": [{ "package": "opencode-forgekeeper", "options": { "reviewStatus": false } }]
290
+ }
291
+ ```
292
+
293
+ The server still registers `set_session_target`, adds the agent guidance, and maintains the
294
+ title prefix. It doesn't poll or send review notifications, and the CLI shows no footer.
295
+ `/forge-status` and `/forge-open` explain that the status is off.
296
+
297
+ ## RPC
298
+
299
+ The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) that the CLI
300
+ uses, defined in `src/rpc.ts`:
301
+
302
+ - `target({ sessionID })` returns `{ url, issueUrl?, targets }` for explicit targets, and
303
+ `{}` in branch mode. `targets` lists every target as `{ url, issueUrl? }`. `url` and
304
+ `issueUrl` describe the first target, for callers that expect a single target.
305
+ - `watch({ clientID, keys, visible? })` renews the caller's leases on `keys` and returns
306
+ `{ enabled, statuses }`. A key is a session ID, or `""` for the checkout outside a
307
+ session. `visible` is the key on screen.
308
+ - `release({ clientID })` drops the caller's leases.
309
+ - `refresh({ key })` looks the key up now and returns `{ enabled, snapshot }`.
310
+ - `targetChanged` fires after `set_session_target` runs, with the same fields as `target`
311
+ and the `sessionID`. It has only the `sessionID` in branch mode.
312
+ - `status` fires with `{ directory, key, snapshot }` when a key's status changes.
313
+ - `notice` fires with `{ sessionID?, title?, message, variant }` for the CLIs to show as a
314
+ toast.
315
+
316
+ Keys are relative to the location of the call or event, because each checkout's server
317
+ plugin instance watches its own sessions.
318
+
319
+ ## Migrating from opencode-forge-session-title
320
+
321
+ Replace `opencode-forge-session-title` with `opencode-forgekeeper` in `opencode.json`. Never
322
+ load both: the server keeps `opencode-forge-session-title`'s plugin and RPC IDs, so the two
323
+ would collide.
324
+
325
+ | Entry point | Plugin ID | Stored state |
326
+ | ----------- | ------------------------------ | ---------------------------------------------------------------------------------------- |
327
+ | Server | `opencode-forge-session-title` | Session targets, owned title prefixes, human-review baselines, and pending notifications |
328
+ | CLI | `pedropombeiro.forgekeeper` | None |
329
+
330
+ Because the IDs match, stored targets carry over, and callers of the `target` RPC keep working.
331
+ The server reads a session's single stored `target` as a list of one, and writes `targets` from
332
+ the next change on.
333
+
334
+ With default options, the new footer and review notifications start right away, and
335
+ notifications resume the session. Set `reviewStatus` to `false` to keep only titles, or
336
+ `resumeSession` to `false` to queue notifications for the next turn.
337
+
338
+ ## Code layout
339
+
340
+ - `src/index.ts` wires the server: the forge catalogs from the options, with a shared
341
+ concurrency limit for status, and title lookups.
342
+ - `src/server.ts` registers the tool, the agent guidance, title reconciliation, and the RPC.
343
+ - `src/status-server.ts` connects the status service to sessions, storage, notifications,
344
+ and RPC events. `src/status-service.ts` holds the leases and decides which keys poll.
345
+ - `src/target.ts` parses, normalizes, changes, and formats session targets, and matches
346
+ forge URLs against them.
347
+ - `src/limit.ts` bounds how many forge requests run at once.
348
+ - `src/title.ts` extracts branch issue numbers and reconciles title prefixes.
349
+ - `src/options.ts` reads the plugin's status options.
350
+ - `src/lookups.ts` answers the title's forge questions: the branch's newest PR/MR number
351
+ and a PR/MR's source branch.
352
+ - `src/status-client.ts` holds the CLI's leases, snapshots, and per-directory settings.
353
+ `src/tui.tsx` connects it to the RPC and renders the footer, dialog, commands, and notices.
354
+ - `src/forge.ts` defines the forge-neutral types, the `Forge` adapter interface, and
355
+ `ForgeTraits` with its optional `automatedReview` and `feedback` capabilities.
356
+ - `src/gitlab.ts` and `src/github.ts` provide each forge's adapter and traits. Adapters own
357
+ API calls, pagination, error classification, and status normalization. Traits cover
358
+ everything else: hosts, URL parsing, reference syntax, vocabulary, and forge-specific
359
+ footer and dialog content.
360
+ - `src/forges.ts` registers each forge's traits, classifies hosts, opens adapters, and
361
+ parses URLs.
362
+ - `src/git.ts` resolves a checkout's remotes, branch, and pushed branch name.
363
+ - `src/locate.ts` picks each key's PRs/MRs. `src/store.ts` caches lookups, polls, backs off
364
+ after failures, and discards lookups that started before a change. `src/format.ts`
365
+ renders the footer and dialog.
366
+ - `src/automated-review-watch.ts`, `src/human-review-watch.ts`, and
367
+ `src/gitlab-feedback.ts` decide when to notify the agent.
368
+
369
+ Shared code never compares a forge against a specific name. It asks the forge's traits, or
370
+ checks for a capability, instead. To add a forge, add its kind to `ForgeKind` and
371
+ `ReviewRequest`, implement `Forge` and `ForgeTraits`, and register the traits in
372
+ `src/forges.ts`.
373
+
374
+ ## Development
375
+
376
+ From the repository root:
377
+
378
+ ```bash
379
+ mise run test
380
+ mise run typecheck
381
+ mise run lint
382
+ mise run build forgekeeper
383
+ ```
384
+
385
+ ## License
386
+
387
+ [MIT](../../LICENSE)