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