@bevel-software/platform-core-backend 0.25.0 → 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/kb-startup-runner.d.ts +70 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
- package/dist/modules/workspace/startup/kb-startup-runner.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/__tests__/kb-startup-runner.test.ts +231 -1
- package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
- 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
|
@@ -1,7 +1,63 @@
|
|
|
1
|
-
import type { FileApprovalState, IPullRequestService, PrReviewComment, PullRequestDetail, PullRequestFile, PullRequestState, PullRequestSummary, IGitService } from '@bevel-software/platform-shared';
|
|
1
|
+
import type { AppliedChangeRef, FileApprovalState, IPullRequestService, PrReviewComment, PullRequestDetail, PullRequestFile, PullRequestState, PullRequestSummary, IGitService } from '@bevel-software/platform-shared';
|
|
2
2
|
import type { Database } from '../../database/connection.js';
|
|
3
3
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
4
4
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where a change request's files are read from.
|
|
7
|
+
*
|
|
8
|
+
* - `branches` — the source/target pair, as a live proposal is read.
|
|
9
|
+
* - `commit` — the merge commit the row records, as an applied one is.
|
|
10
|
+
* - `none` — nothing: there is no durable record of what this request
|
|
11
|
+
* proposed, so it has no file list to show.
|
|
12
|
+
*/
|
|
13
|
+
export type ChangeSource = {
|
|
14
|
+
kind: 'branches';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'commit';
|
|
17
|
+
applied: AppliedChangeRef;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'none';
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Which diff a change-request row is read from — asked ONCE, here, by both the
|
|
23
|
+
* list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
|
|
24
|
+
* surfaces answered this question separately before, and so answered it
|
|
25
|
+
* differently: the list gave a declined request no paths while the detail
|
|
26
|
+
* resolved its branch pair, which left `list_change_requests` and the four
|
|
27
|
+
* by-number tools contradicting each other about the same request for the same
|
|
28
|
+
* caller. One function, one answer.
|
|
29
|
+
*
|
|
30
|
+
* An OPEN request is its two branch tips, which is what a proposal IS.
|
|
31
|
+
*
|
|
32
|
+
* An APPLIED one is the merge commit its row records: the branch is retired, and
|
|
33
|
+
* the commit is local, immutable, and holds exactly what landed.
|
|
34
|
+
*
|
|
35
|
+
* A DECLINED one is read from NOTHING, and that is not a degradation:
|
|
36
|
+
*
|
|
37
|
+
* - The row records no sha, so nothing durable says what the request
|
|
38
|
+
* proposed. Declining retires the source branch as merging does
|
|
39
|
+
* (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
|
|
40
|
+
* another open request still needs that branch, which keeps it alive and
|
|
41
|
+
* moving. Where the branch pair still resolves it would answer with what
|
|
42
|
+
* that branch differs by NOW, work committed since for the request that
|
|
43
|
+
* kept it included; reading the declined request would then present later
|
|
44
|
+
* work as the proposal that was turned down.
|
|
45
|
+
* - An empty file set proves no read access downstream, so a declined request
|
|
46
|
+
* is readable by its author alone — the owner's criterion of 2026-10-02.
|
|
47
|
+
* - And it asks git nothing, so neither a listing nor a by-number read of a
|
|
48
|
+
* declined request costs a network round trip.
|
|
49
|
+
*
|
|
50
|
+
* A merged row that records no merge commit (or whose commit this clone has not
|
|
51
|
+
* fetched) lands on `none` too, and fails closed the same way until it can be
|
|
52
|
+
* read in full.
|
|
53
|
+
*/
|
|
54
|
+
export declare function changeSourceFor(row: {
|
|
55
|
+
number: number;
|
|
56
|
+
state: string;
|
|
57
|
+
/** The stored title, handed along so a merge commit in the old message format is still recognised. */
|
|
58
|
+
title?: string;
|
|
59
|
+
mergedSha?: string | null;
|
|
60
|
+
}): ChangeSource;
|
|
5
61
|
/**
|
|
6
62
|
* The slice of the review-workflow service this module needs to compose detail
|
|
7
63
|
* responses. Kept narrow (just what `getPrDetail` stitches in) so the PR
|
|
@@ -64,6 +120,14 @@ export declare class PullRequestService implements IPullRequestService {
|
|
|
64
120
|
* list cache stores, so it lives and dies with them.
|
|
65
121
|
*/
|
|
66
122
|
private routingPaths;
|
|
123
|
+
/**
|
|
124
|
+
* The files an APPLIED request landed, per clone and merge commit. A merge
|
|
125
|
+
* commit is immutable, so its answer is good for the life of the process;
|
|
126
|
+
* nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
|
|
127
|
+
*/
|
|
128
|
+
private readonly appliedChanges;
|
|
129
|
+
/** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
|
|
130
|
+
private readonly mismatchedAppliedChanges;
|
|
67
131
|
/**
|
|
68
132
|
* Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
|
|
69
133
|
* The payload includes per-file approvals resolved against the caller's
|
|
@@ -101,6 +165,19 @@ export declare class PullRequestService implements IPullRequestService {
|
|
|
101
165
|
/**
|
|
102
166
|
* Cheap touched-paths for a CR row (empty when no workspace exists yet),
|
|
103
167
|
* placeholders included — see {@link summaryOf} for what a summary shows.
|
|
168
|
+
*
|
|
169
|
+
* WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
|
|
170
|
+
* takes, so a list and a by-number read never disagree about which files a
|
|
171
|
+
* request has. No state reaches the network per request: an open row is
|
|
172
|
+
* diffed from two branch tips a single whole-clone refresh has already brought
|
|
173
|
+
* up to date (`fetch: false`), an applied one from a local immutable commit,
|
|
174
|
+
* and a declined one from nothing at all.
|
|
175
|
+
*
|
|
176
|
+
* The merge commit also undid the flood finding 1 of Razvan's review named:
|
|
177
|
+
* `publishedPrCommits` answers null for a retired branch, which sent
|
|
178
|
+
* `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
|
|
179
|
+
* REQUEST and then logged a warning for each, so a list of a few hundred
|
|
180
|
+
* applied requests opened a few hundred concurrent fetches to answer nothing.
|
|
104
181
|
*/
|
|
105
182
|
private touchedPathsFor;
|
|
106
183
|
/**
|
|
@@ -114,6 +191,22 @@ export declare class PullRequestService implements IPullRequestService {
|
|
|
114
191
|
fresh?: boolean;
|
|
115
192
|
workspaceId?: string;
|
|
116
193
|
}): Promise<PullRequestSummary[]>;
|
|
194
|
+
/**
|
|
195
|
+
* Every request in `states`, newest first. `['open']` is delegated so the
|
|
196
|
+
* list the app polls keeps its cache; any other set is read straight from
|
|
197
|
+
* the table, because a closed-request read is rare and a second cache keyed
|
|
198
|
+
* on a state set would mostly hold misses.
|
|
199
|
+
*
|
|
200
|
+
* Touched paths are best-effort exactly as on the open list, and no row costs
|
|
201
|
+
* a network call of its own: a MERGED row is diffed from the merge commit it
|
|
202
|
+
* records (local and immutable), and a DECLINED one is not diffed at all —
|
|
203
|
+
* see {@link touchedPathsFor}. A caller that gates on those paths must still
|
|
204
|
+
* treat an empty set as "cannot prove", never as "nothing to protect".
|
|
205
|
+
*/
|
|
206
|
+
listPrsByState(states: PullRequestState[], opts?: {
|
|
207
|
+
fresh?: boolean;
|
|
208
|
+
workspaceId?: string;
|
|
209
|
+
}): Promise<PullRequestSummary[]>;
|
|
117
210
|
listPrsAuthoredBy(loginOrEmail: string, opts?: {
|
|
118
211
|
fresh?: boolean;
|
|
119
212
|
}): Promise<PullRequestSummary[]>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pull-request.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/workflow/git/pull-request.service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EAChB,kBAAkB,EAClB,WAAW,EACZ,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AAC7E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0CAA0C,CAAC;
|
|
1
|
+
{"version":3,"file":"pull-request.service.d.ts","sourceRoot":"","sources":["../../../../src/modules/workflow/git/pull-request.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,14 +1,101 @@
|
|
|
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
|
const log = logger('cr');
|
|
4
4
|
import { isFolderPlaceholder } from '@bevel-software/platform-shared';
|
|
5
5
|
import { changeRequests } from '../../database/schema.js';
|
|
6
6
|
import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
7
|
-
import { WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
7
|
+
import { AppliedChangeMismatchError, WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
8
8
|
import { canonicalEmail, hashEmail } from '../../../shared/email-identity.js';
|
|
9
9
|
import { changeRequestLink, changeRequestLinkBase } from './change-request-link.js';
|
|
10
|
+
/**
|
|
11
|
+
* The latest moment a change-request row records — its close time when it has
|
|
12
|
+
* one, else its creation time, with `updated_at` folded in for the day
|
|
13
|
+
* something writes it. Deliberately derived from the ROW alone: a time taken
|
|
14
|
+
* from the newest comment or approval would make the same request report
|
|
15
|
+
* different "last changed" moments to a list (which reads no comments) and a
|
|
16
|
+
* detail (which does).
|
|
17
|
+
*/
|
|
18
|
+
function latestRowMoment(row) {
|
|
19
|
+
const times = [row.createdAt, row.updatedAt, row.closedAt]
|
|
20
|
+
.filter((d) => d instanceof Date)
|
|
21
|
+
.map((d) => d.getTime());
|
|
22
|
+
return new Date(Math.max(...times)).toISOString();
|
|
23
|
+
}
|
|
10
24
|
const LIST_PR_CACHE_TTL_MS = 30_000;
|
|
25
|
+
/**
|
|
26
|
+
* How many applied requests' file lists are remembered at once. Each is a
|
|
27
|
+
* short path list, so this is about a deployment that applies requests for
|
|
28
|
+
* years never growing the map without bound. Past it, what is remembered
|
|
29
|
+
* stays and new answers are not kept — see `touchedPathsFor` for why nothing
|
|
30
|
+
* is evicted.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_REMEMBERED_APPLIED_CHANGES = 20_000;
|
|
33
|
+
/** How many mismatched applied rows to remember — see `touchedPathsFor`. */
|
|
34
|
+
const MAX_MISMATCHED_APPLIED_CHANGES = 5_000;
|
|
35
|
+
/** How many rows a listing reads from git at once. */
|
|
36
|
+
const SUMMARY_CONCURRENCY = 8;
|
|
37
|
+
/** `fn` over `items`, at most `limit` in flight, results in order. */
|
|
38
|
+
async function mapWithLimit(items, limit, fn) {
|
|
39
|
+
const results = new Array(items.length);
|
|
40
|
+
let next = 0;
|
|
41
|
+
const worker = async () => {
|
|
42
|
+
while (next < items.length) {
|
|
43
|
+
const index = next;
|
|
44
|
+
next += 1;
|
|
45
|
+
results[index] = await fn(items[index]);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
11
51
|
const DETAIL_CACHE_TTL_MS = 30_000;
|
|
52
|
+
/**
|
|
53
|
+
* Which diff a change-request row is read from — asked ONCE, here, by both the
|
|
54
|
+
* list (`touchedPathsFor`) and the by-number detail (`getPrDetail`). The two
|
|
55
|
+
* surfaces answered this question separately before, and so answered it
|
|
56
|
+
* differently: the list gave a declined request no paths while the detail
|
|
57
|
+
* resolved its branch pair, which left `list_change_requests` and the four
|
|
58
|
+
* by-number tools contradicting each other about the same request for the same
|
|
59
|
+
* caller. One function, one answer.
|
|
60
|
+
*
|
|
61
|
+
* An OPEN request is its two branch tips, which is what a proposal IS.
|
|
62
|
+
*
|
|
63
|
+
* An APPLIED one is the merge commit its row records: the branch is retired, and
|
|
64
|
+
* the commit is local, immutable, and holds exactly what landed.
|
|
65
|
+
*
|
|
66
|
+
* A DECLINED one is read from NOTHING, and that is not a degradation:
|
|
67
|
+
*
|
|
68
|
+
* - The row records no sha, so nothing durable says what the request
|
|
69
|
+
* proposed. Declining retires the source branch as merging does
|
|
70
|
+
* (`deleteChangeRequest` ends in `retireMergedSourceBranch`) — unless
|
|
71
|
+
* another open request still needs that branch, which keeps it alive and
|
|
72
|
+
* moving. Where the branch pair still resolves it would answer with what
|
|
73
|
+
* that branch differs by NOW, work committed since for the request that
|
|
74
|
+
* kept it included; reading the declined request would then present later
|
|
75
|
+
* work as the proposal that was turned down.
|
|
76
|
+
* - An empty file set proves no read access downstream, so a declined request
|
|
77
|
+
* is readable by its author alone — the owner's criterion of 2026-10-02.
|
|
78
|
+
* - And it asks git nothing, so neither a listing nor a by-number read of a
|
|
79
|
+
* declined request costs a network round trip.
|
|
80
|
+
*
|
|
81
|
+
* A merged row that records no merge commit (or whose commit this clone has not
|
|
82
|
+
* fetched) lands on `none` too, and fails closed the same way until it can be
|
|
83
|
+
* read in full.
|
|
84
|
+
*/
|
|
85
|
+
export function changeSourceFor(row) {
|
|
86
|
+
if (row.state === 'open')
|
|
87
|
+
return { kind: 'branches' };
|
|
88
|
+
// The NUMBER travels with the sha, because reading the commit's own change is
|
|
89
|
+
// only sound if the commit is this request's merge commit — and the number is
|
|
90
|
+
// what proves it (the subject ends with `(#<number>)`). A row whose
|
|
91
|
+
// `merged_sha` was written by a merge that made no commit points at the target
|
|
92
|
+
// tip, which is usually another request's merge commit; the git layer rejects
|
|
93
|
+
// that rather than answering with its files. See `merge-commit.ts`.
|
|
94
|
+
if (row.state === 'merged' && row.mergedSha) {
|
|
95
|
+
return { kind: 'commit', applied: { number: row.number, mergeSha: row.mergedSha, title: row.title } };
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'none' };
|
|
98
|
+
}
|
|
12
99
|
/**
|
|
13
100
|
* Reads change requests from the app's own DB (`change_requests`) and computes
|
|
14
101
|
* their diff + SHAs locally from git — no provider PR API. This is what lets the
|
|
@@ -41,6 +128,14 @@ export class PullRequestService {
|
|
|
41
128
|
* list cache stores, so it lives and dies with them.
|
|
42
129
|
*/
|
|
43
130
|
routingPaths = new WeakMap();
|
|
131
|
+
/**
|
|
132
|
+
* The files an APPLIED request landed, per clone and merge commit. A merge
|
|
133
|
+
* commit is immutable, so its answer is good for the life of the process;
|
|
134
|
+
* nothing invalidates this, and nothing needs to. See {@link touchedPathsFor}.
|
|
135
|
+
*/
|
|
136
|
+
appliedChanges = new Map();
|
|
137
|
+
/** Applied rows whose recorded commit is not their own, by the same key — never asked again. */
|
|
138
|
+
mismatchedAppliedChanges = new Set();
|
|
44
139
|
/**
|
|
45
140
|
* Per-CR detail cache, keyed by `${workspaceId ?? 'global'}:${viewer}:${number}`.
|
|
46
141
|
* The payload includes per-file approvals resolved against the caller's
|
|
@@ -86,7 +181,7 @@ export class PullRequestService {
|
|
|
86
181
|
return preferred;
|
|
87
182
|
return this.workspaceService.findAnyWorkspaceId();
|
|
88
183
|
}
|
|
89
|
-
rowToSummary(row, touchedNodePaths) {
|
|
184
|
+
rowToSummary(row, touchedNodePaths, touchedNodeFiles) {
|
|
90
185
|
return {
|
|
91
186
|
number: row.number,
|
|
92
187
|
title: row.title,
|
|
@@ -101,7 +196,14 @@ export class PullRequestService {
|
|
|
101
196
|
base: row.targetBranch,
|
|
102
197
|
state: row.state,
|
|
103
198
|
createdAt: row.createdAt.toISOString(),
|
|
199
|
+
// The latest moment the ROW records. Nothing stamps `updated_at` on a
|
|
200
|
+
// change request today, so in practice this is the close time of a
|
|
201
|
+
// closed request and the creation time of an open one — the honest
|
|
202
|
+
// answer from the row, with the column folded in for the day something
|
|
203
|
+
// does write it.
|
|
204
|
+
updatedAt: latestRowMoment(row),
|
|
104
205
|
touchedNodePaths,
|
|
206
|
+
touchedNodeFiles,
|
|
105
207
|
// Provider reviews are gone; the real approval state lives in the detail
|
|
106
208
|
// view (per-file, DB-backed). The summary badge is derived there.
|
|
107
209
|
review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
|
|
@@ -122,19 +224,89 @@ export class PullRequestService {
|
|
|
122
224
|
/**
|
|
123
225
|
* Cheap touched-paths for a CR row (empty when no workspace exists yet),
|
|
124
226
|
* placeholders included — see {@link summaryOf} for what a summary shows.
|
|
227
|
+
*
|
|
228
|
+
* WHICH DIFF is {@link changeSourceFor}'s answer — the same one `getPrDetail`
|
|
229
|
+
* takes, so a list and a by-number read never disagree about which files a
|
|
230
|
+
* request has. No state reaches the network per request: an open row is
|
|
231
|
+
* diffed from two branch tips a single whole-clone refresh has already brought
|
|
232
|
+
* up to date (`fetch: false`), an applied one from a local immutable commit,
|
|
233
|
+
* and a declined one from nothing at all.
|
|
234
|
+
*
|
|
235
|
+
* The merge commit also undid the flood finding 1 of Razvan's review named:
|
|
236
|
+
* `publishedPrCommits` answers null for a retired branch, which sent
|
|
237
|
+
* `changedPathsAndPairsForPr` to `fetch` two refs — one of them gone — once PER
|
|
238
|
+
* REQUEST and then logged a warning for each, so a list of a few hundred
|
|
239
|
+
* applied requests opened a few hundred concurrent fetches to answer nothing.
|
|
125
240
|
*/
|
|
126
241
|
async touchedPathsFor(row, workspaceId, opts = {}) {
|
|
242
|
+
const empty = { paths: [], pairs: [] };
|
|
127
243
|
if (!workspaceId)
|
|
128
|
-
return
|
|
244
|
+
return empty;
|
|
245
|
+
// Best-effort, but logged: an empty result silently hides a CR from the
|
|
246
|
+
// owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
|
|
247
|
+
// shouldn't be invisible.
|
|
248
|
+
const degrade = (what) => (err) => {
|
|
249
|
+
const where = `#${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}`;
|
|
250
|
+
// A clone that simply does not hold the merge commit yet is not a failure
|
|
251
|
+
// to shout about — it is this row's turn to be fetched, and the next list
|
|
252
|
+
// read answers it. Warning per request on that would be the log flood the
|
|
253
|
+
// network flood came with.
|
|
254
|
+
if (err instanceof WorkflowValidationError) {
|
|
255
|
+
log.debug(`${what} could not resolve ${where}; answering no touched paths:`, { err });
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
log.warn(`${what} failed for ${where}:`, { err });
|
|
259
|
+
}
|
|
260
|
+
return empty;
|
|
261
|
+
};
|
|
262
|
+
const source = changeSourceFor(row);
|
|
263
|
+
if (source.kind === 'none')
|
|
264
|
+
return empty;
|
|
265
|
+
if (source.kind === 'commit') {
|
|
266
|
+
// Remembered once read: a merge commit never changes, so neither does
|
|
267
|
+
// its diff, and a list of a few hundred applied requests would otherwise
|
|
268
|
+
// run four git processes per row on every call. Only an ANSWER is kept.
|
|
269
|
+
// A commit this clone does not hold yet rejects, and that is this row's
|
|
270
|
+
// turn to be fetched, so the next read asks again.
|
|
271
|
+
const key = `${workspaceId}\u0000${source.applied.mergeSha}\u0000${source.applied.number}`;
|
|
272
|
+
const known = this.appliedChanges.get(key);
|
|
273
|
+
if (known)
|
|
274
|
+
return known;
|
|
275
|
+
// A row whose commit the clone holds but which is NOT this request's
|
|
276
|
+
// own (a `merged_sha` written before merges recorded their own commit)
|
|
277
|
+
// never becomes it, and is remembered as such, bounded in count:
|
|
278
|
+
// without this, every list re-ran its git calls for every such row,
|
|
279
|
+
// serialized under the workspace mutex, on every poll, and a deployment
|
|
280
|
+
// with hundreds of pre-fix rows paid seconds of git per poll
|
|
281
|
+
// indefinitely. A commit the clone does not HOLD is not remembered —
|
|
282
|
+
// the next fetch may bring it, and the next list asks again.
|
|
283
|
+
if (this.mismatchedAppliedChanges.has(key))
|
|
284
|
+
return empty;
|
|
285
|
+
return this.gitService
|
|
286
|
+
.changedPathsAndPairsOfAppliedChange(workspaceId, source.applied)
|
|
287
|
+
.then((answer) => {
|
|
288
|
+
// Full: what is remembered stays, and this answer is simply not
|
|
289
|
+
// kept. A list scans every applied row in one pass, so clearing or
|
|
290
|
+
// evicting here would throw out entries the SAME pass is about to
|
|
291
|
+
// ask for again, and a deployment past the cap would recompute
|
|
292
|
+
// every row on every list. Kept, the rows past the cap are the only
|
|
293
|
+
// ones that cost git anything.
|
|
294
|
+
if (this.appliedChanges.size < MAX_REMEMBERED_APPLIED_CHANGES)
|
|
295
|
+
this.appliedChanges.set(key, answer);
|
|
296
|
+
return answer;
|
|
297
|
+
})
|
|
298
|
+
.catch((err) => {
|
|
299
|
+
if (err instanceof AppliedChangeMismatchError) {
|
|
300
|
+
if (this.mismatchedAppliedChanges.size >= MAX_MISMATCHED_APPLIED_CHANGES)
|
|
301
|
+
this.mismatchedAppliedChanges.clear();
|
|
302
|
+
this.mismatchedAppliedChanges.add(key);
|
|
303
|
+
}
|
|
304
|
+
return degrade('changedPathsAndPairsOfAppliedChange')(err);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
129
307
|
return this.gitService
|
|
130
|
-
.
|
|
131
|
-
.catch((
|
|
132
|
-
// Best-effort, but log it: an empty result silently hides a CR from the
|
|
133
|
-
// owner-routing match in `listPrsForOwnerEmail`, so a swallowed failure
|
|
134
|
-
// shouldn't be invisible.
|
|
135
|
-
log.warn(`changedPathsForPr failed for #${row.number} (${row.sourceBranch} → ${row.targetBranch}) in ${workspaceId}:`, { err });
|
|
136
|
-
return [];
|
|
137
|
-
});
|
|
308
|
+
.changedPathsAndPairsForPr(workspaceId, row.targetBranch, row.sourceBranch, opts)
|
|
309
|
+
.catch(degrade('changedPathsAndPairsForPr'));
|
|
138
310
|
}
|
|
139
311
|
/**
|
|
140
312
|
* The summary of a CR row. The empty-folder placeholder is never content,
|
|
@@ -144,8 +316,11 @@ export class PullRequestService {
|
|
|
144
316
|
*/
|
|
145
317
|
async summaryOf(row, workspaceId, opts = {}) {
|
|
146
318
|
const touched = await this.touchedPathsFor(row, workspaceId, opts);
|
|
147
|
-
const summary = this.rowToSummary(row, touched.filter((p) => !isFolderPlaceholder(p))
|
|
148
|
-
|
|
319
|
+
const summary = this.rowToSummary(row, touched.paths.filter((p) => !isFolderPlaceholder(p)),
|
|
320
|
+
// Already placeholder-free and roles.yaml-free (see `changedPathPairs`),
|
|
321
|
+
// so the pairs need no filtering of their own here.
|
|
322
|
+
touched.pairs);
|
|
323
|
+
this.routingPaths.set(summary, touched.paths);
|
|
149
324
|
return summary;
|
|
150
325
|
}
|
|
151
326
|
async listOpenPrs(opts = {}) {
|
|
@@ -196,6 +371,48 @@ export class PullRequestService {
|
|
|
196
371
|
}
|
|
197
372
|
return summaries;
|
|
198
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* Every request in `states`, newest first. `['open']` is delegated so the
|
|
376
|
+
* list the app polls keeps its cache; any other set is read straight from
|
|
377
|
+
* the table, because a closed-request read is rare and a second cache keyed
|
|
378
|
+
* on a state set would mostly hold misses.
|
|
379
|
+
*
|
|
380
|
+
* Touched paths are best-effort exactly as on the open list, and no row costs
|
|
381
|
+
* a network call of its own: a MERGED row is diffed from the merge commit it
|
|
382
|
+
* records (local and immutable), and a DECLINED one is not diffed at all —
|
|
383
|
+
* see {@link touchedPathsFor}. A caller that gates on those paths must still
|
|
384
|
+
* treat an empty set as "cannot prove", never as "nothing to protect".
|
|
385
|
+
*/
|
|
386
|
+
async listPrsByState(states, opts = {}) {
|
|
387
|
+
const wanted = [...new Set(states)];
|
|
388
|
+
if (wanted.length === 0)
|
|
389
|
+
return [];
|
|
390
|
+
if (wanted.length === 1 && wanted[0] === 'open')
|
|
391
|
+
return this.listOpenPrs(opts);
|
|
392
|
+
const workspaceId = await this.resolveWorkspaceId(opts.workspaceId);
|
|
393
|
+
const rows = await this.db
|
|
394
|
+
.select()
|
|
395
|
+
.from(changeRequests)
|
|
396
|
+
.where(inArray(changeRequests.state, wanted))
|
|
397
|
+
.orderBy(desc(changeRequests.createdAt));
|
|
398
|
+
// ONE fetch for the whole list, for the reason spelled out on listOpenPrs —
|
|
399
|
+
// never one per row — and only when a row in scope reads from git at all.
|
|
400
|
+
// An open request is diffed from two branch refs, which are as current as
|
|
401
|
+
// the last fetch; a MERGED one from its merge commit, which cannot change
|
|
402
|
+
// but which a clone that has not fetched since the merge does not hold —
|
|
403
|
+
// without this fetch such a clone would read every applied request as
|
|
404
|
+
// author-only for good. A declined one reads from nothing, so a listing
|
|
405
|
+
// of declined requests alone reaches the network not once.
|
|
406
|
+
if (workspaceId && rows.some((row) => row.state === 'open' || row.state === 'merged')) {
|
|
407
|
+
await this.workspaceService
|
|
408
|
+
.ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
|
|
409
|
+
.catch(() => undefined);
|
|
410
|
+
}
|
|
411
|
+
// Bounded fan-out: an applied row's read takes the workspace mutex for
|
|
412
|
+
// its git calls, so a hundred rows launched at once would only queue on
|
|
413
|
+
// it while holding a hundred pending promises.
|
|
414
|
+
return mapWithLimit(rows, SUMMARY_CONCURRENCY, (row) => this.summaryOf(row, workspaceId, { fetch: false }));
|
|
415
|
+
}
|
|
199
416
|
async listPrsAuthoredBy(loginOrEmail, opts = {}) {
|
|
200
417
|
const needle = canonicalEmail(loginOrEmail);
|
|
201
418
|
if (!needle)
|
|
@@ -346,28 +563,100 @@ export class PullRequestService {
|
|
|
346
563
|
};
|
|
347
564
|
/** Did the target change, since the fork point, a file this request changes? */
|
|
348
565
|
let targetChangedShared = false;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
566
|
+
// The SAME routing the list takes, from the same function, so the two
|
|
567
|
+
// surfaces cannot disagree about which files a request has — see
|
|
568
|
+
// {@link changeSourceFor} for why each state reads from what it does.
|
|
569
|
+
const source = workspaceId ? changeSourceFor(row) : { kind: 'none' };
|
|
570
|
+
try {
|
|
571
|
+
if (workspaceId && source.kind === 'commit') {
|
|
572
|
+
// An APPLIED request is read from its merge commit, not from its
|
|
573
|
+
// branches: the source branch is retired, so there is nothing to resolve
|
|
574
|
+
// and nothing to fetch. The commit's first parent is the target as it
|
|
575
|
+
// stood before the merge and its second is the source tip that landed,
|
|
576
|
+
// so the file list is exactly what was applied, and the `headSha` the
|
|
577
|
+
// approvals are judged stale against is the head they were given on.
|
|
578
|
+
//
|
|
579
|
+
// Before the 2026-10-02 decision this fell into the catch below and
|
|
580
|
+
// answered no files — which, since an empty file set proves no read
|
|
581
|
+
// access, made every applied request readable by its author alone.
|
|
582
|
+
// Reading back what happened is what the ticket exists for.
|
|
583
|
+
//
|
|
584
|
+
// One refresh first: the commit cannot change, but a clone that has
|
|
585
|
+
// not fetched since the merge does not hold it yet, and reading it as
|
|
586
|
+
// missing would make the request author-only on that clone for good.
|
|
587
|
+
await this.workspaceService
|
|
588
|
+
.ensureRemotesFetched(workspaceId, { force: opts.fresh === true })
|
|
589
|
+
.catch(() => undefined);
|
|
590
|
+
const ends = await this.gitService.appliedChangeShas(workspaceId, source.applied);
|
|
591
|
+
baseSha = ends.baseSha;
|
|
592
|
+
headSha = ends.headSha;
|
|
593
|
+
files = await this.gitService.changedFilesOfAppliedChange(workspaceId, source.applied, {
|
|
594
|
+
...(opts.patches === false ? { patchCap: 0 } : {}),
|
|
595
|
+
});
|
|
596
|
+
// `forkPoint` and `targetChangedShared` stay at their defaults. "Is this
|
|
597
|
+
// behind its target, and does the divergence reach its files" is a
|
|
598
|
+
// question about a proposal that could still be updated; an applied one
|
|
599
|
+
// has no answer to give and reports none (`behind` and `needsUpdate` are
|
|
600
|
+
// gated on `state === 'open'` downstream anyway).
|
|
601
|
+
}
|
|
602
|
+
else if (workspaceId && source.kind === 'branches') {
|
|
603
|
+
const shas = await this.gitService.resolvePrShas(workspaceId, row.targetBranch, row.sourceBranch);
|
|
604
|
+
baseSha = shas.baseSha;
|
|
605
|
+
headSha = shas.headSha;
|
|
606
|
+
// The file list is pinned to the SHAs just resolved (`at`), so it and
|
|
607
|
+
// the `headSha` approvals pin against describe the same commits even if
|
|
608
|
+
// another fetch lands on this workspace in between, and the second
|
|
609
|
+
// fetch of the same two refs is gone. `patches: false` is for the
|
|
610
|
+
// internal detail an approve / withdraw / revert fetches to pin its
|
|
611
|
+
// work: those never read `files[].patch`, and generating it cost one git
|
|
612
|
+
// subprocess per changed file per click. The detail served to clients
|
|
613
|
+
// keeps its patches, so the published payload is unchanged.
|
|
614
|
+
files = await this.gitService.changedFilesForPr(workspaceId, row.targetBranch, row.sourceBranch, { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) });
|
|
615
|
+
// Pinned to the same two commits as the file list, so "needs updating"
|
|
616
|
+
// and the diff it qualifies can never describe different heads.
|
|
617
|
+
forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
|
|
618
|
+
// Only a target that moved in a file THIS request also changes makes the
|
|
619
|
+
// proposal's diff describe text that has moved. One extra
|
|
620
|
+
// `diff --name-only` (and only when the branches have diverged at all)
|
|
621
|
+
// buys the difference between opening instantly and paying for a merge,
|
|
622
|
+
// a push and a second detail read.
|
|
623
|
+
targetChangedShared = await this.targetTouchesRequestFiles(workspaceId, forkPoint, baseSha, files);
|
|
624
|
+
}
|
|
625
|
+
// `source.kind === 'none'` — a DECLINED request, a merged row recording no
|
|
626
|
+
// merge commit, or no workspace at all — asks git NOTHING and is answered
|
|
627
|
+
// from the ROW ALONE: no shas, no files. The row's STATE decides that, not
|
|
628
|
+
// whether git happens to fail, which is the fix for the contradiction
|
|
629
|
+
// Local Testing found: declining does not retire the source branch (only
|
|
630
|
+
// merging deletes it), so the branch pair below resolved a declined
|
|
631
|
+
// request perfectly well and published its files to anyone who could read
|
|
632
|
+
// one of them — while the list, which gives a declined row no paths at
|
|
633
|
+
// all, hid the same request from the same caller.
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
// What reaches this is a MERGED request whose merge commit this clone
|
|
637
|
+
// cannot resolve — not fetched yet, or a sha the row records that the
|
|
638
|
+
// object store does not hold. A DECLINED request never gets here at all
|
|
639
|
+
// any more: it asks git nothing (see above), so it cannot fail.
|
|
640
|
+
//
|
|
641
|
+
// Everything the ROW records — title, body, state, author, times — is
|
|
642
|
+
// still true, and this is the degradation the no-workspace case above
|
|
643
|
+
// already takes: no shas and no files, rather than no answer at all. Since
|
|
644
|
+
// an empty file set proves no read access, such a row fails CLOSED to its
|
|
645
|
+
// author, and the next read, once the commit is in the clone, answers in
|
|
646
|
+
// full.
|
|
647
|
+
//
|
|
648
|
+
// An OPEN request still fails loudly. There, an unresolvable branch means
|
|
649
|
+
// a branch not yet published or a clone not yet caught up, and presenting
|
|
650
|
+
// a live proposal as one that changes nothing would tell a reviewer the
|
|
651
|
+
// opposite of the truth.
|
|
652
|
+
if (!(err instanceof WorkflowValidationError) || row.state === 'open')
|
|
653
|
+
throw err;
|
|
654
|
+
log.warn(`change request #${prNumber} is ${row.state} and can no longer be diffed; answering from its row alone:`, { err });
|
|
655
|
+
baseSha = '';
|
|
656
|
+
headSha = '';
|
|
657
|
+
files = [];
|
|
658
|
+
forkPoint = { mergeBaseSha: null, behind: false };
|
|
659
|
+
targetChangedShared = false;
|
|
371
660
|
}
|
|
372
661
|
// Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
|
|
373
662
|
const cached = this.detailCache.get(cacheKey);
|
|
@@ -379,7 +668,13 @@ export class PullRequestService {
|
|
|
379
668
|
cached.baseSha === baseSha) {
|
|
380
669
|
return cached.value;
|
|
381
670
|
}
|
|
382
|
-
|
|
671
|
+
// The detail has the real file list, so its pairs come straight off it —
|
|
672
|
+
// the same shape the list builds from its own diff, so a reader gating on
|
|
673
|
+
// `touchedNodeFiles` gets the same verdict from a summary and a detail.
|
|
674
|
+
const summary = this.rowToSummary(row, files.map((f) => f.path), files.map((f) => ({
|
|
675
|
+
path: f.path,
|
|
676
|
+
...(f.previousPath ? { previousPath: f.previousPath } : {}),
|
|
677
|
+
})));
|
|
383
678
|
// Comments + approvals come from our own DB via the review-workflow service.
|
|
384
679
|
// The enricher is optional — if it isn't wired yet (startup ordering,
|
|
385
680
|
// isolated tests) we return empty lists rather than blocking the fetch.
|