@open-everywhere/provider-github 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +50 -2
  4. package/agent/channels/github-catch-up.ts +134 -0
  5. package/agent/channels/github-engagement.ts +381 -0
  6. package/agent/channels/github-identity.ts +376 -0
  7. package/agent/channels/github-installations.ts +152 -0
  8. package/agent/channels/github-message-ops.ts +62 -0
  9. package/agent/channels/github-origin-codec.ts +42 -0
  10. package/agent/channels/github-post-arm.ts +422 -0
  11. package/agent/channels/github-reaction-feedback.ts +489 -0
  12. package/agent/channels/github-reaction-scope.ts +312 -0
  13. package/agent/channels/github-read.ts +471 -0
  14. package/agent/channels/github-stop.ts +175 -0
  15. package/agent/channels/github-supervisor.ts +645 -0
  16. package/agent/channels/github-webhook-verifier.ts +56 -0
  17. package/agent/channels/github.ts +1458 -0
  18. package/agent/contributions.ts +31 -0
  19. package/agent/docs-generated.ts +15 -0
  20. package/agent/memory/memory.ts +265 -0
  21. package/agent/ops.ts +17 -0
  22. package/agent/provider-events.ts +114 -0
  23. package/agent/register.ts +130 -0
  24. package/agent/surface-parity.ts +116 -0
  25. package/dist/agent/channels/github-catch-up.d.ts +29 -0
  26. package/dist/agent/channels/github-catch-up.d.ts.map +1 -0
  27. package/dist/agent/channels/github-catch-up.js +105 -0
  28. package/dist/agent/channels/github-catch-up.js.map +1 -0
  29. package/dist/agent/channels/github-engagement.d.ts +223 -0
  30. package/dist/agent/channels/github-engagement.d.ts.map +1 -0
  31. package/dist/agent/channels/github-engagement.js +317 -0
  32. package/dist/agent/channels/github-engagement.js.map +1 -0
  33. package/dist/agent/channels/github-identity.d.ts +164 -0
  34. package/dist/agent/channels/github-identity.d.ts.map +1 -0
  35. package/dist/agent/channels/github-identity.js +295 -0
  36. package/dist/agent/channels/github-identity.js.map +1 -0
  37. package/dist/agent/channels/github-installations.d.ts +49 -0
  38. package/dist/agent/channels/github-installations.d.ts.map +1 -0
  39. package/dist/agent/channels/github-installations.js +114 -0
  40. package/dist/agent/channels/github-installations.js.map +1 -0
  41. package/dist/agent/channels/github-message-ops.d.ts +3 -0
  42. package/dist/agent/channels/github-message-ops.d.ts.map +1 -0
  43. package/dist/agent/channels/github-message-ops.js +58 -0
  44. package/dist/agent/channels/github-message-ops.js.map +1 -0
  45. package/dist/agent/channels/github-origin-codec.d.ts +7 -0
  46. package/dist/agent/channels/github-origin-codec.d.ts.map +1 -0
  47. package/dist/agent/channels/github-origin-codec.js +36 -0
  48. package/dist/agent/channels/github-origin-codec.js.map +1 -0
  49. package/dist/agent/channels/github-post-arm.d.ts +54 -0
  50. package/dist/agent/channels/github-post-arm.d.ts.map +1 -0
  51. package/dist/agent/channels/github-post-arm.js +339 -0
  52. package/dist/agent/channels/github-post-arm.js.map +1 -0
  53. package/dist/agent/channels/github-reaction-feedback.d.ts +97 -0
  54. package/dist/agent/channels/github-reaction-feedback.d.ts.map +1 -0
  55. package/dist/agent/channels/github-reaction-feedback.js +380 -0
  56. package/dist/agent/channels/github-reaction-feedback.js.map +1 -0
  57. package/dist/agent/channels/github-reaction-scope.d.ts +102 -0
  58. package/dist/agent/channels/github-reaction-scope.d.ts.map +1 -0
  59. package/dist/agent/channels/github-reaction-scope.js +296 -0
  60. package/dist/agent/channels/github-reaction-scope.js.map +1 -0
  61. package/dist/agent/channels/github-read.d.ts +135 -0
  62. package/dist/agent/channels/github-read.d.ts.map +1 -0
  63. package/dist/agent/channels/github-read.js +371 -0
  64. package/dist/agent/channels/github-read.js.map +1 -0
  65. package/dist/agent/channels/github-stop.d.ts +52 -0
  66. package/dist/agent/channels/github-stop.d.ts.map +1 -0
  67. package/dist/agent/channels/github-stop.js +145 -0
  68. package/dist/agent/channels/github-stop.js.map +1 -0
  69. package/dist/agent/channels/github-supervisor.d.ts +308 -0
  70. package/dist/agent/channels/github-supervisor.d.ts.map +1 -0
  71. package/dist/agent/channels/github-supervisor.js +339 -0
  72. package/dist/agent/channels/github-supervisor.js.map +1 -0
  73. package/dist/agent/channels/github-webhook-verifier.d.ts +5 -0
  74. package/dist/agent/channels/github-webhook-verifier.d.ts.map +1 -0
  75. package/dist/agent/channels/github-webhook-verifier.js +55 -0
  76. package/dist/agent/channels/github-webhook-verifier.js.map +1 -0
  77. package/dist/agent/channels/github.d.ts +139 -0
  78. package/dist/agent/channels/github.d.ts.map +1 -0
  79. package/dist/agent/channels/github.js +1232 -0
  80. package/dist/agent/channels/github.js.map +1 -0
  81. package/dist/agent/contributions.d.ts +12 -0
  82. package/dist/agent/contributions.d.ts.map +1 -0
  83. package/dist/agent/contributions.js +31 -0
  84. package/dist/agent/contributions.js.map +1 -0
  85. package/dist/agent/docs-generated.d.ts +3 -0
  86. package/dist/agent/docs-generated.d.ts.map +1 -0
  87. package/dist/agent/docs-generated.js +13 -0
  88. package/dist/agent/docs-generated.js.map +1 -0
  89. package/dist/agent/memory/memory.d.ts +54 -0
  90. package/dist/agent/memory/memory.d.ts.map +1 -0
  91. package/dist/agent/memory/memory.js +224 -0
  92. package/dist/agent/memory/memory.js.map +1 -0
  93. package/dist/agent/ops.d.ts +3 -0
  94. package/dist/agent/ops.d.ts.map +1 -0
  95. package/dist/agent/ops.js +16 -0
  96. package/dist/agent/ops.js.map +1 -0
  97. package/dist/agent/provider-events.d.ts +3 -0
  98. package/dist/agent/provider-events.d.ts.map +1 -0
  99. package/dist/agent/provider-events.js +112 -0
  100. package/dist/agent/provider-events.js.map +1 -0
  101. package/dist/agent/register.d.ts +4 -0
  102. package/dist/agent/register.d.ts.map +1 -0
  103. package/dist/agent/register.js +116 -0
  104. package/dist/agent/register.js.map +1 -0
  105. package/dist/agent/surface-parity.d.ts +3 -0
  106. package/dist/agent/surface-parity.d.ts.map +1 -0
  107. package/dist/agent/surface-parity.js +110 -0
  108. package/dist/agent/surface-parity.js.map +1 -0
  109. package/package.json +156 -4
  110. package/tsconfig.build.json +22 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,489 @@
