@volter/twin-github 0.1.2 → 2.0.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 (215) hide show
  1. package/README.md +88 -36
  2. package/client/github-mirror.css +277 -319
  3. package/client/github-mirror.d.ts +418 -0
  4. package/client/github-mirror.js +485 -0
  5. package/client/github-mirror.tsx +153 -357
  6. package/client/pulls-rest.ts +159 -0
  7. package/client/pulls-workspace.tsx +841 -0
  8. package/dist/client/github-mirror.bundle.js +239 -0
  9. package/dist/client/github-mirror.css +916 -0
  10. package/dist/client/github-mirror.d.ts +418 -0
  11. package/dist/client/github-mirror.js +485 -0
  12. package/dist/client/github-mirror.tsx +1315 -0
  13. package/dist/client/pulls-rest.d.ts +42 -0
  14. package/dist/client/pulls-rest.js +140 -0
  15. package/dist/client/pulls-rest.ts +159 -0
  16. package/dist/client/pulls-workspace.bundle.js +22 -0
  17. package/dist/client/pulls-workspace.d.ts +114 -0
  18. package/dist/client/pulls-workspace.js +418 -0
  19. package/dist/client/pulls-workspace.tsx +841 -0
  20. package/dist/src/cli.d.ts +2 -0
  21. package/dist/src/cli.js +40 -0
  22. package/dist/src/generated/graphql-sdl.gen.json +1 -0
  23. package/dist/src/generated/graphql.gen.json +1 -0
  24. package/dist/src/generated/surface.gen.json +1 -0
  25. package/dist/src/generated/ui.gen.json +1 -0
  26. package/dist/src/github-budget.d.ts +69 -0
  27. package/dist/src/github-budget.js +172 -0
  28. package/dist/src/github-capabilities.d.ts +5 -0
  29. package/dist/src/github-capabilities.js +4468 -0
  30. package/dist/src/github-conformance.d.ts +43 -0
  31. package/dist/src/github-conformance.js +76 -0
  32. package/dist/src/github-connector.d.ts +307 -0
  33. package/dist/src/github-connector.js +1398 -0
  34. package/dist/src/github-events.d.ts +41 -0
  35. package/dist/src/github-events.js +230 -0
  36. package/dist/src/github-git-http.d.ts +49 -0
  37. package/dist/src/github-git-http.js +185 -0
  38. package/dist/src/github-git-plane.d.ts +114 -0
  39. package/dist/src/github-git-plane.js +407 -0
  40. package/dist/src/github-mirror-state.d.ts +2 -0
  41. package/dist/src/github-mirror-state.js +335 -0
  42. package/dist/src/github-mirror-ui.d.ts +20 -0
  43. package/dist/src/github-mirror-ui.js +101 -0
  44. package/dist/src/github-server.d.ts +14 -0
  45. package/dist/src/github-server.js +240 -0
  46. package/dist/src/github-shared.d.ts +12 -0
  47. package/dist/src/github-shared.js +21 -0
  48. package/dist/src/github-twin.d.ts +1411 -0
  49. package/dist/src/github-twin.js +4084 -0
  50. package/dist/src/github-ui-conformance.d.ts +4 -0
  51. package/dist/src/github-ui-conformance.js +105 -0
  52. package/dist/src/github-ui-structure.d.ts +18 -0
  53. package/dist/src/github-ui-structure.js +251 -0
  54. package/dist/src/graphql-wire.d.ts +22 -0
  55. package/dist/src/graphql-wire.js +89 -0
  56. package/dist/src/index.d.ts +17 -0
  57. package/dist/src/index.js +101 -0
  58. package/dist/src/manifest.d.ts +8 -0
  59. package/dist/src/manifest.js +597 -0
  60. package/dist/src/npm-registry.d.ts +7 -0
  61. package/dist/src/npm-registry.js +47 -0
  62. package/dist/src/screens/app-installation.d.ts +3 -0
  63. package/dist/src/screens/app-installation.js +166 -0
  64. package/dist/src/screens/app-manifest.d.ts +3 -0
  65. package/dist/src/screens/app-manifest.js +81 -0
  66. package/dist/src/screens/oauth.d.ts +15 -0
  67. package/dist/src/screens/oauth.js +257 -0
  68. package/dist/src/screens/session.d.ts +8 -0
  69. package/dist/src/screens/session.js +159 -0
  70. package/dist/src/semantics/actions.d.ts +2 -0
  71. package/dist/src/semantics/actions.js +413 -0
  72. package/dist/src/semantics/activity.d.ts +4 -0
  73. package/dist/src/semantics/activity.js +161 -0
  74. package/dist/src/semantics/apps.d.ts +2 -0
  75. package/dist/src/semantics/apps.js +144 -0
  76. package/dist/src/semantics/branches.d.ts +2 -0
  77. package/dist/src/semantics/branches.js +136 -0
  78. package/dist/src/semantics/checks.d.ts +2 -0
  79. package/dist/src/semantics/checks.js +176 -0
  80. package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
  81. package/dist/src/semantics/code-scanning-upload.js +97 -0
  82. package/dist/src/semantics/codespaces.d.ts +2 -0
  83. package/dist/src/semantics/codespaces.js +58 -0
  84. package/dist/src/semantics/commits.d.ts +2 -0
  85. package/dist/src/semantics/commits.js +109 -0
  86. package/dist/src/semantics/contents.d.ts +2 -0
  87. package/dist/src/semantics/contents.js +131 -0
  88. package/dist/src/semantics/deployments.d.ts +2 -0
  89. package/dist/src/semantics/deployments.js +127 -0
  90. package/dist/src/semantics/gists.d.ts +2 -0
  91. package/dist/src/semantics/gists.js +86 -0
  92. package/dist/src/semantics/git.d.ts +2 -0
  93. package/dist/src/semantics/git.js +235 -0
  94. package/dist/src/semantics/graphql.d.ts +7 -0
  95. package/dist/src/semantics/graphql.js +512 -0
  96. package/dist/src/semantics/index.d.ts +5 -0
  97. package/dist/src/semantics/index.js +62 -0
  98. package/dist/src/semantics/issues.d.ts +2 -0
  99. package/dist/src/semantics/issues.js +456 -0
  100. package/dist/src/semantics/keys.d.ts +2 -0
  101. package/dist/src/semantics/keys.js +66 -0
  102. package/dist/src/semantics/labels.d.ts +2 -0
  103. package/dist/src/semantics/labels.js +100 -0
  104. package/dist/src/semantics/meta.d.ts +12 -0
  105. package/dist/src/semantics/meta.js +144 -0
  106. package/dist/src/semantics/notifications.d.ts +4 -0
  107. package/dist/src/semantics/notifications.js +62 -0
  108. package/dist/src/semantics/orgs.d.ts +2 -0
  109. package/dist/src/semantics/orgs.js +420 -0
  110. package/dist/src/semantics/packages.d.ts +2 -0
  111. package/dist/src/semantics/packages.js +55 -0
  112. package/dist/src/semantics/pages.d.ts +2 -0
  113. package/dist/src/semantics/pages.js +176 -0
  114. package/dist/src/semantics/projects.d.ts +2 -0
  115. package/dist/src/semantics/projects.js +239 -0
  116. package/dist/src/semantics/pulls.d.ts +2 -0
  117. package/dist/src/semantics/pulls.js +462 -0
  118. package/dist/src/semantics/push-reactions.d.ts +101 -0
  119. package/dist/src/semantics/push-reactions.js +513 -0
  120. package/dist/src/semantics/releases.d.ts +19 -0
  121. package/dist/src/semantics/releases.js +231 -0
  122. package/dist/src/semantics/repo-invitations.d.ts +2 -0
  123. package/dist/src/semantics/repo-invitations.js +98 -0
  124. package/dist/src/semantics/repos.d.ts +17 -0
  125. package/dist/src/semantics/repos.js +390 -0
  126. package/dist/src/semantics/rulesets.d.ts +2 -0
  127. package/dist/src/semantics/rulesets.js +111 -0
  128. package/dist/src/semantics/search.d.ts +2 -0
  129. package/dist/src/semantics/search.js +84 -0
  130. package/dist/src/semantics/security.d.ts +2 -0
  131. package/dist/src/semantics/security.js +177 -0
  132. package/dist/src/semantics/shared.d.ts +61 -0
  133. package/dist/src/semantics/shared.js +151 -0
  134. package/dist/src/semantics/users.d.ts +2 -0
  135. package/dist/src/semantics/users.js +255 -0
  136. package/dist/src/semantics/webhooks.d.ts +2 -0
  137. package/dist/src/semantics/webhooks.js +107 -0
  138. package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
  139. package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
  140. package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
  141. package/dist/test-fixtures/github-comment-schema.json +702 -0
  142. package/dist/test-fixtures/github-known-deviations.json +94 -0
  143. package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
  144. package/dist/test-fixtures/github-openapi-operations.json +362 -0
  145. package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
  146. package/dist/test-fixtures/github-pull-schema.json +3601 -0
  147. package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
  148. package/dist/test-fixtures/github-review-schema.json +236 -0
  149. package/package.json +20 -11
  150. package/src/cli.ts +7 -6
  151. package/src/generated/graphql-sdl.gen.json +1 -0
  152. package/src/generated/graphql.gen.json +1 -0
  153. package/src/generated/surface.gen.json +1 -0
  154. package/src/generated/ui.gen.json +1 -0
  155. package/src/github-a11y-snapshot.uitest.ts +5 -5
  156. package/src/github-budget.ts +4 -4
  157. package/src/github-capabilities.ts +1993 -556
  158. package/src/github-conformance.ts +12 -7
  159. package/src/github-connector.ts +108 -96
  160. package/src/github-events.ts +223 -95
  161. package/src/github-git-http.ts +47 -83
  162. package/src/github-git-plane.ts +247 -385
  163. package/src/github-journey.uitest.ts +28 -44
  164. package/src/github-mirror-state.ts +23 -61
  165. package/src/github-mirror-ui.ts +16 -10
  166. package/src/github-server.ts +158 -60
  167. package/src/github-shared.ts +1 -1
  168. package/src/github-twin.ts +1525 -4494
  169. package/src/github-ui-conformance.ts +7 -8
  170. package/src/github-ui-structure.ts +19 -18
  171. package/src/graphql-wire.ts +115 -0
  172. package/src/index.ts +47 -7
  173. package/src/manifest.ts +605 -0
  174. package/src/npm-registry.ts +43 -0
  175. package/src/screens/app-installation.tsx +217 -0
  176. package/src/screens/app-manifest.tsx +97 -0
  177. package/src/screens/oauth.tsx +258 -0
  178. package/src/screens/session.tsx +186 -0
  179. package/src/semantics/actions.ts +395 -0
  180. package/src/semantics/activity.ts +171 -0
  181. package/src/semantics/apps.ts +133 -0
  182. package/src/semantics/branches.ts +133 -0
  183. package/src/semantics/checks.ts +163 -0
  184. package/src/semantics/code-scanning-upload.ts +92 -0
  185. package/src/semantics/codespaces.ts +58 -0
  186. package/src/semantics/commits.ts +109 -0
  187. package/src/semantics/contents.ts +112 -0
  188. package/src/semantics/deployments.ts +116 -0
  189. package/src/semantics/gists.ts +85 -0
  190. package/src/semantics/git.ts +226 -0
  191. package/src/semantics/graphql.ts +505 -0
  192. package/src/semantics/index.ts +68 -0
  193. package/src/semantics/issues.ts +434 -0
  194. package/src/semantics/keys.ts +66 -0
  195. package/src/semantics/labels.ts +91 -0
  196. package/src/semantics/meta.ts +139 -0
  197. package/src/semantics/notifications.ts +58 -0
  198. package/src/semantics/orgs.ts +383 -0
  199. package/src/semantics/packages.ts +59 -0
  200. package/src/semantics/pages.ts +154 -0
  201. package/src/semantics/projects.ts +246 -0
  202. package/src/semantics/pulls.ts +421 -0
  203. package/src/semantics/push-reactions.ts +471 -0
  204. package/src/semantics/releases.ts +213 -0
  205. package/src/semantics/repo-invitations.ts +112 -0
  206. package/src/semantics/repos.ts +373 -0
  207. package/src/semantics/rulesets.ts +107 -0
  208. package/src/semantics/search.ts +83 -0
  209. package/src/semantics/security.ts +153 -0
  210. package/src/semantics/shared.ts +181 -0
  211. package/src/semantics/users.ts +252 -0
  212. package/src/semantics/webhooks.ts +101 -0
  213. package/test-fixtures/github-known-deviations.json +7 -8
  214. package/test-fixtures/github-openapi-operations.json +46 -227
  215. package/src/github-graphql.ts +0 -398
