@bevel-software/platform-core-backend 0.6.0 → 0.7.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.
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { configureBranchModel } from '@bevel-software/platform-shared';
3
+ import type { GitService } from '../git/git.service.js';
4
+ import type { PullRequestService } from '../git/pull-request.service.js';
5
+ import type { IReviewWorkflowService } from '../review-workflow/review-workflow.interface.js';
6
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import type { IAccessControl } from '../../access/access-control.interface.js';
8
+ import type { FileLockService } from '../file-lock.service.js';
9
+ import type { PendingCommitsService } from '../pending-commits.service.js';
10
+ import type { Database } from '../../database/connection.js';
11
+ import { WorkflowService } from '../workflow.service.js';
12
+
13
+ /**
14
+ * `closeChangeRequestsWithDeletedBranches` — the boot sweep that retires
15
+ * change requests whose branch is gone.
16
+ *
17
+ * Everything here is about NOT closing the wrong thing. A change request is
18
+ * somebody's proposal and the row is the only surviving evidence of it once
19
+ * the branch has been deleted, so the interesting cases are the ones where
20
+ * the sweep must keep its hands off.
21
+ */
22
+
23
+ interface OpenRow {
24
+ number: number;
25
+ sourceBranch: string;
26
+ }
27
+
28
+ /**
29
+ * DB stub: `select().from().where()` resolves the open rows, and
30
+ * `update().set().where().returning()` records each close and reports one
31
+ * affected row (the guarded update succeeding).
32
+ */
33
+ function makeDb(open: OpenRow[], closedOut: number[]) {
34
+ const db = {
35
+ select: () => ({ from: () => ({ where: async () => open }) }),
36
+ update: () => ({
37
+ set: () => ({
38
+ where: (cond: unknown) => ({
39
+ returning: async () => {
40
+ // The number is not recoverable from the drizzle condition object,
41
+ // so the caller records it via the `onClose` hook below instead.
42
+ void cond;
43
+ return [{ id: 'row' }];
44
+ },
45
+ }),
46
+ }),
47
+ }),
48
+ };
49
+ void closedOut;
50
+ return db as unknown as Database;
51
+ }
52
+
53
+ function makeService(open: OpenRow[], listBranches: () => Promise<{ name: string }[]>) {
54
+ const invalidated: number[] = [];
55
+ const emitted: unknown[] = [];
56
+ const git = { listBranches: vi.fn(listBranches) } as unknown as GitService;
57
+ const prs = {
58
+ invalidateDetailCache: vi.fn((n: number) => invalidated.push(n)),
59
+ } as unknown as PullRequestService;
60
+ const svc = new WorkflowService(
61
+ makeDb(open, []),
62
+ git,
63
+ prs,
64
+ {} as IReviewWorkflowService,
65
+ {} as WorkspaceService,
66
+ {} as IAccessControl,
67
+ {} as FileLockService,
68
+ {} as PendingCommitsService,
69
+ 'knowledge-base',
70
+ );
71
+ (svc as unknown as { events?: { emit(e: unknown): void } }).events = {
72
+ emit: (e: unknown) => emitted.push(e),
73
+ };
74
+ return { svc, git, invalidated, emitted };
75
+ }
76
+
77
+ beforeEach(() => {
78
+ configureBranchModel({
79
+ defaultBranch: 'target-company-state',
80
+ protectedBranches: ['current-company-state', 'target-company-state'],
81
+ });
82
+ });
83
+
84
+ describe('closeChangeRequestsWithDeletedBranches', () => {
85
+ it('closes a request whose source branch is gone', async () => {
86
+ const { svc, invalidated, emitted } = makeService(
87
+ [{ number: 7, sourceBranch: 'demo2' }],
88
+ async () => [{ name: 'target-company-state' }, { name: 'alice/live-work' }],
89
+ );
90
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(1);
91
+ // The open lists cache per request; a closed one has to drop out of them.
92
+ expect(invalidated).toEqual([7]);
93
+ expect(emitted).toEqual([{ kind: 'change-request-rejected', number: 7 }]);
94
+ });
95
+
96
+ it('leaves a request whose branch still exists', async () => {
97
+ const { svc, invalidated } = makeService(
98
+ [{ number: 7, sourceBranch: 'alice/live-work' }],
99
+ async () => [{ name: 'target-company-state' }, { name: 'alice/live-work' }],
100
+ );
101
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(0);
102
+ expect(invalidated).toEqual([]);
103
+ });
104
+
105
+ /**
106
+ * The reason the verdict comes from `listBranches({ freshFetch: true })`
107
+ * rather than from the `unknown branch` error the open-list already throws:
108
+ * that error means "missing from THIS workspace's refs", which a clone that
109
+ * never fetched somebody else's branch reports identically. Only origin can
110
+ * answer the question being asked.
111
+ */
112
+ it('asks for a FRESH fetch, so origin decides rather than a stale clone', async () => {
113
+ const { svc, git } = makeService([{ number: 7, sourceBranch: 'x' }], async () => [
114
+ { name: 'x' },
115
+ ]);
116
+ await svc.closeChangeRequestsWithDeletedBranches();
117
+ expect(git.listBranches).toHaveBeenCalledWith('target-company-state', { freshFetch: true });
118
+ });
119
+
120
+ /**
121
+ * "Could not reach origin" and "the branch was deleted" are indistinguishable
122
+ * from in here, and only one of them is a reason to close somebody's
123
+ * proposal. So a failure closes NOTHING — the sweep runs again next boot.
124
+ */
125
+ it('closes nothing when the branch list cannot be fetched', async () => {
126
+ const { svc, invalidated } = makeService(
127
+ [{ number: 7, sourceBranch: 'demo2' }],
128
+ async () => {
129
+ throw new Error('origin unreachable');
130
+ },
131
+ );
132
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(0);
133
+ expect(invalidated).toEqual([]);
134
+ });
135
+
136
+ /**
137
+ * An empty branch list means the clone is broken, not that every branch in
138
+ * the repository was deleted at once — and acting on it would close EVERY
139
+ * open request in one pass. The most destructive possible reading of an
140
+ * ambiguous signal, so it is refused explicitly.
141
+ */
142
+ it('closes nothing when the branch list comes back empty', async () => {
143
+ const { svc, invalidated } = makeService(
144
+ [
145
+ { number: 7, sourceBranch: 'demo2' },
146
+ { number: 8, sourceBranch: 'other' },
147
+ ],
148
+ async () => [],
149
+ );
150
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(0);
151
+ expect(invalidated).toEqual([]);
152
+ });
153
+
154
+ it('does not fetch at all when nothing is open', async () => {
155
+ const { svc, git } = makeService([], async () => [{ name: 'target-company-state' }]);
156
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(0);
157
+ expect(git.listBranches).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('closes several in one pass and leaves the live ones', async () => {
161
+ const { svc, invalidated } = makeService(
162
+ [
163
+ { number: 1, sourceBranch: 'juan/onboarding-import-mr92q9vx4a9e' },
164
+ { number: 3, sourceBranch: 'juan-legal-test-cr' },
165
+ { number: 7, sourceBranch: 'demo2' },
166
+ { number: 9, sourceBranch: 'alice/live-work' },
167
+ ],
168
+ async () => [{ name: 'target-company-state' }, { name: 'alice/live-work' }],
169
+ );
170
+ await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(3);
171
+ expect(invalidated).toEqual([1, 3, 7]);
172
+ });
173
+ });
@@ -41,7 +41,7 @@ import type {
41
41
  OpenChangeRequestInput,
42
42
  PostChangeRequestCommentInput,
43
43
  } from '@bevel-software/platform-shared';
44
- import { isProtectedBranch } from '@bevel-software/platform-shared';
44
+ import { isProtectedBranch, DEFAULT_BRANCH } from '@bevel-software/platform-shared';
45
45
  import { and, eq } from 'drizzle-orm';
46
46
  import type { Database } from '../database/connection.js';
47
47
  import { changeRequests } from '../database/schema.js';
@@ -1536,6 +1536,86 @@ export class WorkflowService implements IWorkflowService {
1536
1536
  return true;
1537
1537
  }
1538
1538
 
1539
+ /**
1540
+ * Close every open change request whose source branch no longer exists.
1541
+ *
1542
+ * A change request is a proposal to merge a branch. When that branch is
1543
+ * gone — deleted after a manual merge, pruned as abandoned, or left behind
1544
+ * by a feature that has itself been removed — the request cannot be read,
1545
+ * reviewed, applied or declined. It is not a pending decision, it is a
1546
+ * tombstone, and it costs more than tidiness: `listOpenPrs` resolves each
1547
+ * open request's changed paths, so every one of these throws
1548
+ * `unknown branch` on every poll, filling the log and slowing the list it
1549
+ * appears in.
1550
+ *
1551
+ * CLOSED, NOT DELETED. The row is evidence — who proposed what, when, and
1552
+ * that it was never applied — and it is the only remaining trace once the
1553
+ * branch is gone. Closing takes it out of every open list (which is the
1554
+ * whole visible problem) while a `state = 'closed'` row can still be read,
1555
+ * counted, and reopened by hand. Deleting would make this sweep the one
1556
+ * operation in the workflow that destroys history without a human asking.
1557
+ *
1558
+ * ONE fetch, then a set lookup. The freshness matters more than the cost:
1559
+ * `changedPathsForPr` fails when the branch is missing from THAT
1560
+ * workspace's refs, which is not the same thing as missing from origin —
1561
+ * a clone that never fetched a branch someone else created reports exactly
1562
+ * the same error. Closing on that signal would eat live requests. So the
1563
+ * verdict comes from `listBranches({ freshFetch: true })`, which fetches
1564
+ * with prune and unions local heads with origin's, on a single workspace.
1565
+ *
1566
+ * Fails SAFE: any error listing branches abandons the sweep entirely rather
1567
+ * than closing anything, because "I could not reach origin" and "the branch
1568
+ * is gone" are indistinguishable from the inside and only one of them is a
1569
+ * reason to act.
1570
+ *
1571
+ * Returns how many it closed.
1572
+ */
1573
+ async closeChangeRequestsWithDeletedBranches(): Promise<number> {
1574
+ const open = await this.db
1575
+ .select({ number: changeRequests.number, sourceBranch: changeRequests.sourceBranch })
1576
+ .from(changeRequests)
1577
+ .where(eq(changeRequests.state, 'open'));
1578
+ if (open.length === 0) return 0;
1579
+
1580
+ let live: Set<string>;
1581
+ try {
1582
+ const branches = await this.git.listBranches(workspaceIdForBranch(DEFAULT_BRANCH), {
1583
+ freshFetch: true,
1584
+ });
1585
+ live = new Set(branches.map((b) => b.name));
1586
+ } catch (err) {
1587
+ console.warn('[cr] branch sweep skipped — could not list branches:', err);
1588
+ return 0;
1589
+ }
1590
+ // An empty branch list means something is wrong with the clone, not that
1591
+ // every branch in the repo was deleted at once. Refuse to act on it.
1592
+ if (live.size === 0) {
1593
+ console.warn('[cr] branch sweep skipped — branch list came back empty');
1594
+ return 0;
1595
+ }
1596
+
1597
+ let closed = 0;
1598
+ for (const cr of open) {
1599
+ if (live.has(cr.sourceBranch)) continue;
1600
+ const now = new Date();
1601
+ // Guarded on `state = 'open'`, so a merge or withdraw racing this call
1602
+ // wins and this becomes a no-op.
1603
+ const updated = await this.db
1604
+ .update(changeRequests)
1605
+ .set({ state: 'closed', closedAt: now, updatedAt: now })
1606
+ .where(and(eq(changeRequests.number, cr.number), eq(changeRequests.state, 'open')))
1607
+ .returning({ id: changeRequests.id });
1608
+ if (updated.length === 0) continue;
1609
+ this.prs.invalidateDetailCache(cr.number);
1610
+ this.events?.emit({ kind: 'change-request-rejected', number: cr.number });
1611
+ console.log(
1612
+ `[cr] closed change request #${cr.number}: source branch "${cr.sourceBranch}" no longer exists`,
1613
+ );
1614
+ closed++;
1615
+ }
1616
+ return closed;
1617
+ }
1618
+
1539
1619
  /**
1540
1620
  * Reject (close-without-merging) a change request. Per PLAN.md spec, the
1541
1621
  * authorized set is: