@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,422 @@
1
+ // github-post-arm — GitHub's POST arm for the post-arms registry (OPE-114 stage 1c). Absorbs, VERBATIM,
2
+ // GitHub's arms out of unified-reply.ts (liveGitHubPost + its installation-token minting/caching) and
3
+ // unified-redrive.ts (the github describe/container cases). The unified session runs on the synthetic
4
+ // `unified` channel, so this arm mints its OWN repo-scoped CONVERSATION-WRITE installation token
5
+ // (scm/mint.ts `CONVERSATION_WRITE_PERMISSIONS`) rather than using an eve github channel handle.
6
+ //
7
+ // 🔴 THE MID-TURN ARM RIDES THE SAME TRANSPORT (OPE-811). This file used to state that GitHub has no
8
+ // mid-turn reply arm "because eve exports no public proactive comment helper". That reasoned from the
9
+ // EVE ENTRY, not from what this deployment can do: `liveGitHubPost` below has posted every unified
10
+ // GitHub reply for weeks, and it needs no eve channel handle — it mints its own token and calls the
11
+ // REST comments endpoint. So the honest availability bar ("never expose a `reply` that would throw")
12
+ // is met, and `resolveTarget` / `dispatchReply` now sit here over the SAME function the unified spine
13
+ // uses. A GitHub supervisor turn therefore delivers through the same `reply` tool as a Slack seat.
14
+
15
+ // The SCM plane (mint/config) STAYS in core by doctrine (the single SCM implementation) — this arm
16
+ // reaches its App-token minting + config through the adapter-api barrel, alongside the post-arm /
17
+ // wake-origin / unified-reply contracts. See docs/extending/provider-bundles.md.
18
+ import {
19
+ parsePrivateKey,
20
+ signAppJwt,
21
+ mintInstallationToken,
22
+ CONVERSATION_WRITE_PERMISSIONS,
23
+ loadGitHubAppConfig,
24
+ resolveGitHubInstallationId,
25
+ warmCredential,
26
+ markNotApplied,
27
+ withRetryAfter,
28
+ type RateLimitRetryOptions,
29
+ type BrokeredSecret,
30
+ type PostArm,
31
+ type ReplyTarget,
32
+ type ReactTarget,
33
+ type TargetResolveBag,
34
+ type UnifiedWakeOrigin,
35
+ type UnifiedReplyDeps,
36
+ } from "@open-everywhere/core/agent/adapter-api.js";
37
+ import { markGitHubConversationPosted } from "./github-reaction-scope.js";
38
+
39
+ type GitHubOrigin = Extract<UnifiedWakeOrigin, { platform: "github" }>;
40
+ type GitHubReactTarget = Extract<ReactTarget, { platform: "github" }>;
41
+
42
+ /** What the mint needs at call time. `privateKeyPem` never leaves this process and is never part of
43
+ * the cache key or any log/audit line. */
44
+ interface GitHubMintContext {
45
+ readonly appId: string;
46
+ readonly privateKeyPem: string;
47
+ readonly installationId: string;
48
+ readonly owner: string;
49
+ readonly repo: string;
50
+ }
51
+
52
+ /**
53
+ * One minted token per (installation, repo), held warm by the SHARED seam (core
54
+ * credentials/warm.ts, OPE-134). Installation tokens live 1h; re-minting per reply would be correct
55
+ * but wasteful. Keyed by installation id AND repo, because the token is down-scoped to
56
+ * `[origin.repo]` (buildMintBody): a domain spanning two repos under ONE installation must NOT serve
57
+ * repo-A's repo-scoped token for a repo-B reply (GitHub 404s). `re-mint` posture with the seam's
58
+ * 5-minute default margin — exactly the rhythm this arm ran before, now with per-key single-flight
59
+ * and, crucially, a LOUD `github.token_mint_failed` audit row on failure (previously a mint failure
60
+ * left nothing durable behind at all: the reply just threw).
61
+ */
62
+ const installationToken = warmCredential<GitHubMintContext>({
63
+ name: "github.installation-token",
64
+ platform: "github",
65
+ auditAction: "github.token_mint_failed",
66
+ refresh: "re-mint",
67
+ fallbackTtlSeconds: 3600,
68
+ // Non-secret by construction: an installation id and a repo name, the same key the local cache used.
69
+ key: (ctx) => `${ctx.installationId} ${ctx.repo}`,
70
+ mint: async (ctx): Promise<BrokeredSecret> => {
71
+ const jwt = await signAppJwt(parsePrivateKey(ctx.privateKeyPem), ctx.appId, Math.floor(Date.now() / 1000));
72
+ // Repo-scoped CONVERSATION-WRITE set (scm/mint.ts): `issues:write` + `pull_requests:write` +
73
+ // `metadata:read`. NEVER `contents` — this arm must not be able to touch code.
74
+ //
75
+ // 🔴 BOTH WRITE FAMILIES, BECAUSE A CONVERSATION IS AN ISSUE OR A PR (OPE-1020). This file used
76
+ // to mint `{issues: "write"}` alone and call it "the narrowest permission that posts an
77
+ // issue/PR-timeline comment". That was WRONG for the PR half, and it made the agent MUTE on
78
+ // every GitHub PR thread: the wake fired, the turn ran, the spend was incurred, and the reply
79
+ // POST came back 403. Measured on deploy #118 — two plain issues took 201 and one PR took 403
80
+ // twice, same repo, same session, same endpoint. GitHub routes `/issues/{n}/comments` by the
81
+ // RESOURCE the number names, and lists that endpoint under BOTH permission families.
82
+ const minted = await mintInstallationToken({ jwt, installationId: ctx.installationId, repoNames: [ctx.repo], permissions: CONVERSATION_WRITE_PERMISSIONS });
83
+ // OPE-914: a 201 mint is NOT proof the token covers the repo it was asked for — GitHub can
84
+ // return a token whose `repositories` list omits the requested name (a stale/mismatched
85
+ // installation-repo mapping) without erroring the mint call itself, and the ONLY symptom
86
+ // downstream is a 403 on the comment POST that looks identical to a permissions gap. Check the
87
+ // grant here, at the one place that still holds the expected full_name, so a repo the
88
+ // installation does not actually cover fails LOUD (the warmCredential seam's own
89
+ // `github.token_mint_failed` audit row) instead of a confusing later 403.
90
+ const expected = `${ctx.owner}/${ctx.repo}`;
91
+ if (!minted.repositories.includes(expected)) {
92
+ throw new Error(
93
+ `installation token for "${ctx.installationId}" does not cover "${expected}" — granted: [${minted.repositories.join(", ") || "none"}].`,
94
+ );
95
+ }
96
+ // GitHub states expiry as an ISO-8601 string. An unparseable one falls back to the declared
97
+ // 1h TTL rather than becoming a NaN deadline (which would compare false forever and re-mint on
98
+ // every single reply).
99
+ const parsed = Date.parse(minted.expiresAt);
100
+ return { value: minted.token, expiresAtMs: Number.isFinite(parsed) ? parsed : undefined };
101
+ },
102
+ });
103
+
104
+ /** Test-only reset — drops every held installation token. */
105
+ export function _resetGitHubInstallationTokenCache(): void {
106
+ installationToken.reset();
107
+ }
108
+
109
+ /** GitHub App credentials for the unified reply arm: the scm plane's env pair preferred, the eve
110
+ * channel's pair as fallback (same App in a normal deploy — two spellings, one identity). */
111
+ function githubAppCreds(): { appId: string; privateKeyPem: string; installations: Record<string, string> } | null {
112
+ const scm = loadGitHubAppConfig();
113
+ if (scm) return { appId: scm.clientId ?? scm.appId, privateKeyPem: scm.privateKeyPem, installations: scm.installations };
114
+ const appId = process.env.GITHUB_APP_ID;
115
+ const privateKeyPem = process.env.GITHUB_APP_PRIVATE_KEY;
116
+ if (!appId || !privateKeyPem) return null;
117
+ return { appId, privateKeyPem, installations: {} };
118
+ }
119
+
120
+ /** Exported for the reaction-feedback poll (channels/github-reaction-feedback.ts, OPE-1502), which
121
+ * reads under the same App identity every other GitHub call in this bundle uses. */
122
+ export async function installationTokenFor(origin: GitHubOrigin): Promise<string> {
123
+ const creds = githubAppCreds();
124
+ if (!creds) {
125
+ // DEFINITIVE (OPE-825): no App credentials ⇒ no request ever left this host.
126
+ throw markNotApplied(new Error("reply couldn't post to GitHub — no App credentials (set OE_GITHUB_APP_* or GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY)."));
127
+ }
128
+ // The wake's own (webhook-supplied) installation id first; then the SCM plane's composed map —
129
+ // OE_GITHUB_INSTALLATIONS authoritative, `installation`-webhook-discovered installs augmenting it
130
+ // (scm/installations.ts), so an org that self-installed resolves without an env edit.
131
+ const installationId = origin.installationId !== undefined ? String(origin.installationId) : await resolveGitHubInstallationId(origin.owner, creds.installations);
132
+ if (!installationId) {
133
+ // DEFINITIVE (OPE-825): unresolved before any comment request is built — nothing left this host.
134
+ throw markNotApplied(new Error(
135
+ `reply couldn't post to GitHub — no installation id for owner "${origin.owner}" (the wake carried none, OE_GITHUB_INSTALLATIONS has no entry, and no installation webhook has registered one).`,
136
+ ));
137
+ }
138
+ // Key by (installation, repo): the minted token is scoped to exactly [origin.repo], so a sibling
139
+ // repo under the same installation needs its OWN token.
140
+ return installationToken.get({ appId: creds.appId, privateKeyPem: creds.privateKeyPem, installationId, owner: origin.owner, repo: origin.repo });
141
+ }
142
+
143
+ // --- THE GITHUB WRITE SEAM (OPE-1235) ----------------------------------------------------------
144
+ //
145
+ // THE ONE PLACE a GitHub CHANNEL write leaves this package. Before OPE-1235 the three writes below —
146
+ // the comment POST, the comment PATCH, the reaction POST — each called the global `fetch` with its
147
+ // own copy of the same four headers and no 429 handling at all. GitHub answers a rate-limited write
148
+ // with HTTP 429 and a `Retry-After` header, so every one of those writes failed on the FIRST
149
+ // refusal: the reply threw `reply couldn't post to GitHub — 429 …` and the answer was lost.
150
+ //
151
+ // 🔴 "THIS PACKAGE" IS THE HONEST SCOPE, AND THE COUNTER-EXAMPLE IS ONE LINE ABOVE EVERY CALL.
152
+ // `installationTokenFor` mints the token through core's `mintInstallationToken` (scm/mint.ts), which
153
+ // is a bare `fetch` POST to `api.github.com/app/installations/{id}/access_tokens` with NO backoff. It
154
+ // runs immediately before every write below, against the same vendor limiter. A 429 there loses the
155
+ // reply exactly as a 429 here used to — one call earlier. `scm/mint.ts` is a PROTECTED PATH
156
+ // (`scripts/upgrade-fence/manifest.ts`'s `github-permission-sets` rule), so the merge fence refuses
157
+ // EVERY diff that touches it, whoever authored it (OPE-1315). Closing this gap needs the
158
+ // orchestrator's explicit review-and-override.
159
+ // Core's OTHER SCM write plane files (issue-actions.ts, pr.ts, contents.ts, refs.ts) DO carry the
160
+ // backoff now, via `withRetryAfterLoose` (OPE-1253) — `docs/extending/provider-rate-limits.md`
161
+ // records the mint as the one remaining gap. Do not read the paragraph above as "the mint is
162
+ // covered".
163
+ //
164
+ // THE 403 SPELLING IS COVERED HERE NOW, AND ONLY HERE (OPE-1256). GitHub documents a rate-limit
165
+ // refusal as "403 Forbidden or 429 Too Many Requests", often with `Retry-After` on either. The shared
166
+ // primitive still branches on 429 for every other provider; this seam sets the per-seam opt-in
167
+ // `retryForbiddenWithRetryAfter`, which admits a 403 ONLY when it carries a readable `Retry-After`. A
168
+ // BARE 403 stays a permission refusal on every seam, this one included. GitHub's live 403 rate-limit
169
+ // response is still UNMEASURED — `isRateLimitedForbidden` in `channels/rate-limit-retry.ts` carries
170
+ // that residual and the argument for why the discriminator can under-fire but cannot misfire.
171
+ // STILL OUT OF SCOPE: GitHub's OTHER paths do not set the flag — core's SCM write plane (pr.ts,
172
+ // issue-actions.ts, contents.ts, refs.ts) and `github-read.ts` carry the 429 backoff only.
173
+ //
174
+ // One funnel fixes that once. It also removes the header triplication, which is the reason a fourth
175
+ // write would have grown a fourth copy with a fourth chance to omit something.
176
+ //
177
+ // WHAT IT DOES, and only this:
178
+ // • Builds the request — base URL, method, the four fixed headers, a JSON body.
179
+ // • Honours a 429's `Retry-After` through `channels/rate-limit-retry.js`, bounded in attempts, in
180
+ // one wait, and in total wait.
181
+ // • Ends an outlasted 429 in the NAMED `RateLimitExhaustedError`, which states the surface and the
182
+ // operation and carries no token, no body and no personal data.
183
+ // • Returns EVERY other response untouched — a 201, a 404, a 5xx. The callers below keep reading
184
+ // `res.ok` and `res.status` exactly as they did, so the OPE-825 status-on-the-error posture is
185
+ // unchanged. A transport throw still propagates unretried: GitHub may already have applied that
186
+ // write, and repeating it could double a comment.
187
+ //
188
+ // 🔴 NOTHING ELSE IN THIS PACKAGE'S `agent/` MAY CALL THE GLOBAL `fetch` DIRECTLY.
189
+ // `github-write-seam-test.ts` scans this package and reds when a bare `fetch(` appears in it. That
190
+ // scan reaches THIS PACKAGE ONLY: a call site that imports a core SCM helper bypasses the backoff
191
+ // with the scan still green, which is why the paragraph above names those helpers rather than
192
+ // trusting the gate to catch them.
193
+ //
194
+ // `github-read.ts` holds its own read-only transport (`PrFetchFn`) — a SEPARATE seam from this one,
195
+ // tracked as its own § 4 entry in the same scan, and it now carries the backoff too (OPE-1255, via
196
+ // `withRetryAfterLoose`). See `channels/rate-limit-retry.ts`'s doc comment on `withRetryAfterLoose`
197
+ // for why the loose adapter exists rather than `withRetryAfter` directly.
198
+
199
+ /** GitHub's REST base. */
200
+ export const GITHUB_API_BASE = "https://api.github.com";
201
+
202
+ /** The subset of the global `fetch` this seam uses — injectable so a test drives it without GitHub. */
203
+ export type GitHubFetchLike = (input: string, init: RequestInit) => Promise<Response>;
204
+
205
+ /** Transport and retry seams. A test injects them; live code passes none and takes the shared
206
+ * defaults from `channels/rate-limit-retry.ts`. */
207
+ export interface GitHubWriteDeps {
208
+ readonly fetch?: GitHubFetchLike;
209
+ readonly retry?: Pick<RateLimitRetryOptions, "maxAttempts" | "capMs" | "fallbackMs" | "totalBudgetMs" | "sleep" | "now">;
210
+ }
211
+
212
+ /**
213
+ * Issue one GitHub REST write under an installation token, with the 429 backoff applied.
214
+ *
215
+ * `operation` is the vendor route name (`issues.createComment`), NOT the resolved path: it is what
216
+ * `RateLimitExhaustedError` reports, and a path would put an owner, a repo and an issue number into
217
+ * a failure that promises every field on it is safe to log.
218
+ */
219
+ export async function githubWrite(
220
+ input: {
221
+ readonly operation: string;
222
+ readonly method: "POST" | "PATCH";
223
+ readonly path: string;
224
+ readonly token: string;
225
+ readonly body: unknown;
226
+ },
227
+ deps: GitHubWriteDeps = {},
228
+ ): Promise<Response> {
229
+ const send = (): Promise<Response> =>
230
+ (deps.fetch ?? (globalThis.fetch as GitHubFetchLike))(`${GITHUB_API_BASE}${input.path}`, {
231
+ method: input.method,
232
+ headers: {
233
+ authorization: `Bearer ${input.token}`,
234
+ accept: "application/vnd.github+json",
235
+ "x-github-api-version": "2022-11-28",
236
+ "content-type": "application/json",
237
+ },
238
+ body: JSON.stringify(input.body),
239
+ });
240
+ // `retryForbiddenWithRetryAfter` is the PER-SEAM opt-in of OPE-1256, and this seam is the only
241
+ // place in the repo that sets it. GitHub is the one vendor that documents 403 as a rate-limit
242
+ // spelling; the primitive still refuses to retry a BARE 403 here or anywhere. The unverified half —
243
+ // GitHub's live 403 shape — and why it cannot misfire are written on `isRateLimitedForbidden`.
244
+ return withRetryAfter(send, { ...deps.retry, surface: "github", operation: input.operation, retryForbiddenWithRetryAfter: true });
245
+ }
246
+
247
+ /** THE ONE GitHub POST: an issue/PR-timeline comment via the GitHub REST API under a SHORT-LIVED
248
+ * installation token minted host-side from the App credentials. Both spines call it — the UNIFIED
249
+ * window (with a frozen wake origin) and the MID-TURN `reply` tool (with a binding-resolved target,
250
+ * OPE-811). The installation id rides the origin/target when the webhook carried one, with the scm
251
+ * config's owner map as the fallback — a missing/mismatched installation fails VISIBLE, never a
252
+ * comment into the wrong repo. */
253
+ /** Exported for github-message-ops.ts (OPE-899's `SurfaceMessageOps.postText`) — the SAME comment
254
+ * POST, never a second copy of the mint/fetch mechanics. */
255
+ export async function liveGitHubPost(origin: GitHubOrigin, text: string): Promise<string> {
256
+ const token = await installationTokenFor(origin);
257
+ const res = await githubWrite({
258
+ operation: "issues.createComment",
259
+ method: "POST",
260
+ path: `/repos/${origin.owner}/${origin.repo}/issues/${origin.issueNumber}/comments`,
261
+ token,
262
+ body: { body: text },
263
+ });
264
+ if (!res.ok) {
265
+ // Token-free error (the response body can't contain our credential; keep it short anyway). The
266
+ // numeric HTTP status rides on the error (OPE-825) so the classifier can read it directly.
267
+ throw Object.assign(new Error(`reply couldn't post to GitHub — ${res.status} on ${origin.owner}/${origin.repo}#${origin.issueNumber}`), { status: res.status });
268
+ }
269
+ const json = (await res.json().catch(() => ({}))) as { id?: number };
270
+ // 🔴 ONE HALF OF THE REACTION POLL'S SCOPE RECORD (OPE-1540). This is the one place a GitHub
271
+ // comment leaves THIS BUNDLE, so it is where this bundle learns the agent is now in a conversation.
272
+ // It is NOT every comment the agent posts: eve's own poster still emits the `input.requested`
273
+ // comment, which this bundle does not override and this code never sees. (The native @mention
274
+ // leg's TERMINAL completion does claim here now — it posts through this function since OPE-1681.)
275
+ // The poll's gate therefore reads the ENGAGEMENT row as well — see
276
+ // channels/github-reaction-scope.ts, which states why one record cannot cover both posters. AFTER
277
+ // the post, and never a reason to fail one: the call never throws.
278
+ //
279
+ // 🔴 AND NEVER A REASON TO DELAY ONE EITHER. The comment has already landed when this runs, so the
280
+ // only thing this await can cost is the reply tool's RETURN — on the turn path, where an unbounded
281
+ // store call has no business. `markGitHubConversationPosted` owns that ceiling
282
+ // (`GITHUB_POSTED_MARK_TIMEOUT_MS`) rather than this call site, because the best-effort contract
283
+ // that says a lost mark costs a later poll and never a reply belongs with the mark.
284
+ await markGitHubConversationPosted(origin.owner, origin.repo, origin.issueNumber, new Date().toISOString());
285
+ return json.id !== undefined ? String(json.id) : "unknown";
286
+ }
287
+
288
+ type GitHubReplyTarget = Extract<ReplyTarget, { platform: "github" }>;
289
+
290
+ /** The {@link GitHubOrigin} shape {@link liveGitHubPost} posts to, built from a mid-turn
291
+ * {@link ReplyTarget}. The two carry the same four facts under the same names, so this is a widening
292
+ * rather than a translation — stated as a function so a future divergence has one place to live. */
293
+ function originForTarget(target: GitHubReplyTarget): GitHubOrigin {
294
+ return {
295
+ platform: "github",
296
+ owner: target.owner,
297
+ repo: target.repo,
298
+ issueNumber: target.issueNumber,
299
+ ...(target.installationId !== undefined ? { installationId: target.installationId } : {}),
300
+ };
301
+ }
302
+
303
+ /** THE ONE GitHub EDIT (OPE-862): rewrite an existing issue/PR-timeline comment's body. `channelId`
304
+ * is the SAME composite "owner/repo" string {@link replyDedupeLocus}/{@link unifiedDedupeLocus} in
305
+ * reply-post.ts/unified-reply.ts already use for a GitHub locus — the arm parses it back apart
306
+ * rather than the EditLocus contract growing a second field, mirroring how those two dedupe keys
307
+ * are already built. The installation id rides no locus, so this resolves through the SAME
308
+ * owner→installation fallback map `liveGitHubPost` falls back to when a target carries none. */
309
+ /** Exported for github-message-ops.ts (OPE-899's `SurfaceMessageOps.editMessage`) — same reason as
310
+ * {@link liveGitHubPost}: one PATCH implementation, never two. */
311
+ export async function liveGitHubEditComment(channelId: string, commentId: string, text: string): Promise<void> {
312
+ const [owner, repo] = channelId.split("/");
313
+ if (!owner || !repo) {
314
+ throw markNotApplied(new Error(`edit_reply couldn't parse the GitHub locus "${channelId}" — expected "owner/repo".`));
315
+ }
316
+ const token = await installationTokenFor({ platform: "github", owner, repo, issueNumber: 0 });
317
+ const res = await githubWrite({
318
+ operation: "issues.updateComment",
319
+ method: "PATCH",
320
+ path: `/repos/${owner}/${repo}/issues/comments/${commentId}`,
321
+ token,
322
+ body: { body: text },
323
+ });
324
+ if (!res.ok) {
325
+ throw Object.assign(new Error(`edit_reply couldn't edit the GitHub comment — ${res.status} on ${owner}/${repo} comment ${commentId}`), { status: res.status });
326
+ }
327
+ }
328
+
329
+ // --- REACT (OPE-862) --------------------------------------------------------------------------
330
+ //
331
+ // GitHub's reaction primitive (`POST /repos/{o}/{r}/issues/{n}/reactions`) is a FIXED 8-value
332
+ // `content` enum, unlike Slack/Linear's free-text emoji name — so the model's bare name (react.ts's
333
+ // EMOJI_NAME_PATTERN) is mapped to GitHub's own vocabulary here, and a name with no equivalent
334
+ // refuses honestly rather than silently reacting with the wrong glyph or dropping the call.
335
+ // Idempotent on GitHub's side (a duplicate reaction returns the existing one, 200/201 either way),
336
+ // so — unlike Slack's `already_reacted` — no special-case handling is needed for a repeat call.
337
+ type GitHubReactionContent = "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes";
338
+
339
+ const GITHUB_REACTION_MAP: Readonly<Record<string, GitHubReactionContent>> = {
340
+ "+1": "+1",
341
+ thumbsup: "+1",
342
+ "-1": "-1",
343
+ thumbsdown: "-1",
344
+ laugh: "laugh",
345
+ laughing: "laugh",
346
+ joy: "laugh",
347
+ confused: "confused",
348
+ heart: "heart",
349
+ hooray: "hooray",
350
+ tada: "hooray",
351
+ rocket: "rocket",
352
+ eyes: "eyes",
353
+ };
354
+
355
+ async function liveGitHubReact(origin: GitHubOrigin, emoji: string): Promise<void> {
356
+ const content = GITHUB_REACTION_MAP[emoji.toLowerCase()];
357
+ if (!content) {
358
+ // DEFINITIVE (OPE-825): decided before any byte left this host — the model's name has no mapping.
359
+ throw markNotApplied(
360
+ new Error(
361
+ `react couldn't post to GitHub — "${emoji}" has no GitHub reaction equivalent; GitHub only supports: ${[...new Set(Object.values(GITHUB_REACTION_MAP))].join(", ")}.`,
362
+ ),
363
+ );
364
+ }
365
+ const token = await installationTokenFor(origin);
366
+ const res = await githubWrite({
367
+ operation: "issues.createReaction",
368
+ method: "POST",
369
+ path: `/repos/${origin.owner}/${origin.repo}/issues/${origin.issueNumber}/reactions`,
370
+ token,
371
+ body: { content },
372
+ });
373
+ if (!res.ok) {
374
+ throw Object.assign(new Error(`react couldn't post to GitHub — ${res.status} on ${origin.owner}/${origin.repo}#${origin.issueNumber}`), { status: res.status });
375
+ }
376
+ }
377
+
378
+ export const githubPostArm: PostArm = {
379
+ platform: "github",
380
+ // MID-TURN (OPE-811): owner + repo + the issue/PR anchor, all off the binding's post-back target
381
+ // (github-identity.ts `bindGitHub`), never a tool argument. A PR timeline comment anchors on the
382
+ // SAME `/issues/{n}/comments` endpoint, so either anchor number resolves the one target shape.
383
+ resolveTarget: (bag: TargetResolveBag): ReplyTarget | null => {
384
+ const owner = bag.str("owner");
385
+ const repo = bag.str("repo");
386
+ const issueNumber = bag.num("issueNumber") ?? bag.num("pullRequestNumber");
387
+ if (!owner || !repo || issueNumber === undefined || !Number.isFinite(issueNumber) || issueNumber <= 0) return null;
388
+ const installationId = bag.num("installationId");
389
+ return { platform: "github", owner, repo, issueNumber, ...(installationId !== undefined ? { installationId } : {}) };
390
+ },
391
+ dispatchReply: (target, text) => liveGitHubPost(originForTarget(target as GitHubReplyTarget), text),
392
+ dispatchUnified: (origin, text, deps: UnifiedReplyDeps) => (deps.postGitHub ?? liveGitHubPost)(origin as GitHubOrigin, text),
393
+ // EDIT (OPE-862): PATCH the issue/PR-timeline comment `liveGitHubPost` already returns a real id
394
+ // for — no sentinel-id prerequisite here, unlike Linear's ambient route.
395
+ editMessage: (locus, messageId, text) => liveGitHubEditComment(locus.channelId, messageId, text),
396
+ // REACT (OPE-862): the same conversation anchor `reply` posts into — GitHub has no message-level
397
+ // react target the binding carries, so this resolves owner/repo/issueNumber independently of
398
+ // resolveTarget (see ReactTarget's doc in post-arms.ts).
399
+ resolveReactTarget: (bag: TargetResolveBag): ReactTarget | null => {
400
+ const owner = bag.str("owner");
401
+ const repo = bag.str("repo");
402
+ const issueNumber = bag.num("issueNumber") ?? bag.num("pullRequestNumber");
403
+ if (!owner || !repo || issueNumber === undefined || !Number.isFinite(issueNumber) || issueNumber <= 0) return null;
404
+ const installationId = bag.num("installationId");
405
+ return { platform: "github", owner, repo, issueNumber, ...(installationId !== undefined ? { installationId } : {}) };
406
+ },
407
+ react: (target, emoji) => {
408
+ const t = target as GitHubReactTarget;
409
+ return liveGitHubReact(
410
+ { platform: "github", owner: t.owner, repo: t.repo, issueNumber: t.issueNumber, ...(t.installationId !== undefined ? { installationId: t.installationId } : {}) },
411
+ emoji,
412
+ );
413
+ },
414
+ describeRedriveTarget: (origin) => {
415
+ const o = origin as GitHubOrigin;
416
+ return `github ${o.owner}/${o.repo}#${o.issueNumber}`;
417
+ },
418
+ redriveContainerId: (origin) => {
419
+ const o = origin as GitHubOrigin;
420
+ return `${o.owner}/${o.repo}`;
421
+ },
422
+ };