1
+ // THE GITHUB REACTION-FEEDBACK ARM (OPE-1502) — a reaction on the agent's own comment becomes a
2
+ // durable feedback signal, the same way it already does on Slack and Discord.
3
+ //
4
+ // 🔴 GITHUB PUSHES NO REACTION WEBHOOK. Slack, Discord, Teams and Linear all DELIVER a reaction
5
+ // event; GitHub delivers none for an issue, a pull request or a comment. That is the same vendor
6
+ // fact the stop arm rests on (channels/github-stop.ts, which is a `stop` COMMENT for exactly this
7
+ // reason). So the inbound half here is a READ, not a delivery — and every design choice below falls
8
+ // out of that one difference.
9
+ //
10
+ // ── THE TRIGGER, AND WHAT IT COSTS
11
+ //
12
+ // The poll rides an inbound delivery this deployment already receives for the same conversation: an
13
+ // `issue_comment` or `issues` webhook names an issue, and the arm then reads the reactions on the
14
+ // agent's own comments in it. A reaction therefore reaches the seat on the NEXT delivery for that
15
+ // conversation, not the instant somebody clicks. That is a real latency, it is the honest ceiling of
16
+ // what GitHub offers, and it is what the bundle's parity declaration says out loud rather than
17
+ // claiming a parity this surface does not have.
18
+ //
19
+ // 🔴 TWO GATES RUN BEFORE THE FIRST REST CALL (OPE-1540), and channels/github-reaction-scope.ts owns
20
+ // both. The agent must be IN this conversation — a conversation it is not in can hold no reaction it
21
+ // could record — and the repository must have hourly budget left. Only then does the arm read
22
+ // anything. Before those gates every delivery paid the issue read and the comment page just to
23
+ // discover there was nothing to look at.
24
+ //
25
+ // 🔴 READING THE NEWEST COMMENTS TAKES AN EXTRA REQUEST, and getting this wrong is silent.
26
+ // `/issues/{n}/comments` returns OLDEST first and has no `direction` parameter — the measured vendor
27
+ // fact this bundle's read adapter is built on (channels/github-read.ts, its header). Asking page 1
28
+ // of a 250-comment thread would hand the poll the conversation's START, so the agent's recent
29
+ // replies would never be examined and the arm would report nothing while looking perfectly healthy.
30
+ // So the poll does what the read adapter does: it takes the issue's own `comments` COUNT, which
31
+ // names the last page directly, and reads that page.
32
+ //
33
+ // THE COST, per ADMITTED delivery — one the two gates above let through: one issue read for the
34
+ // count, one comment page (a second when that page is short and there is a page before it), plus one
35
+ // reactions read per agent comment the page already says carries a reaction — capped by
36
+ // {@link GITHUB_REACTION_POLL_COMMENT_BUDGET}. A conversation with no comments costs the issue read
37
+ // and nothing more. A REFUSED delivery costs no read at all.
38
+ //
39
+ // ── WHY ONLY `added`
40
+ //
41
+ // The reactions route returns the reactions that EXIST. It cannot report one that was taken away, so
42
+ // a poll can only ever see an add. The arm records `added` and never invents a `removed`: a
43
+ // retraction this surface cannot observe is better absent than guessed at.
44
+ //
45
+ // ── THE DEDUPE IS A KEY (OPE-1535)
46
+ //
47
+ // A poll re-reads reactions it has already recorded, so it must not write a second row for each. The
48
+ // arm therefore keeps a durable CLAIM per recorded reaction, keyed by the three fields that identify
49
+ // one: the comment, the reactor and the glyph ({@link githubReactionKey}). A poll asks the store
50
+ // about the exact reaction in front of it and skips the ones it already holds.
51
+ //
52
+ // 🔴 IT IS A `notice_claim`, AND THE LIFETIME IS THE WHOLE POINT. "This reaction was recorded" never
53
+ // stops being true, and a reaction stays visible to the poll for as long as its comment stays in the
54
+ // poll's view — which on a quiet conversation is forever. So the claim must never expire.
55
+ // `claimNotice`/`noticeClaimed` (operational/store.ts) is the tree's at-most-once claim with no TTL
56
+ // and no prune, which is exactly that shape. Growth is one row per recorded reaction — the same
57
+ // number of rows the audit log already holds for them, so the marginal cost is a copy of a thing the
58
+ // deployment already keeps.
59
+ //
60
+ // 🔴 WHY THE AUDIT LOG STOPPED BEING THE ANSWER. It was: the arm read back the newest reaction rows
61
+ // for the surface and skipped what it found. That read is a WINDOW, not a key — `readAudit` takes a
62
+ // LIMIT rather than a cursor, and the surface grain the rows carry is the REPOSITORY, so a repository
63
+ // with more recorded reactions than the window held would re-record one whose row had aged out as
64
+ // soon as a delivery brought the poll back to that conversation. The window's size decided
65
+ // correctness, which is the property a key does not have.
66
+ //
67
+ // 🔴 THE DEDUPE FAILS CLOSED, AND SAYS SO. An unreadable claim reads as ALREADY RECORDED, which
68
+ // bounds what the audit log claims happened — but a store that stays down then disables the feedback
69
+ // signal entirely, with zero rows and no error to look at. So a poll that could not read the claim
70
+ // store writes ONE `github.reaction_claim_unreadable` audit row naming how many reactions it skipped,
71
+ // the same way the budget gate audits its trip (channels/github-reaction-scope.ts).
72
+ //
73
+ // 🔴 THAT ROW ANSWERS A PARTIAL FAULT, NOT EVERY ONE. It is written through `appendAudit`, into the
74
+ // SAME operational store whose claim read just threw — so a whole-store outage loses the row as well,
75
+ // and shows up as the audit DROP COUNTER and its warn instead. The row is what makes the partial case
76
+ // legible: a missing `notice_claim` table, or a grant that covers `audit_log` and not it.
77
+ //
78
+ // 🔴 THE ORDER IS READ, RECORD, CLAIM — and it is chosen so nothing is ever LOST. The claim is
79
+ // written only after `ingestReaction` reports the row recorded, so a sink that fails leaves no claim
80
+ // and the next poll retries the reaction. Claiming first would make the dedupe a mutex across
81
+ // concurrent deliveries, but a failed write would then swallow the reaction permanently — the claim
82
+ // table has no release. Two cases stay open in the same benign direction, both of them a repeated
83
+ // audit row that over-reports the count and corrupts nothing: two deliveries polling the same
84
+ // conversation at the same instant, where both read "not recorded" before either writes; and a claim
85
+ // WRITE that fails after the row landed, which the next poll answers by recording the reaction again.
86
+ // The first was there before the key and is unchanged by it; the second is the price of the order.
87
+ //
88
+ // ── IDENTITY
89
+ //
90
+ // "The agent's own comment" is `<GITHUB_APP_SLUG>[bot]` — the login GitHub gives an App's comments,
91
+ // and the same self-identity channels/github-supervisor.ts drops its own replies by. 🔴 IT FAILS
92
+ // CLOSED: with no slug configured the arm polls nothing at all, because it cannot tell the agent's
93
+ // comments from anybody else's and reading every comment's reactions would record strangers' reactions
94
+ // to strangers' posts as feedback about the agent.
95
+
96
+ import {
97
+ buildIngestedReaction,
98
+ deploymentOrg,
99
+ getOperationalStore,
100
+ ingestReaction,
101
+ liveReactionSink,
102
+ resolvePartition,
103
+ type ReactionIngestOutcome,
104
+ type PrFetchFn,
105
+ type ReactionSinkDeps,
106
+ } from "@open-everywhere/core/agent/adapter-api.js";
107
+ import { buildGitHubAuth } from "./github-identity.js";
108
+ import { GITHUB_API_BASE, installationTokenFor } from "./github-post-arm.js";
109
+ import { getJson, liveGithubReadFetch } from "./github-read.js";
110
+ import { auditGitHubReactionClaimUnreadable, githubConversationInScope, spendGitHubPollBudget } from "./github-reaction-scope.js";
111
+
112
+ /** The webhook families that name a conversation this arm can poll. Both carry `repository` and an
113
+ * issue number; a PR arrives as an `issue_comment` too, because GitHub models a PR as an issue for
114
+ * timeline comments. */
115
+ const POLL_EVENT_TYPES: ReadonlySet<string> = new Set(["issue_comment", "issues"]);
116
+
117
+ /** The conversation one accepted delivery names — everything the poll needs and nothing else. */
118
+ export interface GitHubReactionPollTarget {
119
+ readonly owner: string;
120
+ readonly repo: string;
121
+ readonly number: number;
122
+ readonly installationId?: number;
123
+ readonly repoPrivate?: boolean;
124
+ }
125
+
126
+ /** One of the agent's timeline comments, narrowed to what the poll reads. */
127
+ export interface GitHubPolledComment {
128
+ readonly id: number;
129
+ readonly authorLogin?: string;
130
+ /** GitHub's `user.type` — `"Bot"` for an App's comment. */
131
+ readonly authorType?: string;
132
+ /** `reactions.total_count`. Zero ⇒ the arm never asks for the reactions. */
133
+ readonly reactionCount?: number;
134
+ }
135
+
136
+ /** One reaction on one of those comments. */
137
+ export interface GitHubPolledReaction {
138
+ /** GitHub's fixed eight-value `content` enum (`+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`,
139
+ * `rocket`, `eyes`) — passed through as the emoji DATA, bucketed by core. */
140
+ readonly content: string;
141
+ /** WHO reacted. GitHub sends a `user.type` beside it, and this shape deliberately drops it: the
142
+ * agent's own reaction is identified by its EXACT login, and adding the type could only widen
143
+ * what counts as ours. That is the opposite of {@link GitHubPolledComment}, where the type is a
144
+ * second signal that must AGREE before a comment counts as the agent's. */
145
+ readonly userLogin?: string;
146
+ }
147
+
148
+ /** Injected seams, so parse + filter + dedupe + record is unit-tested with no live installation. */
149
+ export interface GitHubReactionFeedbackDeps {
150
+ readonly sink: ReactionSinkDeps;
151
+ /** The app's own bot login (`<slug>[bot]`). Absent ⇒ the arm polls nothing — see the header. */
152
+ readonly botLogin?: string;
153
+ readonly listComments: (target: GitHubReactionPollTarget) => Promise<readonly GitHubPolledComment[]>;
154
+ readonly listReactions: (target: GitHubReactionPollTarget, commentId: number) => Promise<readonly GitHubPolledReaction[]>;
155
+ /** Has this exact reaction already been recorded? The keyed dedupe's read half — see the header.
156
+ * {@link liveGitHubReactionRecorded} answers it off the durable claim store. */
157
+ readonly recorded: (partition: GitHubReactionPartition, key: string) => Promise<boolean>;
158
+ /** Claim this reaction as recorded. Called ONLY after the sink reports the row written, which is
159
+ * what makes a failed write retryable rather than lost. */
160
+ readonly markRecorded: (partition: GitHubReactionPartition, key: string, atIso: string) => Promise<void>;
161
+ /** GATE 1 (OPE-1540): is the agent IN this conversation — has this bundle posted here, or did an
162
+ * @mention engage the container? Refusing here is what makes a delivery in a conversation the
163
+ * agent is not in cost ZERO REST calls. */
164
+ readonly inScope: (target: GitHubReactionPollTarget, partition: GitHubReactionPartition) => Promise<boolean>;
165
+ /** GATE 2 (OPE-1540): spend one poll from this repository's hourly budget. False refuses the poll
166
+ * and the refusal is audited by the scope module, not here. */
167
+ readonly spendBudget: (target: GitHubReactionPollTarget, partition: GitHubReactionPartition, atIso: string) => Promise<boolean>;
168
+ /** Say out loud that {@link GitHubReactionFeedbackDeps.recorded} threw during this poll, and how
169
+ * many reactions the fail-closed dedupe skipped because of it. Called AT MOST ONCE per poll —
170
+ * see the write's own header in channels/github-reaction-scope.ts for why the count rides the row
171
+ * rather than the row riding each reaction. Absent ⇒ the poll writes nothing, which is what the
172
+ * unit stubs take. */
173
+ readonly auditClaimUnreadable?: (target: GitHubReactionPollTarget, partition: GitHubReactionPartition, atIso: string, skipped: number) => Promise<void>;
174
+ /** How many of the agent's own comments one poll examines, newest first. Bounds the REST cost of a
175
+ * long thread; defaults to {@link GITHUB_REACTION_POLL_COMMENT_BUDGET}. */
176
+ readonly commentBudget?: number;
177
+ readonly now?: () => string;
178
+ }
179
+
180
+ /** The partition coordinates the audit read and the ingest share. */
181
+ export interface GitHubReactionPartition {
182
+ readonly org: string;
183
+ readonly workspace: string;
184
+ readonly surfaceKey: string;
185
+ }
186
+
187
+ /** One poll examines at most this many of the agent's own comments, newest first. A conversation
188
+ * with more agent comments than this is one where the older ones stopped attracting reactions. */
189
+ export const GITHUB_REACTION_POLL_COMMENT_BUDGET = 10;
190
+
191
+ function isObject(v: unknown): v is Record<string, unknown> {
192
+ return v !== null && typeof v === "object" && !Array.isArray(v);
193
+ }
194
+
195
+ function str(v: unknown): string | undefined {
196
+ return typeof v === "string" && v.length > 0 ? v : undefined;
197
+ }
198
+
199
+ function num(v: unknown): number | undefined {
200
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
201
+ }
202
+
203
+ function pick(o: unknown, k: string): unknown {
204
+ return isObject(o) ? o[k] : undefined;
205
+ }
206
+
207
+ /**
208
+ * The conversation one delivery names, or null when the delivery names none. NEVER THROWS.
209
+ *
210
+ * It reads the SAME body the stop leg and the ambient leg read, and it accepts a delivery those two
211
+ * refuse — an `issues` event, or an `issue_comment` the agent itself wrote. The poll is about the
212
+ * CONVERSATION, so any accepted delivery naming one is a valid occasion to look.
213
+ */
214
+ export function parseGitHubReactionPollTarget(body: string, eventType: string): GitHubReactionPollTarget | null {
215
+ if (!POLL_EVENT_TYPES.has(eventType)) return null;
216
+ let ev: unknown;
217
+ try {
218
+ ev = JSON.parse(body);
219
+ } catch {
220
+ return null;
221
+ }
222
+ if (!isObject(ev)) return null;
223
+ const repository = ev["repository"];
224
+ const owner = str(pick(pick(repository, "owner"), "login"));
225
+ const repo = str(pick(repository, "name"));
226
+ const number = num(pick(ev["issue"], "number")) ?? num(pick(ev["pull_request"], "number"));
227
+ if (!owner || !repo || number === undefined) return null;
228
+ const installationId = num(pick(ev["installation"], "id"));
229
+ const repoPrivate = pick(repository, "private");
230
+ return {
231
+ owner,
232
+ repo,
233
+ number,
234
+ ...(installationId === undefined ? {} : { installationId }),
235
+ ...(typeof repoPrivate === "boolean" ? { repoPrivate } : {}),
236
+ };
237
+ }
238
+
239
+ /** Is this comment the agent's own? `user.type === "Bot"` AND the configured `<slug>[bot]` login —
240
+ * both, because either alone is wrong: every App's comment is type `Bot`, and a human account may
241
+ * legally be named to look like one. */
242
+ export function githubCommentIsOwn(comment: GitHubPolledComment, botLogin: string | undefined): boolean {
243
+ const login = botLogin?.trim().toLowerCase();
244
+ if (!login) return false;
245
+ if (comment.authorType?.toLowerCase() !== "bot") return false;
246
+ return comment.authorLogin?.toLowerCase() === login;
247
+ }
248
+
249
+ /** The identity of one recorded reaction — a comment, a reactor and a glyph. The dedupe key, and the
250
+ * same three fields `liveReactionSink` writes into the audit row. */
251
+ export function githubReactionKey(messageId: string, reactorId: string, emoji: string): string {
252
+ return [messageId, reactorId, emoji].join("\u001f");
253
+ }
254
+
255
+ /**
256
+ * Poll one conversation and record every reaction on the agent's own comments that is not recorded
257
+ * yet. Returns one outcome per NEW reaction — an empty array when there is nothing new, which is the
258
+ * ordinary case on a busy conversation.
259
+ *
260
+ * NEVER THROWS: a failed read or a failed sink must never cost the ingress its ack. A comment whose
261
+ * reactions cannot be read is skipped, and the rest of the poll continues.
262
+ */
263
+ export async function routeGitHubReactionFeedback(
264
+ target: GitHubReactionPollTarget,
265
+ deps: GitHubReactionFeedbackDeps,
266
+ ): Promise<readonly ReactionIngestOutcome[]> {
267
+ // FAIL CLOSED on self-identity — see the header. Without it every reaction in the thread would
268
+ // read as feedback on the agent.
269
+ const botLogin = deps.botLogin?.trim().toLowerCase();
270
+ if (!botLogin) return [];
271
+
272
+ // THE PARTITION IS RESOLVED FIRST, because the gates below are keyed by the same surface the rows
273
+ // are. It is PURE — no store, no network — so computing it ahead of a gate that may refuse costs
274
+ // nothing.
275
+ const partition = resolvePartition({
276
+ channel: { kind: "github" },
277
+ session: {
278
+ auth: {
279
+ current: buildGitHubAuth({
280
+ owner: target.owner,
281
+ repo: target.repo,
282
+ issueNumber: target.number,
283
+ // The POLL has no actor — it is the deployment looking, not a person acting. The reactor's
284
+ // own login rides each ingested reaction instead, which is where it belongs.
285
+ actorLogin: botLogin,
286
+ ...(target.repoPrivate === undefined ? {} : { isPrivate: target.repoPrivate }),
287
+ }),
288
+ },
289
+ },
290
+ });
291
+ const coords: GitHubReactionPartition = { org: partition.org, workspace: partition.workspace, surfaceKey: partition.surfaceKey };
292
+
293
+ // ── THE TWO SCOPE GATES (OPE-1540) ─────────────────────────────────────────────────────────────
294
+ //
295
+ // 🔴 BOTH RUN BEFORE THE FIRST REST CALL, which is the whole of the fix: the cost this bundle used
296
+ // to pay was paid to LEARN there was nothing to do. Gate 1 is the cheap one and the one that cuts
297
+ // the most, so it runs first — and a conversation it refuses spends no budget either, because a
298
+ // poll that never happens is not a poll this repository ran.
299
+ //
300
+ // Both FAIL OPEN on a throw, matching their live implementations: a gate that cannot answer costs
301
+ // REST calls, never a lost feedback signal. The `.catch` is also what keeps this function's
302
+ // never-throws contract true for an injected dep.
303
+ const atIso = deps.now?.() ?? new Date().toISOString();
304
+ if (!(await deps.inScope(target, coords).catch(() => true))) return [];
305
+ if (!(await deps.spendBudget(target, coords, atIso).catch(() => true))) return [];
306
+
307
+ const comments = await deps.listComments(target).catch(() => []);
308
+ const own = comments
309
+ .filter((c) => githubCommentIsOwn(c, botLogin) && (c.reactionCount ?? 0) > 0)
310
+ .slice(0, deps.commentBudget ?? GITHUB_REACTION_POLL_COMMENT_BUDGET);
311
+ if (own.length === 0) return [];
312
+
313
+ // The keys this poll has already answered for. It is a memo over `deps.recorded`, not the dedupe
314
+ // itself: the durable claim is what decides, and this only stops one poll asking the store twice
315
+ // about a reaction the vendor happened to list twice.
316
+ const asked = new Set<string>();
317
+
318
+ // How many reactions this poll skipped because the claim store could not answer. It is a COUNT,
319
+ // not a flag, because the row written at the end reports it — see below.
320
+ let claimUnreadable = 0;
321
+
322
+ const outcomes: ReactionIngestOutcome[] = [];
323
+ for (const comment of own) {
324
+ const reactions = await deps.listReactions(target, comment.id).catch(() => []);
325
+ for (const reaction of reactions) {
326
+ const reactorId = reaction.userLogin;
327
+ // The agent's OWN reactions ride the same route — its `react` verb places them. They are never
328
+ // feedback, and unlike the comment filter this one needs no type check: the login is exact.
329
+ if (!reactorId || reactorId.toLowerCase() === botLogin) continue;
330
+ const messageId = String(comment.id);
331
+ const key = githubReactionKey(messageId, reactorId, reaction.content);
332
+ if (asked.has(key)) continue;
333
+ asked.add(key);
334
+ // FAIL-CLOSED on an unreadable claim: an unknown answer is treated as ALREADY RECORDED, so a
335
+ // store blip costs a delayed feedback row rather than a duplicate one. The opposite posture
336
+ // from the two scope gates above, and deliberately: those bound a COST, this one bounds what
337
+ // the audit log claims happened.
338
+ //
339
+ // 🔴 BUT IT IS NEVER SILENT. Failing closed on a store that stays down disables the whole
340
+ // feedback signal with zero rows and no error, which reads exactly like a week nobody reacted
341
+ // in. The throw is counted here and audited ONCE at the end of the poll.
342
+ const claimed = await deps.recorded(coords, key).catch(() => {
343
+ claimUnreadable++;
344
+ return true;
345
+ });
346
+ if (claimed) continue;
347
+ const ingested = buildIngestedReaction({
348
+ surface: "github",
349
+ // Only ever an ADD — see the header. A poll cannot observe a retraction.
350
+ verb: "added",
351
+ reactorId,
352
+ emoji: reaction.content,
353
+ // The CONVERSATION is the channel grain a GitHub turn is keyed by; the COMMENT is the
354
+ // message the reaction landed on.
355
+ target: { channelId: `${target.owner}/${target.repo}#${target.number}`, messageId },
356
+ // Structurally true: the loop only ever reaches the agent's own comments.
357
+ onBotReply: true,
358
+ partition,
359
+ });
360
+ const outcome = await ingestReaction(ingested, deps.sink).catch(() => ({ recorded: false, onBotReply: true, reason: "sink failed" }));
361
+ outcomes.push(outcome);
362
+ // CLAIM ONLY WHAT LANDED — see the header's read/record/claim order. A sink that failed leaves
363
+ // no claim, so the next poll for this conversation tries the reaction again.
364
+ if (outcome.recorded) await deps.markRecorded(coords, key, atIso).catch(() => undefined);
365
+ }
366
+ }
367
+ // ONE ROW PER POLL for an unreadable claim store, and only when one read actually threw. The count
368
+ // is what makes a single row as informative as the flood it replaces. Best-effort and swallowed,
369
+ // like every other write on this path — the never-throws contract holds through it.
370
+ if (claimUnreadable > 0) await deps.auditClaimUnreadable?.(target, coords, atIso, claimUnreadable).catch(() => undefined);
371
+ return outcomes;
372
+ }
373
+
374
+ /** The durable claim key one recorded reaction occupies, namespaced by the SURFACE — `notice_claim`
375
+ * is keyed by org alone, so two repositories in one deployment would otherwise share a key space and
376
+ * a comment id collision across repos would read as a duplicate. */
377
+ function githubReactionClaimKey(p: GitHubReactionPartition, key: string): string {
378
+ return `github-reaction:${p.surfaceKey}\u001f${key}`;
379
+ }
380
+
381
+ /** The live dedupe's read half: has this exact reaction already been recorded? */
382
+ async function liveGitHubReactionRecorded(p: GitHubReactionPartition, key: string): Promise<boolean> {
383
+ return getOperationalStore().noticeClaimed(deploymentOrg(), githubReactionClaimKey(p, key));
384
+ }
385
+
386
+ /** The live dedupe's write half. Idempotent — a re-claim of a held key answers false, which nothing
387
+ * here reads: the question this store answers is "is it claimed", never "who claimed it". */
388
+ async function liveGitHubReactionMarkRecorded(p: GitHubReactionPartition, key: string, atIso: string): Promise<void> {
389
+ await getOperationalStore().claimNotice(deploymentOrg(), githubReactionClaimKey(p, key), atIso);
390
+ }
391
+
392
+ // --- THE LIVE READS ------------------------------------------------------------------------------
393
+ //
394
+ // Every GET below runs under the SAME short-lived installation token every other GitHub call in this
395
+ // bundle mints, through the SAME `getJson` and the SAME transport the read adapter uses — so they
396
+ // inherit its 429 backoff and add no second transport (channels/github-read.ts, and
397
+ // `test:github-write-seam` §4). None of them reads a comment BODY: the poll needs an author, a count
398
+ // and a glyph, and nothing else.
399
+
400
+ /** GitHub's own page ceiling for the comment list. The read adapter pages by the same number. */
401
+ const GITHUB_COMMENT_PAGE = 100;
402
+
403
+ interface RawPollComment {
404
+ readonly id?: number;
405
+ readonly user?: { readonly login?: string; readonly type?: string };
406
+ readonly reactions?: { readonly total_count?: number };
407
+ }
408
+
409
+ interface RawPollReaction {
410
+ readonly content?: string;
411
+ readonly user?: { readonly login?: string; readonly type?: string };
412
+ }
413
+
414
+ function tokenFor(target: GitHubReactionPollTarget): Promise<string> {
415
+ return installationTokenFor({
416
+ platform: "github",
417
+ owner: target.owner,
418
+ repo: target.repo,
419
+ issueNumber: target.number,
420
+ ...(target.installationId === undefined ? {} : { installationId: target.installationId }),
421
+ });
422
+ }
423
+
424
+ /** Production deps for the poll. `botLogin` is `<GITHUB_APP_SLUG>[bot]` — absent when the slug is
425
+ * unset, which is exactly the fail-closed case {@link routeGitHubReactionFeedback} refuses on. */
426
+ export function liveGitHubReactionFeedbackDeps(appSlug: string | undefined, fetchFn: PrFetchFn = liveGithubReadFetch): GitHubReactionFeedbackDeps {
427
+ const slug = appSlug?.trim();
428
+ return {
429
+ sink: liveReactionSink(),
430
+ ...(slug ? { botLogin: `${slug}[bot]` } : {}),
431
+ listComments: async (target) => {
432
+ const token = await tokenFor(target);
433
+ // 🔴 THE LAST PAGE, NOT THE FIRST. `/issues/{n}/comments` returns OLDEST first and honours no
434
+ // `direction` — see the header. The issue object's own `comments` count names the last page
435
+ // directly, which is the same two-request shape channels/github-read.ts uses and for the same
436
+ // reason. Reading page 1 here would examine the conversation's START and find nothing.
437
+ const issue = await getJson<{ readonly comments?: number }>(
438
+ fetchFn,
439
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.number}`,
440
+ token,
441
+ "issues.get",
442
+ );
443
+ const total = typeof issue.comments === "number" && issue.comments > 0 ? issue.comments : 0;
444
+ if (total === 0) return [];
445
+ const page = (nth: number): string => `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/issues/${target.number}/comments?per_page=${GITHUB_COMMENT_PAGE}&page=${nth}`;
446
+ const lastPage = Math.max(1, Math.ceil(total / GITHUB_COMMENT_PAGE));
447
+ let raw = await getJson<readonly RawPollComment[]>(fetchFn, page(lastPage), token, "issues.comments.list");
448
+ if (!Array.isArray(raw)) return [];
449
+ // A short last page can hold fewer of the agent's comments than the budget wants, so the page
450
+ // before it is prepended — the same fallback the read adapter makes, and enough for any budget
451
+ // up to the page size.
452
+ if (raw.length < GITHUB_REACTION_POLL_COMMENT_BUDGET && lastPage > 1) {
453
+ const previous = await getJson<readonly RawPollComment[]>(fetchFn, page(lastPage - 1), token, "issues.comments.list");
454
+ if (Array.isArray(previous)) raw = [...previous, ...raw];
455
+ }
456
+ return raw
457
+ .filter((c): c is RawPollComment & { id: number } => typeof c.id === "number")
458
+ // NEWEST FIRST, so the budget's slice takes the agent's most recent comments — the ones a
459
+ // person is plausibly still reacting to.
460
+ .reverse()
461
+ .map((c) => ({
462
+ id: c.id,
463
+ ...(c.user?.login ? { authorLogin: c.user.login } : {}),
464
+ ...(c.user?.type ? { authorType: c.user.type } : {}),
465
+ ...(typeof c.reactions?.total_count === "number" ? { reactionCount: c.reactions.total_count } : {}),
466
+ }));
467
+ },
468
+ listReactions: async (target, commentId) => {
469
+ const token = await tokenFor(target);
470
+ const raw = await getJson<readonly RawPollReaction[]>(
471
+ fetchFn,
472
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/issues/comments/${commentId}/reactions?per_page=100`,
473
+ token,
474
+ "reactions.listForIssueComment",
475
+ );
476
+ return raw
477
+ .filter((r): r is RawPollReaction & { content: string } => typeof r.content === "string" && r.content.length > 0)
478
+ .map((r) => ({
479
+ content: r.content,
480
+ ...(r.user?.login ? { userLogin: r.user.login } : {}),
481
+ }));
482
+ },
483
+ recorded: (partition, key) => liveGitHubReactionRecorded(partition, key),
484
+ markRecorded: (partition, key, atIso) => liveGitHubReactionMarkRecorded(partition, key, atIso),
485
+ inScope: (target, partition) => githubConversationInScope(partition.surfaceKey, target.owner, target.repo, target.number),
486
+ spendBudget: async (target, _partition, atIso) => (await spendGitHubPollBudget(target.owner, target.repo, atIso)).admitted,
487
+ auditClaimUnreadable: (target, _partition, atIso, skipped) => auditGitHubReactionClaimUnreadable(target.owner, target.repo, atIso, skipped),
488
+ };
489
+ }