@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
|
@@ -366,6 +366,60 @@ export class PullRequestService implements IPullRequestService {
|
|
|
366
366
|
return this.summaryOf(row, workspaceId);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Did the target change, since the fork point, any file this request also
|
|
371
|
+
* changes? The question behind `needsUpdate`.
|
|
372
|
+
*
|
|
373
|
+
* The intersection is taken over BOTH names of every rename on both sides,
|
|
374
|
+
* because a wrong "no" is the expensive answer: it would open a request on
|
|
375
|
+
* a diff read against text that has since moved, with Approve live. So
|
|
376
|
+
* `pathsChangedBetween` runs without rename detection (a rename on the
|
|
377
|
+
* TARGET reports the path it left as well as the one it arrived at), and
|
|
378
|
+
* the request's own side contributes each file's `previousPath` alongside
|
|
379
|
+
* its `path`.
|
|
380
|
+
*
|
|
381
|
+
* Answers true without asking git in the two cases where nothing may be
|
|
382
|
+
* assumed: no fork point (the branches share no history, so there is no
|
|
383
|
+
* range to diff) and a failed diff (an infra error must not read as "this
|
|
384
|
+
* request is fine"). Answers false without asking git when the request is
|
|
385
|
+
* not behind at all, or changes no files — the common case, and the one
|
|
386
|
+
* that must stay free.
|
|
387
|
+
*/
|
|
388
|
+
private async targetTouchesRequestFiles(
|
|
389
|
+
workspaceId: string,
|
|
390
|
+
forkPoint: { mergeBaseSha: string | null; behind: boolean },
|
|
391
|
+
baseSha: string,
|
|
392
|
+
files: PullRequestFile[],
|
|
393
|
+
): Promise<boolean> {
|
|
394
|
+
if (!forkPoint.behind) return false;
|
|
395
|
+
if (files.length === 0) return false;
|
|
396
|
+
// Diverged with no common ancestor: the target's whole history is
|
|
397
|
+
// "changed since the fork point", so anything this request touches is
|
|
398
|
+
// touched. Nothing to intersect against, and nothing to skip for.
|
|
399
|
+
if (!forkPoint.mergeBaseSha) return true;
|
|
400
|
+
const requestPaths = new Set<string>();
|
|
401
|
+
for (const f of files) {
|
|
402
|
+
requestPaths.add(f.path);
|
|
403
|
+
if (f.previousPath) requestPaths.add(f.previousPath);
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const onTarget = await this.gitService.pathsChangedBetween(
|
|
407
|
+
workspaceId,
|
|
408
|
+
forkPoint.mergeBaseSha,
|
|
409
|
+
// The target tip the WHOLE detail is pinned to, so this range ends
|
|
410
|
+
// exactly where `behind` and the file list were computed.
|
|
411
|
+
baseSha,
|
|
412
|
+
);
|
|
413
|
+
return onTarget.some((path) => requestPaths.has(path));
|
|
414
|
+
} catch (err) {
|
|
415
|
+
log.warn(
|
|
416
|
+
'could not compare the target against a change request\'s files; treating it as needing an update',
|
|
417
|
+
{ err },
|
|
418
|
+
);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
369
423
|
async getPrDetail(
|
|
370
424
|
prNumber: number,
|
|
371
425
|
opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean } = {},
|
|
@@ -392,6 +446,8 @@ export class PullRequestService implements IPullRequestService {
|
|
|
392
446
|
mergeBaseSha: null,
|
|
393
447
|
behind: false,
|
|
394
448
|
};
|
|
449
|
+
/** Did the target change, since the fork point, a file this request changes? */
|
|
450
|
+
let targetChangedShared = false;
|
|
395
451
|
if (workspaceId) {
|
|
396
452
|
const shas = await this.gitService.resolvePrShas(
|
|
397
453
|
workspaceId,
|
|
@@ -417,6 +473,17 @@ export class PullRequestService implements IPullRequestService {
|
|
|
417
473
|
// Pinned to the same two commits as the file list, so "needs updating"
|
|
418
474
|
// and the diff it qualifies can never describe different heads.
|
|
419
475
|
forkPoint = await this.gitService.forkPointForPr(workspaceId, { baseSha, headSha });
|
|
476
|
+
// Only a target that moved in a file THIS request also changes makes the
|
|
477
|
+
// proposal's diff describe text that has moved. One extra
|
|
478
|
+
// `diff --name-only` (and only when the branches have diverged at all)
|
|
479
|
+
// buys the difference between opening instantly and paying for a merge,
|
|
480
|
+
// a push and a second detail read.
|
|
481
|
+
targetChangedShared = await this.targetTouchesRequestFiles(
|
|
482
|
+
workspaceId,
|
|
483
|
+
forkPoint,
|
|
484
|
+
baseSha,
|
|
485
|
+
files,
|
|
486
|
+
);
|
|
420
487
|
}
|
|
421
488
|
|
|
422
489
|
// Validated cache hit: TTL fresh AND head SHA unchanged since we cached.
|
|
@@ -511,6 +578,22 @@ export class PullRequestService implements IPullRequestService {
|
|
|
511
578
|
approvals,
|
|
512
579
|
});
|
|
513
580
|
|
|
581
|
+
// Hashed here, not on the client: the detail carries an author HASH and no
|
|
582
|
+
// author email, so the comparison can only happen where the viewer's email
|
|
583
|
+
// is already known.
|
|
584
|
+
const viewerIsAuthor = !!(
|
|
585
|
+
opts.viewerEmail &&
|
|
586
|
+
summary.authorId &&
|
|
587
|
+
summary.authorId === hashEmail(opts.viewerEmail)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const viewerCanDelete = computeViewerCanDelete({
|
|
591
|
+
state: summary.state,
|
|
592
|
+
authorId: summary.authorId,
|
|
593
|
+
viewerEmail: opts.viewerEmail,
|
|
594
|
+
viewerCanBypassMerge,
|
|
595
|
+
});
|
|
596
|
+
|
|
514
597
|
const detail: PullRequestDetail = {
|
|
515
598
|
...summary,
|
|
516
599
|
body: row.body,
|
|
@@ -526,7 +609,12 @@ export class PullRequestService implements IPullRequestService {
|
|
|
526
609
|
viewerCanCancel,
|
|
527
610
|
mergeBaseSha: forkPoint.mergeBaseSha,
|
|
528
611
|
behind: summary.state === 'open' && forkPoint.behind,
|
|
612
|
+
// `behind` AND the divergence actually reaches this request's files.
|
|
613
|
+
// Both halves, in this order, so a closed request is never either.
|
|
614
|
+
needsUpdate: summary.state === 'open' && forkPoint.behind && targetChangedShared,
|
|
529
615
|
viewerCanUpdate,
|
|
616
|
+
viewerIsAuthor,
|
|
617
|
+
viewerCanDelete,
|
|
530
618
|
};
|
|
531
619
|
|
|
532
620
|
// A patch-less detail is an internal read; it must not be served to the
|
|
@@ -631,7 +719,38 @@ export function computeViewerCanUpdate(input: {
|
|
|
631
719
|
return viewerIsAuthor || input.viewerCanBypassMerge || viewerMayApply;
|
|
632
720
|
}
|
|
633
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Pure predicate for the `viewerCanDelete` hint — who may delete a request
|
|
724
|
+
* outright, closing it AND retiring its branch. The author (their own
|
|
725
|
+
* proposal and their own branch) or an admin (`viewerCanBypassMerge` is the
|
|
726
|
+
* proxy — the same `canWriteAtRef('roles.yaml')` predicate the DELETE route
|
|
727
|
+
* enforces). Mirrors `deleteChangeRequest` exactly, which is why it differs
|
|
728
|
+
* from its two siblings in both directions:
|
|
729
|
+
*
|
|
730
|
+
* - It does NOT grant the owner of every changed file, as
|
|
731
|
+
* `computeViewerCanCancel` does. Declining someone else's request leaves
|
|
732
|
+
* their branch to rework; deleting it destroys their text.
|
|
733
|
+
* - It bars only `merged`, not everything that is not `open`. A request
|
|
734
|
+
* withdrawn in another tab is `closed` and still has a leftover branch the
|
|
735
|
+
* server will retire, so the button must survive it. Applied history is
|
|
736
|
+
* nobody's to delete.
|
|
737
|
+
*
|
|
738
|
+
* Fail-closed: no viewer email → false, whatever the grants say.
|
|
739
|
+
*/
|
|
740
|
+
export function computeViewerCanDelete(input: {
|
|
741
|
+
state: PullRequestState;
|
|
742
|
+
authorId: string | undefined;
|
|
743
|
+
viewerEmail: string | undefined;
|
|
744
|
+
viewerCanBypassMerge: boolean;
|
|
745
|
+
}): boolean {
|
|
746
|
+
if (input.state === 'merged') return false;
|
|
747
|
+
if (!input.viewerEmail) return false;
|
|
748
|
+
const viewerIsAuthor = !!(input.authorId && input.authorId === hashEmail(input.viewerEmail));
|
|
749
|
+
return viewerIsAuthor || input.viewerCanBypassMerge;
|
|
750
|
+
}
|
|
751
|
+
|
|
634
752
|
export const __testing = {
|
|
635
753
|
computeViewerCanCancel,
|
|
636
754
|
computeViewerCanUpdate,
|
|
755
|
+
computeViewerCanDelete,
|
|
637
756
|
};
|
|
@@ -790,7 +790,9 @@ export function createWorkflowRoutes(
|
|
|
790
790
|
|
|
791
791
|
/**
|
|
792
792
|
* Delete a change request outright — close it and retire its source
|
|
793
|
-
* branch.
|
|
793
|
+
* branch. The request's author or an admin: their own proposal to throw
|
|
794
|
+
* away, and the moderation verb for a shared deployment. The changed files'
|
|
795
|
+
* owners are NOT included — they keep reject, which leaves the branch.
|
|
794
796
|
*/
|
|
795
797
|
router.delete('/workflow/change-requests/:number', async (req, res) => {
|
|
796
798
|
const num = parsePrNumber(req.params.number);
|
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
ChangeRequestApplyFailureKind,
|
|
44
44
|
ChangeRequestComment,
|
|
45
45
|
ChangeRequestDetail,
|
|
46
|
+
ChangeRequestUpdateResult,
|
|
46
47
|
ChangeRequestState,
|
|
47
48
|
ChangedFile,
|
|
48
49
|
FileApproval,
|
|
@@ -62,7 +63,7 @@ import {
|
|
|
62
63
|
isPlatformRestoreShape,
|
|
63
64
|
} from '@bevel-software/platform-shared';
|
|
64
65
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
65
|
-
import { and, eq, inArray, isNotNull, isNull, lt, or } from 'drizzle-orm';
|
|
66
|
+
import { and, desc, eq, inArray, isNotNull, isNull, lt, ne, or } from 'drizzle-orm';
|
|
66
67
|
import type { Database } from '../database/connection.js';
|
|
67
68
|
import { changeRequests } from '../database/schema.js';
|
|
68
69
|
import type { GitService } from './git/git.service.js';
|
|
@@ -576,8 +577,11 @@ export class WorkflowService implements IWorkflowService {
|
|
|
576
577
|
* and nothing else. The pull itself reports the change, since it is the
|
|
577
578
|
* only code that can observe it under the workspace mutex.
|
|
578
579
|
*/
|
|
579
|
-
private async pullWorkspace(
|
|
580
|
-
|
|
580
|
+
private async pullWorkspace(
|
|
581
|
+
workspaceId: string,
|
|
582
|
+
opts: { preserveMerges?: boolean } = {},
|
|
583
|
+
): Promise<boolean> {
|
|
584
|
+
const { treeChanged } = await this.git.pull(workspaceId, opts);
|
|
581
585
|
const defaultBranch = this.kb.defaultBranch;
|
|
582
586
|
if (treeChanged && branchForWorkspaceId(workspaceId) === defaultBranch) {
|
|
583
587
|
this.events?.emit({ kind: 'fs-tree-changed', workspaceId, branch: defaultBranch });
|
|
@@ -1729,6 +1733,38 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1729
1733
|
return this.prs.getPr(number);
|
|
1730
1734
|
}
|
|
1731
1735
|
|
|
1736
|
+
/**
|
|
1737
|
+
* The newest non-open change request from `sourceBranch` by `authorEmail`.
|
|
1738
|
+
*
|
|
1739
|
+
* Read straight off `change_requests` rather than through the PR listing,
|
|
1740
|
+
* which serves OPEN requests only: the whole point here is the closed ones.
|
|
1741
|
+
* `author_email` is stored lowercased at insert, so the needle is too.
|
|
1742
|
+
* Ordered by `closed_at` with `created_at` as the tiebreak, because a row
|
|
1743
|
+
* closed before the column existed (or by a path that forgot to stamp it)
|
|
1744
|
+
* still has a creation time and must not sort as the oldest.
|
|
1745
|
+
*/
|
|
1746
|
+
async latestClosedChangeRequest(
|
|
1747
|
+
authorEmail: string,
|
|
1748
|
+
sourceBranch: string,
|
|
1749
|
+
): Promise<{ number: number; state: ChangeRequestState } | null> {
|
|
1750
|
+
const email = authorEmail.trim().toLowerCase();
|
|
1751
|
+
if (!email || !sourceBranch) return null;
|
|
1752
|
+
const [row] = await this.db
|
|
1753
|
+
.select({ number: changeRequests.number, state: changeRequests.state })
|
|
1754
|
+
.from(changeRequests)
|
|
1755
|
+
.where(
|
|
1756
|
+
and(
|
|
1757
|
+
eq(changeRequests.authorEmail, email),
|
|
1758
|
+
eq(changeRequests.sourceBranch, sourceBranch),
|
|
1759
|
+
ne(changeRequests.state, 'open'),
|
|
1760
|
+
),
|
|
1761
|
+
)
|
|
1762
|
+
.orderBy(desc(changeRequests.closedAt), desc(changeRequests.createdAt))
|
|
1763
|
+
.limit(1);
|
|
1764
|
+
if (!row) return null;
|
|
1765
|
+
return { number: row.number, state: row.state as ChangeRequestState };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1732
1768
|
getChangeRequestDetail(
|
|
1733
1769
|
number: number,
|
|
1734
1770
|
opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean },
|
|
@@ -1953,13 +1989,29 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1953
1989
|
* the enforcement read one predicate. A conflicting merge is aborted
|
|
1954
1990
|
* inside `mergeFromOrigin`: nothing is committed or pushed, and the branch
|
|
1955
1991
|
* is exactly what it was.
|
|
1992
|
+
*
|
|
1993
|
+
* ONE detail read after the merge. The read before it exists only to
|
|
1994
|
+
* decide authority and to name the two branches, so it asks for no patches
|
|
1995
|
+
* — that is one git subprocess per changed file it does not spend. The
|
|
1996
|
+
* approvals carry-forward runs BEFORE the final read rather than after it,
|
|
1997
|
+
* against a head resolved with `resolvePrShas` (two rev-parses) instead of
|
|
1998
|
+
* a whole second detail, so the detail that goes back already describes the
|
|
1999
|
+
* approvals it carried. What used to be three detail reads is now one.
|
|
1956
2000
|
*/
|
|
1957
2001
|
async updateFromTarget(
|
|
1958
2002
|
workspaceId: string,
|
|
1959
2003
|
user: AuthUser,
|
|
1960
2004
|
number: number,
|
|
1961
|
-
): Promise<
|
|
1962
|
-
|
|
2005
|
+
): Promise<ChangeRequestUpdateResult> {
|
|
2006
|
+
// No patches: all this read decides is authority, state and the two
|
|
2007
|
+
// branch names. Asking for patch text here would diff every changed file
|
|
2008
|
+
// in a request that is about to be merged and read in full anyway.
|
|
2009
|
+
const detail = await this.prs.getPrDetail(number, {
|
|
2010
|
+
fresh: true,
|
|
2011
|
+
workspaceId,
|
|
2012
|
+
viewerEmail: user.email,
|
|
2013
|
+
patches: false,
|
|
2014
|
+
});
|
|
1963
2015
|
if (!detail) {
|
|
1964
2016
|
throw new WorkflowValidationError(
|
|
1965
2017
|
`Change request #${number} not found.`,
|
|
@@ -1987,8 +2039,15 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1987
2039
|
// merge commit that then cannot push, stranding it in the workspace. A
|
|
1988
2040
|
// rebase conflict hands the stranded saves to recovery, as every other
|
|
1989
2041
|
// pull does, before the refusal reaches the caller.
|
|
2042
|
+
//
|
|
2043
|
+
// `preserveMerges`: this is the one pull whose unpushed commit may be a
|
|
2044
|
+
// MERGE — the update that ran before this one and could not push. A plain
|
|
2045
|
+
// rebase replays it as cherry-picks of the target's commits, which drops
|
|
2046
|
+
// the target as a parent, so the branch never contains the target's head
|
|
2047
|
+
// however many times the update runs. Replayed with `--rebase-merges` it
|
|
2048
|
+
// stays a merge and the next push ends the loop.
|
|
1990
2049
|
try {
|
|
1991
|
-
await this.pullWorkspace(workspaceId);
|
|
2050
|
+
await this.pullWorkspace(workspaceId, { preserveMerges: true });
|
|
1992
2051
|
} catch (err) {
|
|
1993
2052
|
if (err instanceof PullRebaseConflictError) {
|
|
1994
2053
|
await this.queuePullConflictRecovery(workspaceId, err, user);
|
|
@@ -2026,24 +2085,53 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2026
2085
|
if (outcome.kind === 'conflicts') {
|
|
2027
2086
|
throw new ChangeRequestConflictsError(detail.branch, detail.base, outcome.paths);
|
|
2028
2087
|
}
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
2031
|
-
|
|
2088
|
+
// Push whenever the CLONE holds a commit origin has not seen — not when
|
|
2089
|
+
// THIS merge authored one. The two differ in exactly the case that keeps
|
|
2090
|
+
// a request behind for good: an earlier update merged, its push failed,
|
|
2091
|
+
// and the merge is sitting in the workspace. That merge already contains
|
|
2092
|
+
// the target, so this call's `mergeFromOrigin` reports
|
|
2093
|
+
// `alreadyUpToDate` and the old gate skipped the push — leaving the
|
|
2094
|
+
// request behind on the remote, and the next open with the same work to
|
|
2095
|
+
// do. Asking git what is unpushed retries it instead.
|
|
2096
|
+
//
|
|
2097
|
+
// A failed probe falls back to the old gate rather than to "push
|
|
2098
|
+
// anyway": `trackedPush` on a clone with nothing to push is a wasted
|
|
2099
|
+
// round trip on every single update.
|
|
2100
|
+
const hasUnpushed = await this.git
|
|
2101
|
+
.hasUnpushedCommits(workspaceId)
|
|
2102
|
+
.catch((err: unknown) => {
|
|
2103
|
+
log.warn(
|
|
2104
|
+
`could not tell whether change request #${number} has unpushed commits; falling back to whether this merge authored one`,
|
|
2105
|
+
{ err },
|
|
2106
|
+
);
|
|
2107
|
+
return !outcome.alreadyUpToDate;
|
|
2108
|
+
});
|
|
2109
|
+
if (hasUnpushed) {
|
|
2032
2110
|
await this.trackedPush(workspaceId, user);
|
|
2033
|
-
// The source head moved: the refusal described a revision that is gone.
|
|
2034
|
-
await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
|
|
2035
2111
|
}
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2112
|
+
// Where the branch ended up, as PUBLISHED — two rev-parses, not a second
|
|
2113
|
+
// detail read. Everything below needs only the sha, and the detail that
|
|
2114
|
+
// goes back to the caller is read once, at the end, after the approvals
|
|
2115
|
+
// it should describe have been carried.
|
|
2116
|
+
let headAfterMerge = headBeforeMerge;
|
|
2117
|
+
try {
|
|
2118
|
+
const at = await this.git.resolvePrShas(workspaceId, detail.base, detail.branch);
|
|
2119
|
+
headAfterMerge = at.headSha;
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
log.warn(
|
|
2122
|
+
`could not resolve the published head of change request #${number} after updating it from target`,
|
|
2123
|
+
{ err },
|
|
2045
2124
|
);
|
|
2046
2125
|
}
|
|
2126
|
+
const headMoved = Boolean(headBeforeMerge) && headAfterMerge !== headBeforeMerge;
|
|
2127
|
+
// The source head moved: the refusal described a revision that is gone.
|
|
2128
|
+
// `hasUnpushed` counts as well as the sha comparison, and deliberately —
|
|
2129
|
+
// a push that just landed moved the published head by definition, and
|
|
2130
|
+
// reading the sha to confirm it is a call that can fail. A refusal left
|
|
2131
|
+
// standing over a revision nobody can apply any more is the worse error.
|
|
2132
|
+
if (headMoved || hasUnpushed) {
|
|
2133
|
+
await this.clearApplyFailure(number, { recordedBefore: headMovedAfter });
|
|
2134
|
+
}
|
|
2047
2135
|
// The merge is a commit the APPROVERS did not make. Re-pin their
|
|
2048
2136
|
// approvals onto the new head for every file whose bytes it left alone —
|
|
2049
2137
|
// git's own two-dot diff between the two heads is the verdict on which
|
|
@@ -2051,45 +2139,61 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2051
2139
|
// than the bookkeeping, so a failure here is logged and the fresh detail
|
|
2052
2140
|
// still goes back.
|
|
2053
2141
|
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
|
|
2142
|
+
// The head moving is the real gate: nothing moved, nothing moved under
|
|
2143
|
+
// anyone. It also covers the sha that is not a sha — a resolve that
|
|
2144
|
+
// failed onto a detail with no head — which `pathsChangedBetween` and
|
|
2145
|
+
// `carryApprovalsForward` would refuse anyway.
|
|
2146
|
+
//
|
|
2147
|
+
// This runs BEFORE the detail read, not after it. The rows it writes are
|
|
2148
|
+
// what the caller's `approvals` must describe, so doing it first means
|
|
2149
|
+
// the one read at the end is already correct — where carrying afterwards
|
|
2150
|
+
// needed a THIRD read to correct a detail assembled before the rows
|
|
2151
|
+
// existed.
|
|
2152
|
+
//
|
|
2153
|
+
// The same list answers the caller's `updatedPaths`: "which files did
|
|
2154
|
+
// this merge change" is one question, and the two answers must not be
|
|
2155
|
+
// allowed to differ — a file whose approval was carried (so: untouched)
|
|
2156
|
+
// that the dialog then re-read would be the dialog discarding content the
|
|
2157
|
+
// server just called unchanged.
|
|
2158
|
+
let updatedPaths: string[] = [];
|
|
2159
|
+
if (headMoved) {
|
|
2061
2160
|
try {
|
|
2062
|
-
|
|
2161
|
+
updatedPaths = await this.git.pathsChangedBetween(
|
|
2063
2162
|
workspaceId,
|
|
2064
2163
|
headBeforeMerge,
|
|
2065
|
-
|
|
2164
|
+
headAfterMerge,
|
|
2066
2165
|
);
|
|
2067
|
-
|
|
2166
|
+
await this.reviewWorkflow.carryApprovalsForward(
|
|
2068
2167
|
number,
|
|
2069
2168
|
headBeforeMerge,
|
|
2070
|
-
|
|
2071
|
-
|
|
2169
|
+
headAfterMerge,
|
|
2170
|
+
updatedPaths,
|
|
2072
2171
|
);
|
|
2073
|
-
// The detail above was assembled before those rows existed, so its
|
|
2074
|
-
// `approvals` (and the merge gate derived from them) still describe
|
|
2075
|
-
// the pre-merge head. Only re-read when something actually carried.
|
|
2076
|
-
if (carried > 0) {
|
|
2077
|
-
this.prs.invalidateDetailCache(number);
|
|
2078
|
-
const withApprovals = await this.prs.getPrDetail(number, {
|
|
2079
|
-
fresh: true,
|
|
2080
|
-
workspaceId,
|
|
2081
|
-
viewerEmail: user.email,
|
|
2082
|
-
});
|
|
2083
|
-
if (withApprovals) return withApprovals;
|
|
2084
|
-
}
|
|
2085
2172
|
} catch (err) {
|
|
2086
2173
|
log.warn(
|
|
2087
2174
|
`could not carry approvals forward on change request #${number} after updating from target`,
|
|
2088
2175
|
{ err },
|
|
2089
2176
|
);
|
|
2177
|
+
// The diff is what failed, so which files moved is unknown. Say so as
|
|
2178
|
+
// "every file this request had", which is every file a caller can
|
|
2179
|
+
// already be showing: slow beats a dialog that goes on presenting
|
|
2180
|
+
// pre-merge text as current. Anything the merge ADDED is not on the
|
|
2181
|
+
// list and does not need to be — no caller has read it yet.
|
|
2182
|
+
updatedPaths = (detail.files ?? []).map((f) => f.path);
|
|
2090
2183
|
}
|
|
2091
2184
|
}
|
|
2092
|
-
|
|
2185
|
+
this.prs.invalidateDetailCache(number);
|
|
2186
|
+
const refreshed = await this.prs.getPrDetail(number, {
|
|
2187
|
+
fresh: true,
|
|
2188
|
+
workspaceId,
|
|
2189
|
+
viewerEmail: user.email,
|
|
2190
|
+
});
|
|
2191
|
+
if (!refreshed) {
|
|
2192
|
+
throw new WorkflowValidationError(
|
|
2193
|
+
`Refreshed change request #${number} but could not re-fetch its detail.`,
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
return { ...refreshed, updatedPaths };
|
|
2093
2197
|
}
|
|
2094
2198
|
|
|
2095
2199
|
listComments(number: number): Promise<ChangeRequestComment[]> {
|
|
@@ -2651,11 +2755,16 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2651
2755
|
/**
|
|
2652
2756
|
* DELETE a change request outright: close it (whatever its diff says) and
|
|
2653
2757
|
* retire its source branch — the request, its proposal, and the branch that
|
|
2654
|
-
* carried it are gone in one verb.
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2758
|
+
* carried it are gone in one verb. The request's AUTHOR or an admin, and
|
|
2759
|
+
* nobody else: it is the author's own proposal and their own branch to throw
|
|
2760
|
+
* away, and it is the moderation verb for a shared deployment. Admin rights
|
|
2761
|
+
* resolve the same way every other admin check here does (write on
|
|
2762
|
+
* `roles.yaml` at `origin/<base>`, never a local ref); authorship is the
|
|
2763
|
+
* STORED `authorId` hash, never anything the caller sends.
|
|
2764
|
+
*
|
|
2765
|
+
* Stronger than reject, which the changed files' owners may also do and which
|
|
2766
|
+
* leaves the branch for a second round. They keep reject and do not get this:
|
|
2767
|
+
* an owner must not be able to destroy someone else's text and branch.
|
|
2659
2768
|
*/
|
|
2660
2769
|
async deleteChangeRequest(number: number, user: AuthUser): Promise<void> {
|
|
2661
2770
|
const summary = await this.prs.getPr(number);
|
|
@@ -2714,8 +2823,17 @@ export class WorkflowService implements IWorkflowService {
|
|
|
2714
2823
|
409,
|
|
2715
2824
|
);
|
|
2716
2825
|
}
|
|
2717
|
-
|
|
2718
|
-
|
|
2826
|
+
// Authorship is decided from the STORED author hash, never from anything
|
|
2827
|
+
// the caller sends. Note what is deliberately NOT short-circuited for an
|
|
2828
|
+
// author: the strict fetch and the `null` check above still run. An
|
|
2829
|
+
// author's grant needs no `roles.yaml` read, so skipping them would look
|
|
2830
|
+
// free — but it would let a delete close the request while the branch
|
|
2831
|
+
// removal silently failed against an unreachable origin, and "the shared
|
|
2832
|
+
// repository was down, so nothing changed" is the one refusal that has to
|
|
2833
|
+
// stay honest.
|
|
2834
|
+
const callerIsAuthor = !!(summary.authorId && summary.authorId === hashEmail(user.email));
|
|
2835
|
+
if (!callerIsAuthor && isAdmin !== true) {
|
|
2836
|
+
throw new WorkflowDomainError("Only the request's author or an admin can delete it.", 403);
|
|
2719
2837
|
}
|
|
2720
2838
|
|
|
2721
2839
|
// Close first (idempotent: an already-closed request just skips to the
|
|
@@ -77,6 +77,49 @@ describe('WorkspaceService — a branch origin does not have', () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The second defect on this ticket. A bootstrap that fails to clone used to
|
|
82
|
+
* leave its workspace directory behind, and a directory named after a branch
|
|
83
|
+
* is one of the ways the platform remembers having KNOWN a branch. So the
|
|
84
|
+
* first attempt at a typo answered 404 and then quietly created the evidence
|
|
85
|
+
* that turned every later attempt into 410 — the reader was told a branch
|
|
86
|
+
* they had invented "no longer exists on the remote".
|
|
87
|
+
*/
|
|
88
|
+
it('leaves nothing on disk when the clone fails, so a second attempt is still a 404', async () => {
|
|
89
|
+
const svc = service();
|
|
90
|
+
|
|
91
|
+
await expect(svc.getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
|
|
92
|
+
name: 'BranchNotFoundError',
|
|
93
|
+
status: 404,
|
|
94
|
+
});
|
|
95
|
+
// The failed bootstrap rolled back completely — not the clone directory,
|
|
96
|
+
// not the workspace shell around it.
|
|
97
|
+
expect(await fs.readdir(workspacesRoot)).toEqual([]);
|
|
98
|
+
|
|
99
|
+
// The same name again, on the same instance that has now already failed on
|
|
100
|
+
// it once. Nothing about the first failure may change this answer.
|
|
101
|
+
await expect(svc.getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
|
|
102
|
+
name: 'BranchNotFoundError',
|
|
103
|
+
status: 404,
|
|
104
|
+
message: 'There is no branch named nobody/never-made-this.',
|
|
105
|
+
payload: { kind: 'branch-not-found', branch: 'nobody/never-made-this' },
|
|
106
|
+
});
|
|
107
|
+
expect(await fs.readdir(workspacesRoot)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('does not count a bare directory with no clone in it as a branch it knew', async () => {
|
|
111
|
+
// A shell left by an older process (one that crashed mid-bootstrap, or that
|
|
112
|
+
// predates the rollback above). The "have I heard of this branch" probe asks
|
|
113
|
+
// for a COMPLETED clone, so a directory with no `.git` under it is absence:
|
|
114
|
+
// the honest 404 survives whatever is lying around the workspaces root.
|
|
115
|
+
await fs.mkdir(path.join(workspacesRoot, workspaceIdForBranch('nobody/never-made-this')), { recursive: true });
|
|
116
|
+
|
|
117
|
+
await expect(service().getOrCreateForBranch('nobody/never-made-this')).rejects.toMatchObject({
|
|
118
|
+
name: 'BranchNotFoundError',
|
|
119
|
+
status: 404,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
80
123
|
it('a name a listing showed us, and origin no longer has, stays a 410', async () => {
|
|
81
124
|
const svc = service();
|
|
82
125
|
// What the branch selector saw while the draft still existed.
|