ensemble-review 1.0.35 → 1.0.37

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 (186) hide show
  1. package/dist/api/plugins/rate-limit.js +22 -0
  2. package/dist/api/routes/releases.js +181 -0
  3. package/dist/app.js +3 -0
  4. package/dist/coding/story-build/base-refs.js +10 -2
  5. package/dist/coding/story-build/loop/driver.js +1 -1
  6. package/dist/coding/story-build/loop/routes.js +9 -1
  7. package/dist/coding/story-build/persist.js +53 -7
  8. package/dist/coding/story-build/plan-persist.js +1 -1
  9. package/dist/coding/story-build/push-hotfix.js +293 -0
  10. package/dist/coding/story-build/push.js +5 -0
  11. package/dist/coding/story-build/routes-build.js +25 -3
  12. package/dist/db/erase-account.js +14 -0
  13. package/dist/db/hotfix-bases.js +31 -0
  14. package/dist/db/migrations/0195_releases.sql +128 -0
  15. package/dist/db/migrations/0196_story_build_hotfix.sql +54 -0
  16. package/dist/db/migrations/0197_release_items_live_tag.sql +11 -0
  17. package/dist/db/migrations/0198_story_build_port_report.sql +7 -0
  18. package/dist/db/migrations/meta/_journal.json +28 -0
  19. package/dist/db/queries.js +21 -0
  20. package/dist/db/release-check.js +378 -0
  21. package/dist/db/release-settings.js +94 -0
  22. package/dist/db/repo-tags.js +82 -0
  23. package/dist/db/schema.sqlite.js +246 -0
  24. package/dist/github/mutations.js +67 -0
  25. package/dist/github/queries.js +5 -0
  26. package/dist/github/settle-query.js +11 -1
  27. package/dist/github/tag-queries.js +101 -0
  28. package/dist/releases/create.js +539 -0
  29. package/dist/releases/github.js +78 -0
  30. package/dist/releases/notes.js +152 -0
  31. package/dist/releases/refresh.js +43 -0
  32. package/dist/sandbox/session/manager.js +207 -12
  33. package/dist/sandbox/session/plan.js +19 -7
  34. package/dist/sandbox/session/routes.js +12 -2
  35. package/dist/sandbox/workspace/hotfix.js +30 -0
  36. package/dist/sandbox/workspace/index.js +38 -2
  37. package/dist/sandbox/workspace/port-report.js +137 -0
  38. package/dist/sandbox/workspace/seed.js +40 -0
  39. package/dist/shared/hotfix.js +165 -0
  40. package/dist/shared/index.js +4 -0
  41. package/dist/shared/release-check.js +528 -0
  42. package/dist/shared/release-write.js +264 -0
  43. package/dist/shared/releases.js +89 -0
  44. package/dist/shared/story-build.js +2 -0
  45. package/dist/sync/repo-tags.js +368 -0
  46. package/dist/sync/settle-sweep.js +47 -2
  47. package/dist/sync/sync-repo.js +12 -0
  48. package/dist/sync/tag-ranges.js +350 -0
  49. package/dist/sync/tags-step.js +77 -0
  50. package/dist/sync/upsert.js +23 -0
  51. package/package.json +5 -2
  52. package/public/assets/{AiFixTab-CVIZl9I8.js → AiFixTab-BC3tDZ3z.js} +1 -1
  53. package/public/assets/{PrBotBehaviourTab-q0M7RU1e.js → PrBotBehaviourTab-C5j5uhLn.js} +1 -1
  54. package/public/assets/index-BrjUDY3j.js +138 -0
  55. package/public/assets/index-D1zhsqSF.css +1 -0
  56. package/public/index.html +2 -2
  57. package/dist/ai/check-tool.js +0 -6
  58. package/dist/api/routes/auth.js +0 -194
  59. package/dist/api/routes/billing.js +0 -209
  60. package/dist/api/routes/claude-review.js +0 -522
  61. package/dist/api/routes/contact.js +0 -318
  62. package/dist/api/routes/feed.js +0 -21
  63. package/dist/api/routes/inbox.js +0 -45
  64. package/dist/api/routes/mentions.js +0 -22
  65. package/dist/api/routes/teams.js +0 -150
  66. package/dist/api/routes/webhooks.js +0 -181
  67. package/dist/coding/activity.js +0 -80
  68. package/dist/coding/agent.js +0 -147
  69. package/dist/coding/ai-fix/checks-preview.js +0 -58
  70. package/dist/coding/ai-fix/comment-seed.js +0 -519
  71. package/dist/coding/ai-fix/prompts.js +0 -283
  72. package/dist/coding/ai-resolve/prompt.js +0 -161
  73. package/dist/coding/ai-resolve/routes.js +0 -97
  74. package/dist/coding/ai-resolve/run.js +0 -343
  75. package/dist/coding/ai-resolve/schema.js +0 -26
  76. package/dist/coding/ai-resolve/validate.js +0 -136
  77. package/dist/coding/checks/deny-scan.js +0 -75
  78. package/dist/coding/checks/discover.js +0 -541
  79. package/dist/coding/checks/docker.js +0 -38
  80. package/dist/coding/checks/env.js +0 -88
  81. package/dist/coding/checks/hooks.js +0 -91
  82. package/dist/coding/checks/index.js +0 -8
  83. package/dist/coding/checks/local.js +0 -40
  84. package/dist/coding/checks/sandbox.js +0 -206
  85. package/dist/coding/checks/session.js +0 -518
  86. package/dist/coding/checks/spawn.js +0 -123
  87. package/dist/coding/checks/spec.js +0 -140
  88. package/dist/coding/checks/types.js +0 -6
  89. package/dist/coding/partial-spend.js +0 -19
  90. package/dist/coding/safe-path.js +0 -75
  91. package/dist/coding/story-build/build-prompts.js +0 -178
  92. package/dist/coding/story-build/build.js +0 -332
  93. package/dist/coding/story-build/checkouts.js +0 -66
  94. package/dist/coding/story-build/docs-runner.js +0 -178
  95. package/dist/coding/story-build/docs.js +0 -334
  96. package/dist/coding/story-build/manager.js +0 -999
  97. package/dist/coding/story-build/plan-agent.js +0 -190
  98. package/dist/coding/story-build/plan.js +0 -338
  99. package/dist/coding/story-build/prompts.js +0 -147
  100. package/dist/coding/story-build/routes-docs.js +0 -113
  101. package/dist/coding/story-build/routes.js +0 -295
  102. package/dist/coding/story-build/schema.js +0 -53
  103. package/dist/db/automation-output.js +0 -349
  104. package/dist/db/credits.js +0 -79
  105. package/dist/db/schema.js +0 -321
  106. package/dist/db/schema.pg.js +0 -2197
  107. package/dist/db/sprint-repo-suggest.js +0 -257
  108. package/dist/db/sprint-stories.js +0 -42
  109. package/dist/db/sprint-story.js +0 -505
  110. package/dist/db/sprint-ticket-repos.js +0 -195
  111. package/dist/db/story-selection.js +0 -270
  112. package/dist/db/team-comparison.js +0 -124
  113. package/dist/db/workspace-comparison.js +0 -98
  114. package/dist/feed/fyi-provider.js +0 -10
  115. package/dist/github/local-user.js +0 -92
  116. package/dist/github/repo-files.js +0 -26
  117. package/dist/ml/worker.js +0 -31
  118. package/dist/platform/advisor/discovery/tier2.js +0 -88
  119. package/dist/platform/advisor/discovery/tier3.js +0 -57
  120. package/dist/platform/advisor/emitters/adapter.js +0 -5
  121. package/dist/platform/advisor/emitters/coderabbit.js +0 -175
  122. package/dist/platform/advisor/emitters/copilot-prose.js +0 -118
  123. package/dist/platform/advisor/emitters/greptile.js +0 -176
  124. package/dist/platform/advisor/emitters/lint.js +0 -37
  125. package/dist/platform/advisor/emitters/manifest.js +0 -239
  126. package/dist/platform/advisor/emitters/prose.js +0 -183
  127. package/dist/platform/advisor/emitters/registry.js +0 -20
  128. package/dist/platform/advisor/emitters/yaml-doc.js +0 -95
  129. package/dist/platform/advisor/evidence.js +0 -79
  130. package/dist/platform/advisor/findings.js +0 -15
  131. package/dist/platform/advisor/intents.js +0 -269
  132. package/dist/platform/advisor/measure/effect.js +0 -18
  133. package/dist/platform/advisor/outputs/brief.js +0 -68
  134. package/dist/platform/advisor/outputs/config-pr.js +0 -156
  135. package/dist/platform/advisor/refine/diff-guard.js +0 -38
  136. package/dist/platform/advisor/refine/refine.js +0 -35
  137. package/dist/platform/advisor/retro-check.js +0 -98
  138. package/dist/platform/advisor/routes.js +0 -648
  139. package/dist/platform/advisor/store.js +0 -321
  140. package/dist/platform/advisor/taxonomy.js +0 -67
  141. package/dist/platform/annotations/prompts.js +0 -84
  142. package/dist/platform/annotations/routes.js +0 -86
  143. package/dist/platform/annotations/runner.js +0 -234
  144. package/dist/platform/annotations/targets.js +0 -203
  145. package/dist/platform/insights/grounded-figures.js +0 -162
  146. package/dist/platform/insights/period-questions.js +0 -235
  147. package/dist/platform/work-plan/hash.js +0 -99
  148. package/dist/platform/work-plan/prompt.js +0 -209
  149. package/dist/platform/work-plan/routes.js +0 -350
  150. package/dist/pro/bind.js +0 -309
  151. package/dist/pro/contract.js +0 -36
  152. package/dist/pro/event-hooks.js +0 -37
  153. package/dist/pro/migrate.js +0 -64
  154. package/dist/review/events.js +0 -24
  155. package/dist/review/local-settings.js +0 -78
  156. package/dist/review/memory/capture.js +0 -0
  157. package/dist/review/memory/retrieval.js +0 -272
  158. package/dist/review/memory/routes.js +0 -78
  159. package/dist/review/persist.js +0 -203
  160. package/dist/review/plugin-providers.js +0 -12
  161. package/dist/review/prompt.js +0 -264
  162. package/dist/review/review-manager.js +0 -259
  163. package/dist/review/routing.js +0 -164
  164. package/dist/review/sprint-assess/agent.js +0 -145
  165. package/dist/review/sprint-assess/manager.js +0 -382
  166. package/dist/review/sprint-assess/prompts.js +0 -99
  167. package/dist/review/sprint-assess/routes.js +0 -109
  168. package/dist/review/sprint-assess/schema.js +0 -70
  169. package/dist/review/sprint-assess/validate.js +0 -180
  170. package/dist/review/sprint-story/manager.js +0 -98
  171. package/dist/review/sprint-story/prompt.js +0 -45
  172. package/dist/review/sprint-story/validate.js +0 -127
  173. package/dist/review/story-docs/build-prompts.js +0 -153
  174. package/dist/review/story-docs/build-validate.js +0 -162
  175. package/dist/review/story-report/agent.js +0 -203
  176. package/dist/review/story-report/fingerprint.js +0 -45
  177. package/dist/review/story-report/manager.js +0 -287
  178. package/dist/review/story-report/routes.js +0 -154
  179. package/dist/review/story-report/story-detail.js +0 -148
  180. package/dist/review/story-report/validate.js +0 -105
  181. package/dist/review/ticket-review/sweep-waits.js +0 -20
  182. package/dist/review/write-deny.js +0 -170
  183. package/dist/shared/conflict-ai.js +0 -24
  184. package/dist/sync/benchmark-rollup.js +0 -50
  185. package/public/assets/index-CbEfmqOD.css +0 -1
  186. package/public/assets/index-DBK4RWQu.js +0 -136
