@bevel-software/platform-shared 0.1.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/LICENSE +202 -0
- package/dist/auth/types.d.ts +15 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/chat/types.d.ts +39 -0
- package/dist/chat/types.d.ts.map +1 -0
- package/dist/chat/types.js +13 -0
- package/dist/chat/types.js.map +1 -0
- package/dist/git/branchAuthor.d.ts +28 -0
- package/dist/git/branchAuthor.d.ts.map +1 -0
- package/dist/git/branchAuthor.js +45 -0
- package/dist/git/branchAuthor.js.map +1 -0
- package/dist/git/pr.types.d.ts +264 -0
- package/dist/git/pr.types.d.ts.map +1 -0
- package/dist/git/pr.types.js +2 -0
- package/dist/git/pr.types.js.map +1 -0
- package/dist/git/protected.d.ts +52 -0
- package/dist/git/protected.d.ts.map +1 -0
- package/dist/git/protected.js +92 -0
- package/dist/git/protected.js.map +1 -0
- package/dist/git/review.types.d.ts +49 -0
- package/dist/git/review.types.d.ts.map +1 -0
- package/dist/git/review.types.js +2 -0
- package/dist/git/review.types.js.map +1 -0
- package/dist/git/types.d.ts +107 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/git/types.js +2 -0
- package/dist/git/types.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/workflow/events.d.ts +218 -0
- package/dist/workflow/events.d.ts.map +1 -0
- package/dist/workflow/events.js +48 -0
- package/dist/workflow/events.js.map +1 -0
- package/dist/workflow/interface.d.ts +233 -0
- package/dist/workflow/interface.d.ts.map +1 -0
- package/dist/workflow/interface.js +20 -0
- package/dist/workflow/interface.js.map +1 -0
- package/dist/workflow/types.d.ts +131 -0
- package/dist/workflow/types.d.ts.map +1 -0
- package/dist/workflow/types.js +17 -0
- package/dist/workflow/types.js.map +1 -0
- package/dist/workspace/filename.d.ts +33 -0
- package/dist/workspace/filename.d.ts.map +1 -0
- package/dist/workspace/filename.js +98 -0
- package/dist/workspace/filename.js.map +1 -0
- package/dist/workspace/frontmatter.d.ts +23 -0
- package/dist/workspace/frontmatter.d.ts.map +1 -0
- package/dist/workspace/frontmatter.js +48 -0
- package/dist/workspace/frontmatter.js.map +1 -0
- package/dist/workspace/kb-layout.d.ts +66 -0
- package/dist/workspace/kb-layout.d.ts.map +1 -0
- package/dist/workspace/kb-layout.js +63 -0
- package/dist/workspace/kb-layout.js.map +1 -0
- package/dist/workspace/types.d.ts +57 -0
- package/dist/workspace/types.d.ts.map +1 -0
- package/dist/workspace/types.js +2 -0
- package/dist/workspace/types.js.map +1 -0
- package/package.json +41 -0
- package/src/auth/types.ts +16 -0
- package/src/chat/types.ts +53 -0
- package/src/git/branchAuthor.ts +46 -0
- package/src/git/pr.types.ts +274 -0
- package/src/git/protected.ts +121 -0
- package/src/git/review.types.ts +51 -0
- package/src/git/types.ts +170 -0
- package/src/index.ts +23 -0
- package/src/workflow/events.ts +271 -0
- package/src/workflow/interface.ts +357 -0
- package/src/workflow/types.ts +156 -0
- package/src/workspace/filename.ts +102 -0
- package/src/workspace/frontmatter.ts +45 -0
- package/src/workspace/kb-layout.ts +81 -0
- package/src/workspace/types.ts +59 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
export type PullRequestState = 'open' | 'merged' | 'closed';
|
|
2
|
+
|
|
3
|
+
export interface PullRequestAuthor {
|
|
4
|
+
login: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PullRequestReviewStatus {
|
|
9
|
+
approvals: number;
|
|
10
|
+
changesRequested: number;
|
|
11
|
+
/** GitHub logins of reviewers with a pending request (no review yet). */
|
|
12
|
+
pendingLogins: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PullRequestSummary {
|
|
16
|
+
number: number;
|
|
17
|
+
title: string;
|
|
18
|
+
author: PullRequestAuthor;
|
|
19
|
+
/**
|
|
20
|
+
* Opaque SHA-256 hash of the authoring user's email. Embedded in the PR body via a hidden
|
|
21
|
+
* marker so `/pr/mine` can filter by identity without leaking raw emails into bodies. Absent
|
|
22
|
+
* on PRs not created through this backend. `author.login` is always the GitHub account that
|
|
23
|
+
* actually opened the PR (typically our service account). Compare by re-hashing the caller's
|
|
24
|
+
* email; there is no reverse lookup.
|
|
25
|
+
*/
|
|
26
|
+
authorId?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The app user (not the GitHub account) who triggered this PR. Resolved server-side by
|
|
29
|
+
* matching `authorId` against the hash of every app user's email. Use this in user-facing
|
|
30
|
+
* surfaces — `author.login` is always the shared service account.
|
|
31
|
+
*
|
|
32
|
+
* Display name only — no email. Other authenticated workspace members don't need raw
|
|
33
|
+
* emails to render PR attribution. The hash in `authorId` is the stable identity if
|
|
34
|
+
* downstream code needs to compare against the current user.
|
|
35
|
+
*
|
|
36
|
+
* Absent when no app user matches the hash (e.g. PRs opened outside this backend, or after
|
|
37
|
+
* an app user was removed from the users table).
|
|
38
|
+
*/
|
|
39
|
+
appAuthor?: { name: string };
|
|
40
|
+
branch: string;
|
|
41
|
+
base: string;
|
|
42
|
+
state: PullRequestState;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
/** Relative paths within `knowledge-base/`. Empty if not yet computed. */
|
|
45
|
+
touchedNodePaths: string[];
|
|
46
|
+
review: PullRequestReviewStatus;
|
|
47
|
+
url: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PrFileStatus =
|
|
51
|
+
| 'added'
|
|
52
|
+
| 'modified'
|
|
53
|
+
| 'removed'
|
|
54
|
+
| 'renamed'
|
|
55
|
+
| 'copied'
|
|
56
|
+
| 'changed'
|
|
57
|
+
| 'unchanged';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A single file in a PR. Patch is GitHub's unified-diff text; it's absent for
|
|
61
|
+
* binary files and for files larger than the API cutoff (~3 MB). `previousPath`
|
|
62
|
+
* is populated for renames/copies.
|
|
63
|
+
*/
|
|
64
|
+
export interface PullRequestFile {
|
|
65
|
+
path: string;
|
|
66
|
+
previousPath?: string;
|
|
67
|
+
status: PrFileStatus;
|
|
68
|
+
additions: number;
|
|
69
|
+
deletions: number;
|
|
70
|
+
patch?: string;
|
|
71
|
+
isBinary: boolean;
|
|
72
|
+
/** Blob SHA at the PR head — stable identifier for caching raw content. */
|
|
73
|
+
sha: string;
|
|
74
|
+
/** Authenticated `raw.githubusercontent.com`-style URL for the blob at head. */
|
|
75
|
+
rawUrl: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One comment in the Bevel-owned PR discussion. GitHub never sees these —
|
|
80
|
+
* they live only in our DB, attributed to the authenticated user. Shape
|
|
81
|
+
* encodes three comment kinds:
|
|
82
|
+
*
|
|
83
|
+
* General → path undefined, line undefined
|
|
84
|
+
* File-level → path set, line undefined
|
|
85
|
+
* Inline (line) → path set, line set
|
|
86
|
+
*
|
|
87
|
+
* Threading: `parentId` points at the root of the thread; top-level comments
|
|
88
|
+
* have it undefined.
|
|
89
|
+
*/
|
|
90
|
+
export interface PrReviewComment {
|
|
91
|
+
id: string;
|
|
92
|
+
author: { email: string; name: string };
|
|
93
|
+
body: string;
|
|
94
|
+
path?: string;
|
|
95
|
+
line?: number;
|
|
96
|
+
/** The head SHA the comment was authored against — lets the UI grey out stale inline pins. */
|
|
97
|
+
headSha: string;
|
|
98
|
+
parentId?: string;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
updatedAt?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PostPrCommentInput {
|
|
104
|
+
body: string;
|
|
105
|
+
path?: string;
|
|
106
|
+
line?: number;
|
|
107
|
+
parentId?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One owner's approval on one file. Stored straight from the
|
|
112
|
+
* `pr_file_approvals` row, with derived flags the UI needs.
|
|
113
|
+
*/
|
|
114
|
+
export interface FileApprovalEntry {
|
|
115
|
+
email: string;
|
|
116
|
+
name: string;
|
|
117
|
+
approvedAt: string;
|
|
118
|
+
/** True when this approval was captured against a SHA that is no longer the PR head. */
|
|
119
|
+
isStale: boolean;
|
|
120
|
+
/** True when the approver is the PR author (hash-matched against the PR body marker). */
|
|
121
|
+
isSelfApproval: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Per-file approval state the PR viewer needs to render the approval badge,
|
|
126
|
+
* the Approve button, and the merge gate.
|
|
127
|
+
*
|
|
128
|
+
* `eligibleApprovers` lists everyone who could approve this file at the PR
|
|
129
|
+
* head SHA, derived from the access tree (`roles.yaml` + `access.md`). A
|
|
130
|
+
* file is approved when any non-author user holding one of those roles (or
|
|
131
|
+
* directly named) submits a non-stale approval. The built-in role `everyone`
|
|
132
|
+
* means any signed-in approver is eligible.
|
|
133
|
+
*/
|
|
134
|
+
export interface FileApprovalState {
|
|
135
|
+
path: string;
|
|
136
|
+
/**
|
|
137
|
+
* Roles + direct user grants that confer write access on this file as of
|
|
138
|
+
* the PR head. `roles` may include the built-in role `everyone`. Empty roles
|
|
139
|
+
* + empty users means the file is outside the access-controlled surface (no
|
|
140
|
+
* rule grants write); the merge gate silently excludes such entries instead
|
|
141
|
+
* of blocking on them.
|
|
142
|
+
*/
|
|
143
|
+
eligibleApprovers: {
|
|
144
|
+
roles: string[];
|
|
145
|
+
users: { name: string; email: string }[];
|
|
146
|
+
};
|
|
147
|
+
approvedBy: FileApprovalEntry[];
|
|
148
|
+
/**
|
|
149
|
+
* True iff at least one eligible approver has submitted a non-stale
|
|
150
|
+
* approval. Always `false` when `eligibleApprovers` is empty — with no
|
|
151
|
+
* eligible set, nobody can satisfy the check.
|
|
152
|
+
* Whether the gate *cares* about this file is a separate concern handled
|
|
153
|
+
* by the merge-gate logic: non-md files and files with no eligible
|
|
154
|
+
* approvers are silently excluded from the gate, so `isApproved: false`
|
|
155
|
+
* on one of them does not block merge.
|
|
156
|
+
*/
|
|
157
|
+
isApproved: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Pre-computed for the requesting viewer: would `approveFile` accept their
|
|
160
|
+
* click? True iff their email resolves to `write` on this path under the
|
|
161
|
+
* access tree at `origin/<baseBranch>`. The frontend uses this to gate the
|
|
162
|
+
* Approve button — without it, the button shows for any user whenever the
|
|
163
|
+
* eligible-roles list is non-empty, which is misleading because most users
|
|
164
|
+
* aren't actually in those roles. Backend remains authoritative; this is a
|
|
165
|
+
* UX hint, not a security boundary.
|
|
166
|
+
*/
|
|
167
|
+
viewerCanApprove: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Full read-only PR detail for the in-app viewer. Extends the summary with
|
|
172
|
+
* body, SHAs, file list, and the Bevel-owned comments. Later phases add
|
|
173
|
+
* `approvals` and merge-gate fields — this shape grows incrementally.
|
|
174
|
+
*/
|
|
175
|
+
export interface PullRequestDetail extends PullRequestSummary {
|
|
176
|
+
body: string;
|
|
177
|
+
headSha: string;
|
|
178
|
+
baseSha: string;
|
|
179
|
+
files: PullRequestFile[];
|
|
180
|
+
comments: PrReviewComment[];
|
|
181
|
+
/**
|
|
182
|
+
* One entry per file in `files`, same order. Empty when the caller had no
|
|
183
|
+
* resolvable workspace to do the owner lookup against.
|
|
184
|
+
*/
|
|
185
|
+
approvals: FileApprovalState[];
|
|
186
|
+
/**
|
|
187
|
+
* True iff no *hard* block applies — the PR is open, has files, and isn't
|
|
188
|
+
* merged/closed. Soft warnings (missing owner approvals on md files) do
|
|
189
|
+
* **not** set this to false; the UI handles them via the bypass dialog.
|
|
190
|
+
* The button is disabled only when this is false.
|
|
191
|
+
*/
|
|
192
|
+
mergeableInBevel: boolean;
|
|
193
|
+
/**
|
|
194
|
+
* Hard-block reasons — merging is impossible until these resolve (PR state,
|
|
195
|
+
* no files, etc.). Empty when the PR can be merged (possibly after bypass).
|
|
196
|
+
*/
|
|
197
|
+
mergeBlockedReasons: string[];
|
|
198
|
+
/**
|
|
199
|
+
* Soft warnings — md files with an owner who hasn't approved (or whose
|
|
200
|
+
* approval is stale). Merging is allowed but the UI asks for an explicit
|
|
201
|
+
* bypass confirmation first. Non-md files and ownerless md files are silent.
|
|
202
|
+
*/
|
|
203
|
+
mergeWarnings: string[];
|
|
204
|
+
/**
|
|
205
|
+
* True iff the viewer holds write on `roles.yaml` at `origin/<base>` —
|
|
206
|
+
* i.e. they're in the Admin role on the canonical access tree. The
|
|
207
|
+
* frontend uses this to hide / disable the bypass action for non-admins;
|
|
208
|
+
* the backend enforces the same check on the merge route. False when no
|
|
209
|
+
* viewer was passed (anonymous detail fetch) or when the access tree on
|
|
210
|
+
* base can't be resolved.
|
|
211
|
+
*/
|
|
212
|
+
viewerCanBypassMerge: boolean;
|
|
213
|
+
/**
|
|
214
|
+
* True iff the viewer is allowed to cancel this change request: the PR is
|
|
215
|
+
* open AND the viewer is either its author (hash-matched against the body
|
|
216
|
+
* marker) or an admin (write on `roles.yaml` at `origin/<base>` — same
|
|
217
|
+
* predicate `viewerCanBypassMerge` uses). The frontend renders the Cancel
|
|
218
|
+
* button as disabled when this is false; the backend re-checks server-side
|
|
219
|
+
* on `POST /pr/:n/cancel`, so this is a UX hint, not a security boundary.
|
|
220
|
+
* False when no viewer was passed, the state isn't `open`, or the access
|
|
221
|
+
* tree can't be resolved.
|
|
222
|
+
*/
|
|
223
|
+
viewerCanCancel: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Result of a successful merge via the in-app gate. `sha` is the merge commit
|
|
228
|
+
* GitHub produced; the UI uses it to link to the resulting commit and to
|
|
229
|
+
* invalidate any stale views.
|
|
230
|
+
*/
|
|
231
|
+
export interface MergePrResult {
|
|
232
|
+
prNumber: number;
|
|
233
|
+
sha: string;
|
|
234
|
+
mergedAt: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Result of a successful cancel via `POST /pr/:n/cancel`. No SHA — closing a
|
|
239
|
+
* PR without merging produces no merge commit. `cancelledAt` is the
|
|
240
|
+
* server-side timestamp the close was recorded; useful for audit display.
|
|
241
|
+
*/
|
|
242
|
+
export interface CancelPrResult {
|
|
243
|
+
prNumber: number;
|
|
244
|
+
cancelledAt: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface IPullRequestService {
|
|
248
|
+
/**
|
|
249
|
+
* `opts.fresh` bypasses the internal 30s list cache — used when the UI has
|
|
250
|
+
* a reason to believe the server-side state just changed (agent-opened PR
|
|
251
|
+
* via `gh pr create`) and the user expects to see the update immediately.
|
|
252
|
+
*/
|
|
253
|
+
listOpenPrs(opts?: { fresh?: boolean }): Promise<PullRequestSummary[]>;
|
|
254
|
+
listPrsAuthoredBy(githubLoginOrEmail: string): Promise<PullRequestSummary[]>;
|
|
255
|
+
/**
|
|
256
|
+
* PRs whose touched paths have an owner with the given email.
|
|
257
|
+
* Resolution uses the given workspace's KB — caller passes their own workspaceId.
|
|
258
|
+
*/
|
|
259
|
+
listPrsForOwnerEmail(
|
|
260
|
+
workspaceId: string,
|
|
261
|
+
email: string,
|
|
262
|
+
opts?: { fresh?: boolean },
|
|
263
|
+
): Promise<PullRequestSummary[]>;
|
|
264
|
+
getPr(prNumber: number): Promise<PullRequestSummary | null>;
|
|
265
|
+
/**
|
|
266
|
+
* Full detail for the in-app PR viewer. `opts.fresh` bypasses the detail cache
|
|
267
|
+
* — use for event-driven refreshes (post-merge, share-dialog create).
|
|
268
|
+
* `opts.viewerEmail` pre-computes `viewerCanApprove` per file.
|
|
269
|
+
*/
|
|
270
|
+
getPrDetail(
|
|
271
|
+
prNumber: number,
|
|
272
|
+
opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
|
|
273
|
+
): Promise<PullRequestDetail | null>;
|
|
274
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authoritative, env-overridable registry of the default branch + the set of
|
|
3
|
+
* protected branch slugs in the KB repo, plus their user-visible display names.
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth for BOTH sides of the app:
|
|
6
|
+
* - Backend (Node) reads `process.env.*` at runtime — the values are populated
|
|
7
|
+
* from the environment / the `.env` loaded by `config.ts`, which runs before
|
|
8
|
+
* this module is first evaluated (see `backend/src/index.ts` import order).
|
|
9
|
+
* - Frontend (browser) can't read `process.env` at runtime, so Vite statically
|
|
10
|
+
* replaces the `process.env.DEFAULT_BRANCH` / `process.env.PROTECTED_BRANCHES`
|
|
11
|
+
* references at build time via `define` in `frontend/vite.config.ts`. Keep
|
|
12
|
+
* those references written as the literal `process.env.<NAME>` member access
|
|
13
|
+
* so the static replacement keeps matching.
|
|
14
|
+
*
|
|
15
|
+
* Both the backend (server-side enforcement — refusing commit/push/revert and
|
|
16
|
+
* rejecting `createBranch` on protected names) and the frontend (disabling UI
|
|
17
|
+
* actions, showing the protected-branch banner, branch sort order) consume this
|
|
18
|
+
* single table so the two can never drift. Don't re-declare or hard-code the
|
|
19
|
+
* names elsewhere.
|
|
20
|
+
*
|
|
21
|
+
* Env vars (both REQUIRED — no fallback; the module throws at load if either is
|
|
22
|
+
* missing/empty, so a misconfigured deploy fails fast instead of silently
|
|
23
|
+
* defaulting to the wrong branches). Note the frontend needs them present at
|
|
24
|
+
* BUILD time (Vite `define`), the backend at runtime:
|
|
25
|
+
* - `DEFAULT_BRANCH` — the branch a user lands on / the default propose
|
|
26
|
+
* target.
|
|
27
|
+
* - `PROTECTED_BRANCHES` — comma/space-separated list of protected slugs.
|
|
28
|
+
* Display names are auto-derived from each slug
|
|
29
|
+
* (`target-company-state` → "Target company state").
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// `process` is supplied by Node at runtime on the backend and statically
|
|
33
|
+
// replaced by Vite at build time on the frontend (vite.config.ts `define`).
|
|
34
|
+
// Declared locally — narrowed to just `env` — so this shared module typechecks
|
|
35
|
+
// without pulling @types/node into the @bevel-software/platform-shared package. On the backend
|
|
36
|
+
// build (which has @types/node) this module-scoped declaration simply shadows
|
|
37
|
+
// the global `process`, which is harmless here.
|
|
38
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
39
|
+
|
|
40
|
+
function parseBranchList(raw: string | undefined): string[] {
|
|
41
|
+
return (raw ?? '')
|
|
42
|
+
.split(/[\s,]+/)
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter((s) => s.length > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Human-readable name derived from a kebab/snake slug: lowercase words joined by
|
|
49
|
+
* spaces with only the first letter capitalised — e.g. `target-company-state` →
|
|
50
|
+
* "Target company state". Matches the historical hand-written display names.
|
|
51
|
+
*/
|
|
52
|
+
function deriveDisplayName(slug: string): string {
|
|
53
|
+
const spaced = slug.replace(/[-_]+/g, ' ').trim();
|
|
54
|
+
if (!spaced) return slug;
|
|
55
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The branch a logged-in user lands on when they don't explicitly pick one, and
|
|
60
|
+
* the default destination for shared drafts. Env-overridable via `DEFAULT_BRANCH`.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_BRANCH: string = (process.env.DEFAULT_BRANCH ?? '').trim();
|
|
63
|
+
if (!DEFAULT_BRANCH) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'DEFAULT_BRANCH env var is required (no fallback). Set it on the backend ' +
|
|
66
|
+
'runtime and at the frontend build (Vite reads it via define).',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PROTECTED_BRANCH_LIST: string[] = parseBranchList(process.env.PROTECTED_BRANCHES);
|
|
71
|
+
if (PROTECTED_BRANCH_LIST.length === 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'PROTECTED_BRANCHES env var is required (no fallback). Provide a ' +
|
|
74
|
+
'comma/space-separated list of protected branch slugs.',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
// The default branch is where users land and the default propose target — it
|
|
78
|
+
// must itself be protected, or `isProtectedBranch(DEFAULT_BRANCH)` is false and
|
|
79
|
+
// the protected-branch guards silently don't apply to it. Fail fast on a
|
|
80
|
+
// misconfigured pair rather than shipping that inconsistency.
|
|
81
|
+
if (!PROTECTED_BRANCH_LIST.includes(DEFAULT_BRANCH)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`DEFAULT_BRANCH ("${DEFAULT_BRANCH}") must be one of PROTECTED_BRANCHES ` +
|
|
84
|
+
`(${PROTECTED_BRANCH_LIST.join(', ')}).`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> =
|
|
89
|
+
Object.freeze(
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
PROTECTED_BRANCH_LIST.map((slug) => [slug, deriveDisplayName(slug)]),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
export const PROTECTED_BRANCHES: ReadonlySet<string> = new Set(PROTECTED_BRANCH_LIST);
|
|
96
|
+
|
|
97
|
+
export function isProtectedBranch(name: string | null | undefined): boolean {
|
|
98
|
+
return !!name && Object.prototype.hasOwnProperty.call(
|
|
99
|
+
PROTECTED_BRANCH_DISPLAY_NAMES,
|
|
100
|
+
name,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Capitalised, human-readable name for a protected branch — e.g.
|
|
106
|
+
* `current-company-state` → "Current company state".
|
|
107
|
+
*
|
|
108
|
+
* Use this everywhere the branch name appears in user-visible body copy
|
|
109
|
+
* (banners, tooltips, button labels). The raw kebab slug is fine in small
|
|
110
|
+
* monospace badges for power users / debug surfaces, but body copy should
|
|
111
|
+
* never read "merge into current-company-state" to a non-developer.
|
|
112
|
+
*
|
|
113
|
+
* Returns `null` for unknown / non-protected names so the caller can decide
|
|
114
|
+
* whether to fall back to the raw string or hide the affordance.
|
|
115
|
+
*/
|
|
116
|
+
export function protectedBranchDisplayName(
|
|
117
|
+
name: string | null | undefined,
|
|
118
|
+
): string | null {
|
|
119
|
+
if (!name) return null;
|
|
120
|
+
return PROTECTED_BRANCH_DISPLAY_NAMES[name] ?? null;
|
|
121
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type ChangeKind = 'added' | 'modified' | 'deleted' | 'renamed';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single pending change — the diff between the on-disk backup ledger
|
|
5
|
+
* (the user's last-confirmed state) and the current working tree for one path.
|
|
6
|
+
*/
|
|
7
|
+
export interface PendingChange {
|
|
8
|
+
/** Current path (for added/modified/renamed) or original path (for deleted). */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Populated only when kind === 'renamed' — the pre-rename path. */
|
|
11
|
+
oldPath?: string;
|
|
12
|
+
kind: ChangeKind;
|
|
13
|
+
isBinary: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Line counts from an LCS diff. Null in two cases:
|
|
16
|
+
* - the file is binary (no line concept), or
|
|
17
|
+
* - the text diff exceeds the backend's LCS cap and exact counts were
|
|
18
|
+
* skipped to avoid OOM. Consumers should treat null as "not computed".
|
|
19
|
+
*/
|
|
20
|
+
linesAdded: number | null;
|
|
21
|
+
linesRemoved: number | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pending-changes envelope returned by the review API. Backed by the
|
|
26
|
+
* on-disk backup ledger (a sibling-to-workspaces folder containing each
|
|
27
|
+
* diffable file's last-confirmed contents). `baselineRef` is retained for
|
|
28
|
+
* wire-shape compatibility but is empty under the backup-folder model;
|
|
29
|
+
* the frontend reads `changes` and `branchName` only.
|
|
30
|
+
*/
|
|
31
|
+
export interface ReviewSession {
|
|
32
|
+
branchName: string;
|
|
33
|
+
/** Always empty in the backup-folder model — kept for wire compatibility. */
|
|
34
|
+
baselineRef: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
changes: PendingChange[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Full before/after content for one path in an active session. The UI renders
|
|
41
|
+
* this through a line-level LCS diff.
|
|
42
|
+
*/
|
|
43
|
+
export interface FileDiffPayload {
|
|
44
|
+
path: string;
|
|
45
|
+
kind: ChangeKind;
|
|
46
|
+
/** Content at the baseline. Null for `added` or binary files. */
|
|
47
|
+
baseline: string | null;
|
|
48
|
+
/** Current working-tree content. Null for `deleted` or binary files. */
|
|
49
|
+
current: string | null;
|
|
50
|
+
isBinary: boolean;
|
|
51
|
+
}
|
package/src/git/types.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { AuthUser } from '../auth/types.js';
|
|
2
|
+
|
|
3
|
+
export interface BranchInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
isProtected: boolean;
|
|
6
|
+
// Commits ahead/behind the branch's upstream (falls back to the nearest
|
|
7
|
+
// protected branch on origin). Null when neither can be resolved, e.g. a
|
|
8
|
+
// freshly-created local branch that has never been pushed.
|
|
9
|
+
ahead: number | null;
|
|
10
|
+
behind: number | null;
|
|
11
|
+
/**
|
|
12
|
+
* True iff `refs/remotes/origin/<name>` exists after the last `fetch --prune`.
|
|
13
|
+
* False means the branch is local-only — either never pushed, or its remote
|
|
14
|
+
* counterpart was deleted (typically after a PR merged + branch cleanup on
|
|
15
|
+
* GitHub). The UI uses this to offer a "delete local branch" affordance.
|
|
16
|
+
*/
|
|
17
|
+
hasRemote: boolean;
|
|
18
|
+
// `isCurrent` removed: under the per-branch workspace model the workspace
|
|
19
|
+
// IS the current branch — every consumer can derive it as
|
|
20
|
+
// `branch.name === decodeURIComponent(workspaceId)`. The server-side
|
|
21
|
+
// `git rev-parse` that used to compute this is no longer needed.
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-workspace branch sync state. Slimmed down from the legacy
|
|
26
|
+
* "one workspace, many branches via checkout" model: fields like `isDirty`,
|
|
27
|
+
* `unpushedCommits`, and `conflicted` are gone because save=share guarantees
|
|
28
|
+
* the working tree is never dirty, there are no committed-but-unpushed
|
|
29
|
+
* commits, and conflicts are resolved before lock release. What's left is
|
|
30
|
+
* the upstream-sync signal that drives the auto-update flow + the branch
|
|
31
|
+
* name (kept as a server-side cross-check against the URL-derived branch).
|
|
32
|
+
*/
|
|
33
|
+
export interface WorkingTreeStatus {
|
|
34
|
+
branch: string;
|
|
35
|
+
// False when the current branch has no configured upstream — typically a
|
|
36
|
+
// freshly-created local draft that has never been pushed. Drives the
|
|
37
|
+
// "this draft has never been shared" affordance.
|
|
38
|
+
hasUpstream: boolean;
|
|
39
|
+
// True when origin/<branch> has commits this clone hasn't merged in yet —
|
|
40
|
+
// e.g. a teammate pushed to the same branch from their workspace. The
|
|
41
|
+
// auto-pull hook + PullNeededBanner key off this; everything else has
|
|
42
|
+
// gone with the legacy dirty-tree model.
|
|
43
|
+
unmergedFromUpstream: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `WorkingTreeFile` + `WorkingTreeFileStatus` removed: the working tree is
|
|
47
|
+
// never dirty under save=share, so the "list dirty files with their per-file
|
|
48
|
+
// status" surface has no meaningful content.
|
|
49
|
+
|
|
50
|
+
export interface CommitAttribution {
|
|
51
|
+
authorName: string;
|
|
52
|
+
authorEmail: string;
|
|
53
|
+
sha: string;
|
|
54
|
+
subject: string;
|
|
55
|
+
committedAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ShareChangesRequest {
|
|
59
|
+
summary: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ValidationReport {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
mustFix: string[];
|
|
66
|
+
warnings: string[];
|
|
67
|
+
rawOutput: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IGitService {
|
|
71
|
+
status(workspaceId: string): Promise<WorkingTreeStatus>;
|
|
72
|
+
listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<BranchInfo[]>;
|
|
73
|
+
createBranch(
|
|
74
|
+
workspaceId: string,
|
|
75
|
+
name: string,
|
|
76
|
+
fromBase?: string,
|
|
77
|
+
): Promise<BranchInfo>;
|
|
78
|
+
// No `switchBranch` here on purpose — see the comment in IWorkflowService.
|
|
79
|
+
// Branch is workspace identity under the per-branch workspace model.
|
|
80
|
+
/**
|
|
81
|
+
* Delete a branch. Removes the local ref (if present) AND the origin ref
|
|
82
|
+
* (if present); both sides go in one call so the picker's "discard draft"
|
|
83
|
+
* affordance leaves no orphan behind.
|
|
84
|
+
*
|
|
85
|
+
* Authorisation: the branch's author OR an admin can delete it. Authorship
|
|
86
|
+
* is inferred from the `<email-localpart>/...` naming convention — see
|
|
87
|
+
* `isBranchAuthoredBy`. Admins (per the workspace's `roles.yaml`) can
|
|
88
|
+
* additionally remove unprefixed CLI-created branches that have no
|
|
89
|
+
* recognisable author.
|
|
90
|
+
*
|
|
91
|
+
* `onlyIfNoRemote: true` is the legacy orphan-cleanup path used after a
|
|
92
|
+
* PR merge prunes the remote head: it BYPASSES the author/admin check
|
|
93
|
+
* (callers prune any orphan they encounter) and REFUSES if origin still
|
|
94
|
+
* has the ref (the safety property the flag has always provided).
|
|
95
|
+
* Protected branches and the currently-checked-out branch are always
|
|
96
|
+
* rejected.
|
|
97
|
+
*/
|
|
98
|
+
deleteBranch(
|
|
99
|
+
workspaceId: string,
|
|
100
|
+
name: string,
|
|
101
|
+
user: AuthUser,
|
|
102
|
+
opts?: { onlyIfNoRemote?: boolean },
|
|
103
|
+
): Promise<void>;
|
|
104
|
+
// `forkCurrentToDraft` removed: under the per-branch workspace model each
|
|
105
|
+
// branch is its own workspace by construction, so the "carry uncommitted
|
|
106
|
+
// edits onto a new draft" escape hatch can't fire. Use `createBranch` +
|
|
107
|
+
// workspace-bootstrap navigation instead.
|
|
108
|
+
//
|
|
109
|
+
// `discardChanges` removed: under save=share the working tree is never
|
|
110
|
+
// dirty, so there's nothing to discard.
|
|
111
|
+
commit(
|
|
112
|
+
workspaceId: string,
|
|
113
|
+
user: AuthUser,
|
|
114
|
+
req: ShareChangesRequest,
|
|
115
|
+
): Promise<CommitAttribution>;
|
|
116
|
+
push(workspaceId: string, user: AuthUser): Promise<void>;
|
|
117
|
+
fetch(workspaceId: string): Promise<void>;
|
|
118
|
+
pull(workspaceId: string): Promise<void>;
|
|
119
|
+
diffStat(workspaceId: string, base?: string): Promise<string[]>;
|
|
120
|
+
/**
|
|
121
|
+
* Paths in the working tree that the next commit would include — the set
|
|
122
|
+
* `git add -A` would stage: modified + deleted + renamed + untracked files
|
|
123
|
+
* (honouring `.gitignore`). Use this for share-dialog previews; for the
|
|
124
|
+
* committed delta vs a base ref (e.g. PR owner lookup), use `diffStat`.
|
|
125
|
+
*/
|
|
126
|
+
pendingChanges(workspaceId: string): Promise<string[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Best-fit protected branch a feature branch was forked from, resolved by
|
|
129
|
+
* picking whichever protected branch on origin yields the smallest "ahead"
|
|
130
|
+
* count. Returns null if no protected branch can be reached.
|
|
131
|
+
*/
|
|
132
|
+
resolveForkBase(workspaceId: string, branch: string): Promise<string | null>;
|
|
133
|
+
revertCommit(
|
|
134
|
+
workspaceId: string,
|
|
135
|
+
user: AuthUser,
|
|
136
|
+
sha: string,
|
|
137
|
+
): Promise<CommitAttribution>;
|
|
138
|
+
logForFile(
|
|
139
|
+
workspaceId: string,
|
|
140
|
+
relativePath: string,
|
|
141
|
+
limit?: number,
|
|
142
|
+
): Promise<CommitAttribution[]>;
|
|
143
|
+
diffFileAtCommit(
|
|
144
|
+
workspaceId: string,
|
|
145
|
+
relativePath: string,
|
|
146
|
+
sha: string,
|
|
147
|
+
): Promise<string>;
|
|
148
|
+
/**
|
|
149
|
+
* Unified diff of a single file between two branches. Both names must
|
|
150
|
+
* resolve to a known branch on this workspace (local head or
|
|
151
|
+
* `refs/remotes/origin/<name>`); arbitrary refspecs and SHAs are
|
|
152
|
+
* rejected upstream. Returns the raw `git diff` output — empty string
|
|
153
|
+
* when the file is identical on both sides, a unified-diff body
|
|
154
|
+
* otherwise (including the "new file mode" / "deleted file mode"
|
|
155
|
+
* headers when the file only exists on one side).
|
|
156
|
+
*
|
|
157
|
+
* Read-only: nothing is checked out, fetched, or written.
|
|
158
|
+
*/
|
|
159
|
+
diffFileBetweenBranches(
|
|
160
|
+
workspaceId: string,
|
|
161
|
+
relativePath: string,
|
|
162
|
+
fromBranch: string,
|
|
163
|
+
toBranch: string,
|
|
164
|
+
): Promise<string>;
|
|
165
|
+
// `workingStatus` + `diffFileWorking` removed: under save=share the
|
|
166
|
+
// working tree is never dirty, so listing dirty files / diffing them
|
|
167
|
+
// against HEAD reports the empty state by definition. Cross-branch
|
|
168
|
+
// comparison (`diffFileBetweenBranches`) + history (`diffFileAtCommit`)
|
|
169
|
+
// still cover the meaningful diff cases.
|
|
170
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Auth
|
|
2
|
+
export * from './auth/types.js';
|
|
3
|
+
|
|
4
|
+
// Chat
|
|
5
|
+
export * from './chat/types.js';
|
|
6
|
+
|
|
7
|
+
// Workspace
|
|
8
|
+
export * from './workspace/types.js';
|
|
9
|
+
export * from './workspace/filename.js';
|
|
10
|
+
export * from './workspace/kb-layout.js';
|
|
11
|
+
export * from './workspace/frontmatter.js';
|
|
12
|
+
|
|
13
|
+
// Git
|
|
14
|
+
export * from './git/types.js';
|
|
15
|
+
export * from './git/pr.types.js';
|
|
16
|
+
export * from './git/protected.js';
|
|
17
|
+
export * from './git/branchAuthor.js';
|
|
18
|
+
export * from './git/review.types.js';
|
|
19
|
+
|
|
20
|
+
// Workflow — abstraction layer over git/PR/review-workflow. See PLAN.md.
|
|
21
|
+
export * from './workflow/types.js';
|
|
22
|
+
export * from './workflow/interface.js';
|
|
23
|
+
export * from './workflow/events.js';
|