@bevel-software/platform-shared 0.20.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/git/pr.types.d.ts +53 -2
- package/dist/git/pr.types.d.ts.map +1 -1
- package/dist/git/protected.d.ts +82 -25
- package/dist/git/protected.d.ts.map +1 -1
- package/dist/git/protected.js +85 -27
- package/dist/git/protected.js.map +1 -1
- package/dist/git/types.d.ts +21 -1
- package/dist/git/types.d.ts.map +1 -1
- package/dist/workflow/interface.d.ts +23 -2
- package/dist/workflow/interface.d.ts.map +1 -1
- package/dist/workflow/types.d.ts +18 -0
- package/dist/workflow/types.d.ts.map +1 -1
- package/dist/workspace/kb-layout.d.ts +47 -33
- package/dist/workspace/kb-layout.d.ts.map +1 -1
- package/dist/workspace/kb-layout.js +65 -43
- package/dist/workspace/kb-layout.js.map +1 -1
- package/dist/workspace/platform-files.d.ts +13 -12
- package/dist/workspace/platform-files.d.ts.map +1 -1
- package/dist/workspace/platform-files.js +23 -22
- package/dist/workspace/platform-files.js.map +1 -1
- package/package.json +1 -1
- package/src/git/pr.types.ts +53 -2
- package/src/git/protected.ts +112 -38
- package/src/git/types.ts +29 -1
- package/src/workflow/interface.ts +27 -1
- package/src/workflow/types.ts +16 -0
- package/src/workspace/kb-layout.ts +66 -43
- package/src/workspace/platform-files.ts +22 -20
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf,
|
|
1
|
+
import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf, reservedRootDirNames, } from './kb-layout.js';
|
|
2
2
|
/**
|
|
3
3
|
* The files the platform reads as configuration, not content. `access.md`
|
|
4
4
|
* governs the folder it sits in and `.bevelignore` layers like `.gitignore`,
|
|
@@ -6,14 +6,15 @@ import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf, currentKbLa
|
|
|
6
6
|
* the repository root only, so a nested file of either name is ordinary
|
|
7
7
|
* content. Moving one changes what the platform enforces, so moves refuse them.
|
|
8
8
|
*
|
|
9
|
-
* A FUNCTION, not a constant, and that is the whole of the
|
|
10
|
-
* change on this side: the guide's name is a deployment
|
|
11
|
-
* platform file is `HEXIS.md` on one deployment and
|
|
12
|
-
* and on the first, a root `AGENTS.md` is the
|
|
13
|
-
* which has to move and delete like any page.
|
|
14
|
-
*
|
|
9
|
+
* A FUNCTION OF THE LAYOUT, not a constant, and that is the whole of the
|
|
10
|
+
* configurable-guide change on this side: the guide's name is a deployment
|
|
11
|
+
* setting, so the fourth platform file is `HEXIS.md` on one deployment and
|
|
12
|
+
* `AGENTS.md` on the next — and on the first, a root `AGENTS.md` is the
|
|
13
|
+
* CUSTOMER'S own conventions file, which has to move and delete like any page.
|
|
14
|
+
* Every gate asks this with the layout it serves rather than reading a list
|
|
15
|
+
* captured at module load.
|
|
15
16
|
*/
|
|
16
|
-
export function platformFileNames(layout
|
|
17
|
+
export function platformFileNames(layout) {
|
|
17
18
|
return [...FIXED_PLATFORM_FILE_NAMES, agentsFileOf(layout)];
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
@@ -25,8 +26,8 @@ export function platformFileNames(layout = currentKbLayout()) {
|
|
|
25
26
|
export const PLATFORM_FILE_NAMES = Object.freeze(platformFileNames(DEFAULT_KB_LAYOUT));
|
|
26
27
|
/** The platform files that are read wherever they sit, not only at the root. */
|
|
27
28
|
const PLATFORM_FILES_AT_ANY_DEPTH = new Set(['access.md', '.bevelignore']);
|
|
28
|
-
/** The names
|
|
29
|
-
const platformFiles = () => new Set(platformFileNames());
|
|
29
|
+
/** The names under `layout`, as a set — rebuilt per call, because the guide's is configurable. */
|
|
30
|
+
const platformFiles = (layout) => new Set(platformFileNames(layout));
|
|
30
31
|
const normalize = (path) => path.replace(/^\.?\/+/, '').replace(/\/+$/, '');
|
|
31
32
|
const baseName = (path) => {
|
|
32
33
|
const trimmed = normalize(path);
|
|
@@ -47,10 +48,10 @@ const hasTraversal = (path) => normalize(path).split('/').some((segment) => segm
|
|
|
47
48
|
* Exact spelling, as the platform reads it: `Access.md` is content. A caller
|
|
48
49
|
* on a case-insensitive disk passes the path's on-disk spelling.
|
|
49
50
|
*/
|
|
50
|
-
export function isPlatformFile(repoRelativePath) {
|
|
51
|
+
export function isPlatformFile(repoRelativePath, layout) {
|
|
51
52
|
const norm = normalize(repoRelativePath);
|
|
52
53
|
const name = baseName(norm);
|
|
53
|
-
if (!platformFiles().has(name))
|
|
54
|
+
if (!platformFiles(layout).has(name))
|
|
54
55
|
return false;
|
|
55
56
|
return PLATFORM_FILES_AT_ANY_DEPTH.has(name) || norm === name;
|
|
56
57
|
}
|
|
@@ -76,11 +77,11 @@ export function platformFileCreationRefusal(pathOrName) {
|
|
|
76
77
|
* one takes a whole section of the knowledge base with it. Exact spelling; a
|
|
77
78
|
* caller on a case-insensitive disk passes the path's on-disk spelling.
|
|
78
79
|
*/
|
|
79
|
-
export function isPlatformFolder(repoRelativeDir) {
|
|
80
|
+
export function isPlatformFolder(repoRelativeDir, layout) {
|
|
80
81
|
const norm = normalize(repoRelativeDir);
|
|
81
82
|
if (norm === '')
|
|
82
83
|
return true;
|
|
83
|
-
return !norm.includes('/') && reservedRootDirNames().has(norm);
|
|
84
|
+
return !norm.includes('/') && reservedRootDirNames(layout).has(norm);
|
|
84
85
|
}
|
|
85
86
|
/** The sentence a delete or move of a platform folder is refused with. */
|
|
86
87
|
export function platformFolderRefusal(repoRelativeDir) {
|
|
@@ -97,15 +98,15 @@ export function platformFolderRefusal(repoRelativeDir) {
|
|
|
97
98
|
* of the root's rather than standing in for it, which is why moving the
|
|
98
99
|
* ROOT's copy into a folder is a move out and not a restore.
|
|
99
100
|
*/
|
|
100
|
-
export function isRootPlatformFile(repoRelativePath) {
|
|
101
|
-
return platformFiles().has(normalize(repoRelativePath));
|
|
101
|
+
export function isRootPlatformFile(repoRelativePath, layout) {
|
|
102
|
+
return platformFiles(layout).has(normalize(repoRelativePath));
|
|
102
103
|
}
|
|
103
|
-
export function platformRestoreDestination(repoRelativeDestination) {
|
|
104
|
+
export function platformRestoreDestination(repoRelativeDestination, layout) {
|
|
104
105
|
const norm = normalize(repoRelativeDestination);
|
|
105
106
|
if (hasTraversal(norm))
|
|
106
107
|
return null;
|
|
107
108
|
const name = baseName(norm);
|
|
108
|
-
if (!platformFiles().has(name))
|
|
109
|
+
if (!platformFiles(layout).has(name))
|
|
109
110
|
return null;
|
|
110
111
|
if (name === 'access.md') {
|
|
111
112
|
const slash = norm.lastIndexOf('/');
|
|
@@ -133,15 +134,15 @@ export function platformRestoreDestination(repoRelativeDestination) {
|
|
|
133
134
|
* - the destination is a required location for that same name, so the file
|
|
134
135
|
* lands under the name the platform reads rather than beside it.
|
|
135
136
|
*/
|
|
136
|
-
export function isPlatformRestoreShape(repoRelativeSource, repoRelativeDestination) {
|
|
137
|
+
export function isPlatformRestoreShape(repoRelativeSource, repoRelativeDestination, layout) {
|
|
137
138
|
if (hasTraversal(repoRelativeSource))
|
|
138
139
|
return false;
|
|
139
140
|
const name = baseName(repoRelativeSource);
|
|
140
|
-
if (!platformFiles().has(name))
|
|
141
|
+
if (!platformFiles(layout).has(name))
|
|
141
142
|
return false;
|
|
142
|
-
if (isRootPlatformFile(repoRelativeSource))
|
|
143
|
+
if (isRootPlatformFile(repoRelativeSource, layout))
|
|
143
144
|
return false;
|
|
144
|
-
const target = platformRestoreDestination(repoRelativeDestination);
|
|
145
|
+
const target = platformRestoreDestination(repoRelativeDestination, layout);
|
|
145
146
|
return target !== null && target.name === name;
|
|
146
147
|
}
|
|
147
148
|
//# sourceMappingURL=platform-files.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform-files.js","sourceRoot":"","sources":["../../src/workspace/platform-files.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,YAAY,EACZ,
|
|
1
|
+
{"version":3,"file":"platform-files.js","sourceRoot":"","sources":["../../src/workspace/platform-files.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bevel-software/platform-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Shared types and pure domain utilities of the Bevel core platform (auth, workspace, git/workflow contracts, branch registry).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/src/git/pr.types.ts
CHANGED
|
@@ -277,16 +277,67 @@ export interface PullRequestDetail extends PullRequestSummary {
|
|
|
277
277
|
*/
|
|
278
278
|
mergeBaseSha: string | null;
|
|
279
279
|
/**
|
|
280
|
-
* True iff the target holds commits the proposal does not contain
|
|
281
|
-
*
|
|
280
|
+
* True iff the target holds commits the proposal does not contain. False
|
|
281
|
+
* for anything not open.
|
|
282
|
+
*
|
|
283
|
+
* This is the honest git fact and nothing more — it says the two branches
|
|
284
|
+
* have diverged, NOT that anything about this request has gone stale. On a
|
|
285
|
+
* knowledge base where every shared save commits to the default branch,
|
|
286
|
+
* it is true again minutes after any update. What the dialog acts on is
|
|
287
|
+
* `needsUpdate`.
|
|
282
288
|
*/
|
|
283
289
|
behind: boolean;
|
|
290
|
+
/**
|
|
291
|
+
* True iff the request is `behind` AND at least one of the files it
|
|
292
|
+
* changes was also changed on the target since the fork point — the only
|
|
293
|
+
* case where the proposal's diff describes text that has moved under it,
|
|
294
|
+
* and so the only case worth merging the target in for.
|
|
295
|
+
*
|
|
296
|
+
* A target that moved in files this request does not contain leaves the
|
|
297
|
+
* request's diff exactly as true as it was: every "before" side is read at
|
|
298
|
+
* the fork point, and the apply merges against the latest target whether an
|
|
299
|
+
* update ran or not. So a request that is `behind` but not `needsUpdate`
|
|
300
|
+
* opens straight to its files.
|
|
301
|
+
*
|
|
302
|
+
* Rename-safe in the conservative direction: the target's change list is
|
|
303
|
+
* computed without rename detection, so a file the target renamed appears
|
|
304
|
+
* under both names and a request holding either name counts as affected.
|
|
305
|
+
* False whenever `behind` is false, and true (with `behind`) when the two
|
|
306
|
+
* branches share no history at all — there is no fork point to intersect
|
|
307
|
+
* against, so nothing may be assumed unaffected.
|
|
308
|
+
*/
|
|
309
|
+
needsUpdate: boolean;
|
|
284
310
|
/**
|
|
285
311
|
* True iff the viewer may Update the request (merge its target into it):
|
|
286
312
|
* the request is open AND the viewer is its author or may apply it. A UX
|
|
287
313
|
* hint — the update route re-checks the same predicate server-side.
|
|
288
314
|
*/
|
|
289
315
|
viewerCanUpdate: boolean;
|
|
316
|
+
/**
|
|
317
|
+
* True iff the viewer is this request's author — the person who opened it,
|
|
318
|
+
* hash-matched against the stored `authorId` so the client never has to
|
|
319
|
+
* hash an email (and no raw email is exposed to do it with). A request a
|
|
320
|
+
* person's agent opened belongs to that person. Drives wording that speaks
|
|
321
|
+
* to the author directly ("You can delete it below.") rather than naming
|
|
322
|
+
* them in the third person. False when no viewer was passed, and for a
|
|
323
|
+
* request opened outside this backend (no `authorId`).
|
|
324
|
+
*/
|
|
325
|
+
viewerIsAuthor: boolean;
|
|
326
|
+
/**
|
|
327
|
+
* True iff the viewer may delete this request (close it AND retire its
|
|
328
|
+
* branch): the request is not applied AND the viewer is either its author
|
|
329
|
+
* or an admin (`viewerCanBypassMerge` is the proxy — the same
|
|
330
|
+
* `canWriteAtRef('roles.yaml')` predicate the DELETE route enforces).
|
|
331
|
+
* Deliberately NOT `viewerCanCancel`: that one also grants the changed
|
|
332
|
+
* files' owners, who may decline a request but must not destroy someone
|
|
333
|
+
* else's text and branch.
|
|
334
|
+
*
|
|
335
|
+
* Note this is true on a CLOSED request too, mirroring the server: a
|
|
336
|
+
* request withdrawn in another tab still has a leftover branch for the
|
|
337
|
+
* delete to retire. Only an applied one is nobody's to delete. A UX hint —
|
|
338
|
+
* `DELETE /api/workflow/change-requests/:number` re-checks server-side.
|
|
339
|
+
*/
|
|
340
|
+
viewerCanDelete: boolean;
|
|
290
341
|
}
|
|
291
342
|
|
|
292
343
|
/**
|
package/src/git/protected.ts
CHANGED
|
@@ -3,20 +3,29 @@
|
|
|
3
3
|
* their user-visible display names. Single source of truth for BOTH sides of
|
|
4
4
|
* the app, so backend enforcement and frontend affordances can never drift.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* time and throw when it found nothing — which made the branch model something
|
|
8
|
-
* a deployment had to know before any code could load. On the frontend that
|
|
9
|
-
* meant baking it into the bundle at build time (Vite `define`), so the same
|
|
10
|
-
* artifact could not serve two deployments and changing a branch name meant a
|
|
11
|
-
* rebuild. Both sides now CALL {@link configureBranchModel} during boot:
|
|
12
|
-
* the backend from its environment, the browser from `GET /api/config`.
|
|
6
|
+
* TWO SHAPES, ONE RULE SET.
|
|
13
7
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
8
|
+
* A VALUE ({@link BranchModelValue}), resolved by {@link resolveBranchModel}
|
|
9
|
+
* from the shape `/api/config` serves and the setup screen collects. The pure
|
|
10
|
+
* helpers below (`isProtectedBranch`, `protectedBranchDisplayName`) take the
|
|
11
|
+
* value they judge against, so a caller holding two deployments' models — a
|
|
12
|
+
* server hosting several knowledge bases in one process — asks about each by
|
|
13
|
+
* name. The backend uses ONLY this shape: every service is handed its
|
|
14
|
+
* knowledge base's model at construction and never reads a process-wide one.
|
|
15
|
+
*
|
|
16
|
+
* The BROWSER'S live bindings (`DEFAULT_BRANCH`, `PROTECTED_BRANCHES`, …),
|
|
17
|
+
* applied once by {@link configureBranchModel} from `GET /api/config` before
|
|
18
|
+
* React renders. A page shows one deployment, so one process-wide value is
|
|
19
|
+
* the right shape there, and the bindings let a component read the model
|
|
20
|
+
* without threading it through props. They are for the browser: the backend
|
|
21
|
+
* package forbids importing them (an ESLint rule names each one), and a
|
|
22
|
+
* server that configured them would be setting a value for every knowledge
|
|
23
|
+
* base it serves at once.
|
|
24
|
+
*
|
|
25
|
+
* Reading a live binding works inside a function body, which sees whatever
|
|
26
|
+
* configuration has been applied by the time it runs. What does NOT work is
|
|
27
|
+
* capturing one at module scope (`const X = DEFAULT_BRANCH` in a file's top
|
|
28
|
+
* level), which snapshots the value at import — before configuration.
|
|
20
29
|
*/
|
|
21
30
|
|
|
22
31
|
declare const process: { env: Record<string, string | undefined> };
|
|
@@ -39,26 +48,66 @@ function deriveDisplayName(slug: string): string {
|
|
|
39
48
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
/** The shape both sides configure from, and the shape `/api/config` serves. */
|
|
52
|
+
export interface BranchModel {
|
|
53
|
+
defaultBranch: string;
|
|
54
|
+
/** Slugs; a comma/space-separated string is accepted for env convenience. */
|
|
55
|
+
protectedBranches: string[] | string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A branch model as a VALUE: the resolved, validated form of {@link BranchModel}
|
|
60
|
+
* that everything judging a branch is handed.
|
|
61
|
+
*
|
|
62
|
+
* `defaultBranch` is the branch a logged-in user lands on when they don't
|
|
63
|
+
* explicitly pick one, and the default destination for shared drafts. It is
|
|
64
|
+
* the empty string on the one model that is not configured yet
|
|
65
|
+
* ({@link UNCONFIGURED_BRANCH_MODEL}): a fresh deployment has no branch model
|
|
66
|
+
* until its setup screen is answered, and everything behind that gate reads
|
|
67
|
+
* the model only once it is.
|
|
68
|
+
*/
|
|
69
|
+
export interface BranchModelValue {
|
|
70
|
+
readonly defaultBranch: string;
|
|
71
|
+
readonly protectedBranches: ReadonlySet<string>;
|
|
72
|
+
/** Slug → human-readable name, for every protected branch. */
|
|
73
|
+
readonly displayNames: Readonly<Record<string, string>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The model a deployment has before setup has answered for one: no default
|
|
78
|
+
* branch, nothing protected. {@link isBranchModelConfigured} is false for
|
|
79
|
+
* exactly this value.
|
|
80
|
+
*/
|
|
81
|
+
export const UNCONFIGURED_BRANCH_MODEL: BranchModelValue = Object.freeze({
|
|
82
|
+
defaultBranch: '',
|
|
83
|
+
protectedBranches: new Set<string>(),
|
|
84
|
+
displayNames: Object.freeze({}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether a model has been configured — the frontend gates render on this, and
|
|
89
|
+
* the backend's setup gate keeps the app shut until it holds.
|
|
90
|
+
*/
|
|
91
|
+
export function isBranchModelConfigured(model: BranchModelValue): boolean {
|
|
92
|
+
return model.defaultBranch !== '';
|
|
93
|
+
}
|
|
94
|
+
|
|
42
95
|
/**
|
|
43
96
|
* The branch a logged-in user lands on when they don't explicitly pick one, and
|
|
44
|
-
* the default destination for shared drafts.
|
|
97
|
+
* the default destination for shared drafts. BROWSER-SIDE live binding — see
|
|
98
|
+
* this file's header; the backend reads its `BranchModelValue` instead.
|
|
45
99
|
*
|
|
46
100
|
* Empty until {@link configureBranchModel} runs. Read it inside a function, not
|
|
47
|
-
* at module scope
|
|
101
|
+
* at module scope.
|
|
48
102
|
*/
|
|
49
103
|
export let DEFAULT_BRANCH: string = '';
|
|
50
104
|
|
|
105
|
+
/** Browser-side live binding — see this file's header. */
|
|
51
106
|
export let PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> = Object.freeze({});
|
|
52
107
|
|
|
108
|
+
/** Browser-side live binding — see this file's header. */
|
|
53
109
|
export let PROTECTED_BRANCHES: ReadonlySet<string> = new Set<string>();
|
|
54
110
|
|
|
55
|
-
/** The shape both sides configure from, and the shape `/api/config` serves. */
|
|
56
|
-
export interface BranchModel {
|
|
57
|
-
defaultBranch: string;
|
|
58
|
-
/** Slugs; a comma/space-separated string is accepted for env convenience. */
|
|
59
|
-
protectedBranches: string[] | string;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
111
|
/**
|
|
63
112
|
* Apply the branch model. Called once during boot on each side, and validated
|
|
64
113
|
* here rather than at either call site so the two cannot disagree about what
|
|
@@ -96,22 +145,46 @@ export function validateBranchModel(model: BranchModel): string | null {
|
|
|
96
145
|
return null;
|
|
97
146
|
}
|
|
98
147
|
|
|
99
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a model into the value everything judges against. Throws on an
|
|
150
|
+
* invalid pair — the same rule {@link validateBranchModel} states — so a
|
|
151
|
+
* value, once held, is known to be a usable one.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveBranchModel(model: BranchModel): BranchModelValue {
|
|
100
154
|
const problem = validateBranchModel(model);
|
|
101
155
|
if (problem) throw new Error(problem);
|
|
102
|
-
const defaultBranch = model.defaultBranch.trim();
|
|
103
156
|
const list = branchListOf(model);
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
defaultBranch: model.defaultBranch.trim(),
|
|
159
|
+
protectedBranches: new Set(list),
|
|
160
|
+
displayNames: Object.freeze(Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)]))),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
104
163
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Apply the model to the BROWSER'S live bindings. Called once during boot,
|
|
166
|
+
* from `GET /api/config`, before anything reads them. Validated by
|
|
167
|
+
* {@link resolveBranchModel}, so the bindings and a value resolved from the
|
|
168
|
+
* same model can never disagree.
|
|
169
|
+
*/
|
|
170
|
+
export function configureBranchModel(model: BranchModel): void {
|
|
171
|
+
const value = resolveBranchModel(model);
|
|
172
|
+
DEFAULT_BRANCH = value.defaultBranch;
|
|
173
|
+
PROTECTED_BRANCH_DISPLAY_NAMES = value.displayNames;
|
|
174
|
+
PROTECTED_BRANCHES = value.protectedBranches;
|
|
110
175
|
}
|
|
111
176
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
177
|
+
/**
|
|
178
|
+
* The browser's model as a value — for the code paths that take a
|
|
179
|
+
* {@link BranchModelValue} and run in the browser, where the one model in
|
|
180
|
+
* effect is the configured one.
|
|
181
|
+
*/
|
|
182
|
+
export function currentBranchModel(): BranchModelValue {
|
|
183
|
+
return {
|
|
184
|
+
defaultBranch: DEFAULT_BRANCH,
|
|
185
|
+
protectedBranches: PROTECTED_BRANCHES,
|
|
186
|
+
displayNames: PROTECTED_BRANCH_DISPLAY_NAMES,
|
|
187
|
+
};
|
|
115
188
|
}
|
|
116
189
|
|
|
117
190
|
/**
|
|
@@ -125,11 +198,9 @@ export function branchModelFromEnv(): BranchModel {
|
|
|
125
198
|
};
|
|
126
199
|
}
|
|
127
200
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
name,
|
|
132
|
-
);
|
|
201
|
+
/** Whether `name` is one of `model`'s protected branches. */
|
|
202
|
+
export function isProtectedBranch(model: BranchModelValue, name: string | null | undefined): boolean {
|
|
203
|
+
return !!name && model.protectedBranches.has(name);
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
/**
|
|
@@ -145,8 +216,11 @@ export function isProtectedBranch(name: string | null | undefined): boolean {
|
|
|
145
216
|
* whether to fall back to the raw string or hide the affordance.
|
|
146
217
|
*/
|
|
147
218
|
export function protectedBranchDisplayName(
|
|
219
|
+
model: BranchModelValue,
|
|
148
220
|
name: string | null | undefined,
|
|
149
221
|
): string | null {
|
|
150
222
|
if (!name) return null;
|
|
151
|
-
return
|
|
223
|
+
return Object.prototype.hasOwnProperty.call(model.displayNames, name)
|
|
224
|
+
? model.displayNames[name]!
|
|
225
|
+
: null;
|
|
152
226
|
}
|
package/src/git/types.ts
CHANGED
|
@@ -152,7 +152,20 @@ export interface IGitService {
|
|
|
152
152
|
* the distinction: an "already up to date" pull that broadcast anyway would
|
|
153
153
|
* drop every catalog cache and reload every attached browser for nothing.
|
|
154
154
|
*/
|
|
155
|
-
pull(
|
|
155
|
+
pull(
|
|
156
|
+
workspaceId: string,
|
|
157
|
+
opts?: {
|
|
158
|
+
/**
|
|
159
|
+
* Replay the local commits with `--rebase-merges`, so a merge commit
|
|
160
|
+
* the clone holds but origin has not seen survives the replay AS a
|
|
161
|
+
* merge instead of being flattened into cherry-picks of its second
|
|
162
|
+
* parent's commits. Only the change-request update asks for it: it is
|
|
163
|
+
* the one caller whose unpushed commit is deliberately a merge, and
|
|
164
|
+
* whose whole point is that the merge reaches the remote intact.
|
|
165
|
+
*/
|
|
166
|
+
preserveMerges?: boolean;
|
|
167
|
+
},
|
|
168
|
+
): Promise<{ treeChanged: boolean }>;
|
|
156
169
|
/**
|
|
157
170
|
* The remote sync's pull, observed as ONE serialized operation: where HEAD
|
|
158
171
|
* was, the pull, where HEAD is, and which repo-relative paths changed
|
|
@@ -275,6 +288,21 @@ export interface IGitService {
|
|
|
275
288
|
at: { baseSha: string; headSha: string },
|
|
276
289
|
): Promise<{ mergeBaseSha: string | null; behind: boolean }>;
|
|
277
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Every repo-relative path whose content differs between two commits, as a
|
|
293
|
+
* plain two-dot diff with rename detection OFF — so a rename reports both
|
|
294
|
+
* the path it left and the path it arrived at. Two callers want exactly
|
|
295
|
+
* that conservative answer: the approvals carry-forward (a path that
|
|
296
|
+
* appears loses its approval) and "did the target change a file this
|
|
297
|
+
* request also changes" (a request editing a file the target renamed has
|
|
298
|
+
* to count as affected).
|
|
299
|
+
*/
|
|
300
|
+
pathsChangedBetween(
|
|
301
|
+
workspaceId: string,
|
|
302
|
+
fromSha: string,
|
|
303
|
+
toSha: string,
|
|
304
|
+
): Promise<string[]>;
|
|
305
|
+
|
|
278
306
|
/**
|
|
279
307
|
* A file's content at a change request's fork point — a commit that must
|
|
280
308
|
* be on `baseBranch`'s history. `null` when the path did not exist there.
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
ChangeRequest,
|
|
31
31
|
ChangeRequestComment,
|
|
32
32
|
ChangeRequestDetail,
|
|
33
|
+
ChangeRequestUpdateResult,
|
|
33
34
|
ChangeRequestState,
|
|
34
35
|
ChangedFile,
|
|
35
36
|
FileApproval,
|
|
@@ -424,6 +425,23 @@ export interface IWorkflowService {
|
|
|
424
425
|
opts?: { fresh?: boolean },
|
|
425
426
|
): Promise<ChangeRequest[]>;
|
|
426
427
|
getChangeRequest(number: number): Promise<ChangeRequest | null>;
|
|
428
|
+
/**
|
|
429
|
+
* The NEWEST change request from `sourceBranch` by `authorEmail` that is no
|
|
430
|
+
* longer open, or null when every one of theirs on that branch is still
|
|
431
|
+
* open (or there never was one).
|
|
432
|
+
*
|
|
433
|
+
* Why it is asked this narrowly: a request that has been declined, or that
|
|
434
|
+
* its author withdrew, leaves no other trace — `listChangeRequestsAuthoredBy`
|
|
435
|
+
* lists open requests only, and nothing records WHO closed a request. Asking
|
|
436
|
+
* for the last non-open one on a deterministic branch is enough to tell
|
|
437
|
+
* "your last request was not accepted" from "you never asked", without a new
|
|
438
|
+
* column. A MERGED request counts as non-open too: the caller distinguishes
|
|
439
|
+
* the two by whether the access it asked for has landed.
|
|
440
|
+
*/
|
|
441
|
+
latestClosedChangeRequest(
|
|
442
|
+
authorEmail: string,
|
|
443
|
+
sourceBranch: string,
|
|
444
|
+
): Promise<{ number: number; state: ChangeRequestState } | null>;
|
|
427
445
|
getChangeRequestDetail(
|
|
428
446
|
number: number,
|
|
429
447
|
/**
|
|
@@ -457,8 +475,16 @@ export interface IWorkflowService {
|
|
|
457
475
|
* someone who may apply it (`viewerCanUpdate`) may run it (403 otherwise).
|
|
458
476
|
* A conflicting merge is aborted, leaving the branch exactly as it was, and
|
|
459
477
|
* surfaces as `ChangeRequestConflictsError` (409).
|
|
478
|
+
*
|
|
479
|
+
* Answers the refreshed detail plus `updatedPaths`: which files the merge
|
|
480
|
+
* changed on the branch, so a caller showing the pre-update files can
|
|
481
|
+
* replace exactly those.
|
|
460
482
|
*/
|
|
461
|
-
updateFromTarget(
|
|
483
|
+
updateFromTarget(
|
|
484
|
+
workspaceId: string,
|
|
485
|
+
user: AuthUser,
|
|
486
|
+
number: number,
|
|
487
|
+
): Promise<ChangeRequestUpdateResult>;
|
|
462
488
|
|
|
463
489
|
// Comments
|
|
464
490
|
listComments(number: number): Promise<ChangeRequestComment[]>;
|
package/src/workflow/types.ts
CHANGED
|
@@ -124,6 +124,22 @@ export type AcquireLockResult =
|
|
|
124
124
|
*/
|
|
125
125
|
export type ChangeRequest = PullRequestSummary;
|
|
126
126
|
export type ChangeRequestDetail = PullRequestDetail;
|
|
127
|
+
/**
|
|
128
|
+
* What an Update hands back: the refreshed detail, plus the repo-relative
|
|
129
|
+
* paths the merge actually changed on the request's branch.
|
|
130
|
+
*
|
|
131
|
+
* The dialog runs its update behind the files it is already showing, so when
|
|
132
|
+
* the update lands it has to replace file content it has read. `updatedPaths`
|
|
133
|
+
* is which — git's own two-dot diff between the branch head before the merge
|
|
134
|
+
* and after it, the same list the approvals carry-forward is decided on. A
|
|
135
|
+
* client that forgot everything instead would re-read every file in the
|
|
136
|
+
* request to replace the one or two the merge touched, and blank the pane the
|
|
137
|
+
* reader is mid-sentence in.
|
|
138
|
+
*
|
|
139
|
+
* Empty when the merge changed nothing on the branch — including the case
|
|
140
|
+
* where there was nothing to merge.
|
|
141
|
+
*/
|
|
142
|
+
export type ChangeRequestUpdateResult = ChangeRequestDetail & { updatedPaths: string[] };
|
|
127
143
|
export type ChangeRequestState = PullRequestState;
|
|
128
144
|
export type ChangedFile = PullRequestFile;
|
|
129
145
|
|