@bevel-software/platform-core-backend 0.12.0 → 0.13.1
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/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PullRequestService } from '../pull-request.service.js';
|
|
4
|
+
import type { Database } from '../../../database/connection.js';
|
|
5
|
+
import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
6
|
+
import type { GitService } from '../git.service.js';
|
|
7
|
+
import type { IAccessControl } from '../../../access/access-control.interface.js';
|
|
8
|
+
|
|
9
|
+
const ROW = {
|
|
10
|
+
id: 'cr-7',
|
|
11
|
+
number: 7,
|
|
12
|
+
sourceBranch: 'alice/feature',
|
|
13
|
+
targetBranch: 'current-company-state',
|
|
14
|
+
title: 'Feature',
|
|
15
|
+
body: '',
|
|
16
|
+
authorEmail: 'alice@bevel.software',
|
|
17
|
+
authorName: 'Alice',
|
|
18
|
+
state: 'open',
|
|
19
|
+
mergedSha: null,
|
|
20
|
+
createdAt: new Date('2026-08-01T00:00:00Z'),
|
|
21
|
+
updatedAt: null,
|
|
22
|
+
closedAt: null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function makeDb(): Database {
|
|
26
|
+
return {
|
|
27
|
+
select: () => ({
|
|
28
|
+
from: () => ({
|
|
29
|
+
where: () => ({ limit: async () => [ROW] }),
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
} as unknown as Database;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BASE = 'b'.repeat(40);
|
|
36
|
+
const HEAD = 'a'.repeat(40);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A detail read is the hot path behind every change-request poll, every
|
|
40
|
+
* approval click, and every merge. It resolves the SHAs first (that fetches
|
|
41
|
+
* both refs), so the file list must be pinned to those same commits rather
|
|
42
|
+
* than fetch and resolve again. The SHAs and the file list are read on every
|
|
43
|
+
* call (a new head must show); what the detail cache saves is the DB
|
|
44
|
+
* enrichment (comments, approvals), so that is where "cached" is observed.
|
|
45
|
+
*/
|
|
46
|
+
describe('PullRequestService.getPrDetail — git work per read', () => {
|
|
47
|
+
function harness() {
|
|
48
|
+
const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
|
|
49
|
+
const changedFilesForPr = vi.fn(async () => []);
|
|
50
|
+
const git = { resolvePrShas, changedFilesForPr } as unknown as GitService;
|
|
51
|
+
const workspace = {
|
|
52
|
+
findAnyWorkspaceId: async () => 'ws-main',
|
|
53
|
+
ensureRemotesFetched: async () => undefined,
|
|
54
|
+
} as unknown as WorkspaceService;
|
|
55
|
+
const access = { canWriteAtRef: async () => false } as unknown as IAccessControl;
|
|
56
|
+
const svc = new PullRequestService(makeDb(), workspace, access, git);
|
|
57
|
+
const listComments = vi.fn(async () => []);
|
|
58
|
+
const getApprovalStates = vi.fn(async () => []);
|
|
59
|
+
svc.setDetailEnricher({
|
|
60
|
+
listComments,
|
|
61
|
+
getApprovalStates,
|
|
62
|
+
evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
|
|
63
|
+
});
|
|
64
|
+
return { svc, resolvePrShas, changedFilesForPr, getApprovalStates };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it('pins the file list to the SHAs it just resolved, and keeps patches for a client read', async () => {
|
|
68
|
+
const { svc, resolvePrShas, changedFilesForPr } = harness();
|
|
69
|
+
const detail = await svc.getPrDetail(7, { fresh: true });
|
|
70
|
+
|
|
71
|
+
expect(detail?.headSha).toBe(HEAD);
|
|
72
|
+
expect(detail?.baseSha).toBe(BASE);
|
|
73
|
+
expect(resolvePrShas).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(changedFilesForPr).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(changedFilesForPr).toHaveBeenCalledWith(
|
|
76
|
+
'ws-main',
|
|
77
|
+
'current-company-state',
|
|
78
|
+
'alice/feature',
|
|
79
|
+
{ at: { baseSha: BASE, headSha: HEAD } },
|
|
80
|
+
);
|
|
81
|
+
// The SHAs (and their fetch) come first; the file list rides on it.
|
|
82
|
+
expect(resolvePrShas.mock.invocationCallOrder[0]).toBeLessThan(
|
|
83
|
+
changedFilesForPr.mock.invocationCallOrder[0]!,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a client read is cached: the next plain read skips the enrichment', async () => {
|
|
88
|
+
const { svc, getApprovalStates } = harness();
|
|
89
|
+
await svc.getPrDetail(7, { fresh: true });
|
|
90
|
+
await svc.getPrDetail(7);
|
|
91
|
+
expect(getApprovalStates).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('an internal read (patches: false) skips patches and is not cached', async () => {
|
|
95
|
+
const { svc, changedFilesForPr, getApprovalStates } = harness();
|
|
96
|
+
await svc.getPrDetail(7, { fresh: true, patches: false });
|
|
97
|
+
expect(changedFilesForPr).toHaveBeenLastCalledWith(
|
|
98
|
+
'ws-main',
|
|
99
|
+
'current-company-state',
|
|
100
|
+
'alice/feature',
|
|
101
|
+
{ at: { baseSha: BASE, headSha: HEAD }, patchCap: 0 },
|
|
102
|
+
);
|
|
103
|
+
// Not cached: the next plain read enriches again and asks for its own
|
|
104
|
+
// (full) file list.
|
|
105
|
+
await svc.getPrDetail(7);
|
|
106
|
+
expect(getApprovalStates).toHaveBeenCalledTimes(2);
|
|
107
|
+
expect(changedFilesForPr).toHaveBeenLastCalledWith(
|
|
108
|
+
'ws-main',
|
|
109
|
+
'current-company-state',
|
|
110
|
+
'alice/feature',
|
|
111
|
+
{ at: { baseSha: BASE, headSha: HEAD } },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -17,6 +17,7 @@ import type { WorkflowHooks, CommitValidationContext } from '../workflow-hooks.j
|
|
|
17
17
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
18
18
|
import { AccessDeniedError } from '../../access-model/access-errors.js';
|
|
19
19
|
import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
20
|
+
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
20
21
|
import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
|
|
21
22
|
import {
|
|
22
23
|
assertValidBranchName,
|
|
@@ -244,6 +245,23 @@ async function synthesizeUntrackedSideDiff(
|
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/**
|
|
249
|
+
* `stat` for the stray check: false only when nothing can be there (ENOENT,
|
|
250
|
+
* or ENOTDIR when a parent segment is a file). Permission and I/O failures
|
|
251
|
+
* propagate: treating them as "absent" would let an unreadable stray pass as
|
|
252
|
+
* committed.
|
|
253
|
+
*/
|
|
254
|
+
async function existsForCommitCheck(absolutePath: string): Promise<boolean> {
|
|
255
|
+
try {
|
|
256
|
+
await fs.stat(absolutePath);
|
|
257
|
+
return true;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
260
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
247
265
|
export class GitService implements IGitService {
|
|
248
266
|
// Per-workspace fetch state, kept OUTSIDE the workspace mutex on purpose.
|
|
249
267
|
// `git fetch` only writes `refs/remotes/origin/*` and packs new objects —
|
|
@@ -928,7 +946,10 @@ export class GitService implements IGitService {
|
|
|
928
946
|
const { stdout: scoped } = await this.git(cwd, [
|
|
929
947
|
'status', '--porcelain=v1', '--', repoRelativePath,
|
|
930
948
|
]);
|
|
931
|
-
if (!scoped.trim())
|
|
949
|
+
if (!scoped.trim()) {
|
|
950
|
+
await this.assertNoBytesBesideRepo(workspaceId, cwd, relativePath, repoRelativePath);
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
932
953
|
|
|
933
954
|
// **No access check at commit time.** Per the "disk is the source of
|
|
934
955
|
// truth" rule, by the time content is on disk it must not be rejected.
|
|
@@ -1969,24 +1990,39 @@ export class GitService implements IGitService {
|
|
|
1969
1990
|
* list, statuses, and +/- counts; per-file patches are generated for up to
|
|
1970
1991
|
* `patchCap` files (default 400) — beyond that `patch` is left undefined,
|
|
1971
1992
|
* exactly like the old API's binary/oversized files (the UI renders those with
|
|
1972
|
-
* no inline diff). Patch generation is skipped for binary files
|
|
1993
|
+
* no inline diff). Patch generation is skipped for binary files; `patchCap: 0`
|
|
1994
|
+
* skips it entirely, which is one git subprocess per changed file saved.
|
|
1995
|
+
*
|
|
1996
|
+
* `at` pins the diff to two commits the caller has ALREADY resolved (the
|
|
1997
|
+
* change-request detail resolves its SHAs first, which fetches both refs):
|
|
1998
|
+
* no fetch, no ref resolution, and the file list is computed from exactly
|
|
1999
|
+
* the commits the caller pins its head SHA to, so a fetch that lands in
|
|
2000
|
+
* between cannot make the two disagree. Never pass SHAs from a path that
|
|
2001
|
+
* has not just resolved them: the diff would describe a stale head.
|
|
1973
2002
|
*/
|
|
1974
2003
|
async changedFilesForPr(
|
|
1975
2004
|
workspaceId: string,
|
|
1976
2005
|
baseBranch: string,
|
|
1977
2006
|
headBranch: string,
|
|
1978
|
-
opts: { patchCap?: number } = {},
|
|
2007
|
+
opts: { patchCap?: number; at?: { baseSha: string; headSha: string } } = {},
|
|
1979
2008
|
): Promise<PullRequestFile[]> {
|
|
1980
2009
|
assertValidBranchName(baseBranch);
|
|
1981
2010
|
assertValidBranchName(headBranch);
|
|
2011
|
+
if (opts.at) {
|
|
2012
|
+
for (const sha of [opts.at.baseSha, opts.at.headSha]) {
|
|
2013
|
+
if (!/^[0-9a-f]{40,64}$/.test(sha)) {
|
|
2014
|
+
throw new WorkflowValidationError(`invalid commit sha: ${sha}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
1982
2018
|
const patchCap = opts.patchCap ?? 400;
|
|
1983
2019
|
// Fetch outside the mutex (network round-trip) so origin latency can't hold
|
|
1984
2020
|
// the workspace lock; the lock guards only the local diff work below.
|
|
1985
2021
|
const cwd = await this.repoDir(workspaceId);
|
|
1986
|
-
await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
2022
|
+
if (!opts.at) await this.fetchPrRefs(cwd, baseBranch, headBranch);
|
|
1987
2023
|
return this.mutex.run(workspaceId, async () => {
|
|
1988
|
-
const baseRef = await this.resolveBranchRef(cwd, baseBranch);
|
|
1989
|
-
const headRef = await this.resolveBranchRef(cwd, headBranch);
|
|
2024
|
+
const baseRef = opts.at ? opts.at.baseSha : await this.resolveBranchRef(cwd, baseBranch);
|
|
2025
|
+
const headRef = opts.at ? opts.at.headSha : await this.resolveBranchRef(cwd, headBranch);
|
|
1990
2026
|
const range = `${baseRef}...${headRef}`; // three-dot = changes on head since merge-base
|
|
1991
2027
|
|
|
1992
2028
|
const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
|
|
@@ -2383,6 +2419,37 @@ export class GitService implements IGitService {
|
|
|
2383
2419
|
}
|
|
2384
2420
|
}
|
|
2385
2421
|
|
|
2422
|
+
/**
|
|
2423
|
+
* A clean `git status` for a path has two honest readings (already
|
|
2424
|
+
* committed; a queued re-apply of something that landed) and one dishonest
|
|
2425
|
+
* one: the bytes exist, but BESIDE the repository, because a caller wrote a
|
|
2426
|
+
* workspace-relative path without the `<kbDirName>/` prefix. Git never sees
|
|
2427
|
+
* that file, so returning null would report "nothing to commit" for a write
|
|
2428
|
+
* that silently never landed. Throw instead; the pending-commits ladder
|
|
2429
|
+
* escalates the row to a needs-attention notice rather than dropping it.
|
|
2430
|
+
*
|
|
2431
|
+
* The stray has to be PROVABLE, because some callers pass repo-relative
|
|
2432
|
+
* paths on purpose (the decline-revert flow, the merge-time `roles.yaml`
|
|
2433
|
+
* preservation): bytes at the workspace-relative location AND nothing at
|
|
2434
|
+
* the same repo-relative location inside the clone. A clean, committed
|
|
2435
|
+
* `roles.yaml` keeps its null even if something of that name sits beside the
|
|
2436
|
+
* clone, so an unrelated stray cannot abort a merge. Only ENOENT (or a
|
|
2437
|
+
* parent that is not a directory) means "absent"; any other stat failure
|
|
2438
|
+
* surfaces, so an unreadable stray is never reported as committed.
|
|
2439
|
+
*/
|
|
2440
|
+
private async assertNoBytesBesideRepo(
|
|
2441
|
+
workspaceId: string,
|
|
2442
|
+
repoDir: string,
|
|
2443
|
+
relativePath: string,
|
|
2444
|
+
repoRelativePath: string,
|
|
2445
|
+
): Promise<void> {
|
|
2446
|
+
if (relativePath !== repoRelativePath) return;
|
|
2447
|
+
const workspaceDir = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
2448
|
+
if (!(await existsForCommitCheck(path.join(workspaceDir, relativePath)))) return;
|
|
2449
|
+
if (await existsForCommitCheck(path.join(repoDir, repoRelativePath))) return;
|
|
2450
|
+
assertInsideRepo(relativePath, this.kbDirName);
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2386
2453
|
private async repoDir(workspaceId: string): Promise<string> {
|
|
2387
2454
|
return path.join(await this.workspaceService.getWorkspacePath(workspaceId), this.kbDirName);
|
|
2388
2455
|
}
|
|
@@ -7,12 +7,13 @@ import type {
|
|
|
7
7
|
PullRequestFile,
|
|
8
8
|
PullRequestState,
|
|
9
9
|
PullRequestSummary,
|
|
10
|
+
IGitService,
|
|
10
11
|
} from '@bevel-software/platform-shared';
|
|
11
12
|
import type { Database } from '../../database/connection.js';
|
|
12
13
|
import { changeRequests } from '../../database/schema.js';
|
|
13
14
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
14
15
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
15
|
-
import
|
|
16
|
+
import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
16
17
|
import { WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
17
18
|
import { hashEmail } from '../../../shared/hash-email.js';
|
|
18
19
|
|
|
@@ -100,7 +101,7 @@ export class PullRequestService implements IPullRequestService {
|
|
|
100
101
|
private readonly db: Database,
|
|
101
102
|
private readonly workspaceService: WorkspaceService,
|
|
102
103
|
private readonly accessControl: IAccessControl,
|
|
103
|
-
private readonly gitService:
|
|
104
|
+
private readonly gitService: IGitService,
|
|
104
105
|
) {}
|
|
105
106
|
|
|
106
107
|
setDetailEnricher(enricher: PrDetailEnricher): void {
|
|
@@ -276,7 +277,7 @@ export class PullRequestService implements IPullRequestService {
|
|
|
276
277
|
|
|
277
278
|
async getPrDetail(
|
|
278
279
|
prNumber: number,
|
|
279
|
-
opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string } = {},
|
|
280
|
+
opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean } = {},
|
|
280
281
|
): Promise<PullRequestDetail | null> {
|
|
281
282
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
282
283
|
throw new WorkflowValidationError('PR number must be a positive integer');
|
|
@@ -303,10 +304,19 @@ export class PullRequestService implements IPullRequestService {
|
|
|
303
304
|
);
|
|
304
305
|
baseSha = shas.baseSha;
|
|
305
306
|
headSha = shas.headSha;
|
|
307
|
+
// The file list is pinned to the SHAs just resolved (`at`), so it and
|
|
308
|
+
// the `headSha` approvals pin against describe the same commits even if
|
|
309
|
+
// another fetch lands on this workspace in between, and the second
|
|
310
|
+
// fetch of the same two refs is gone. `patches: false` is for the
|
|
311
|
+
// internal detail an approve / withdraw / revert fetches to pin its
|
|
312
|
+
// work: those never read `files[].patch`, and generating it cost one git
|
|
313
|
+
// subprocess per changed file per click. The detail served to clients
|
|
314
|
+
// keeps its patches, so the published payload is unchanged.
|
|
306
315
|
files = await this.gitService.changedFilesForPr(
|
|
307
316
|
workspaceId,
|
|
308
317
|
row.targetBranch,
|
|
309
318
|
row.sourceBranch,
|
|
319
|
+
{ at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) },
|
|
310
320
|
);
|
|
311
321
|
}
|
|
312
322
|
|
|
@@ -343,6 +353,10 @@ export class PullRequestService implements IPullRequestService {
|
|
|
343
353
|
opts.viewerEmail,
|
|
344
354
|
)
|
|
345
355
|
.catch((err) => {
|
|
356
|
+
// An unreadable access tree fails the whole read (503): a detail
|
|
357
|
+
// with empty approvals would tell the merge gate "nothing to
|
|
358
|
+
// approve", and a reviewer nothing at all.
|
|
359
|
+
if (err instanceof AccessUnreadableError) throw err;
|
|
346
360
|
console.warn(`[cr] getApprovalStates failed for #${prNumber}:`, err);
|
|
347
361
|
return [] as FileApprovalState[];
|
|
348
362
|
}),
|
|
@@ -403,7 +417,11 @@ export class PullRequestService implements IPullRequestService {
|
|
|
403
417
|
viewerCanCancel,
|
|
404
418
|
};
|
|
405
419
|
|
|
406
|
-
|
|
420
|
+
// A patch-less detail is an internal read; it must not be served to the
|
|
421
|
+
// next client poll as if it were the full one.
|
|
422
|
+
if (opts.patches !== false) {
|
|
423
|
+
this.detailCache.set(cacheKey, { at: now, headSha: detail.headSha, value: detail });
|
|
424
|
+
}
|
|
407
425
|
return detail;
|
|
408
426
|
}
|
|
409
427
|
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from './pending-commits.service.js';
|
|
27
27
|
import { BACKOFF_MS, N_RECOVERY, N_TRANSIENT } from './pending-commits.service.js';
|
|
28
28
|
import { sanitizeError } from './sanitize-error.js';
|
|
29
|
+
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Where the worker escalates terminal failures — the narrow, workflow-owned
|
|
@@ -276,6 +277,8 @@ export class PendingCommitsWorker {
|
|
|
276
277
|
/**
|
|
277
278
|
* Process a single claimed row. Outcome paths (see plan §lifecycle):
|
|
278
279
|
* - commit + push succeeds → delete the row.
|
|
280
|
+
* - throws because the path is outside the repository → markNeedsAttention
|
|
281
|
+
* + feedback notice at once (no retry can change it, see below).
|
|
279
282
|
* - throws, transient budget remains → markTransientFailure.
|
|
280
283
|
* - throws, transient exhausted, recovery budget remains → spawn agent.
|
|
281
284
|
* - throws, recovery exhausted → markNeedsAttention + feedback notice.
|
|
@@ -303,6 +306,19 @@ export class PendingCommitsWorker {
|
|
|
303
306
|
// contain credentialed URLs ("https://x-access-token:ghp_…@…"); we
|
|
304
307
|
// can't undo a persisted leak, so mask before any of them sees it.
|
|
305
308
|
const message = sanitizeError(err);
|
|
309
|
+
|
|
310
|
+
const corrected = pathOutsideRepoCorrection(err);
|
|
311
|
+
if (corrected !== null) {
|
|
312
|
+
// The bytes sit BESIDE the clone (a workspace-relative path without
|
|
313
|
+
// the clone-folder prefix), so git will never see them: no retry can
|
|
314
|
+
// succeed, and a recovery agent has no commit to repair. Every attempt
|
|
315
|
+
// would throw this same refusal, so escalate now, with the corrected
|
|
316
|
+
// path in the notice, instead of spending the transient budget and
|
|
317
|
+
// N_RECOVERY agent runs to reach the same row state.
|
|
318
|
+
await this.escalate(row, message, strayPathNotice(row, message, corrected), 'path outside the repository');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
306
322
|
const nextAttempts = row.attempts + 1;
|
|
307
323
|
|
|
308
324
|
if (nextAttempts < N_TRANSIENT) {
|
|
@@ -346,30 +362,76 @@ export class PendingCommitsWorker {
|
|
|
346
362
|
}
|
|
347
363
|
|
|
348
364
|
// Recovery budget exhausted — escalate.
|
|
349
|
-
await this.
|
|
365
|
+
await this.escalate(row, message, terminalFailureNotice(row, message), `after ${N_RECOVERY} recovery runs`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The last step of every terminal path: flag the row `needs_attention`
|
|
371
|
+
* and tell the dashboard. `why` is the log line's reason; `notice` is the
|
|
372
|
+
* body the admin reads.
|
|
373
|
+
*/
|
|
374
|
+
private async escalate(row: PendingCommit, message: string, notice: string, why: string): Promise<void> {
|
|
375
|
+
await this.deps.service.markNeedsAttention(row.id, message);
|
|
376
|
+
console.error(
|
|
377
|
+
`[pending-commits] TERMINAL ws=${row.workspaceId} branch=${row.branch} path=${row.path} ${why}: ${message}`,
|
|
378
|
+
);
|
|
379
|
+
try {
|
|
380
|
+
await this.deps.feedback.send({
|
|
381
|
+
source: 'system',
|
|
382
|
+
user: this.deps.recoveryBot,
|
|
383
|
+
message: notice,
|
|
384
|
+
});
|
|
385
|
+
} catch (feedbackErr) {
|
|
386
|
+
// Don't let a feedback-sink hiccup mask the underlying terminal
|
|
387
|
+
// failure — the row is already `needs_attention` and the next
|
|
388
|
+
// process restart will keep logging it. Just note that the
|
|
389
|
+
// notice didn't reach the dashboard.
|
|
350
390
|
console.error(
|
|
351
|
-
`[pending-commits]
|
|
391
|
+
`[pending-commits] failed to emit terminal-failure feedback notice for ws=${row.workspaceId} path=${row.path}:`,
|
|
392
|
+
sanitizeError(feedbackErr),
|
|
352
393
|
);
|
|
353
|
-
try {
|
|
354
|
-
await this.deps.feedback.send({
|
|
355
|
-
source: 'system',
|
|
356
|
-
user: this.deps.recoveryBot,
|
|
357
|
-
message: terminalFailureNotice(row, message),
|
|
358
|
-
});
|
|
359
|
-
} catch (feedbackErr) {
|
|
360
|
-
// Don't let a feedback-sink hiccup mask the underlying terminal
|
|
361
|
-
// failure — the row is already `needs_attention` and the next
|
|
362
|
-
// process restart will keep logging it. Just note that the
|
|
363
|
-
// notice didn't reach the dashboard.
|
|
364
|
-
console.error(
|
|
365
|
-
`[pending-commits] failed to emit terminal-failure feedback notice for ws=${row.workspaceId} path=${row.path}:`,
|
|
366
|
-
sanitizeError(feedbackErr),
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
394
|
}
|
|
370
395
|
}
|
|
371
396
|
}
|
|
372
397
|
|
|
398
|
+
/**
|
|
399
|
+
* The commit layer's refusal of a path whose bytes sit beside the clone
|
|
400
|
+
* rather than in it (`kb-fs/repo-path.ts`): the corrected path it carries,
|
|
401
|
+
* or null for any other error. Read from the payload, not the message: the
|
|
402
|
+
* message the worker keeps is sanitized and truncated to fit `last_error`,
|
|
403
|
+
* and the correction is what gets cut. Deterministic: the same row throws
|
|
404
|
+
* it on every attempt.
|
|
405
|
+
*/
|
|
406
|
+
function pathOutsideRepoCorrection(err: unknown): string | null {
|
|
407
|
+
if (!(err instanceof WorkflowDomainError) || err.payload?.kind !== 'path-outside-repo') return null;
|
|
408
|
+
const corrected = err.payload.corrected;
|
|
409
|
+
return typeof corrected === 'string' && corrected.length > 0 ? corrected : '(see error)';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function strayPathNotice(row: PendingCommit, error: string, corrected: string): string {
|
|
413
|
+
return [
|
|
414
|
+
'[pending_commits] Commit refused: the file is outside the repository.',
|
|
415
|
+
'',
|
|
416
|
+
`Workspace: ${row.workspaceId}`,
|
|
417
|
+
`Branch: ${row.branch}`,
|
|
418
|
+
`Path: ${row.path}`,
|
|
419
|
+
`Use instead: ${corrected}`,
|
|
420
|
+
`Original author: ${row.authorEmail}`,
|
|
421
|
+
`Queued at: ${row.queuedAt.toISOString()}`,
|
|
422
|
+
`Error: ${error}`,
|
|
423
|
+
'',
|
|
424
|
+
'The bytes were written beside the git clone (a workspace-relative path',
|
|
425
|
+
'without the clone-folder prefix), so git never sees them. Nothing was',
|
|
426
|
+
'committed, retrying cannot change that, and no recovery agent was run.',
|
|
427
|
+
'',
|
|
428
|
+
'Resolve manually:',
|
|
429
|
+
` - move the file to "${corrected}" and save it again, or delete it if it`,
|
|
430
|
+
' should be discarded',
|
|
431
|
+
' - then delete the row from pending_commits.',
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
373
435
|
function terminalFailureNotice(row: PendingCommit, error: string): string {
|
|
374
436
|
const elapsedMs = Date.now() - row.queuedAt.getTime();
|
|
375
437
|
const elapsed = formatElapsed(elapsedMs);
|
|
@@ -13,6 +13,7 @@ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
|
13
13
|
import type { GitService } from '../../git/git.service.js';
|
|
14
14
|
import type { IAccessControl } from '../../../access/access-control.interface.js';
|
|
15
15
|
import { hashEmail as hash } from '../../../../shared/hash-email.js';
|
|
16
|
+
import { AccessUnreadableError } from '../../../access-model/access-errors.js';
|
|
16
17
|
|
|
17
18
|
function file(overrides: Partial<PullRequestFile>): PullRequestFile {
|
|
18
19
|
return {
|
|
@@ -557,3 +558,62 @@ describe('mergePr — roles.yaml privilege-escalation guard', () => {
|
|
|
557
558
|
expect(mergeChangeRequestMock).toHaveBeenCalledTimes(1);
|
|
558
559
|
});
|
|
559
560
|
});
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* An access tree that could not be READ is not the same as "no eligible
|
|
564
|
+
* writers": the latter drops a file out of the merge gate, so answering it for
|
|
565
|
+
* a lost git read would let a change merge past a deny nobody managed to
|
|
566
|
+
* load. The unreadable case propagates (the caller fails closed, 503); any
|
|
567
|
+
* other lookup failure still degrades to empty eligibility as documented.
|
|
568
|
+
*/
|
|
569
|
+
describe('ReviewWorkflowService.getApprovalStates — unreadable access tree', () => {
|
|
570
|
+
const FILES = [file({ path: 'Knowledge/Foo.md' })];
|
|
571
|
+
function serviceWith(overrides: Partial<IAccessControl>) {
|
|
572
|
+
const workspace = {
|
|
573
|
+
ensureRemotesFetched: vi.fn(async () => undefined),
|
|
574
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-base' })),
|
|
575
|
+
} as unknown as WorkspaceService;
|
|
576
|
+
const git = { mergeChangeRequest: mergeChangeRequestMock } as unknown as GitService;
|
|
577
|
+
return new ReviewWorkflowService(makeDb([]), { ...makeAccessControl({}), ...overrides }, workspace, git);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
it('propagates AccessUnreadableError from the eligibility lookup', async () => {
|
|
581
|
+
const svc = serviceWith({
|
|
582
|
+
eligibleWritersForPathsAtRef: async () => {
|
|
583
|
+
throw new AccessUnreadableError('origin/current-company-state', 'Knowledge/access.md');
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
await expect(
|
|
587
|
+
svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base', 'alice@bevel.software'),
|
|
588
|
+
).rejects.toBeInstanceOf(AccessUnreadableError);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('propagates AccessUnreadableError from the viewer batch too', async () => {
|
|
592
|
+
const svc = serviceWith({
|
|
593
|
+
canWriteBatchAtRef: async () => {
|
|
594
|
+
throw new AccessUnreadableError('origin/current-company-state', 'Knowledge/access.md');
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
await expect(
|
|
598
|
+
svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base', 'alice@bevel.software'),
|
|
599
|
+
).rejects.toBeInstanceOf(AccessUnreadableError);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('still degrades any other lookup failure to empty eligibility', async () => {
|
|
603
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
604
|
+
try {
|
|
605
|
+
const svc = serviceWith({
|
|
606
|
+
eligibleWritersForPathsAtRef: async () => {
|
|
607
|
+
throw new Error('origin not fetched yet');
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
const states = await svc.getApprovalStates(1, FILES, 'head-sha-1', 'current-company-state', null, 'ws-base');
|
|
611
|
+
expect(states).toHaveLength(1);
|
|
612
|
+
expect(states[0].eligibleApprovers).toEqual({ roles: [], users: [] });
|
|
613
|
+
expect(states[0].isApproved).toBe(false);
|
|
614
|
+
expect(warn).toHaveBeenCalled();
|
|
615
|
+
} finally {
|
|
616
|
+
warn.mockRestore();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
});
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from '@bevel-software/platform-shared';
|
|
13
13
|
import type { Database } from '../../database/connection.js';
|
|
14
14
|
import { changeRequests, prComments, prFileApprovals, prMergeLog } from '../../database/schema.js';
|
|
15
|
+
import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
15
16
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
16
17
|
import { isAccessMdPath } from '../../access-model/access-grammar.js';
|
|
17
18
|
import type { GitService } from '../git/git.service.js';
|
|
@@ -374,6 +375,9 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
374
375
|
);
|
|
375
376
|
if (resolved) eligibilityByPath = resolved;
|
|
376
377
|
} catch (err) {
|
|
378
|
+
// An unreadable tree is not "no eligible writers": that answer would
|
|
379
|
+
// drop every file out of the merge gate. Fail closed instead.
|
|
380
|
+
if (err instanceof AccessUnreadableError) throw err;
|
|
377
381
|
console.warn(
|
|
378
382
|
`[review-workflow] eligibleWritersForPathsAtRef failed for PR #${prNumber} (base=${baseBranch}):`,
|
|
379
383
|
err,
|
|
@@ -390,6 +394,7 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
|
|
|
390
394
|
);
|
|
391
395
|
if (batch) viewerCanApproveByPath = batch;
|
|
392
396
|
} catch (err) {
|
|
397
|
+
if (err instanceof AccessUnreadableError) throw err;
|
|
393
398
|
console.warn(
|
|
394
399
|
`[review-workflow] canWriteBatchAtRef failed for PR #${prNumber} viewer=${viewerEmail}:`,
|
|
395
400
|
err,
|
|
@@ -764,6 +764,10 @@ export function createWorkflowRoutes(
|
|
|
764
764
|
fresh: true,
|
|
765
765
|
workspaceId: workspace.id,
|
|
766
766
|
viewerEmail: user.email,
|
|
767
|
+
// Internal read: this route pins its work on the detail and never
|
|
768
|
+
// reads a patch; skipping them is one git subprocess per file saved
|
|
769
|
+
// on every click, and the result stays out of the client cache.
|
|
770
|
+
patches: false,
|
|
767
771
|
});
|
|
768
772
|
if (!detail) {
|
|
769
773
|
res.status(404).json({ error: 'change request not found' });
|
|
@@ -805,6 +809,10 @@ export function createWorkflowRoutes(
|
|
|
805
809
|
fresh: true,
|
|
806
810
|
workspaceId: workspace.id,
|
|
807
811
|
viewerEmail: user.email,
|
|
812
|
+
// Internal read: this route pins its work on the detail and never
|
|
813
|
+
// reads a patch; skipping them is one git subprocess per file saved
|
|
814
|
+
// on every click, and the result stays out of the client cache.
|
|
815
|
+
patches: false,
|
|
808
816
|
});
|
|
809
817
|
if (!detail) {
|
|
810
818
|
res.status(404).json({ error: 'change request not found' });
|
|
@@ -841,6 +849,10 @@ export function createWorkflowRoutes(
|
|
|
841
849
|
fresh: true,
|
|
842
850
|
workspaceId: workspace.id,
|
|
843
851
|
viewerEmail: user.email,
|
|
852
|
+
// Internal read: this route pins its work on the detail and never
|
|
853
|
+
// reads a patch; skipping them is one git subprocess per file saved
|
|
854
|
+
// on every click, and the result stays out of the client cache.
|
|
855
|
+
patches: false,
|
|
844
856
|
});
|
|
845
857
|
if (!detail) {
|
|
846
858
|
res.status(404).json({ error: 'change request not found' });
|