@bevel-software/platform-core-backend 0.21.0 → 0.22.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.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 +15 -0
- package/dist/modules/tool-helpers/tool-context.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 +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- 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 +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -17,6 +17,7 @@ import { PullRebaseConflictError, WorkflowDomainError } from '../../../shared/do
|
|
|
17
17
|
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
18
18
|
import type { Database } from '../../database/connection.js';
|
|
19
19
|
import { openChangeGate } from '../../../__tests__/open-change-gate.js';
|
|
20
|
+
import { hashEmail } from '../../../shared/email-identity.js';
|
|
20
21
|
|
|
21
22
|
// `deleteBranch`'s open-request guard is the only DB touch these tests
|
|
22
23
|
// exercise. The stub ANSWERS FROM THE WHERE-CLAUSE rather than returning the
|
|
@@ -375,7 +376,7 @@ describe('WorkflowService — change request delegation + cache invalidation', (
|
|
|
375
376
|
expect(outcome).toEqual({ kind: 'merged', result: mergeResult });
|
|
376
377
|
// The target branch's workspace is pulled so it doesn't fall behind origin.
|
|
377
378
|
expect(workspaceService.getOrCreateForBranch).toHaveBeenCalledWith('main');
|
|
378
|
-
expect(git.pull).toHaveBeenCalledWith('main');
|
|
379
|
+
expect(git.pull).toHaveBeenCalledWith('main', {});
|
|
379
380
|
expect(reviewWorkflow.mergePr).toHaveBeenCalledWith(
|
|
380
381
|
4,
|
|
381
382
|
expect.objectContaining({ email: 'alice@example.com' }),
|
|
@@ -784,8 +785,13 @@ describe('WorkflowService — revertChangeRequestFile / closeEmptyChangeRequest'
|
|
|
784
785
|
});
|
|
785
786
|
});
|
|
786
787
|
|
|
787
|
-
describe('WorkflowService — deleteChangeRequest (admin
|
|
788
|
-
|
|
788
|
+
describe('WorkflowService — deleteChangeRequest (the author’s and the admin’s verb)', () => {
|
|
789
|
+
const AUTHOR_EMAIL = 'alice@example.com'; // makeUser()'s default caller
|
|
790
|
+
const STRANGER_AUTHOR = 'mallory@example.com';
|
|
791
|
+
|
|
792
|
+
function makeDeleteHarness(
|
|
793
|
+
opts: { isAdmin?: boolean; prState?: string; authorEmail?: string | null } = {},
|
|
794
|
+
) {
|
|
789
795
|
const git = Object.assign(makeGit(), {
|
|
790
796
|
changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md']),
|
|
791
797
|
}) as unknown as GitService;
|
|
@@ -795,26 +801,83 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
|
|
|
795
801
|
base: 'main',
|
|
796
802
|
branch: 'mallory/spam',
|
|
797
803
|
state: opts.prState ?? 'open',
|
|
804
|
+
// Authorship as the server reads it: the stored hash, never the caller's
|
|
805
|
+
// claim. Defaults to someone OTHER than makeUser(), so a test that says
|
|
806
|
+
// nothing about authorship is testing the admin grant alone.
|
|
807
|
+
// `null` means a request with no stored author (opened outside this
|
|
808
|
+
// backend); undefined means "someone, just not the caller".
|
|
809
|
+
authorId:
|
|
810
|
+
opts.authorEmail === null ? undefined : hashEmail(opts.authorEmail ?? STRANGER_AUTHOR),
|
|
798
811
|
});
|
|
799
812
|
const access = makeAccessControl();
|
|
800
813
|
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.isAdmin ?? false);
|
|
801
814
|
const chain: Record<string, unknown> = {};
|
|
815
|
+
// Two different selects run under one chain mock: the branch-retirement
|
|
816
|
+
// lookup asks for `{ sourceBranch }`, and `openChangeRequestOn` asks for
|
|
817
|
+
// `{ number, sourceBranch }`. Answer by projection rather than call order,
|
|
818
|
+
// so the retirement path is exercised (a branch to retire, and no other
|
|
819
|
+
// open request holding it) without the test depending on call sequence.
|
|
820
|
+
let projection: Record<string, unknown> = {};
|
|
802
821
|
Object.assign(chain, {
|
|
803
|
-
select: vi.fn(() =>
|
|
822
|
+
select: vi.fn((proj?: Record<string, unknown>) => {
|
|
823
|
+
projection = proj ?? {};
|
|
824
|
+
return chain;
|
|
825
|
+
}),
|
|
804
826
|
from: vi.fn(() => chain),
|
|
805
827
|
where: vi.fn(() => chain),
|
|
806
|
-
limit: vi.fn(async () =>
|
|
828
|
+
limit: vi.fn(async () =>
|
|
829
|
+
'number' in projection ? [] : [{ sourceBranch: 'mallory/spam' }],
|
|
830
|
+
),
|
|
807
831
|
update: vi.fn(() => chain),
|
|
808
832
|
set: vi.fn(() => chain),
|
|
809
833
|
returning: vi.fn(async () => [{ id: 1 }]),
|
|
810
834
|
});
|
|
811
835
|
const workspaces = makeWorkspaceService();
|
|
812
836
|
const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), workspaces, access, makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate());
|
|
813
|
-
return { svc, prs, db: chain, access, workspaces };
|
|
837
|
+
return { svc, prs, db: chain, access, workspaces, git };
|
|
814
838
|
}
|
|
815
839
|
|
|
816
|
-
it('refuses a non-admin with 403 and
|
|
817
|
-
const { svc, db } = makeDeleteHarness({ isAdmin: false });
|
|
840
|
+
it('refuses a non-admin who is not the author, with 403 and the author-or-admin message', async () => {
|
|
841
|
+
const { svc, db, git } = makeDeleteHarness({ isAdmin: false });
|
|
842
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
|
|
843
|
+
status: 403,
|
|
844
|
+
message: "Only the request's author or an admin can delete it.",
|
|
845
|
+
});
|
|
846
|
+
// "Changes nothing" is the whole promise of the refusal: no row flipped,
|
|
847
|
+
// no branch removed.
|
|
848
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
849
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('lets the AUTHOR delete their own request without admin rights: closes it and retires the branch', async () => {
|
|
853
|
+
const { svc, prs, db, git } = makeDeleteHarness({
|
|
854
|
+
isAdmin: false,
|
|
855
|
+
authorEmail: AUTHOR_EMAIL,
|
|
856
|
+
});
|
|
857
|
+
await svc.deleteChangeRequest(9, makeUser());
|
|
858
|
+
expect(db.update).toHaveBeenCalled();
|
|
859
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(9);
|
|
860
|
+
expect(git.deleteBranch).toHaveBeenCalledWith(
|
|
861
|
+
expect.any(String),
|
|
862
|
+
'mallory/spam',
|
|
863
|
+
expect.objectContaining({ email: AUTHOR_EMAIL }),
|
|
864
|
+
expect.objectContaining({ systemCleanup: true }),
|
|
865
|
+
);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('matches the author case-insensitively, as hashEmail does', async () => {
|
|
869
|
+
// The stored hash is of the normalized address; a caller signed in as
|
|
870
|
+
// `Alice@Example.com` is the same person and must not be refused.
|
|
871
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: false, authorEmail: AUTHOR_EMAIL });
|
|
872
|
+
await svc.deleteChangeRequest(9, makeUser({ email: ' Alice@Example.com ' }));
|
|
873
|
+
expect(db.update).toHaveBeenCalled();
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('refuses a request with no stored author to a non-admin', async () => {
|
|
877
|
+
// Opened outside this backend: nobody can claim authorship of it, so the
|
|
878
|
+
// admin grant is the only one left. An absent hash must never read as a
|
|
879
|
+
// match.
|
|
880
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: false, authorEmail: null });
|
|
818
881
|
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
|
|
819
882
|
expect(db.update).not.toHaveBeenCalled();
|
|
820
883
|
});
|
|
@@ -832,6 +895,94 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
|
|
|
832
895
|
expect(db.update).not.toHaveBeenCalled();
|
|
833
896
|
});
|
|
834
897
|
|
|
898
|
+
it('refuses the AUTHOR on an applied request with 422 and the applied message', async () => {
|
|
899
|
+
// Applied between opening the dialog and confirming. The author's new
|
|
900
|
+
// grant does not reach applied history either.
|
|
901
|
+
const { svc, db, git } = makeDeleteHarness({
|
|
902
|
+
isAdmin: false,
|
|
903
|
+
prState: 'merged',
|
|
904
|
+
authorEmail: AUTHOR_EMAIL,
|
|
905
|
+
});
|
|
906
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
|
|
907
|
+
status: 422,
|
|
908
|
+
message: 'This change request has already been applied.',
|
|
909
|
+
});
|
|
910
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
911
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('refuses the AUTHOR with 409 when the base cannot be verified, and changes nothing', async () => {
|
|
915
|
+
// The strict base fetch is NOT skipped for an author, even though their
|
|
916
|
+
// grant needs no roles.yaml read: an unreachable origin means the branch
|
|
917
|
+
// removal would fail silently, and the delete must refuse whole rather
|
|
918
|
+
// than close the request and leave the branch.
|
|
919
|
+
const { svc, db, git, workspaces } = makeDeleteHarness({
|
|
920
|
+
isAdmin: false,
|
|
921
|
+
authorEmail: AUTHOR_EMAIL,
|
|
922
|
+
});
|
|
923
|
+
(workspaces.ensureRemotesFetched as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
924
|
+
new Error('origin unreachable'),
|
|
925
|
+
);
|
|
926
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({
|
|
927
|
+
status: 409,
|
|
928
|
+
message: expect.stringContaining('Retry in a moment.'),
|
|
929
|
+
});
|
|
930
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
931
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('refuses the AUTHOR with 409 when the base ref does not resolve', async () => {
|
|
935
|
+
const { svc, db, access } = makeDeleteHarness({
|
|
936
|
+
isAdmin: false,
|
|
937
|
+
authorEmail: AUTHOR_EMAIL,
|
|
938
|
+
});
|
|
939
|
+
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
940
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 409 });
|
|
941
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('a request already closed elsewhere still has its branch retired', async () => {
|
|
945
|
+
// Withdrawn in another tab: the guarded update flips no row, the request
|
|
946
|
+
// is not merged, so the delete finishes the job the withdraw left undone.
|
|
947
|
+
const { svc, db, git } = makeDeleteHarness({
|
|
948
|
+
isAdmin: false,
|
|
949
|
+
prState: 'closed',
|
|
950
|
+
authorEmail: AUTHOR_EMAIL,
|
|
951
|
+
});
|
|
952
|
+
// The update is guarded on `state = 'open'`, so it flips nothing here.
|
|
953
|
+
(db.returning as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
954
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).resolves.toBeUndefined();
|
|
955
|
+
expect(git.deleteBranch).toHaveBeenCalledWith(
|
|
956
|
+
expect.any(String),
|
|
957
|
+
'mallory/spam',
|
|
958
|
+
expect.anything(),
|
|
959
|
+
expect.objectContaining({ systemCleanup: true }),
|
|
960
|
+
);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('keeps a branch another open request still uses, and closes the request silently', async () => {
|
|
964
|
+
const { svc, db, git } = makeDeleteHarness({
|
|
965
|
+
isAdmin: false,
|
|
966
|
+
authorEmail: AUTHOR_EMAIL,
|
|
967
|
+
});
|
|
968
|
+
// `openChangeRequestOn` finds a live request on the branch (the
|
|
969
|
+
// `{ number, sourceBranch }` projection), so retirement backs off.
|
|
970
|
+
let projection: Record<string, unknown> = {};
|
|
971
|
+
(db.select as ReturnType<typeof vi.fn>).mockImplementation((proj?: Record<string, unknown>) => {
|
|
972
|
+
projection = proj ?? {};
|
|
973
|
+
return db;
|
|
974
|
+
});
|
|
975
|
+
(db.limit as ReturnType<typeof vi.fn>).mockImplementation(async () =>
|
|
976
|
+
'number' in projection
|
|
977
|
+
? [{ number: 11, sourceBranch: 'mallory/spam' }]
|
|
978
|
+
: [{ sourceBranch: 'mallory/spam' }],
|
|
979
|
+
);
|
|
980
|
+
// No throw, no message: the request closes and the branch survives.
|
|
981
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).resolves.toBeUndefined();
|
|
982
|
+
expect(db.update).toHaveBeenCalled();
|
|
983
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
984
|
+
});
|
|
985
|
+
|
|
835
986
|
it('kicks the deleted-branch sweep when the base workspace cannot be resolved', async () => {
|
|
836
987
|
const { svc, db, workspaces } = makeDeleteHarness({ isAdmin: true });
|
|
837
988
|
(workspaces.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
@@ -45,17 +45,26 @@ function detail(overrides: Partial<PullRequestDetail> = {}): PullRequestDetail {
|
|
|
45
45
|
branch: 'alice/deal',
|
|
46
46
|
base: 'current-company-state',
|
|
47
47
|
behind: true,
|
|
48
|
+
needsUpdate: true,
|
|
48
49
|
viewerCanUpdate: true,
|
|
49
50
|
headSha: HEAD,
|
|
50
51
|
mergeBaseSha: 'c'.repeat(40),
|
|
52
|
+
// The pre-flight read asks for no patches, but it still names the files —
|
|
53
|
+
// which is what the fallback for a failed diff answers with.
|
|
54
|
+
files: [{ path: 'Sales/Deal.md' }],
|
|
51
55
|
...overrides,
|
|
52
|
-
} as PullRequestDetail;
|
|
56
|
+
} as unknown as PullRequestDetail;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
function harness(opts: {
|
|
56
60
|
first?: PullRequestDetail | null;
|
|
57
61
|
/** The published head of the proposal branch as resolved just before the merge. */
|
|
58
62
|
publishedHead?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The published head AFTER the merge. Defaults to the merge commit; set it
|
|
65
|
+
* equal to the pre-merge head to describe a merge that moved nothing.
|
|
66
|
+
*/
|
|
67
|
+
headAfterMerge?: string;
|
|
59
68
|
resolveError?: Error;
|
|
60
69
|
merge?: { kind: 'clean'; alreadyUpToDate: boolean } | { kind: 'conflicts'; paths: string[] };
|
|
61
70
|
pullError?: Error;
|
|
@@ -64,31 +73,42 @@ function harness(opts: {
|
|
|
64
73
|
changedPathsError?: Error;
|
|
65
74
|
/** How many approval rows the carry-forward wrote. */
|
|
66
75
|
carried?: number;
|
|
67
|
-
/**
|
|
68
|
-
|
|
76
|
+
/** Does the clone hold commits origin has not seen? */
|
|
77
|
+
unpushed?: boolean;
|
|
78
|
+
unpushedError?: Error;
|
|
69
79
|
}) {
|
|
80
|
+
// Two resolves per update: once after the pull (where the branch IS), once
|
|
81
|
+
// after the merge (where it ended up). One mock that answers in order, so a
|
|
82
|
+
// test can describe a head that moved without stubbing call indices.
|
|
83
|
+
const preMergeHead = opts.publishedHead ?? HEAD;
|
|
84
|
+
const postMergeHead = opts.headAfterMerge ?? MERGED_HEAD;
|
|
85
|
+
const resolvePrShas = opts.resolveError
|
|
86
|
+
? vi.fn().mockRejectedValue(opts.resolveError)
|
|
87
|
+
: vi
|
|
88
|
+
.fn()
|
|
89
|
+
.mockResolvedValueOnce({ baseSha: 'b'.repeat(40), headSha: preMergeHead })
|
|
90
|
+
.mockResolvedValue({ baseSha: 'b'.repeat(40), headSha: postMergeHead });
|
|
70
91
|
const git = {
|
|
71
92
|
pull: opts.pullError
|
|
72
93
|
? vi.fn().mockRejectedValue(opts.pullError)
|
|
73
94
|
: vi.fn().mockResolvedValue({ treeChanged: false }),
|
|
74
95
|
mergeFromOrigin: vi.fn().mockResolvedValue(opts.merge ?? { kind: 'clean', alreadyUpToDate: false }),
|
|
75
96
|
push: vi.fn().mockResolvedValue(undefined),
|
|
97
|
+
hasUnpushedCommits: opts.unpushedError
|
|
98
|
+
? vi.fn().mockRejectedValue(opts.unpushedError)
|
|
99
|
+
: vi.fn().mockResolvedValue(opts.unpushed ?? true),
|
|
76
100
|
pathsChangedBetween: opts.changedPathsError
|
|
77
101
|
? vi.fn().mockRejectedValue(opts.changedPathsError)
|
|
78
102
|
: vi.fn().mockResolvedValue(opts.changedPaths ?? []),
|
|
79
|
-
resolvePrShas
|
|
80
|
-
? vi.fn().mockRejectedValue(opts.resolveError)
|
|
81
|
-
: vi.fn().mockResolvedValue({
|
|
82
|
-
baseSha: 'b'.repeat(40),
|
|
83
|
-
headSha: opts.publishedHead ?? HEAD,
|
|
84
|
-
}),
|
|
103
|
+
resolvePrShas,
|
|
85
104
|
};
|
|
86
|
-
const refreshed = detail({ behind: false, headSha:
|
|
105
|
+
const refreshed = detail({ behind: false, needsUpdate: false, headSha: postMergeHead });
|
|
106
|
+
// Exactly two reads per update: the pre-flight (authority, branch names)
|
|
107
|
+
// and the one that goes back. A third would be the cost this ticket removed.
|
|
87
108
|
const getPrDetail = vi
|
|
88
109
|
.fn()
|
|
89
110
|
.mockResolvedValueOnce(opts.first === undefined ? detail() : opts.first)
|
|
90
|
-
.
|
|
91
|
-
.mockResolvedValue(opts.withApprovals ?? refreshed);
|
|
111
|
+
.mockResolvedValue(refreshed);
|
|
92
112
|
const prs = { getPrDetail, invalidateDetailCache: vi.fn() };
|
|
93
113
|
const pendingCommits = { enqueueIfAbsent: vi.fn().mockResolvedValue(true) };
|
|
94
114
|
const reviewWorkflow = {
|
|
@@ -111,14 +131,19 @@ function harness(opts: {
|
|
|
111
131
|
|
|
112
132
|
describe('WorkflowService.updateFromTarget', () => {
|
|
113
133
|
it('merges the target into the proposal, pushes, and returns the refreshed detail', async () => {
|
|
114
|
-
const h = harness({});
|
|
115
|
-
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.
|
|
134
|
+
const h = harness({ changedPaths: ['Sales/Deal.md'] });
|
|
135
|
+
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.toEqual({
|
|
136
|
+
...h.refreshed,
|
|
137
|
+
updatedPaths: ['Sales/Deal.md'],
|
|
138
|
+
});
|
|
116
139
|
|
|
117
|
-
// The permission read is computed for THIS caller
|
|
140
|
+
// The permission read is computed for THIS caller — and asks for no
|
|
141
|
+
// patches: all it decides is authority, state and the two branch names.
|
|
118
142
|
expect(h.prs.getPrDetail).toHaveBeenNthCalledWith(1, 7, {
|
|
119
143
|
fresh: true,
|
|
120
144
|
workspaceId: WS,
|
|
121
145
|
viewerEmail: USER.email,
|
|
146
|
+
patches: false,
|
|
122
147
|
});
|
|
123
148
|
expect(h.git.mergeFromOrigin).toHaveBeenCalledWith(WS, 'alice/deal', 'current-company-state', USER);
|
|
124
149
|
expect(h.git.push).toHaveBeenCalledWith(WS, USER);
|
|
@@ -126,6 +151,29 @@ describe('WorkflowService.updateFromTarget', () => {
|
|
|
126
151
|
expect(h.refreshed.behind).toBe(false);
|
|
127
152
|
});
|
|
128
153
|
|
|
154
|
+
it('costs ONE detail read after the merge', async () => {
|
|
155
|
+
// The whole point of the ticket on the server side. Two reads total: the
|
|
156
|
+
// pre-flight that decides authority, and the one that goes back. The
|
|
157
|
+
// carry-forward runs BEFORE that second read rather than correcting it
|
|
158
|
+
// with a third.
|
|
159
|
+
const h = harness({ changedPaths: ['Sales/Deal.md'], carried: 2 });
|
|
160
|
+
await h.svc.updateFromTarget(WS, USER, 7);
|
|
161
|
+
expect(h.prs.getPrDetail).toHaveBeenCalledTimes(2);
|
|
162
|
+
const carriedAt = h.reviewWorkflow.carryApprovalsForward.mock.invocationCallOrder[0];
|
|
163
|
+
const lastReadAt = h.prs.getPrDetail.mock.invocationCallOrder[1];
|
|
164
|
+
expect(carriedAt).toBeLessThan(lastReadAt);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('replays the pull with --rebase-merges, so an unpushed merge stays a merge', async () => {
|
|
168
|
+
// A plain rebase flattens a merge commit origin has not seen into
|
|
169
|
+
// cherry-picks of the target's commits, which drops the target as a
|
|
170
|
+
// parent — and the request never contains the target's head however many
|
|
171
|
+
// times the update runs.
|
|
172
|
+
const h = harness({});
|
|
173
|
+
await h.svc.updateFromTarget(WS, USER, 7);
|
|
174
|
+
expect(h.git.pull).toHaveBeenCalledWith(WS, { preserveMerges: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
129
177
|
it('refuses a viewer who is neither the author nor able to apply — nothing is merged', async () => {
|
|
130
178
|
const h = harness({ first: detail({ viewerCanUpdate: false }) });
|
|
131
179
|
const err = await h.svc.updateFromTarget(WS, USER, 7).catch((e: unknown) => e);
|
|
@@ -157,13 +205,54 @@ describe('WorkflowService.updateFromTarget', () => {
|
|
|
157
205
|
expect(h.git.push).not.toHaveBeenCalled();
|
|
158
206
|
});
|
|
159
207
|
|
|
160
|
-
it('an already up-to-date merge
|
|
161
|
-
const h = harness({
|
|
162
|
-
|
|
208
|
+
it('an already up-to-date merge with nothing unpushed pushes nothing', async () => {
|
|
209
|
+
const h = harness({
|
|
210
|
+
merge: { kind: 'clean', alreadyUpToDate: true },
|
|
211
|
+
unpushed: false,
|
|
212
|
+
headAfterMerge: HEAD,
|
|
213
|
+
});
|
|
214
|
+
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.toEqual({
|
|
215
|
+
...h.refreshed,
|
|
216
|
+
updatedPaths: [],
|
|
217
|
+
});
|
|
163
218
|
expect(h.git.push).not.toHaveBeenCalled();
|
|
164
219
|
expect(h.prs.invalidateDetailCache).toHaveBeenCalledWith(7);
|
|
165
220
|
});
|
|
166
221
|
|
|
222
|
+
it('retries a push that failed last time, even though this merge has nothing to do', async () => {
|
|
223
|
+
// The loop this ticket closes. An earlier update merged the target in and
|
|
224
|
+
// its push failed, so the merge is sitting in the clone. This update's
|
|
225
|
+
// merge is therefore ALREADY up to date — and the old gate, which pushed
|
|
226
|
+
// only when the merge authored a commit, skipped the push and left the
|
|
227
|
+
// request behind on the remote for the next open to find.
|
|
228
|
+
const h = harness({
|
|
229
|
+
merge: { kind: 'clean', alreadyUpToDate: true },
|
|
230
|
+
unpushed: true,
|
|
231
|
+
publishedHead: HEAD,
|
|
232
|
+
headAfterMerge: MERGED_HEAD,
|
|
233
|
+
changedPaths: ['Sales/Deal.md'],
|
|
234
|
+
});
|
|
235
|
+
await h.svc.updateFromTarget(WS, USER, 7);
|
|
236
|
+
expect(h.git.hasUnpushedCommits).toHaveBeenCalledWith(WS);
|
|
237
|
+
expect(h.git.push).toHaveBeenCalledWith(WS, USER);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('falls back to "did this merge author a commit" when git cannot say what is unpushed', async () => {
|
|
241
|
+
// Never "push anyway": a push on a clone with nothing to push is a wasted
|
|
242
|
+
// round trip on every update.
|
|
243
|
+
const clean = harness({ unpushedError: new Error('git exploded') });
|
|
244
|
+
await clean.svc.updateFromTarget(WS, USER, 7);
|
|
245
|
+
expect(clean.git.push).toHaveBeenCalledWith(WS, USER);
|
|
246
|
+
|
|
247
|
+
const nothingToDo = harness({
|
|
248
|
+
unpushedError: new Error('git exploded'),
|
|
249
|
+
merge: { kind: 'clean', alreadyUpToDate: true },
|
|
250
|
+
headAfterMerge: HEAD,
|
|
251
|
+
});
|
|
252
|
+
await nothingToDo.svc.updateFromTarget(WS, USER, 7);
|
|
253
|
+
expect(nothingToDo.git.push).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
167
256
|
it('a rebase conflict refreshing the checkout queues recovery for the saves, then refuses', async () => {
|
|
168
257
|
const conflict = new PullRebaseConflictError('alice/deal', ['Sales/Deal.md'], 'rebase stopped');
|
|
169
258
|
const h = harness({ pullError: conflict });
|
|
@@ -183,15 +272,13 @@ describe('WorkflowService.updateFromTarget', () => {
|
|
|
183
272
|
});
|
|
184
273
|
|
|
185
274
|
describe('WorkflowService.updateFromTarget: the approvals the merge did not touch', () => {
|
|
186
|
-
it('carries them onto the new head,
|
|
187
|
-
const
|
|
188
|
-
const h = harness({
|
|
189
|
-
changedPaths: ['Sales/Deal.md'],
|
|
190
|
-
carried: 2,
|
|
191
|
-
withApprovals,
|
|
192
|
-
});
|
|
275
|
+
it('carries them onto the new head, before the one detail read that goes back', async () => {
|
|
276
|
+
const h = harness({ changedPaths: ['Sales/Deal.md'], carried: 2 });
|
|
193
277
|
|
|
194
|
-
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.
|
|
278
|
+
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.toEqual({
|
|
279
|
+
...h.refreshed,
|
|
280
|
+
updatedPaths: ['Sales/Deal.md'],
|
|
281
|
+
});
|
|
195
282
|
|
|
196
283
|
// What moved is git's answer between the two heads — never a guess, and
|
|
197
284
|
// never the request's own three-dot diff against the target.
|
|
@@ -202,23 +289,33 @@ describe('WorkflowService.updateFromTarget: the approvals the merge did not touc
|
|
|
202
289
|
MERGED_HEAD,
|
|
203
290
|
['Sales/Deal.md'],
|
|
204
291
|
);
|
|
205
|
-
// The first refreshed detail was assembled before the rows existed, so it
|
|
206
|
-
// is not the one that goes back.
|
|
207
|
-
expect(h.prs.getPrDetail).toHaveBeenCalledTimes(3);
|
|
208
292
|
});
|
|
209
293
|
|
|
210
|
-
it('
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
294
|
+
it('answers the same list as updatedPaths — carried and re-read cannot disagree', async () => {
|
|
295
|
+
// A file whose approval was carried (so: untouched by the merge) that the
|
|
296
|
+
// dialog then re-read would be the dialog discarding content the server
|
|
297
|
+
// just called unchanged.
|
|
298
|
+
const h = harness({ changedPaths: ['Sales/Deal.md', 'Sales/Terms.md'], carried: 1 });
|
|
299
|
+
const result = await h.svc.updateFromTarget(WS, USER, 7);
|
|
300
|
+
expect(result.updatedPaths).toEqual(['Sales/Deal.md', 'Sales/Terms.md']);
|
|
301
|
+
expect(h.reviewWorkflow.carryApprovalsForward).toHaveBeenCalledWith(
|
|
302
|
+
7,
|
|
303
|
+
HEAD,
|
|
304
|
+
MERGED_HEAD,
|
|
305
|
+
result.updatedPaths,
|
|
306
|
+
);
|
|
215
307
|
});
|
|
216
308
|
|
|
217
|
-
it('touches nothing when the
|
|
218
|
-
const h = harness({
|
|
219
|
-
|
|
309
|
+
it('touches nothing when the head did not move', async () => {
|
|
310
|
+
const h = harness({
|
|
311
|
+
merge: { kind: 'clean', alreadyUpToDate: true },
|
|
312
|
+
unpushed: false,
|
|
313
|
+
headAfterMerge: HEAD,
|
|
314
|
+
});
|
|
315
|
+
const result = await h.svc.updateFromTarget(WS, USER, 7);
|
|
220
316
|
expect(h.git.pathsChangedBetween).not.toHaveBeenCalled();
|
|
221
317
|
expect(h.reviewWorkflow.carryApprovalsForward).not.toHaveBeenCalled();
|
|
318
|
+
expect(result.updatedPaths).toEqual([]);
|
|
222
319
|
});
|
|
223
320
|
|
|
224
321
|
it('touches nothing when the merge conflicted — the rows describe a head that still stands', async () => {
|
|
@@ -250,10 +347,12 @@ describe('WorkflowService.updateFromTarget: the approvals the merge did not touc
|
|
|
250
347
|
it('falls back to the head the detail reported when that head cannot be resolved', async () => {
|
|
251
348
|
// Bookkeeping never costs the update: an unresolvable ref leaves the
|
|
252
349
|
// carry-forward working off the detail's head, exactly as it did before.
|
|
350
|
+
// Both resolves fail, so the head reads as unmoved from the detail's own
|
|
351
|
+
// — there is no pair of shas to diff, and the update still lands.
|
|
253
352
|
const h = harness({ resolveError: new Error('no such ref'), carried: 1 });
|
|
254
353
|
await h.svc.updateFromTarget(WS, USER, 7);
|
|
255
354
|
expect(h.git.push).toHaveBeenCalledWith(WS, USER);
|
|
256
|
-
expect(h.reviewWorkflow.carryApprovalsForward).
|
|
355
|
+
expect(h.reviewWorkflow.carryApprovalsForward).not.toHaveBeenCalled();
|
|
257
356
|
});
|
|
258
357
|
|
|
259
358
|
it('carries nothing when there is no head to compare at all', async () => {
|
|
@@ -266,7 +365,10 @@ describe('WorkflowService.updateFromTarget: the approvals the merge did not touc
|
|
|
266
365
|
resolveError: new Error('no such ref'),
|
|
267
366
|
carried: 1,
|
|
268
367
|
});
|
|
269
|
-
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.
|
|
368
|
+
await expect(h.svc.updateFromTarget(WS, USER, 7)).resolves.toEqual({
|
|
369
|
+
...h.refreshed,
|
|
370
|
+
updatedPaths: [],
|
|
371
|
+
});
|
|
270
372
|
expect(h.git.push).toHaveBeenCalledWith(WS, USER);
|
|
271
373
|
expect(h.git.pathsChangedBetween).not.toHaveBeenCalled();
|
|
272
374
|
expect(h.reviewWorkflow.carryApprovalsForward).not.toHaveBeenCalled();
|
|
@@ -276,7 +378,11 @@ describe('WorkflowService.updateFromTarget: the approvals the merge did not touc
|
|
|
276
378
|
// A request that IS up to date is worth more than the carry-forward: the
|
|
277
379
|
// approvals simply go stale, exactly as they did before this existed.
|
|
278
380
|
const h = harness({ changedPathsError: new Error('git exploded') });
|
|
279
|
-
await
|
|
381
|
+
const result = await h.svc.updateFromTarget(WS, USER, 7);
|
|
382
|
+
expect(result).toMatchObject({ number: 7, behind: false });
|
|
280
383
|
expect(h.reviewWorkflow.carryApprovalsForward).not.toHaveBeenCalled();
|
|
384
|
+
// Which files moved is unknown, so every file the request had is named:
|
|
385
|
+
// slow beats a dialog that goes on presenting pre-merge text as current.
|
|
386
|
+
expect(result.updatedPaths).toEqual(['Sales/Deal.md']);
|
|
281
387
|
});
|
|
282
388
|
});
|
|
@@ -278,9 +278,149 @@ describe('registerWorkflowTools', () => {
|
|
|
278
278
|
expect(calls.filter((c) => c[0] === 'createBranch')).toHaveLength(0);
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* These tools address their workspace by ID — `workspaceIdForBranch(branch)`
|
|
283
|
+
* straight from the argument — so they never pass the `getFilesystem` choke
|
|
284
|
+
* point that guards the file tools. A branch-less call used to commit to a
|
|
285
|
+
* workspace id that was literally the string "undefined" — and `create_branch`
|
|
286
|
+
* used to fork from one, cloning a branch of that name and answering "There is
|
|
287
|
+
* no branch named undefined." The guard sits on the mount and is keyed on
|
|
288
|
+
* whether the tool's declared inputs REQUIRE `branch`, so it covers the
|
|
289
|
+
* injected input and a tool's own alike, without any of them remembering it.
|
|
290
|
+
*/
|
|
291
|
+
describe('a branch-less call to a tool that declares `branch`', () => {
|
|
292
|
+
/**
|
|
293
|
+
* Every tool whose DECLARED inputs require `branch`, read off the mounted
|
|
294
|
+
* registry — not a list written by hand. The hand-written list is how the
|
|
295
|
+
* gap that failed Local Testing stayed invisible: it named the three tools
|
|
296
|
+
* that take the injected input and missed `create_branch`, whose `branch`
|
|
297
|
+
* is its own (the fork base) and is just as required. Anything mounted
|
|
298
|
+
* later is swept in without this file being touched.
|
|
299
|
+
*/
|
|
300
|
+
const branchTakingTools = async (): Promise<string[]> =>
|
|
301
|
+
(await registryRef!.listInternal())
|
|
302
|
+
.filter((t) => {
|
|
303
|
+
const body = (t.inputs as { properties?: { body?: { required?: string[] } } }).properties?.body;
|
|
304
|
+
return (body?.required ?? []).includes('branch');
|
|
305
|
+
})
|
|
306
|
+
.map((t) => t.name);
|
|
307
|
+
|
|
308
|
+
// The inputs each tool needs BESIDES `branch`, so the only thing wrong with
|
|
309
|
+
// every call below is the missing branch.
|
|
310
|
+
const bodyFor = (tool: string): Record<string, unknown> =>
|
|
311
|
+
({
|
|
312
|
+
commit_change: { summary: 's' },
|
|
313
|
+
save_file: { path: 'KnowledgeBase/x.md', content: 'c' },
|
|
314
|
+
create_branch: { name: 'alice/new-draft' },
|
|
315
|
+
write_file: { path: 'KnowledgeBase/x.md', content: 'c' },
|
|
316
|
+
})[tool] ?? {};
|
|
317
|
+
|
|
318
|
+
it('400s branch-required on every mounted tool that declares `branch`, and acts on nothing', async () => {
|
|
319
|
+
const base = await start();
|
|
320
|
+
const tools = await branchTakingTools();
|
|
321
|
+
// Both kinds are in here: the injected input and a tool's own required
|
|
322
|
+
// `branch`. `create_branch` is the one the reported bug survived on.
|
|
323
|
+
expect(tools).toContain('commit_change');
|
|
324
|
+
expect(tools).toContain('create_branch');
|
|
325
|
+
expect(tools).toContain('switch_branch');
|
|
326
|
+
|
|
327
|
+
for (const tool of tools) {
|
|
328
|
+
const res = await post(`${base}/api/agent/tools/${tool}`, writeTok(), bodyFor(tool));
|
|
329
|
+
|
|
330
|
+
expect(res.status, `${tool} must 400 on a branch-less call`).toBe(400);
|
|
331
|
+
expect(await res.json(), `${tool} must answer the shared refusal`).toEqual({
|
|
332
|
+
kind: 'branch-required',
|
|
333
|
+
error: '`branch` is required: pass the branch (draft) you are working on.',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// No workflow call was made at all — least of all one naming a workspace
|
|
337
|
+
// id built out of the missing value, or a branch forked from it.
|
|
338
|
+
expect(calls).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The exact shapes Local Testing drove over MCP against `create_branch`:
|
|
343
|
+
* a missing branch answered 404 `There is no branch named undefined.`
|
|
344
|
+
* (a clone of that "branch" was attempted), an empty one 500 'Invalid
|
|
345
|
+
* workspace ID', and `["main"]` — which `encodeURIComponent` stringifies
|
|
346
|
+
* straight back to `main` — answered 200 and really created and pushed a
|
|
347
|
+
* branch off the default. All four are one refusal now.
|
|
348
|
+
*/
|
|
349
|
+
it('refuses every absent shape on create_branch, and forks from nothing', async () => {
|
|
350
|
+
const base = await start();
|
|
351
|
+
const absent: Array<[string, Record<string, unknown>]> = [
|
|
352
|
+
['missing', { name: 'alice/new-draft' }],
|
|
353
|
+
['empty', { name: 'alice/new-draft', branch: '' }],
|
|
354
|
+
['null', { name: 'alice/new-draft', branch: null }],
|
|
355
|
+
['a number', { name: 'alice/new-draft', branch: 42 }],
|
|
356
|
+
['an array (stringifies to a real branch name)', { name: 'alice/new-draft', branch: ['main'] }],
|
|
357
|
+
['the literal "undefined"', { name: 'alice/new-draft', branch: 'undefined' }],
|
|
358
|
+
['the literal "null"', { name: 'alice/new-draft', branch: 'null' }],
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
for (const [label, body] of absent) {
|
|
362
|
+
const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), body);
|
|
363
|
+
expect(res.status, `branch ${label} must 400`).toBe(400);
|
|
364
|
+
const answered = await res.json();
|
|
365
|
+
expect(answered.kind, `branch ${label} must answer branch-required`).toBe('branch-required');
|
|
366
|
+
expect(JSON.stringify(answered), `branch ${label} must not echo an absent value`).not.toContain('undefined');
|
|
367
|
+
}
|
|
368
|
+
// Nothing was forked, from any workspace id.
|
|
369
|
+
expect(calls).toEqual([]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `create_branch` compares `branch` (the fork base) to `name` and refuses
|
|
374
|
+
* the two being equal, QUOTING the name. A call that sent neither compared
|
|
375
|
+
* undefined to undefined, matched, and answered "not 'undefined', the draft
|
|
376
|
+
* being created" — an absent value presented as a draft name. `name` is
|
|
377
|
+
* refused by name instead, so no such sentence can be produced.
|
|
378
|
+
*/
|
|
379
|
+
it('refuses a missing `name` without quoting it back', async () => {
|
|
380
|
+
const base = await start();
|
|
381
|
+
|
|
382
|
+
const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), { branch: 'main' });
|
|
383
|
+
|
|
384
|
+
expect(res.status).toBe(400);
|
|
385
|
+
const answered = await res.json();
|
|
386
|
+
expect(answered).toEqual({ kind: 'name-required', error: '`name` is required: pass the name of the draft to create.' });
|
|
387
|
+
expect(JSON.stringify(answered)).not.toContain('undefined');
|
|
388
|
+
expect(calls).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('still forks from a named base once both inputs are given', async () => {
|
|
392
|
+
const base = await start();
|
|
393
|
+
// The guards refuse absent inputs, not the tool.
|
|
394
|
+
const res = await post(`${base}/api/agent/tools/create_branch`, writeTok(), { name: 'alice/new-draft', branch: 'main' });
|
|
395
|
+
|
|
396
|
+
expect(res.status).toBe(200);
|
|
397
|
+
expect(calls).toContainEqual(['createBranch', 'main', 'alice/new-draft']);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('refuses the stringified absent values the same way', async () => {
|
|
401
|
+
const base = await start();
|
|
402
|
+
for (const literal of ['undefined', 'null']) {
|
|
403
|
+
const res = await post(`${base}/api/agent/tools/commit_change`, writeTok(), { summary: 's', branch: literal });
|
|
404
|
+
expect(res.status, `branch "${literal}" must 400`).toBe(400);
|
|
405
|
+
expect((await res.json()).kind).toBe('branch-required');
|
|
406
|
+
}
|
|
407
|
+
expect(calls).toEqual([]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('leaves a tool that declares no `branch` alone', async () => {
|
|
411
|
+
const base = await start();
|
|
412
|
+
// `list_branches` is repo-global and deliberately takes no branch: the
|
|
413
|
+
// guard must not start demanding one from the tools that opted out.
|
|
414
|
+
expect((await post(`${base}/api/agent/tools/list_branches`, writeTok(), {})).status).toBe(200);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
281
418
|
it('works under a connection key too (both surface)', async () => {
|
|
282
419
|
const base = await start();
|
|
283
|
-
|
|
420
|
+
// `branch` is named because every caller must name it — this test is about
|
|
421
|
+
// the connection-key SURFACE, and an external key is precisely the caller
|
|
422
|
+
// that carries no focused branch to fall back on.
|
|
423
|
+
expect((await post(`${base}/api/agent/tools/commit_change`, 'bevel_key', { summary: 's', branch: WS })).status).toBe(200);
|
|
284
424
|
});
|
|
285
425
|
|
|
286
426
|
it('switch_branch validates + pre-warms + emits a user-scoped event (internal-only)', async () => {
|