@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
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
ChangeRequest,
|
|
4
|
+
ChangeRequestDetail,
|
|
5
|
+
FileApproval,
|
|
6
|
+
} from '@bevel-software/platform-shared';
|
|
7
|
+
import { hashEmail } from '../../../../shared/email-identity.js';
|
|
8
|
+
import {
|
|
9
|
+
authorsDescription,
|
|
10
|
+
fileIsReadable,
|
|
11
|
+
isAuthor,
|
|
12
|
+
loginFor,
|
|
13
|
+
matchesAuthor,
|
|
14
|
+
maySeeChangeRequest,
|
|
15
|
+
pageOf,
|
|
16
|
+
pagingOf,
|
|
17
|
+
pathsOf,
|
|
18
|
+
statesFor,
|
|
19
|
+
toCrViewer,
|
|
20
|
+
toCrSummary,
|
|
21
|
+
APPLY_FAILURE_REASON_WITHHELD,
|
|
22
|
+
visibleComments,
|
|
23
|
+
toCrComment,
|
|
24
|
+
toCrFile,
|
|
25
|
+
toCrReviews,
|
|
26
|
+
visibleBlockers,
|
|
27
|
+
} from '../change-request-read-shape.js';
|
|
28
|
+
|
|
29
|
+
const AUTHOR = 'juan@bevel.software';
|
|
30
|
+
|
|
31
|
+
function summary(over: Partial<ChangeRequest> = {}): ChangeRequest {
|
|
32
|
+
return {
|
|
33
|
+
number: 12,
|
|
34
|
+
title: 'Rework the onboarding note',
|
|
35
|
+
authorId: hashEmail(AUTHOR),
|
|
36
|
+
author: { login: loginFor(AUTHOR), name: 'Juan' },
|
|
37
|
+
appAuthor: { name: 'Juan' },
|
|
38
|
+
branch: 'juan/my-draft',
|
|
39
|
+
base: 'main',
|
|
40
|
+
state: 'open',
|
|
41
|
+
createdAt: '2026-09-28T10:00:00.000Z',
|
|
42
|
+
updatedAt: '2026-09-28T10:00:00.000Z',
|
|
43
|
+
touchedNodePaths: ['Knowledge/A.md'],
|
|
44
|
+
review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
|
|
45
|
+
url: 'https://hexis.example.com/change-requests/12',
|
|
46
|
+
...over,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function approval(over: Partial<FileApproval> = {}): FileApproval {
|
|
51
|
+
return {
|
|
52
|
+
path: 'Knowledge/A.md',
|
|
53
|
+
eligibleApprovers: { roles: ['Engineering'], users: [] },
|
|
54
|
+
approvedBy: [],
|
|
55
|
+
eligibilityResolved: true,
|
|
56
|
+
isApproved: false,
|
|
57
|
+
inMergeGate: true,
|
|
58
|
+
viewerCanApprove: false,
|
|
59
|
+
...over,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("state is Hexis's own, and only the filter speaks GitHub's", () => {
|
|
64
|
+
// Scenario: WHEN the request is merged THEN `state` says `merged`. GitHub
|
|
65
|
+
// would say `closed` with a `merged` flag beside it; Hexis has the word, so
|
|
66
|
+
// it uses it, and no answer carries the flag (Razvan, 2026-10-02).
|
|
67
|
+
it('reports each of the three states as itself, with no flag to cross-read', () => {
|
|
68
|
+
for (const state of ['open', 'merged', 'closed'] as const) {
|
|
69
|
+
const answer = toCrSummary(summary({ state }), { readable: 1, withheld: 0 });
|
|
70
|
+
expect(answer.state).toBe(state);
|
|
71
|
+
expect(answer).not.toHaveProperty('merged');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('the `closed` filter covers applied and declined alike; `all` covers everything', () => {
|
|
76
|
+
expect(statesFor('open')).toEqual(['open']);
|
|
77
|
+
expect(statesFor('closed').sort()).toEqual(['closed', 'merged']);
|
|
78
|
+
expect(statesFor('all').sort()).toEqual(['closed', 'merged', 'open']);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('summary mapping', () => {
|
|
83
|
+
it("uses the field names `open_change_request` answers in, `url` first", () => {
|
|
84
|
+
const answer = toCrSummary(summary(), { readable: 1, withheld: 0 });
|
|
85
|
+
expect(answer).toEqual({
|
|
86
|
+
url: 'https://hexis.example.com/change-requests/12',
|
|
87
|
+
number: 12,
|
|
88
|
+
title: 'Rework the onboarding note',
|
|
89
|
+
state: 'open',
|
|
90
|
+
author: { login: loginFor(AUTHOR), name: 'Juan' },
|
|
91
|
+
sourceBranch: 'juan/my-draft',
|
|
92
|
+
targetBranch: 'main',
|
|
93
|
+
createdAt: '2026-09-28T10:00:00.000Z',
|
|
94
|
+
updatedAt: '2026-09-28T10:00:00.000Z',
|
|
95
|
+
changedFiles: 1,
|
|
96
|
+
withheldFiles: 0,
|
|
97
|
+
});
|
|
98
|
+
// `url` first on purpose: it is the field an agent must hand a person, so a
|
|
99
|
+
// truncation of the answer cannot take it. `toEqual` above pins the set of
|
|
100
|
+
// keys; this pins the order.
|
|
101
|
+
expect(Object.keys(answer)[0]).toBe('url');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('carries the note through when the deployment has no public address', () => {
|
|
105
|
+
const cr = summary({ url: '/change-requests/12', urlNote: 'Configure a public address.' });
|
|
106
|
+
expect(toCrSummary(cr, { readable: 1, withheld: 0 })).toMatchObject({
|
|
107
|
+
url: '/change-requests/12',
|
|
108
|
+
urlNote: 'Configure a public address.',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('falls back to the creation time when the row records no later moment', () => {
|
|
113
|
+
const cr = summary();
|
|
114
|
+
delete cr.updatedAt;
|
|
115
|
+
expect(toCrSummary(cr, { readable: 1, withheld: 0 }).updatedAt).toBe(cr.createdAt);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("carries the last apply failure, its reason only for a caller who may read every file of a request that has some", () => {
|
|
119
|
+
const failure = { reason: 'Conflicts in Payroll/Rates.md', conflicts: true, at: '2026-09-29T09:00:00.000Z' };
|
|
120
|
+
const cr = summary({ lastApplyFailure: failure });
|
|
121
|
+
// Every file readable — one is enough: the author's words.
|
|
122
|
+
expect(toCrSummary(cr, { readable: 1, withheld: 0 }).lastApplyFailure).toEqual(failure);
|
|
123
|
+
// One file withheld: the reason could name it, so it is the app's
|
|
124
|
+
// withheld line — the time and the kind are still the caller's to see.
|
|
125
|
+
expect(toCrSummary(cr, { readable: 1, withheld: 1 }).lastApplyFailure).toEqual({
|
|
126
|
+
reason: APPLY_FAILURE_REASON_WITHHELD,
|
|
127
|
+
conflicts: true,
|
|
128
|
+
at: failure.at,
|
|
129
|
+
});
|
|
130
|
+
// No file at all proves nothing, so it grants nothing.
|
|
131
|
+
expect(toCrSummary(cr, { readable: 0, withheld: 0 }).lastApplyFailure?.reason).toBe(APPLY_FAILURE_REASON_WITHHELD);
|
|
132
|
+
// Nothing to report: the key is absent, not null.
|
|
133
|
+
expect(toCrSummary(summary({ lastApplyFailure: null }), { readable: 1, withheld: 0 })).not.toHaveProperty('lastApplyFailure');
|
|
134
|
+
expect(toCrSummary(summary(), { readable: 1, withheld: 0 })).not.toHaveProperty('lastApplyFailure');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('the author filter', () => {
|
|
139
|
+
it('matches an email through the stored author hash', () => {
|
|
140
|
+
expect(matchesAuthor(summary(), AUTHOR)).toBe(true);
|
|
141
|
+
expect(matchesAuthor(summary(), ' JUAN@Bevel.Software ')).toBe(true);
|
|
142
|
+
expect(matchesAuthor(summary(), 'mia@bevel.software')).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('matches a login by name', () => {
|
|
146
|
+
expect(matchesAuthor(summary(), loginFor(AUTHOR))).toBe(true);
|
|
147
|
+
expect(matchesAuthor(summary(), 'user-000000000000')).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('matches nothing on an empty needle, and nothing on a request with no author hash', () => {
|
|
151
|
+
expect(matchesAuthor(summary(), ' ')).toBe(false);
|
|
152
|
+
expect(matchesAuthor(summary({ authorId: undefined }), AUTHOR)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('knows the viewer is the author only when the hashes agree', () => {
|
|
156
|
+
expect(isAuthor(summary(), AUTHOR)).toBe(true);
|
|
157
|
+
expect(isAuthor(summary(), 'mia@bevel.software')).toBe(false);
|
|
158
|
+
expect(isAuthor(summary(), undefined)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('visibility', () => {
|
|
163
|
+
it('shows a request with at least one readable file', () => {
|
|
164
|
+
expect(maySeeChangeRequest({ readableFiles: 1, isAuthor: false })).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Scenario: WHEN the caller may read none of the request's files and is not
|
|
168
|
+
// its author THEN `get_change_request` answers 404.
|
|
169
|
+
it('hides a request whose every file is closed to a caller who is not its author', () => {
|
|
170
|
+
expect(maySeeChangeRequest({ readableFiles: 0, isAuthor: false })).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('shows the author their own request even when they may read none of it', () => {
|
|
174
|
+
expect(maySeeChangeRequest({ readableFiles: 0, isAuthor: true })).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('paging', () => {
|
|
179
|
+
it("reads GitHub's defaults and clamps what is out of range", () => {
|
|
180
|
+
expect(pagingOf({})).toEqual({ perPage: 30, page: 1 });
|
|
181
|
+
expect(pagingOf({ per_page: 100, page: 3 })).toEqual({ perPage: 100, page: 3 });
|
|
182
|
+
expect(pagingOf({ per_page: 500 })).toEqual({ perPage: 100, page: 1 });
|
|
183
|
+
expect(pagingOf({ per_page: 0, page: 0 })).toEqual({ perPage: 1, page: 1 });
|
|
184
|
+
expect(pagingOf({ per_page: 'lots', page: null })).toEqual({ perPage: 30, page: 1 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Scenario: WHEN the request has 40 files THEN the first page holds 30 and
|
|
188
|
+
// says there is a second.
|
|
189
|
+
it('a first page of 40 holds 30 and says there is a second', () => {
|
|
190
|
+
const all = Array.from({ length: 40 }, (_, i) => i);
|
|
191
|
+
const first = pageOf(all, 30, 1);
|
|
192
|
+
expect(first.items).toHaveLength(30);
|
|
193
|
+
expect(first).toMatchObject({ totalCount: 40, page: 1, perPage: 30, hasNextPage: true });
|
|
194
|
+
const second = pageOf(all, 30, 2);
|
|
195
|
+
expect(second.items).toHaveLength(10);
|
|
196
|
+
expect(second.hasNextPage).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('a page past the end is empty rather than an error', () => {
|
|
200
|
+
expect(pageOf([1, 2], 30, 9)).toMatchObject({ items: [], totalCount: 2, hasNextPage: false });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('files', () => {
|
|
205
|
+
it("names the path `path`, the kind of change `change`, and the old name `previousPath`", () => {
|
|
206
|
+
const file = {
|
|
207
|
+
path: 'Knowledge/A.md',
|
|
208
|
+
previousPath: 'Knowledge/Old.md',
|
|
209
|
+
status: 'renamed' as const,
|
|
210
|
+
additions: 3,
|
|
211
|
+
deletions: 1,
|
|
212
|
+
isBinary: false,
|
|
213
|
+
sha: 'blob-1',
|
|
214
|
+
rawUrl: '',
|
|
215
|
+
patch: '@@ -1 +1 @@',
|
|
216
|
+
};
|
|
217
|
+
const approved = approval({
|
|
218
|
+
approvedBy: [
|
|
219
|
+
{ email: 'mia@bevel.software', name: 'Mia', approvedAt: '2026-09-29T08:00:00.000Z', isStale: false, isSelfApproval: false },
|
|
220
|
+
],
|
|
221
|
+
isApproved: true,
|
|
222
|
+
viewerCanApprove: true,
|
|
223
|
+
});
|
|
224
|
+
expect(toCrFile(file, approved, { patches: false, open: true })).toEqual({
|
|
225
|
+
path: 'Knowledge/A.md',
|
|
226
|
+
previousPath: 'Knowledge/Old.md',
|
|
227
|
+
// git's `renamed` through `open_change_request`'s own `changeKindOf`.
|
|
228
|
+
change: 'moved',
|
|
229
|
+
additions: 3,
|
|
230
|
+
deletions: 1,
|
|
231
|
+
// No `sha`: nothing in Hexis populates a blob sha, so answering one would
|
|
232
|
+
// answer `""` for every file. See `toCrFile`.
|
|
233
|
+
isBinary: false,
|
|
234
|
+
requiredApprovers: { roles: ['Engineering'], users: [] },
|
|
235
|
+
approvedBy: [
|
|
236
|
+
{
|
|
237
|
+
user: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
|
|
238
|
+
approvedAt: '2026-09-29T08:00:00.000Z',
|
|
239
|
+
stale: false,
|
|
240
|
+
selfApproval: false,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
approved: true,
|
|
244
|
+
inMergeGate: true,
|
|
245
|
+
viewerMayApprove: true,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('never says the viewer may approve a file of a request that is no longer open', () => {
|
|
250
|
+
// A write grant on the file outlives the request: `viewerCanApprove` is
|
|
251
|
+
// still true on a merged or declined request's approval entry, and
|
|
252
|
+
// answering it would offer an approval nothing accepts.
|
|
253
|
+
const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '' };
|
|
254
|
+
const grant = approval({ viewerCanApprove: true });
|
|
255
|
+
expect(toCrFile(file, grant, { patches: false, open: true }).viewerMayApprove).toBe(true);
|
|
256
|
+
expect(toCrFile(file, grant, { patches: false, open: false }).viewerMayApprove).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns a patch only when one was asked for', () => {
|
|
260
|
+
const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '', patch: '@@' };
|
|
261
|
+
expect(toCrFile(file, undefined, { patches: false, open: true }).patch).toBeUndefined();
|
|
262
|
+
expect(toCrFile(file, undefined, { patches: true, open: true }).patch).toBe('@@');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// `approversUnknown` is PRESENT or absent, never `false` — the same shape
|
|
266
|
+
// `open_change_request` answers in, so the fail-closed reading ("empty means
|
|
267
|
+
// not known") is the one a caller gets by default rather than one they have to
|
|
268
|
+
// look for.
|
|
269
|
+
it('says the approver set is unknown when nothing could answer for the file', () => {
|
|
270
|
+
const file = { path: 'A.md', status: 'modified' as const, additions: 1, deletions: 0, isBinary: false, sha: 's', rawUrl: '' };
|
|
271
|
+
expect(toCrFile(file, undefined, { patches: false, open: true })).toMatchObject({
|
|
272
|
+
requiredApprovers: { roles: [], users: [] },
|
|
273
|
+
approversUnknown: true,
|
|
274
|
+
approved: false,
|
|
275
|
+
inMergeGate: false,
|
|
276
|
+
viewerMayApprove: false,
|
|
277
|
+
});
|
|
278
|
+
expect(toCrFile(file, approval({ eligibilityResolved: false }), { patches: false, open: true })
|
|
279
|
+
.approversUnknown).toBe(true);
|
|
280
|
+
// Resolved: the key is absent, not false.
|
|
281
|
+
expect(toCrFile(file, approval(), { patches: false, open: true })).not.toHaveProperty('approversUnknown');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('reads each of git\'s statuses as one of the four words', () => {
|
|
285
|
+
const kind = (status: 'added' | 'removed' | 'renamed' | 'copied' | 'modified' | 'changed' | 'unchanged') =>
|
|
286
|
+
toCrFile(
|
|
287
|
+
{ path: 'A.md', status, additions: 0, deletions: 0, isBinary: false, sha: 's', rawUrl: '' },
|
|
288
|
+
undefined,
|
|
289
|
+
{ patches: false, open: true },
|
|
290
|
+
).change;
|
|
291
|
+
expect(kind('added')).toBe('added');
|
|
292
|
+
expect(kind('removed')).toBe('deleted');
|
|
293
|
+
expect(kind('renamed')).toBe('moved');
|
|
294
|
+
expect(kind('copied')).toBe('moved');
|
|
295
|
+
expect(kind('modified')).toBe('changed');
|
|
296
|
+
expect(kind('unchanged')).toBe('changed');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('reviews, gathered out of the per-file approvals', () => {
|
|
301
|
+
// Scenario: WHEN a reviewer approved two of three files THEN
|
|
302
|
+
// `list_change_request_reviews` names the reviewer and the two files.
|
|
303
|
+
it('names a reviewer once with the files they approved and the latest time', () => {
|
|
304
|
+
const mia = (at: string, isStale = false) => ({
|
|
305
|
+
email: 'mia@bevel.software',
|
|
306
|
+
name: 'Mia',
|
|
307
|
+
approvedAt: at,
|
|
308
|
+
isStale,
|
|
309
|
+
isSelfApproval: false,
|
|
310
|
+
});
|
|
311
|
+
const { reviews, withheldReviews } = toCrReviews([
|
|
312
|
+
approval({ path: 'A.md', approvedBy: [mia('2026-09-29T08:00:00.000Z')] }),
|
|
313
|
+
approval({ path: 'B.md', approvedBy: [mia('2026-09-29T09:00:00.000Z')] }),
|
|
314
|
+
approval({ path: 'C.md', approvedBy: [] }),
|
|
315
|
+
]);
|
|
316
|
+
expect(reviews).toEqual([
|
|
317
|
+
{
|
|
318
|
+
id: `${loginFor('mia@bevel.software')}:current`,
|
|
319
|
+
reviewer: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
|
|
320
|
+
stale: false,
|
|
321
|
+
submittedAt: '2026-09-29T09:00:00.000Z',
|
|
322
|
+
files: ['A.md', 'B.md'],
|
|
323
|
+
withheldFiles: 0,
|
|
324
|
+
},
|
|
325
|
+
]);
|
|
326
|
+
expect(withheldReviews).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// GitHub's word for this is `DISMISSED`; Hexis's is `isStale`, and the answer
|
|
330
|
+
// uses Hexis's.
|
|
331
|
+
it("reports an approval a later push invalidated as `stale`, separately", () => {
|
|
332
|
+
const mia = (at: string, isStale: boolean) => ({ email: 'mia@x', name: 'Mia', approvedAt: at, isStale, isSelfApproval: false });
|
|
333
|
+
const { reviews } = toCrReviews([
|
|
334
|
+
approval({ path: 'A.md', approvedBy: [mia('2026-09-29T08:00:00.000Z', false)] }),
|
|
335
|
+
approval({ path: 'B.md', approvedBy: [mia('2026-09-28T08:00:00.000Z', true)] }),
|
|
336
|
+
]);
|
|
337
|
+
expect(reviews.map((r) => [r.stale, r.files])).toEqual([
|
|
338
|
+
[true, ['B.md']],
|
|
339
|
+
[false, ['A.md']],
|
|
340
|
+
]);
|
|
341
|
+
expect(JSON.stringify(reviews)).not.toContain('DISMISSED');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("keeps a review's readable files and counts the rest, naming none of them", () => {
|
|
345
|
+
const entry = (at: string) => ({ email: 'mia@x', name: 'Mia', approvedAt: at, isStale: false, isSelfApproval: false });
|
|
346
|
+
const { reviews, withheldReviews } = toCrReviews(
|
|
347
|
+
[
|
|
348
|
+
approval({ path: 'Knowledge/A.md', approvedBy: [entry('2026-09-29T08:00:00.000Z')] }),
|
|
349
|
+
approval({ path: 'Secret/Pay.md', approvedBy: [entry('2026-09-29T10:00:00.000Z')] }),
|
|
350
|
+
],
|
|
351
|
+
(path) => path === 'Knowledge/A.md',
|
|
352
|
+
);
|
|
353
|
+
expect(reviews).toHaveLength(1);
|
|
354
|
+
expect(reviews[0]).toMatchObject({ files: ['Knowledge/A.md'], withheldFiles: 1 });
|
|
355
|
+
// Neither the path nor the moment it was approved leaks out.
|
|
356
|
+
expect(JSON.stringify(reviews)).not.toContain('Secret');
|
|
357
|
+
expect(JSON.stringify(reviews)).not.toContain('10:00:00');
|
|
358
|
+
expect(withheldReviews).toBe(0);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('drops a review whose every file is withheld, and counts it instead', () => {
|
|
362
|
+
const entry = { email: 'mia@x', name: 'Mia', approvedAt: '2026-09-29T08:00:00.000Z', isStale: false, isSelfApproval: false };
|
|
363
|
+
const { reviews, withheldReviews } = toCrReviews(
|
|
364
|
+
[approval({ path: 'Secret/Pay.md', approvedBy: [entry] })],
|
|
365
|
+
() => false,
|
|
366
|
+
);
|
|
367
|
+
expect(reviews).toEqual([]);
|
|
368
|
+
expect(withheldReviews).toBe(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('answers with no reviews when nobody has approved anything', () => {
|
|
372
|
+
expect(toCrReviews([approval({ path: 'A.md' })])).toEqual({ reviews: [], withheldReviews: 0 });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('comments', () => {
|
|
377
|
+
// Scenario: WHEN a reviewer left an inline comment and a reply followed THEN
|
|
378
|
+
// `list_change_request_comments` returns both, the reply with `parentId`.
|
|
379
|
+
it('maps an inline comment and a reply, the reply carrying `parentId`', () => {
|
|
380
|
+
const inline = toCrComment({
|
|
381
|
+
id: 'c-1',
|
|
382
|
+
author: { email: 'mia@bevel.software', name: 'Mia' },
|
|
383
|
+
body: 'This line is out of date.',
|
|
384
|
+
path: 'Knowledge/A.md',
|
|
385
|
+
line: 14,
|
|
386
|
+
headSha: 'head-1',
|
|
387
|
+
createdAt: '2026-09-29T08:00:00.000Z',
|
|
388
|
+
});
|
|
389
|
+
expect(inline).toEqual({
|
|
390
|
+
id: 'c-1',
|
|
391
|
+
author: { login: loginFor('mia@bevel.software'), name: 'Mia', email: 'mia@bevel.software' },
|
|
392
|
+
body: 'This line is out of date.',
|
|
393
|
+
path: 'Knowledge/A.md',
|
|
394
|
+
line: 14,
|
|
395
|
+
headSha: 'head-1',
|
|
396
|
+
createdAt: '2026-09-29T08:00:00.000Z',
|
|
397
|
+
});
|
|
398
|
+
const reply = toCrComment({
|
|
399
|
+
id: 'c-2',
|
|
400
|
+
author: { email: AUTHOR, name: 'Juan' },
|
|
401
|
+
body: 'Fixed.',
|
|
402
|
+
path: 'Knowledge/A.md',
|
|
403
|
+
line: 14,
|
|
404
|
+
headSha: 'head-1',
|
|
405
|
+
parentId: 'c-1',
|
|
406
|
+
createdAt: '2026-09-29T09:00:00.000Z',
|
|
407
|
+
updatedAt: '2026-09-29T09:05:00.000Z',
|
|
408
|
+
});
|
|
409
|
+
expect(reply).toMatchObject({ parentId: 'c-1', updatedAt: '2026-09-29T09:05:00.000Z' });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('leaves `path`, `line` and `parentId` off a general comment', () => {
|
|
413
|
+
const general = toCrComment({
|
|
414
|
+
id: 'c-3',
|
|
415
|
+
author: { email: AUTHOR, name: 'Juan' },
|
|
416
|
+
body: 'Ready for review.',
|
|
417
|
+
headSha: 'head-1',
|
|
418
|
+
createdAt: '2026-09-29T07:00:00.000Z',
|
|
419
|
+
});
|
|
420
|
+
expect(general.path).toBeUndefined();
|
|
421
|
+
expect(general.line).toBeUndefined();
|
|
422
|
+
expect(general.parentId).toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('the viewer block — what this caller may do', () => {
|
|
427
|
+
const detail = (over: Partial<ChangeRequestDetail> = {}) =>
|
|
428
|
+
({
|
|
429
|
+
state: 'open',
|
|
430
|
+
mergeBlockedReasons: [],
|
|
431
|
+
mergeWarnings: [],
|
|
432
|
+
viewerCanBypassMerge: false,
|
|
433
|
+
approvals: [],
|
|
434
|
+
...over,
|
|
435
|
+
}) as ChangeRequestDetail;
|
|
436
|
+
|
|
437
|
+
it('mayMerge is true when the gate waits on nothing', () => {
|
|
438
|
+
// Only the three verbs. The blockers live beside it on the answer, under
|
|
439
|
+
// `open_change_request`'s own `mergeBlockedReasons`, so this block holds
|
|
440
|
+
// exactly what is about the CALLER and nothing about the request.
|
|
441
|
+
expect(toCrViewer(detail(), false, [])).toEqual({
|
|
442
|
+
mayApprove: false,
|
|
443
|
+
mayMerge: true,
|
|
444
|
+
isAuthor: false,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('mayMerge is false on a missing approval, and true for an admin who may bypass it', () => {
|
|
449
|
+
const waiting = ['Waiting on approval for A.md from Engineering.'];
|
|
450
|
+
expect(
|
|
451
|
+
toCrViewer(detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting }), false, []).mayMerge,
|
|
452
|
+
).toBe(false);
|
|
453
|
+
expect(
|
|
454
|
+
toCrViewer(
|
|
455
|
+
detail({ mergeBlockedReasons: waiting, mergeWarnings: waiting, viewerCanBypassMerge: true }),
|
|
456
|
+
false,
|
|
457
|
+
[],
|
|
458
|
+
).mayMerge,
|
|
459
|
+
).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('mayMerge is false on a hard block, bypass or not', () => {
|
|
463
|
+
const hard = ['This pull request is closed.'];
|
|
464
|
+
expect(
|
|
465
|
+
toCrViewer(detail({ state: 'closed', mergeBlockedReasons: hard, viewerCanBypassMerge: true }), false, []).mayMerge,
|
|
466
|
+
).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('mayApprove is true when the caller may approve any one file they are shown', () => {
|
|
470
|
+
expect(
|
|
471
|
+
toCrViewer(detail(), false, [
|
|
472
|
+
approval(),
|
|
473
|
+
approval({ path: 'B.md', viewerCanApprove: true }),
|
|
474
|
+
]).mayApprove,
|
|
475
|
+
).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('mayApprove is false on a merged or declined request, whatever grant the caller holds', () => {
|
|
479
|
+
const grant = [approval({ viewerCanApprove: true })];
|
|
480
|
+
for (const state of ['merged', 'closed'] as const) {
|
|
481
|
+
expect(toCrViewer(detail({ state }), false, grant).mayApprove, state).toBe(false);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('mayApprove follows exactly the approval set it is given', () => {
|
|
486
|
+
// What this pins is the narrow thing the signature can pin: the answer is a
|
|
487
|
+
// function of the SHOWN approvals and of nothing else. The leak it exists to
|
|
488
|
+
// prevent — a write grant outliving a read refusal, so the only approvable
|
|
489
|
+
// file is one the caller is not shown — cannot be staged here at all, since
|
|
490
|
+
// `toCrViewer` no longer receives the request's whole approval set (its
|
|
491
|
+
// `Pick` excludes `approvals` for exactly that reason). That case is driven
|
|
492
|
+
// end to end by 'answers mayApprove false when the only approvable file is
|
|
493
|
+
// the withheld one' in change-request-read.tools.test.ts.
|
|
494
|
+
const shown = [approval({ path: 'Knowledge/A.md' })];
|
|
495
|
+
expect(toCrViewer(detail(), false, shown).mayApprove).toBe(false);
|
|
496
|
+
expect(
|
|
497
|
+
toCrViewer(detail(), false, [
|
|
498
|
+
...shown,
|
|
499
|
+
approval({ path: 'Knowledge/A.md', viewerCanApprove: true }),
|
|
500
|
+
]).mayApprove,
|
|
501
|
+
).toBe(true);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe('merge blockers never name a file the caller may not read', () => {
|
|
506
|
+
it('withholds and counts a blocker that quotes a withheld path', () => {
|
|
507
|
+
const reasons = [
|
|
508
|
+
'Waiting on approval for Knowledge/Open.md from Engineering.',
|
|
509
|
+
'Waiting on approval for Secret/Pay.md from Finance.',
|
|
510
|
+
];
|
|
511
|
+
const scoped = visibleBlockers(reasons, ['Secret/Pay.md']);
|
|
512
|
+
expect(scoped).toEqual({ visible: [reasons[0]], withheld: 1 });
|
|
513
|
+
expect(JSON.stringify(scoped)).not.toContain('Secret/Pay.md');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('keeps every blocker when nothing is withheld', () => {
|
|
517
|
+
const reasons = ['This pull request has no file changes to approve.'];
|
|
518
|
+
expect(visibleBlockers(reasons, [])).toEqual({ visible: reasons, withheld: 0 });
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The leak Local Testing caught on the first attempt: `openChangeRequest`
|
|
524
|
+
* appends a generated `## Affected owners` block to the body, naming every
|
|
525
|
+
* changed path and its approvers, and the detail returned the body verbatim.
|
|
526
|
+
*/
|
|
527
|
+
describe('the body a caller is handed is the author\'s, not the machine\'s', () => {
|
|
528
|
+
const GENERATED = [
|
|
529
|
+
'## Affected owners',
|
|
530
|
+
'',
|
|
531
|
+
'- `KnowledgeBase/Engineering/Knowledge/Avi-Checkin.md` — Admin',
|
|
532
|
+
'- `KnowledgeBase/GTM/Notes.md` — GTM Team',
|
|
533
|
+
].join('\n');
|
|
534
|
+
|
|
535
|
+
it('drops the generated owners block, keeping what the author typed', () => {
|
|
536
|
+
expect(authorsDescription(`Please take a look.\n\n${GENERATED}`)).toBe('Please take a look.');
|
|
537
|
+
expect(authorsDescription(GENERATED)).toBe('');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('names no path of the generated block, readable or not', () => {
|
|
541
|
+
const out = authorsDescription(`Why this is needed.\n\n${GENERATED}`);
|
|
542
|
+
expect(out).not.toContain('Avi-Checkin');
|
|
543
|
+
expect(out).not.toContain('KnowledgeBase');
|
|
544
|
+
expect(out).not.toContain('Affected owners');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("strips the hidden identity markers older bodies carry, and only those — an author's own HTML comment is prose", () => {
|
|
548
|
+
expect(authorsDescription('<!-- bevel:author:abc123 -->\nMy reason.')).toBe('My reason.');
|
|
549
|
+
expect(authorsDescription('<!--hexis:access-request-level:write-->\nMy reason.')).toBe('My reason.');
|
|
550
|
+
expect(authorsDescription('My reason.\n<!-- TODO: ask Mia about the title -->')).toBe(
|
|
551
|
+
'My reason.\n<!-- TODO: ask Mia about the title -->',
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('leaves a body that is only the author\'s prose alone', () => {
|
|
556
|
+
expect(authorsDescription(' A multi-line\nreason. ')).toBe('A multi-line\nreason.');
|
|
557
|
+
expect(authorsDescription('')).toBe('');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Razvan's review (2026-10-02): the first cut copied the app's dialog, which
|
|
561
|
+
// drops everything from the first `##` heading on. An author who writes their
|
|
562
|
+
// description in sections lost it from that heading onwards, and was never
|
|
563
|
+
// told. The cut is now the generated block and nothing else.
|
|
564
|
+
it("keeps the author's own headings, and everything under them", () => {
|
|
565
|
+
expect(authorsDescription('Lead.\n\n## My own heading\n\nmore')).toBe(
|
|
566
|
+
'Lead.\n\n## My own heading\n\nmore',
|
|
567
|
+
);
|
|
568
|
+
expect(authorsDescription(`Lead.\n\n## Why now\n\nBecause.\n\n${GENERATED}`)).toBe(
|
|
569
|
+
'Lead.\n\n## Why now\n\nBecause.',
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Hexis appends the block LAST, so the last such line is always the generated
|
|
574
|
+
// one — which is what makes an author who happens to use that heading
|
|
575
|
+
// themselves no worse off than before.
|
|
576
|
+
it('cuts the LAST such heading, so an author may use the words too', () => {
|
|
577
|
+
const out = authorsDescription(`Lead.\n\n## Affected owners\n\nI mean the GTM ones.\n\n${GENERATED}`);
|
|
578
|
+
expect(out).toBe('Lead.\n\n## Affected owners\n\nI mean the GTM ones.');
|
|
579
|
+
// Their sentence is kept; not one path of the generated block is.
|
|
580
|
+
expect(out).not.toContain('Avi-Checkin');
|
|
581
|
+
expect(out).not.toContain('KnowledgeBase');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('cuts an author\'s own owners heading when there is no generated block', () => {
|
|
585
|
+
// Indistinguishable from the generated one with nothing after it, so it is
|
|
586
|
+
// cut — the safe direction, and the only way this can err.
|
|
587
|
+
expect(authorsDescription('Lead.\n\n## Affected owners\n\nI mean the GTM ones.')).toBe('Lead.');
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* A rename names TWO paths. The diff of a rename shows the content that was at
|
|
593
|
+
* the old one, so a file renamed out of a folder the caller may not read must
|
|
594
|
+
* not be listed under its new name either.
|
|
595
|
+
*/
|
|
596
|
+
describe('a renamed file is judged on both of its names', () => {
|
|
597
|
+
const renamed = {
|
|
598
|
+
path: 'Knowledge/Open.md',
|
|
599
|
+
previousPath: 'Payroll/Rates.md',
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
it('is readable only when both of its names are', () => {
|
|
603
|
+
expect(fileIsReadable(renamed, () => true)).toBe(true);
|
|
604
|
+
expect(fileIsReadable(renamed, (p) => p === 'Knowledge/Open.md')).toBe(false);
|
|
605
|
+
expect(fileIsReadable(renamed, (p) => p === 'Payroll/Rates.md')).toBe(false);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('judges a file that was not renamed on its one name', () => {
|
|
609
|
+
expect(fileIsReadable({ path: 'A.md' }, (p) => p === 'A.md')).toBe(true);
|
|
610
|
+
expect(fileIsReadable({ path: 'A.md' }, () => false)).toBe(false);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('offers both names for the access lookup, and one for a plain file', () => {
|
|
614
|
+
expect(pathsOf(renamed)).toEqual(['Knowledge/Open.md', 'Payroll/Rates.md']);
|
|
615
|
+
expect(pathsOf({ path: 'A.md' })).toEqual(['A.md']);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A reply is shown only when the comment it replies to is (Razvan's review,
|
|
621
|
+
* 2026-10-02, finding 3). A reply posted without a path of its own — which
|
|
622
|
+
* `post_change_request_comment` accepts, taking `parentId` without `path` — read
|
|
623
|
+
* as a general comment when judged on itself, so a reply to a comment on a
|
|
624
|
+
* withheld file came back with its body and a `parentId` naming a comment the
|
|
625
|
+
* caller cannot see.
|
|
626
|
+
*/
|
|
627
|
+
describe('a reply takes its parent\'s verdict, up the chain', () => {
|
|
628
|
+
const c = (id: string, over: { path?: string; parentId?: string } = {}) => ({ id, ...over });
|
|
629
|
+
const mayShow = (path: string) => path.startsWith('Open/');
|
|
630
|
+
|
|
631
|
+
it('withholds a pathless reply to a comment on a file the caller may not read', () => {
|
|
632
|
+
const { visible, withheld } = visibleComments(
|
|
633
|
+
[c('c-1', { path: 'Secret/Pay.md' }), c('c-2', { parentId: 'c-1' })],
|
|
634
|
+
mayShow,
|
|
635
|
+
);
|
|
636
|
+
expect(visible).toEqual([]);
|
|
637
|
+
expect(withheld).toBe(2);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('withholds a reply to that reply, however deep the thread goes', () => {
|
|
641
|
+
const { visible, withheld } = visibleComments(
|
|
642
|
+
[
|
|
643
|
+
c('c-1', { path: 'Secret/Pay.md' }),
|
|
644
|
+
c('c-2', { parentId: 'c-1' }),
|
|
645
|
+
c('c-3', { parentId: 'c-2' }),
|
|
646
|
+
c('c-4', { parentId: 'c-3' }),
|
|
647
|
+
],
|
|
648
|
+
mayShow,
|
|
649
|
+
);
|
|
650
|
+
expect(visible).toEqual([]);
|
|
651
|
+
expect(withheld).toBe(4);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('keeps a reply whose whole chain is readable', () => {
|
|
655
|
+
const { visible, withheld } = visibleComments(
|
|
656
|
+
[c('c-1', { path: 'Open/A.md' }), c('c-2', { parentId: 'c-1' }), c('c-3', { parentId: 'c-2' })],
|
|
657
|
+
mayShow,
|
|
658
|
+
);
|
|
659
|
+
expect(visible.map((v) => v.id)).toEqual(['c-1', 'c-2', 'c-3']);
|
|
660
|
+
expect(withheld).toBe(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Both halves of the rule are needed, and this is the half the inheritance
|
|
664
|
+
// alone would miss: the reply names a withheld file itself.
|
|
665
|
+
it('withholds a reply that names a withheld file under a readable parent', () => {
|
|
666
|
+
const { visible } = visibleComments(
|
|
667
|
+
[c('c-1', { path: 'Open/A.md' }), c('c-2', { path: 'Secret/Pay.md', parentId: 'c-1' })],
|
|
668
|
+
mayShow,
|
|
669
|
+
);
|
|
670
|
+
expect(visible.map((v) => v.id)).toEqual(['c-1']);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('keeps a general comment and a reply to one — neither is about a file', () => {
|
|
674
|
+
const { visible, withheld } = visibleComments([c('c-1'), c('c-2', { parentId: 'c-1' })], mayShow);
|
|
675
|
+
expect(visible.map((v) => v.id)).toEqual(['c-1', 'c-2']);
|
|
676
|
+
expect(withheld).toBe(0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('withholds a reply whose parent is not in the list at all', () => {
|
|
680
|
+
// Not provably pathless, so it goes: the parent may have been anchored to
|
|
681
|
+
// anything, including a file this caller was refused.
|
|
682
|
+
const { visible, withheld } = visibleComments([c('c-2', { parentId: 'gone' })], mayShow);
|
|
683
|
+
expect(visible).toEqual([]);
|
|
684
|
+
expect(withheld).toBe(1);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('withholds a cycle rather than looping on it', () => {
|
|
688
|
+
const { visible, withheld } = visibleComments(
|
|
689
|
+
[c('c-1', { parentId: 'c-2' }), c('c-2', { parentId: 'c-1' })],
|
|
690
|
+
mayShow,
|
|
691
|
+
);
|
|
692
|
+
expect(visible).toEqual([]);
|
|
693
|
+
expect(withheld).toBe(2);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('resolves a long thread without re-walking it per reply', () => {
|
|
697
|
+
// 500 replies in one chain: memoised, this is linear; unmemoised it is
|
|
698
|
+
// quadratic and the suite would feel it.
|
|
699
|
+
const chain = [c('c-0', { path: 'Open/A.md' })];
|
|
700
|
+
for (let i = 1; i <= 500; i++) chain.push(c(`c-${i}`, { parentId: `c-${i - 1}` }));
|
|
701
|
+
const { visible, withheld } = visibleComments(chain, mayShow);
|
|
702
|
+
expect(visible).toHaveLength(501);
|
|
703
|
+
expect(withheld).toBe(0);
|
|
704
|
+
});
|
|
705
|
+
});
|