@@ -185,6 +185,12 @@ export const repos = sqliteTable('repos', {
185
185
  // from GitHub (a nulled selection leaves it as it was); `checked_at` stamps every attempt.
186
186
  usesGithubIssues: integer('uses_github_issues', { mode: 'boolean' }),
187
187
  githubIssuesCheckedAt: integer('github_issues_checked_at', { mode: 'timestamp' }),
188
+ // ---- TAGS (migration 0195; docs/RELEASES.md) ----
189
+ // What the last COMPLETE tag read saw, as `tagSignature` (sync/repo-tags.ts): the tag count plus
190
+ // the newest tag's name and target. The settle sweep reads the same three facts for ~nothing and
191
+ // starts a tag sync when they differ — a tag push is updatedAt-silent. NULL = never read.
192
+ tagsSignature: text('tags_signature'),
193
+ tagsSyncedAt: integer('tags_synced_at', { mode: 'timestamp' }),
188
194
  }, (t) => ({
189
195
  // Composite uniques so two accounts can watch the same GitHub repo (each
190
196
  // gets its own row). The upsert conflict targets these.
@@ -408,8 +414,17 @@ export const pullRequests = sqliteTable('pull_requests', {
408
414
  linearLinks: text('linear_links', { mode: 'json' }).$type(),
409
415
  linearLinksRoot: text('linear_links_root'),
410
416
  linearLinksCheckedAt: integer('linear_links_checked_at', { mode: 'timestamp' }),
417
+ // ---- RELEASES (migration 0195; docs/RELEASES.md) ----
418
+ // The commit this PR landed as on its base branch (GraphQL `mergeCommit { oid }`): the squash
419
+ // commit, the merge commit, or the last rebased commit. A version tag's range maps the commits
420
+ // between two tags to PRs through it (sync/tag-ranges.ts). ⚠ Cleared only on a positive
421
+ // statement: an oid is written when one arrives; null only for a PR GitHub says is NOT merged
422
+ // (sync/upsert.ts `mergeCommitOidToWrite`). NULL on a merged PR = not observed yet.
423
+ mergeCommitOid: text('merge_commit_oid'),
411
424
  }, (t) => ({
412
425
  repoIdx: index('pr_repo_idx').on(t.repoId),
426
+ // The tag range's commit → PR lookup (sync/tag-ranges.ts), a batch of shas within one repo.
427
+ mergeCommitIdx: index('pr_account_repo_merge_commit_idx').on(t.accountId, t.repoId, t.mergeCommitOid),
413
428
  openedIdx: index('pr_opened_idx').on(t.openedAt),
414
429
  accountIdx: index('pr_account_idx').on(t.accountId),
415
430
  // Drives getUserStats (the contributor popover) from the person rather than the account.
@@ -2113,6 +2128,13 @@ export const workspaces = sqliteTable('workspaces', {
2113
2128
  // `AI_REVIEWS_FIRST_LOOK_DAYS` days). Never moves back. The ONE reader/writer is
2114
2129
  // db/ai-reviews-seen.ts.
2115
2130
  aiReviewsSeenAt: integer('ai_reviews_seen_at', { mode: 'timestamp' }),
2131
+ // RELEASES (migration 0195; docs/RELEASES.md § Terms and settings): the RELEASE suffix (a
2132
+ // release tag is `<version tag><release suffix>`) and the HOTFIX suffix (a hotfix tag ends in
2133
+ // it). NULL = not set, and there is NO default: the Releases tab and the hotfix option do
2134
+ // nothing until BOTH are set. Validated by shared `validateReleaseSettings`; the ONE reader /
2135
+ // writer is db/release-settings.ts. Per workspace, no inheritance.
2136
+ releaseSuffix: text('release_suffix'),
2137
+ hotfixSuffix: text('hotfix_suffix'),
2116
2138
  createdAt: integer('created_at', { mode: 'timestamp' })
2117
2139
  .notNull()
2118
2140
  .default(sql `(unixepoch())`),
@@ -4217,6 +4239,10 @@ export const storyBuilds = sqliteTable('story_builds', {
4217
4239
  endToEnd: integer('end_to_end', { mode: 'boolean' }).notNull().default(false),
4218
4240
  e2eTicketClaimedAt: integer('e2e_ticket_claimed_at', { mode: 'timestamp' }),
4219
4241
  e2eTicketJson: text('e2e_ticket_json', { mode: 'json' }).$type(),
4242
+ // HOTFIX (migration 0196; docs/STORY-BUILD.md § Hotfix builds): "Treat as a hotfix" at Start. Each repository
4243
+ // with a latest release is fixed there and the fix PORTED to the default branch; the review loop and
4244
+ // "Build end to end" are refused for it.
4245
+ hotfix: integer('hotfix', { mode: 'boolean' }).notNull().default(false),
4220
4246
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql `(unixepoch())`),
4221
4247
  startedAt: integer('started_at', { mode: 'timestamp' }),
4222
4248
  completedAt: integer('completed_at', { mode: 'timestamp' }),
@@ -4282,6 +4308,35 @@ export const storyBuildRepos = sqliteTable('story_build_repos', {
4282
4308
  loopPushedTree: text('loop_pushed_tree'),
4283
4309
  loopPushedAt: integer('loop_pushed_at', { mode: 'timestamp' }),
4284
4310
  loopEnded: text('loop_ended'),
4311
+ // HOTFIX (migration 0196; docs/STORY-BUILD.md § Hotfix builds). `hotfix_tag` = the latest release the
4312
+ // release checkout starts at (NULL on an ordinary build and on a read-only repository); `read_only` = no
4313
+ // release yet, its changes are refused at extraction. `main_*` = THE MAIN FIX (the default-branch checkout
4314
+ // in the same sandbox): its base, path, patch and push record, beside the row's own (the RELEASE FIX).
4315
+ hotfixTag: text('hotfix_tag'),
4316
+ readOnly: integer('read_only', { mode: 'boolean' }).notNull().default(false),
4317
+ mainBaseRef: text('main_base_ref'),
4318
+ mainBaseSha: text('main_base_sha'),
4319
+ mainSandboxPath: text('main_sandbox_path'),
4320
+ mainPatch: text('main_patch'),
4321
+ mainPatchSha: text('main_patch_sha'),
4322
+ mainStatJson: text('main_stat_json', { mode: 'json' }).$type(),
4323
+ mainFilesChangedJson: text('main_files_changed_json', { mode: 'json' }).$type(),
4324
+ mainCommitMessage: text('main_commit_message'),
4325
+ // NULL (not built yet) | 'ready' | 'skipped' (no change) | 'failed' | 'pushed'.
4326
+ mainStatus: text('main_status'),
4327
+ mainError: text('main_error'),
4328
+ mainPushedBranch: text('main_pushed_branch'),
4329
+ mainPushedPrNumber: integer('main_pushed_pr_number'),
4330
+ mainPushedPrUrl: text('main_pushed_pr_url'),
4331
+ mainPushedCommitSha: text('main_pushed_commit_sha'),
4332
+ mainPushedAt: integer('main_pushed_at', { mode: 'timestamp' }),
4333
+ mainPushError: text('main_push_error'),
4334
+ mainPushErrorCode: text('main_push_error_code'),
4335
+ // Push's record of `hotfix/<tag>` (shared StoryBuildHotfixBranch): created or found, moved, the PRs on it.
4336
+ hotfixBranchJson: text('hotfix_branch_json', { mode: 'json' }).$type(),
4337
+ // THE PORT REPORT (migration 0198): the agent's claim for this repository (same | adapted | not_needed,
4338
+ // the reason, the evidence), as the host checked and clipped it; NULL = no report. Untrusted model text.
4339
+ portReportJson: text('port_report_json', { mode: 'json' }).$type(),
4285
4340
  provider: text('provider'),
4286
4341
  model: text('model'),
4287
4342
  effort: text('effort'),
@@ -4853,4 +4908,195 @@ export const limnActions = sqliteTable('limn_actions', {
4853
4908
  foreignColumns: [pullRequests.id, pullRequests.accountId],
4854
4909
  }).onDelete('cascade'),
4855
4910
  }));
4911
+ // ---- RELEASES (migration 0195; docs/RELEASES.md) ------------------------------------------------
4912
+ //
4913
+ // FIVE tables, all account-scoped with composite FKs (tenancy as a constraint), all in
4914
+ // `accountScopedTables()` and erased explicitly; the repo-anchored ones in `deleteRepo` too.
4915
+ //
4916
+ // repo_tags one row per (account, repo, tag name): what the tag points at.
4917
+ // repo_tag_ranges one row per (account, repo, tag name): the PRs between the tag and the
4918
+ // tag before it on the default branch, computed once per (tag sha, base sha).
4919
+ // releases one row per reader press of "Create release…".
4920
+ // release_items one row per (release, repo): the release tag and the pre-release.
4921
+ // release_ticket_comments one row per (release, ticket): the tracker comment.
4922
+ // The repo's git tags, synced as part of the default-branch snapshot step (sync/repo-tags.ts) —
4923
+ // STRICTLY NON-FATAL, the newest REPO_TAGS_READ_CAP by the tagged commit's date. A tag PUSH is
4924
+ // updatedAt-silent, so the settle sweep notices it (`repos.tags_signature`).
4925
+ // sha the COMMIT the tag points at (an annotated tag is peeled one level).
4926
+ // tag_object_oid the annotated tag object's oid; NULL for a lightweight tag.
4927
+ // committed_at the tagged commit's date — the order "newest version tag" means.
4928
+ // tagged_at the annotated tag's tagger date (the tie-break "by tag creation"); NULL for a
4929
+ // lightweight tag.
4930
+ // on_default_branch the tagged commit is an ancestor of (or equal to) the default branch head.
4931
+ // NULL = not known yet. `ancestry_head_sha` is the head it was checked against:
4932
+ // true is final for that sha (until the tag moves); false is re-checked when the
4933
+ // head moves.
4934
+ // previous_sha / moved_at the tag was MOVED to another commit (re-pushed with --force): the sha
4935
+ // it pointed at before, and when Ensemble saw the move.
4936
+ export const repoTags = sqliteTable('repo_tags', {
4937
+ id: integer('id').primaryKey({ autoIncrement: true }),
4938
+ accountId: integer('account_id')
4939
+ .notNull()
4940
+ .references(() => accounts.id, { onDelete: 'cascade' }),
4941
+ repoId: integer('repo_id').notNull(),
4942
+ name: text('name').notNull(),
4943
+ sha: text('sha').notNull(),
4944
+ tagObjectOid: text('tag_object_oid'),
4945
+ committedAt: integer('committed_at', { mode: 'timestamp' }),
4946
+ taggedAt: integer('tagged_at', { mode: 'timestamp' }),
4947
+ onDefaultBranch: integer('on_default_branch', { mode: 'boolean' }),
4948
+ ancestryHeadSha: text('ancestry_head_sha'),
4949
+ previousSha: text('previous_sha'),
4950
+ movedAt: integer('moved_at', { mode: 'timestamp' }),
4951
+ firstSeenAt: integer('first_seen_at', { mode: 'timestamp' }).notNull(),
4952
+ lastSeenAt: integer('last_seen_at', { mode: 'timestamp' }).notNull(),
4953
+ }, (t) => ({
4954
+ nameUx: uniqueIndex('repo_tags_account_repo_name').on(t.accountId, t.repoId, t.name),
4955
+ shaIdx: index('repo_tags_account_repo_sha_idx').on(t.accountId, t.repoId, t.sha),
4956
+ repoAccountFk: foreignKey({
4957
+ name: 'repo_tags_repo_account_fk',
4958
+ columns: [t.repoId, t.accountId],
4959
+ foreignColumns: [repos.id, repos.accountId],
4960
+ }).onDelete('cascade'),
4961
+ }));
4962
+ // A tag's range INCREMENT (sync/tag-ranges.ts): the PRs merged into the default branch in
4963
+ // (base, tag], where base is the tag before it on the default branch (by committed date, then
4964
+ // name). Suffix-free on purpose — the release check sums increments from the baseline up to a
4965
+ // version tag, so a new release (a new baseline) or a changed suffix needs no recompute.
4966
+ // base_tag_name / base_sha NULL for the OLDEST tag Ensemble knows: its range is every PR in the
4967
+ // synced history merged into the default branch no later than the tag's commit
4968
+ // (method 'history'), so older PRs are not checked.
4969
+ // method 'compare' (REST compare base...tag) | 'history' (no base: the stored PRs).
4970
+ // pr_numbers the PRs, by NUMBER (a PR the walk has not stored yet still counts). Resolved to
4971
+ // local ids within (account, repo) on read (db/repo-tags.ts).
4972
+ // commit_count / unmatched_commits commits in the range, and how many matched no PR (direct pushes).
4973
+ // truncated the compare had more commits than Ensemble reads (TAG_RANGE_MAX_COMMITS).
4974
+ // moved_from_sha the tag MOVED since the last computation: the sha the old range was for.
4975
+ // Recomputed only when the tag's sha or its base's sha changes.
4976
+ export const repoTagRanges = sqliteTable('repo_tag_ranges', {
4977
+ id: integer('id').primaryKey({ autoIncrement: true }),
4978
+ accountId: integer('account_id')
4979
+ .notNull()
4980
+ .references(() => accounts.id, { onDelete: 'cascade' }),
4981
+ repoId: integer('repo_id').notNull(),
4982
+ tagName: text('tag_name').notNull(),
4983
+ sha: text('sha').notNull(),
4984
+ baseTagName: text('base_tag_name'),
4985
+ baseSha: text('base_sha'),
4986
+ method: text('method', { enum: ['compare', 'history'] }).notNull(),
4987
+ prNumbers: text('pr_numbers', { mode: 'json' }).$type().notNull(),
4988
+ commitCount: integer('commit_count').notNull().default(0),
4989
+ unmatchedCommits: integer('unmatched_commits').notNull().default(0),
4990
+ truncated: integer('truncated', { mode: 'boolean' }).notNull().default(false),
4991
+ movedFromSha: text('moved_from_sha'),
4992
+ computedAt: integer('computed_at', { mode: 'timestamp' }).notNull(),
4993
+ }, (t) => ({
4994
+ tagUx: uniqueIndex('repo_tag_ranges_account_repo_tag').on(t.accountId, t.repoId, t.tagName),
4995
+ repoAccountFk: foreignKey({
4996
+ name: 'repo_tag_ranges_repo_account_fk',
4997
+ columns: [t.repoId, t.accountId],
4998
+ foreignColumns: [repos.id, repos.accountId],
4999
+ }).onDelete('cascade'),
5000
+ }));
5001
+ // One row per press of "Create release…" (docs/RELEASES.md § Releases tab). `notes_markdown` is the
5002
+ // whole document (a section per repo); `release_suffix` the suffix in force when it was made.
5003
+ // state 'writing' (the writes are running) · 'done' (every step worked) · 'partial' (some
5004
+ // failed) · 'failed' (nothing was written) · 'stopped' (Confirm's re-check found a change).
5005
+ export const releases = sqliteTable('releases', {
5006
+ id: integer('id').primaryKey({ autoIncrement: true }),
5007
+ accountId: integer('account_id')
5008
+ .notNull()
5009
+ .references(() => accounts.id, { onDelete: 'cascade' }),
5010
+ workspaceId: integer('workspace_id').notNull(),
5011
+ createdByUserId: integer('created_by_user_id').references(() => users.id),
5012
+ createdByLogin: text('created_by_login'),
5013
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
5014
+ releaseSuffix: text('release_suffix').notNull(),
5015
+ notesMarkdown: text('notes_markdown').notNull().default(''),
5016
+ state: text('state', { enum: ['writing', 'done', 'partial', 'failed', 'stopped'] }).notNull(),
5017
+ finishedAt: integer('finished_at', { mode: 'timestamp' }),
5018
+ }, (t) => ({
5019
+ workspaceIdx: index('releases_account_workspace_idx').on(t.accountId, t.workspaceId, t.createdAt),
5020
+ // The PARENT KEY of the two child tables' composite FKs (the `repos_id_account` trick).
5021
+ idAccountUx: uniqueIndex('releases_id_account').on(t.id, t.accountId),
5022
+ workspaceAccountFk: foreignKey({
5023
+ name: 'releases_workspace_account_fk',
5024
+ columns: [t.workspaceId, t.accountId],
5025
+ foreignColumns: [workspaces.id, workspaces.accountId],
5026
+ }).onDelete('cascade'),
5027
+ }));
5028
+ // One row per (release, repo). Two GitHub writes, each CLAIMED (compare-and-set on its state)
5029
+ // BEFORE the call and NEVER retried: the annotated release tag, then the pre-release on it.
5030
+ // *_state 'pending' → 'claimed' → 'done' | 'failed'; a tag whose name already exists is
5031
+ // 'refused' (never moved); a pre-release whose tag did not land is 'skipped'.
5032
+ // ⚠ UNIQUE (account, repo, release_tag_name) across releases: a second press can never write the
5033
+ // same tag again — the claim is the row.
5034
+ export const releaseItems = sqliteTable('release_items', {
5035
+ id: integer('id').primaryKey({ autoIncrement: true }),
5036
+ accountId: integer('account_id')
5037
+ .notNull()
5038
+ .references(() => accounts.id, { onDelete: 'cascade' }),
5039
+ releaseId: integer('release_id').notNull(),
5040
+ repoId: integer('repo_id').notNull(),
5041
+ versionTag: text('version_tag').notNull(),
5042
+ sha: text('sha').notNull(),
5043
+ releaseTagName: text('release_tag_name').notNull(),
5044
+ notesMarkdown: text('notes_markdown').notNull().default(''),
5045
+ tagState: text('tag_state', { enum: ['pending', 'claimed', 'done', 'failed', 'refused'] })
5046
+ .notNull()
5047
+ .default('pending'),
5048
+ tagObjectOid: text('tag_object_oid'),
5049
+ prereleaseState: text('prerelease_state', {
5050
+ enum: ['pending', 'claimed', 'done', 'failed', 'skipped'],
5051
+ })
5052
+ .notNull()
5053
+ .default('pending'),
5054
+ githubReleaseId: integer('github_release_id'),
5055
+ githubReleaseUrl: text('github_release_url'),
5056
+ error: text('error'),
5057
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
5058
+ }, (t) => ({
5059
+ releaseRepoUx: uniqueIndex('release_items_account_release_repo').on(t.accountId, t.releaseId, t.repoId),
5060
+ // ⚠ The tag-name UNIQUE is PARTIAL — only a live row (pending / claimed / done) holds the name, so
5061
+ // a failed or refused write does not block a later press. It lives in migration 0197 ONLY
5062
+ // (`release_items_account_repo_tag_live`); this is its plain lookup index.
5063
+ tagNameIdx: index('release_items_account_repo_tag_idx').on(t.accountId, t.repoId, t.releaseTagName),
5064
+ releaseAccountFk: foreignKey({
5065
+ name: 'release_items_release_account_fk',
5066
+ columns: [t.releaseId, t.accountId],
5067
+ foreignColumns: [releases.id, releases.accountId],
5068
+ }).onDelete('cascade'),
5069
+ repoAccountFk: foreignKey({
5070
+ name: 'release_items_repo_account_fk',
5071
+ columns: [t.repoId, t.accountId],
5072
+ foreignColumns: [repos.id, repos.accountId],
5073
+ }).onDelete('cascade'),
5074
+ }));
5075
+ // One row per (release, ticket): the tracker comment on a shipped ticket (a ticket whose repos ALL
5076
+ // tagged). CLAIMED before the tracker write, NEVER retried.
5077
+ // state 'pending' → 'claimed' → 'posted' | 'failed'; 'skipped' when the ticket did not ship
5078
+ // whole or the tracker cannot take a comment yet (`error` says which).
5079
+ export const releaseTicketComments = sqliteTable('release_ticket_comments', {
5080
+ id: integer('id').primaryKey({ autoIncrement: true }),
5081
+ accountId: integer('account_id')
5082
+ .notNull()
5083
+ .references(() => accounts.id, { onDelete: 'cascade' }),
5084
+ releaseId: integer('release_id').notNull(),
5085
+ ticketKey: text('ticket_key').notNull(),
5086
+ trackerKind: text('tracker_kind'),
5087
+ state: text('state', { enum: ['pending', 'claimed', 'posted', 'failed', 'skipped'] })
5088
+ .notNull()
5089
+ .default('pending'),
5090
+ commentId: text('comment_id'),
5091
+ error: text('error'),
5092
+ claimedAt: integer('claimed_at', { mode: 'timestamp' }),
5093
+ postedAt: integer('posted_at', { mode: 'timestamp' }),
5094
+ }, (t) => ({
5095
+ releaseTicketUx: uniqueIndex('release_ticket_comments_account_release_ticket').on(t.accountId, t.releaseId, t.ticketKey),
5096
+ releaseAccountFk: foreignKey({
5097
+ name: 'release_ticket_comments_release_account_fk',
5098
+ columns: [t.releaseId, t.accountId],
5099
+ foreignColumns: [releases.id, releases.accountId],
5100
+ }).onDelete('cascade'),
5101
+ }));
4856
5102
  //# sourceMappingURL=schema.sqlite.js.map
@@ -964,6 +964,73 @@ async function setReactionNow(token, subjectNodeId, content, add) {
964
964
  viewerCanReact: subject.viewerCanReact === true,
965
965
  };
966
966
  }
967
+ // ---- Release tags and pre-releases (docs/RELEASES.md § Releases tab 6) ----
968
+ //
969
+ // The READER's Confirm (releases/create.ts) — the caller claims each write first and never retries.
970
+ // `createAnnotatedReleaseTag` makes the annotated tag object on the commit, then its ref; it NEVER moves
971
+ // a ref (no PATCH): a 422 on the ref (the name exists) is reported as `exists`. Errors carry GitHub's
972
+ // `status` (ghRest), which the caller turns into a short sentence.
973
+ export async function createAnnotatedReleaseTag(token, args, origin) {
974
+ return limnWrite(origin, 'release_tag', {}, async () => {
975
+ const seg = encodeURIComponent;
976
+ const obj = await ghRestPostFor(token, `/repos/${seg(args.owner)}/${seg(args.name)}/git/tags`, {
977
+ tag: args.tag,
978
+ message: args.message,
979
+ object: args.sha,
980
+ type: 'commit',
981
+ });
982
+ if (typeof obj.sha !== 'string')
983
+ throw Object.assign(new Error('GitHub returned no tag object'), { status: 422, stage: 'object' });
984
+ try {
985
+ await ghRestPostFor(token, `/repos/${seg(args.owner)}/${seg(args.name)}/git/refs`, { ref: `refs/tags/${args.tag}`, sha: obj.sha });
986
+ }
987
+ catch (err) {
988
+ throw Object.assign(err instanceof Error ? err : new Error(String(err)), { stage: 'ref' });
989
+ }
990
+ return { tagObjectOid: obj.sha };
991
+ }, () => ({ detail: args.tag }));
992
+ }
993
+ // HOTFIX (docs/STORY-BUILD.md § Hotfix builds): `hotfix/<tag>` made at the release commit, on the reader's Push.
994
+ // A NEW ref only (POST git/refs): it never moves one. GitHub's 422 ("Reference already exists") comes back as
995
+ // `exists`, so the caller re-reads the branch instead of guessing.
996
+ export async function createHotfixBranch(token, args, origin) {
997
+ return limnWrite(origin, 'hotfix_branch', {}, async () => {
998
+ const seg = encodeURIComponent;
999
+ try {
1000
+ await ghRestPostFor(token, `/repos/${seg(args.owner)}/${seg(args.name)}/git/refs`, { ref: `refs/heads/${args.branch}`, sha: args.sha });
1001
+ return { outcome: 'created' };
1002
+ }
1003
+ catch (err) {
1004
+ if (err?.status === 422)
1005
+ return { outcome: 'exists' };
1006
+ throw err;
1007
+ }
1008
+ }, () => ({ detail: args.branch }));
1009
+ }
1010
+ /** The OPEN pull requests whose base is `branch` (a hotfix branch that moved past its release: the tab names
1011
+ * them). A read; at most 30, newest first. Throws on a failed read. */
1012
+ export async function fetchOpenPrsOnBase(token, owner, name, branch) {
1013
+ const seg = encodeURIComponent;
1014
+ const rows = await ghRestGetFor(token, `/repos/${seg(owner)}/${seg(name)}/pulls?state=open&base=${seg(branch)}&sort=created&direction=desc&per_page=30`);
1015
+ return (Array.isArray(rows) ? rows : [])
1016
+ .filter((p) => typeof p.number === 'number')
1017
+ .map((p) => ({ number: p.number, title: typeof p.title === 'string' ? p.title : '', url: typeof p.html_url === 'string' ? p.html_url : null }));
1018
+ }
1019
+ export async function createGithubPrerelease(token, args, origin) {
1020
+ return limnWrite(origin, 'prerelease', {}, async () => {
1021
+ const seg = encodeURIComponent;
1022
+ const r = await ghRestPostFor(token, `/repos/${seg(args.owner)}/${seg(args.name)}/releases`, {
1023
+ tag_name: args.tag,
1024
+ name: args.title,
1025
+ body: args.body,
1026
+ prerelease: true,
1027
+ draft: false,
1028
+ });
1029
+ if (typeof r.id !== 'number' || typeof r.html_url !== 'string')
1030
+ throw Object.assign(new Error('GitHub returned no release'), { status: 422 });
1031
+ return { id: r.id, url: r.html_url };
1032
+ }, (r) => ({ detail: r.url }));
1033
+ }
967
1034
  // ---- Git data writes (the Implement build claim's placeholder branch) ----
968
1035
  // Raw git-data REST writes live here, with the other guarded writes (write-origin.test.ts). The
969
1036
  // placeholder belongs to the reader's Implement build, so its caller passes the reader's origin.
@@ -141,6 +141,11 @@ const PR_NODE_FIELDS = /* GraphQL */ `
141
141
  # another open PR's head branch in THIS repo, and a fork's branch name says nothing about a branch
142
142
  # here — so a parent must be proven same-repo. A Boolean! scalar on a node already fetched.
143
143
  isCrossRepository
144
+ # The commit this PR landed as (docs/RELEASES.md): a version tag's range maps commits to PRs
145
+ # through it. A nullable OBJECT, not a connection — no nodes, no cost.
146
+ mergeCommit {
147
+ oid
148
+ }
144
149
  mergeable
145
150
  mergeStateStatus
146
151
  # ---- merge verdict (cluster A: the merge surface) ------------------------------------
@@ -4,7 +4,8 @@
4
4
  // enough to tell whether our stored row is still right about the things GitHub changes WITHOUT
5
5
  // bumping `updatedAt`: the head commit, its CI rollup, the review-thread resolutions — plus
6
6
  // `updatedAt` itself, so a PR the walk missed is noticed too, and the default branch's head, so a
7
- // trunk move (which silently re-decides every open PR's merge state) is noticed.
7
+ // trunk move (which silently re-decides every open PR's merge state) is noticed, and the newest
8
+ // tag + tag count (`refs(first: 1)`, one node), so a tag push is noticed (sync/tags-step.ts).
8
9
  //
9
10
  // COST — MEASURED 2026-10-28 (`rateLimit { cost }`, read-only, on a real 9-repo workspace): 9
10
11
  // points for 9 aliased repos, i.e. ~1 point per repo. GitHub prices a query by the requests its
@@ -65,6 +66,15 @@ export function buildSettleSweepQuery(count) {
65
66
  oid
66
67
  }
67
68
  }
69
+ tags: refs(refPrefix: "refs/tags/", first: 1, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
70
+ totalCount
71
+ nodes {
72
+ name
73
+ target {
74
+ oid
75
+ }
76
+ }
77
+ }
68
78
  pullRequests(
69
79
  states: [OPEN]
70
80
  first: ${SETTLE_PR_PAGE}
@@ -0,0 +1,101 @@
1
+ // TAGS (docs/RELEASES.md) — READ only: the repo's tags (GraphQL), the commits between two commits
2
+ // (REST compare), and the PRs a handful of commits came from (GraphQL, the fallback for a commit
3
+ // no stored PR's `merge_commit_oid` names).
4
+ //
5
+ // COST.
6
+ // • REPO_TAGS_QUERY: one `refs(first: 100)` connection with no child connection ⇒ 1 point.
7
+ // • The settle sweep adds `refs(first: 1)` per repo to its own query (github/settle-query.ts):
8
+ // one node, no measurable change to its ~1 point per repo.
9
+ // • compareCommits: REST (the core REST budget, not GraphQL points), one call per page of 100
10
+ // commits; ancestry asks for ONE commit (per_page=1).
11
+ // • buildCommitPrsQuery: ≤ COMMIT_PRS_ALIAS_CAP aliases × associatedPullRequests(first: 3) ⇒
12
+ // ≤ 150 nodes ⇒ 2 points.
13
+ //
14
+ // Nullability: everything a tolerant partial (`graphqlTolerant`) can null is optional below.
15
+ import { ghRestGetFor, withGithubRetry } from './client.js';
16
+ /** Tags read per sync, newest by the tagged commit's date. Past this the read is incomplete. */
17
+ export const REPO_TAGS_READ_CAP = 100;
18
+ export const REPO_TAGS_QUERY = /* GraphQL */ `
19
+ query RepoTags($owner: String!, $name: String!, $first: Int!) {
20
+ rateLimit {
21
+ cost
22
+ remaining
23
+ resetAt
24
+ }
25
+ repository(owner: $owner, name: $name) {
26
+ defaultBranchRef {
27
+ name
28
+ target {
29
+ oid
30
+ }
31
+ }
32
+ refs(refPrefix: "refs/tags/", first: $first, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
33
+ totalCount
34
+ nodes {
35
+ name
36
+ target {
37
+ __typename
38
+ oid
39
+ ... on Commit {
40
+ committedDate
41
+ }
42
+ ... on Tag {
43
+ tagger {
44
+ date
45
+ }
46
+ target {
47
+ __typename
48
+ oid
49
+ ... on Commit {
50
+ committedDate
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ `;
60
+ // ---- REST compare -------------------------------------------------------------------------------
61
+ const SHA_RE = /^[0-9a-fA-F]{7,64}$/;
62
+ /** One page of `GET /repos/{owner}/{name}/compare/{base}...{head}`. Throws as ghRestGetFor does. */
63
+ export async function fetchComparePage(token, args) {
64
+ if (!SHA_RE.test(args.base) || !SHA_RE.test(args.head))
65
+ throw new Error('compare: not a commit sha');
66
+ const path = `/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.name)}` +
67
+ `/compare/${args.base}...${args.head}?per_page=${args.perPage}&page=${args.page}`;
68
+ return withGithubRetry(() => ghRestGetFor(token, path));
69
+ }
70
+ // ---- The commit → PR fallback -------------------------------------------------------------------
71
+ /** Commits asked about in one request. */
72
+ export const COMMIT_PRS_ALIAS_CAP = 50;
73
+ /** One aliased `object(oid:)` per commit sha; the shas travel as VARIABLES (`s<i>`). */
74
+ export function buildCommitPrsQuery(count) {
75
+ const vars = Array.from({ length: count }, (_, i) => `$s${i}: GitObjectID!`).join(', ');
76
+ const aliases = Array.from({ length: count }, (_, i) => ` c${i}: object(oid: $s${i}) {
77
+ ... on Commit {
78
+ associatedPullRequests(first: 3) {
79
+ nodes {
80
+ number
81
+ merged
82
+ baseRefName
83
+ repository {
84
+ nameWithOwner
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }`).join('\n');
90
+ return `query CommitPrs($owner: String!, $name: String!${count > 0 ? `, ${vars}` : ''}) {
91
+ rateLimit {
92
+ cost
93
+ remaining
94
+ resetAt
95
+ }
96
+ repository(owner: $owner, name: $name) {
97
+ ${aliases}
98
+ }
99
+ }`;
100
+ }
101
+ //# sourceMappingURL=tag-queries.js.map