@@ -8,7 +8,8 @@
8
8
  // NOT declared fails CI. The companion coverage report says what we emit vs what the
9
9
  // vendor declares. Standing gate: wrong types, undeclared omissions, fabricated
10
10
  // surface, or enum violations all fail.
11
- import { specConformance } from '@volter/twin-tooling';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { specConformance } from '@volter/world-tooling';
12
13
  import { githubState, toGithubComment, toGithubRest, toGithubReview } from './github-twin.ts';
13
14
 
14
15
  type JsonSchema = specConformance.JsonSchema;
@@ -29,22 +30,22 @@ const fixturePath = (name: string): string => new URL(`../test-fixtures/${name}`
29
30
 
30
31
  /** Load the vendored full GitHub pull-request JSON Schema (dereferenced OpenAPI). */
31
32
  export async function loadGithubPrSchema(): Promise<JsonSchema> {
32
- return JSON.parse(await Bun.file(fixturePath('github-pull-schema.json')).text()) as JsonSchema;
33
+ return JSON.parse(await readFile(fixturePath('github-pull-schema.json'), 'utf8')) as JsonSchema;
33
34
  }
34
35
 
35
36
  /** Load the vendored GitHub pull-request-review JSON Schema. */
36
37
  export async function loadGithubReviewSchema(): Promise<JsonSchema> {
37
- return JSON.parse(await Bun.file(fixturePath('github-review-schema.json')).text()) as JsonSchema;
38
+ return JSON.parse(await readFile(fixturePath('github-review-schema.json'), 'utf8')) as JsonSchema;
38
39
  }
39
40
 
40
41
  /** Load the vendored GitHub issue-comment JSON Schema. */
41
42
  export async function loadGithubCommentSchema(): Promise<JsonSchema> {
42
- return JSON.parse(await Bun.file(fixturePath('github-comment-schema.json')).text()) as JsonSchema;
43
+ return JSON.parse(await readFile(fixturePath('github-comment-schema.json'), 'utf8')) as JsonSchema;
43
44
  }
44
45
 
45
46
  /** Load the declared evidence-twin scope (fields deliberately not modeled, + reasons). */
46
47
  export async function loadGithubKnownDeviations(): Promise<KnownDeviation[]> {
47
- const doc = JSON.parse(await Bun.file(fixturePath('github-known-deviations.json')).text()) as { deviations: KnownDeviation[] };
48
+ const doc = JSON.parse(await readFile(fixturePath('github-known-deviations.json'), 'utf8')) as { deviations: KnownDeviation[] };
48
49
  return doc.deviations;
49
50
  }
50
51
 
@@ -58,14 +59,18 @@ export function checkGithubConformance(schema: JsonSchema, opts: { root?: string
58
59
  const violations: GithubViolation[] = [];
59
60
  let fieldsChecked = 0;
60
61
  let knownIgnored = 0;
61
- for (const pr of state.prs) {
62
+ // "Every PR the twin SERVES" — which is the rows marked as pull-request evidence, not the
63
+ // counter shells `ensure()` materializes for issue numbers. Checking a shell measures an
64
+ // object no door answers with, and its emptiness would read as the twin's conformance.
65
+ const served = state.prs.filter((p) => p.is_pull_request);
66
+ for (const pr of served) {
62
67
  const rest = toGithubRest(pr);
63
68
  const rep = specConformance.checkSpecConformance(rest, schema, { exemptKey: isTwinExtra, known: opts.known ?? [] });
64
69
  fieldsChecked += rep.fieldsChecked;
65
70
  knownIgnored += rep.knownIgnored;
66
71
  for (const v of rep.violations) violations.push({ ...v, prId: pr.id });
67
72
  }
68
- return { ok: violations.length === 0, prsChecked: state.prs.length, fieldsChecked, violations, knownIgnored };
73
+ return { ok: violations.length === 0, prsChecked: served.length, fieldsChecked, violations, knownIgnored };
69
74
  }
70
75
 
71
76
  export type GithubCoverageReport = specConformance.SpecCoverageReport;
@@ -18,16 +18,11 @@
18
18
  // text it didn't receive; the one excluded thing is the Non-goal — actual repository
19
19
  // file CONTENTS (git blob bytes) — which the pull does not fetch. Content-bearing twin
20
20
  // state thus comes from BOTH the observed fold and LOCAL writes, which push reconciles.
21
- //
22
- // THE CONVERSATION IS A SET OF SUBJECTS (not a fold on the PR): every review and every issue
23
- // comment pulls as its OWN SyncResource, because the shadow diff emits one delta per SUBJECT.
24
- // Folded onto the PR as `reviewCount`/`latestReview`, two reviews between two polls collapsed
25
- // into one delta (the second overwrote the first), and nothing said WHO reviewed. A review
26
- // carries its inline (diff-anchored) comments, since GitHub wraps every inline comment in a
27
- // review — `pull_request_review_id` on each `/pulls/{n}/comments` row is the join.
28
- import { appendEvent, assertBudgetGuardIntact, buildShadowState, confirmAction, pendingActions, syncPull } from '@volter/twin';
29
- import type { RemoteExecute, SyncResource, WorldServiceEvent } from '@volter/twin';
30
- import { GithubBudget, githubCallWeight, type GithubBudgetOptions } from './github-budget.ts';
21
+ import { assertBudgetGuardIntact } from '@volter/world-core';
22
+ import { observeResource, toEntry } from '@volter/world-core';
23
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction, WorldServiceEvent } from '@volter/world-core';
24
+ import { twinResources, ownFields } from '@volter/world-core';
25
+ import { GithubBudget, GithubBudgetError, githubCallWeight, type GithubBudgetOptions } from './github-budget.ts';
31
26
  import { githubState } from './github-twin.ts';
32
27
 
33
28
  const SERVICE = 'github';
@@ -149,7 +144,14 @@ export function liveGithubExecute(
149
144
  // first either way, so the refusal survives the throw. GitHub answers a tripped SECONDARY
150
145
  // limit with 403 + `Retry-After` rather than 429, which is why the header is read on every
151
146
  // response and not only on a 429.
152
- budget.recordCall(weight, resHeaders, { status: res.status, reservation });
147
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
148
+ // call that louder refusal wins; an answer GitHub ACCEPTED is kept, so a write that landed is
149
+ // never recorded as failed and performed again on retry.
150
+ try {
151
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
152
+ } catch (error) {
153
+ if (!(error instanceof GithubBudgetError) || !res.ok) throw error;
154
+ }
153
155
  return { status: res.status, data };
154
156
  },
155
157
  };
@@ -226,7 +228,8 @@ type ObservedPr = {
226
228
  headSha?: string;
227
229
  changedFiles?: number;
228
230
  commitsCount?: number;
229
- authorLogin?: string;
231
+ authorLogin?: string | null;
232
+ authorType?: string;
230
233
  /** The PR's own `updated_at` — the provider instant that dates its delta AND the stamp
231
234
  * the next pull compares against to decide whether its conversation is worth buying. */
232
235
  updatedAt?: string;
@@ -279,6 +282,8 @@ type ObservedIssue = {
279
282
  title?: string;
280
283
  body?: string;
281
284
  state?: string;
285
+ created_at?: string;
286
+ updated_at?: string;
282
287
  };
283
288
 
284
289
  /**
@@ -476,7 +481,9 @@ export async function pullGithubPrs(
476
481
  });
477
482
  pr.commentCount = commentNodes.length;
478
483
  }
479
- if (n.user?.login !== undefined && n.user?.login !== null) pr.authorLogin = String(n.user.login);
484
+ if (n.user === null) pr.authorLogin = null;
485
+ else if (n.user?.login !== undefined && n.user?.login !== null) pr.authorLogin = String(n.user.login);
486
+ if (n.user?.type !== undefined && n.user?.type !== null) pr.authorType = String(n.user.type);
480
487
  // CONTENT: title/body/state are real text the REST PR object returns — fold them.
481
488
  if (n.title !== undefined && n.title !== null) pr.title = String(n.title);
482
489
  if (n.body !== undefined && n.body !== null) pr.body = String(n.body);
@@ -526,6 +533,9 @@ export async function pullGithubIssues(
526
533
  if (n.title !== undefined && n.title !== null) iss.title = String(n.title);
527
534
  if (n.body !== undefined && n.body !== null) iss.body = String(n.body);
528
535
  if (n.state !== undefined && n.state !== null) iss.state = String(n.state);
536
+ // the timestamps the write handler stores: shape parity between a written and an observed issue
537
+ if (typeof n.created_at === 'string') iss.created_at = n.created_at;
538
+ if (typeof n.updated_at === 'string') iss.updated_at = n.updated_at;
529
539
  out.push(iss);
530
540
  }
531
541
  return out;
@@ -559,7 +569,8 @@ export function observedPrsToResources(prs: ObservedPr[]): SyncResource[] {
559
569
  // WHO opened it, and when GitHub last saw it change — the stamp the next pull's budget
560
570
  // compares against, kept on the subject rather than in a cache beside it.
561
571
  if (p.authorLogin !== undefined) fields.authorLogin = p.authorLogin;
562
- if (p.updatedAt !== undefined) fields.updatedAt = p.updatedAt;
572
+ if (p.authorType !== undefined) fields.user_type = p.authorType;
573
+ if (p.updatedAt !== undefined) { fields.updatedAt = p.updatedAt; fields.observedUpdatedAt = p.updatedAt; }
563
574
  // The reviewers' WORDS ride the observation: how many reviews, and the newest one's
564
575
  // verdict and body, so a delta on the feed says what a reviewer said (a consumer decides
565
576
  // from the words, never from a bare count moving). The reviews themselves are subjects
@@ -649,11 +660,11 @@ export function observedPrsToResources(prs: ObservedPr[]): SyncResource[] {
649
660
  * conversation gets bought.
650
661
  */
651
662
  export function lastObservedPrUpdates(root?: string): Record<string, string> {
652
- const shadow = buildShadowState(SERVICE, () => null, root);
653
663
  const out: Record<string, string> = {};
654
- for (const subject of Object.values(shadow.subjects)) {
664
+ for (const resource of twinResources(SERVICE, root)) {
665
+ const subject = { subject: resource, fields: ownFields(resource) };
655
666
  if (subject.subject.type !== 'pull_request') continue;
656
- const updatedAt = subject.fields.updatedAt;
667
+ const updatedAt = subject.fields.observedUpdatedAt ?? subject.fields.updatedAt;
657
668
  if (typeof updatedAt === 'string' && updatedAt !== '') out[subject.subject.id] = updatedAt;
658
669
  }
659
670
  return out;
@@ -666,6 +677,8 @@ export function observedIssuesToResources(issues: ObservedIssue[]): SyncResource
666
677
  if (i.title !== undefined) fields.title = i.title;
667
678
  if (i.body !== undefined) fields.body = i.body;
668
679
  if (i.state !== undefined) fields.state = i.state;
680
+ if (i.created_at !== undefined) fields.created_at = i.created_at;
681
+ if (i.updated_at !== undefined) fields.updated_at = i.updated_at;
669
682
  return { type: 'issue', id: i.id, fields };
670
683
  });
671
684
  }
@@ -698,14 +711,13 @@ type AppendOutcome = { appended: boolean; conflictId?: string };
698
711
  // resources in the same pull, so it must NOT abort the whole repo's fold: skip the row,
699
712
  // surface its stable id, keep folding. Every OTHER error stays fatal (re-thrown).
700
713
  function appendTolerant(event: WorldServiceEvent, root?: string): AppendOutcome {
701
- try {
702
- return { appended: appendEvent(event, root).appended };
703
- } catch (error) {
704
- if (error instanceof Error && error.message.includes('Conflicting duplicate')) {
705
- return { appended: false, conflictId: (event as unknown as { id: string }).id };
706
- }
707
- throw error;
708
- }
714
+ // PROTOCOL 2: the pack OBSERVES a resource; the kernel diffs it against the tree and folds what
715
+ // changed onto the root's log (observe.ts). The v1 row shape is read for its subject and fields.
716
+ const entry = toEntry(event as unknown as Record<string, unknown>);
717
+ // Observations are field patches. Copying the pre-refresh tree here would reintroduce
718
+ // stale fields after earlier observations in the same collected batch update them.
719
+ const report = observeResource(event.service, { type: entry.subject.type, id: entry.subject.id, fields: entry.fields ?? {} }, { ...(root !== undefined ? { root } : {}), at: entry.occurredAt });
720
+ return { appended: report.appended > 0 || report.observed > 0 && report.unchanged === 0 && report.appended === 0 };
709
721
  }
710
722
 
711
723
  // A content-hashed, deterministic id for a folded evidence snapshot — never random,
@@ -736,6 +748,8 @@ function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): Appe
736
748
  },
