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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,376 @@
1
+ // Pure GitHub→OpenEverywhere identity capture. The GitHub channel (github.ts) calls
2
+ // these from its `onComment` hook to stow RAW GitHub facts (repo owner/name + privacy +
3
+ // conversation anchor + actor) into the session auth context's `attributes`. The
4
+ // INTERPRETATION of those facts into visibility/trust lives here too (githubVisibility/
5
+ // githubTrust) — kept beside the capture so the mapper in agent/identity/binding.ts
6
+ // (bindGitHub) just calls them, exactly as Slack/Teams split capture from
7
+ // interpretation. This file imports no eve types, so it's trivially unit-testable.
8
+ //
9
+ // The two security-critical decisions are ONE tested place each and both FAIL CLOSED:
10
+ // - githubVisibility: "is this a world-readable (public-repo) surface whose learnings
11
+ // may flow up to shared memory?" — only an explicit public repo yields `public`.
12
+ // - githubTrust: "is this surface positively inside the org trust boundary?" — only a
13
+ // PRIVATE repo touched by an org OWNER/MEMBER yields `internal`; everything else
14
+ // (public repos, outside collaborators, missing signals) → no verdict, so bindFrom
15
+ // binds it `external`.
16
+ //
17
+ // GitHub has no 1:1 DM surface (every issue/PR/review thread is a shared conversation on
18
+ // a repo), so mode is always "channel" — bindGitHub inlines that, no helper needed.
19
+ //
20
+ // The IDENTITY MAPPER (bindGitHub) lives here too (moved out of core identity/binding.ts in
21
+ // OPE-114 stage 4, mirroring the notion bundle): registerGithubProvider() registers it via the
22
+ // registerIdentityMapper seam. The only imports are TYPE-ONLY barrel contracts (erased at
23
+ // compile), so the file stays trivially unit-testable.
24
+
25
+ import type { BindableContext, IdentityMapper, IngestedChatMessage, RawIdentity } from "@open-everywhere/core/agent/adapter-api.js";
26
+
27
+ /** Structural match for eve's SessionAuthContext (attributes + principal). */
28
+ export interface OpenEverywhereGitHubAuth {
29
+ readonly authenticator: string;
30
+ readonly principalId: string;
31
+ readonly principalType: string;
32
+ readonly attributes: Record<string, string>;
33
+ }
34
+
35
+ export interface GitHubIdentityInput {
36
+ /** Repo owner (org/user login) — becomes the OpenEverywhere `workspace`, the org boundary. */
37
+ readonly owner: string;
38
+ /** Repo name — becomes the OpenEverywhere `surface` (the silo grain: one repo, many issues/PRs). */
39
+ readonly repo: string;
40
+ /** "owner/name" — carried for reference/logging (not a partition key on its own). */
41
+ readonly repositoryFullName?: string;
42
+ readonly repositoryId?: number;
43
+ /** Repo visibility, when positively known (off ctx.repository.private); absent → fail closed. */
44
+ readonly isPrivate?: boolean;
45
+ /** "issue" | "pull_request" | "review_thread" — picks the post-back anchor (issue vs PR number). */
46
+ readonly conversationKind?: string;
47
+ /** Issue number for the conversation, when it's an issue thread (post-back anchor). */
48
+ readonly issueNumber?: number;
49
+ /** PR number for the conversation, when it's a PR/review thread (post-back anchor). */
50
+ readonly pullRequestNumber?: number;
51
+ /**
52
+ * The ROOT comment id of the review thread this turn arrived in (OPE-958) — present only on a
53
+ * `review_thread` conversation. It is the id `read_thread` fetches the thread by, and the binding
54
+ * is the only place it can ride: `ReadAdapter.wakeAddress` is pure and synchronous and reads the
55
+ * binding, while eve carries the root on its own channel STATE, which the mapper never sees.
56
+ *
57
+ * GitHub sends `in_reply_to_id` on a REPLY and omits it on the comment that OPENED the thread, so
58
+ * the root is `in_reply_to_id ?? comment.id` and github.ts derives it there, once.
59
+ */
60
+ readonly reviewThreadRootCommentId?: number;
61
+ /** GitHub App installation id (needed to mint tokens for proactive post-back). */
62
+ readonly installationId?: number;
63
+ /** The acting GitHub user (comment author) — the OpenEverywhere principal. */
64
+ readonly actorLogin: string;
65
+ readonly actorId?: number;
66
+ /** "User" | "Bot" | "Organization" — a Bot actor maps to a `service` principal. */
67
+ readonly actorType?: string;
68
+ /**
69
+ * The actor's relationship to the repo, as GitHub reports it on the comment payload
70
+ * (`author_association`): OWNER | MEMBER | COLLABORATOR | CONTRIBUTOR |
71
+ * FIRST_TIME_CONTRIBUTOR | FIRST_TIMER | MANNEQUIN | NONE. A verifiable trust signal.
72
+ */
73
+ readonly authorAssociation?: string;
74
+ }
75
+
76
+ /** Build the auth context OpenEverywhere dispatches with — raw facts as string attributes. */
77
+ export function buildGitHubAuth(input: GitHubIdentityInput): OpenEverywhereGitHubAuth {
78
+ const attributes: Record<string, string> = {
79
+ owner: input.owner,
80
+ repo: input.repo,
81
+ user_login: input.actorLogin,
82
+ };
83
+ if (input.repositoryFullName) attributes.repository = input.repositoryFullName;
84
+ if (typeof input.repositoryId === "number") attributes.repository_id = String(input.repositoryId);
85
+ if (typeof input.isPrivate === "boolean") attributes.repo_private = String(input.isPrivate);
86
+ if (input.conversationKind) attributes.conversation_kind = input.conversationKind;
87
+ if (typeof input.issueNumber === "number") attributes.issue_number = String(input.issueNumber);
88
+ if (typeof input.pullRequestNumber === "number") attributes.pull_request_number = String(input.pullRequestNumber);
89
+ if (typeof input.reviewThreadRootCommentId === "number") attributes.review_thread_root_comment_id = String(input.reviewThreadRootCommentId);
90
+ if (typeof input.installationId === "number") attributes.installation_id = String(input.installationId);
91
+ if (typeof input.actorId === "number") attributes.user_id = String(input.actorId);
92
+ if (input.actorType) attributes.user_type = input.actorType;
93
+ if (input.authorAssociation) attributes.author_association = input.authorAssociation;
94
+ return {
95
+ authenticator: "github",
96
+ principalId: input.actorLogin,
97
+ principalType: input.actorType === "Bot" ? "service" : "github-user",
98
+ attributes,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * A comment @mentions the bot and should wake a turn. Faithfully mirrors eve's
104
+ * internal gate (shouldDispatchGitHubComment: isIgnoredGitHubComment +
105
+ * extractGitHubCommentTrigger), which is NOT on eve's public export surface — so we
106
+ * replicate it here (small, deterministic, and unit-tested) rather than reach into a
107
+ * package-internal path. Kept byte-consistent with eve because the channel's message
108
+ * layer still strips the mention token via eve's own extractGitHubCommentTrigger; a
109
+ * divergent gate here would fire turns eve then can't strip. FAIL CLOSED: no botName,
110
+ * a bot author, the bot's own comments, or eve's marker → no wake.
111
+ */
112
+ export function githubBotMentioned(
113
+ body: string | undefined,
114
+ botName: string | undefined,
115
+ author: { readonly login?: string; readonly type?: string } | undefined,
116
+ ): boolean {
117
+ const name = botName?.trim();
118
+ if (!name || !body) return false;
119
+ if (body.includes("<!-- eve:github:")) return false; // eve's own comment marker — never self-trigger
120
+ if (author?.type === "Bot") return false; // ignore bot actors (loop guard)
121
+ if (author?.login && author.login.toLowerCase() === `${name}[bot]`.toLowerCase()) return false; // the bot itself
122
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
123
+ // "@name" not followed by a word char — matches eve's `@${name}(?=$|[^A-Za-z0-9_-])`.
124
+ return new RegExp(`@${escaped}(?=$|[^A-Za-z0-9_-])`, "iu").test(body);
125
+ }
126
+
127
+ /**
128
+ * GitHub repo privacy → visibility. FAIL CLOSED, mirroring slackVisibility/
129
+ * linearVisibility: only an explicitly PUBLIC repo (`repo_private="false"`) yields
130
+ * `public`; a private repo (`"true"`) or an unknown signal → `private`, so an
131
+ * under-identified session or a private repo never writes its learnings up to shared
132
+ * (workspace) memory. A public repo's issue/PR threads are world-readable, so treating
133
+ * them as a public surface (learnings may flow to the workspace brain across the org's
134
+ * repos) is correct; anything we can't confirm public stays surface-tier private.
135
+ */
136
+ export function githubVisibility(repoPrivate: string | undefined): Visibility {
137
+ if (repoPrivate === "false") return "public";
138
+ return "private"; // "true" or unknown → fail closed
139
+ }
140
+
141
+ /**
142
+ * GitHub actor + repo privacy → trust (the shared-memory gate). FAIL CLOSED — only a
143
+ * POSITIVE in-boundary fact yields a verdict:
144
+ * - A PRIVATE repo (only org members + invited collaborators can see it) touched by an
145
+ * org OWNER or MEMBER → `internal`. OWNER/MEMBER are GitHub's strong "part of the org
146
+ * that owns this repo" signals (`author_association`); on a private repo that means an
147
+ * insider acting inside the boundary, so shared/org memory is safe to read.
148
+ * - ANYTHING ELSE → `undefined`, so bindFrom FAILS CLOSED to `external`:
149
+ * · a PUBLIC repo — world-readable, anyone (external contributors) can comment, so it
150
+ * must never light up the org brain regardless of who this particular actor is;
151
+ * · a private repo touched by a COLLABORATOR (may be an OUTSIDE collaborator),
152
+ * CONTRIBUTOR, first-timer, mannequin, NONE, or a missing/unknown association;
153
+ * · a missing repo-privacy signal.
154
+ * The operator can still vouch for a deployment with OE_TRUST=internal — the same layer
155
+ * Slack-DM/Teams/Discord/Linear use. An ambiguous GitHub event must never reach
156
+ * the shared org brain. (Per-repo collaborator-membership enrichment — GET
157
+ * /repos/{o}/{r}/collaborators/{u}/permission — is the future layer-2 gate that could
158
+ * promote a verified internal collaborator; until then, only OWNER/MEMBER on a private
159
+ * repo is trusted.)
160
+ */
161
+ export function githubTrust(repoPrivate: string | undefined, authorAssociation: string | undefined): Trust | undefined {
162
+ if (repoPrivate !== "true") return undefined; // public / unknown repo → no verdict (binds external)
163
+ if (authorAssociation === "OWNER" || authorAssociation === "MEMBER") return "internal";
164
+ return undefined; // collaborator/contributor/outside/unknown on a private repo → fail closed
165
+ }
166
+
167
+ /** Local copies of the binding's unions (kept eve-free + import-cycle-free). */
168
+ type Visibility = "public" | "private";
169
+ type Trust = "internal" | "external";
170
+
171
+ // --- the EXTERNAL-AUTHOR grain (OPE-533) --------------------------------------------------------
172
+ //
173
+ // THE EVIDENCE IS FREE. GitHub reports `author_association` on every `issue_comment` payload, and
174
+ // {@link buildGitHubAuth} already stows it. The author grain needs no new call and no new
175
+ // installation permission — the org-membership probe (`orgs/{org}/members/{user}`) would need the
176
+ // "Members" permission, which every installed org would have to re-approve.
177
+ //
178
+ // 🔴 THE SET IS A RULING, NOT AN INFERENCE (Tommy, 2026-08-14). INTERNAL is the three values that
179
+ // each carry a DELIBERATE ACCESS GRANT on the repo: `OWNER`, `MEMBER`, `COLLABORATOR`. Every other
180
+ // value is EXCLUDED. Tommy verbatim: "contributor shouldn't be trusted" — a past merged PR is not an
181
+ // access grant. `MANNEQUIN` is an import placeholder with no live person behind it, so it is
182
+ // excluded as hygiene.
183
+ //
184
+ // 🔴 THIS IS A DIFFERENT QUESTION FROM {@link githubTrust}, AND THE TWO MUST NOT BE MERGED.
185
+ // `githubTrust` asks "may this session read the shared org brain?" and fails CLOSED, so it demands
186
+ // a PRIVATE repo AND an OWNER/MEMBER author. This asks "may this person's bytes task the agent?"
187
+ // and fails OPEN. A COLLABORATOR is internal here and still not trusted there, which is correct:
188
+ // denying service is a different act from opening the org's memory.
189
+
190
+ /** `author_association` values that carry a deliberate access grant on the repo — the INTERNAL set. */
191
+ export const GITHUB_INTERNAL_ASSOCIATIONS: readonly string[] = ["OWNER", "MEMBER", "COLLABORATOR"];
192
+
193
+ /** `author_association` values that carry NO access grant — the EXCLUDED (external) set. This is
194
+ * GitHub's whole documented enum minus {@link GITHUB_INTERNAL_ASSOCIATIONS}, written out rather
195
+ * than derived by negation: see {@link githubAssociationRefutes} for why the difference matters. */
196
+ export const GITHUB_EXTERNAL_ASSOCIATIONS: readonly string[] = [
197
+ "CONTRIBUTOR",
198
+ "FIRST_TIME_CONTRIBUTOR",
199
+ "FIRST_TIMER",
200
+ "NONE",
201
+ "MANNEQUIN",
202
+ ];
203
+
204
+ /**
205
+ * Does this `author_association` POSITIVELY refute the author's membership?
206
+ *
207
+ * 🔴 IT MATCHES THE EXCLUDED SET, IT DOES NOT NEGATE THE INTERNAL ONE, and the difference is the
208
+ * whole fail-open posture. An ABSENT association (a payload shape GitHub changed, a synthesised
209
+ * event) and an UNRECOGNISED one (a value GitHub adds later) both answer `false` — "not refuted",
210
+ * which is what `IngestedChatMessage.refuted` means by absence of evidence. Negating the internal
211
+ * set would read every unknown as external and take the agent off the air on a payload change.
212
+ *
213
+ * Case-sensitive on purpose: GitHub sends these values upper-case, and accepting a lower-case
214
+ * variant would be inventing a second spelling of a vendor enum.
215
+ */
216
+ export function githubAssociationRefutes(authorAssociation: string | undefined): boolean {
217
+ return authorAssociation !== undefined && GITHUB_EXTERNAL_ASSOCIATIONS.includes(authorAssociation);
218
+ }
219
+
220
+ /**
221
+ * The author grain's evidence for one GitHub comment, in the core's own vocabulary
222
+ * (`IngestedChatMessage`) so access/external-policy.ts answers the exclusion question — this bundle
223
+ * never re-derives it.
224
+ *
225
+ * A `Bot` actor grades as the machine floor `relay` and is therefore NEVER excluded, which is the
226
+ * core's own ruling (access/taint-signal.ts `nonMemberChatTrust`): a bot is below peer standing but
227
+ * it is not a person outside the organisation, and withholding every app's comments would be an
228
+ * outage. Stating it as the tier rather than as a local `if` keeps ONE place deciding.
229
+ *
230
+ * A human author carries `refuted` only when {@link githubAssociationRefutes} says so. `trust` is
231
+ * left ABSENT for a human on purpose: this runs before the per-wake trust resolution, so naming a
232
+ * tier here would be a guess, and the refutation leg needs no tier.
233
+ */
234
+ export function githubAuthorFacts(input: {
235
+ readonly authorAssociation?: string;
236
+ readonly actorType?: string;
237
+ }): IngestedChatMessage {
238
+ if (input.actorType === "Bot") return { trust: "relay" };
239
+ return githubAssociationRefutes(input.authorAssociation) ? { refuted: true } : {};
240
+ }
241
+
242
+ // --- the identity MAPPER (moved out of core identity/binding.ts in OPE-114 stage 4) ---------
243
+ // A GitHub session runs on an ISSUE / PR / review thread inside a repo, which lives under an owner
244
+ // (org or user). The silo grain is the REPO — the persistent container that groups all of a repo's
245
+ // issues and PRs (the Slack-channel analog); the issue/PR number is the post-back ANCHOR, not the
246
+ // partition key. So: workspace = owner (the org boundary), surface = repo, mode = channel (GitHub
247
+ // has no 1:1 DM surface). Raw facts are stowed by buildGitHubAuth above; visibility/trust
248
+ // interpretation lives here too (githubVisibility/githubTrust) — the one tested place. Trust FAILS
249
+ // CLOSED: githubTrust returns a verdict only on a POSITIVE fact (a private repo touched by an org
250
+ // OWNER/MEMBER → internal), so bindFrom binds a public repo or an outside/unknown actor `external`
251
+ // unless the operator vouches with OE_TRUST=internal.
252
+ //
253
+ // The two attribute helpers are LOCAL COPIES of core binding.ts's module-private probes (attr /
254
+ // authPrincipalId) — deliberately not barrel ports (mirrors the notion bundle): they are three-line
255
+ // structural reads over the public BindableContext shape; the barrel exposes the CONTRACT
256
+ // (BindableContext / RawIdentity / IdentityMapper), not core's private conveniences.
257
+
258
+ type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
259
+
260
+ function githubAuthOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
261
+ return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
262
+ }
263
+
264
+ function githubAttr(a: AuthAttrs, keys: readonly string[]): string | undefined {
265
+ if (!a) return undefined;
266
+ for (const k of keys) {
267
+ const v = a[k];
268
+ if (typeof v === "string" && v.length > 0) return v;
269
+ if (Array.isArray(v) && typeof v[0] === "string" && v[0].length > 0) return v[0];
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ function githubAuthPrincipalId(ctx: BindableContext): string | undefined {
275
+ const pid = githubAuthOf(ctx)?.principalId;
276
+ return pid && pid.length > 0 ? pid : undefined;
277
+ }
278
+
279
+ /**
280
+ * The CONVERSATION key the stop registry keys a GitHub turn by (OPE-898) — `owner/repo#number`.
281
+ *
282
+ * ONE derivation, used by BOTH sides of the affordance: {@link bindGitHub} writes it onto the anchor
283
+ * row at turn start, and the stop-comment leg (channels/github-stop.ts) rebuilds it from the webhook
284
+ * that carries the directive. Two spellings of this key would make the whole arm silently miss.
285
+ *
286
+ * It is NOT a surface key and never becomes one — the GitHub surface grain is the REPO, and this
287
+ * value names one issue or PR inside it. Returns undefined on any missing part: a partial key would
288
+ * collide across repos.
289
+ */
290
+ export function githubConversationKey(owner: string | undefined, repo: string | undefined, number: number | undefined): string | undefined {
291
+ return owner && repo && Number.isFinite(number) ? `${owner}/${repo}#${number}` : undefined;
292
+ }
293
+
294
+ /**
295
+ * The ROOT comment id of the review thread this turn arrived in, or `undefined` (OPE-958). The read
296
+ * side of {@link GitHubIdentityInput.reviewThreadRootCommentId} — this file owns the attribute
297
+ * spelling on BOTH sides, so a rename cannot leave the reader looking for a key nobody writes.
298
+ *
299
+ * It reads the AUTH ATTRIBUTES rather than the composed binding on purpose. `RawIdentity.target` is
300
+ * eve's `GitHubReceiveTarget` and throws on a member it does not know, so the root cannot ride
301
+ * there, and no other binding field names a comment.
302
+ *
303
+ * Returns the id as a STRING of digits, which is what `ReadAdapter.readThread` takes as its thread
304
+ * id. A non-numeric value is treated as absent: a review thread id that is not a comment id would
305
+ * fetch nothing, and refusing honestly beats reading an empty thread.
306
+ */
307
+ export function githubReviewThreadRoot(ctx: BindableContext): string | undefined {
308
+ const raw = githubAttr(githubAuthOf(ctx)?.attributes, ["review_thread_root_comment_id", "reviewThreadRootCommentId"]);
309
+ return raw !== undefined && /^\d+$/u.test(raw) ? raw : undefined;
310
+ }
311
+
312
+ export const bindGitHub: IdentityMapper = (ctx): RawIdentity => {
313
+ const a = githubAuthOf(ctx)?.attributes;
314
+ const owner = githubAttr(a, ["owner"]);
315
+ const repo = githubAttr(a, ["repo"]);
316
+ const repoPrivate = githubAttr(a, ["repo_private", "repoPrivate"]);
317
+ const conversationKind = githubAttr(a, ["conversation_kind", "conversationKind"]);
318
+ const issueNumber = githubAttr(a, ["issue_number", "issueNumber"]);
319
+ const pullRequestNumber = githubAttr(a, ["pull_request_number", "pullRequestNumber"]);
320
+ const installationId = githubAttr(a, ["installation_id", "installationId"]);
321
+ // Proactive post-back uses eve's GitHubReceiveTarget: { owner, repo, and EXACTLY ONE of
322
+ // issueNumber | pullRequestNumber } (supplying both throws). Pick the PR number for a
323
+ // pull_request/review_thread conversation, else the issue number. Only emitted when we
324
+ // have owner + repo + a usable anchor number.
325
+ const isPr = conversationKind === "pull_request" || conversationKind === "review_thread";
326
+ const anchor =
327
+ isPr && pullRequestNumber
328
+ ? { pullRequestNumber: Number(pullRequestNumber) }
329
+ : issueNumber
330
+ ? { issueNumber: Number(issueNumber) }
331
+ : pullRequestNumber
332
+ ? { pullRequestNumber: Number(pullRequestNumber) }
333
+ : undefined;
334
+ return {
335
+ workspace: owner,
336
+ surface: repo,
337
+ mode: "channel", // GitHub has no DM analog — every issue/PR/review thread is shared
338
+ visibility: githubVisibility(repoPrivate),
339
+ trust: githubTrust(repoPrivate, githubAttr(a, ["author_association", "authorAssociation"])),
340
+ principalId: githubAuthPrincipalId(ctx) ?? githubAttr(a, ["user_login", "userLogin", "user_id", "userId"]),
341
+ // OPE-811: `installationId` rides the post-back target so the mid-turn `reply` arm mints its
342
+ // token against THIS wake's own installation instead of falling back to the owner→installation
343
+ // map. Absent when the webhook carried none — the arm's map lookup is the fallback, never a guess.
344
+ target: owner && repo && anchor ? { owner, repo, ...anchor, ...(installationId ? { installationId: Number(installationId) } : {}) } : undefined,
345
+ // THE STOP REGISTRY'S ADDRESS FOR THIS TURN (OPE-898). A GitHub conversation is ONE issue or PR
346
+ // thread, so that is what a stop directive posted into the thread can name. It cannot ride
347
+ // `target`: that object is eve's `GitHubReceiveTarget` and it throws on an unexpected member.
348
+ //
349
+ // GitHub sends NO webhook for a reaction (verified against the event list, 2026-08-20), so the
350
+ // trigger on this surface is a bare `stop` comment read at the ingress — see
351
+ // channels/github-stop.ts. The key it resolves by is this one.
352
+ ...(anchor
353
+ ? {
354
+ anchorChannelId: githubConversationKey(
355
+ owner,
356
+ repo,
357
+ "pullRequestNumber" in anchor ? anchor.pullRequestNumber : anchor.issueNumber,
358
+ ),
359
+ // THE ENGAGEMENT REGISTRY'S ADDRESS FOR THIS TURN (OPE-1501) — the SAME container key, and
360
+ // deliberately the same derivation rather than a second spelling of it. `stand_down` marks
361
+ // `binding.engagementKey ?? binding.threadTs`, and GitHub carries no `threadTs`, so
362
+ // without this field the verb found no anchor and refused on every GitHub turn.
363
+ //
364
+ // It is a SEPARATE field from `anchorChannelId` because the two answer different
365
+ // questions — which running turn a stop directive cancels, and which conversation row the
366
+ // agent was asked to stop following — and `channels/github-engagement.ts` reads this one
367
+ // back through `githubEngagementAnchor`, which calls the very same function.
368
+ engagementKey: githubConversationKey(
369
+ owner,
370
+ repo,
371
+ "pullRequestNumber" in anchor ? anchor.pullRequestNumber : anchor.issueNumber,
372
+ ),
373
+ }
374
+ : {}),
375
+ };
376
+ };
@@ -0,0 +1,152 @@
1
+ // The GitHub App's INSTALLATION leg — the eve-free, tested half of "an org installing the App
2
+ // registers itself" (OPE-118 GitHub §3; github.ts is the thin wiring, mirroring github-supervisor.ts).
3
+ //
4
+ // GitHub's App-level webhook (ONE url + ONE secret, fanning in every installation of the App across
5
+ // every account) delivers:
6
+ // • `installation` — actions `created`, `deleted`, `suspend`, `unsuspend`, `new_permissions_accepted`
7
+ // • `installation_repositories` — actions `added`, `removed` (the repo-scope change)
8
+ // Verified against docs.github.com/en/webhooks/webhook-events-and-payloads (2026-07). We map those
9
+ // onto exactly two effects, LEARN and REVOKE:
10
+ // LEARN ← created / unsuspend / new_permissions_accepted / added / removed
11
+ // REVOKE ← deleted / suspend
12
+ // `suspend` revokes because a suspended installation's tokens are refused by GitHub — keeping the
13
+ // row would advertise an installation that cannot mint, and `unsuspend` re-learns it. The two
14
+ // metadata-only actions (`new_permissions_accepted`, and both `installation_repositories` actions)
15
+ // re-LEARN rather than no-op so `repository_selection` on the row can't drift from reality.
16
+ //
17
+ // SIGNATURE: this module never verifies anything — it runs INSIDE the route, AFTER the one
18
+ // existing `gateGithubDelivery` HMAC gate (GITHUB_WEBHOOK_SECRET) that already fail-closed 401s
19
+ // every unverified delivery for the whole route. There is deliberately no second verification path.
20
+ //
21
+ // AUDIT: install/uninstall is a surface-lifecycle fact, so it takes the cross-platform `surface.*`
22
+ // action namespace (the same one slack-front/teams-front's wake_failed/wake_dropped rows use),
23
+ // not a github-only verb — an operator asking "who installed us, and when?" wants one query.
24
+
25
+ /** One installation delivery, normalized. `effect` is the whole decision; everything else is the
26
+ * row/audit payload. */
27
+ export interface GitHubInstallationEvent {
28
+ readonly effect: "learn" | "revoke";
29
+ /** The webhook's `action` — carried verbatim into the audit row (why the effect happened). */
30
+ readonly action: string;
31
+ /** `installation.account.login` — the SCM map's key. */
32
+ readonly owner: string;
33
+ readonly installationId: string;
34
+ /** `installation.account.type` — "Organization" | "User". */
35
+ readonly accountType?: string;
36
+ /** `installation.repository_selection` — "all" | "selected". */
37
+ readonly repositorySelection?: string;
38
+ /** How many repos the delivery named, when it named any (diagnostics in the audit row only —
39
+ * the repo LIST is never stored; the App's own grant is the source of truth). */
40
+ readonly repositoryCount?: number;
41
+ }
42
+
43
+ const LEARN_ACTIONS = new Set(["created", "unsuspend", "new_permissions_accepted", "added", "removed"]);
44
+ const REVOKE_ACTIONS = new Set(["deleted", "suspend"]);
45
+
46
+ function str(v: unknown): string | undefined {
47
+ return typeof v === "string" && v.length > 0 ? v : undefined;
48
+ }
49
+
50
+ /** GitHub sends `installation.id` as a NUMBER; the mint API wants a path segment. Accept both
51
+ * spellings and reject anything else (never coerce an object/array into "[object Object]"). */
52
+ function idOf(v: unknown): string | undefined {
53
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
54
+ return str(v);
55
+ }
56
+
57
+ function countOf(v: unknown): number | undefined {
58
+ return Array.isArray(v) ? v.length : undefined;
59
+ }
60
+
61
+ /**
62
+ * Normalize one webhook delivery into a {@link GitHubInstallationEvent}, or null when it isn't an
63
+ * installation delivery we act on. PURE — no I/O, so every branch is unit-tested.
64
+ *
65
+ * FAIL-CLOSED on an unusable payload: an unknown action, a missing `installation.id`, or a missing
66
+ * `installation.account.login` all return null (nothing learned, nothing revoked) rather than
67
+ * writing a half-formed row. An installation with no resolvable owner is unroutable anyway.
68
+ */
69
+ export function extractGitHubInstallationEvent(eventType: string, payload: Record<string, unknown>): GitHubInstallationEvent | null {
70
+ if (eventType !== "installation" && eventType !== "installation_repositories") return null;
71
+ const action = str(payload["action"]);
72
+ if (!action) return null;
73
+ const effect = LEARN_ACTIONS.has(action) ? "learn" : REVOKE_ACTIONS.has(action) ? "revoke" : undefined;
74
+ if (!effect) return null;
75
+
76
+ const installation = payload["installation"];
77
+ if (typeof installation !== "object" || installation === null) return null;
78
+ const inst = installation as Record<string, unknown>;
79
+ const installationId = idOf(inst["id"]);
80
+ if (!installationId) return null;
81
+
82
+ const account = typeof inst["account"] === "object" && inst["account"] !== null ? (inst["account"] as Record<string, unknown>) : undefined;
83
+ const owner = str(account?.["login"]);
84
+ if (!owner) return null;
85
+
86
+ // `repository_selection` sits on the installation for `installation`, and is re-sent at the TOP
87
+ // level of an `installation_repositories` delivery (that event's whole point is the change) —
88
+ // prefer the top-level value so a scope change is reflected, not the pre-change installation copy.
89
+ const repositorySelection = str(payload["repository_selection"]) ?? str(inst["repository_selection"]);
90
+ const repositoryCount =
91
+ countOf(payload["repositories"]) ?? countOf(payload["repositories_added"]) ?? countOf(payload["repositories_removed"]);
92
+
93
+ return {
94
+ effect,
95
+ action,
96
+ owner,
97
+ installationId,
98
+ ...(str(account?.["type"]) ? { accountType: str(account?.["type"])! } : {}),
99
+ ...(repositorySelection ? { repositorySelection } : {}),
100
+ ...(repositoryCount !== undefined ? { repositoryCount } : {}),
101
+ };
102
+ }
103
+
104
+ /** The side-effecting seams, injected so the dispatch core is testable without a store or a clock. */
105
+ export interface GitHubInstallationDeps {
106
+ readonly record: (rec: {
107
+ owner: string;
108
+ installationId: string;
109
+ accountType?: string;
110
+ repositorySelection?: string;
111
+ updatedAt: string;
112
+ }) => Promise<void>;
113
+ readonly forget: (owner: string) => Promise<boolean>;
114
+ readonly audit: (action: string, detail: Record<string, unknown>, workspace: string) => Promise<void>;
115
+ readonly now?: () => string;
116
+ readonly log?: (msg: string) => void;
117
+ }
118
+
119
+ /**
120
+ * Apply one normalized delivery: persist-or-clear the owner→installation entry, then audit it.
121
+ *
122
+ * ORDER IS DELIBERATE — the durable write happens FIRST and its failure propagates (the route's
123
+ * catch logs it), so we never audit an install we didn't actually record. The audit row is written
124
+ * after, best-effort at the appendAudit layer.
125
+ */
126
+ export async function handleGitHubInstallationDelivery(ev: GitHubInstallationEvent, deps: GitHubInstallationDeps): Promise<void> {
127
+ const at = deps.now ? deps.now() : new Date().toISOString();
128
+ const detail: Record<string, unknown> = {
129
+ action: ev.action,
130
+ installationId: ev.installationId,
131
+ ...(ev.accountType ? { accountType: ev.accountType } : {}),
132
+ ...(ev.repositorySelection ? { repositorySelection: ev.repositorySelection } : {}),
133
+ ...(ev.repositoryCount !== undefined ? { repositoryCount: ev.repositoryCount } : {}),
134
+ };
135
+
136
+ if (ev.effect === "revoke") {
137
+ const removed = await deps.forget(ev.owner);
138
+ deps.log?.(`installation ${ev.action} for ${ev.owner} — ${removed ? "discovered entry cleared" : "no discovered entry to clear"}`);
139
+ await deps.audit("surface.app_uninstalled", { ...detail, removed }, ev.owner);
140
+ return;
141
+ }
142
+
143
+ await deps.record({
144
+ owner: ev.owner,
145
+ installationId: ev.installationId,
146
+ ...(ev.accountType ? { accountType: ev.accountType } : {}),
147
+ ...(ev.repositorySelection ? { repositorySelection: ev.repositorySelection } : {}),
148
+ updatedAt: at,
149
+ });
150
+ deps.log?.(`installation ${ev.action} for ${ev.owner} → installation ${ev.installationId} registered (no env edit needed)`);
151
+ await deps.audit("surface.app_installed", detail, ev.owner);
152
+ }
@@ -0,0 +1,62 @@
1
+ // github-message-ops — GitHub's SurfaceMessageOps (OPE-899): the ancillary message ops core
2
+ // features dispatch through channels/surface-message-ops.ts. Before this file, GitHub registered
3
+ // none, so the stop acknowledgement (turn-anchor.ts's `postSurfaceText`) resolved nothing on a
4
+ // GitHub turn — the stop control worked (github-stop.ts, the bare `stop` comment), it was just
5
+ // invisible: a person who pressed it saw the agent go quiet and read nothing.
6
+ //
7
+ // - postText → the SAME issue/PR-timeline comment POST the reply spine uses
8
+ // (github-post-arm.ts's `liveGitHubPost`, exported for this file — one mint/fetch
9
+ // implementation, never a second copy of it).
10
+ // - editMessage → the SAME comment PATCH the `edit_reply` verb uses (`liveGitHubEditComment`).
11
+ // - NO `postFiles` (OPE-899's registry ruling): GitHub has no API for attaching a file to a
12
+ // comment — web upload only. The optional tier exists exactly for this shape.
13
+ // - NO `setReaction`: GitHub's reaction set is the fixed eight (+1, -1, laugh, confused, heart,
14
+ // hooray, rocket, eyes) — there is no raised hand. The bare `stop` comment (github-stop.ts) IS
15
+ // GitHub's stop affordance; there is no ✋ glyph to place beside it, so this module offers no
16
+ // `setReaction` rather than faking one against the wrong emoji.
17
+ //
18
+ // `channelId` IS THE CONVERSATION KEY `owner/repo#number`, not a GitHub-specific pair invented
19
+ // here. `turn-anchor.ts`'s `turnAnchorAddress` reads it off `binding.anchorChannelId`, which
20
+ // `bindGitHub` (github-identity.ts) mints through `githubConversationKey` — the SAME key
21
+ // github-read.ts's `parseGithubContainer` already parses for the read adapter's container id. This
22
+ // file reuses that parser rather than writing a second one: two derivations of the same address
23
+ // would be exactly the "silently miss" trap github-identity.ts's own header warns about.
24
+ //
25
+ // `messageId` on `editMessage` is the COMMENT id `postText`/`liveGitHubPost` returned — GitHub's
26
+ // comment ids are unique across the whole repo, so `editMessage` needs only the owner/repo half of
27
+ // the parsed container, never the issue number.
28
+ //
29
+ // NO THREADING ON `postText`. GitHub's issue/PR-timeline comment stream is flat — there is no
30
+ // "reply to this specific comment" primitive on the endpoint `liveGitHubPost` posts through (that
31
+ // exists only for PR review comments, a different family this registry does not address) — so
32
+ // `opts?.replyToMessageId` is accepted (the interface's contract) and ignored, exactly like a
33
+ // top-level Slack post with no thread.
34
+
35
+ import type { SurfaceMessageOps } from "@open-everywhere/core/agent/adapter-api.js";
36
+ import { liveGitHubEditComment, liveGitHubPost } from "./github-post-arm.js";
37
+ import { parseGithubContainer } from "./github-read.js";
38
+
39
+ /** Parse the `owner/repo#number` container id, or throw the same honest, naming refusal every
40
+ * other surface-message-ops call gives on a malformed address — never a guessed target. */
41
+ function requireContainer(channelId: string): { readonly owner: string; readonly repo: string; readonly repoFull: string; readonly number: number } {
42
+ const target = parseGithubContainer(channelId);
43
+ if (!target) throw new Error(`GitHub surface message ops: "${channelId}" is not a conversation id (expected "owner/repo#number").`);
44
+ return target;
45
+ }
46
+
47
+ export const githubMessageOps: SurfaceMessageOps = {
48
+ platform: "github",
49
+
50
+ /** Post to the issue/PR-timeline comment stream. `opts?.replyToMessageId` is accepted and
51
+ * ignored — see the header's "NO THREADING" note. */
52
+ async postText(channelId, text) {
53
+ const { owner, repo, number } = requireContainer(channelId);
54
+ return liveGitHubPost({ platform: "github", owner, repo, issueNumber: number }, text);
55
+ },
56
+
57
+ /** PATCH the comment's body in place — the same edit `edit_reply` performs on the reply spine. */
58
+ async editMessage(channelId, messageId, text) {
59
+ const { repoFull } = requireContainer(channelId);
60
+ await liveGitHubEditComment(repoFull, messageId, text);
61
+ },
62
+ };
@@ -0,0 +1,42 @@
1
+ // The GitHub UnifiedWakeOrigin codec — MOVED VERBATIM out of core's
2
+ // channels/wake-origin-codecs.ts (OPE-114 stage 4). STORED-DATA COMPATIBILITY IS THE CONTRACT:
3
+ // a GitHub origin is JSON-serialised into the durable operational store and fail-closed
4
+ // re-validated on read, so every behaviour here is frozen by core's
5
+ // scripts/wake-origin-fixtures-test.ts (run from the app root with this bundle's boot wiring —
6
+ // scripts/wake-origin-fixtures-boot.ts). Validator tolerances (typeof-only optional checks, extra
7
+ // unknown fields) are LOAD-BEARING for rows already on disk — never tighten them here.
8
+ //
9
+ // The `UnifiedWakeOrigin` TYPE union (including its github arm) stays in core — it is the wire
10
+ // schema for stored rows; only the codec BEHAVIOUR lives in the bundle, registered by
11
+ // registerGithubProvider() (../register.ts).
12
+
13
+ import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
14
+
15
+ type GitHubOrigin = Extract<UnifiedWakeOrigin, { platform: "github" }>;
16
+
17
+ /** COLON-FREE by construction (a task windowId is `[^:]+` — core channels/window-key.ts): any colon
18
+ * in a raw surface id is folded to `_`, deterministically, so spawn + ingress agree on identity. */
19
+ const noColon = (s: string): string => s.replace(/:/g, "_");
20
+
21
+ export const githubWakeOriginCodec: WakeOriginCodec<GitHubOrigin> = {
22
+ validate: (o) =>
23
+ typeof o.owner === "string" &&
24
+ o.owner.length > 0 &&
25
+ typeof o.repo === "string" &&
26
+ o.repo.length > 0 &&
27
+ typeof o.issueNumber === "number" &&
28
+ Number.isFinite(o.issueNumber) &&
29
+ o.issueNumber > 0 &&
30
+ (o.installationId === undefined || typeof o.installationId === "number"),
31
+ threadRef: (origin) => ({ channelKey: noColon(`github/${origin.owner}/${origin.repo}`), threadKey: String(origin.issueNumber) }),
32
+ // The container and the thread coincide (an issue IS its own thread) ⇒ always thread-scoped.
33
+ isThreadScoped: () => true,
34
+ containerId: (origin) => `${origin.owner}/${origin.repo}`,
35
+ // NO `placementRef` AND NO `carryPlacement`, AND THAT ABSENCE IS THE DECLARATION (OPE-1826). A
36
+ // GitHub reply is a comment appended to the issue timeline, and the issue IS the thread key above —
37
+ // there is no "where inside it" left to name, so two origins that pass `sameUnifiedConversation`
38
+ // cannot disagree about placement. `installationId` is not a counter-example: it selects the
39
+ // credential the post mints under, never where the post lands. The position is recorded, and held
40
+ // to account against the live registry, by the `origin-placement-carry` row in core's
41
+ // identity/surface-parity.ts.
42
+ };