@open-everywhere/provider-github 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +50 -2
- package/agent/channels/github-catch-up.ts +134 -0
- package/agent/channels/github-engagement.ts +381 -0
- package/agent/channels/github-identity.ts +376 -0
- package/agent/channels/github-installations.ts +152 -0
- package/agent/channels/github-message-ops.ts +62 -0
- package/agent/channels/github-origin-codec.ts +42 -0
- package/agent/channels/github-post-arm.ts +422 -0
- package/agent/channels/github-reaction-feedback.ts +489 -0
- package/agent/channels/github-reaction-scope.ts +312 -0
- package/agent/channels/github-read.ts +471 -0
- package/agent/channels/github-stop.ts +175 -0
- package/agent/channels/github-supervisor.ts +645 -0
- package/agent/channels/github-webhook-verifier.ts +56 -0
- package/agent/channels/github.ts +1458 -0
- package/agent/contributions.ts +31 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/memory.ts +265 -0
- package/agent/ops.ts +17 -0
- package/agent/provider-events.ts +114 -0
- package/agent/register.ts +130 -0
- package/agent/surface-parity.ts +116 -0
- package/dist/agent/channels/github-catch-up.d.ts +29 -0
- package/dist/agent/channels/github-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/github-catch-up.js +105 -0
- package/dist/agent/channels/github-catch-up.js.map +1 -0
- package/dist/agent/channels/github-engagement.d.ts +223 -0
- package/dist/agent/channels/github-engagement.d.ts.map +1 -0
- package/dist/agent/channels/github-engagement.js +317 -0
- package/dist/agent/channels/github-engagement.js.map +1 -0
- package/dist/agent/channels/github-identity.d.ts +164 -0
- package/dist/agent/channels/github-identity.d.ts.map +1 -0
- package/dist/agent/channels/github-identity.js +295 -0
- package/dist/agent/channels/github-identity.js.map +1 -0
- package/dist/agent/channels/github-installations.d.ts +49 -0
- package/dist/agent/channels/github-installations.d.ts.map +1 -0
- package/dist/agent/channels/github-installations.js +114 -0
- package/dist/agent/channels/github-installations.js.map +1 -0
- package/dist/agent/channels/github-message-ops.d.ts +3 -0
- package/dist/agent/channels/github-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/github-message-ops.js +58 -0
- package/dist/agent/channels/github-message-ops.js.map +1 -0
- package/dist/agent/channels/github-origin-codec.d.ts +7 -0
- package/dist/agent/channels/github-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/github-origin-codec.js +36 -0
- package/dist/agent/channels/github-origin-codec.js.map +1 -0
- package/dist/agent/channels/github-post-arm.d.ts +54 -0
- package/dist/agent/channels/github-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/github-post-arm.js +339 -0
- package/dist/agent/channels/github-post-arm.js.map +1 -0
- package/dist/agent/channels/github-reaction-feedback.d.ts +97 -0
- package/dist/agent/channels/github-reaction-feedback.d.ts.map +1 -0
- package/dist/agent/channels/github-reaction-feedback.js +380 -0
- package/dist/agent/channels/github-reaction-feedback.js.map +1 -0
- package/dist/agent/channels/github-reaction-scope.d.ts +102 -0
- package/dist/agent/channels/github-reaction-scope.d.ts.map +1 -0
- package/dist/agent/channels/github-reaction-scope.js +296 -0
- package/dist/agent/channels/github-reaction-scope.js.map +1 -0
- package/dist/agent/channels/github-read.d.ts +135 -0
- package/dist/agent/channels/github-read.d.ts.map +1 -0
- package/dist/agent/channels/github-read.js +371 -0
- package/dist/agent/channels/github-read.js.map +1 -0
- package/dist/agent/channels/github-stop.d.ts +52 -0
- package/dist/agent/channels/github-stop.d.ts.map +1 -0
- package/dist/agent/channels/github-stop.js +145 -0
- package/dist/agent/channels/github-stop.js.map +1 -0
- package/dist/agent/channels/github-supervisor.d.ts +308 -0
- package/dist/agent/channels/github-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/github-supervisor.js +339 -0
- package/dist/agent/channels/github-supervisor.js.map +1 -0
- package/dist/agent/channels/github-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/github-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/github-webhook-verifier.js +55 -0
- package/dist/agent/channels/github-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/github.d.ts +139 -0
- package/dist/agent/channels/github.d.ts.map +1 -0
- package/dist/agent/channels/github.js +1232 -0
- package/dist/agent/channels/github.js.map +1 -0
- package/dist/agent/contributions.d.ts +12 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +31 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +54 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +224 -0
- package/dist/agent/memory/memory.js.map +1 -0
- package/dist/agent/ops.d.ts +3 -0
- package/dist/agent/ops.d.ts.map +1 -0
- package/dist/agent/ops.js +16 -0
- package/dist/agent/ops.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +112 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +4 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +116 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +110 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +156 -4
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// The GitHub read adapter (OPE-958) — the sixth surface's arm of the uniform chat read, and the
|
|
2
|
+
// last one that was missing.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT CLOSES, IN BOTH PLANES AT ONCE.
|
|
5
|
+
// • THE SEAT. `read_channel` / `read_thread` are documented as identical on all six surfaces and
|
|
6
|
+
// were not: with no adapter registered, a GitHub seat got "reading a github channel is not
|
|
7
|
+
// supported yet" (core channels/channel-read.ts `resolveReadAdapter`).
|
|
8
|
+
// • THE WORKER. `read_work_channel` / `read_work_thread` run the NEUTRAL engine over the same
|
|
9
|
+
// adapter, so GitHub could not hold a work read either. It now does, under its own grant — the
|
|
10
|
+
// access bundle's `repos:` list (access/surface-work-grant.ts).
|
|
11
|
+
//
|
|
12
|
+
// 🔴 THE CONTAINER IS THE ISSUE-OR-PR CONVERSATION, NOT THE REPO. The GitHub SURFACE grain is the
|
|
13
|
+
// repo (github-identity.ts `bindGitHub`: workspace = owner, surface = repo), because a repo is the
|
|
14
|
+
// persistent room that groups every issue and PR in it. The READ grain is one conversation inside
|
|
15
|
+
// it, and it has to be: a "repo timeline" is not a thing a member wrote in, and the read verbs read
|
|
16
|
+
// the container THIS turn arrived in (OPE-929). So the container id is the conversation key
|
|
17
|
+
// `owner/repo#number` — the SAME key `githubConversationKey` already mints for the stop registry, so
|
|
18
|
+
// this file adds no second spelling of a GitHub conversation's address.
|
|
19
|
+
//
|
|
20
|
+
// 🔴 A PLAIN ISSUE READS THE SAME WAY A PR DOES, AND THAT IS THE SECOND GAP THIS CLOSES.
|
|
21
|
+
// `fetchPrFeedback` (core scm/pr-feedback.ts) opens with `GET /repos/{repo}/pulls/{number}`, which
|
|
22
|
+
// 404s on an issue, so a worker delegated from an ISSUE thread had no read of it at all. This
|
|
23
|
+
// adapter fetches `GET /repos/{repo}/issues/{number}` instead — GitHub's issues endpoint serves
|
|
24
|
+
// PULL REQUESTS too, because every PR IS an issue with a `pull_request` member. One call, both
|
|
25
|
+
// kinds, no branch. The comment fetch was never the problem: `/issues/{n}/comments` already served
|
|
26
|
+
// both, which is why `get_pr_feedback` reads a PR's timeline correctly today.
|
|
27
|
+
//
|
|
28
|
+
// 🔴 THE PAGE IS THE NEWEST MESSAGES, WHICH TAKES ONE EXTRA REQUEST. `/issues/{n}/comments` returns
|
|
29
|
+
// OLDEST first with no `direction` parameter, so reading page 1 would hand the model the START of a
|
|
30
|
+
// long conversation while the render says "Recent messages … (oldest first)" and the coverage note
|
|
31
|
+
// says "the most recent". That is the fabrication class this read tier exists to close, so the
|
|
32
|
+
// adapter pages to the END instead: the issue object it already fetched carries the `comments`
|
|
33
|
+
// COUNT, which names the last page directly. At most two comment requests, no `Link` header
|
|
34
|
+
// parsing — {@link PrFetchFn} exposes no headers.
|
|
35
|
+
//
|
|
36
|
+
// OPE-1015 — AND THAT SAME `comments` COUNT ANSWERS COVERAGE EXACTLY, NO HEURISTIC NEEDED. The issue
|
|
37
|
+
// object names the TRUE total comment count; {@link readChannel} compares it against how many this
|
|
38
|
+
// call actually returned and DECLARES `AdapterReadFetch.possiblyIncomplete` from that exact fact
|
|
39
|
+
// rather than leaving core to guess from the line count. A review thread's coverage
|
|
40
|
+
// ({@link readThread}) is not exact the same way — GitHub's review-comment endpoint carries no total
|
|
41
|
+
// count — so that arm declares from whether {@link fetchReviewComments}'s bounded walk ran out of
|
|
42
|
+
// pages (exact "no more") or hit its own ceiling (honest "cannot tell").
|
|
43
|
+
//
|
|
44
|
+
// THE FENCE IS TWO CHECKS AND BOTH FAIL CLOSED. The shared `assertSurfaceAllowed` runs the domain
|
|
45
|
+
// read scope on the REPO (and audits its refusal, like every other surface), and an OWNER check runs
|
|
46
|
+
// beside it because that allowlist compares surface SEGMENTS: `acme/api` and `evil/api` are both
|
|
47
|
+
// "api", so the repo name alone would admit another owner's repo of the same name. The owner is
|
|
48
|
+
// compared against THIS turn's own binding, which is the container fence in its strictest form.
|
|
49
|
+
//
|
|
50
|
+
// THE CREDENTIAL IS THE BROKERED CONVERSATION-READ TOKEN — `resolveConversationReadToken` (core
|
|
51
|
+
// scm/pr-status.ts), the App installation token for the repo, falling back to `GITHUB_TOKEN`.
|
|
52
|
+
// GitHub holds ONE credential family, so the chat read and the work read present the same token;
|
|
53
|
+
// access/surface-work-grant.ts's header carries the full argument for why that is the shipped seam
|
|
54
|
+
// and not an oversight.
|
|
55
|
+
//
|
|
56
|
+
// 🔴 IT IS NOT `resolveReadToken` (OPE-1019). That resolver mints `{contents, pull_requests,
|
|
57
|
+
// metadata}` for the PR verbs, and a token with no `issues` family is refused 403 on `/issues/{n}`
|
|
58
|
+
// when the number names a PLAIN ISSUE — measured live on deploy #118, where both read drives went
|
|
59
|
+
// red while the reply POST on the same two issues returned 201. `resolveConversationReadToken`
|
|
60
|
+
// mints `{issues, pull_requests, metadata}`, which is what an endpoint that serves both
|
|
61
|
+
// conversation kinds needs. See scm/mint.ts for the full measurement.
|
|
62
|
+
//
|
|
63
|
+
// NO `refutedAuthorIds`. The core contract calls its absence a real answer, and GitHub's own header
|
|
64
|
+
// says why: GitHub reads are classified by their own taint-table rows and the author grain is served
|
|
65
|
+
// at the WAKE by `githubAuthorFacts` off `author_association`. A second probe here would be a second
|
|
66
|
+
// place deciding one question.
|
|
67
|
+
|
|
68
|
+
import {
|
|
69
|
+
assertGithubActingIdentity,
|
|
70
|
+
assertSurfaceAllowed,
|
|
71
|
+
bindFrom,
|
|
72
|
+
prApiHeaders,
|
|
73
|
+
resolveConversationReadToken,
|
|
74
|
+
withRetryAfterLoose,
|
|
75
|
+
ChannelScopeError,
|
|
76
|
+
type AdapterReadFetch,
|
|
77
|
+
type BindableContext,
|
|
78
|
+
type PrFetchFn,
|
|
79
|
+
type RenderedLine,
|
|
80
|
+
type RetryPolicy,
|
|
81
|
+
type WakeReadAddress,
|
|
82
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
83
|
+
import { githubReviewThreadRoot } from "./github-identity.js";
|
|
84
|
+
|
|
85
|
+
const GITHUB_CHANNEL_DEFAULT_LIMIT = 50;
|
|
86
|
+
const GITHUB_THREAD_DEFAULT_LIMIT = 100;
|
|
87
|
+
/** GitHub's REST page ceiling for both comment endpoints. */
|
|
88
|
+
const GITHUB_MAX_PAGE = 100;
|
|
89
|
+
/** How many review-comment pages one thread read will walk. A review thread is a handful of
|
|
90
|
+
* comments; the ceiling exists so a pathological PR cannot turn one read into unbounded paging. */
|
|
91
|
+
const MAX_REVIEW_PAGES = 5;
|
|
92
|
+
|
|
93
|
+
const API_BASE = "https://api.github.com";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The 429 budget ONE READ may spend, across every call it makes (OPE-1299).
|
|
97
|
+
*
|
|
98
|
+
* 🔴 THE DEFECT WAS MULTIPLICATION, NOT THE PER-CALL POLICY. `withRetryAfterLoose`'s budget is
|
|
99
|
+
* per call, and both reads here walk several: `readChannel` issues up to three (`issues.get`, then
|
|
100
|
+
* one or two `issues.comments.list` pages) and `readThread` up to {@link MAX_REVIEW_PAGES}
|
|
101
|
+
* `pulls.comments.list` pages (it fetches no issue object — the review-comment walk is the whole
|
|
102
|
+
* read). Under the shared 60 s default that is ~3 and ~5 minutes of waiting for ONE `read_channel` /
|
|
103
|
+
* `read_thread` tool call, and nothing bounded the sum.
|
|
104
|
+
*
|
|
105
|
+
* So the whole read shares ONE deadline, read fresh per call ({@link walkRetry}). The first call
|
|
106
|
+
* still gets the full budget — a single-call read is not tightened at all, which is why this costs
|
|
107
|
+
* no extra failures on the ordinary path — and each later call gets only what is left. The worst
|
|
108
|
+
* case for a whole read becomes this number instead of a multiple of it.
|
|
109
|
+
*
|
|
110
|
+
* 🔴 THIS IS THE SEAM'S OWN POLICY, NOT A CALLER'S. `ReadAdapter.readChannel`/`readThread`
|
|
111
|
+
* (core's channels/channel-read.ts) take no options object, and every one of the six adapters
|
|
112
|
+
* already spends the trailing parameter on its own transport seam — so widening the interface to
|
|
113
|
+
* carry a policy is a cross-provider change, and no production caller could pass one until it
|
|
114
|
+
* happened. Slack answered the same question the same way: `PROBE_OPERATIONS`
|
|
115
|
+
* (providers/slack/agent/channels/slack-web-api.ts) is a table the seam applies itself, with an
|
|
116
|
+
* explicit dependency still winning. `getJson`'s `retry` argument keeps that property here — a test
|
|
117
|
+
* passes its own policy and this default never reaches it.
|
|
118
|
+
*
|
|
119
|
+
* 60 s, matching `DEFAULT_TOTAL_BUDGET_MS`: a read that 429s out degrades the turn's context rather
|
|
120
|
+
* than losing an answer, so the budget buys latency back without making a first attempt any more
|
|
121
|
+
* likely to fail.
|
|
122
|
+
*/
|
|
123
|
+
export const READ_WALK_BUDGET_MS = 60_000;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The clock and sleep one walk runs on. Absent in production — the adapter takes the real ones.
|
|
127
|
+
*
|
|
128
|
+
* 🔴 IT IS HERE SO THE SHARED BUDGET IS TESTABLE AT ALL. The claim this seam makes is about the
|
|
129
|
+
* SECOND and later calls of a walk, and a walk under the real clock can only be observed by waiting
|
|
130
|
+
* out its real waits. `github-read-test.ts` § 8 drives a clock that advances on each recorded sleep,
|
|
131
|
+
* which is what lets one assertion separate "one deadline for the walk" from "one budget per call".
|
|
132
|
+
*/
|
|
133
|
+
export interface ReadWalkSeam {
|
|
134
|
+
readonly now?: () => number;
|
|
135
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The policy one call inside a walk takes: whatever is left of the walk's shared deadline. Zero is
|
|
139
|
+
* a real value the primitive honours — at the deadline, a retry gives up instead of waiting. */
|
|
140
|
+
export function walkRetry(deadlineMs: number, seam: ReadWalkSeam = {}): RetryPolicy {
|
|
141
|
+
const now = seam.now ?? Date.now;
|
|
142
|
+
return {
|
|
143
|
+
totalBudgetMs: Math.max(0, deadlineMs - now()),
|
|
144
|
+
...(seam.sleep ? { sleep: seam.sleep } : {}),
|
|
145
|
+
...(seam.now ? { now: seam.now } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One GitHub conversation's address, parsed out of the container id. */
|
|
150
|
+
export interface GitHubContainer {
|
|
151
|
+
/** The repo owner, as GitHub spells it — the API path segment, never a sanitized key segment. */
|
|
152
|
+
readonly owner: string;
|
|
153
|
+
readonly repo: string;
|
|
154
|
+
/** `owner/repo` — the form every core SCM helper takes. */
|
|
155
|
+
readonly repoFull: string;
|
|
156
|
+
/** The issue or PR number. */
|
|
157
|
+
readonly number: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse `owner/repo#number` — the container id {@link githubReadAdapter.wakeAddress} mints.
|
|
162
|
+
*
|
|
163
|
+
* Returns `undefined` on anything else rather than guessing a part. A partial address would fetch
|
|
164
|
+
* another repo's conversation of the same number, which is the silent mis-address the whole
|
|
165
|
+
* wake-container design refuses (core channels/wake-container.ts).
|
|
166
|
+
*/
|
|
167
|
+
export function parseGithubContainer(container: string): GitHubContainer | undefined {
|
|
168
|
+
const hash = container.lastIndexOf("#");
|
|
169
|
+
if (hash <= 0) return undefined;
|
|
170
|
+
const repoFull = container.slice(0, hash);
|
|
171
|
+
const rest = container.slice(hash + 1);
|
|
172
|
+
if (!/^[0-9]+$/u.test(rest)) return undefined;
|
|
173
|
+
const parts = repoFull.split("/");
|
|
174
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return undefined;
|
|
175
|
+
const number = Number(rest);
|
|
176
|
+
if (!Number.isSafeInteger(number) || number <= 0) return undefined;
|
|
177
|
+
return { owner: parts[0], repo: parts[1], repoFull, number };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The live transport. Injected in every read below so the whole adapter drives offline — the same
|
|
181
|
+
* DI shape slack-read / discord-read take. */
|
|
182
|
+
/** THE package's ONE read transport. Exported so channels/github-reaction-feedback.ts borrows it
|
|
183
|
+
* rather than reaching for `globalThis.fetch` itself — a second reach would be a second transport
|
|
184
|
+
* outside this module's retry seam, which `test:github-write-seam` §4 exists to refuse. */
|
|
185
|
+
export const liveGithubReadFetch = globalThis.fetch as unknown as PrFetchFn;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `operation` is the vendor route name (OPE-1255) `withRetryAfterLoose`'s `RateLimitExhaustedError`
|
|
189
|
+
* reports on exhaustion — this read-only transport was, until now, the ONE issue/PR reader in this
|
|
190
|
+
* package with no 429 backoff (`docs/extending/provider-rate-limits.md`).
|
|
191
|
+
*
|
|
192
|
+
* Exported so its retry behaviour is unit-tested directly (github-read-test.ts), the same way
|
|
193
|
+
* `github-rate-limit-test.ts` drives `githubWrite` directly rather than through a full reply.
|
|
194
|
+
*
|
|
195
|
+
* `retry` reaches no caller OUTSIDE this file — `ReadAdapter.readChannel`/`readThread`
|
|
196
|
+
* (channels/channel-read.ts) take no options object. Since OPE-1299 the adapter itself passes one:
|
|
197
|
+
* each read builds a shared deadline and hands every call what is left of it
|
|
198
|
+
* ({@link READ_WALK_BUDGET_MS}, {@link walkRetry}), so a multi-call read is bounded as a whole.
|
|
199
|
+
*/
|
|
200
|
+
export async function getJson<T>(fetchFn: PrFetchFn, url: string, token: string, operation: string, retry?: RetryPolicy): Promise<T> {
|
|
201
|
+
const res = await withRetryAfterLoose(() => fetchFn(url, { method: "GET", headers: prApiHeaders(token) }), { ...retry, surface: "github", operation });
|
|
202
|
+
if (!res.ok) throw new Error(`read: GitHub GET failed (HTTP ${res.status}) for ${url}.`);
|
|
203
|
+
return (await res.json()) as T;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The raw shapes this adapter reads. Only the fields it renders are declared. */
|
|
207
|
+
interface RawGitHubUser {
|
|
208
|
+
readonly login?: string;
|
|
209
|
+
readonly type?: string;
|
|
210
|
+
}
|
|
211
|
+
interface RawIssue {
|
|
212
|
+
readonly title?: string;
|
|
213
|
+
readonly body?: string | null;
|
|
214
|
+
readonly user?: RawGitHubUser;
|
|
215
|
+
readonly created_at?: string;
|
|
216
|
+
/** The TIMELINE comment count — the field that names the last comment page. */
|
|
217
|
+
readonly comments?: number;
|
|
218
|
+
}
|
|
219
|
+
interface RawComment {
|
|
220
|
+
readonly id?: number;
|
|
221
|
+
readonly body?: string | null;
|
|
222
|
+
readonly user?: RawGitHubUser;
|
|
223
|
+
readonly created_at?: string;
|
|
224
|
+
readonly in_reply_to_id?: number | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One GitHub author rendered into the neutral line's fields. A `Bot` actor is marked a MACHINE for
|
|
228
|
+
* the same reason every other adapter marks one: a bot sits below peer standing and is never an
|
|
229
|
+
* outside person. An unattributed comment carries neither id nor mark, which is the fail-open
|
|
230
|
+
* answer for an author nobody can name. */
|
|
231
|
+
function authorFields(user: RawGitHubUser | undefined): Pick<RenderedLine, "author" | "authorId" | "bot"> {
|
|
232
|
+
const login = user?.login;
|
|
233
|
+
if (!login) return { author: "(unknown)" };
|
|
234
|
+
return { author: login, ...(user?.type === "Bot" ? { bot: true } : { authorId: login }) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The OPENING line of a conversation — the issue/PR title and body, as the author wrote them.
|
|
238
|
+
* Included because it IS the first message: a read that started at the first COMMENT would drop
|
|
239
|
+
* the request the whole thread answers. */
|
|
240
|
+
function openingLine(issue: RawIssue): RenderedLine {
|
|
241
|
+
const title = (issue.title ?? "").trim();
|
|
242
|
+
const body = (issue.body ?? "").trim();
|
|
243
|
+
return {
|
|
244
|
+
...authorFields(issue.user),
|
|
245
|
+
ts: issue.created_at ?? "",
|
|
246
|
+
text: [title, body].filter((part) => part.length > 0).join("\n\n"),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function commentLine(comment: RawComment): RenderedLine {
|
|
251
|
+
return { ...authorFields(comment.user), ts: comment.created_at ?? "", text: (comment.body ?? "").trim() };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The FENCE — this read names the conversation THIS turn arrived in, in a repo this session may
|
|
256
|
+
* read. Both halves throw {@link ChannelScopeError}; see the header for why the owner needs its own
|
|
257
|
+
* check.
|
|
258
|
+
*
|
|
259
|
+
* The owner is compared against the binding's own post-back target, which carries the RAW owner the
|
|
260
|
+
* webhook delivered. The partition's surface key cannot serve here: its segments are sanitized for
|
|
261
|
+
* use as storage keys, so they are not always the vendor ids an API call takes.
|
|
262
|
+
*
|
|
263
|
+
* Comparison is case-INSENSITIVE because GitHub logins are.
|
|
264
|
+
*/
|
|
265
|
+
async function assertGithubContainerAllowed(ctx: BindableContext, target: GitHubContainer): Promise<void> {
|
|
266
|
+
const bound = bindFrom(ctx).target as { owner?: unknown } | undefined;
|
|
267
|
+
const boundOwner = typeof bound?.owner === "string" ? bound.owner : undefined;
|
|
268
|
+
if (boundOwner === undefined || boundOwner.toLowerCase() !== target.owner.toLowerCase()) {
|
|
269
|
+
throw new ChannelScopeError(
|
|
270
|
+
`owner "${target.owner}" is not the owner this turn arrived under (${boundOwner ?? "none"}). A GitHub read ` +
|
|
271
|
+
"reads the conversation its own turn is in.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
await assertSurfaceAllowed(ctx, "github", target.repo);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parse the container id and run the fence — the FIRST two steps of both reads, in one place so
|
|
279
|
+
* they can never come apart. A read that parsed here and fenced there would be one edit away from a
|
|
280
|
+
* fetch on an unfenced address.
|
|
281
|
+
*
|
|
282
|
+
* A malformed id raises {@link ChannelScopeError} rather than a plain `Error` because the two mean
|
|
283
|
+
* the same thing to a caller: this read names an address it may not have, and nothing was fetched.
|
|
284
|
+
*
|
|
285
|
+
* 🔴 THE ACTING-IDENTITY GATE RUNS LAST, AFTER THE ADDRESS FENCE. The fence answers "may this
|
|
286
|
+
* session read this conversation at all"; the gate answers "whose account may answer it". A gate
|
|
287
|
+
* that ran first would write a `connector.acting_identity` row for an address this session may not
|
|
288
|
+
* name, and the row would read as a real read that was refused for the wrong reason.
|
|
289
|
+
*/
|
|
290
|
+
async function admitContainer(ctx: BindableContext, channel: string, tool: string): Promise<GitHubContainer> {
|
|
291
|
+
const target = parseGithubContainer(channel);
|
|
292
|
+
if (!target) throw new ChannelScopeError(`"${channel}" is not a GitHub conversation id (expected "owner/repo#number").`);
|
|
293
|
+
await assertGithubContainerAllowed(ctx, target);
|
|
294
|
+
await assertGithubActingIdentity(ctx, tool);
|
|
295
|
+
return target;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Fetch the issue-or-PR object. `/issues/{n}` serves BOTH kinds — see the header. */
|
|
299
|
+
async function fetchIssue(fetchFn: PrFetchFn, token: string, target: GitHubContainer, retry?: RetryPolicy): Promise<RawIssue> {
|
|
300
|
+
return await getJson<RawIssue>(fetchFn, `${API_BASE}/repos/${target.repoFull}/issues/${target.number}`, token, "issues.get", retry);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The NEWEST `limit` timeline comments, oldest-first. `total` is the issue object's own `comments`
|
|
305
|
+
* count, which names the last page: page `ceil(total / 100)`. When that last page holds fewer lines
|
|
306
|
+
* than the caller asked for, the page before it is prepended, which is enough for any `limit` up to
|
|
307
|
+
* the 100-comment page size (the read verbs cap at 100).
|
|
308
|
+
*/
|
|
309
|
+
async function fetchRecentComments(
|
|
310
|
+
fetchFn: PrFetchFn,
|
|
311
|
+
token: string,
|
|
312
|
+
target: GitHubContainer,
|
|
313
|
+
total: number,
|
|
314
|
+
limit: number,
|
|
315
|
+
retry?: () => RetryPolicy,
|
|
316
|
+
): Promise<RawComment[]> {
|
|
317
|
+
const url = (page: number): string =>
|
|
318
|
+
`${API_BASE}/repos/${target.repoFull}/issues/${target.number}/comments?per_page=${GITHUB_MAX_PAGE}&page=${page}`;
|
|
319
|
+
const lastPage = Math.max(1, Math.ceil(total / GITHUB_MAX_PAGE));
|
|
320
|
+
let out = await getJson<RawComment[]>(fetchFn, url(lastPage), token, "issues.comments.list", retry?.());
|
|
321
|
+
if (!Array.isArray(out)) return [];
|
|
322
|
+
if (out.length < limit && lastPage > 1) {
|
|
323
|
+
const previous = await getJson<RawComment[]>(fetchFn, url(lastPage - 1), token, "issues.comments.list", retry?.());
|
|
324
|
+
if (Array.isArray(previous)) out = [...previous, ...out];
|
|
325
|
+
}
|
|
326
|
+
return out.slice(-limit);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Every review comment on a PR, oldest-first, walked to the {@link MAX_REVIEW_PAGES} ceiling. The
|
|
331
|
+
* thread filter runs over the whole set rather than a recent slice: a review thread's OPENING
|
|
332
|
+
* comment is the one that names the code being discussed, so a read that dropped it would render
|
|
333
|
+
* replies with nothing to reply to.
|
|
334
|
+
*
|
|
335
|
+
* OPE-1015 — `mightBeTruncated` is the walk's own exact answer to "did I definitely see every review
|
|
336
|
+
* comment?": `false` when the walk stopped because a page came back short or empty (there is
|
|
337
|
+
* provably nothing more), `true` only when it stopped because it hit {@link MAX_REVIEW_PAGES} with a
|
|
338
|
+
* full last page (there COULD be more, and this walk cannot tell). GitHub's review-comment endpoint
|
|
339
|
+
* carries no total count the way the issue object does for {@link fetchRecentComments}, so this is
|
|
340
|
+
* the exact fact this endpoint CAN give, not a guess from the returned thread's own length.
|
|
341
|
+
*/
|
|
342
|
+
async function fetchReviewComments(
|
|
343
|
+
fetchFn: PrFetchFn,
|
|
344
|
+
token: string,
|
|
345
|
+
target: GitHubContainer,
|
|
346
|
+
retry?: () => RetryPolicy,
|
|
347
|
+
): Promise<{ comments: RawComment[]; mightBeTruncated: boolean }> {
|
|
348
|
+
const out: RawComment[] = [];
|
|
349
|
+
let mightBeTruncated = false;
|
|
350
|
+
for (let page = 1; page <= MAX_REVIEW_PAGES; page++) {
|
|
351
|
+
const batch = await getJson<RawComment[]>(
|
|
352
|
+
fetchFn,
|
|
353
|
+
`${API_BASE}/repos/${target.repoFull}/pulls/${target.number}/comments?per_page=${GITHUB_MAX_PAGE}&page=${page}`,
|
|
354
|
+
token,
|
|
355
|
+
"pulls.comments.list",
|
|
356
|
+
retry?.(),
|
|
357
|
+
);
|
|
358
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
359
|
+
mightBeTruncated = false;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
out.push(...batch);
|
|
363
|
+
if (batch.length < GITHUB_MAX_PAGE) {
|
|
364
|
+
mightBeTruncated = false;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
// A full page at the LAST page this walk will ever take: the loop is about to exit on the `for`
|
|
368
|
+
// condition, not on a short/empty page, so "no more" was never confirmed.
|
|
369
|
+
mightBeTruncated = page === MAX_REVIEW_PAGES;
|
|
370
|
+
}
|
|
371
|
+
return { comments: out, mightBeTruncated };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The review comments belonging to ONE thread, oldest-first — the thread's own root plus every
|
|
376
|
+
* comment that replies into it. PURE, and exported for the render test.
|
|
377
|
+
*
|
|
378
|
+
* GitHub sets `in_reply_to_id` to the thread's ROOT on every reply, not to the comment being
|
|
379
|
+
* answered, so one equality covers a thread of any depth. The root itself carries no
|
|
380
|
+
* `in_reply_to_id`, which is why it is matched by id.
|
|
381
|
+
*/
|
|
382
|
+
export function reviewThreadLines(comments: readonly RawComment[], rootId: string): RenderedLine[] {
|
|
383
|
+
const root = Number(rootId);
|
|
384
|
+
return comments.filter((c) => c.id === root || c.in_reply_to_id === root).map((c) => commentLine(c));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const githubReadAdapter = {
|
|
388
|
+
platform: "github" as const,
|
|
389
|
+
/**
|
|
390
|
+
* OPE-929 — GitHub's own spelling of "which container and which thread is this turn in".
|
|
391
|
+
*
|
|
392
|
+
* The CONTAINER is the conversation key `owner/repo#number`, read off the binding's
|
|
393
|
+
* `anchorChannelId`: `bindGitHub` already mints it there through `githubConversationKey`, so this
|
|
394
|
+
* adds no second derivation. A turn with no conversation anchor (a supervisor seed, a degraded
|
|
395
|
+
* binding) reports none, and the caller refuses loudly rather than reading the repo's newest
|
|
396
|
+
* issue.
|
|
397
|
+
*
|
|
398
|
+
* The THREAD ROOT is present only on a REVIEW-THREAD turn — an inline comment on a PR's diff.
|
|
399
|
+
* An issue comment and a PR TIMELINE comment are at the top level of their conversation, so they
|
|
400
|
+
* report no thread root and `read_thread` refuses them honestly. That is the same answer a
|
|
401
|
+
* base-channel Discord turn gets, and for the same reason.
|
|
402
|
+
*/
|
|
403
|
+
wakeAddress(ctx: BindableContext): WakeReadAddress | undefined {
|
|
404
|
+
const container = bindFrom(ctx).anchorChannelId;
|
|
405
|
+
if (!container || parseGithubContainer(container) === undefined) return undefined;
|
|
406
|
+
const threadRoot = githubReviewThreadRoot(ctx);
|
|
407
|
+
return { container, ...(threadRoot ? { threadRoot } : {}) };
|
|
408
|
+
},
|
|
409
|
+
/**
|
|
410
|
+
* The issue-or-PR conversation: its opening post, then its newest timeline comments. `channel` is
|
|
411
|
+
* the container id — see {@link parseGithubContainer}.
|
|
412
|
+
*/
|
|
413
|
+
async readChannel(
|
|
414
|
+
ctx: BindableContext,
|
|
415
|
+
channel: string,
|
|
416
|
+
limit?: number,
|
|
417
|
+
fetchFn: PrFetchFn = liveGithubReadFetch,
|
|
418
|
+
walk: ReadWalkSeam = {},
|
|
419
|
+
): Promise<AdapterReadFetch> {
|
|
420
|
+
const target = await admitContainer(ctx, channel, "github_read_channel");
|
|
421
|
+
const effective = Math.min(limit ?? GITHUB_CHANNEL_DEFAULT_LIMIT, GITHUB_MAX_PAGE);
|
|
422
|
+
const token = await resolveConversationReadToken(target.repoFull);
|
|
423
|
+
// OPE-1299 — one deadline for the whole read, read fresh per call. See READ_WALK_BUDGET_MS.
|
|
424
|
+
const deadline = (walk.now ?? Date.now)() + READ_WALK_BUDGET_MS;
|
|
425
|
+
const issue = await fetchIssue(fetchFn, token, target, walkRetry(deadline, walk));
|
|
426
|
+
const total = typeof issue.comments === "number" && issue.comments > 0 ? issue.comments : 0;
|
|
427
|
+
// The OPENING line takes one of the caller's slots, so the comment fetch asks for one fewer.
|
|
428
|
+
// Counting it would return `limit + 1` lines and set the coverage flag on a complete read.
|
|
429
|
+
const wanted = Math.max(1, effective - 1);
|
|
430
|
+
const comments = total === 0 ? [] : await fetchRecentComments(fetchFn, token, target, total, wanted, () => walkRetry(deadline, walk));
|
|
431
|
+
// OPE-1015 — the issue's OWN `comments` count names the true total exactly: MORE exists beyond
|
|
432
|
+
// this page iff the total exceeds what this call asked for, regardless of the page's own length.
|
|
433
|
+
const possiblyIncomplete = total > wanted;
|
|
434
|
+
return {
|
|
435
|
+
lines: [openingLine(issue), ...comments.map((c) => commentLine(c))],
|
|
436
|
+
possiblyIncomplete,
|
|
437
|
+
...(possiblyIncomplete ? { partialReason: `vendor page limit: the conversation holds ${total} comments, only the newest ${wanted} were fetched` } : {}),
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
/**
|
|
441
|
+
* One REVIEW THREAD on a PR — the inline discussion `threadTs` roots. An issue has no review
|
|
442
|
+
* threads, so a read there comes back empty and the neutral render says so; it never invents a
|
|
443
|
+
* thread out of the timeline.
|
|
444
|
+
*/
|
|
445
|
+
async readThread(
|
|
446
|
+
ctx: BindableContext,
|
|
447
|
+
channel: string,
|
|
448
|
+
threadTs: string,
|
|
449
|
+
limit?: number,
|
|
450
|
+
fetchFn: PrFetchFn = liveGithubReadFetch,
|
|
451
|
+
walk: ReadWalkSeam = {},
|
|
452
|
+
): Promise<AdapterReadFetch> {
|
|
453
|
+
const target = await admitContainer(ctx, channel, "github_read_thread");
|
|
454
|
+
const token = await resolveConversationReadToken(target.repoFull);
|
|
455
|
+
// OPE-1299 — the walk this budget exists for: up to MAX_REVIEW_PAGES calls under one deadline.
|
|
456
|
+
const deadline = (walk.now ?? Date.now)() + READ_WALK_BUDGET_MS;
|
|
457
|
+
const { comments, mightBeTruncated } = await fetchReviewComments(fetchFn, token, target, () => walkRetry(deadline, walk));
|
|
458
|
+
const threadAll = reviewThreadLines(comments, threadTs);
|
|
459
|
+
const lines = threadAll.slice(-(limit ?? GITHUB_THREAD_DEFAULT_LIMIT));
|
|
460
|
+
// OPE-1015 — either the review-comment WALK could not confirm it saw everything (hit its own
|
|
461
|
+
// page ceiling), or THIS call trimmed the thread to the caller's limit — both are real partial
|
|
462
|
+
// views. The reason names only the walk's own honest "cannot tell"; a caller-limit trim needs no
|
|
463
|
+
// reason beyond the coverage note's own words.
|
|
464
|
+
const callerTrimmed = lines.length < threadAll.length;
|
|
465
|
+
return {
|
|
466
|
+
lines,
|
|
467
|
+
possiblyIncomplete: mightBeTruncated || callerTrimmed,
|
|
468
|
+
...(mightBeTruncated ? { partialReason: `vendor page limit: only the first ${MAX_REVIEW_PAGES * GITHUB_MAX_PAGE} review comments on this PR were fetched` } : {}),
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// THE GITHUB STOP ARM (OPE-898) — the nearest honest equivalent to the ✋ reaction.
|
|
2
|
+
//
|
|
3
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
// 🔴 GITHUB SENDS NO WEBHOOK FOR A REACTION. THIS IS THE VENDOR EVIDENCE, AND IT IS WHY THIS FILE
|
|
5
|
+
// READS A COMMENT INSTEAD OF AN EMOJI.
|
|
6
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
//
|
|
8
|
+
// GitHub's complete webhook event list (docs.github.com/en/webhooks/webhook-events-and-payloads,
|
|
9
|
+
// read 2026-08-20) carries no `reaction` member and no reaction sub-event on any of the comment
|
|
10
|
+
// families. Reactions exist in the REST API (`/reactions`) as a read-and-write resource, but nothing
|
|
11
|
+
// PUSHES one — so consuming a ✋ on this surface would need polling, and a stop affordance that costs
|
|
12
|
+
// a poll loop is not an affordance, it is a background job with a latency floor.
|
|
13
|
+
//
|
|
14
|
+
// SO THE TRIGGER IS A COMMENT, READ AT THE INGRESS. `channels/turn-cancel.ts`'s `isStopDirective`
|
|
15
|
+
// owns the grammar and its header carries the reasoning for why the match is exactly one word. Two
|
|
16
|
+
// properties make it a real equivalent rather than a chat message the agent happens to notice:
|
|
17
|
+
//
|
|
18
|
+
// 1. IT NEVER QUEUES BEHIND THE TURN IT STOPS. The cancel runs in the INGRESS process, before any
|
|
19
|
+
// session is minted and before any model call — the same pre-dispatch position the Slack and
|
|
20
|
+
// Discord reactions run in. A message the AGENT read would queue behind the very turn it means
|
|
21
|
+
// to stop, which is the structural reason stop cannot be an inference decision at all.
|
|
22
|
+
// 2. IT COSTS NOTHING. No session, no model call, nothing spent — so it keeps working for exactly
|
|
23
|
+
// the person most likely to need it, whose scope is already over its cap.
|
|
24
|
+
//
|
|
25
|
+
// 🔴 THE BARE SPELLINGS ONLY, AND THAT IS DELIBERATE. An `@bot stop` comment is a MENTION, so eve's
|
|
26
|
+
// native GitHub channel would open a session for it — the ingress would cancel the running turn and
|
|
27
|
+
// start a new one in the same breath. `isStopDirective` refuses the mention spellings for that
|
|
28
|
+
// reason. The caller ALSO suppresses its own ambient leg for a stop delivery (channels/github.ts),
|
|
29
|
+
// so a bare `stop` wakes nothing on either path.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
cancelAnchoredTurn,
|
|
33
|
+
isStopDirective,
|
|
34
|
+
resolvePartition,
|
|
35
|
+
turnChannelKey,
|
|
36
|
+
type AttachSessionForCancel,
|
|
37
|
+
type CancelOutcome,
|
|
38
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
39
|
+
import { buildGitHubAuth, githubConversationKey } from "./github-identity.js";
|
|
40
|
+
|
|
41
|
+
/** The two comment event types a stop directive can arrive on. A review COMMENT and an issue/PR
|
|
42
|
+
* comment are the only GitHub comment families that hang off a numbered conversation the anchor
|
|
43
|
+
* registry can key by. A commit comment has no number, so it is not here. */
|
|
44
|
+
const STOP_EVENT_TYPES = new Set(["issue_comment", "pull_request_review_comment"]);
|
|
45
|
+
|
|
46
|
+
/** One stop directive, resolved from a webhook delivery. */
|
|
47
|
+
export interface GitHubStopComment {
|
|
48
|
+
readonly owner: string;
|
|
49
|
+
readonly repo: string;
|
|
50
|
+
/** The issue or PR number the comment sits on — half of the conversation key. */
|
|
51
|
+
readonly number: number;
|
|
52
|
+
/** WHO asked. Recorded on the audit row; the bot check has already passed by the time this exists. */
|
|
53
|
+
readonly authorLogin: string;
|
|
54
|
+
readonly repoPrivate?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Why the arm did what it did — for the caller's log, never for a person to read. */
|
|
58
|
+
export type GitHubStopOutcome =
|
|
59
|
+
| { readonly stopped: false; readonly reason: string }
|
|
60
|
+
| { readonly stopped: true; readonly outcome: CancelOutcome };
|
|
61
|
+
|
|
62
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
63
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function str(v: unknown): string | undefined {
|
|
67
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pick(o: unknown, k: string): unknown {
|
|
71
|
+
return isObject(o) ? o[k] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function num(v: unknown): number | undefined {
|
|
75
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Is this comment author a machine? FAIL-CLOSED: an unresolvable author counts as one.
|
|
80
|
+
*
|
|
81
|
+
* Behind `cancel()` a fail-OPEN author check is a self-cancel — the agent posts into these very
|
|
82
|
+
* threads, and its own comment could then stop its own turn. GitHub reports an app's comment with
|
|
83
|
+
* `user.type: "Bot"` AND a login that ends `[bot]`; both are checked, because either one alone has a
|
|
84
|
+
* spelling this tree has not measured.
|
|
85
|
+
*/
|
|
86
|
+
export function githubCommentIsMachine(comment: unknown): boolean {
|
|
87
|
+
const login = str(pick(pick(comment, "user"), "login"));
|
|
88
|
+
if (!login) return true; // no author ⇒ refuse
|
|
89
|
+
const type = str(pick(pick(comment, "user"), "type"));
|
|
90
|
+
return type?.toLowerCase() === "bot" || login.endsWith("[bot]");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a GitHub webhook delivery into a {@link GitHubStopComment}, or null when it is not a stop
|
|
95
|
+
* directive from a human. NEVER THROWS.
|
|
96
|
+
*
|
|
97
|
+
* Every refusal below is silent by design. A delivery that is not a stop directive is the ordinary
|
|
98
|
+
* case — this function runs on every comment GitHub sends — so there is nothing to log.
|
|
99
|
+
*/
|
|
100
|
+
export function parseGitHubStopComment(body: string, eventType: string): GitHubStopComment | null {
|
|
101
|
+
if (!STOP_EVENT_TYPES.has(eventType)) return null;
|
|
102
|
+
let ev: unknown;
|
|
103
|
+
try {
|
|
104
|
+
ev = JSON.parse(body);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!isObject(ev)) return null;
|
|
109
|
+
// `created` only. An EDIT that turns a sentence into "stop" is not somebody pressing a button; it
|
|
110
|
+
// is a rewrite of history, and acting on one would make the affordance retro-triggerable.
|
|
111
|
+
if (ev["action"] !== "created") return null;
|
|
112
|
+
const comment = ev["comment"];
|
|
113
|
+
if (!isStopDirective(str(pick(comment, "body")))) return null;
|
|
114
|
+
if (githubCommentIsMachine(comment)) return null;
|
|
115
|
+
const repository = ev["repository"];
|
|
116
|
+
const owner = str(pick(pick(repository, "owner"), "login"));
|
|
117
|
+
const repo = str(pick(repository, "name"));
|
|
118
|
+
// An `issue_comment` on a PR still carries the PR under `issue` (GitHub models a PR as an issue for
|
|
119
|
+
// comments), so `issue.number` is the right probe for both; `pull_request.number` covers the review
|
|
120
|
+
// comment family, whose payload has no `issue`.
|
|
121
|
+
const number = num(pick(ev["issue"], "number")) ?? num(pick(ev["pull_request"], "number"));
|
|
122
|
+
const authorLogin = str(pick(pick(comment, "user"), "login"));
|
|
123
|
+
if (!owner || !repo || number === undefined || !authorLogin) return null;
|
|
124
|
+
const repoPrivate = pick(repository, "private");
|
|
125
|
+
return { owner, repo, number, authorLogin, ...(typeof repoPrivate === "boolean" ? { repoPrivate } : {}) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Injected seams, so parse + guard + cancel is unit-tested without a live installation. */
|
|
129
|
+
export interface GitHubStopDeps {
|
|
130
|
+
readonly attachSession: AttachSessionForCancel;
|
|
131
|
+
/** Injectable for the test; defaults to the shared `cancelAnchoredTurn`. There is no second cancel
|
|
132
|
+
* implementation behind this — the seam exists to observe the call, never to replace it. */
|
|
133
|
+
readonly cancel?: typeof cancelAnchoredTurn;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cancel the turn running on the conversation this stop directive was posted into.
|
|
138
|
+
*
|
|
139
|
+
* THE CONVERSATION KEY ONLY. A comment id is not the turn's anchor message — the anchor is the
|
|
140
|
+
* message that STARTED the turn — so offering an `anchorKey` here would be a lookup that always
|
|
141
|
+
* misses. This is the `/oe stop` grain: the newest running turn in this issue or PR.
|
|
142
|
+
*/
|
|
143
|
+
export async function routeGitHubStopComment(stop: GitHubStopComment, deps: GitHubStopDeps): Promise<GitHubStopOutcome> {
|
|
144
|
+
const conversationKey = githubConversationKey(stop.owner, stop.repo, stop.number);
|
|
145
|
+
const channelKey = conversationKey ? turnChannelKey("github", conversationKey) : undefined;
|
|
146
|
+
if (!channelKey) return { stopped: false, reason: "stop: no conversation key" };
|
|
147
|
+
const partition = resolvePartition({
|
|
148
|
+
channel: { kind: "github" },
|
|
149
|
+
session: {
|
|
150
|
+
auth: {
|
|
151
|
+
current: buildGitHubAuth({
|
|
152
|
+
owner: stop.owner,
|
|
153
|
+
repo: stop.repo,
|
|
154
|
+
issueNumber: stop.number,
|
|
155
|
+
actorLogin: stop.authorLogin,
|
|
156
|
+
...(stop.repoPrivate === undefined ? {} : { isPrivate: stop.repoPrivate }),
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const result = await (deps.cancel ?? cancelAnchoredTurn)({
|
|
162
|
+
org: partition.org,
|
|
163
|
+
platform: "github",
|
|
164
|
+
workspace: partition.workspace,
|
|
165
|
+
requestedBy: stop.authorLogin,
|
|
166
|
+
// `reaction` names the AFFORDANCE class, not the vendor mechanism: a member pressed the stop
|
|
167
|
+
// control this surface offers. `command` is the slash-command door and `tool` is the agent's own
|
|
168
|
+
// verb — neither is what happened here. The audit row's job is "which door do people reach for",
|
|
169
|
+
// and a GitHub stop comment is the same door as a Slack ✋.
|
|
170
|
+
via: "reaction",
|
|
171
|
+
query: { channelKey },
|
|
172
|
+
attachSession: deps.attachSession,
|
|
173
|
+
});
|
|
174
|
+
return { stopped: true, outcome: result.outcome };
|
|
175
|
+
}
|