@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +14 -0
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +14 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +32 -1
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +35 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +80 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/package.json +2 -2
- package/src/core/create-core-server.ts +15 -0
- package/src/core/create-core-services.ts +9 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +72 -0
- package/src/modules/access/access-control.service.ts +49 -1
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +173 -0
- package/src/modules/workflow/workflow.service.ts +81 -1
|
@@ -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:
|