737
749
  };
738
750
  if (pr.title !== undefined) data.title = pr.title;
751
+ if (pr.authorLogin !== undefined) data.user_login = pr.authorLogin;
752
+ if (pr.authorType !== undefined) data.user_type = pr.authorType;
739
753
  if (pr.body !== undefined) data.body = pr.body;
740
754
  if (pr.state !== undefined) data.state = pr.state;
741
755
  if (pr.draft !== undefined) data.draft = pr.draft;
@@ -745,7 +759,7 @@ function foldObservedPr(pr: ObservedPr, occurredAt: string, root?: string): Appe
745
759
  if (pr.commitsCount !== undefined) data.commitsCount = pr.commitsCount;
746
760
  // draft/merged/mergeCommit are in the hash so a draft→ready or open→merged transition
747
761
  // re-folds (a new event) rather than being deduped away as an unchanged observation.
748
- const hash = evidenceHash([pr.number, pr.repository, pr.title, pr.body, pr.state, pr.draft, pr.merged, pr.mergeCommit, pr.baseRef, pr.headSha, pr.changedFiles, pr.commitsCount]);
762
+ const hash = evidenceHash([pr.number, pr.repository, pr.title, pr.body, pr.state, pr.draft, pr.merged, pr.mergeCommit, pr.baseRef, pr.headSha, pr.changedFiles, pr.commitsCount, pr.authorLogin, pr.authorType]);
749
763
  const id = `github:pull_request:${pr.id}:${hash}`;
