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
@@ -1366,6 +1366,34 @@
1366
1366
  "when": 1793520000000,
1367
1367
  "tag": "0194_story_build_claims",
1368
1368
  "breakpoints": true
1369
+ },
1370
+ {
1371
+ "idx": 195,
1372
+ "version": "6",
1373
+ "when": 1793606400000,
1374
+ "tag": "0195_releases",
1375
+ "breakpoints": true
1376
+ },
1377
+ {
1378
+ "idx": 196,
1379
+ "version": "6",
1380
+ "when": 1793692800000,
1381
+ "tag": "0196_story_build_hotfix",
1382
+ "breakpoints": true
1383
+ },
1384
+ {
1385
+ "idx": 197,
1386
+ "version": "6",
1387
+ "when": 1793779200000,
1388
+ "tag": "0197_release_items_live_tag",
1389
+ "breakpoints": true
1390
+ },
1391
+ {
1392
+ "idx": 198,
1393
+ "version": "6",
1394
+ "when": 1793865600000,
1395
+ "tag": "0198_story_build_port_report",
1396
+ "breakpoints": true
1369
1397
  }
1370
1398
  ]
1371
1399
  }
@@ -491,6 +491,13 @@ export async function deleteWorkspace(id, accountId) {
491
491
  // 1b. EXTRA External memberships (migration 0191). The deleted workspace's own rows go (the
492
492
  // FK cascades too); a repo re-homed to Default by step 1 loses any extra row it had THERE,
493
493
  // because a repo is never External in its own home.
494
+ // RELEASES (migration 0195): the workspace's releases follow its repos to Default — a release is
495
+ // history of those repos' tags, and the cascade would delete it.
496
+ await tx
497
+ .update(schema.releases)
498
+ .set({ workspaceId: defaultId })
499
+ .where(and(eq(schema.releases.accountId, accountId), eq(schema.releases.workspaceId, id)))
500
+ .execute();
494
501
  await tx
495
502
  .delete(workspaceExternalRepos)
496
503
  .where(and(eq(workspaceExternalRepos.accountId, accountId), eq(workspaceExternalRepos.workspaceId, id)))
@@ -8205,6 +8212,20 @@ export async function deleteRepo(id, accountId) {
8205
8212
  // nothing else, but a surviving row would keep this repo's file paths after the repo is gone
8206
8213
  // AND, on Postgres, block the `repos` delete outright.
8207
8214
  await tx.delete(schema.repoFileCoupling).where(eq(schema.repoFileCoupling.repoId, id)).execute();
8215
+ // RELEASES (migration 0195): the repo's tags, their ranges and its release items (the release
8216
+ // rows themselves are the workspace's and stay). Their repo FKs cascade; explicit all the same.
8217
+ await tx
8218
+ .delete(schema.repoTagRanges)
8219
+ .where(and(eq(schema.repoTagRanges.repoId, id), eq(schema.repoTagRanges.accountId, accountId)))
8220
+ .execute();
8221
+ await tx
8222
+ .delete(schema.repoTags)
8223
+ .where(and(eq(schema.repoTags.repoId, id), eq(schema.repoTags.accountId, accountId)))
8224
+ .execute();
8225
+ await tx
8226
+ .delete(schema.releaseItems)
8227
+ .where(and(eq(schema.releaseItems.repoId, id), eq(schema.releaseItems.accountId, accountId)))
8228
+ .execute();
8208
8229
  // Docs folders offered from this repo (migration 0155). The composite FK cascades; explicit
8209
8230
  // anyway, for the same stated reason as the rows above.
8210
8231
  await tx
@@ -0,0 +1,378 @@
1
+ // THE RELEASE CHECK'S DB HALF (docs/RELEASES.md § The release check) — gathers the stored inputs of
2
+ // shared `foldReleaseCheck` and answers GET /api/releases. DB ONLY: never a GitHub or tracker call.
3
+ //
4
+ // • Repos: the workspace's members (`resolveWorkspaceScope`), their stored tags and range increments
5
+ // (db/repo-tags.ts), `writable` from the synced `viewerPermission` (the ONE WRITE_PERMISSIONS set).
6
+ // • PRs: every PR in a stored range, plus every PR (in the workspace's repos) on a ticket one of them
7
+ // names — the ships-whole and open-work rules need the ticket's PRs everywhere.
8
+ // • Tickets: `tracker_tickets` rows ON THE WORKSPACE'S TRACKER SITE only (a row of another site is a
9
+ // ticket of a tracker the workspace no longer uses: not a link). The best row per ticket is the
10
+ // newest successful read; a fresher `sprint_tickets` row's status wins. DONE = the This Sprint
11
+ // stage resolver (`sprintStageOf` over `sprintLayoutFor`) says Complete — never a second resolver.
12
+ // A range PR with a ticket key THE detection rule finds but no stored row yet is `not_read` (blocks).
13
+ // • A 'history' range (the oldest tag) is read fresh from the stored PRs, never from its frozen row.
14
+ // • Bot-ness: `globalAutomationUserIds`, never the hidden-bots list.
15
+ // • Accepted repos: `sprint_ticket_repos` (the reader's accepted repos per ticket).
16
+ import { and, desc, eq, inArray, isNotNull, lte, or } from 'drizzle-orm';
17
+ import { foldReleaseCheck, releaseSettingsConfigured, tagKind, trackerTicketIdent, } from '../shared/index.js';
18
+ import { globalAutomationUserIds } from './automation-ids.js';
19
+ import { db, schema } from './client.js';
20
+ import { WRITE_PERMISSIONS, resolveWorkspaceScope } from './queries.js';
21
+ import { getReleaseSettings } from './release-settings.js';
22
+ import { getRepoTags, getTagRanges } from './repo-tags.js';
23
+ import { sprintLayoutFor, sprintStageOf } from './sprint-status-layout.js';
24
+ const { repos, pullRequests, trackerTickets, sprintTickets, sprintTicketRepos, releases } = schema;
25
+ const CHUNK = 400;
26
+ function chunks(xs, n = CHUNK) {
27
+ const out = [];
28
+ for (let i = 0; i < xs.length; i += n)
29
+ out.push(xs.slice(i, i + n));
30
+ return out;
31
+ }
32
+ /** Done = the workspace's Complete stage, through THE This Sprint stage resolver. With no stored
33
+ * layout (the sprint was never read) the resolver's category fallback decides (`done` → Complete,
34
+ * so "Won't do" counts). Pure. */
35
+ export function ticketIsDone(layout, statusName, statusCategory) {
36
+ if (statusName == null && statusCategory == null)
37
+ return false;
38
+ return sprintStageOf(layout ?? { order: [] }, { name: statusName ?? '', category: statusCategory }) === 'done';
39
+ }
40
+ const READ_STATES = new Set(['ok', 'not_found', 'no_access', 'failed']);
41
+ /** The ticket from its rows (one per PR): the newest successful read, else the newest attempt. Pure. */
42
+ export function bestTicketRow(rows) {
43
+ const ms = (d) => (d == null ? 0 : d.getTime());
44
+ const ok = rows.filter((r) => r.state === 'ok' && r.fetchedAt != null).sort((a, b) => ms(b.fetchedAt) - ms(a.fetchedAt));
45
+ if (ok[0] != null)
46
+ return ok[0];
47
+ return [...rows].sort((a, b) => ms(b.checkedAt) - ms(a.checkedAt))[0] ?? null;
48
+ }
49
+ /**
50
+ * THE ONE detection rule (tracker/enricher.ts `detectKeysWithAccess`: title + head branch + stored
51
+ * links) over these PRs, against the WORKSPACE's tracker: prId → keys. DB only (no tracker call). A
52
+ * key detected on a range PR with no stored row is a ticket the worker has not read yet — it BLOCKS
53
+ * (`not_read`), never passes as an untracked PR.
54
+ */
55
+ async function detectedKeysByPr(accountId, workspaceId, prIds) {
56
+ const out = new Map();
57
+ if (prIds.length === 0)
58
+ return out;
59
+ // LAZY: tracker/runtime.ts opens the database at import.
60
+ const [{ trackerContext }, { readWorkspaceTrackerRow, trackerAccessOf }, { detectKeysWithAccess, withParsedLinks, prLinkColumns }] = await Promise.all([import('../tracker/runtime.js'), import('../tracker/settings.js'), import('../tracker/enricher.js')]);
61
+ const ctx = trackerContext();
62
+ const access = trackerAccessOf(ctx.host, await readWorkspaceTrackerRow(ctx, accountId, workspaceId));
63
+ for (const part of chunks(prIds)) {
64
+ const rows = await db
65
+ .select({ id: pullRequests.id, title: pullRequests.title, headRefName: pullRequests.headRefName, ...prLinkColumns(pullRequests) })
66
+ .from(pullRequests)
67
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.id, part)))
68
+ .execute();
69
+ for (const r of rows) {
70
+ const keys = detectKeysWithAccess(access, withParsedLinks(r));
71
+ if (keys != null && keys.length > 0)
72
+ out.set(r.id, keys);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * A 'history' range (the oldest tag: no base) is read FRESH from the stored PRs, never from its frozen
79
+ * row: PRs the deep backfill brings in after the row was computed must enter it. Merged into the
80
+ * default branch no later than the tag's commit (or by the tag's own commit). prIds per tag name.
81
+ */
82
+ async function historyPrIds(accountId, repoId, defaultBranch, tag) {
83
+ const rows = await db
84
+ .select({ id: pullRequests.id })
85
+ .from(pullRequests)
86
+ .where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), eq(pullRequests.state, 'merged'), eq(pullRequests.baseRefName, defaultBranch), or(eq(pullRequests.mergeCommitOid, tag.sha), and(isNotNull(pullRequests.mergedAt), lte(pullRequests.mergedAt, tag.committedAt)))))
87
+ .execute();
88
+ return [...new Set(rows.map((r) => r.id))].sort((a, b) => a - b);
89
+ }
90
+ /** Every stored input of the fold for one workspace (the caller checked the settings are set). */
91
+ export async function loadReleaseCheckInput(accountId, workspaceId, repoIds, settings) {
92
+ const repoRows = repoIds.length === 0
93
+ ? []
94
+ : await db
95
+ .select({
96
+ id: repos.id,
97
+ owner: repos.owner,
98
+ name: repos.name,
99
+ defaultBranch: repos.defaultBranch,
100
+ defaultBranchName: repos.defaultBranchName,
101
+ viewerPermission: repos.viewerPermission,
102
+ })
103
+ .from(repos)
104
+ .where(and(eq(repos.accountId, accountId), inArray(repos.id, [...repoIds])))
105
+ .execute();
106
+ const ids = repoRows.map((r) => r.id);
107
+ const [tags, storedRanges] = await Promise.all([getRepoTags(accountId, ids), getTagRanges(accountId, ids)]);
108
+ const repoRowById = new Map(repoRows.map((r) => [r.id, r]));
109
+ const ranges = await Promise.all(storedRanges.map(async (r) => {
110
+ if (r.method !== 'history')
111
+ return r;
112
+ const tag = tags.find((t) => t.repoId === r.repoId && t.name === r.tagName);
113
+ const row = repoRowById.get(r.repoId);
114
+ const def = row?.defaultBranch ?? row?.defaultBranchName ?? null;
115
+ if (tag == null || tag.sha !== r.sha || tag.committedAt == null || def == null)
116
+ return r;
117
+ return { ...r, prIds: await historyPrIds(accountId, r.repoId, def, { sha: tag.sha, committedAt: tag.committedAt }), unresolvedPrNumbers: [] };
118
+ }));
119
+ // The workspace's tracker site (LAZY: db/stories.ts reaches the tracker runtime).
120
+ const { workspaceSite } = await import('./stories.js');
121
+ const site = await workspaceSite(accountId, workspaceId);
122
+ const rangePrIds = [...new Set(ranges.flatMap((r) => r.prIds))];
123
+ const ticketRows = [];
124
+ const detectedOnly = new Map();
125
+ const ticketSelect = {
126
+ prId: trackerTickets.prId,
127
+ issueKey: trackerTickets.issueKey,
128
+ url: trackerTickets.url,
129
+ state: trackerTickets.state,
130
+ title: trackerTickets.title,
131
+ statusName: trackerTickets.statusName,
132
+ statusCategory: trackerTickets.statusCategory,
133
+ fetchedAt: trackerTickets.fetchedAt,
134
+ checkedAt: trackerTickets.checkedAt,
135
+ };
136
+ if (site != null) {
137
+ for (const part of chunks(rangePrIds)) {
138
+ ticketRows.push(...(await db
139
+ .select(ticketSelect)
140
+ .from(trackerTickets)
141
+ .where(and(eq(trackerTickets.accountId, accountId), eq(trackerTickets.provider, site.provider), eq(trackerTickets.apiRoot, site.apiRoot), inArray(trackerTickets.prId, part)))
142
+ .execute()));
143
+ }
144
+ // Keys the detection rule finds on a range PR but no stored row answers for that PR yet.
145
+ const rowKeys = new Map();
146
+ for (const r of ticketRows)
147
+ rowKeys.set(r.prId, (rowKeys.get(r.prId) ?? new Set()).add(r.issueKey));
148
+ for (const [prId, keys] of await detectedKeysByPr(accountId, workspaceId, rangePrIds)) {
149
+ const missing = keys.filter((k) => !(rowKeys.get(prId)?.has(k) ?? false));
150
+ if (missing.length > 0)
151
+ detectedOnly.set(prId, missing);
152
+ }
153
+ // Every PR on those tickets (the ticket's other repos, its open work).
154
+ const keys = [...new Set([...ticketRows.map((r) => r.issueKey), ...[...detectedOnly.values()].flat()])];
155
+ const have = new Set(ticketRows.map((r) => `${r.prId}#${r.issueKey}`));
156
+ for (const part of chunks(keys)) {
157
+ const more = await db
158
+ .select(ticketSelect)
159
+ .from(trackerTickets)
160
+ .where(and(eq(trackerTickets.accountId, accountId), eq(trackerTickets.provider, site.provider), eq(trackerTickets.apiRoot, site.apiRoot), inArray(trackerTickets.issueKey, part)))
161
+ .execute();
162
+ for (const r of more)
163
+ if (!have.has(`${r.prId}#${r.issueKey}`))
164
+ ticketRows.push(r);
165
+ }
166
+ }
167
+ // The PRs, within the workspace's repos only.
168
+ const prIdSet = [...new Set([...rangePrIds, ...ticketRows.map((r) => r.prId)])];
169
+ const prRows = [];
170
+ if (ids.length > 0) {
171
+ for (const part of chunks(prIdSet)) {
172
+ prRows.push(...(await db
173
+ .select({
174
+ id: pullRequests.id,
175
+ repoId: pullRequests.repoId,
176
+ number: pullRequests.number,
177
+ title: pullRequests.title,
178
+ state: pullRequests.state,
179
+ baseRefName: pullRequests.baseRefName,
180
+ mergedAt: pullRequests.mergedAt,
181
+ authorId: pullRequests.authorId,
182
+ })
183
+ .from(pullRequests)
184
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, ids), inArray(pullRequests.id, part)))
185
+ .execute()));
186
+ }
187
+ }
188
+ const bots = prRows.some((p) => p.authorId != null) ? await globalAutomationUserIds() : new Set();
189
+ const identOf = (issueKey) => trackerTicketIdent(site.provider, site.apiRoot, issueKey);
190
+ const identsByPr = new Map();
191
+ const rowsByIdent = new Map();
192
+ for (const r of ticketRows) {
193
+ const ident = identOf(r.issueKey);
194
+ const list = identsByPr.get(r.prId) ?? [];
195
+ if (!list.includes(ident))
196
+ list.push(ident);
197
+ identsByPr.set(r.prId, list);
198
+ rowsByIdent.set(ident, [...(rowsByIdent.get(ident) ?? []), r]);
199
+ }
200
+ /** Detected on a range PR, no stored row anywhere on the site: ident → key (read state `not_read`). */
201
+ const unreadKeys = new Map();
202
+ for (const [prId, keys] of detectedOnly) {
203
+ for (const key of keys) {
204
+ const ident = identOf(key);
205
+ const list = identsByPr.get(prId) ?? [];
206
+ if (!list.includes(ident))
207
+ list.push(ident);
208
+ identsByPr.set(prId, list);
209
+ if (!rowsByIdent.has(ident))
210
+ unreadKeys.set(ident, key);
211
+ }
212
+ }
213
+ const repoById = new Map(repoRows.map((r) => [r.id, r]));
214
+ const defaultOf = (repoId) => {
215
+ const r = repoById.get(repoId);
216
+ return r?.defaultBranch ?? r?.defaultBranchName ?? null;
217
+ };
218
+ const prs = prRows.map((p) => {
219
+ const r = repoById.get(p.repoId);
220
+ const def = defaultOf(p.repoId);
221
+ return {
222
+ id: p.id,
223
+ repoId: p.repoId,
224
+ number: p.number,
225
+ title: p.title,
226
+ url: `https://github.com/${r.owner}/${r.name}/pull/${p.number}`,
227
+ isBot: p.authorId != null && bots.has(p.authorId),
228
+ intoDefaultBranch: def != null && p.baseRefName === def,
229
+ state: p.state,
230
+ mergedAt: p.mergedAt,
231
+ ticketIdents: identsByPr.get(p.id) ?? [],
232
+ };
233
+ });
234
+ // The tickets: best stored read, a fresher sprint read's status wins, Done by the stage resolver.
235
+ const idents = [...rowsByIdent.keys(), ...unreadKeys.keys()];
236
+ const sprintByIdent = new Map();
237
+ const accepted = [];
238
+ for (const part of chunks(idents)) {
239
+ const [srows, arows] = await Promise.all([
240
+ db
241
+ .select({
242
+ ident: sprintTickets.ident,
243
+ statusName: sprintTickets.statusName,
244
+ statusCategory: sprintTickets.statusCategory,
245
+ fetchedAt: sprintTickets.fetchedAt,
246
+ title: sprintTickets.title,
247
+ url: sprintTickets.url,
248
+ })
249
+ .from(sprintTickets)
250
+ .where(and(eq(sprintTickets.accountId, accountId), eq(sprintTickets.workspaceId, workspaceId), inArray(sprintTickets.ident, part)))
251
+ .execute(),
252
+ db
253
+ .select({ ident: sprintTicketRepos.ident, repoId: sprintTicketRepos.repoId })
254
+ .from(sprintTicketRepos)
255
+ .where(and(eq(sprintTicketRepos.accountId, accountId), eq(sprintTicketRepos.workspaceId, workspaceId), inArray(sprintTicketRepos.ident, part)))
256
+ .execute(),
257
+ ]);
258
+ for (const s of srows)
259
+ sprintByIdent.set(s.ident, s);
260
+ accepted.push(...arows.filter((a) => repoById.has(a.repoId)));
261
+ }
262
+ const layout = idents.length > 0 ? await sprintLayoutFor(accountId, workspaceId) : null;
263
+ const tickets = idents.map((ident) => {
264
+ const unreadKey = unreadKeys.get(ident);
265
+ if (unreadKey != null) {
266
+ // A sprint read of it is a real read of the ticket; with none, it is not read yet (blocks).
267
+ const sprint = sprintByIdent.get(ident);
268
+ if (sprint == null)
269
+ return { ident, key: unreadKey, title: null, url: null, statusName: null, done: false, readState: 'not_read' };
270
+ return {
271
+ ident,
272
+ key: unreadKey,
273
+ title: sprint.title,
274
+ url: sprint.url,
275
+ statusName: sprint.statusName,
276
+ done: ticketIsDone(layout, sprint.statusName, sprint.statusCategory),
277
+ readState: 'ok',
278
+ };
279
+ }
280
+ const rows = rowsByIdent.get(ident);
281
+ const best = bestTicketRow(rows);
282
+ const readState = best.state === 'ok' && best.fetchedAt != null
283
+ ? 'ok'
284
+ : READ_STATES.has(best.state) && best.state !== 'ok'
285
+ ? best.state
286
+ : 'not_read';
287
+ const sprint = sprintByIdent.get(ident);
288
+ const useSprint = sprint != null && (best.fetchedAt == null || sprint.fetchedAt.getTime() > best.fetchedAt.getTime());
289
+ const statusName = useSprint ? sprint.statusName : best.statusName;
290
+ const statusCategory = useSprint ? sprint.statusCategory : best.statusCategory;
291
+ return {
292
+ ident,
293
+ key: best.issueKey,
294
+ title: best.title ?? sprint?.title ?? null,
295
+ url: best.url ?? sprint?.url ?? null,
296
+ statusName,
297
+ done: readState === 'ok' && ticketIsDone(layout, statusName, statusCategory),
298
+ readState,
299
+ };
300
+ });
301
+ const tagsByRepo = new Map();
302
+ for (const t of tags)
303
+ tagsByRepo.set(t.repoId, [...(tagsByRepo.get(t.repoId) ?? []), t]);
304
+ const rangesByRepo = new Map();
305
+ for (const r of ranges)
306
+ rangesByRepo.set(r.repoId, [...(rangesByRepo.get(r.repoId) ?? []), r]);
307
+ return {
308
+ settings,
309
+ repos: [...repoRows]
310
+ .sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id)
311
+ .map((r) => ({
312
+ repoId: r.id,
313
+ fullName: `${r.owner}/${r.name}`,
314
+ name: r.name,
315
+ writable: WRITE_PERMISSIONS.has(r.viewerPermission ?? ''),
316
+ tags: (tagsByRepo.get(r.id) ?? []).map((t) => ({
317
+ name: t.name,
318
+ sha: t.sha,
319
+ committedAt: t.committedAt,
320
+ taggedAt: t.taggedAt,
321
+ onDefaultBranch: t.onDefaultBranch,
322
+ moved: t.movedAt != null,
323
+ })),
324
+ ranges: (rangesByRepo.get(r.id) ?? []).map((x) => ({
325
+ tagName: x.tagName,
326
+ sha: x.sha,
327
+ baseTagName: x.baseTagName,
328
+ baseSha: x.baseSha,
329
+ method: x.method,
330
+ prIds: x.prIds,
331
+ unresolvedPrNumbers: x.unresolvedPrNumbers,
332
+ unmatchedCommits: x.unmatchedCommits,
333
+ truncated: x.truncated,
334
+ })),
335
+ })),
336
+ prs,
337
+ tickets,
338
+ acceptedRepos: accepted,
339
+ };
340
+ }
341
+ /** GET /api/releases. `rawWorkspace` absent / unknown / unparseable ⇒ the Default workspace. */
342
+ export async function getReleases(accountId, rawWorkspace) {
343
+ const scope = await resolveWorkspaceScope(accountId, rawWorkspace);
344
+ const workspaceId = scope.workspaceId;
345
+ const settings = (await getReleaseSettings(accountId, workspaceId)) ?? { releaseSuffix: null, hotfixSuffix: null };
346
+ const latest = (await db
347
+ .select({ id: releases.id, createdAt: releases.createdAt, state: releases.state })
348
+ .from(releases)
349
+ .where(and(eq(releases.accountId, accountId), eq(releases.workspaceId, workspaceId)))
350
+ .orderBy(desc(releases.createdAt), desc(releases.id))
351
+ .limit(1)
352
+ .execute())[0];
353
+ const base = {
354
+ workspaceId,
355
+ configured: releaseSettingsConfigured(settings),
356
+ releaseSuffix: settings.releaseSuffix,
357
+ hotfixSuffix: settings.hotfixSuffix,
358
+ latestRelease: latest != null ? { id: latest.id, createdAt: latest.createdAt.toISOString(), state: latest.state } : null,
359
+ };
360
+ if (!base.configured)
361
+ return { ...base, latestReleaseTagAt: null, check: null };
362
+ const input = await loadReleaseCheckInput(accountId, workspaceId, scope.repoIds, settings);
363
+ let latestTagMs = null;
364
+ for (const r of input.repos) {
365
+ for (const t of r.tags) {
366
+ const k = tagKind(t.name, settings);
367
+ if ((k === 'release' || k === 'hotfix') && t.committedAt != null) {
368
+ latestTagMs = Math.max(latestTagMs ?? 0, t.committedAt.getTime());
369
+ }
370
+ }
371
+ }
372
+ return {
373
+ ...base,
374
+ latestReleaseTagAt: latestTagMs == null ? null : new Date(latestTagMs).toISOString(),
375
+ check: foldReleaseCheck(input),
376
+ };
377
+ }
378
+ //# sourceMappingURL=release-check.js.map
@@ -0,0 +1,94 @@
1
+ // RELEASE SETTINGS (docs/RELEASES.md § Terms and settings) — the ONE reader and writer of
2
+ // `workspaces.release_suffix` / `workspaces.hotfix_suffix` (migration 0195). Per workspace, no
3
+ // inheritance, NO default: null until the reader sets one. Callers validate with shared
4
+ // `validateReleaseSettings` first (the route does; the Settings form runs the same function).
5
+ import { and, eq, inArray } from 'drizzle-orm';
6
+ import { normaliseReleaseSuffix, releaseSettingsConfigured, } from '../shared/index.js';
7
+ import { db, schema } from './client.js';
8
+ const { workspaces, workspaceRepos, repoTags, releases } = schema;
9
+ /** The workspace's settings, RAW (null = not set); null when the workspace is not this account's. */
10
+ export async function getReleaseSettings(accountId, workspaceId) {
11
+ const row = (await db
12
+ .select({ releaseSuffix: workspaces.releaseSuffix, hotfixSuffix: workspaces.hotfixSuffix })
13
+ .from(workspaces)
14
+ .where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
15
+ .limit(1)
16
+ .execute())[0];
17
+ if (!row)
18
+ return null;
19
+ return {
20
+ releaseSuffix: normaliseReleaseSuffix(row.releaseSuffix),
21
+ hotfixSuffix: normaliseReleaseSuffix(row.hotfixSuffix),
22
+ };
23
+ }
24
+ /** Every workspace's settings of this account that has BOTH suffixes set (the range planner keeps
25
+ * every range after the oldest baseline any of them gives). */
26
+ export async function configuredReleaseSettings(accountId) {
27
+ const rows = await db
28
+ .select({ releaseSuffix: workspaces.releaseSuffix, hotfixSuffix: workspaces.hotfixSuffix })
29
+ .from(workspaces)
30
+ .where(eq(workspaces.accountId, accountId))
31
+ .execute();
32
+ return rows
33
+ .map((r) => ({ releaseSuffix: normaliseReleaseSuffix(r.releaseSuffix), hotfixSuffix: normaliseReleaseSuffix(r.hotfixSuffix) }))
34
+ .filter(releaseSettingsConfigured);
35
+ }
36
+ /**
37
+ * Replace both suffixes (an empty string stores NULL). Returns false when the workspace is not this
38
+ * account's. Does NOT validate — the caller already ran `validateReleaseSettings`.
39
+ */
40
+ export async function setReleaseSettings(accountId, workspaceId, settings) {
41
+ const rows = await db
42
+ .update(workspaces)
43
+ .set({
44
+ releaseSuffix: normaliseReleaseSuffix(settings.releaseSuffix),
45
+ hotfixSuffix: normaliseReleaseSuffix(settings.hotfixSuffix),
46
+ })
47
+ .where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
48
+ .returning({ id: workspaces.id })
49
+ .execute();
50
+ return rows.length > 0;
51
+ }
52
+ /**
53
+ * A release already exists for this workspace: a release row, or a stored tag in one of its repos
54
+ * that ends in the CURRENT release suffix (a team may have tagged by hand before Ensemble). The Settings
55
+ * form warns before a change. DB only.
56
+ */
57
+ export async function workspaceHasReleases(accountId, workspaceId, releaseSuffix) {
58
+ const made = await db
59
+ .select({ id: releases.id })
60
+ .from(releases)
61
+ .where(and(eq(releases.accountId, accountId), eq(releases.workspaceId, workspaceId)))
62
+ .limit(1)
63
+ .execute();
64
+ if (made.length > 0)
65
+ return true;
66
+ if (releaseSuffix == null)
67
+ return false;
68
+ const repoIds = (await db
69
+ .select({ repoId: workspaceRepos.repoId })
70
+ .from(workspaceRepos)
71
+ .where(and(eq(workspaceRepos.accountId, accountId), eq(workspaceRepos.workspaceId, workspaceId)))
72
+ .execute()).map((r) => r.repoId);
73
+ if (repoIds.length === 0)
74
+ return false;
75
+ const names = await db
76
+ .select({ name: repoTags.name })
77
+ .from(repoTags)
78
+ .where(and(eq(repoTags.accountId, accountId), inArray(repoTags.repoId, repoIds)))
79
+ .execute();
80
+ return names.some((n) => n.name.length > releaseSuffix.length && n.name.endsWith(releaseSuffix));
81
+ }
82
+ /** The wire shape for GET / PUT /api/workspaces/:id/release-settings; null = not this account's. */
83
+ export async function getReleaseSettingsResponse(accountId, workspaceId) {
84
+ const settings = await getReleaseSettings(accountId, workspaceId);
85
+ if (!settings)
86
+ return null;
87
+ return {
88
+ workspaceId,
89
+ ...settings,
90
+ configured: releaseSettingsConfigured(settings),
91
+ hasReleases: await workspaceHasReleases(accountId, workspaceId, settings.releaseSuffix),
92
+ };
93
+ }
94
+ //# sourceMappingURL=release-settings.js.map
@@ -0,0 +1,82 @@
1
+ // RELEASES' READS of the synced tags and tag ranges (docs/RELEASES.md) — DB only, account-scoped.
2
+ // The release check (a pure fold, next to the Releases routes) builds on these two:
3
+ // getRepoTags(accountId, repoIds) every stored tag of those repos
4
+ // getTagRanges(accountId, repoIds) every stored range increment, PR NUMBERS resolved to local
5
+ // PR ids within (account, repo) — a number not stored yet is
6
+ // kept in `unresolvedPrNumbers`, never dropped.
7
+ // The writers are sync/repo-tags.ts and sync/tag-ranges.ts.
8
+ import { and, eq, inArray } from 'drizzle-orm';
9
+ import { db, schema } from './client.js';
10
+ const { repoTags, repoTagRanges, pullRequests } = schema;
11
+ export async function getRepoTags(accountId, repoIds) {
12
+ if (repoIds.length === 0)
13
+ return [];
14
+ const rows = await db
15
+ .select({
16
+ repoId: repoTags.repoId,
17
+ name: repoTags.name,
18
+ sha: repoTags.sha,
19
+ tagObjectOid: repoTags.tagObjectOid,
20
+ committedAt: repoTags.committedAt,
21
+ taggedAt: repoTags.taggedAt,
22
+ onDefaultBranch: repoTags.onDefaultBranch,
23
+ previousSha: repoTags.previousSha,
24
+ movedAt: repoTags.movedAt,
25
+ firstSeenAt: repoTags.firstSeenAt,
26
+ })
27
+ .from(repoTags)
28
+ .where(and(eq(repoTags.accountId, accountId), inArray(repoTags.repoId, [...repoIds])))
29
+ .execute();
30
+ return rows.map(({ tagObjectOid, ...r }) => ({ ...r, annotated: tagObjectOid != null }));
31
+ }
32
+ export async function getTagRanges(accountId, repoIds) {
33
+ if (repoIds.length === 0)
34
+ return [];
35
+ const rows = await db
36
+ .select()
37
+ .from(repoTagRanges)
38
+ .where(and(eq(repoTagRanges.accountId, accountId), inArray(repoTagRanges.repoId, [...repoIds])))
39
+ .execute();
40
+ if (rows.length === 0)
41
+ return [];
42
+ // number → id, per repo. ONE read of the repos' PR numbers (indexed on account, repo, number).
43
+ const wanted = [...new Set(rows.flatMap((r) => r.prNumbers ?? []))];
44
+ const idByKey = new Map();
45
+ for (let i = 0; i < wanted.length; i += 400) {
46
+ const part = wanted.slice(i, i + 400);
47
+ const prs = await db
48
+ .select({ id: pullRequests.id, repoId: pullRequests.repoId, number: pullRequests.number })
49
+ .from(pullRequests)
50
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, [...repoIds]), inArray(pullRequests.number, part)))
51
+ .execute();
52
+ for (const p of prs)
53
+ idByKey.set(`${p.repoId}#${p.number}`, p.id);
54
+ }
55
+ return rows.map((r) => {
56
+ const prIds = [];
57
+ const unresolvedPrNumbers = [];
58
+ for (const n of r.prNumbers ?? []) {
59
+ const id = idByKey.get(`${r.repoId}#${n}`);
60
+ if (id != null)
61
+ prIds.push(id);
62
+ else
63
+ unresolvedPrNumbers.push(n);
64
+ }
65
+ return {
66
+ repoId: r.repoId,
67
+ tagName: r.tagName,
68
+ sha: r.sha,
69
+ baseTagName: r.baseTagName,
70
+ baseSha: r.baseSha,
71
+ method: r.method,
72
+ prIds,
73
+ unresolvedPrNumbers,
74
+ commitCount: r.commitCount,
75
+ unmatchedCommits: r.unmatchedCommits,
76
+ truncated: r.truncated,
77
+ movedFromSha: r.movedFromSha,
78
+ computedAt: r.computedAt,
79
+ };
80
+ });
81
+ }
82
+ //# sourceMappingURL=repo-tags.js.map