@bevel-software/platform-core-backend 0.30.0 → 0.32.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 +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -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 +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { Router, RequestHandler } from 'express';
|
|
2
2
|
import type { IToolRegistry, JsonSchema } from '../../tool-registry/tool.contract.js';
|
|
3
|
-
import { ToolError, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
3
|
+
import { ToolError, hasHttpStatus, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
4
|
+
import { isCommitSha } from '../../../shared/git.contract.js';
|
|
4
5
|
import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
5
6
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
6
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
9
|
+
import { assertAgentWriteAllowed, notifyAgentRead, SESSION_ID_INPUT, type AgentAccessGate } from '../../workspace/index.js';
|
|
10
|
+
import { readablePathsAt, readableAtCommits, assertReadableAtCommits } from '../read-on-branch.js';
|
|
7
11
|
import type { KbContext } from '../../../shared/kb-context.js';
|
|
8
12
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
13
|
import { assertInsideRepo, normalizePathArgs } from '../../kb-fs/repo-path.js';
|
|
@@ -150,6 +154,12 @@ const changeRequestSummarySchema: JsonSchema = {
|
|
|
150
154
|
description: `The changed paths with their kind of change, cut to ${CHANGE_REQUEST_PATH_LIMIT}; \`include: ["all-paths"]\` lists them all.`,
|
|
151
155
|
},
|
|
152
156
|
totalFiles: { type: 'integer', description: 'How many files the request changes in total, cut or not.' },
|
|
157
|
+
notified: {
|
|
158
|
+
type: 'array',
|
|
159
|
+
items: { type: 'string' },
|
|
160
|
+
description:
|
|
161
|
+
'On the answer to an open only: the channels that reached the approvers ("email", say), as the deployment\'s listeners said; empty when nobody was told and the request waits to be found in the app.',
|
|
162
|
+
},
|
|
153
163
|
},
|
|
154
164
|
required: ['url', 'number', 'title', 'state', 'sourceBranch', 'targetBranch', 'approvals', 'mergeBlockedReasons', 'files', 'totalFiles'],
|
|
155
165
|
};
|
|
@@ -176,6 +186,21 @@ export function registerWorkflowTools(
|
|
|
176
186
|
* nor links a request it answers no for.
|
|
177
187
|
*/
|
|
178
188
|
maySeeChangeRequest: (ctx: ToolContext, number: number) => Promise<boolean>,
|
|
189
|
+
/**
|
|
190
|
+
* The per-file read gate the history tools apply: a file's past is the
|
|
191
|
+
* file, so a history read takes the read the file itself takes — on the
|
|
192
|
+
* workspace for its own branch's history (`canRead`), at the commit
|
|
193
|
+
* another branch's content was served from (`canReadAtRef`, through
|
|
194
|
+
* `readableAtCommits`, the compare route's own rule), and in batches for
|
|
195
|
+
* the path lists `branch_status` shows.
|
|
196
|
+
*/
|
|
197
|
+
accessControl: Pick<IAccessControl, 'canRead' | 'canReadAtRef' | 'canReadBatch' | 'canReadBatchAtRef'>,
|
|
198
|
+
/**
|
|
199
|
+
* The deployment's blocking read hook, told of every file content a history
|
|
200
|
+
* tool serves, as `read_file` tells it — a hook that refuses the read
|
|
201
|
+
* refuses the history too.
|
|
202
|
+
*/
|
|
203
|
+
agentAccessGate: AgentAccessGate,
|
|
179
204
|
): void {
|
|
180
205
|
const { kbDirName } = kb;
|
|
181
206
|
const mount = (spec: {
|
|
@@ -748,4 +773,446 @@ export function registerWorkflowTools(
|
|
|
748
773
|
};
|
|
749
774
|
},
|
|
750
775
|
});
|
|
776
|
+
|
|
777
|
+
// ── History and syncing ────────────────────────────────────────────────────
|
|
778
|
+
//
|
|
779
|
+
// What the app's History tab, compare view and "update from target" do,
|
|
780
|
+
// as tools: the agent never runs git itself. Each reads through the
|
|
781
|
+
// workflow service's gated methods and takes the per-file read the file
|
|
782
|
+
// itself takes.
|
|
783
|
+
|
|
784
|
+
/** The branch's workspace, made when it is not on disk yet, and its id. */
|
|
785
|
+
const workspaceOf = async (ctx: ToolContext, branch: string): Promise<string> => {
|
|
786
|
+
await ctx.workspaceService.getOrCreateForBranch(branch);
|
|
787
|
+
return workspaceIdForBranch(branch);
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The per-file read gate on the workspace's own branch, and the
|
|
792
|
+
* deployment's read hook told of the read (as `read_file` tells it);
|
|
793
|
+
* answers the repository-relative path.
|
|
794
|
+
*/
|
|
795
|
+
const requireRead = async (ctx: ToolContext, branch: string, workspaceId: string, path: string): Promise<string> => {
|
|
796
|
+
const rel = stripKbDir(path, kbDirName);
|
|
797
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
798
|
+
if (!(await accessControl.canRead(workspaceId, ctx.user.email, rel))) {
|
|
799
|
+
throw new ToolError(`You don't have permission to read "${rel}".`, 403, { kind: 'read-denied', path: rel });
|
|
800
|
+
}
|
|
801
|
+
return rel;
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* The per-file read gate for content served from ANOTHER branch — the
|
|
806
|
+
* compare route's rule: at every commit the branch resolves to (the one
|
|
807
|
+
* the service serves it from, and its other tip when the local head and
|
|
808
|
+
* the published tip differ), all of which must grant, asked by the
|
|
809
|
+
* service inside its reservation before anything is said about the path
|
|
810
|
+
* there.
|
|
811
|
+
*/
|
|
812
|
+
const requireReadAtCommits = (ctx: ToolContext, workspaceId: string, rel: string, commits: readonly string[], branch: string): Promise<void> =>
|
|
813
|
+
assertReadableAtCommits(accessControl, workspaceId, ctx.user.email, rel, commits, branch);
|
|
814
|
+
|
|
815
|
+
/** Which of `paths` the caller may read at `origin/<base>` — the change-request reads' rule, `readablePathsAt`. */
|
|
816
|
+
const readableAt = (ctx: ToolContext, workspaceId: string, base: string, paths: string[]): Promise<Set<string>> =>
|
|
817
|
+
readablePathsAt(accessControl, workspaceId, ctx.user.email, base, paths);
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
mount({
|
|
821
|
+
name: 'list_file_changes',
|
|
822
|
+
description:
|
|
823
|
+
'The history of one file on `branch`: its commits, newest first — who made each, when, and its ' +
|
|
824
|
+
'subject — as the app\'s History tab shows them. `limit` caps the list (default 20, at most 100). ' +
|
|
825
|
+
'Read a version with `read_file_at` and its diff with `compare_file`. Takes the read the file itself ' +
|
|
826
|
+
'takes; a folder has no history of its own (ask per file).',
|
|
827
|
+
inputs: {
|
|
828
|
+
type: 'object',
|
|
829
|
+
properties: {
|
|
830
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative (as `read_file` takes it).' },
|
|
831
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many commits at most (default 20).' },
|
|
832
|
+
sessionId: SESSION_ID_INPUT,
|
|
833
|
+
},
|
|
834
|
+
required: ['path'],
|
|
835
|
+
additionalProperties: false,
|
|
836
|
+
},
|
|
837
|
+
outputs: {
|
|
838
|
+
type: 'object',
|
|
839
|
+
properties: {
|
|
840
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
841
|
+
changes: { type: 'array', items: commitSchema, description: 'Newest first.' },
|
|
842
|
+
},
|
|
843
|
+
required: ['path', 'changes'],
|
|
844
|
+
},
|
|
845
|
+
write: false,
|
|
846
|
+
handler: async (args, ctx: ToolContext) => {
|
|
847
|
+
const branch = args.branch as string;
|
|
848
|
+
const path = args.path;
|
|
849
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
850
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
851
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
852
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
853
|
+
const changes = await ctx.workflowService.listChangesForFile(workspaceId, path, limit);
|
|
854
|
+
return { path: rel, changes };
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
mount({
|
|
859
|
+
name: 'read_file_at',
|
|
860
|
+
description:
|
|
861
|
+
'One file as it was at a point in history: `at` is a commit (from `list_file_changes`, on `branch`\'s ' +
|
|
862
|
+
'own history) or the name of another branch (its published tip). Answers `content` (null when the ' +
|
|
863
|
+
'file was not there) and `sha`, the commit it was read at; for a commit, also `before`, the file ' +
|
|
864
|
+
'just before that commit. To put an earlier version back, write it with `write_file`. Takes the read ' +
|
|
865
|
+
'the file takes at that point.',
|
|
866
|
+
inputs: {
|
|
867
|
+
type: 'object',
|
|
868
|
+
properties: {
|
|
869
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
870
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha, or a branch name.' },
|
|
871
|
+
sessionId: SESSION_ID_INPUT,
|
|
872
|
+
},
|
|
873
|
+
required: ['path', 'at'],
|
|
874
|
+
additionalProperties: false,
|
|
875
|
+
},
|
|
876
|
+
outputs: {
|
|
877
|
+
type: 'object',
|
|
878
|
+
properties: {
|
|
879
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
880
|
+
at: { type: 'string', description: 'What was asked for.' },
|
|
881
|
+
kind: { type: 'string', enum: ['commit', 'branch'] },
|
|
882
|
+
sha: { type: 'string', description: 'The commit the content was read at.' },
|
|
883
|
+
content: { type: ['string', 'null'], description: 'The file at that commit; null when it was not there.' },
|
|
884
|
+
before: { type: ['string', 'null'], description: 'For a commit: the file just before it; null when it was not there (or the commit is the first).' },
|
|
885
|
+
},
|
|
886
|
+
required: ['path', 'at', 'kind', 'sha', 'content'],
|
|
887
|
+
},
|
|
888
|
+
write: false,
|
|
889
|
+
handler: async (args, ctx: ToolContext) => {
|
|
890
|
+
const branch = args.branch as string;
|
|
891
|
+
const path = args.path;
|
|
892
|
+
const at = args.at;
|
|
893
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
894
|
+
if (typeof at !== 'string' || at.length === 0) throw new ToolError('`at` is required: a commit sha or a branch name.', 400);
|
|
895
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
896
|
+
// The hook and the workspace gate first, whichever point in history is
|
|
897
|
+
// read: the file is this workspace's file.
|
|
898
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
899
|
+
if (isCommitSha(at)) {
|
|
900
|
+
const { baseline, current } = await ctx.workflowService.fileAtChange(workspaceId, path, at);
|
|
901
|
+
return { path: rel, at, kind: 'commit', sha: at, content: current, before: baseline };
|
|
902
|
+
}
|
|
903
|
+
// Read at the branch's published tip, authorized at that very commit
|
|
904
|
+
// inside the read — before anything is said about the path there,
|
|
905
|
+
// the folder refusal included: the ref is a name a fetch may move
|
|
906
|
+
// between a verdict and a read; the commit is not.
|
|
907
|
+
const read = await ctx.workflowService.fileAtBranch(workspaceId, path, at, (commits) => requireReadAtCommits(ctx, workspaceId, rel, commits, at));
|
|
908
|
+
return { path: rel, at, kind: 'branch', sha: read.sha, content: read.content };
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
mount({
|
|
913
|
+
name: 'compare_file',
|
|
914
|
+
description:
|
|
915
|
+
'The unified diff of one file: between two branches (`from`, `to`; either may be `branch` itself), ' +
|
|
916
|
+
'or of one commit on `branch` (`at`: what that commit changed in the file). Empty when identical. ' +
|
|
917
|
+
'Takes the read the file takes on each side.',
|
|
918
|
+
inputs: {
|
|
919
|
+
type: 'object',
|
|
920
|
+
properties: {
|
|
921
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
922
|
+
from: { type: 'string', minLength: 1, description: 'A branch: the diff\'s left side.' },
|
|
923
|
+
to: { type: 'string', minLength: 1, description: 'A branch: the diff\'s right side.' },
|
|
924
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha on `branch`: the diff that commit made in the file.' },
|
|
925
|
+
sessionId: SESSION_ID_INPUT,
|
|
926
|
+
},
|
|
927
|
+
required: ['path'],
|
|
928
|
+
additionalProperties: false,
|
|
929
|
+
},
|
|
930
|
+
outputs: {
|
|
931
|
+
type: 'object',
|
|
932
|
+
properties: {
|
|
933
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
934
|
+
diff: { type: 'string', description: 'A unified diff; empty when the two sides are identical.' },
|
|
935
|
+
},
|
|
936
|
+
required: ['path', 'diff'],
|
|
937
|
+
},
|
|
938
|
+
write: false,
|
|
939
|
+
handler: async (args, ctx: ToolContext) => {
|
|
940
|
+
const branch = args.branch as string;
|
|
941
|
+
const path = args.path;
|
|
942
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
943
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
944
|
+
// As the compare route: the workspace's own gate on the path (the
|
|
945
|
+
// checked-out side can be served from the working tree), then each
|
|
946
|
+
// compared side at the commit it was served from.
|
|
947
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
948
|
+
if (typeof args.at === 'string' && args.at.length > 0) {
|
|
949
|
+
if (!isCommitSha(args.at)) throw new ToolError('`at` must be a full commit sha.', 400);
|
|
950
|
+
return { path: rel, diff: await ctx.workflowService.showFileAtChange(workspaceId, path, args.at) };
|
|
951
|
+
}
|
|
952
|
+
const from = args.from;
|
|
953
|
+
const to = args.to;
|
|
954
|
+
if (typeof from !== 'string' || from.length === 0 || typeof to !== 'string' || to.length === 0) {
|
|
955
|
+
throw new ToolError('Give `from` and `to` (two branches), or `at` (one commit).', 400);
|
|
956
|
+
}
|
|
957
|
+
const compared = await ctx.workflowService.compareFile(workspaceId, path, from, to, async (sides) => {
|
|
958
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.from, from);
|
|
959
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.to, to);
|
|
960
|
+
});
|
|
961
|
+
return { path: rel, diff: compared.diff };
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
mount({
|
|
966
|
+
name: 'branch_status',
|
|
967
|
+
description:
|
|
968
|
+
'Where `branch` stands, before proposing or syncing a draft. Answers whether it is on the shared ' +
|
|
969
|
+
'repository, how far ahead of and behind its upstream it is, whether the shared branch has commits this ' +
|
|
970
|
+
'workspace lacks (`behindRemote`: `update_branch` brings them in), the files written but not committed ' +
|
|
971
|
+
'yet, the protected branch it was forked from (`forkBase`), and `changedPaths`: what it changes against ' +
|
|
972
|
+
'`base` (default: the fork base) — the files a change request into it would carry.',
|
|
973
|
+
inputs: {
|
|
974
|
+
type: 'object',
|
|
975
|
+
properties: {
|
|
976
|
+
base: { type: 'string', minLength: 1, description: 'The branch to compare against (default: the fork base).' },
|
|
977
|
+
sessionId: SESSION_ID_INPUT,
|
|
978
|
+
},
|
|
979
|
+
additionalProperties: false,
|
|
980
|
+
},
|
|
981
|
+
outputs: {
|
|
982
|
+
type: 'object',
|
|
983
|
+
properties: {
|
|
984
|
+
branch: { type: 'string' },
|
|
985
|
+
isProtected: { type: 'boolean' },
|
|
986
|
+
hasRemote: { type: 'boolean', description: 'True when the shared repository has the branch.' },
|
|
987
|
+
ahead: { type: ['integer', 'null'], description: 'Commits ahead of the upstream (or the fork base); null when unresolved.' },
|
|
988
|
+
behind: { type: ['integer', 'null'], description: 'Commits behind; null when unresolved.' },
|
|
989
|
+
behindRemote: { type: 'boolean', description: 'The shared branch has commits this workspace has not merged yet.' },
|
|
990
|
+
uncommittedPaths: { type: 'array', items: { type: 'string' }, description: 'Written but not committed yet (saves still landing).' },
|
|
991
|
+
forkBase: { type: ['string', 'null'], description: 'The protected branch this one was forked from; null when none can be reached.' },
|
|
992
|
+
base: { type: ['string', 'null'], description: 'What `changedPaths` is against.' },
|
|
993
|
+
changedPaths: { type: 'array', items: { type: 'string' }, description: 'Repository-relative; rename-aware; only the paths you may read. Empty when `base` is null.' },
|
|
994
|
+
withheldPaths: { type: 'integer', description: 'How many changed or uncommitted paths were left out because you may not read them.' },
|
|
995
|
+
},
|
|
996
|
+
required: ['branch', 'isProtected', 'hasRemote', 'ahead', 'behind', 'behindRemote', 'uncommittedPaths', 'forkBase', 'base', 'changedPaths', 'withheldPaths'],
|
|
997
|
+
},
|
|
998
|
+
write: false,
|
|
999
|
+
handler: async (args, ctx: ToolContext) => {
|
|
1000
|
+
const branch = args.branch as string;
|
|
1001
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
1002
|
+
// Each read below takes the workspace's reservation on its own: this
|
|
1003
|
+
// is a REPORT of independent facts, not a range measured across steps,
|
|
1004
|
+
// and a save or sync landing between two of them is tolerated — the
|
|
1005
|
+
// next call reports it. (The one answer that must be exact, what a
|
|
1006
|
+
// merge is of, is taken under one reservation by `update_branch`.)
|
|
1007
|
+
const [status, branches, pending, forkBase] = await Promise.all([
|
|
1008
|
+
ctx.workflowService.branchStatus(workspaceId),
|
|
1009
|
+
ctx.workflowService.listBranches(workspaceId),
|
|
1010
|
+
ctx.workflowService.listPendingChangePaths(workspaceId),
|
|
1011
|
+
ctx.workflowService.resolveForkBase(workspaceId, branch),
|
|
1012
|
+
]);
|
|
1013
|
+
const info = branches.find((b) => b.name === branch);
|
|
1014
|
+
const base = typeof args.base === 'string' && args.base.length > 0 ? args.base : forkBase;
|
|
1015
|
+
// A path's name is information too: the changed paths are shown as the
|
|
1016
|
+
// change-request reads show them (readable at `origin/<base>`), the
|
|
1017
|
+
// uncommitted ones as the workspace's own listing would.
|
|
1018
|
+
const changedAll =
|
|
1019
|
+
base && base !== branch ? await ctx.workflowService.changedPathsBetweenBranches(workspaceId, base, branch) : [];
|
|
1020
|
+
const readableChanged = base ? await readableAt(ctx, workspaceId, base, changedAll) : new Set<string>();
|
|
1021
|
+
const changedPaths = changedAll.filter((p) => readableChanged.has(p));
|
|
1022
|
+
const pendingRel = pending.map((p) => stripKbDir(p, kbDirName));
|
|
1023
|
+
const pendingVerdicts = pendingRel.length > 0 ? await accessControl.canReadBatch(workspaceId, ctx.user.email, pendingRel) : new Map<string, boolean>();
|
|
1024
|
+
const uncommittedPaths = pendingRel.filter((p) => pendingVerdicts.get(p) === true);
|
|
1025
|
+
return {
|
|
1026
|
+
branch,
|
|
1027
|
+
isProtected: info?.isProtected ?? kb.protectedBranches.has(branch),
|
|
1028
|
+
hasRemote: info?.hasRemote ?? status.hasUpstream,
|
|
1029
|
+
ahead: info?.ahead ?? null,
|
|
1030
|
+
behind: info?.behind ?? null,
|
|
1031
|
+
behindRemote: status.unmergedFromUpstream,
|
|
1032
|
+
uncommittedPaths,
|
|
1033
|
+
forkBase,
|
|
1034
|
+
base: base && base !== branch ? base : null,
|
|
1035
|
+
changedPaths,
|
|
1036
|
+
withheldPaths: changedAll.length - changedPaths.length + (pendingRel.length - uncommittedPaths.length),
|
|
1037
|
+
};
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
mount({
|
|
1042
|
+
name: 'update_branch',
|
|
1043
|
+
description:
|
|
1044
|
+
'Bring another branch into `branch` as a merge commit by you, and publish it: `from` (a draft\'s ' +
|
|
1045
|
+
'target, to refresh the draft), or left out, the branch\'s own shared state when it moved while this ' +
|
|
1046
|
+
'workspace held commits (`branch_status` says `behindRemote`). Never a protected branch. CONFLICTS: ' +
|
|
1047
|
+
'the first call answers `conflicts` naming every conflicted path with the file at the merge base, on ' +
|
|
1048
|
+
'each side (`ours` = `branch`, `theirs` = `from`) and the working copy with git\'s markers, and leaves ' +
|
|
1049
|
+
'the branch as it was. Decide each file (keep both sides\' information unless one is clearly stale; ' +
|
|
1050
|
+
'`list_file_changes` shows intent), then call again with `resolutions`, one per conflicted path: ' +
|
|
1051
|
+
'`content` (the resolved text) or `choose` (`ours`, `theirs`, `delete`). Only conflicted paths are ' +
|
|
1052
|
+
'written here; other edits go through the file tools. Answers `merged`, `nothing-to-merge`, ' +
|
|
1053
|
+
'`conflicts` again (something changed meanwhile), or `pending-commits` (writes still landing: retry).',
|
|
1054
|
+
inputs: {
|
|
1055
|
+
type: 'object',
|
|
1056
|
+
properties: {
|
|
1057
|
+
from: { type: 'string', minLength: 1, description: 'The branch to bring in (default: this branch\'s own shared state).' },
|
|
1058
|
+
sessionId: SESSION_ID_INPUT,
|
|
1059
|
+
resolutions: {
|
|
1060
|
+
type: 'array',
|
|
1061
|
+
description: 'One per conflicted path, from a previous `conflicts` answer.',
|
|
1062
|
+
items: {
|
|
1063
|
+
type: 'object',
|
|
1064
|
+
properties: {
|
|
1065
|
+
path: { type: 'string', minLength: 1, description: 'The conflicted path, as the answer named it.' },
|
|
1066
|
+
content: { type: 'string', description: 'The resolved text of the file.' },
|
|
1067
|
+
choose: { type: 'string', enum: ['ours', 'theirs', 'delete'], description: 'Keep one side whole, or drop the file.' },
|
|
1068
|
+
},
|
|
1069
|
+
required: ['path'],
|
|
1070
|
+
additionalProperties: false,
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
additionalProperties: false,
|
|
1075
|
+
},
|
|
1076
|
+
outputs: {
|
|
1077
|
+
type: 'object',
|
|
1078
|
+
properties: {
|
|
1079
|
+
kind: { type: 'string', enum: ['merged', 'nothing-to-merge', 'conflicts', 'pending-commits'] },
|
|
1080
|
+
from: { type: 'string', description: 'The branch brought in.' },
|
|
1081
|
+
sha: { type: 'string', description: '`merged`: the merge commit, now the tip of `branch`, published; `nothing-to-merge`: the unchanged tip.' },
|
|
1082
|
+
resolvedPaths: { type: 'array', items: { type: 'string' }, description: '`merged`: the conflicts the resolutions settled.' },
|
|
1083
|
+
mergeBase: { type: ['string', 'null'], description: '`conflicts`: the common ancestor; null when the histories are unrelated.' },
|
|
1084
|
+
ours: { type: 'string', description: '`conflicts`: the tip of `branch`.' },
|
|
1085
|
+
theirs: { type: 'string', description: '`conflicts`: the tip of `from`.' },
|
|
1086
|
+
files: {
|
|
1087
|
+
type: 'array',
|
|
1088
|
+
description:
|
|
1089
|
+
'`conflicts`: every conflicted path you may read, with the file at the base, on each side, and the marked-up ' +
|
|
1090
|
+
'working copy. A side is null when that side has no file — or when `truncated` names it (too large or not ' +
|
|
1091
|
+
'text): then read `ours` with `read_file`, `theirs` with `read_file_at` at `from`, `base` with `read_file_at` ' +
|
|
1092
|
+
'at `mergeBase`.',
|
|
1093
|
+
items: {
|
|
1094
|
+
type: 'object',
|
|
1095
|
+
properties: {
|
|
1096
|
+
path: { type: 'string' },
|
|
1097
|
+
base: { type: ['string', 'null'] },
|
|
1098
|
+
ours: { type: ['string', 'null'] },
|
|
1099
|
+
theirs: { type: ['string', 'null'] },
|
|
1100
|
+
merged: { type: ['string', 'null'] },
|
|
1101
|
+
truncated: { type: 'array', items: { type: 'string', enum: ['base', 'ours', 'theirs', 'merged'] } },
|
|
1102
|
+
},
|
|
1103
|
+
required: ['path', 'base', 'ours', 'theirs', 'merged'],
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
unresolved: { type: 'array', items: { type: 'string' }, description: '`conflicts`: the paths you may read that no resolution covered.' },
|
|
1107
|
+
withheldConflicts: {
|
|
1108
|
+
type: 'integer',
|
|
1109
|
+
description:
|
|
1110
|
+
'`conflicts`: how many conflicted files were left out because you may not read them on one of the two branches. ' +
|
|
1111
|
+
'They are not yours to settle: ask a person who can read them to update the draft in the app.',
|
|
1112
|
+
},
|
|
1113
|
+
branch: { type: 'string', description: '`pending-commits`: the branch whose writes are not committed yet.' },
|
|
1114
|
+
pending: { type: 'integer', description: '`pending-commits`: how many.' },
|
|
1115
|
+
needsAttention: { type: 'string', description: '`pending-commits`: a queued commit failed; the worker\'s message. Tell the user.' },
|
|
1116
|
+
message: { type: 'string', description: '`pending-commits`: what to do next.' },
|
|
1117
|
+
},
|
|
1118
|
+
required: ['kind'],
|
|
1119
|
+
},
|
|
1120
|
+
write: true,
|
|
1121
|
+
handler: async (args, ctx: ToolContext) => {
|
|
1122
|
+
const branch = args.branch as string;
|
|
1123
|
+
const from = args.from;
|
|
1124
|
+
if (from !== undefined && (typeof from !== 'string' || from.length === 0)) {
|
|
1125
|
+
throw new ToolError('`from` must be a branch name.', 400);
|
|
1126
|
+
}
|
|
1127
|
+
let resolutions: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' }[] | undefined;
|
|
1128
|
+
if (args.resolutions !== undefined) {
|
|
1129
|
+
if (!Array.isArray(args.resolutions)) throw new ToolError('`resolutions` must be a list.', 400);
|
|
1130
|
+
resolutions = args.resolutions.map((r: unknown, i: number) => {
|
|
1131
|
+
const entry = r as Record<string, unknown>;
|
|
1132
|
+
if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
1133
|
+
throw new ToolError(`resolutions[${i}]: \`path\` is required.`, 400);
|
|
1134
|
+
}
|
|
1135
|
+
const out: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' } = { path: entry.path };
|
|
1136
|
+
if (entry.content !== undefined) {
|
|
1137
|
+
if (typeof entry.content !== 'string') throw new ToolError(`resolutions[${i}]: \`content\` must be text.`, 400);
|
|
1138
|
+
out.content = entry.content;
|
|
1139
|
+
}
|
|
1140
|
+
if (entry.choose !== undefined) {
|
|
1141
|
+
if (entry.choose !== 'ours' && entry.choose !== 'theirs' && entry.choose !== 'delete') {
|
|
1142
|
+
throw new ToolError(`resolutions[${i}]: \`choose\` is \`ours\`, \`theirs\` or \`delete\`.`, 400);
|
|
1143
|
+
}
|
|
1144
|
+
out.choose = entry.choose;
|
|
1145
|
+
}
|
|
1146
|
+
return out;
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
1150
|
+
/**
|
|
1151
|
+
* Whether the caller may read `rel` on both sides of this merge — at
|
|
1152
|
+
* the two COMMITS the answer says the merge was of, which is what its
|
|
1153
|
+
* bytes are from, never at a branch ref that may have moved since the
|
|
1154
|
+
* reservation was released — with the deployment's read hook told of
|
|
1155
|
+
* the read, as every history read tells it: a hook that refuses makes
|
|
1156
|
+
* the file one the caller may not read; a hook that fails is not an
|
|
1157
|
+
* answer and propagates.
|
|
1158
|
+
*/
|
|
1159
|
+
const mayReadBothSides = async (rel: string, at: { ours: string; theirs: string }): Promise<boolean> => {
|
|
1160
|
+
try {
|
|
1161
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, `${kbDirName}/${rel}`);
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
// A hook's own refusal carries a status; anything else is a failure of the hook, not an answer.
|
|
1164
|
+
if (hasHttpStatus(err)) return false;
|
|
1165
|
+
throw err;
|
|
1166
|
+
}
|
|
1167
|
+
return readableAtCommits(accessControl, workspaceId, ctx.user.email, rel, [at.ours, at.theirs]);
|
|
1168
|
+
};
|
|
1169
|
+
// A resolution reads a file on both branches and writes it on the
|
|
1170
|
+
// draft, so the deployment's hooks are asked as they are for any read
|
|
1171
|
+
// and any write. The access model's own verdicts — may the caller read
|
|
1172
|
+
// the file on both branches, may they change it on the draft — are the
|
|
1173
|
+
// service's, asked inside the merge against the source commit as
|
|
1174
|
+
// fetched (`updateBranch`), so a rule that changed with the source
|
|
1175
|
+
// cannot be outrun by a check made before it was fetched.
|
|
1176
|
+
for (const r of resolutions ?? []) {
|
|
1177
|
+
const wsPath = `${kbDirName}/${stripKbDir(r.path, kbDirName)}`;
|
|
1178
|
+
try {
|
|
1179
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, wsPath);
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
if (hasHttpStatus(err)) {
|
|
1182
|
+
throw new ToolError(`You may not read "${stripKbDir(r.path, kbDirName)}" here, so it is not yours to settle.`, 403, {
|
|
1183
|
+
kind: 'read-denied',
|
|
1184
|
+
path: stripKbDir(r.path, kbDirName),
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
throw err;
|
|
1188
|
+
}
|
|
1189
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPath);
|
|
1190
|
+
}
|
|
1191
|
+
// A resolution's content lands on the draft by the merge, not by the
|
|
1192
|
+
// file tools' filesystem: it takes the same pre-disk gate that
|
|
1193
|
+
// filesystem runs (`roles.yaml` must parse, no new role, no unknown
|
|
1194
|
+
// group), handed to the service to ask before the merge.
|
|
1195
|
+
const outcome =
|
|
1196
|
+
resolutions !== undefined
|
|
1197
|
+
? await ctx.workflowService.updateBranch(workspaceId, ctx.user, {
|
|
1198
|
+
...(from !== undefined ? { from } : {}),
|
|
1199
|
+
resolutions,
|
|
1200
|
+
validateWrite: await ctx.getWriteValidator(branch),
|
|
1201
|
+
})
|
|
1202
|
+
: await ctx.workflowService.updateBranch(workspaceId, ctx.user, from !== undefined ? { from } : {});
|
|
1203
|
+
if (outcome.kind !== 'conflicts') return outcome;
|
|
1204
|
+
// A conflict answer carries the file on both branches, and a path's
|
|
1205
|
+
// name is information too: a file the caller may not read on both is
|
|
1206
|
+
// left out of `files` and `unresolved` alike and only counted.
|
|
1207
|
+
const files = [];
|
|
1208
|
+
for (const f of outcome.files) if (await mayReadBothSides(f.path, outcome)) files.push(f);
|
|
1209
|
+
const shown = new Set(files.map((f) => f.path));
|
|
1210
|
+
return {
|
|
1211
|
+
...outcome,
|
|
1212
|
+
files,
|
|
1213
|
+
unresolved: outcome.unresolved.filter((p) => shown.has(p)),
|
|
1214
|
+
withheldConflicts: outcome.files.length - files.length,
|
|
1215
|
+
};
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
751
1218
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
2
|
+
import { logger } from '../../shared/logging.js';
|
|
3
|
+
import { ListenerSeam, type NotifyingListener } from '../../shared/listener-seam.js';
|
|
4
|
+
|
|
5
|
+
const log = logger('cr');
|
|
6
|
+
|
|
7
|
+
/** Somebody a listener can write to: an account's id, address and name. */
|
|
8
|
+
export type ChangeRequestPerson = Pick<AuthUser, 'id' | 'email' | 'name'>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A change request was OPENED — by a person in the app or by an agent through
|
|
12
|
+
* its tool, which take the one road (`WorkflowService.openChangeRequest`).
|
|
13
|
+
* Raised once per request, after its row exists and its source branch was
|
|
14
|
+
* pushed, or the push was refused: the request exists either way, and the
|
|
15
|
+
* branch's next push carries the commits. Never raised for a request that is
|
|
16
|
+
* reopened after a failed branch deletion — it was opened once already — nor
|
|
17
|
+
* for one updated from its target.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChangeRequestOpenedEvent {
|
|
20
|
+
number: number;
|
|
21
|
+
title: string;
|
|
22
|
+
sourceBranch: string;
|
|
23
|
+
targetBranch: string;
|
|
24
|
+
/** Who opened it: the person, or the person the agent acted for. */
|
|
25
|
+
openedBy: ChangeRequestPerson;
|
|
26
|
+
/**
|
|
27
|
+
* The distinct people eligible to approve some changed path, as accounts —
|
|
28
|
+
* the eligibility the request's `## Affected owners` block lists, with the
|
|
29
|
+
* roles and groups expanded to their members, each person once however
|
|
30
|
+
* many paths or roles name them. Only a person with an account is here: a
|
|
31
|
+
* listener writes to accounts. The author is not left out; a listener that
|
|
32
|
+
* will not tell people of their own request reads `openedBy`.
|
|
33
|
+
*/
|
|
34
|
+
approvers: ChangeRequestPerson[];
|
|
35
|
+
/**
|
|
36
|
+
* Eligible approvers the event cannot hand over as accounts, each counted
|
|
37
|
+
* once: a person the access model names who has no account yet, and the
|
|
38
|
+
* built-in `everyone` (anyone signed in may approve, and nobody can be
|
|
39
|
+
* listed for that). Zero when every approver is in `approvers`.
|
|
40
|
+
*/
|
|
41
|
+
unresolvedApprovers: number;
|
|
42
|
+
/**
|
|
43
|
+
* The request's address, when the deployment has a public address
|
|
44
|
+
* configured (`PUBLIC_FRONTEND_URL`). Absent otherwise: a listener that
|
|
45
|
+
* knows where the app is builds the link to `/change-requests/<number>`
|
|
46
|
+
* itself.
|
|
47
|
+
*/
|
|
48
|
+
url?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the source branch's push was refused by the host as the request
|
|
51
|
+
* was opened: the request exists, but its commits reach the host only when
|
|
52
|
+
* an agent has reconciled the branch and pushed again, which is seconds
|
|
53
|
+
* usually and may fail. A listener decides what that is worth — a mail
|
|
54
|
+
* worded for it, a delay, or none — because a reviewer sent to the
|
|
55
|
+
* request before then opens one whose changes are not there yet. False
|
|
56
|
+
* when the push landed.
|
|
57
|
+
*/
|
|
58
|
+
pushPending: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A listener answers how it reached the approvers — a non-empty channel name,
|
|
63
|
+
* `'email'` say — or null when it did not; the rule is {@link NotifyingListener}'s:
|
|
64
|
+
* awaited, a throw logged and counted as null, never failing the open.
|
|
65
|
+
*/
|
|
66
|
+
export type ChangeRequestOpenedHook = NotifyingListener<ChangeRequestOpenedEvent>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Core's seam for "a change request was opened": a deployment (the cloud
|
|
70
|
+
* with its mail, an enterprise overlay) registers how to tell the reviewers,
|
|
71
|
+
* and core says in its answer whether somebody did. Core registers nothing,
|
|
72
|
+
* so a core-only deployment notifies nobody and the request waits to be
|
|
73
|
+
* found in the app, as before.
|
|
74
|
+
*/
|
|
75
|
+
export interface IChangeRequestHooks {
|
|
76
|
+
onChangeRequestOpened(hook: ChangeRequestOpenedHook): void;
|
|
77
|
+
/** Run every listener in registration order; the channels that reached the approvers, in that order. */
|
|
78
|
+
runChangeRequestOpened(event: ChangeRequestOpenedEvent): Promise<string[]>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class ChangeRequestHooks implements IChangeRequestHooks {
|
|
82
|
+
// The request exists whether or not the reviewers were told: the opener's
|
|
83
|
+
// answer says they were not, and the request is found in the app as before.
|
|
84
|
+
private readonly opened = new ListenerSeam<ChangeRequestOpenedEvent>(
|
|
85
|
+
log,
|
|
86
|
+
'a change-request-opened listener failed; the reviewers were not notified by it:',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
onChangeRequestOpened(hook: ChangeRequestOpenedHook): void {
|
|
90
|
+
this.opened.add(hook);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
runChangeRequestOpened(event: ChangeRequestOpenedEvent): Promise<string[]> {
|
|
94
|
+
return this.opened.run(event);
|
|
95
|
+
}
|
|
96
|
+
}
|