Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions packages/forgekeeper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,9 @@ title prefix. It doesn't poll or send review notifications, and the CLI shows no

## RPC

The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) that the CLI
uses, defined in `src/rpc.ts`:
The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) with the ID
`opencode-forgekeeper`, defined in `src/rpc.ts`. The CLI uses it, and other plugins can call
`target` and listen for `targetChanged`:

- `target({ sessionID })` returns `{ url, issueUrl?, targets }` for explicit targets, and
`{}` in branch mode. `targets` lists every target as `{ url, issueUrl? }`. `url` and
Expand All @@ -318,18 +319,25 @@ plugin instance watches its own sessions.

## Migrating from opencode-forge-session-title

Replace `opencode-forge-session-title` with `opencode-forgekeeper` in `opencode.json`. Never
load both: the server keeps `opencode-forge-session-title`'s plugin and RPC IDs, so the two
would collide.

| Entry point | Plugin ID | Stored state |
| ----------- | ------------------------------ | ---------------------------------------------------------------------------------------- |
| Server | `opencode-forge-session-title` | Session targets, owned title prefixes, human-review baselines, and pending notifications |
| CLI | `pedropombeiro.forgekeeper` | None |

Because the IDs match, stored targets carry over, and callers of the `target` RPC keep working.
The server reads a session's single stored `target` as a list of one, and writes `targets` from
the next change on.
Replace `opencode-forge-session-title` with `opencode-forgekeeper` in `opencode.json`. Upgrading
from `opencode-forgekeeper` 1.x works the same way, because 1.x used the same IDs as
`opencode-forge-session-title`.

| Entry point | Plugin ID | Stored state |
| ----------- | --------------------------- | ---------------------------------------------------------------------------------------- |
| Server | `opencode-forgekeeper` | Session targets, owned title prefixes, human-review baselines, and pending notifications |
| CLI | `pedropombeiro.forgekeeper` | None |

OpenCode scopes plugin storage by plugin ID, so nothing stored under the former ID,
`opencode-forge-session-title`, carries over:

- Sessions start in branch-based naming. Ask the agent to set their targets again.
- A title that kept a single-reference prefix from before, such as `[!456] Review`, can get a
second prefix, such as `[#12, !45] [!456] Review`. Remove the older one by hand. Older prefixes
with two references, such as `[#12, !45]`, are replaced automatically.
- The first look at each PR/MR records a new review baseline, so comments posted before then
aren't announced.
- Callers of the RPC need the new ID, `opencode-forgekeeper`.

With default options, the new footer and review notifications start right away, and
notifications resume the session. Set `reviewStatus` to `false` to keep only titles, or
Expand Down
15 changes: 6 additions & 9 deletions packages/forgekeeper/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// Merges opencode-forge-session-title and forge-review-status. This server
// entry point owns each session's targets: the `set_session_target` tool, the
// agent guidance, and the title prefix. It also watches the PRs/MRs of the
// sessions that CLIs show and tells sessions about their reviews.
// opencode.json loads this directory through a path entry; OpenCode then loads
// tui.tsx, which renders the status that this entry point serves, in the CLI.
//
// The ID is opencode-forge-session-title's, so stored targets carry over.
// This server entry point owns each session's targets: the
// `set_session_target` tool, the agent guidance, and the title prefix. It
// also watches the PRs/MRs of the sessions that CLIs show and tells sessions
// about their reviews. OpenCode loads the package's CLI entry point, tui.tsx,
// which renders the status that this entry point serves.
import type { Plugin } from '@opencode/plugin';
import { exec, execWithTimeout } from './exec';
import { Forges, hostsFrom } from './forges';
Expand All @@ -14,7 +11,7 @@ import { createTitleLookups } from './lookups';
import { setupServer } from './server';

