@jeffober/thatch 0.1.36 → 0.1.38
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.
- package/README.md +1 -1
- package/docs/dev/README.md +1 -1
- package/docs/dev/features/multi-host.md +2 -2
- package/docs/dev/features/qa-system.md +3 -3
- package/docs/dev/features/watchers.md +31 -20
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +1 -1
- package/docs/user/README.md +1 -1
- package/docs/user/watchers.md +23 -11
- package/package.json +1 -1
- package/src/index.ts +22 -4
- package/src/prompts.ts +13 -10
- package/src/tool-defs.ts +83 -13
- package/src/watchers.ts +383 -90
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ Thatch gives your agent three layers of persistent context:
|
|
|
89
89
|
`session_get`, opencode only) and as CLI subcommands (`thatch session
|
|
90
90
|
list/get/transcript/search`, JSONL output designed for piping to `jq`).
|
|
91
91
|
- **Watchers** -- event-driven notifications from external sources (opencode
|
|
92
|
-
only). The agent registers a watch on a GitHub PR; thatch polls it in the
|
|
92
|
+
only). The agent registers a watch on a GitHub PR or branch (main); thatch polls it in the
|
|
93
93
|
background and prompts the session when comments, commits, CI results, or
|
|
94
94
|
status changes arrive. Notifications carry pointer data only.
|
|
95
95
|
|
package/docs/dev/README.md
CHANGED
|
@@ -73,7 +73,7 @@ bin/thatch → CLI: stores|list|show|forget|search|mcp|reminder|hygi
|
|
|
73
73
|
| `extraction.ts` | Per-session in-memory ring buffer (cap 20) that buffers non-thatch tool interactions and serializes them into the JSON payload the `get_extraction_payload` tool returns. The in-memory pipeline is opencode-only, but the payload builders (`buildExtractionPayload`, `deriveTitle`) are shared by both paths — `extract-queue.ts` imports `deriveTitle`, `mcp.ts` imports `buildExtractionPayload` for the extraction payload provider. `summarizeArgs` is used internally by `buildExtractionPayload`. |
|
|
74
74
|
| `extract-queue.ts` | File-backed per-session JSONL queue (caps 20, oldest dropped). Shared by the Claude Code and Cursor hook paths, which fire one-shot per event with no cross-call state. This is the MCP-side equivalent of `extraction.ts`. |
|
|
75
75
|
| `session-db.ts` | Read-only access to the opencode session database (opencode.db): session timeline (`session list`), full part/message retrieval (`session get`), OpenAI chat-completions transcript builder (`session transcript`), and decoded-content search (`session search`). Backs the `thatch session` CLI subcommands and the opencode-only `session_search`/`session_get` tools. Path resolution honors `OPENCODE_DB`, then the XDG default; the db opens read-only so a thatch bug cannot corrupt host history. |
|
|
76
|
-
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches
|
|
76
|
+
| `watchers.ts` | In-memory watcher registry and background poller for event-driven notifications (opencode-only). Watches GitHub PRs and branches via `gh api` (injected `GhRunner`), diffs against last-seen state, queues pointer-only events per session, and delivers them through a plugin-supplied callback when the session is idle. Process-lifetime state by design - no SQLite. |
|
|
77
77
|
| `sideband.ts` | Unix domain socket server + client. The MCP server (long-lived, warm model) runs `SidebandServer` so one-shot hook processes can ask it to embed a prompt and search for matches without loading the model themselves. Handles three methods: `match` (recall nudge), `predictions` (prediction auto-fire), and `behaviors` (behavior auto-fire). Socket path is a hash of the DB path — both processes compute it independently. |
|
|
78
78
|
| `prompts.ts` | Text constants: opencode system prompt, compaction context, session-start reminder, prompt-aware recall nudge (`recallNudge` / `claudeRecallNudge`), prediction nudge (`predictionNudge`), behavior nudge (`behaviorNudge`), prediction verb selection (`predictionVerb`), Claude Code CLAUDE.md instructions, Cursor AGENTS.md instructions, Claude Code hook text. |
|
|
79
79
|
| `skills.ts` | `SKILL.md` content for all thatch skills, plus the installer. Skills are split into `SHARED_SKILLS` (25 skills: fact-extractor, dedup-classifier, project-primer, 8 review specialists, review synthesizer, review context, code archaeology, review followup, review response, change walkthrough, code walkthrough, session reflection, coding-workflow, thatch-pr-description, thatch-ticket-description, thatch-split-overlarge-pr, memory-verify, knowledge-export — work on all three hosts) and `OPENCODE_ONLY_SKILLS` (1 skill: code-review coordinator — requires sub-agent support, not installed for Claude Code or Cursor). `installSkills(dir, skills)` defaults to `SHARED_SKILLS`; the opencode plugin passes `[...SHARED_SKILLS, ...OPENCODE_ONLY_SKILLS]`. |
|
|
@@ -17,9 +17,9 @@ Thatch supports three host agents from a single codebase:
|
|
|
17
17
|
`mcp__thatch__` tool prefix, system prompt in AGENTS.md
|
|
18
18
|
|
|
19
19
|
All three paths share the tool definitions from `src/tool-defs.ts`: 18
|
|
20
|
-
host-agnostic tools plus opencode-only tools (currently
|
|
20
|
+
host-agnostic tools plus opencode-only tools (currently seven:
|
|
21
21
|
`get_session_info`, `session_search`/`session_get`, and the
|
|
22
|
-
`watch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
|
|
22
|
+
`watch_create`/`watch_branch_create`/`watch_list`/`watch_cancel` watcher tools), which the MCP server
|
|
23
23
|
filters out. The shared tools take args and a `CoreContext`
|
|
24
24
|
(`{ db, model, defaultStore }`) and return a string. The host-specific
|
|
25
25
|
wrapping---prefixing, transport, session hooks---lives outside the core.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# QA System
|
|
2
2
|
|
|
3
|
-
Thatch ships
|
|
3
|
+
Thatch ships 95 QA use cases that verify end-to-end behavior. Each use case is
|
|
4
4
|
a `bun:test` file that runs in an isolated environment — a temp-directory copy
|
|
5
5
|
of the repo, not the working tree itself. Use cases are split into
|
|
6
6
|
**automatable** (no LLM, fast) and **live** (spawns opencode, costs model
|
|
@@ -8,7 +8,7 @@ tokens).
|
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
-
|
|
11
|
+
- 95 use cases: 75 automatable + 20 live
|
|
12
12
|
- Automatable use cases run without an LLM — they test CLI behavior, hooks, DB
|
|
13
13
|
operations, and tool execution directly via bun assertions
|
|
14
14
|
- Live use cases spawn real opencode sessions — they test the full
|
|
@@ -182,7 +182,7 @@ includes all of `tests/`), just not executed during `check`. See
|
|
|
182
182
|
| `tests/qa/runner.ts` | `registerUseCase`, `UseCase` type, `ensureMaster`, `createFixture`, `runViaOpencode`, execution logic |
|
|
183
183
|
| `tests/qa/auto/index.test.ts` | Automatable use case barrel — imports all auto use case modules |
|
|
184
184
|
| `tests/qa/live/index.test.ts` | Live use case barrel — imports all live use case modules |
|
|
185
|
-
| `tests/qa/auto/uc-NNN-*.ts` |
|
|
185
|
+
| `tests/qa/auto/uc-NNN-*.ts` | 75 automatable use case files |
|
|
186
186
|
| `tests/qa/live/uc-NNN-*.ts` | 20 live use case files |
|
|
187
187
|
|
|
188
188
|
No separate QA docs directory exists. QA use cases live in `tests/qa/` as executable tests, not docs.
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
Watchers are the general mechanism for event-driven notifications from
|
|
4
4
|
external sources: the model registers a watch on something outside the
|
|
5
5
|
session, thatch polls it in the background, and the plugin prompts the
|
|
6
|
-
session when a watched event happens.
|
|
7
|
-
requests
|
|
8
|
-
|
|
6
|
+
session when a watched event happens. Two sources ship: GitHub pull
|
|
7
|
+
requests (source `pr`) and GitHub branches (source `branch`, for
|
|
8
|
+
watching CI and workflow runs on main). The registry, poller, and
|
|
9
|
+
delivery are source-agnostic - a new source adds a fetch-and-diff
|
|
10
|
+
function pair and event types.
|
|
9
11
|
|
|
10
12
|
User-facing behavior is documented in [docs/user/watchers.md](../../user/watchers.md).
|
|
11
13
|
|
|
12
14
|
## What it does
|
|
13
15
|
|
|
14
|
-
- `thatch_watch_create`
|
|
16
|
+
- `thatch_watch_create` (PRs) / `thatch_watch_branch_create` (branches) /
|
|
17
|
+
`thatch_watch_list` / `thatch_watch_cancel` tools (all opencode-only)
|
|
15
18
|
- A background poller in the plugin process diffs each watcher's target
|
|
16
19
|
against its last-seen state
|
|
17
20
|
- Delivery prompts the session with a synthetic part - the same
|
|
@@ -51,12 +54,13 @@ process exit is the real lifetime boundary in both TUI and serve mode
|
|
|
51
54
|
### Registration (`watch_create`)
|
|
52
55
|
|
|
53
56
|
`create()` immediately fetches the PR's state as a baseline via
|
|
54
|
-
`fetchPrState()` -
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
`fetchPrState()` - five gh calls (the pull, issue comments, review
|
|
58
|
+
comments, check runs on the head SHA, and one GraphQL query for
|
|
59
|
+
review-thread resolution state), four of them in parallel. The
|
|
60
|
+
baseline doubles as validation: a missing PR, wrong repo, or broken
|
|
61
|
+
gh setup fails registration with a real error instead of producing a
|
|
62
|
+
watcher that never fires. Watch creation is also gated on a cached
|
|
63
|
+
`gh --version` availability check.
|
|
60
64
|
|
|
61
65
|
### Polling
|
|
62
66
|
|
|
@@ -76,6 +80,11 @@ Event detection:
|
|
|
76
80
|
- description by SHA-256 of the body text (the body itself is never
|
|
77
81
|
stored)
|
|
78
82
|
- CI by check-run transitions into a completed status
|
|
83
|
+
- review-thread resolution by symmetric difference of the sorted
|
|
84
|
+
resolved-thread-id lists (thread `isResolved` state exists only in
|
|
85
|
+
the GraphQL API, so the fetch is a `gh api graphql -f query=...`
|
|
86
|
+
call; the `GhRunner` interface carries an args array to fit both
|
|
87
|
+
transports)
|
|
79
88
|
|
|
80
89
|
A diff emits at most 10 events per watcher per cycle, so comment
|
|
81
90
|
floods collapse into one batch.
|
|
@@ -102,9 +111,10 @@ cycle or when the session next goes idle (the event hook calls
|
|
|
102
111
|
|
|
103
112
|
### Event vocabulary
|
|
104
113
|
|
|
105
|
-
`pr_comment`, `pr_review_comment`, `pr_review_reply`,
|
|
106
|
-
`
|
|
107
|
-
selects a subset; the default is
|
|
114
|
+
`pr_comment`, `pr_review_comment`, `pr_review_reply`,
|
|
115
|
+
`pr_review_resolved`, `pr_commit`, `pr_status`, `pr_description`,
|
|
116
|
+
`pr_ci`. The tool's `events` argument selects a subset; the default is
|
|
117
|
+
all eight.
|
|
108
118
|
|
|
109
119
|
## Interactions with other features
|
|
110
120
|
|
|
@@ -141,10 +151,11 @@ selects a subset; the default is all seven.
|
|
|
141
151
|
|
|
142
152
|
## Adding a new source type
|
|
143
153
|
|
|
144
|
-
The registry is
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
registering it. Keep the
|
|
150
|
-
content enters the context on
|
|
154
|
+
The registry is a discriminated union over sources: `PrWatcher` and
|
|
155
|
+
`BranchWatcher` implement the same lifecycle (id, session, repo,
|
|
156
|
+
events, expiry, snapshot), and `poll()` dispatches to the source's
|
|
157
|
+
fetch/diff pair. Adding a third source means: new event types, a
|
|
158
|
+
`fetchXState`/`diffXState` pair, a new arm of the `Watcher` union, a
|
|
159
|
+
`#pollOne` branch, and the tool surface for registering it. Keep the
|
|
160
|
+
pointer-only notification rule: external content enters the context on
|
|
161
|
+
explicit fetch, never via notification.
|
package/docs/dev/mcp-parity.md
CHANGED
|
@@ -18,7 +18,7 @@ This document maps the feature parity and documents the remaining gaps.
|
|
|
18
18
|
|
|
19
19
|
| Feature | OpenCode plugin | Claude Code MCP + hooks | Cursor MCP + hooks | Parity |
|
|
20
20
|
|---------|----------------|------------------------|---------------------|--------|
|
|
21
|
-
| **Tools** (18 shared +
|
|
21
|
+
| **Tools** (18 shared + 7 opencode-only, single source of truth) | Plugin tool registration, `thatch_` prefix | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | MCP `tools/list` + `tools/call`, `mcp__thatch__` prefix (opencode-only tools filtered out) | **Full** for the 18 shared tools; `get_session_info` (session identity) and `session_search`/`session_get` (conversation archaeology) are opencode-only — MCP hosts have no session concept. The `thatch session` CLI subcommands cover the same archaeology on any host with an opencode.db file. The `watch_*` tools (PR watchers) are opencode-only — MCP hosts have no poller or proactive-prompt channel |
|
|
22
22
|
| **System prompt** (store names, usage rules) | `experimental.chat.system.transform` — dynamic, repo baked in at runtime | CLAUDE.md static text appended by `thatch setup`; repo auto-detected by the MCP server at startup | AGENTS.md static text appended by `thatch setup`; repo auto-detected at startup | **Approximate** — static text persists through compaction, so the agent retains the usage instructions, but the dynamic per-turn refresh is lost |
|
|
23
23
|
| **Session-start reminder** (recall nudge + hygiene heartbeat) | `session.created` event → `client.session.prompt` injects a synthetic message | `SessionStart` hook → `thatch reminder`; stdout becomes context | `sessionStart` hook → `thatch reminder --json`; output is `additional_context` JSON | **Full** |
|
|
24
24
|
| **Compaction context** (re-familiarize after compaction) | `experimental.session.compacting` hook appends context to the compaction output | `PostCompact` hook — side-effects only, **cannot inject context** | No equivalent hook | **Gapped** — see below |
|
package/docs/dev/skills.md
CHANGED
|
@@ -99,7 +99,7 @@ The synthesizer reuses the same verification rigor but has its own structure
|
|
|
99
99
|
## The two arrays
|
|
100
100
|
|
|
101
101
|
```ts
|
|
102
|
-
const SHARED_SKILLS: SkillDef[] = [ /*
|
|
102
|
+
const SHARED_SKILLS: SkillDef[] = [ /* 26 skills above */ ];
|
|
103
103
|
const OPENCODE_ONLY_SKILLS: SkillDef[] = [ /* code-review coordinator */ ];
|
|
104
104
|
```
|
|
105
105
|
|
package/docs/user/README.md
CHANGED
|
@@ -11,7 +11,7 @@ This README is the overview. Each feature has its own guide:
|
|
|
11
11
|
|
|
12
12
|
- [memory.md](memory.md): persistent memory store
|
|
13
13
|
- [extraction.md](extraction.md): automatic fact extraction from tool calls
|
|
14
|
-
- [watchers.md](watchers.md): event-driven notifications (PR watching)
|
|
14
|
+
- [watchers.md](watchers.md): event-driven notifications (PR and branch watching)
|
|
15
15
|
- [prediction-engine.md](prediction-engine.md): user decision model
|
|
16
16
|
- [behavior-engine.md](behavior-engine.md): agent self-discipline rules
|
|
17
17
|
- [default-behaviors.md](default-behaviors.md): what ships automatically
|
package/docs/user/watchers.md
CHANGED
|
@@ -1,29 +1,39 @@
|
|
|
1
1
|
# Watchers
|
|
2
2
|
|
|
3
|
-
Watchers monitor external sources - today, GitHub pull requests
|
|
4
|
-
inject a notification into the conversation when
|
|
5
|
-
ask the agent to watch a PR
|
|
6
|
-
agent gets woken up when a watched
|
|
7
|
-
asking "any updates yet?"
|
|
3
|
+
Watchers monitor external sources - today, GitHub pull requests and
|
|
4
|
+
branches - and inject a notification into the conversation when
|
|
5
|
+
something happens. You ask the agent to watch a PR or a branch; thatch
|
|
6
|
+
polls it in the background; the agent gets woken up when a watched
|
|
7
|
+
event occurs. No one has to keep asking "any updates yet?"
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
The agent calls `thatch_watch_create` to register a watcher on a PR
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
The agent calls `thatch_watch_create` to register a watcher on a PR,
|
|
12
|
+
or `thatch_watch_branch_create` to register a watcher on a branch
|
|
13
|
+
(typically main). thatch captures the target's current state as a
|
|
14
|
+
baseline, then polls the GitHub API through the `gh` CLI once a
|
|
15
|
+
minute. When the target changes in a way the watcher cares about, the
|
|
16
|
+
plugin prompts the agent with a notification.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
PR watched events:
|
|
18
19
|
|
|
19
20
|
- `pr_comment` - new top-level comments on the PR
|
|
20
21
|
- `pr_review_comment` - new inline comments on the diff
|
|
21
22
|
- `pr_review_reply` - replies to an existing review comment
|
|
23
|
+
- `pr_review_resolved` - a review thread resolved or reopened
|
|
22
24
|
- `pr_commit` - new commits (the head SHA moved)
|
|
23
25
|
- `pr_status` - the PR opened, closed, or merged
|
|
24
26
|
- `pr_description` - title or description edited
|
|
25
27
|
- `pr_ci` - a CI check run on the head SHA completed
|
|
26
28
|
|
|
29
|
+
Branch watched events:
|
|
30
|
+
|
|
31
|
+
- `branch_commit` - new commits land on the branch (e.g. a PR merged)
|
|
32
|
+
- `branch_ci` - a check run on the branch head completed
|
|
33
|
+
- `branch_workflow` - a GitHub Actions workflow run starts or finishes
|
|
34
|
+
on the branch, optionally filtered by workflow name (substring,
|
|
35
|
+
case-insensitive)
|
|
36
|
+
|
|
27
37
|
Notifications are delivered as synthetic parts: the model sees them,
|
|
28
38
|
the transcript does not. A notification triggers a model turn, so the
|
|
29
39
|
agent can act on the event even while you are away.
|
|
@@ -51,6 +61,8 @@ do about it.
|
|
|
51
61
|
|
|
52
62
|
Other tools:
|
|
53
63
|
|
|
64
|
+
- `thatch_watch_branch_create` - watch a branch (typically main) for
|
|
65
|
+
commits, CI check runs, and workflow runs
|
|
54
66
|
- `thatch_watch_list` - shows the session's active watchers
|
|
55
67
|
- `thatch_watch_cancel` - cancels one by id
|
|
56
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffober/thatch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -101,15 +101,33 @@ export const server: Plugin = async ({ client, worktree }) => {
|
|
|
101
101
|
// pointer data; the model fetches details itself with gh.
|
|
102
102
|
const watchers = new WatcherRegistry({
|
|
103
103
|
deliver: async (sessionID, events) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
// Events carry their watch's target label from the registry, so the
|
|
105
|
+
// notification header is correct for every source (PRs, branches,
|
|
106
|
+
// CI events whose URLs would not parse).
|
|
107
107
|
await client.session.promptAsync({
|
|
108
108
|
path: { id: sessionID },
|
|
109
109
|
body: {
|
|
110
|
-
parts: [{ type: "text", text: watcherNotificationNudge(target, events), synthetic: true }],
|
|
110
|
+
parts: [{ type: "text", text: watcherNotificationNudge(events[0]?.target ?? "watched target", events), synthetic: true }],
|
|
111
111
|
},
|
|
112
112
|
});
|
|
113
|
+
// Toast: the notification part is TUI-hidden, so without this the
|
|
114
|
+
// user watching the session sees the model wake up with no visible
|
|
115
|
+
// cause. Announce what fired. Failed checks/workflows surface as a
|
|
116
|
+
// warning variant; routine events as info. Best-effort - the TUI may
|
|
117
|
+
// not be connected (headless mode).
|
|
118
|
+
const failed = events.some((e) => e.summary.includes("failure"));
|
|
119
|
+
const more = events.length > 1 ? ` +${events.length - 1} more` : "";
|
|
120
|
+
try {
|
|
121
|
+
await client.tui.showToast({
|
|
122
|
+
body: {
|
|
123
|
+
message: `\u23F0 ${events[0]?.target ?? "watched target"}: ${events[0]?.summary ?? ""}${more}`,
|
|
124
|
+
variant: failed ? "warning" : "info",
|
|
125
|
+
duration: 5000,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// TUI may not be connected. Best-effort.
|
|
130
|
+
}
|
|
113
131
|
},
|
|
114
132
|
canDeliver: (sessionID) =>
|
|
115
133
|
!compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
|
package/src/prompts.ts
CHANGED
|
@@ -25,7 +25,8 @@ Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
|
|
|
25
25
|
thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
|
|
26
26
|
thatch_behavior_delete, thatch_get_session_info,
|
|
27
27
|
thatch_session_search, thatch_session_get,
|
|
28
|
-
thatch_watch_create,
|
|
28
|
+
thatch_watch_create, thatch_watch_branch_create,
|
|
29
|
+
thatch_watch_list, thatch_watch_cancel
|
|
29
30
|
|
|
30
31
|
## Stores
|
|
31
32
|
|
|
@@ -67,7 +68,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
|
|
|
67
68
|
- \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
|
|
68
69
|
- \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
|
|
69
70
|
- \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
|
|
70
|
-
|
|
71
71
|
## When to Write
|
|
72
72
|
|
|
73
73
|
**One signal is enough.** Do not wait for confirmation.
|
|
@@ -112,12 +112,15 @@ intractable prompt, not a transient hang.
|
|
|
112
112
|
## Watchers
|
|
113
113
|
|
|
114
114
|
Use thatch_watch_create to watch a GitHub PR for events (comments, review
|
|
115
|
-
comments and replies, commits, status changes,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
comments and replies, review thread resolutions, commits, status changes,
|
|
116
|
+
description edits, CI check completions), or thatch_watch_branch_create to
|
|
117
|
+
watch a branch (typically main) for commit landings, check-run completions,
|
|
118
|
+
and workflow runs on the branch. thatch polls in the
|
|
119
|
+
background and injects a notification into this session when a watched event
|
|
120
|
+
happens. The
|
|
121
|
+
notification carries pointer data only (author, URL); fetch
|
|
122
|
+
details with the gh CLI when you decide to act. When the user asks you to
|
|
123
|
+
watch something, state your handling policy for notifications out loud first
|
|
121
124
|
("I'll report human comments and handle bugbot replies myself") so the
|
|
122
125
|
notification turn knows what to do. Treat watcher notifications like
|
|
123
126
|
background task completions: they are system events, not user input, and
|
|
@@ -286,7 +289,8 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
|
|
|
286
289
|
\`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
|
|
287
290
|
\`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
|
|
288
291
|
\`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`. Bare names used below for readability.
|
|
289
|
-
get_session_info, session_search, session_get, watch_create,
|
|
292
|
+
get_session_info, session_search, session_get, watch_create,
|
|
293
|
+
watch_branch_create, watch_list,
|
|
290
294
|
and watch_cancel are intentionally absent: they are opencode-only (MCP
|
|
291
295
|
hosts have no session concept, session database, or proactive-prompt
|
|
292
296
|
channel), so do not expect them here.
|
|
@@ -331,7 +335,6 @@ workflows. The host auto-discovers them, but reach for them proactively:
|
|
|
331
335
|
- \`thatch-review-response\` - respond to code review on the user's own PR. Triage findings, fix bugs one by one, reply on each thread, then post a top-level summary comment.
|
|
332
336
|
- \`thatch-memory-verify\` - fact-check a single memory against the current codebase and correct stale claims. Use when a memory has an old \`updated_at\` date or when preparing a knowledge export.
|
|
333
337
|
- \`thatch-knowledge-export\` - compile everything thatch knows about a topic into a curated markdown file for knowledge transfer. Searches across stores, curates out personal noise, fact-checks code-related memories, and generates a clean export.
|
|
334
|
-
|
|
335
338
|
## When to Write
|
|
336
339
|
|
|
337
340
|
**One signal is enough.** Do not wait for confirmation.
|
package/src/tool-defs.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ThatchDB, DedupCandidate, MemoryRow } from "./db";
|
|
|
3
3
|
import type { EmbeddingModel } from "./embeddings";
|
|
4
4
|
import { predictionVerb } from "./prompts";
|
|
5
5
|
import { resolveOpencodeDbPath, SessionDB, partToTimelineEntry, partToFullJson, messageToFullJson } from "./session-db";
|
|
6
|
-
import {
|
|
6
|
+
import { PR_EVENT_TYPES, BRANCH_EVENT_TYPES, type WatcherRegistry, type PrWatcherEventType, type BranchWatcherEventType } from "./watchers";
|
|
7
7
|
|
|
8
8
|
// Near-duplicate thresholds for matcher/prediction/behavior dedup at
|
|
9
9
|
// creation time. Matches the thatch_find_duplicates threshold (0.85).
|
|
@@ -960,12 +960,13 @@ const watchCreateDef: ToolDef = {
|
|
|
960
960
|
description:
|
|
961
961
|
"Watch a GitHub PR for events and get notified in this session when they " +
|
|
962
962
|
"happen. Events: new top-level comments, new inline review comments, " +
|
|
963
|
-
"replies to review comments,
|
|
964
|
-
"changes, title/description edits, and
|
|
965
|
-
"watcher polls in the background and
|
|
966
|
-
"notifications carry pointers only
|
|
967
|
-
"with the gh CLI when you decide
|
|
968
|
-
"the session ends, or opencode
|
|
963
|
+
"replies to review comments, review thread resolutions, new commits " +
|
|
964
|
+
"(head SHA change), PR status changes, title/description edits, and " +
|
|
965
|
+
"completed CI check runs. The watcher polls in the background and " +
|
|
966
|
+
"injects a notification prompt; notifications carry pointers only " +
|
|
967
|
+
"(author, URL) - fetch details yourself with the gh CLI when you decide " +
|
|
968
|
+
"to act. Watches live until cancelled, the session ends, or opencode " +
|
|
969
|
+
"restarts. Requires the gh CLI.",
|
|
969
970
|
args: {
|
|
970
971
|
pr: z.number().int().positive().describe(
|
|
971
972
|
"The PR number to watch.",
|
|
@@ -973,8 +974,8 @@ const watchCreateDef: ToolDef = {
|
|
|
973
974
|
repo: z.string().optional().describe(
|
|
974
975
|
"GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
|
|
975
976
|
),
|
|
976
|
-
events: z.array(z.enum(
|
|
977
|
-
"Which event types to watch. Omit for all event types.",
|
|
977
|
+
events: z.array(z.enum(PR_EVENT_TYPES as [PrWatcherEventType, ...PrWatcherEventType[]])).optional().describe(
|
|
978
|
+
"Which event types to watch. Omit for all PR event types.",
|
|
978
979
|
),
|
|
979
980
|
},
|
|
980
981
|
opencodeOnly: true,
|
|
@@ -986,8 +987,8 @@ const watchCreateDef: ToolDef = {
|
|
|
986
987
|
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
987
988
|
}
|
|
988
989
|
const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
|
|
989
|
-
const events = (args.events as
|
|
990
|
-
const result = await ctx.watchers.
|
|
990
|
+
const events = (args.events as PrWatcherEventType[] | undefined) ?? [...PR_EVENT_TYPES];
|
|
991
|
+
const result = await ctx.watchers.createPr(host.sessionID, repo, args.pr as number, events);
|
|
991
992
|
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
992
993
|
const w = result.watcher;
|
|
993
994
|
return (
|
|
@@ -1025,11 +1026,79 @@ const watchListDef: ToolDef = {
|
|
|
1025
1026
|
if (watchers.length === 0) return "No active watchers.";
|
|
1026
1027
|
const minutesLeft = (w: { expiresAt: number }) => Math.max(0, Math.round((w.expiresAt - Date.now()) / 60_000));
|
|
1027
1028
|
return watchers
|
|
1028
|
-
.map((w) =>
|
|
1029
|
+
.map((w) => {
|
|
1030
|
+
const target = w.source === "pr" ? `${w.repo}#${w.pr}` : `${w.repo}@${w.branch}`;
|
|
1031
|
+
return `${w.id}: ${target} [${w.source}] events=[${w.events.join(",")}] expires in ${minutesLeft(w)}m head=${w.state.headSha.slice(0, 7)}`;
|
|
1032
|
+
})
|
|
1029
1033
|
.join("\n");
|
|
1030
1034
|
},
|
|
1031
1035
|
};
|
|
1032
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* Registers a background watcher on a GitHub branch (typically main): commit
|
|
1039
|
+
* landings, check-run completions on the head, and workflow runs. This is
|
|
1040
|
+
* how the model watches "CI against main" or waits for a post-merge build.
|
|
1041
|
+
* opencode-only, like the PR watch tools.
|
|
1042
|
+
*/
|
|
1043
|
+
const watchBranchCreateDef: ToolDef = {
|
|
1044
|
+
name: "watch_branch_create",
|
|
1045
|
+
description:
|
|
1046
|
+
"Watch a GitHub branch (typically main) and get notified in this session " +
|
|
1047
|
+
"when things happen on it: branch_commit when new commits land (e.g. a " +
|
|
1048
|
+
"PR merged), branch_ci when a check run on the head completes, and " +
|
|
1049
|
+
"branch_workflow when a GitHub Actions workflow run starts or finishes " +
|
|
1050
|
+
"on the branch. Optionally filter to workflow names (substring match). " +
|
|
1051
|
+
"The watcher polls in the background and injects a notification prompt; " +
|
|
1052
|
+
"notifications carry pointers only - fetch details with the gh CLI when " +
|
|
1053
|
+
"you decide to act. Watches live until cancelled, the session ends, or " +
|
|
1054
|
+
"opencode restarts. Requires the gh CLI.",
|
|
1055
|
+
args: {
|
|
1056
|
+
branch: z.string().describe(
|
|
1057
|
+
"The branch name to watch, e.g. main.",
|
|
1058
|
+
),
|
|
1059
|
+
repo: z.string().optional().describe(
|
|
1060
|
+
"GitHub repo as owner/repo. Defaults to this project's repo from the git remote.",
|
|
1061
|
+
),
|
|
1062
|
+
events: z.array(z.enum(BRANCH_EVENT_TYPES as [BranchWatcherEventType, ...BranchWatcherEventType[]])).optional().describe(
|
|
1063
|
+
"Which event types to watch. Omit for all branch event types.",
|
|
1064
|
+
),
|
|
1065
|
+
workflows: z.array(z.string()).optional().describe(
|
|
1066
|
+
"Only notify for workflow runs whose name contains one of these substrings (case-insensitive). Omit for all workflows.",
|
|
1067
|
+
),
|
|
1068
|
+
},
|
|
1069
|
+
opencodeOnly: true,
|
|
1070
|
+
async execute(args, ctx, host) {
|
|
1071
|
+
if (!host) {
|
|
1072
|
+
return "Watching is unavailable: this host did not provide a session context.";
|
|
1073
|
+
}
|
|
1074
|
+
if (!ctx.watchers) {
|
|
1075
|
+
return "Watching is unavailable: no watcher registry was wired by this host.";
|
|
1076
|
+
}
|
|
1077
|
+
const repo = (args.repo as string | undefined) ?? ctx.defaultStore;
|
|
1078
|
+
const events = (args.events as BranchWatcherEventType[] | undefined) ?? [...BRANCH_EVENT_TYPES];
|
|
1079
|
+
const result = await ctx.watchers.createBranch(
|
|
1080
|
+
host.sessionID,
|
|
1081
|
+
repo,
|
|
1082
|
+
args.branch as string,
|
|
1083
|
+
events,
|
|
1084
|
+
(args.workflows as string[] | undefined) ?? [],
|
|
1085
|
+
);
|
|
1086
|
+
if (!result.ok) return `Watcher not created: ${result.error}`;
|
|
1087
|
+
const w = result.watcher;
|
|
1088
|
+
return (
|
|
1089
|
+
`[watching] ${w.repo}@${w.branch}\n` +
|
|
1090
|
+
`id: ${w.id}\n` +
|
|
1091
|
+
`events: ${w.events.join(", ")}\n` +
|
|
1092
|
+
(w.workflows.length > 0 ? `workflow filter: ${w.workflows.join(", ")}\n` : "") +
|
|
1093
|
+
`head: ${w.state.headSha.slice(0, 7)}\n\n` +
|
|
1094
|
+
`The baseline is captured now - only changes from this point notify. ` +
|
|
1095
|
+
`You will receive a system notification in this session when a watched event happens. ` +
|
|
1096
|
+
`State any handling policy for those notifications now (e.g. what to act on, what just to report), ` +
|
|
1097
|
+
`since the notification itself carries only pointers.`
|
|
1098
|
+
);
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1033
1102
|
/**
|
|
1034
1103
|
* Cancels one watcher. opencode-only, session-scoped: cancelling by id only
|
|
1035
1104
|
* works for the session that created it.
|
|
@@ -1038,7 +1107,7 @@ const watchCancelDef: ToolDef = {
|
|
|
1038
1107
|
name: "watch_cancel",
|
|
1039
1108
|
description: "Cancel one of this session's watchers by id. opencode-only.",
|
|
1040
1109
|
args: {
|
|
1041
|
-
id: z.string().describe("The watcher id from watch_create or watch_list."),
|
|
1110
|
+
id: z.string().describe("The watcher id from watch_create, watch_branch_create, or watch_list."),
|
|
1042
1111
|
},
|
|
1043
1112
|
opencodeOnly: true,
|
|
1044
1113
|
async execute(args, ctx, host) {
|
|
@@ -1083,6 +1152,7 @@ export const TOOL_DEFS: ToolDef[] = [
|
|
|
1083
1152
|
sessionSearchDef,
|
|
1084
1153
|
sessionGetDef,
|
|
1085
1154
|
watchCreateDef,
|
|
1155
|
+
watchBranchCreateDef,
|
|
1086
1156
|
watchListDef,
|
|
1087
1157
|
watchCancelDef,
|
|
1088
1158
|
];
|
package/src/watchers.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Watchers: event-driven notifications from external sources, delivered into
|
|
3
3
|
* a live opencode session as injected prompts.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The registry supports multiple source types behind one mechanism. The
|
|
6
|
+
* first source was GitHub pull requests (source "pr"); GitHub branches
|
|
7
|
+
* (source "branch") arrived second, for watching CI and workflow runs
|
|
8
|
+
* against main. A source contributes: event types, a fetch function that
|
|
9
|
+
* produces a snapshot of the watched state, and a pure diff between two
|
|
10
|
+
* snapshots. Everything else - registry, pending queue, delivery gating -
|
|
11
|
+
* is source-agnostic.
|
|
10
12
|
*
|
|
11
13
|
* Lifetime is deliberately process-scoped. The registry lives in plugin
|
|
12
14
|
* memory, never in SQLite: opencode loads thatch in-process, so the plugin
|
|
@@ -21,38 +23,50 @@
|
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
24
|
-
//
|
|
26
|
+
// Event types
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
* against the watcher's last-seen PR state. Notifications carry pointer data
|
|
30
|
-
* (author, URL, counts) rather than content, so text authored by strangers
|
|
31
|
-
* on GitHub never enters the model's context directly - the model fetches
|
|
32
|
-
* details on demand with the gh CLI.
|
|
33
|
-
*/
|
|
34
|
-
export type WatcherEventType =
|
|
29
|
+
/** Event types for source "pr". */
|
|
30
|
+
export type PrWatcherEventType =
|
|
35
31
|
| "pr_comment"
|
|
36
32
|
| "pr_review_comment"
|
|
37
33
|
| "pr_review_reply"
|
|
34
|
+
| "pr_review_resolved"
|
|
38
35
|
| "pr_commit"
|
|
39
36
|
| "pr_status"
|
|
40
37
|
| "pr_description"
|
|
41
38
|
| "pr_ci";
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
/** Event types for source "branch" (typically main): commits land, CI and workflow runs execute. */
|
|
41
|
+
export type BranchWatcherEventType = "branch_commit" | "branch_ci" | "branch_workflow";
|
|
42
|
+
|
|
43
|
+
export type WatcherEventType = PrWatcherEventType | BranchWatcherEventType;
|
|
44
|
+
|
|
45
|
+
export const PR_EVENT_TYPES: PrWatcherEventType[] = [
|
|
44
46
|
"pr_comment",
|
|
45
47
|
"pr_review_comment",
|
|
46
48
|
"pr_review_reply",
|
|
49
|
+
"pr_review_resolved",
|
|
47
50
|
"pr_commit",
|
|
48
51
|
"pr_status",
|
|
49
52
|
"pr_description",
|
|
50
53
|
"pr_ci",
|
|
51
54
|
];
|
|
52
55
|
|
|
56
|
+
export const BRANCH_EVENT_TYPES: BranchWatcherEventType[] = [
|
|
57
|
+
"branch_commit",
|
|
58
|
+
"branch_ci",
|
|
59
|
+
"branch_workflow",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Every event type across all sources - for diagnostics and tests. */
|
|
63
|
+
export const WATCHER_EVENT_TYPES: WatcherEventType[] = [...PR_EVENT_TYPES, ...BRANCH_EVENT_TYPES];
|
|
64
|
+
|
|
53
65
|
/** A single detected change, ready for delivery. Pointer data only. */
|
|
54
66
|
export interface WatcherEvent {
|
|
55
67
|
type: WatcherEventType;
|
|
68
|
+
/** Which watch produced this event, e.g. "acme/widgets#7" or "acme/widgets@main". */
|
|
69
|
+
target: string;
|
|
56
70
|
/** Human-readable, content-free summary: who/what/where, never body text. */
|
|
57
71
|
summary: string;
|
|
58
72
|
url: string;
|
|
@@ -67,6 +81,31 @@ export interface CommentRef {
|
|
|
67
81
|
isReply: boolean;
|
|
68
82
|
}
|
|
69
83
|
|
|
84
|
+
/** One CI check run, keyed by id in state snapshots. */
|
|
85
|
+
export interface CheckRunRef {
|
|
86
|
+
name: string;
|
|
87
|
+
status: string;
|
|
88
|
+
conclusion: string | null;
|
|
89
|
+
url: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One GitHub Actions workflow run, keyed by run id. */
|
|
93
|
+
export interface WorkflowRunRef {
|
|
94
|
+
name: string;
|
|
95
|
+
status: string;
|
|
96
|
+
conclusion: string | null;
|
|
97
|
+
url: string;
|
|
98
|
+
/** What triggered the run: push, pull_request, schedule, workflow_run, ... */
|
|
99
|
+
event: string;
|
|
100
|
+
/** The branch the run's head commit is on (a tag-triggered run puts the TAG name here). */
|
|
101
|
+
headBranch: string;
|
|
102
|
+
headSha: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Watchers: a discriminated union over sources
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
70
109
|
/**
|
|
71
110
|
* The last-seen state of a PR, captured at watch creation and updated each
|
|
72
111
|
* poll. Comment arrays are kept whole so each diff can name individual new
|
|
@@ -83,25 +122,56 @@ export interface PrState {
|
|
|
83
122
|
lastReviewCommentId: number;
|
|
84
123
|
issueComments: CommentRef[];
|
|
85
124
|
reviewComments: CommentRef[];
|
|
86
|
-
/**
|
|
87
|
-
|
|
125
|
+
/** Sorted ids of review threads currently in the resolved state. */
|
|
126
|
+
resolvedThreads: string[];
|
|
127
|
+
checkRuns: Record<string, CheckRunRef>;
|
|
88
128
|
}
|
|
89
129
|
|
|
90
|
-
|
|
130
|
+
/** The last-seen state of a branch. */
|
|
131
|
+
export interface BranchState {
|
|
132
|
+
headSha: string;
|
|
133
|
+
checkRuns: Record<string, CheckRunRef>;
|
|
134
|
+
/** Workflow runs on the branch, keyed by run id (last 20). */
|
|
135
|
+
workflowRuns: Record<string, WorkflowRunRef>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PrWatcher {
|
|
91
139
|
id: string;
|
|
140
|
+
source: "pr";
|
|
92
141
|
sessionID: string;
|
|
93
142
|
/** owner/repo */
|
|
94
143
|
repo: string;
|
|
95
144
|
pr: number;
|
|
96
|
-
events:
|
|
145
|
+
events: PrWatcherEventType[];
|
|
97
146
|
/** Epoch ms. When now > expiresAt the watcher is silently dropped. */
|
|
98
147
|
expiresAt: number;
|
|
99
148
|
createdAt: number;
|
|
100
149
|
state: PrState;
|
|
101
150
|
}
|
|
102
151
|
|
|
103
|
-
|
|
104
|
-
|
|
152
|
+
export interface BranchWatcher {
|
|
153
|
+
id: string;
|
|
154
|
+
source: "branch";
|
|
155
|
+
sessionID: string;
|
|
156
|
+
/** owner/repo */
|
|
157
|
+
repo: string;
|
|
158
|
+
branch: string;
|
|
159
|
+
events: BranchWatcherEventType[];
|
|
160
|
+
/** Substring filters on workflow run names; empty means all workflows. */
|
|
161
|
+
workflows: string[];
|
|
162
|
+
expiresAt: number;
|
|
163
|
+
createdAt: number;
|
|
164
|
+
state: BranchState;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type Watcher = PrWatcher | BranchWatcher;
|
|
168
|
+
|
|
169
|
+
/** Runs one GitHub API call via the gh CLI and parses the JSON response.
|
|
170
|
+
* The array is the argument list after `gh api`: a REST path like
|
|
171
|
+
* ["/repos/o/r/pulls/7"] or a GraphQL invocation like
|
|
172
|
+
* ["graphql", "-f", "query={...}"]. Array-shaped so both transports fit
|
|
173
|
+
* without the callers string-concatenating shell quotes. */
|
|
174
|
+
export type GhRunner = (apiArgs: string[]) => Promise<unknown>;
|
|
105
175
|
|
|
106
176
|
export interface WatcherRegistryOptions {
|
|
107
177
|
/** Delivers a batch of events for a session. Injected so tests never spawn. */
|
|
@@ -128,8 +198,8 @@ export interface WatcherRegistryOptions {
|
|
|
128
198
|
* existing gh session - thatch never sees or stores a token. Throws on
|
|
129
199
|
* non-zero exit or unparseable output.
|
|
130
200
|
*/
|
|
131
|
-
export async function ghApiRun(
|
|
132
|
-
const proc = Bun.spawn(["gh", "api",
|
|
201
|
+
export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
|
|
202
|
+
const proc = Bun.spawn(["gh", "api", ...apiArgs], {
|
|
133
203
|
stdout: "pipe",
|
|
134
204
|
stderr: "pipe",
|
|
135
205
|
});
|
|
@@ -141,7 +211,7 @@ export async function ghApiRun(apiPath: string): Promise<unknown> {
|
|
|
141
211
|
]);
|
|
142
212
|
if (exitCode !== 0) {
|
|
143
213
|
const stderr = await new Response(proc.stderr).text();
|
|
144
|
-
throw new Error(`gh api ${
|
|
214
|
+
throw new Error(`gh api ${apiArgs.join(" ")} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
|
|
145
215
|
}
|
|
146
216
|
return JSON.parse(stdout);
|
|
147
217
|
} finally {
|
|
@@ -176,7 +246,7 @@ export function _resetGhAvailability(): void {
|
|
|
176
246
|
}
|
|
177
247
|
|
|
178
248
|
// ---------------------------------------------------------------------------
|
|
179
|
-
//
|
|
249
|
+
// Snapshot construction
|
|
180
250
|
// ---------------------------------------------------------------------------
|
|
181
251
|
|
|
182
252
|
function sha256(text: string): string {
|
|
@@ -206,32 +276,9 @@ function maxCommentId(refs: CommentRef[]): number {
|
|
|
206
276
|
return refs.reduce((max, c) => Math.max(max, c.id), 0);
|
|
207
277
|
}
|
|
208
278
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
* (top-level PR conversation), review comments (inline diff comments), and
|
|
213
|
-
* check runs on the head SHA. Comment and check-run fetches run in parallel.
|
|
214
|
-
*/
|
|
215
|
-
export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
|
|
216
|
-
const pull = (await gh(`/repos/${repo}/pulls/${pr}`)) as {
|
|
217
|
-
head?: { sha?: string };
|
|
218
|
-
state?: string;
|
|
219
|
-
merged?: boolean;
|
|
220
|
-
title?: string;
|
|
221
|
-
body?: string | null;
|
|
222
|
-
};
|
|
223
|
-
const headSha = pull.head?.sha ?? "";
|
|
224
|
-
const [issueComments, reviewComments, checkRuns] = await Promise.all([
|
|
225
|
-
gh(`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`),
|
|
226
|
-
gh(`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`),
|
|
227
|
-
gh(`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`),
|
|
228
|
-
]);
|
|
229
|
-
|
|
230
|
-
const issueRefs = toCommentRefs(issueComments);
|
|
231
|
-
const reviewRefs = toCommentRefs(reviewComments);
|
|
232
|
-
|
|
233
|
-
const runs: PrState["checkRuns"] = {};
|
|
234
|
-
const rawRuns = (checkRuns as { check_runs?: Array<{
|
|
279
|
+
function parseCheckRuns(raw: unknown): Record<string, CheckRunRef> {
|
|
280
|
+
const runs: Record<string, CheckRunRef> = {};
|
|
281
|
+
const rawRuns = (raw as { check_runs?: Array<{
|
|
235
282
|
id?: number;
|
|
236
283
|
name?: string;
|
|
237
284
|
status?: string;
|
|
@@ -247,6 +294,76 @@ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Prom
|
|
|
247
294
|
url: run.html_url ?? null,
|
|
248
295
|
};
|
|
249
296
|
}
|
|
297
|
+
return runs;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseWorkflowRuns(raw: unknown): Record<string, WorkflowRunRef> {
|
|
301
|
+
const runs: Record<string, WorkflowRunRef> = {};
|
|
302
|
+
const rawRuns = (raw as { workflow_runs?: Array<{
|
|
303
|
+
id?: number;
|
|
304
|
+
name?: string;
|
|
305
|
+
status?: string;
|
|
306
|
+
conclusion?: string | null;
|
|
307
|
+
html_url?: string;
|
|
308
|
+
event?: string;
|
|
309
|
+
head_branch?: string;
|
|
310
|
+
head_sha?: string;
|
|
311
|
+
}> }).workflow_runs ?? [];
|
|
312
|
+
for (const run of rawRuns) {
|
|
313
|
+
if (run.id === undefined) continue;
|
|
314
|
+
runs[String(run.id)] = {
|
|
315
|
+
name: run.name ?? "workflow",
|
|
316
|
+
status: run.status ?? "unknown",
|
|
317
|
+
conclusion: run.conclusion ?? null,
|
|
318
|
+
url: run.html_url ?? "",
|
|
319
|
+
event: run.event ?? "unknown",
|
|
320
|
+
headBranch: run.head_branch ?? "",
|
|
321
|
+
headSha: run.head_sha ?? "",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return runs;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseResolvedThreads(raw: unknown): string[] {
|
|
328
|
+
const nodes = (raw as {
|
|
329
|
+
data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: Array<{ id?: string; isResolved?: boolean }> } } } };
|
|
330
|
+
}).data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
331
|
+
return nodes
|
|
332
|
+
.filter((n) => n.id && n.isResolved)
|
|
333
|
+
.map((n) => n.id!)
|
|
334
|
+
.sort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Fetches the current state of a PR plus everything the diff functions need.
|
|
339
|
+
* Five gh calls per poll per watcher: the PR itself, issue comments
|
|
340
|
+
* (top-level PR conversation), review comments (inline diff comments), check
|
|
341
|
+
* runs on the head SHA, and one GraphQL query for review-thread resolution
|
|
342
|
+
* state (isResolved exists only in GraphQL, not the REST API). All
|
|
343
|
+
* non-pull fetches run in parallel.
|
|
344
|
+
*/
|
|
345
|
+
export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
|
|
346
|
+
const pull = (await gh([`/repos/${repo}/pulls/${pr}`])) as {
|
|
347
|
+
head?: { sha?: string };
|
|
348
|
+
state?: string;
|
|
349
|
+
merged?: boolean;
|
|
350
|
+
title?: string;
|
|
351
|
+
body?: string | null;
|
|
352
|
+
};
|
|
353
|
+
const headSha = pull.head?.sha ?? "";
|
|
354
|
+
const [owner, repoName] = repo.split("/");
|
|
355
|
+
const graphqlQuery =
|
|
356
|
+
`query { repository(owner: "${owner}", name: "${repoName}") { ` +
|
|
357
|
+
`pullRequest(number: ${pr}) { reviewThreads(first: 100) { nodes { id isResolved } } } } }`;
|
|
358
|
+
const [issueComments, reviewComments, checkRuns, threads] = await Promise.all([
|
|
359
|
+
gh([`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`]),
|
|
360
|
+
gh([`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`]),
|
|
361
|
+
gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]),
|
|
362
|
+
gh(["graphql", "-f", `query=${graphqlQuery}`]),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
const issueRefs = toCommentRefs(issueComments);
|
|
366
|
+
const reviewRefs = toCommentRefs(reviewComments);
|
|
250
367
|
|
|
251
368
|
return {
|
|
252
369
|
headSha,
|
|
@@ -258,59 +375,168 @@ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Prom
|
|
|
258
375
|
lastReviewCommentId: maxCommentId(reviewRefs),
|
|
259
376
|
issueComments: issueRefs,
|
|
260
377
|
reviewComments: reviewRefs,
|
|
261
|
-
|
|
378
|
+
resolvedThreads: parseResolvedThreads(threads),
|
|
379
|
+
checkRuns: parseCheckRuns(checkRuns),
|
|
262
380
|
};
|
|
263
381
|
}
|
|
264
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Fetches the current state of a branch: the head commit, check runs on that
|
|
385
|
+
* head, and recent workflow runs relevant to the branch. The branch and
|
|
386
|
+
* workflow-run calls run in parallel; check runs need the head SHA first.
|
|
387
|
+
*
|
|
388
|
+
* Workflow runs are fetched WITHOUT the API's branch filter and filtered
|
|
389
|
+
* client-side: keep runs whose head_branch matches the branch (push runs)
|
|
390
|
+
* or whose head_sha is the branch's current head (tag-triggered runs like a
|
|
391
|
+
* release Publish run - their head_branch is the tag name, so the API's
|
|
392
|
+
* branch filter would hide exactly the runs a release watch exists for).
|
|
393
|
+
*/
|
|
394
|
+
export async function fetchBranchState(gh: GhRunner, repo: string, branch: string): Promise<BranchState> {
|
|
395
|
+
const [branchInfo, runs] = await Promise.all([
|
|
396
|
+
gh([`/repos/${repo}/branches/${branch}`]),
|
|
397
|
+
gh([`/repos/${repo}/actions/runs?per_page=30`]),
|
|
398
|
+
]);
|
|
399
|
+
const headSha = (branchInfo as { commit?: { sha?: string } }).commit?.sha ?? "";
|
|
400
|
+
const allRuns = parseWorkflowRuns(runs);
|
|
401
|
+
const relevant = Object.fromEntries(
|
|
402
|
+
Object.entries(allRuns).filter(([, run]) => run.headBranch === branch || run.headSha === headSha),
|
|
403
|
+
);
|
|
404
|
+
const checkRuns = await gh([`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`]);
|
|
405
|
+
return {
|
|
406
|
+
headSha,
|
|
407
|
+
checkRuns: parseCheckRuns(checkRuns),
|
|
408
|
+
workflowRuns: relevant,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Diffing
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
265
416
|
/** At most this many events per watcher per cycle; comment floods collapse. */
|
|
266
417
|
const MAX_EVENTS_PER_DIFF = 10;
|
|
267
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Shared check-run diff: emits one event per run that reaches a completed
|
|
421
|
+
* status. A run that was already completed before produces nothing; a run
|
|
422
|
+
* that appears between polls already completed produces one.
|
|
423
|
+
*/
|
|
424
|
+
function diffCheckRuns(
|
|
425
|
+
before: Record<string, CheckRunRef>,
|
|
426
|
+
after: Record<string, CheckRunRef>,
|
|
427
|
+
eventType: "pr_ci" | "branch_ci",
|
|
428
|
+
fallbackUrl: string,
|
|
429
|
+
out: WatcherEvent[],
|
|
430
|
+
): void {
|
|
431
|
+
for (const [runId, run] of Object.entries(after)) {
|
|
432
|
+
const previous = before[runId];
|
|
433
|
+
if (run.status !== "completed") continue;
|
|
434
|
+
if (previous && previous.status === "completed") continue;
|
|
435
|
+
const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
|
|
436
|
+
out.push({
|
|
437
|
+
type: eventType,
|
|
438
|
+
target: "",
|
|
439
|
+
summary: `check "${run.name}" completed${conclusion}`,
|
|
440
|
+
url: run.url ?? fallbackUrl,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
268
445
|
/**
|
|
269
446
|
* Pure diff between the last-seen and current PR state. Detects comments by
|
|
270
447
|
* id monotonicity, commits by head SHA, description by body hash, CI by
|
|
271
|
-
* check-run transitions
|
|
448
|
+
* check-run transitions, thread resolution by symmetric difference of the
|
|
449
|
+
* sorted resolved-id lists.
|
|
272
450
|
*/
|
|
273
|
-
export function diffPrState(before: PrState, after: PrState,
|
|
451
|
+
export function diffPrState(before: PrState, after: PrState, target: string, baseUrl: string): WatcherEvent[] {
|
|
274
452
|
const events: WatcherEvent[] = [];
|
|
275
453
|
|
|
276
454
|
if (after.headSha !== before.headSha && after.headSha) {
|
|
277
|
-
events.push({
|
|
278
|
-
type: "pr_commit",
|
|
279
|
-
summary: `head moved to ${after.headSha.slice(0, 7)}`,
|
|
280
|
-
url,
|
|
281
|
-
});
|
|
455
|
+
events.push({ type: "pr_commit", target, summary: `head moved to ${after.headSha.slice(0, 7)}`, url: baseUrl });
|
|
282
456
|
}
|
|
283
457
|
|
|
284
458
|
if (after.state !== before.state || after.merged !== before.merged) {
|
|
285
459
|
const status = after.merged ? "merged" : after.state;
|
|
286
|
-
events.push({ type: "pr_status", summary: `PR is now ${status}`, url });
|
|
460
|
+
events.push({ type: "pr_status", target, summary: `PR is now ${status}`, url: baseUrl });
|
|
287
461
|
}
|
|
288
462
|
|
|
289
463
|
if (after.bodySha !== before.bodySha || after.title !== before.title) {
|
|
290
|
-
events.push({ type: "pr_description", summary: "PR title or description changed", url });
|
|
464
|
+
events.push({ type: "pr_description", target, summary: "PR title or description changed", url: baseUrl });
|
|
291
465
|
}
|
|
292
466
|
|
|
293
467
|
const fresh = (refs: CommentRef[], lastSeenId: number) =>
|
|
294
468
|
refs.filter((c) => c.id > lastSeenId);
|
|
295
469
|
|
|
296
470
|
for (const c of fresh(after.issueComments, before.lastIssueCommentId)) {
|
|
297
|
-
events.push({ type: "pr_comment", summary: `comment by ${c.author}`, url: c.url ||
|
|
471
|
+
events.push({ type: "pr_comment", target, summary: `comment by ${c.author}`, url: c.url || baseUrl });
|
|
298
472
|
}
|
|
299
473
|
for (const c of fresh(after.reviewComments, before.lastReviewCommentId)) {
|
|
300
474
|
events.push({
|
|
301
475
|
type: c.isReply ? "pr_review_reply" : "pr_review_comment",
|
|
476
|
+
target,
|
|
302
477
|
summary: `comment by ${c.author}`,
|
|
303
|
-
url: c.url ||
|
|
478
|
+
url: c.url || baseUrl,
|
|
304
479
|
});
|
|
305
480
|
}
|
|
306
481
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (!
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
482
|
+
const afterResolved = new Set(after.resolvedThreads);
|
|
483
|
+
const beforeResolved = new Set(before.resolvedThreads);
|
|
484
|
+
for (const id of after.resolvedThreads) {
|
|
485
|
+
if (!beforeResolved.has(id)) {
|
|
486
|
+
events.push({ type: "pr_review_resolved", target, summary: "review thread resolved", url: baseUrl });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
for (const id of before.resolvedThreads) {
|
|
490
|
+
if (!afterResolved.has(id)) {
|
|
491
|
+
events.push({ type: "pr_review_resolved", target, summary: "review thread reopened", url: baseUrl });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
diffCheckRuns(before.checkRuns, after.checkRuns, "pr_ci", baseUrl, events);
|
|
496
|
+
|
|
497
|
+
return events.slice(0, MAX_EVENTS_PER_DIFF);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Pure diff between the last-seen and current branch state: head movement,
|
|
502
|
+
* check-run completions on the head, and workflow runs appearing or
|
|
503
|
+
* transitioning on the branch.
|
|
504
|
+
*/
|
|
505
|
+
export function diffBranchState(
|
|
506
|
+
before: BranchState,
|
|
507
|
+
after: BranchState,
|
|
508
|
+
target: string,
|
|
509
|
+
repo: string,
|
|
510
|
+
): WatcherEvent[] {
|
|
511
|
+
const events: WatcherEvent[] = [];
|
|
512
|
+
|
|
513
|
+
if (after.headSha !== before.headSha && after.headSha) {
|
|
514
|
+
events.push({
|
|
515
|
+
type: "branch_commit",
|
|
516
|
+
target,
|
|
517
|
+
summary: `head moved to ${after.headSha.slice(0, 7)}`,
|
|
518
|
+
url: `https://github.com/${repo}/commit/${after.headSha}`,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
diffCheckRuns(before.checkRuns, after.checkRuns, "branch_ci", `https://github.com/${repo}/actions`, events);
|
|
523
|
+
|
|
524
|
+
for (const [runId, run] of Object.entries(after.workflowRuns)) {
|
|
525
|
+
const previous = before.workflowRuns[runId];
|
|
526
|
+
const transition = !previous
|
|
527
|
+
? `started (${run.status})`
|
|
528
|
+
: previous.status !== run.status
|
|
529
|
+
? run.status === "completed"
|
|
530
|
+
? `completed (${run.conclusion ?? "no conclusion"})`
|
|
531
|
+
: `${previous.status} -> ${run.status}`
|
|
532
|
+
: null;
|
|
533
|
+
if (!transition) continue;
|
|
534
|
+
events.push({
|
|
535
|
+
type: "branch_workflow",
|
|
536
|
+
target,
|
|
537
|
+
summary: `workflow "${run.name}" ${transition} [${run.event}]`,
|
|
538
|
+
url: run.url,
|
|
539
|
+
});
|
|
314
540
|
}
|
|
315
541
|
|
|
316
542
|
return events.slice(0, MAX_EVENTS_PER_DIFF);
|
|
@@ -371,24 +597,20 @@ export class WatcherRegistry {
|
|
|
371
597
|
// -- CRUD ----------------------------------------------------------------
|
|
372
598
|
|
|
373
599
|
/**
|
|
374
|
-
* Registers a watcher and captures the baseline state immediately. The
|
|
600
|
+
* Registers a PR watcher and captures the baseline state immediately. The
|
|
375
601
|
* baseline fetch doubles as validation: a bad repo, missing PR, or broken
|
|
376
602
|
* gh setup fails here with a real error instead of a watcher that never
|
|
377
603
|
* fires.
|
|
378
604
|
*/
|
|
379
|
-
async
|
|
605
|
+
async createPr(
|
|
380
606
|
sessionID: string,
|
|
381
607
|
repo: string,
|
|
382
608
|
pr: number,
|
|
383
|
-
events:
|
|
384
|
-
): Promise<{ ok: true; watcher:
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
ok: false,
|
|
389
|
-
error: `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
609
|
+
events: PrWatcherEventType[],
|
|
610
|
+
): Promise<{ ok: true; watcher: PrWatcher } | { ok: false; error: string }> {
|
|
611
|
+
const limit = this.#checkLimit(sessionID);
|
|
612
|
+
if (limit) return { ok: false, error: limit };
|
|
613
|
+
if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
|
|
392
614
|
|
|
393
615
|
let state: PrState;
|
|
394
616
|
try {
|
|
@@ -400,8 +622,9 @@ export class WatcherRegistry {
|
|
|
400
622
|
return { ok: false, error: `Could not resolve a head SHA for ${repo}#${pr}. Is it an open PR?` };
|
|
401
623
|
}
|
|
402
624
|
|
|
403
|
-
const watcher:
|
|
625
|
+
const watcher: PrWatcher = {
|
|
404
626
|
id: `watch_${Math.random().toString(36).slice(2, 10)}`,
|
|
627
|
+
source: "pr",
|
|
405
628
|
sessionID,
|
|
406
629
|
repo,
|
|
407
630
|
pr,
|
|
@@ -414,6 +637,56 @@ export class WatcherRegistry {
|
|
|
414
637
|
return { ok: true, watcher };
|
|
415
638
|
}
|
|
416
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Registers a branch watcher (typically main): commit landings, check-run
|
|
642
|
+
* completions on the head, and workflow runs on the branch. Same baseline
|
|
643
|
+
* validation as createPr.
|
|
644
|
+
*/
|
|
645
|
+
async createBranch(
|
|
646
|
+
sessionID: string,
|
|
647
|
+
repo: string,
|
|
648
|
+
branch: string,
|
|
649
|
+
events: BranchWatcherEventType[],
|
|
650
|
+
workflows: string[] = [],
|
|
651
|
+
): Promise<{ ok: true; watcher: BranchWatcher } | { ok: false; error: string }> {
|
|
652
|
+
const limit = this.#checkLimit(sessionID);
|
|
653
|
+
if (limit) return { ok: false, error: limit };
|
|
654
|
+
if (events.length === 0) return { ok: false, error: "No events to watch - pass at least one event type." };
|
|
655
|
+
|
|
656
|
+
let state: BranchState;
|
|
657
|
+
try {
|
|
658
|
+
state = await fetchBranchState(this.#opts.ghRunner, repo, branch);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return { ok: false, error: `Failed to read ${repo}@${branch}: ${err instanceof Error ? err.message : String(err)}` };
|
|
661
|
+
}
|
|
662
|
+
if (!state.headSha) {
|
|
663
|
+
return { ok: false, error: `Could not resolve a head SHA for ${repo}@${branch}. Does the branch exist and have commits?` };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const watcher: BranchWatcher = {
|
|
667
|
+
id: `watch_${Math.random().toString(36).slice(2, 10)}`,
|
|
668
|
+
source: "branch",
|
|
669
|
+
sessionID,
|
|
670
|
+
repo,
|
|
671
|
+
branch,
|
|
672
|
+
events,
|
|
673
|
+
workflows,
|
|
674
|
+
expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
|
|
675
|
+
createdAt: Date.now(),
|
|
676
|
+
state,
|
|
677
|
+
};
|
|
678
|
+
this.#watchers.set(watcher.id, watcher);
|
|
679
|
+
return { ok: true, watcher };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#checkLimit(sessionID: string): string | null {
|
|
683
|
+
const existing = this.listForSession(sessionID);
|
|
684
|
+
if (existing.length >= this.#opts.maxPerSession) {
|
|
685
|
+
return `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`;
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
417
690
|
/** Summaries of one session's watchers, newest first. */
|
|
418
691
|
listForSession(sessionID: string): Watcher[] {
|
|
419
692
|
return [...this.#watchers.values()]
|
|
@@ -442,9 +715,9 @@ export class WatcherRegistry {
|
|
|
442
715
|
/**
|
|
443
716
|
* One poll cycle: diff every watcher, queue events, then deliver whatever
|
|
444
717
|
* is pending for sessions that can accept a prompt. Errors are per-watcher:
|
|
445
|
-
* one bad
|
|
446
|
-
* the interval) are dropped - overlapping diffs against the same
|
|
447
|
-
* would queue duplicate events.
|
|
718
|
+
* one bad target never blocks the others. Reentrant calls (a cycle slower
|
|
719
|
+
* than the interval) are dropped - overlapping diffs against the same
|
|
720
|
+
* baseline would queue duplicate events.
|
|
448
721
|
*/
|
|
449
722
|
async poll(): Promise<void> {
|
|
450
723
|
if (this.#polling) return;
|
|
@@ -457,15 +730,12 @@ export class WatcherRegistry {
|
|
|
457
730
|
continue;
|
|
458
731
|
}
|
|
459
732
|
try {
|
|
460
|
-
const
|
|
461
|
-
const events = diffPrState(watcher.state, after, this.pullUrl(watcher))
|
|
462
|
-
.filter((e) => watcher.events.includes(e.type));
|
|
733
|
+
const events = await this.#pollOne(watcher);
|
|
463
734
|
if (events.length > 0) {
|
|
464
735
|
const queue = this.#pending.get(watcher.sessionID) ?? [];
|
|
465
736
|
queue.push(...events);
|
|
466
737
|
this.#pending.set(watcher.sessionID, queue);
|
|
467
738
|
}
|
|
468
|
-
watcher.state = after;
|
|
469
739
|
} catch (err) {
|
|
470
740
|
console.error(`[thatch] watcher ${id} poll failed: ${err}`);
|
|
471
741
|
}
|
|
@@ -476,6 +746,29 @@ export class WatcherRegistry {
|
|
|
476
746
|
}
|
|
477
747
|
}
|
|
478
748
|
|
|
749
|
+
/** Fetches and diffs one watcher, filtered to its watched event types. */
|
|
750
|
+
async #pollOne(watcher: Watcher): Promise<WatcherEvent[]> {
|
|
751
|
+
if (watcher.source === "pr") {
|
|
752
|
+
const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
|
|
753
|
+
const events = diffPrState(watcher.state, after, `${watcher.repo}#${watcher.pr}`, this.#prUrl(watcher))
|
|
754
|
+
.filter((e) => watcher.events.includes(e.type as PrWatcherEventType));
|
|
755
|
+
watcher.state = after;
|
|
756
|
+
return events;
|
|
757
|
+
}
|
|
758
|
+
const after = await fetchBranchState(this.#opts.ghRunner, watcher.repo, watcher.branch);
|
|
759
|
+
const all = diffBranchState(watcher.state, after, `${watcher.repo}@${watcher.branch}`, watcher.repo);
|
|
760
|
+
watcher.state = after;
|
|
761
|
+
// The workflow-name filter narrows branch_workflow events only -
|
|
762
|
+
// commits and check runs pass through unfiltered, or a watch filtered
|
|
763
|
+
// to "Publish" would silently drop its commit and CI notifications.
|
|
764
|
+
const matchesFilter = (e: WatcherEvent) =>
|
|
765
|
+
e.type !== "branch_workflow" ||
|
|
766
|
+
watcher.workflows.length === 0 ||
|
|
767
|
+
watcher.workflows.some((wf) => e.summary.toLowerCase().includes(wf.toLowerCase()));
|
|
768
|
+
return all
|
|
769
|
+
.filter((e) => watcher.events.includes(e.type as BranchWatcherEventType) && matchesFilter(e));
|
|
770
|
+
}
|
|
771
|
+
|
|
479
772
|
/**
|
|
480
773
|
* Delivers pending events for every session that can accept a prompt.
|
|
481
774
|
* Failed deliveries stay pending and retry on the next cycle or the next
|
|
@@ -505,7 +798,7 @@ export class WatcherRegistry {
|
|
|
505
798
|
return (this.#pending.get(sessionID) ?? []).length;
|
|
506
799
|
}
|
|
507
800
|
|
|
508
|
-
|
|
801
|
+
#prUrl(watcher: PrWatcher): string {
|
|
509
802
|
return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
|
|
510
803
|
}
|
|
511
804
|
}
|