@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.
- 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,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
|
+
}
|