export default {
id: 'opencode-forge-session-title',
id: 'opencode-forgekeeper',
async setup(ctx) {
const hosts = hostsFrom((ctx.options ?? {}) as Record<string, unknown>);
// Status polling and feedback fetches share one limit across sessions,
Expand Down
7 changes: 3 additions & 4 deletions packages/forgekeeper/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Contract between the server, which stores each session's targets and
// watches their PRs/MRs, and the CLI, which renders their status. The ID is
// the one that opencode-forge-session-title registered, so callers written
// for that package keep working.
// watches their PRs/MRs, and the CLI, which renders their status. Other
// plugins can call `target` and listen for `targetChanged` too.
//
// Status keys are session IDs, or "" for the checkout outside a session. A
// key is relative to the location the call or event names, because each
Expand Down Expand Up @@ -36,7 +35,7 @@ const status = {
} as const;

export const ForgeSessionRpc = {
id: 'opencode-forge-session-title',
id: 'opencode-forgekeeper',
methods: {
// The session's explicit targets, or `{}` in automatic branch mode.
target: {
Expand Down
4 changes: 2 additions & 2 deletions packages/forgekeeper/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,9 @@ describe('session targets', () => {
await reloaded.cleanup();
});

test('reads a single target stored before sessions could have several', async () => {
test('keeps the inferred issue of a stored target when adding another', async () => {
const storage = new Map<string, unknown>([
['sessions/one', { target: { url: mr, branchIssue: '123' }, prefix: '[#123, !45]' }],
['sessions/one', { targets: [{ url: mr, branchIssue: '123' }], prefix: '[#123, !45]' }],
]);
const app = await harness({ storage, sourceBranches: new Map([['456', '999-other']]) });
expect(await app.target()).toEqual({ url: mr, targets: [{ url: mr }] });
Expand Down
8 changes: 2 additions & 6 deletions packages/forgekeeper/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const SKIP_BRANCHES = new Set(['master', 'main', 'HEAD', 'develop']);
// Stored per session. `prefix` is the title prefix this plugin last wrote, so
// it can be replaced without touching text the user added. Before sessions
// could have several targets, the state held a single `target`.
type StoredState = { target?: Target; targets?: Target[]; prefix?: string };
type StoredState = { targets?: Target[]; prefix?: string };
type SessionState = { targets: Target[]; prefix?: string };

const GUIDANCE = [
Expand Down Expand Up @@ -97,11 +97,7 @@ export async function setupServer(
const started = revision(sessionID);
const stored =
((await ctx.storage.get(`sessions/${sessionID}`)) as StoredState | undefined) ?? {};
const targets = Array.isArray(stored.targets)
? stored.targets
: stored.target
? [stored.target]
: [];
const targets = Array.isArray(stored.targets) ? stored.targets : [];
if (revision(sessionID) === started) known.set(sessionID, targets);
return { targets, ...(stored.prefix ? { prefix: stored.prefix } : {}) };
}
Expand Down
8 changes: 2 additions & 6 deletions packages/forgekeeper/src/status-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,6 @@ const pendingPrefix = (sessionID: string) => `${PENDING_PREFIX}${encodeURICompon
const pendingKey = (sessionID: string, url: string) =>
`${pendingPrefix(sessionID)}${encodeURIComponent(url)}`;
const feedbackRecordKey = (key: string) => `${FEEDBACK_PREFIX}${encodeURIComponent(key)}`;
// Whole logs from before records had their own keys. They're removed, so the
// first look at each PR/MR records a new baseline instead of replaying.
const LEGACY_KEYS = ['status/humanReviewFeedback', 'status/automatedReviewPending'];

// The title without the prefix this plugin wrote.
export function userTitle(
Expand Down Expand Up @@ -297,10 +294,9 @@ export async function setupStatus(
});
invalidate = (sessionID) => service.invalidate(sessionID);

// Housekeeping, on the write queue so it doesn't hold up setup: removes the
// former whole logs, and feedback records not seen for a long time.
// Housekeeping, on the write queue so it doesn't hold up setup: removes
// feedback records not seen for a long time.
write(async () => {
for (const key of LEGACY_KEYS) await ctx.storage.remove(key);
const at = (deps.now ?? Date.now)();
for (const { key, value } of await scanAll(FEEDBACK_PREFIX)) {
if (isExpired(value as FeedbackRecord, at)) await ctx.storage.remove(key);
Expand Down
Loading