@bevel-software/platform-core-backend 0.25.2 → 0.26.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/agent-guide/access-control.md +234 -0
- package/agent-guide/conventions.md +27 -0
- package/agent-guide/directory-structure.md +145 -0
- package/agent-guide/finding-things.md +7 -0
- package/agent-guide/introduction.md +27 -0
- package/agent-guide/skills.md +47 -0
- package/agent-guide/tool-manuals.md +217 -0
- package/agent-guide/where-a-new-file-goes.md +36 -0
- package/dist/assets.d.ts +7 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +9 -0
- package/dist/assets.js.map +1 -1
- package/dist/core/core-ports.d.ts +11 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +13 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +14 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +9 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +16 -0
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +139 -0
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -0
- package/dist/modules/agent-guide/agent-guide.js +191 -0
- package/dist/modules/agent-guide/agent-guide.js.map +1 -0
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +24 -0
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -0
- package/dist/modules/agent-guide/agent-guide.tools.js +100 -0
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +4 -0
- package/dist/modules/agent-guide/index.d.ts.map +1 -0
- package/dist/modules/agent-guide/index.js +4 -0
- package/dist/modules/agent-guide/index.js.map +1 -0
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +3 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +3 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +9 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +9 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +1 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +1 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +10 -50
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +32 -85
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +29 -2
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +113 -16
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/tool-schema-guard.d.ts +105 -0
- package/dist/modules/mcp/tool-schema-guard.d.ts.map +1 -0
- package/dist/modules/mcp/tool-schema-guard.js +171 -0
- package/dist/modules/mcp/tool-schema-guard.js.map +1 -0
- package/dist/modules/plugins/plugins.tools.d.ts +36 -2
- package/dist/modules/plugins/plugins.tools.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.tools.js +71 -14
- package/dist/modules/plugins/plugins.tools.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +0 -7
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +14 -53
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +3 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +58 -16
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +23 -4
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +14 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +7 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +66 -36
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +14 -14
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -1
- package/dist/modules/tool-registry/description-length.js +24 -26
- package/dist/modules/tool-registry/description-length.js.map +1 -1
- package/dist/modules/tool-registry/guide-first.d.ts +23 -0
- package/dist/modules/tool-registry/guide-first.d.ts.map +1 -0
- package/dist/modules/tool-registry/guide-first.js +32 -0
- package/dist/modules/tool-registry/guide-first.js.map +1 -0
- package/dist/modules/tool-registry/tool-registry.d.ts +6 -0
- package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -1
- package/dist/modules/tool-registry/tool-registry.js +9 -2
- package/dist/modules/tool-registry/tool-registry.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts +449 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.js +481 -0
- package/dist/modules/workflow/agent-tools/change-request-read-shape.js.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts +73 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +582 -0
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +12 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +5 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +9 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +210 -13
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +456 -91
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/merge-commit.d.ts +73 -0
- package/dist/modules/workflow/git/merge-commit.d.ts.map +1 -0
- package/dist/modules/workflow/git/merge-commit.js +89 -0
- package/dist/modules/workflow/git/merge-commit.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +94 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +332 -37
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +35 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +178 -12
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +6 -2
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +7 -2
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +4 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +3 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +22 -27
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +58 -52
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-files.step.js +209 -223
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +5 -3
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +5 -3
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +10 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +211 -18
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +11 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +14 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/hidden-tools.d.ts +44 -0
- package/dist/shared/hidden-tools.d.ts.map +1 -0
- package/dist/shared/hidden-tools.js +13 -0
- package/dist/shared/hidden-tools.js.map +1 -0
- package/kb-template/.bevelignore +0 -5
- package/package.json +4 -3
- package/src/__tests__/kb-layout-config.test.ts +10 -100
- package/src/__tests__/packaged-assets-ship.test.ts +54 -0
- package/src/assets.ts +10 -0
- package/src/core/core-ports.ts +11 -0
- package/src/core/create-core-server.ts +13 -2
- package/src/core/create-core-services.ts +28 -4
- package/src/index.ts +2 -2
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +58 -0
- package/src/modules/access/__tests__/access-control.platform-restore.test.ts +8 -7
- package/src/modules/access/__tests__/access-personal-plugin.test.ts +1 -18
- package/src/modules/access/access-control.interface.ts +15 -0
- package/src/modules/access/access-control.service.ts +21 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +328 -0
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +189 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +122 -0
- package/src/modules/agent-guide/agent-guide.ts +291 -0
- package/src/modules/agent-guide/index.ts +21 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +28 -121
- package/src/modules/agent-instructions/agent-instructions.routes.ts +3 -2
- package/src/modules/agent-instructions/compose.ts +9 -6
- package/src/modules/agent-instructions/index.ts +0 -3
- package/src/modules/agent-instructions/shared-file-rules.ts +31 -93
- package/src/modules/mcp/__tests__/fake-downstream-mcp-server.ts +14 -3
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +250 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +31 -23
- package/src/modules/mcp/__tests__/tool-schema-guard.test.ts +266 -0
- package/src/modules/mcp/mcp.service.ts +137 -19
- package/src/modules/mcp/tool-schema-guard.ts +196 -0
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +154 -4
- package/src/modules/plugins/plugins.tools.ts +75 -15
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +26 -55
- package/src/modules/settings/deployment-settings.service.ts +13 -54
- package/src/modules/settings/setup.routes.ts +3 -6
- package/src/modules/skills/__tests__/skills.tools.description.test.ts +91 -0
- package/src/modules/skills/skills.tools.ts +62 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +57 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +73 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +24 -4
- package/src/modules/tool-manuals/tool-manuals.service.ts +17 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +74 -36
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +160 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +61 -59
- package/src/modules/tool-registry/description-length.ts +24 -26
- package/src/modules/tool-registry/guide-first.ts +34 -0
- package/src/modules/tool-registry/tool-registry.ts +9 -2
- package/src/modules/workflow/__tests__/apply-failure.test.ts +6 -1
- package/src/modules/workflow/agent-tools/__tests__/change-request-read-shape.test.ts +705 -0
- package/src/modules/workflow/agent-tools/__tests__/change-request-read.tools.test.ts +1518 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +23 -2
- package/src/modules/workflow/agent-tools/change-request-read-shape.ts +712 -0
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +724 -0
- package/src/modules/workflow/agent-tools/change-request-summary.ts +5 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +8 -0
- package/src/modules/workflow/git/__tests__/git.service.appliedChange.test.ts +285 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +334 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.list-fetch.test.ts +72 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.placeholder.test.ts +24 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +620 -1
- package/src/modules/workflow/git/git.service.ts +537 -94
- package/src/modules/workflow/git/merge-commit.ts +88 -0
- package/src/modules/workflow/git/pull-request.service.ts +380 -54
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +7 -1
- package/src/modules/workflow/review-workflow/__tests__/merge-records-own-commit.test.ts +407 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +189 -11
- package/src/modules/workflow/workflow.routes.ts +7 -2
- package/src/modules/workflow/workflow.service.ts +7 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.routes.move-platform-files.test.ts +21 -10
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +33 -55
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +255 -22
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +191 -489
- package/src/modules/workspace/startup/steps/seed-tree.ts +21 -27
- package/src/modules/workspace/startup/steps/template-files.step.ts +217 -249
- package/src/modules/workspace/startup/steps/template-source.ts +5 -3
- package/src/modules/workspace/workspace.tools.ts +226 -16
- package/src/shared/domain-errors.ts +15 -0
- package/src/shared/hidden-tools.ts +45 -0
- package/kb-template/AGENTS.md +0 -730
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
4
|
+
import type { SQL } from 'drizzle-orm';
|
|
5
|
+
import { ReviewWorkflowService } from '../review-workflow.service.js';
|
|
6
|
+
import { changeRequests, prMergeLog } from '../../../database/schema.js';
|
|
7
|
+
import type { Database } from '../../../database/connection.js';
|
|
8
|
+
import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
9
|
+
import type { GitService } from '../../git/git.service.js';
|
|
10
|
+
import type { AppliedMergeResult, AuthUser, FileApprovalState } from '@bevel-software/platform-shared';
|
|
11
|
+
import type { IAccessControl } from '../../../access/access-control.interface.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What `mergePr` records as a change request's `merged_sha`, which is the only
|
|
15
|
+
* thing an applied request's files can later be read from.
|
|
16
|
+
*
|
|
17
|
+
* Two shas come back from the merge and they are not interchangeable: `sha` is
|
|
18
|
+
* the state the target is left at, `mergeCommit` is the commit this request
|
|
19
|
+
* owns. Recording the first publishes another request's files under this
|
|
20
|
+
* request's number (cubic P1 on #347); dropping the second when a retry finds
|
|
21
|
+
* nothing left to merge makes an already-merged request permanently fileless
|
|
22
|
+
* (cubic P2 on #347). The row must take `mergeCommit`, exactly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const USER: AuthUser = { id: 'u-alice', email: 'alice@bevel.software', name: 'Alice' };
|
|
26
|
+
const BASE = 'current-company-state';
|
|
27
|
+
const HEAD_SHA = 'head-sha-1';
|
|
28
|
+
|
|
29
|
+
const CR_ROW = {
|
|
30
|
+
id: 'cr-1',
|
|
31
|
+
number: 12,
|
|
32
|
+
sourceBranch: 'alice/add',
|
|
33
|
+
targetBranch: BASE,
|
|
34
|
+
title: 'Add a note',
|
|
35
|
+
body: '',
|
|
36
|
+
authorEmail: 'alice@bevel.software',
|
|
37
|
+
authorName: 'Alice',
|
|
38
|
+
state: 'open',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One approved file, which is all the gate needs to let the merge through: an
|
|
43
|
+
* empty approval set is itself a hard block ("no file changes to approve"), so
|
|
44
|
+
* the "nothing to merge" cases below are about git finding the change already on
|
|
45
|
+
* the target, never about a request with no files.
|
|
46
|
+
*/
|
|
47
|
+
const APPROVED: FileApprovalState = {
|
|
48
|
+
path: 'Knowledge/Ops/note.md',
|
|
49
|
+
eligibleApprovers: { roles: ['Ops'], users: [] },
|
|
50
|
+
approvedBy: [
|
|
51
|
+
{
|
|
52
|
+
name: 'Olga',
|
|
53
|
+
email: 'olga@bevel.software',
|
|
54
|
+
approvedAt: '2026-10-02T10:00:00Z',
|
|
55
|
+
isStale: false,
|
|
56
|
+
isSelfApproval: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
eligibilityResolved: true,
|
|
60
|
+
isApproved: true,
|
|
61
|
+
inMergeGate: true,
|
|
62
|
+
viewerCanApprove: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every write the merge performs, in order, with the table it targeted and
|
|
67
|
+
* whether it was an insert or an update — the ORDER is part of what one of the
|
|
68
|
+
* tests below pins.
|
|
69
|
+
*/
|
|
70
|
+
type Captured = { kind: 'insert' | 'update'; table: unknown; values: Record<string, unknown> };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the row says, and whether the CAS wins, are PARAMETERS, not constants.
|
|
74
|
+
* `mergePr` and `finalizeAlreadyApplied` both re-validate the lifecycle against
|
|
75
|
+
* the row rather than against the caller's `state` argument, and a stub that
|
|
76
|
+
* always answers `open` and always wins leaves those paths unreachable — a
|
|
77
|
+
* regression that dropped one would still pass (cubic P3 on #347).
|
|
78
|
+
*
|
|
79
|
+
* `rows` is consumed in order and its last entry repeats, so a test can say
|
|
80
|
+
* what the row looked like when the attempt started and what it says when the
|
|
81
|
+
* lost CAS is explained.
|
|
82
|
+
*/
|
|
83
|
+
function makeDb(
|
|
84
|
+
captured: Captured[],
|
|
85
|
+
{
|
|
86
|
+
rows = [CR_ROW],
|
|
87
|
+
casWon = true,
|
|
88
|
+
begunLog = true,
|
|
89
|
+
}: { rows?: Record<string, unknown>[]; casWon?: boolean; begunLog?: boolean } = {},
|
|
90
|
+
): Database {
|
|
91
|
+
let crReads = 0;
|
|
92
|
+
const thenable = (data: unknown) => {
|
|
93
|
+
const p = Promise.resolve(data) as Promise<unknown> & { limit: () => Promise<unknown> };
|
|
94
|
+
p.limit = () => Promise.resolve(data);
|
|
95
|
+
return p;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* What the CAS compares against: the row's state at the moment of the
|
|
99
|
+
* update. `casWon` says the row is still open then; a lost CAS means it is
|
|
100
|
+
* what the LAST row says (the terminal state the lost CAS is explained by).
|
|
101
|
+
*/
|
|
102
|
+
const stateAtCas = casWon ? 'open' : String(rows[rows.length - 1]?.state ?? 'merged');
|
|
103
|
+
return {
|
|
104
|
+
select: () => ({
|
|
105
|
+
from: (table: unknown) => ({
|
|
106
|
+
where: () => {
|
|
107
|
+
// The merge log begun by the attempt that pushed and crashed —
|
|
108
|
+
// `succeeded: false`, no error — is what makes a retry look for its
|
|
109
|
+
// commit at all.
|
|
110
|
+
if (table === prMergeLog) return thenable(begunLog ? [{ id: 'merge-log-0' }] : []);
|
|
111
|
+
if (table !== changeRequests) return thenable([]);
|
|
112
|
+
const row = rows[Math.min(crReads, rows.length - 1)];
|
|
113
|
+
crReads += 1;
|
|
114
|
+
return thenable([row]);
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
insert: (table: unknown) => ({
|
|
119
|
+
values: (values: Record<string, unknown>) => {
|
|
120
|
+
captured.push({ kind: 'insert', table, values });
|
|
121
|
+
return { returning: async () => [{ id: 'merge-log-1' }] };
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
update: (table: unknown) => ({
|
|
125
|
+
set: (values: Record<string, unknown>) => {
|
|
126
|
+
captured.push({ kind: 'update', table, values });
|
|
127
|
+
return {
|
|
128
|
+
where: (predicate: SQL) => {
|
|
129
|
+
const t = thenable(undefined) as unknown as Promise<unknown> & {
|
|
130
|
+
returning: () => Promise<unknown>;
|
|
131
|
+
};
|
|
132
|
+
// The CAS is answered FROM THE WHERE CLAUSE, not from a flag: the
|
|
133
|
+
// update of the change request row lands only if the row is in
|
|
134
|
+
// the state the predicate asks for. A predicate that no longer
|
|
135
|
+
// guards on `state = 'open'` would therefore land on a terminal
|
|
136
|
+
// row here — which is the race this test file exists to pin, so
|
|
137
|
+
// dropping the guard fails the lost-CAS tests below instead of
|
|
138
|
+
// passing them by a stub that answered from `casWon` alone.
|
|
139
|
+
const { sql, params } = new PgDialect().sqlToQuery(predicate);
|
|
140
|
+
const guardsOpen = /"state"/.test(sql) && params.includes('open');
|
|
141
|
+
const lands = table !== changeRequests || !guardsOpen || stateAtCas === 'open';
|
|
142
|
+
t.returning = () => Promise.resolve(lands ? [{ id: CR_ROW.id }] : []);
|
|
143
|
+
return t;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
} as unknown as Database;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function makeService(mergeResult: AppliedMergeResult) {
|
|
152
|
+
const captured: Captured[] = [];
|
|
153
|
+
// Typed by its signature rather than its body, so the recorded call keeps the
|
|
154
|
+
// arguments the assertion below reads out of it.
|
|
155
|
+
const mergeChangeRequest = vi.fn<(...args: unknown[]) => Promise<AppliedMergeResult>>(
|
|
156
|
+
async () => mergeResult,
|
|
157
|
+
);
|
|
158
|
+
const workspace = {
|
|
159
|
+
ensureRemotesFetched: vi.fn(async () => undefined),
|
|
160
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
|
|
161
|
+
} as unknown as WorkspaceService;
|
|
162
|
+
const svc = new ReviewWorkflowService(
|
|
163
|
+
makeDb(captured),
|
|
164
|
+
{} as unknown as IAccessControl,
|
|
165
|
+
workspace,
|
|
166
|
+
{ mergeChangeRequest } as unknown as GitService,
|
|
167
|
+
);
|
|
168
|
+
const merge = () =>
|
|
169
|
+
svc.mergePr(CR_ROW.number, USER, HEAD_SHA, [APPROVED], 'open', CR_ROW.title, BASE, 'ws-1');
|
|
170
|
+
/** The values written to the change request row (not the merge log). */
|
|
171
|
+
const crUpdate = () => captured.find((c) => c.kind === 'update' && c.table === changeRequests)?.values;
|
|
172
|
+
return { merge, crUpdate, mergeChangeRequest };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('mergePr — which commit the row records', () => {
|
|
176
|
+
it('records the merge commit this request owns, not the state the target is left at', async () => {
|
|
177
|
+
const { merge, crUpdate } = makeService({
|
|
178
|
+
kind: 'merged',
|
|
179
|
+
sha: 'target-tip-which-is-another-requests-merge',
|
|
180
|
+
mergeCommit: 'this-requests-own-merge',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = await merge();
|
|
184
|
+
|
|
185
|
+
expect(crUpdate()).toMatchObject({ state: 'merged', mergedSha: 'this-requests-own-merge' });
|
|
186
|
+
// The caller still hears the state the target is at, which is what "merged"
|
|
187
|
+
// means for the request even when it is not a commit of its own.
|
|
188
|
+
expect(result.sha).toBe('target-tip-which-is-another-requests-merge');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('records nothing when the request owns no merge commit', async () => {
|
|
192
|
+
// The genuinely empty request: the target already contained its change, and
|
|
193
|
+
// no attempt of its own ever wrote a commit. It has no file list to lose, and
|
|
194
|
+
// the target tip it is merged at belongs to somebody else.
|
|
195
|
+
const { merge, crUpdate } = makeService({
|
|
196
|
+
kind: 'merged',
|
|
197
|
+
sha: 'target-tip-which-is-another-requests-merge',
|
|
198
|
+
mergeCommit: null,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await merge();
|
|
202
|
+
|
|
203
|
+
expect(crUpdate()).toMatchObject({ state: 'merged', mergedSha: null });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('names the request to the merge, so a retry can recognise its own commit', async () => {
|
|
207
|
+
// Without the number, a merge that finds nothing to merge cannot tell an
|
|
208
|
+
// empty request from one whose merge commit a previous attempt pushed before
|
|
209
|
+
// the row update failed — and would record null for both.
|
|
210
|
+
const { merge, mergeChangeRequest } = makeService({
|
|
211
|
+
kind: 'merged',
|
|
212
|
+
sha: 'tip',
|
|
213
|
+
mergeCommit: 'own',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await merge();
|
|
217
|
+
|
|
218
|
+
expect(mergeChangeRequest).toHaveBeenCalledTimes(1);
|
|
219
|
+
const opts = mergeChangeRequest.mock.calls[0][5] as { appliedChangeNumber?: number };
|
|
220
|
+
expect(opts.appliedChangeNumber).toBe(CR_ROW.number);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The retry the row-recording fix is FOR, reached the way production reaches it.
|
|
226
|
+
*
|
|
227
|
+
* When an attempt pushes the merge commit and then fails to write the row, the
|
|
228
|
+
* request stays open with its change already on the target — so it differs from
|
|
229
|
+
* the target by nothing, its file list is empty, and the approval gate refuses
|
|
230
|
+
* the next attempt ("no file changes to approve") before any merge runs. The
|
|
231
|
+
* recovery inside `mergeChangeRequest` is therefore never reached from the app,
|
|
232
|
+
* and the request stays fileless and un-appliable for good. The gate has to ask
|
|
233
|
+
* the ownership question itself.
|
|
234
|
+
*/
|
|
235
|
+
function makeStuckService(
|
|
236
|
+
own: string | null,
|
|
237
|
+
// The two states a merge is decided against, kept APART because the service
|
|
238
|
+
// treats them apart: `gateState` is the caller's (what drove the gate it
|
|
239
|
+
// clicked), `rowState` is the authoritative row's. A stale caller passing
|
|
240
|
+
// 'open' for a row that is already terminal is exactly what the row-level
|
|
241
|
+
// guard is for.
|
|
242
|
+
{
|
|
243
|
+
gateState = 'open',
|
|
244
|
+
rowState = 'open',
|
|
245
|
+
casWon = true,
|
|
246
|
+
// What the row says when the lost CAS is explained — a cancel closes a
|
|
247
|
+
// request just as a merge does, and the audit line must not guess.
|
|
248
|
+
stateAfterCas = 'merged',
|
|
249
|
+
// Whether an earlier attempt left a begun, never-completed merge-log row —
|
|
250
|
+
// the only evidence that there may be a pushed commit to recover.
|
|
251
|
+
begunLog = true,
|
|
252
|
+
}: {
|
|
253
|
+
gateState?: string;
|
|
254
|
+
rowState?: string;
|
|
255
|
+
casWon?: boolean;
|
|
256
|
+
stateAfterCas?: string;
|
|
257
|
+
begunLog?: boolean;
|
|
258
|
+
} = {},
|
|
259
|
+
) {
|
|
260
|
+
const rows = [{ ...CR_ROW, state: rowState }, { ...CR_ROW, state: stateAfterCas }];
|
|
261
|
+
const captured: Captured[] = [];
|
|
262
|
+
const mergeChangeRequest = vi.fn<(...args: unknown[]) => Promise<AppliedMergeResult>>(async () => ({
|
|
263
|
+
kind: 'merged',
|
|
264
|
+
sha: 'should-not-be-reached',
|
|
265
|
+
mergeCommit: null,
|
|
266
|
+
}));
|
|
267
|
+
const appliedMergeCommitOnTarget = vi.fn(async () => own);
|
|
268
|
+
const workspace = {
|
|
269
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
|
|
270
|
+
} as unknown as WorkspaceService;
|
|
271
|
+
const svc = new ReviewWorkflowService(
|
|
272
|
+
makeDb(captured, { rows, casWon, begunLog }),
|
|
273
|
+
{} as unknown as IAccessControl,
|
|
274
|
+
workspace,
|
|
275
|
+
{ mergeChangeRequest, appliedMergeCommitOnTarget } as unknown as GitService,
|
|
276
|
+
);
|
|
277
|
+
// NO approvals: an already-applied request has no file to approve, which is
|
|
278
|
+
// exactly what makes it indistinguishable from an empty one at the gate.
|
|
279
|
+
const merge = () =>
|
|
280
|
+
svc.mergePr(CR_ROW.number, USER, HEAD_SHA, [], gateState as 'open', CR_ROW.title, BASE, 'ws-1');
|
|
281
|
+
const crUpdate = () => captured.find((c) => c.kind === 'update' && c.table === changeRequests)?.values;
|
|
282
|
+
return { merge, crUpdate, captured, mergeChangeRequest, appliedMergeCommitOnTarget, workspace };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
describe('mergePr — a request whose merge commit was pushed but never recorded', () => {
|
|
286
|
+
it('asks git only when the database shows an attempt that began and never completed', async () => {
|
|
287
|
+
// A genuinely empty request — nothing on the target, no attempt behind it
|
|
288
|
+
// — is refused as it always was, and the refusal touches no clone and no
|
|
289
|
+
// remote: the probe that fetches the target runs only behind the one
|
|
290
|
+
// piece of evidence a crashed attempt leaves, its begun merge-log row.
|
|
291
|
+
const { merge, appliedMergeCommitOnTarget, workspace } = makeStuckService('a-commit', { begunLog: false });
|
|
292
|
+
await expect(merge()).rejects.toThrow(/no file changes to approve/i);
|
|
293
|
+
expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
|
|
294
|
+
expect((workspace as unknown as { getOrCreateForBranch: ReturnType<typeof vi.fn> }).getOrCreateForBranch).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('hands the stored title to the probe, so a merge commit in the old message format is recognised', async () => {
|
|
298
|
+
const { merge, appliedMergeCommitOnTarget } = makeStuckService('a-commit');
|
|
299
|
+
await merge();
|
|
300
|
+
expect(appliedMergeCommitOnTarget).toHaveBeenCalledWith('ws-base', BASE, CR_ROW.number, CR_ROW.title);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('records that commit instead of refusing, and merges nothing', async () => {
|
|
304
|
+
const { merge, crUpdate, mergeChangeRequest, appliedMergeCommitOnTarget } =
|
|
305
|
+
makeStuckService('the-commit-the-first-attempt-pushed');
|
|
306
|
+
|
|
307
|
+
const result = await merge();
|
|
308
|
+
|
|
309
|
+
expect(crUpdate()).toMatchObject({
|
|
310
|
+
state: 'merged',
|
|
311
|
+
mergedSha: 'the-commit-the-first-attempt-pushed',
|
|
312
|
+
});
|
|
313
|
+
expect(result.sha).toBe('the-commit-the-first-attempt-pushed');
|
|
314
|
+
// Asked of the TARGET branch's own workspace, for this request's number.
|
|
315
|
+
expect(appliedMergeCommitOnTarget).toHaveBeenCalledWith('ws-base', BASE, CR_ROW.number, CR_ROW.title);
|
|
316
|
+
// Nothing was merged, committed or pushed: the merge already happened.
|
|
317
|
+
expect(mergeChangeRequest).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('still refuses a request that owns no merge commit', async () => {
|
|
321
|
+
// The genuinely empty request. The hard block is the right answer for it,
|
|
322
|
+
// and the probe is what tells the two apart.
|
|
323
|
+
const { merge, crUpdate, mergeChangeRequest } = makeStuckService(null);
|
|
324
|
+
|
|
325
|
+
await expect(merge()).rejects.toThrow('no file changes to approve');
|
|
326
|
+
|
|
327
|
+
expect(crUpdate()).toBeUndefined();
|
|
328
|
+
expect(mergeChangeRequest).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('does not go looking when the hard block is the request\'s state', async () => {
|
|
332
|
+
// A closed or merged request earns its own refusal, and must keep it: the
|
|
333
|
+
// recovery is only ever about the empty-file-set block.
|
|
334
|
+
const { merge, appliedMergeCommitOnTarget, crUpdate } = makeStuckService('a-commit', { gateState: 'closed', rowState: 'closed' });
|
|
335
|
+
|
|
336
|
+
await expect(merge()).rejects.toThrow('This pull request is closed.');
|
|
337
|
+
|
|
338
|
+
expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
|
|
339
|
+
expect(crUpdate()).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('refuses when the AUTHORITATIVE row is already terminal, whatever the caller passed', async () => {
|
|
343
|
+
// The caller's gate inputs were resolved before a concurrent apply landed,
|
|
344
|
+
// so it still says `open` while the row says `merged`. The recovery runs
|
|
345
|
+
// ahead of `mergePr`'s own lifecycle re-check, so its own row-state guard is
|
|
346
|
+
// the only thing between a stale click and a second finalization.
|
|
347
|
+
const { merge, crUpdate, appliedMergeCommitOnTarget } = makeStuckService('a-commit', {
|
|
348
|
+
gateState: 'open',
|
|
349
|
+
rowState: 'merged',
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await expect(merge()).rejects.toThrow('no file changes to approve');
|
|
353
|
+
|
|
354
|
+
expect(appliedMergeCommitOnTarget).not.toHaveBeenCalled();
|
|
355
|
+
expect(crUpdate()).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('logs the attempt before it finalizes the row, and completes the log after', async () => {
|
|
359
|
+
// Order matters: a log write that failed after the CAS would leave a request
|
|
360
|
+
// recorded as merged with no attempt behind it, and the caller erroring on a
|
|
361
|
+
// request that IS merged. The attempt goes in first, carrying the commit it
|
|
362
|
+
// is about, and is completed once the row is won.
|
|
363
|
+
const { merge, captured } = makeStuckService('the-commit-the-first-attempt-pushed');
|
|
364
|
+
|
|
365
|
+
await merge();
|
|
366
|
+
|
|
367
|
+
const inserted = captured.find((c) => c.kind === 'insert');
|
|
368
|
+
expect(inserted?.values).toMatchObject({ succeeded: false, mergeMethod: 'merge' });
|
|
369
|
+
expect(String(inserted?.values.error)).toContain('the-commit-the-first-attempt-pushed');
|
|
370
|
+
// The log's completion comes AFTER the change request row is finalized.
|
|
371
|
+
const order = captured.map((c) => (c.kind === 'insert' ? 'log-insert' : c.table === changeRequests ? 'cr' : 'log-update'));
|
|
372
|
+
expect(order).toEqual(['log-insert', 'cr', 'log-update']);
|
|
373
|
+
expect(captured[2].values).toMatchObject({ succeeded: true });
|
|
374
|
+
});
|
|
375
|
+
it('names the state the row actually ended in when the CAS is lost to a CANCEL', async () => {
|
|
376
|
+
// A cancel closes a request exactly as a merge does, and both lose this CAS.
|
|
377
|
+
// Filing a cancellation as "a concurrent merge won" sends whoever reads
|
|
378
|
+
// pr_merge_log afterwards to the wrong cause (cubic P2 on #347).
|
|
379
|
+
const { merge, captured } = makeStuckService('a-commit', {
|
|
380
|
+
casWon: false,
|
|
381
|
+
stateAfterCas: 'closed',
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await expect(merge()).rejects.toThrow('This change request is closed.');
|
|
385
|
+
|
|
386
|
+
const logUpdate = captured.filter((c) => c.kind === 'update' && c.table !== changeRequests).pop();
|
|
387
|
+
expect(logUpdate?.values).toMatchObject({ succeeded: false });
|
|
388
|
+
expect(String(logUpdate?.values.error)).toBe(
|
|
389
|
+
'Change request was no longer open (closed); this attempt did not finalize it.',
|
|
390
|
+
);
|
|
391
|
+
expect(String(logUpdate?.values.error)).not.toContain('concurrent');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('says merged, and refuses as merged, when the CAS is lost to another merge', async () => {
|
|
395
|
+
const { merge, captured } = makeStuckService('a-commit', {
|
|
396
|
+
casWon: false,
|
|
397
|
+
stateAfterCas: 'merged',
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await expect(merge()).rejects.toThrow('This change request has already been merged.');
|
|
401
|
+
|
|
402
|
+
const logUpdate = captured.filter((c) => c.kind === 'update' && c.table !== changeRequests).pop();
|
|
403
|
+
expect(String(logUpdate?.values.error)).toBe(
|
|
404
|
+
'Change request was no longer open (merged); this attempt did not finalize it.',
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, eq } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, isNull } from 'drizzle-orm';
|
|
2
2
|
import { logger } from '../../../shared/logging.js';
|
|
3
3
|
|
|
4
4
|
const log = logger('review-workflow');
|
|
@@ -18,6 +18,7 @@ import { changeRequests, prComments, prFileApprovals, prMergeLog, users } from '
|
|
|
18
18
|
import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
19
19
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
20
20
|
import type { GitService } from '../git/git.service.js';
|
|
21
|
+
import { mergeCommitSubject } from '../git/merge-commit.js';
|
|
21
22
|
import { redactSecret } from '../../../shared/redact-secret.js';
|
|
22
23
|
import {
|
|
23
24
|
ChangeRequestConflictsError,
|
|
@@ -44,6 +45,14 @@ import {
|
|
|
44
45
|
// trail — squashing would collapse them into one. A merge commit preserves every
|
|
45
46
|
// commit on the branch.
|
|
46
47
|
const MERGE_METHOD = 'merge' as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The hard block an empty file set earns — named, because two places have to
|
|
51
|
+
* agree on it: the gate that raises it, and `finalizeAlreadyApplied`, which
|
|
52
|
+
* recognises it as the one refusal that can mean "already applied" rather than
|
|
53
|
+
* "nothing to apply".
|
|
54
|
+
*/
|
|
55
|
+
const NOTHING_TO_APPROVE = 'This pull request has no file changes to approve.';
|
|
47
56
|
const EVERYONE_CANONICAL = 'everyone';
|
|
48
57
|
|
|
49
58
|
/**
|
|
@@ -239,7 +248,7 @@ function evaluateGateParts(input: MergeGateInput): { hardReasons: string[]; warn
|
|
|
239
248
|
// mergeable either — a PR that touches no files shouldn't be opened in
|
|
240
249
|
// the first place, let alone merged.
|
|
241
250
|
if (input.approvals.length === 0 && input.state === 'open') {
|
|
242
|
-
reasons.push(
|
|
251
|
+
reasons.push(NOTHING_TO_APPROVE);
|
|
243
252
|
}
|
|
244
253
|
|
|
245
254
|
// Ownership enforcement binds every file with an eligible approver,
|
|
@@ -671,7 +680,17 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
671
680
|
// opted into bypass; with bypass, the bypassed warnings get inlined in the
|
|
672
681
|
// merge commit body so git history captures the decision.
|
|
673
682
|
const gate = evaluateGateParts({ prNumber, state, approvals });
|
|
674
|
-
if (gate.hardReasons.length > 0)
|
|
683
|
+
if (gate.hardReasons.length > 0) {
|
|
684
|
+
// One of those reasons can mean the opposite of what it says. "No file
|
|
685
|
+
// changes to approve" is also what an ALREADY APPLIED request looks like:
|
|
686
|
+
// its change is on the target, so it differs from it by nothing. That is
|
|
687
|
+
// the state a request is left in when an attempt pushed its merge commit
|
|
688
|
+
// and then failed to record the row — and refusing the retry here is what
|
|
689
|
+
// kept the recovery inside the merge from ever running (cubic P2 on #347).
|
|
690
|
+
const recovered = await this.finalizeAlreadyApplied(prNumber, user, headSha, gate.hardReasons);
|
|
691
|
+
if (recovered) return recovered;
|
|
692
|
+
throw new MergeBlockedError(gate.hardReasons);
|
|
693
|
+
}
|
|
675
694
|
if (gate.warnings.length > 0 && !opts.bypass) {
|
|
676
695
|
throw new MergeBlockedError(gate.warnings);
|
|
677
696
|
}
|
|
@@ -730,7 +749,9 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
730
749
|
// Attribution lives on the merge commit itself (authored as the human
|
|
731
750
|
// triggerer). When bypass is used, the bypassed warnings are appended so the
|
|
732
751
|
// decision survives in git history — no separate audit table needed.
|
|
733
|
-
|
|
752
|
+
// One place builds this, one place reads it back — `merge-commit.ts` says
|
|
753
|
+
// why an applied request's reader has to verify the subject at all.
|
|
754
|
+
const subject = mergeCommitSubject(prTitle, prNumber);
|
|
734
755
|
const bypassFooter =
|
|
735
756
|
opts.bypass && gate.warnings.length > 0
|
|
736
757
|
? `\n\nApproval requirements bypassed:\n${gate.warnings.map((w) => `- ${w}`).join('\n')}`
|
|
@@ -750,6 +771,10 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
750
771
|
cr.targetBranch,
|
|
751
772
|
{ subject, body },
|
|
752
773
|
user,
|
|
774
|
+
// Named so a merge that finds nothing to merge can tell an empty request
|
|
775
|
+
// apart from one a previous attempt already merged and failed to record
|
|
776
|
+
// (see `ownMergeCommitOn`).
|
|
777
|
+
{ appliedChangeNumber: prNumber, appliedChangeTitle: cr.title },
|
|
753
778
|
);
|
|
754
779
|
} catch (err) {
|
|
755
780
|
const redacted = redactTokens(err instanceof Error ? err.message : String(err), this.git.credentials?.token());
|
|
@@ -780,16 +805,35 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
780
805
|
const completedAt = new Date();
|
|
781
806
|
const [updatedCr] = await this.db
|
|
782
807
|
.update(changeRequests)
|
|
783
|
-
.set({
|
|
808
|
+
.set({
|
|
809
|
+
state: 'merged',
|
|
810
|
+
// Only the commit this request OWNS, never `sha`. With nothing to merge
|
|
811
|
+
// `sha` is the target tip — usually another request's merge commit — and
|
|
812
|
+
// recording it here would let this request be read back with that other
|
|
813
|
+
// request's files under its number (cubic P1 on #347).
|
|
814
|
+
//
|
|
815
|
+
// `mergeCommit` is also what makes a retry after a failed finalization
|
|
816
|
+
// idempotent: the merge commit a previous attempt pushed is found and
|
|
817
|
+
// recorded instead of being dropped as "nothing was merged" (cubic P2 on
|
|
818
|
+
// #347). It is null only when the request has no merge commit at all,
|
|
819
|
+
// and such a request has no file list to lose.
|
|
820
|
+
mergedSha: mergeResult.mergeCommit,
|
|
821
|
+
closedAt: completedAt,
|
|
822
|
+
updatedAt: completedAt,
|
|
823
|
+
})
|
|
784
824
|
.where(and(eq(changeRequests.id, cr.id), eq(changeRequests.state, 'open')))
|
|
785
825
|
.returning({ id: changeRequests.id });
|
|
786
826
|
if (!updatedCr) {
|
|
787
|
-
//
|
|
788
|
-
// Our own git merge was a harmless idempotent no-op,
|
|
789
|
-
// NOT finalize the CR. Finalize this log row with an
|
|
790
|
-
// a completedAt so it doesn't linger as a phantom
|
|
791
|
-
// error=null` entry that a "failed merges" audit query
|
|
792
|
-
|
|
827
|
+
// Something else moved the row out of `open` between our lifecycle
|
|
828
|
+
// re-check and here. Our own git merge was a harmless idempotent no-op,
|
|
829
|
+
// but this attempt did NOT finalize the CR. Finalize this log row with an
|
|
830
|
+
// explanatory error and a completedAt so it doesn't linger as a phantom
|
|
831
|
+
// `succeeded=false, error=null` entry that a "failed merges" audit query
|
|
832
|
+
// would misread — and say only what the lost CAS proves, which is that the
|
|
833
|
+
// row was no longer open: a cancel closes it too, and naming a concurrent
|
|
834
|
+
// MERGE would file that cancellation under the wrong cause (cubic P2 on
|
|
835
|
+
// #347, raised against the copy of this line in `finalizeAlreadyApplied`).
|
|
836
|
+
const raceError = 'Change request was no longer open; this attempt did not finalize it.';
|
|
793
837
|
await this.db
|
|
794
838
|
.update(prMergeLog)
|
|
795
839
|
.set({ succeeded: false, completedAt, error: raceError })
|
|
@@ -808,6 +852,140 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
808
852
|
};
|
|
809
853
|
}
|
|
810
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Record a change request that is ALREADY applied — its merge commit is on the
|
|
857
|
+
* target — and that the gate was about to refuse for having nothing to merge.
|
|
858
|
+
* Null when the request owns no such commit, which is every other reason an
|
|
859
|
+
* approval set can be empty.
|
|
860
|
+
*
|
|
861
|
+
* The case this exists for: an attempt pushed the merge commit and then failed
|
|
862
|
+
* to write the row (a transient database fault in between). The request stays
|
|
863
|
+
* `open` while its change is on the target, so the diff that produces its file
|
|
864
|
+
* list — and with it every approval the gate reads — is empty. The retry is
|
|
865
|
+
* then refused BEFORE the merge runs, which is where the recovery inside
|
|
866
|
+
* `mergeChangeRequest` sits, so the request can never be finalized: it is
|
|
867
|
+
* fileless, author-only, and un-appliable for good, with the commit holding
|
|
868
|
+
* its files sitting on the target unreferenced (cubic P2 on #347).
|
|
869
|
+
*
|
|
870
|
+
* Nothing is merged, committed or pushed here. The only write is the row that
|
|
871
|
+
* should have been written by the attempt that pushed, and the merge-log entry
|
|
872
|
+
* that says this caller finalized it.
|
|
873
|
+
*
|
|
874
|
+
* Narrow on purpose:
|
|
875
|
+
* • exactly ONE hard reason, and it is the empty-file-set one. A request that
|
|
876
|
+
* is already `merged` or `closed` has its own reason and must keep it.
|
|
877
|
+
* • the row must still be `open`, read here and re-checked by the CAS below.
|
|
878
|
+
* • the commit must be the request's OWN, by the same subject predicate the
|
|
879
|
+
* reader verifies with (`mergeCommitSubjectNames`), so this records only
|
|
880
|
+
* what `appliedChangeShas` will accept. No branch name a user can choose
|
|
881
|
+
* can produce that subject — `assertValidBranchName` forbids both the space
|
|
882
|
+
* and the `#` the agent `merge_branch` subject would need.
|
|
883
|
+
*
|
|
884
|
+
* Who may trigger it needs no extra check: the app resolves the request's
|
|
885
|
+
* detail for the caller first and refuses when it answers nothing, and a
|
|
886
|
+
* request with no readable file list is visible to its author alone. So the
|
|
887
|
+
* author is the one who can finalize their own stuck request.
|
|
888
|
+
*/
|
|
889
|
+
private async finalizeAlreadyApplied(
|
|
890
|
+
prNumber: number,
|
|
891
|
+
user: AuthUser,
|
|
892
|
+
headSha: string,
|
|
893
|
+
hardReasons: string[],
|
|
894
|
+
): Promise<MergePrResult | null> {
|
|
895
|
+
if (hardReasons.length !== 1 || hardReasons[0] !== NOTHING_TO_APPROVE) return null;
|
|
896
|
+
|
|
897
|
+
const [cr] = await this.db
|
|
898
|
+
.select()
|
|
899
|
+
.from(changeRequests)
|
|
900
|
+
.where(eq(changeRequests.number, prNumber))
|
|
901
|
+
.limit(1);
|
|
902
|
+
if (!cr || cr.state !== 'open' || !cr.targetBranch) return null;
|
|
903
|
+
|
|
904
|
+
// The cheap question first, of the database alone: did an attempt on this
|
|
905
|
+
// request push and then crash before recording? Such an attempt leaves its
|
|
906
|
+
// log row begun and never completed — `succeeded: false` with no error,
|
|
907
|
+
// the shape no finished attempt has. Without one there is nothing to
|
|
908
|
+
// recover, and a genuinely empty request is refused here as it always
|
|
909
|
+
// was, without a clone being created or a remote fetched on its behalf.
|
|
910
|
+
const [begun] = await this.db
|
|
911
|
+
.select({ id: prMergeLog.id })
|
|
912
|
+
.from(prMergeLog)
|
|
913
|
+
.where(and(eq(prMergeLog.prNumber, prNumber), eq(prMergeLog.succeeded, false), isNull(prMergeLog.error)))
|
|
914
|
+
.limit(1);
|
|
915
|
+
if (!begun) return null;
|
|
916
|
+
|
|
917
|
+
const baseWorkspace = await this.workspaceService.getOrCreateForBranch(cr.targetBranch);
|
|
918
|
+
const own = await this.git.appliedMergeCommitOnTarget(baseWorkspace.id, cr.targetBranch, prNumber, cr.title);
|
|
919
|
+
if (!own) return null;
|
|
920
|
+
|
|
921
|
+
// The attempt is logged BEFORE the row is finalized, in the same order the
|
|
922
|
+
// merge path logs its own: a log write that fails must not leave a request
|
|
923
|
+
// recorded as merged with no attempt behind it, and failing here fails
|
|
924
|
+
// before anything is committed, so the retry is clean (cubic P2 on #347).
|
|
925
|
+
const triggeredByEmail = canonicalEmail(user.email);
|
|
926
|
+
const [logRow] = await this.db
|
|
927
|
+
.insert(prMergeLog)
|
|
928
|
+
.values({
|
|
929
|
+
prNumber,
|
|
930
|
+
triggeredByEmail,
|
|
931
|
+
triggeredByEmailBidx: triggeredByEmail,
|
|
932
|
+
triggeredByName: user.name,
|
|
933
|
+
headShaAtMerge: headSha,
|
|
934
|
+
mergeMethod: MERGE_METHOD,
|
|
935
|
+
succeeded: false,
|
|
936
|
+
// Not an error — the one thing a reader of this row has to know is that
|
|
937
|
+
// no merge was performed under it, because it had already been
|
|
938
|
+
// performed. Written at the start so it survives a crash in between.
|
|
939
|
+
error: `Finalizing an earlier attempt's merge commit ${own}; nothing is merged by this one.`,
|
|
940
|
+
})
|
|
941
|
+
.returning({ id: prMergeLog.id });
|
|
942
|
+
|
|
943
|
+
// Same CAS as the merge path: a concurrent finalization wins and we say so
|
|
944
|
+
// rather than overwriting its terminal state.
|
|
945
|
+
const completedAt = new Date();
|
|
946
|
+
const [updated] = await this.db
|
|
947
|
+
.update(changeRequests)
|
|
948
|
+
.set({ state: 'merged', mergedSha: own, closedAt: completedAt, updatedAt: completedAt })
|
|
949
|
+
.where(and(eq(changeRequests.id, cr.id), eq(changeRequests.state, 'open')))
|
|
950
|
+
.returning({ id: changeRequests.id });
|
|
951
|
+
if (!updated) {
|
|
952
|
+
// The CAS loses to ANY terminal transition, not just a merge: `cancelPr`
|
|
953
|
+
// closes a request the same way. So the row is re-read and the reason it
|
|
954
|
+
// actually carries is what goes into the audit line and into the refusal
|
|
955
|
+
// — a cancellation filed as "a concurrent merge won" sends whoever reads
|
|
956
|
+
// pr_merge_log after the fact to the wrong cause (cubic P2 on #347).
|
|
957
|
+
const [current] = await this.db
|
|
958
|
+
.select({ state: changeRequests.state })
|
|
959
|
+
.from(changeRequests)
|
|
960
|
+
.where(eq(changeRequests.id, cr.id))
|
|
961
|
+
.limit(1);
|
|
962
|
+
const raceError =
|
|
963
|
+
`Change request was no longer open (${current?.state ?? 'row missing'}); this attempt did not finalize it.`;
|
|
964
|
+
await this.db
|
|
965
|
+
.update(prMergeLog)
|
|
966
|
+
.set({ succeeded: false, completedAt, error: raceError })
|
|
967
|
+
.where(eq(prMergeLog.id, logRow.id));
|
|
968
|
+
throw new MergeBlockedError([
|
|
969
|
+
current?.state === 'merged'
|
|
970
|
+
? 'This change request has already been merged.'
|
|
971
|
+
: 'This change request is closed.',
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
974
|
+
await this.db
|
|
975
|
+
.update(prMergeLog)
|
|
976
|
+
.set({
|
|
977
|
+
succeeded: true,
|
|
978
|
+
completedAt,
|
|
979
|
+
error: `Finalized an earlier attempt's merge commit ${own}; nothing was merged by this one.`,
|
|
980
|
+
})
|
|
981
|
+
.where(eq(prMergeLog.id, logRow.id));
|
|
982
|
+
log.warn(
|
|
983
|
+
`change request #${prNumber} was already merged as ${own} but its row was never updated; recorded it now.`,
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
return { prNumber, sha: own, mergedAt: completedAt.toISOString() };
|
|
987
|
+
}
|
|
988
|
+
|
|
811
989
|
async cancelPr(
|
|
812
990
|
prNumber: number,
|
|
813
991
|
user: AuthUser,
|