750
764
  const event = {
751
765
  id,
@@ -832,7 +846,7 @@ function foldObservedExistence(pr: ObservedPr, occurredAt: string, root?: string
832
846
  });
833
847
  }
834
848
  });
835
- (pr.comments ?? []).forEach((c, i) => emit('issue_comment', `c${i}`, {
849
+ (pr.comments ?? []).forEach((c, i) => emit('issue_comment', c.id === undefined ? `c${i}` : `ic:${c.id}`, {
836
850
  ...(c.body !== undefined ? { body: c.body } : {}),
837
851
  ...(c.createdAt !== undefined ? { created_at: c.createdAt } : {}),
838
852
  }));
@@ -897,12 +911,13 @@ export async function syncGithubFromReal(
897
911
  const prs = await pullGithubPrs(execute, { ...pullOpts, lastUpdatedAt: lastObservedPrUpdates(opts.root) });
898
912
  const issues = await pullGithubIssues(execute, pullOpts);
899
913
  // Generic shadow-diff fold (content-bearing resources: title/body/state + refs).
900
- const pull = syncPull({
901
- service: SERVICE,
902
- resources: [...observedPrsToResources(prs), ...observedIssuesToResources(issues), ...(await observeRepositoryAndBranches(execute, opts.owner, opts.repo))],
903
- occurredAt: opts.occurredAt,
904
- ...(opts.root !== undefined ? { root: opts.root } : {}),
905
- });
914
+ // PROTOCOL 2: the pack OBSERVES each resource; the kernel diffs it against the tree and folds what
915
+ // changed onto the root's log (observe.ts) — inside a refresh, collected and folded as a batch.
916
+ const pull = { observed: 0, deltasAppended: 0, unchanged: 0 };
917
+ for (const r of [...observedPrsToResources(prs), ...observedIssuesToResources(issues), ...(await observeRepositoryAndBranches(execute, opts.owner, opts.repo))]) {
918
+ const report = observeResource(SERVICE, { type: r.type, id: r.id, fields: r.fields }, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: r.occurredAt ?? opts.occurredAt });
919
+ pull.observed += 1; pull.deltasAppended += report.appended; pull.unchanged += report.unchanged;
920
+ }
906
921
  // GitHub fold (carries counts + review/comment content the delta drops, plus issues).
907
922
  // A per-row `Conflicting duplicate` is tolerated (skipped + recorded), never fatal, so a
908
923
  // single diverged row can't stop later resources in the same pull from folding.
@@ -999,8 +1014,12 @@ export async function pushGithubAction(
999
1014
  // endpoint applyGithubWrite routes a PR edit through; GitHub accepts these on the issue
1000
1015
  // PATCH. The PR PATCH itself ignores labels/assignees/milestone, so route those on the
1001
1016
  // matching issue PATCH (PRs ARE issues for that endpoint) to faithfully persist them.
1002
- const res = await execute.request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', params);
1003
- ensureOk(res, 'pulls.update');
1017
+ let externalId = String(number);
1018
+ if (Object.keys(params).length > 3 || f.draft === undefined) {
1019
+ const res = await execute.request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', params);
1020
+ ensureOk(res, 'pulls.update');
1021
+ externalId = String(res.data?.number ?? number);
1022
+ }
1004
1023
  if (f.labels !== undefined || f.assignees !== undefined || f.milestone !== undefined) {
1005
1024
  const issueParams: Record<string, unknown> = { owner, repo, issue_number: number };
1006
1025
  if (f.labels !== undefined) issueParams.labels = f.labels;
@@ -1009,7 +1028,24 @@ export async function pushGithubAction(
1009
1028
  const issueRes = await execute.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', issueParams);
1010
1029
  ensureOk(issueRes, 'pulls.update.issue_fields');
1011
1030
  }
1012
- return { externalId: String(res.data?.number ?? number) };
1031
+ if (f.draft !== undefined) {
1032
+ if (typeof f.draft !== 'boolean') throw new Error('github push failed (pulls.draft): expected boolean');
1033
+ // Resolve the vendor's node, never send the local twin's synthetic GraphQL ID.
1034
+ const current = await execute.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { owner, repo, pull_number: number });
1035
+ ensureOk(current, 'pulls.draft.read');
1036
+ if (typeof current.data?.node_id !== 'string' || typeof current.data?.draft !== 'boolean') throw new Error('github push failed (pulls.draft): vendor PR identity/state missing');
1037
+ if (current.data.draft !== f.draft) {
1038
+ const mutation = f.draft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview';
1039
+ const res = await execute.request('POST /graphql', {
1040
+ query: `mutation($id:ID!){${mutation}(input:{pullRequestId:$id}){pullRequest{id isDraft}}}`,
1041
+ variables: { id: current.data.node_id },
1042
+ });
1043
+ ensureOk(res, 'pulls.draft');
1044
+ const changed = res.data?.data?.[mutation]?.pullRequest;
1045
+ if (res.data?.errors?.length || changed?.id !== current.data.node_id || changed?.isDraft !== f.draft) throw new Error('github push failed (pulls.draft): GraphQL transition was not confirmed');
1046
+ }
1047
+ }
1048
+ return { externalId };
1013
1049
  }
1014
1050
  if (op === 'pull_request.merge') {
1015
1051
  const number = Number(f.number ?? action.subject.id.split('#').pop());
@@ -1220,57 +1256,6 @@ function knownReviewCommentExternalIds(root?: string): Record<string, string> {
1220
1256
  return out;
1221
1257
  }
1222
1258
 
1223
- /**
1224
- * PUSH (all): push every pending LOCAL GitHub action to real GitHub via the injected
1225
- * executor and CONFIRM each on success — `confirmAction` records the confirmed fields
1226
- * as an observed event and maps the action → that event, SUPPRESSING the local
1227
- * projection (the fact now lives in the observed log; counted exactly once). Closes
1228
- * the action → push → observed loop (R18). Idempotency: a confirmed action is no
1229
- * longer pending, so a re-push calls the executor for it again ZERO times.
1230
- *
1231
- * PER-ACTION TOLERANCE, the same doctrine the pull's per-row conflict skip follows: one
1232
- * action the connector cannot enact (a reply whose root has no id on the real repo) or that
1233
- * the vendor rejected must NOT abort the other pending writes in the sweep — it is skipped
1234
- * with its reason, left PENDING so a later sweep retries it, and REPORTED in `skipped` so a
1235
- * poller can log it loudly instead of it being swallowed. Nothing is confirmed on a failure,
1236
- * so a skipped action is never mistaken for a written one.
1237
- */
1238
- export async function pushPendingGithubActions(
1239
- execute: GithubExecute,
1240
- opts: { root?: string; occurredAt: string },
1241
- ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; skipped: Array<{ actionId: string; operation: string; reason: string }> }> {
1242
- const confirmed: string[] = [];
1243
- const externalIds: Record<string, string> = {};
1244
- const skipped: Array<{ actionId: string; operation: string; reason: string }> = [];
1245
- // Seeded from what the world already knows, then GROWN as the sweep confirms writes — a
1246
- // root comment created and replied to between two pushes resolves within one sweep.
1247
- const commentExternalIds = knownReviewCommentExternalIds(opts.root);
1248
- for (const action of pendingActions(SERVICE, opts.root)) {
1249
- let externalId: string;
1250
- try {
1251
- ({ externalId } = await pushGithubAction(execute, action, { externalIds: commentExternalIds }));
1252
- } catch (error) {
1253
- skipped.push({ actionId: action.id, operation: action.operation ?? '', reason: error instanceof Error ? error.message : String(error) });
1254
- continue;
1255
- }
1256
- confirmAction({
1257
- service: SERVICE,
1258
- actionId: action.id,
1259
- subject: action.subject,
1260
- fields: action.fields ?? {},
1261
- occurredAt: opts.occurredAt,
1262
- ...(opts.root !== undefined ? { root: opts.root } : {}),
1263
- });
1264
- confirmed.push(action.id);
1265
- externalIds[action.id] = externalId;
1266
- // A review comment the vendor just accepted is now addressable, so a reply LATER in this
1267
- // same sweep can name it. Keyed by the twin's own comment id, which is what the reply's
1268
- // `in_reply_to` carries.
1269
- const local = /^comment:(\d+)$/.exec(action.subject.id);
1270
- if (local !== null && action.operation === 'pull_request_review_comment.create' && externalId !== '') commentExternalIds[local[1]!] = externalId;
1271
- }
1272
- return { pushed: confirmed.length, confirmed, externalIds, skipped };
1273
- }
1274
1259
 
1275
1260
  /**
1276
1261
  * THE R14 PUSH ADAPTER for github (jira's `pushJiraToRemote` is the reference; this
@@ -1281,10 +1266,8 @@ export async function pushPendingGithubActions(
1281
1266
  * loudly. The route grammar is the one `pushGithubAction` speaks (`METHOD /path/{param}`,
1282
1267
  * templated params in the path, the rest as the JSON body).
1283
1268
  */
1284
- export async function pushGithubToRemote(
1285
- execute: RemoteExecute,
1286
- opts: { root?: string; origin?: string } = {},
1287
- ): Promise<{ pushed: number; confirmed: string[]; skipped: Array<{ actionId: string; operation: string; reason: string }> }> {
1269
+ /** This pack's vendor executor over the kernel's ONE RemoteExecute (push plane). */
1270
+ export function githubPushExecutor(execute: RemoteExecute): GithubExecute {
1288
1271
  const executor: GithubExecute = {
1289
1272
  async request(route, params = {}) {
1290
1273
  const sp = route.indexOf(' ');
@@ -1316,12 +1299,40 @@ export async function pushGithubToRemote(
1316
1299
  return { status: res.status, data: res.body === '' ? undefined : JSON.parse(res.body) };
1317
1300
  },
1318
1301
  };
1319
- const pushed = await pushPendingGithubActions(executor, { ...(opts.root === undefined ? {} : { root: opts.root }), occurredAt: new Date().toISOString() });
1320
- // The refusals ride out with the successes: a link that pushed nothing because every action
1321
- // was refused must not read as a clean sweep.
1322
- return { pushed: pushed.pushed, confirmed: pushed.confirmed, skipped: pushed.skipped };
1302
+ return executor;
1303
+ }
1304
+
1305
+ /** THE ANCHORED PUSH SEAM (contract "The push arm is the kernel's transaction; a pack performs one
1306
+ * action"): perform ONE pending action against GitHub over the kernel executor; the vendor's id comes back. */
1307
+ /** The vendor's id in the pack's own subject grammar: a number replaces the trailing number of the
1308
+ * local id (`acme/web#issue:1` → `acme/web#issue:57`, `acme/web#3` → `acme/web#9`, `comment:2` →
1309
+ * `comment:184`); anything else (a sha, a ref, a path, an owner/name) leaves the address as it is
1310
+ * and rides on the receipt as `vendorId`. The kernel rebinds the subject to what comes back
1311
+ * (contract "A pushed write adopts the vendor's id"). */
1312
+ export function adoptGithubId(localId: string, vendorId: string | undefined): string {
1313
+ if (!vendorId) return localId;
1314
+ if (/^\d+$/.test(vendorId) && /\d+$/.test(localId)) return localId.replace(/\d+$/, vendorId);
1315
+ if (vendorId === localId || vendorId.includes('#') || vendorId.startsWith('comment:') || vendorId.startsWith('review:')) return vendorId;
1316
+ return localId;
1323
1317
  }
1324
1318
 
1319
+ export async function performGithubAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
1320
+ const externalIds = knownReviewCommentExternalIds(ctx.root);
1321
+ const replyTo = action.fields?.in_reply_to;
1322
+ if (replyTo !== undefined) {
1323
+ const local = `comment:${replyTo}`;
1324
+ const adopted = ctx.resolve('pull_request_review_comment', local);
1325
+ if (adopted !== local && /^comment:\d+$/.test(adopted)) externalIds[String(replyTo)] = adopted.slice('comment:'.length);
1326
+ }
1327
+ const out = await pushGithubAction(githubPushExecutor(execute), action, { externalIds });
1328
+ const adopted = adoptGithubId(action.subject.id, out.externalId);
1329
+ // what the vendor minted rides on the receipt as fields: the number under which the row now lives
1330
+ const numeric = out.externalId !== undefined && /^\d+$/.test(out.externalId) && adopted !== action.subject.id;
1331
+ const data = { ...(numeric ? { number: Number(out.externalId) } : {}), ...(out.externalId && out.externalId !== adopted ? { vendorId: out.externalId } : {}) };
1332
+ return { externalId: adopted, ...(Object.keys(data).length ? { data } : {}) };
1333
+ }
1334
+
1335
+
1325
1336
  /**
1326
1337
  * THE REPOSITORY AND ITS BRANCHES are observed with the PRs and issues: a working copy's
1327
1338
  * confirmed writes (a branch it cut, the repo it declared) survive only as what reality
@@ -1369,7 +1380,8 @@ export async function syncGithubFromRemote(
1369
1380
  execute: RemoteExecute,
1370
1381
  opts: { root?: string; origin?: string; state?: 'open' | 'closed' | 'all'; perPage?: number } = {},
1371
1382
  ): Promise<{ observed: number; deltasAppended: number; eventsAppended: number; issues: number; conflictsSkipped: number; conflictingIds: string[] }> {
1372
- const match = /^\/repos\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(opts.origin === undefined ? '' : new URL(opts.origin).pathname);
1383
+ // the repository is the end of the path: a served world's twin lives under /<org>/<world>/github
1384
+ const match = /\/repos\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(opts.origin === undefined ? '' : new URL(opts.origin).pathname);
1373
1385
  if (match === null) {
1374
1386
  throw new Error('github remote pull: the link origin must name the repo — https://api.github.com/repos/{owner}/{repo}');
1375
1387
  }