@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subject line a change request's merge commit carries, written in one place
|
|
3
|
+
* and READ BACK in another.
|
|
4
|
+
*
|
|
5
|
+
* `mergePr` has always built this subject; what is new is that something now
|
|
6
|
+
* depends on reading it. An applied change request is read back from the merge
|
|
7
|
+
* commit its row records (`merged_sha`), and that row cannot be trusted to point
|
|
8
|
+
* at a commit this request created: when the target already contains the source,
|
|
9
|
+
* `mergeChangeRequest` makes no commit at all and reports the TARGET TIP as the
|
|
10
|
+
* merged state. In a deployment that lands everything through change requests
|
|
11
|
+
* that tip is usually ANOTHER request's merge commit — so reading "the recorded
|
|
12
|
+
* commit's own change" would answer with somebody else's files, under this
|
|
13
|
+
* request's number (cubic P1 on PR #347).
|
|
14
|
+
*
|
|
15
|
+
* The write side is fixed too — a merge that creates no commit now records no
|
|
16
|
+
* sha — but rows written before that fix exist, so the read verifies rather than
|
|
17
|
+
* assumes. The number in the subject is the cheapest honest proof available: it
|
|
18
|
+
* is in the commit, it is immutable, and no other request's merge commit carries
|
|
19
|
+
* it.
|
|
20
|
+
*
|
|
21
|
+
* Both functions live here, next to each other, so the format cannot drift on
|
|
22
|
+
* one side only. `git.service.appliedChangeShas` is tested against a commit
|
|
23
|
+
* written with {@link mergeCommitSubject}, so changing the format without
|
|
24
|
+
* changing the predicate fails the suite rather than quietly making every
|
|
25
|
+
* applied request author-only.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The merge commit's subject for request `number` titled `title`.
|
|
30
|
+
*
|
|
31
|
+
* The title is flattened to ONE line, because the number only proves anything
|
|
32
|
+
* where git reports it. `%s` is the first PARAGRAPH of the message joined with
|
|
33
|
+
* spaces, so a single newline in the title is harmless — but a BLANK line (two
|
|
34
|
+
* newlines, or a whitespace-only line) ends that paragraph, and nothing rejects
|
|
35
|
+
* one: a title is stored `.trim()`-ed and the tool schema bounds only its
|
|
36
|
+
* length. The subject would then end before `(#N)`,
|
|
37
|
+
* {@link mergeCommitSubjectNames} would refuse the request's OWN merge commit,
|
|
38
|
+
* and every applied request with such a title would read as author-only with no
|
|
39
|
+
* files (cubic P2 on PR #347).
|
|
40
|
+
*
|
|
41
|
+
* Flattened here rather than rejected at the write side so the titles already
|
|
42
|
+
* stored are covered too — and because a merge is the wrong moment to discover
|
|
43
|
+
* that a title typed days ago is unacceptable.
|
|
44
|
+
*/
|
|
45
|
+
export function mergeCommitSubject(title: string, number: number): string {
|
|
46
|
+
return `${title.replace(/\s+/g, ' ').trim()} (#${number})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `subject` is the subject of request `number`'s own merge commit.
|
|
51
|
+
*
|
|
52
|
+
* Matched at the END, because that is where {@link mergeCommitSubject} puts the
|
|
53
|
+
* number and a title may contain anything — including another request's number,
|
|
54
|
+
* which a looser match would accept.
|
|
55
|
+
*/
|
|
56
|
+
export function mergeCommitSubjectNames(subject: string, number: number): boolean {
|
|
57
|
+
return subject.trimEnd().endsWith(`(#${number})`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether `message` — a merge commit's WHOLE message — is request `number`'s
|
|
62
|
+
* own, by the subject rule above or, given the request's stored `title`, by the
|
|
63
|
+
* form an earlier release wrote.
|
|
64
|
+
*
|
|
65
|
+
* That release did not flatten the title, so a title with a blank line in it
|
|
66
|
+
* put `(#N)` after the blank line, and git's `%s` subject ended before it: such
|
|
67
|
+
* rows failed {@link mergeCommitSubjectNames} and read as author-only for good.
|
|
68
|
+
* The commit is still exactly `<title> (#N)` followed by a blank line and the
|
|
69
|
+
* body, and the row still holds the title — so a message that opens with the
|
|
70
|
+
* title (its whitespace matched loosely), then ` (#N)`, then the END OF THAT
|
|
71
|
+
* LINE, is this request's. The line end is what keeps the match this
|
|
72
|
+
* request's: a title may contain another request's number (`Fix crash (#42)`),
|
|
73
|
+
* and a CURRENT-format commit of request 43 with that title reads
|
|
74
|
+
* `Fix crash (#42) (#43)` — the number of 42 there is followed by more text on
|
|
75
|
+
* the line, so it does not name 42's commit. Only `title` can vouch for the
|
|
76
|
+
* old format: the number sits wherever that format put it, which nothing else
|
|
77
|
+
* in a message may say.
|
|
78
|
+
*/
|
|
79
|
+
export function mergeCommitMessageNames(message: string, number: number, title?: string): boolean {
|
|
80
|
+
// The first paragraph, which is what git reports as `%s`.
|
|
81
|
+
const subject = message.split(/\r?\n[ \t]*\r?\n/, 1)[0] ?? '';
|
|
82
|
+
if (mergeCommitSubjectNames(subject.replace(/\s+/g, ' '), number)) return true;
|
|
83
|
+
if (title === undefined) return false;
|
|
84
|
+
const words = title.trim().split(/\s+/).map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
85
|
+
if (words.length === 0 || words[0] === '') return false;
|
|
86
|
+
const oldFormat = new RegExp(`^\\s*${words.join('\\s+')}\\s+\\(#${number}\\)[ \\t]*(?:\\r?\\n|$)`);
|
|
87
|
+
return oldFormat.test(message);
|
|
88
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { desc, eq } from 'drizzle-orm';
|
|
1
|
+
import { desc, eq, inArray } from 'drizzle-orm';
|
|
2
2
|
import { logger } from '../../../shared/logging.js';
|
|
3
3
|
|
|
4
4
|
const log = logger('cr');
|
|
5
5
|
import type {
|
|
6
|
+
AppliedChangeRef,
|
|
7
|
+
ChangedPathPair,
|
|
6
8
|
FileApprovalState,
|
|
7
9
|
IPullRequestService,
|
|
8
10
|
PrReviewComment,
|
|
@@ -18,15 +20,127 @@ import { changeRequests } from '../../database/schema.js';
|
|
|
18
20
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
19
21
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
20
22
|
import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
21
|
-
import { WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
23
|
+
import { AppliedChangeMismatchError, WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
22
24
|
import { canonicalEmail, hashEmail } from '../../../shared/email-identity.js';
|
|
23
25
|
import { changeRequestLink, changeRequestLinkBase } from './change-request-link.js';
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The latest moment a change-request row records — its close time when it has
|
|
29
|
+
* one, else its creation time, with `updated_at` folded in for the day
|
|
30
|
+
* something writes it. Deliberately derived from the ROW alone: a time taken
|
|
31
|
+
* from the newest comment or approval would make the same request report
|
|
32
|
+
* different "last changed" moments to a list (which reads no comments) and a
|
|
33
|
+
* detail (which does).
|
|
34
|
+
*/
|
|
35
|
+
function latestRowMoment(row: {
|
|
36
|
+
createdAt: Date;
|
|
37
|
+
updatedAt: Date | null;
|
|
38
|
+
closedAt: Date | null;
|
|
39
|
+
}): string {
|
|
40
|
+
const times = [row.createdAt, row.updatedAt, row.closedAt]
|
|
41
|
+
.filter((d): d is Date => d instanceof Date)
|
|
42
|
+
.map((d) => d.getTime());
|
|
43
|
+
return new Date(Math.max(...times)).toISOString();
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
const LIST_PR_CACHE_TTL_MS = 30_000;
|
|
47
|
+
/**
|
|
48
|
+
* How many applied requests' file lists are remembered at once. Each is a
|
|
49
|
+
* short path list, so this is about a deployment that applies requests for
|
|
50
|
+
* years never growing the map without bound. Past it, what is remembered
|
|
51
|
+
* stays and new answers are not kept — see `touchedPathsFor` for why nothing
|
|
52
|
+
* is evicted.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_REMEMBERED_APPLIED_CHANGES = 20_000;
|
|
55
|
+
/** How many mismatched applied rows to remember — see `touchedPathsFor`. */
|
|
56
|
+
const MAX_MISMATCHED_APPLIED_CHANGES = 5_000;
|
|
57
|
+
/** How many rows a listing reads from git at once. */
|
|
58
|
+
const SUMMARY_CONCURRENCY = 8;
|
|
59
|
+
|
|
60
|
+
/** `fn` over `items`, at most `limit` in flight, results in order. */
|
|
61
|
+
async function mapWithLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
62
|
+
const results: R[] = new Array(items.length);
|
|
63
|
+
let next = 0;
|
|
64
|
+
const worker = async (): Promise<void> => {
|
|
65
|
+
while (next < items.length) {
|
|
66
|
+
const index = next;
|
|
67
|
+
next += 1;
|
|
68
|
+
results[index] = await fn(items[index]!);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
26
74
|
const DETAIL_CACHE_TTL_MS = 30_000;
|
|
27
75
|
|
|
28
76
|
type ChangeRequestRow = typeof changeRequests.$inferSelect;
|
|
29
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Where a change request's files are read from.
|
|
80
|
+
*
|
|
81
|
+
* - `branches` — the source/target pair, as a live proposal is read.
|
|
82
|
+
* - `commit` — the merge commit the row records, as an applied one is.
|
|
83
|
+
* - `none` — nothing: there is no durable record of what this request
|
|
84
|
+
* proposed, so it has no file list to show.
|
|
85
|
+
*/
|
|
86
|
+
export type ChangeSource =
|
|
87
|
+
| { kind: 'branches' }
|
|
88
|
+
| { kind: 'commit'; applied: AppliedChangeRef }
|
|
89
|
+
| { kind: 'none' };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which diff a change-request row is read from — asked ONCE, here, by both the
|
|
93
|
+
* list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
|
|
94
|
+
* surfaces answered this question separately before, and so answered it
|
|
95
|
+
* differently: the list gave a declined request no paths while the detail
|
|
96
|
+
* resolved its branch pair, which left `list_change_requests` and the four
|
|
97
|
+
* by-number tools contradicting each other about the same request for the same
|
|
98
|
+
* caller. One function, one answer.
|
|
99
|
+
*
|
|
100
|
+
* An OPEN request is its two branch tips, which is what a proposal IS.
|
|
101
|
+
*
|
|
102
|
+
* An APPLIED one is the merge commit its row records: the branch is retired, and
|
|
103
|
+
* the commit is local, immutable, and holds exactly what landed.
|
|
104
|
+
*
|
|
105
|
+
* A DECLINED one is read from NOTHING, and that is not a degradation:
|
|
106
|
+
*
|
|
107
|
+
* - The row records no sha, so nothing durable says what the request
|
|
108
|
+
* proposed. Declining retires the source branch as merging does
|
|
109
|
+
* (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
|
|
110
|
+
* another open request still needs that branch, which keeps it alive and
|
|
111
|
+
* moving. Where the branch pair still resolves it would answer with what
|
|
112
|
+
* that branch differs by NOW, work committed since for the request that
|
|
113
|
+
* kept it included; reading the declined request would then present later
|
|
114
|
+
* work as the proposal that was turned down.
|
|
115
|
+
* - An empty file set proves no read access downstream, so a declined request
|
|
116
|
+
* is readable by its author alone — the owner's criterion of 2026-10-02.
|
|
117
|
+
* - And it asks git nothing, so neither a listing nor a by-number read of a
|
|
118
|
+
* declined request costs a network round trip.
|
|
119
|
+
*
|
|
120
|
+
* A merged row that records no merge commit (or whose commit this clone has not
|
|
121
|
+
* fetched) lands on `none` too, and fails closed the same way until it can be
|
|
122
|
+
* read in full.
|
|
123
|
+
*/
|
|
124
|
+
export function changeSourceFor(row: {
|
|
125
|
+
number: number;
|
|
126
|
+
state: string;
|
|
127
|
+
/** The stored title, handed along so a merge commit in the old message format is still recognised. */
|
|
128
|
+
title?: string;
|
|
129
|
+
mergedSha?: string | null;
|
|
130
|
+
}): ChangeSource {
|
|
131
|
+
if (row.state === 'open') return { kind: 'branches' };
|
|
132
|
+
// The NUMBER travels with the sha, because reading the commit's own change is
|
|
133
|
+
// only sound if the commit is this request's merge commit — and the number is
|
|
134
|
+
// what proves it (the subject ends with `(#<number>)`). A row whose
|
|
135
|
+
// `merged_sha` was written by a merge that made no commit points at the target
|
|
136
|
+
// tip, which is usually another request's merge commit; the git layer rejects
|
|
137
|
+
// that rather than answering with its files. See `merge-commit.ts`.
|
|
138
|
+
if (row.state === 'merged' && row.mergedSha) {
|
|
139
|
+
return { kind: 'commit', applied: { number: row.number, mergeSha: row.mergedSha, title: row.title } };
|
|
140
|
+
}
|
|
141
|
+
return { kind: 'none' };
|
|
142
|
+
}
|
|
143
|
+
|
|
30
144
|
/**
|
|
31
145
|
* The slice of the review-workflow service this module needs to compose detail
|
|
32
146
|
* responses. Kept narrow (just what `getPrDetail` stitches in) so the PR
|
|
@@ -90,6 +204,14 @@ export class PullRequestService implements IPullRequestService {
|
|
|
90
204
|
* list cache stores, so it lives and dies with them.
|
|
91
205
|
*/
|
|
92
206
|
private routingPaths = new WeakMap<PullRequestSummary, string[]>();
|
|
207
|
+
/**
|
|
208
|
+
* The files an APPLIED request landed, per clone and merge commit. A merge
|
|
209
|
+
* commit is immutable, so its answer is good for the life of the process;
|
|
210
|
+
* nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
|
|
211
|
+
*/
|
|
212
|
+
private readonly appliedChanges = new Map<string, { paths: string[]; pairs: ChangedPathPair[] }>();
|
|
213
|
+
/** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
|
|
214
|
+
private readonly mismatchedAppliedChanges = new Set<string>();
|
|
93
215
|
/**
|
|
94
216
|
* Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
|
|
95
217
|
* The payload includes per-file approvals resolved against the caller's
|
|
@@ -145,7 +267,11 @@ export class PullRequestService implements IPullRequestService {
|
|
|
145
267
|
return this.workspaceService.findAnyWorkspaceId();
|
|
146
268
|
}
|
|
147
269
|
|
|
148
|
-
private rowToSummary(
|
|
270
|
+
private rowToSummary(
|
|
271
|
+
row: ChangeRequestRow,
|
|
272
|
+
touchedNodePaths: string[],
|
|
273
|
+
touchedNodeFiles: ChangedPathPair[],
|
|
274
|
+
): PullRequestSummary {
|
|
149
275
|
return {
|
|
150
276
|
number: row.number,
|
|
151
277
|
title: row.title,
|
|
@@ -160,7 +286,14 @@ export class PullRequestService implements IPullRequestService {
|
|
|
160
286
|
base: row.targetBranch,
|
|
161
287
|
state: row.state as PullRequestState,
|
|
162
288
|
createdAt: row.createdAt.toISOString(),
|
|
289
|
+
// The latest moment the ROW records. Nothing stamps `updated_at` on a
|
|
290
|
+
// change request today, so in practice this is the close time of a
|
|
291
|
+
// closed request and the creation time of an open one — the honest
|
|
292
|
+
// answer from the row, with the column folded in for the day something
|
|
293
|
+
// does write it.
|
|
294
|
+
updatedAt: latestRowMoment(row),
|
|
163
295
|
touchedNodePaths,
|
|
296
|
+
touchedNodeFiles,
|
|
164
297
|
// Provider reviews are gone; the real approval state lives in the detail
|
|
165
298
|
// view (per-file, DB-backed). The summary badge is derived there.
|
|
166
299
|
review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
|
|
@@ -183,25 +316,86 @@ export class PullRequestService implements IPullRequestService {
|
|
|
183
316
|
/**
|
|
184
317
|
* Cheap touched-paths for a CR row (empty when no workspace exists yet),
|
|
185
318
|
* placeholders included — see {@link summaryOf} for what a summary shows.
|
|
319
|
+
*
|
|
320
|
+
* WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
|
|
321
|
+
* takes, so a list and a by-number read never disagree about which files a
|
|
322
|
+
* request has. No state reaches the network per request: an open row is
|
|
323
|
+
* diffed from two branch tips a single whole-clone refresh has already brought
|
|
324
|
+
* up to date (`fetch: false`), an applied one from a local immutable commit,
|
|
325
|
+
* and a declined one from nothing at all.
|
|
326
|
+
*
|
|
327
|
+
* The merge commit also undid the flood finding 1 of Razvan's review named:
|
|
328
|
+
* `publishedPrCommits` answers null for a retired branch, which sent
|
|
329
|
+
* `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
|
|
330
|
+
* REQUEST and then logged a warning for each, so a list of a few hundred
|
|
331
|
+
* applied requests opened a few hundred concurrent fetches to answer nothing.
|
|
186
332
|
*/
|
|
187
333
|
private async touchedPathsFor(
|
|
188
334
|
row: ChangeRequestRow,
|
|
189
335
|
workspaceId: string | null,
|
|
190
336
|
opts: { fetch?: boolean } = {},
|
|
191
|
-
): Promise<string[]> {
|
|
192
|
-
|
|
337
|
+
): Promise<{ paths: string[]; pairs: ChangedPathPair[] }> {
|
|
338
|
+
const empty = { paths: [] as string[], pairs: [] as ChangedPathPair[] };
|
|
339
|
+
if (!workspaceId) return empty;
|
|
340
|
+
// Best-effort, but logged: an empty result silently hides a CR from the
|
|
341
|
+
// owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
|
|
342
|
+
// shouldn't be invisible.
|
|
343
|
+
const degrade = (what: string) => (err: unknown) => {
|
|
344
|
+
const where = `#${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}`;
|
|
345
|
+
// A clone that simply does not hold the merge commit yet is not a failure
|
|
346
|
+
// to shout about — it is this row's turn to be fetched, and the next list
|
|
347
|
+
// read answers it. Warning per request on that would be the log flood the
|
|
348
|
+
// network flood came with.
|
|
349
|
+
if (err instanceof WorkflowValidationError) {
|
|
350
|
+
log.debug(`${what} could not resolve ${where}; answering no touched paths:`, { err });
|
|
351
|
+
} else {
|
|
352
|
+
log.warn(`${what} failed for ${where}:`, { err });
|
|
353
|
+
}
|
|
354
|
+
return empty;
|
|
355
|
+
};
|
|
356
|
+
const source = changeSourceFor(row);
|
|
357
|
+
if (source.kind === 'none') return empty;
|
|
358
|
+
if (source.kind === 'commit') {
|
|
359
|
+
// Remembered once read: a merge commit never changes, so neither does
|
|
360
|
+
// its diff, and a list of a few hundred applied requests would otherwise
|
|
361
|
+
// run four git processes per row on every call. Only an ANSWER is kept.
|
|
362
|
+
// A commit this clone does not hold yet rejects, and that is this row's
|
|
363
|
+
// turn to be fetched, so the next read asks again.
|
|
364
|
+
const key = `${workspaceId}\u0000${source.applied.mergeSha}\u0000${source.applied.number}`;
|
|
365
|
+
const known = this.appliedChanges.get(key);
|
|
366
|
+
if (known) return known;
|
|
367
|
+
// A row whose commit the clone holds but which is NOT this request's
|
|
368
|
+
// own (a `merged_sha` written before merges recorded their own commit)
|
|
369
|
+
// never becomes it, and is remembered as such, bounded in count:
|
|
370
|
+
// without this, every list re-ran its git calls for every such row,
|
|
371
|
+
// serialized under the workspace mutex, on every poll, and a deployment
|
|
372
|
+
// with hundreds of pre-fix rows paid seconds of git per poll
|
|
373
|
+
// indefinitely. A commit the clone does not HOLD is not remembered —
|
|
374
|
+
// the next fetch may bring it, and the next list asks again.
|
|
375
|
+
if (this.mismatchedAppliedChanges.has(key)) return empty;
|
|
376
|
+
return this.gitService
|
|
377
|
+
.changedPathsAndPairsOfAppliedChange(workspaceId, source.applied)
|
|
378
|
+
.then((answer) => {
|
|
379
|
+
// Full: what is remembered stays, and this answer is simply not
|
|
380
|
+
// kept. A list scans every applied row in one pass, so clearing or
|
|
381
|
+
// evicting here would throw out entries the SAME pass is about to
|
|
382
|
+
// ask for again, and a deployment past the cap would recompute
|
|
383
|
+
// every row on every list. Kept, the rows past the cap are the only
|
|
384
|
+
// ones that cost git anything.
|
|
385
|
+
if (this.appliedChanges.size < MAX_REMEMBERED_APPLIED_CHANGES) this.appliedChanges.set(key, answer);
|
|
386
|
+
return answer;
|
|
387
|
+
})
|
|
388
|
+
.catch((err: unknown) => {
|
|
389
|
+
if (err instanceof AppliedChangeMismatchError) {
|
|
390
|
+
if (this.mismatchedAppliedChanges.size >= MAX_MISMATCHED_APPLIED_CHANGES) this.mismatchedAppliedChanges.clear();
|
|
391
|
+
this.mismatchedAppliedChanges.add(key);
|
|
392
|
+
}
|
|
393
|
+
return degrade('changedPathsAndPairsOfAppliedChange')(err);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
193
396
|
return this.gitService
|
|
194
|
-
.
|
|
195
|
-
.catch((
|
|
196
|
-
// Best-effort, but log it: an empty result silently hides a CR from the
|
|
197
|
-
// owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
|
|
198
|
-
// shouldn't be invisible.
|
|
199
|
-
log.warn(
|
|
200
|
-
`changedPathsForPr failed for #${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}:`,
|
|
201
|
-
{ err },
|
|
202
|
-
);
|
|
203
|
-
return [] as string[];
|
|
204
|
-
});
|
|
397
|
+
.changedPathsAndPairsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
|
|
398
|
+
.catch(degrade('changedPathsAndPairsForPr'));
|
|
205
399
|
}
|
|
206
400
|
|
|
207
401
|
/**
|
|
@@ -216,8 +410,14 @@ export class PullRequestService implements IPullRequestService {
|
|
|
216
410
|
opts: { fetch?: boolean } = {},
|
|
217
411
|
): Promise<PullRequestSummary> {
|
|
218
412
|
const touched = await this.touchedPathsFor(row, workspaceId, opts);
|
|
219
|
-
const summary = this.rowToSummary(
|
|
220
|
-
|
|
413
|
+
const summary = this.rowToSummary(
|
|
414
|
+
row,
|
|
415
|
+
touched.paths.filter((p) => !isFolderPlaceholder(p)),
|
|
416
|
+
// Already placeholder-free and roles.yaml-free (see `changedPathPairs`),
|
|
417
|
+
// so the pairs need no filtering of their own here.
|
|
418
|
+
touched.pairs,
|
|
419
|
+
);
|
|
420
|
+
this.routingPaths.set(summary, touched.paths);
|
|
221
421
|
return summary;
|
|
222
422
|
}
|
|
223
423
|
|
|
@@ -274,6 +474,50 @@ export class PullRequestService implements IPullRequestService {
|
|
|
274
474
|
return summaries;
|
|
275
475
|
}
|
|
276
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Every request in `states`, newest first. `['open']` is delegated so the
|
|
479
|
+
* list the app polls keeps its cache; any other set is read straight from
|
|
480
|
+
* the table, because a closed-request read is rare and a second cache keyed
|
|
481
|
+
* on a state set would mostly hold misses.
|
|
482
|
+
*
|
|
483
|
+
* Touched paths are best-effort exactly as on the open list, and no row costs
|
|
484
|
+
* a network call of its own: a MERGED row is diffed from the merge commit it
|
|
485
|
+
* records (local and immutable), and a DECLINED one is not diffed at all —
|
|
486
|
+
* see {@link touchedPathsFor}. A caller that gates on those paths must still
|
|
487
|
+
* treat an empty set as "cannot prove", never as "nothing to protect".
|
|
488
|
+
*/
|
|
489
|
+
async listPrsByState(
|
|
490
|
+
states: PullRequestState[],
|
|
491
|
+
opts: { fresh?: boolean; workspaceId?: string } = {},
|
|
492
|
+
): Promise<PullRequestSummary[]> {
|
|
493
|
+
const wanted = [...new Set(states)];
|
|
494
|
+
if (wanted.length === 0) return [];
|
|
495
|
+
if (wanted.length === 1 && wanted[0] === 'open') return this.listOpenPrs(opts);
|
|
496
|
+
const workspaceId = await this.resolveWorkspaceId(opts.workspaceId);
|
|
497
|
+
const rows = await this.db
|
|
498
|
+
.select()
|
|
499
|
+
.from(changeRequests)
|
|
500
|
+
.where(inArray(changeRequests.state, wanted))
|
|
501
|
+
.orderBy(desc(changeRequests.createdAt));
|
|
502
|
+
// ONE fetch for the whole list, for the reason spelled out on listOpenPrs —
|
|
503
|
+
// never one per row — and only when a row in scope reads from git at all.
|
|
504
|
+
// An open request is diffed from two branch refs, which are as current as
|
|
505
|
+
// the last fetch; a MERGED one from its merge commit, which cannot change
|
|
506
|
+
// but which a clone that has not fetched since the merge does not hold —
|
|
507
|
+
// without this fetch such a clone would read every applied request as
|
|
508
|
+
// author-only for good. A declined one reads from nothing, so a listing
|
|
509
|
+
// of declined requests alone reaches the network not once.
|
|
510
|
+
if (workspaceId && rows.some((row) => row.state === 'open' || row.state === 'merged')) {
|
|
511
|
+
await this.workspaceService
|
|
512
|
+
.ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
|
|
513
|
+
.catch(() => undefined);
|
|
514
|
+
}
|
|
515
|
+
// Bounded fan-out: an applied row's read takes the workspace mutex for
|
|
516
|
+
// its git calls, so a hundred rows launched at once would only queue on
|
|
517
|
+
// it while holding a hundred pending promises.
|
|
518
|
+
return mapWithLimit(rows, SUMMARY_CONCURRENCY, (row) => this.summaryOf(row, workspaceId, { fetch: false }));
|
|
519
|
+
}
|
|
520
|
+
|
|
277
521
|
async listPrsAuthoredBy(
|
|
278
522
|
loginOrEmail: string,
|
|
279
523
|
opts: { fresh?: boolean } = {},
|
|
@@ -448,42 +692,114 @@ export class PullRequestService implements IPullRequestService {
|
|
|
448
692
|
};
|
|
449
693
|
/** Did the target change, since the fork point, a file this request changes? */
|
|
450
694
|
let targetChangedShared = false;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
695
|
+
// The SAME routing the list takes, from the same function, so the two
|
|
696
|
+
// surfaces cannot disagree about which files a request has — see
|
|
697
|
+
// {@link changeSourceFor} for why each state reads from what it does.
|
|
698
|
+
const source = workspaceId ? changeSourceFor(row) : ({ kind: 'none' } as ChangeSource);
|
|
699
|
+
try {
|
|
700
|
+
if (workspaceId && source.kind === 'commit') {
|
|
701
|
+
// An APPLIED request is read from its merge commit, not from its
|
|
702
|
+
// branches: the source branch is retired, so there is nothing to resolve
|
|
703
|
+
// and nothing to fetch. The commit's first parent is the target as it
|
|
704
|
+
// stood before the merge and its second is the source tip that landed,
|
|
705
|
+
// so the file list is exactly what was applied, and the `headSha` the
|
|
706
|
+
// approvals are judged stale against is the head they were given on.
|
|
707
|
+
//
|
|
708
|
+
// Before the 2026-10-02 decision this fell into the catch below and
|
|
709
|
+
// answered no files — which, since an empty file set proves no read
|
|
710
|
+
// access, made every applied request readable by its author alone.
|
|
711
|
+
// Reading back what happened is what the ticket exists for.
|
|
712
|
+
//
|
|
713
|
+
// One refresh first: the commit cannot change, but a clone that has
|
|
714
|
+
// not fetched since the merge does not hold it yet, and reading it as
|
|
715
|
+
// missing would make the request author-only on that clone for good.
|
|
716
|
+
await this.workspaceService
|
|
717
|
+
.ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
|
|
718
|
+
.catch(() => undefined);
|
|
719
|
+
const ends = await this.gitService.appliedChangeShas(workspaceId, source.applied);
|
|
720
|
+
baseSha = ends.baseSha;
|
|
721
|
+
headSha = ends.headSha;
|
|
722
|
+
files = await this.gitService.changedFilesOfAppliedChange(workspaceId, source.applied, {
|
|
723
|
+
...(opts.patches === false ? { patchCap: 0 } : {}),
|
|
724
|
+
});
|
|
725
|
+
// `forkPoint` and `targetChangedShared` stay at their defaults. "Is this
|
|
726
|
+
// behind its target, and does the divergence reach its files" is a
|
|
727
|
+
// question about a proposal that could still be updated; an applied one
|
|
728
|
+
// has no answer to give and reports none (`behind` and `needsUpdate` are
|
|
729
|
+
// gated on `state === 'open'` downstream anyway).
|
|
730
|
+
} else if (workspaceId && source.kind === 'branches') {
|
|
731
|
+
const shas = await this.gitService.resolvePrShas(
|
|
732
|
+
workspaceId,
|
|
733
|
+
row.targetBranch,
|
|
734
|
+
row.sourceBranch,
|
|
735
|
+
);
|
|
736
|
+
baseSha = shas.baseSha;
|
|
737
|
+
headSha = shas.headSha;
|
|
738
|
+
// The file list is pinned to the SHAs just resolved (`at`), so it and
|
|
739
|
+
// the `headSha` approvals pin against describe the same commits even if
|
|
740
|
+
// another fetch lands on this workspace in between, and the second
|
|
741
|
+
// fetch of the same two refs is gone. `patches: false` is for the
|
|
742
|
+
// internal detail an approve / withdraw / revert fetches to pin its
|
|
743
|
+
// work: those never read `files[].patch`, and generating it cost one git
|
|
744
|
+
// subprocess per changed file per click. The detail served to clients
|
|
745
|
+
// keeps its patches, so the published payload is unchanged.
|
|
746
|
+
files = await this.gitService.changedFilesForPr(
|
|
747
|
+
workspaceId,
|
|
748
|
+
row.targetBranch,
|
|
749
|
+
row.sourceBranch,
|
|
750
|
+
{ at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) },
|
|
751
|
+
);
|
|
752
|
+
// Pinned to the same two commits as the file list, so "needs updating"
|
|
753
|
+
// and the diff it qualifies can never describe different heads.
|
|
754
|
+
forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
|
|
755
|
+
// Only a target that moved in a file THIS request also changes makes the
|
|
756
|
+
// proposal's diff describe text that has moved. One extra
|
|
757
|
+
// `diff --name-only` (and only when the branches have diverged at all)
|
|
758
|
+
// buys the difference between opening instantly and paying for a merge,
|
|
759
|
+
// a push and a second detail read.
|
|
760
|
+
targetChangedShared = await this.targetTouchesRequestFiles(
|
|
761
|
+
workspaceId,
|
|
762
|
+
forkPoint,
|
|
763
|
+
baseSha,
|
|
764
|
+
files,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
// `source.kind === 'none'` — a DECLINED request, a merged row recording no
|
|
768
|
+
// merge commit, or no workspace at all — asks git NOTHING and is answered
|
|
769
|
+
// from the ROW ALONE: no shas, no files. The row's STATE decides that, not
|
|
770
|
+
// whether git happens to fail, which is the fix for the contradiction
|
|
771
|
+
// Local Testing found: declining does not retire the source branch (only
|
|
772
|
+
// merging deletes it), so the branch pair below resolved a declined
|
|
773
|
+
// request perfectly well and published its files to anyone who could read
|
|
774
|
+
// one of them — while the list, which gives a declined row no paths at
|
|
775
|
+
// all, hid the same request from the same caller.
|
|
776
|
+
} catch (err) {
|
|
777
|
+
// What reaches this is a MERGED request whose merge commit this clone
|
|
778
|
+
// cannot resolve — not fetched yet, or a sha the row records that the
|
|
779
|
+
// object store does not hold. A DECLINED request never gets here at all
|
|
780
|
+
// any more: it asks git nothing (see above), so it cannot fail.
|
|
781
|
+
//
|
|
782
|
+
// Everything the ROW records — title, body, state, author, times — is
|
|
783
|
+
// still true, and this is the degradation the no-workspace case above
|
|
784
|
+
// already takes: no shas and no files, rather than no answer at all. Since
|
|
785
|
+
// an empty file set proves no read access, such a row fails CLOSED to its
|
|
786
|
+
// author, and the next read, once the commit is in the clone, answers in
|
|
787
|
+
// full.
|
|
788
|
+
//
|
|
789
|
+
// An OPEN request still fails loudly. There, an unresolvable branch means
|
|
790
|
+
// a branch not yet published or a clone not yet caught up, and presenting
|
|
791
|
+
// a live proposal as one that changes nothing would tell a reviewer the
|
|
792
|
+
// opposite of the truth.
|
|
793
|
+
if (!(err instanceof WorkflowValidationError) || row.state === 'open') throw err;
|
|
794
|
+
log.warn(
|
|
795
|
+
`change request #${prNumber} is ${row.state} and can no longer be diffed; answering from its row alone:`,
|
|
796
|
+
{ err },
|
|
486
797
|
);
|
|
798
|
+
baseSha = '';
|
|
799
|
+
headSha = '';
|
|
800
|
+
files = [];
|
|
801
|
+
forkPoint = { mergeBaseSha: null, behind: false };
|
|
802
|
+
targetChangedShared = false;
|
|
487
803
|
}
|
|
488
804
|
|
|
489
805
|
// Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
|
|
@@ -499,7 +815,17 @@ export class PullRequestService implements IPullRequestService {
|
|
|
499
815
|
return cached.value;
|
|
500
816
|
}
|
|
501
817
|
|
|
502
|
-
|
|
818
|
+
// The detail has the real file list, so its pairs come straight off it —
|
|
819
|
+
// the same shape the list builds from its own diff, so a reader gating on
|
|
820
|
+
// `touchedNodeFiles` gets the same verdict from a summary and a detail.
|
|
821
|
+
const summary = this.rowToSummary(
|
|
822
|
+
row,
|
|
823
|
+
files.map((f) => f.path),
|
|
824
|
+
files.map((f) => ({
|
|
825
|
+
path: f.path,
|
|
826
|
+
...(f.previousPath ? { previousPath: f.previousPath } : {}),
|
|
827
|
+
})),
|
|
828
|
+
);
|
|
503
829
|
|
|
504
830
|
// Comments + approvals come from our own DB via the review-workflow service.
|
|
505
831
|
// The enricher is optional — if it isn't wired yet (startup ordering,
|
|
@@ -4,7 +4,13 @@ import type { PullRequestFile } from '@bevel-software/platform-shared';
|
|
|
4
4
|
// mergePr merges locally through GitService now (no `gh`), so we stub the git
|
|
5
5
|
// seam and spy on it. `mergeChangeRequestMock` stands in for the real
|
|
6
6
|
// merge+push; asserting whether it was called replaces the old `gh`-call checks.
|
|
7
|
-
const mergeChangeRequestMock = vi.fn(async () => ({
|
|
7
|
+
const mergeChangeRequestMock = vi.fn(async () => ({
|
|
8
|
+
kind: 'merged' as const,
|
|
9
|
+
sha: 'merged-sha',
|
|
10
|
+
// The commit the request owns — `sha` and `mergeCommit` coincide whenever the
|
|
11
|
+
// merge wrote one, which is the ordinary case these tests exercise.
|
|
12
|
+
mergeCommit: 'merged-sha',
|
|
13
|
+
}));
|
|
8
14
|
|
|
9
15
|
import { ReviewWorkflowService } from '../review-workflow.service.js';
|
|
10
16
|
import { changeRequests } from '../../../database/schema.js';
|