@bridge-ds/mcp 0.8.3 → 0.8.5
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/branches.d.ts +3 -8
- package/dist/{chunk-C2NPNN67.js → chunk-S5JR43L7.js} +738 -586
- package/dist/index.js +2 -2
- package/dist/lib.js +2 -2
- package/dist/{src-LKQBSVYE.js → src-DRJFSLRV.js} +1 -1
- package/dist/suggestions.d.ts +8 -0
- package/package.json +3 -3
- /package/dist/{chunk-CSJPMSY4.js → chunk-PMZGEZGX.js} +0 -0
|
@@ -79,7 +79,7 @@ import {
|
|
|
79
79
|
validateLayered,
|
|
80
80
|
variantKeyOf,
|
|
81
81
|
whoCanShipSentence
|
|
82
|
-
} from "./chunk-
|
|
82
|
+
} from "./chunk-PMZGEZGX.js";
|
|
83
83
|
|
|
84
84
|
// src/lib.ts
|
|
85
85
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -2741,6 +2741,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
|
|
|
2741
2741
|
}
|
|
2742
2742
|
return t;
|
|
2743
2743
|
};
|
|
2744
|
+
const body = async (sha, e) => (!client2.canWrite ? await client2.readRawFile(e.path, sha).catch(() => void 0) : void 0) ?? client2.getBlobText(e.sha);
|
|
2744
2745
|
const root = config.tokens.root.replace(/\/$/, "");
|
|
2745
2746
|
const prefix = root === "." || !root ? "" : `${root}/`;
|
|
2746
2747
|
const limit = Math.max(1, opts.concurrency ?? 4);
|
|
@@ -2795,7 +2796,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
|
|
|
2795
2796
|
const blobs = entries.filter((e) => e.type === "blob" && e.path.startsWith(prefix));
|
|
2796
2797
|
const picked = new Set(selectTokenFiles(config, blobs.map((e) => e.path)));
|
|
2797
2798
|
const wanted = blobs.filter((e) => picked.has(prefix ? e.path.slice(prefix.length) : e.path));
|
|
2798
|
-
const texts = await pool(wanted, async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await
|
|
2799
|
+
const texts = await pool(wanted, async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await body(sha, e)]);
|
|
2799
2800
|
return new Map(texts);
|
|
2800
2801
|
},
|
|
2801
2802
|
async changesetPaths(sha) {
|
|
@@ -2805,7 +2806,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
|
|
|
2805
2806
|
},
|
|
2806
2807
|
async readFile(sha, path) {
|
|
2807
2808
|
const e = (await tree(sha)).find((x) => x.type === "blob" && x.path === path);
|
|
2808
|
-
return e ?
|
|
2809
|
+
return e ? body(sha, e) : null;
|
|
2809
2810
|
}
|
|
2810
2811
|
};
|
|
2811
2812
|
}
|
|
@@ -3923,6 +3924,33 @@ function explorationRules(branch) {
|
|
|
3923
3924
|
];
|
|
3924
3925
|
}
|
|
3925
3926
|
|
|
3927
|
+
// ../core/src/titles.ts
|
|
3928
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3929
|
+
function properWord(word, names3) {
|
|
3930
|
+
const bare = word.replace(/[^\p{L}\p{N}]+$/u, "");
|
|
3931
|
+
if (!bare) return false;
|
|
3932
|
+
if (names3.some((n) => n === bare)) return true;
|
|
3933
|
+
return new RegExp("\\p{Lu}", "u").test(bare.slice(1));
|
|
3934
|
+
}
|
|
3935
|
+
function lowerFirstWord(text, names3 = []) {
|
|
3936
|
+
const first = text.split(/\s+/)[0] ?? "";
|
|
3937
|
+
if (!first || properWord(first, names3)) return text;
|
|
3938
|
+
return `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
|
|
3939
|
+
}
|
|
3940
|
+
function subjectTitle(subject, title, names3 = []) {
|
|
3941
|
+
const t = title.trim().replace(/\.$/, "");
|
|
3942
|
+
const keep = [subject, ...names3];
|
|
3943
|
+
const s = subject.trim();
|
|
3944
|
+
if (!s) return t;
|
|
3945
|
+
const m = new RegExp(`^${escape(s)}(?=$|[^\\p{L}\\p{N}])\\s*(:)?\\s*`, "iu").exec(t);
|
|
3946
|
+
if (m) {
|
|
3947
|
+
const rest = t.slice(m[0].length);
|
|
3948
|
+
if (!rest) return s;
|
|
3949
|
+
return m[1] ? `${s}: ${lowerFirstWord(rest, keep)}` : `${s} ${rest}`;
|
|
3950
|
+
}
|
|
3951
|
+
return `${s}: ${lowerFirstWord(t, keep)}`;
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3926
3954
|
// ../core/src/requests.ts
|
|
3927
3955
|
var REQUEST_KINDS = [
|
|
3928
3956
|
{ value: "new-component", label: "New component", hint: "A component the design system does not have yet." },
|
|
@@ -3939,8 +3967,7 @@ var MARK2 = "bridge-request";
|
|
|
3939
3967
|
var kindLabel = (k) => REQUEST_KINDS.find((x) => x.value === k)?.label ?? "Other";
|
|
3940
3968
|
var kindSlug = (k) => `request: ${k}`;
|
|
3941
3969
|
function requestIssueTitle(r) {
|
|
3942
|
-
|
|
3943
|
-
return `${r.component}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
|
|
3970
|
+
return subjectTitle(r.component, r.title);
|
|
3944
3971
|
}
|
|
3945
3972
|
function composeRequestIssue(r, opts = {}) {
|
|
3946
3973
|
const rows = r.changes.length ? ["| What | Now | Requested |", "| --- | --- | --- |", ...r.changes.map((c) => `| ${cell2(c.field)} | ${cell2(c.before ?? "")} | ${cell2(c.after ?? "")} |`)] : [];
|
|
@@ -4549,15 +4576,36 @@ function reviewStatusKind(input) {
|
|
|
4549
4576
|
if (input.mergeable === false) return out("conflict");
|
|
4550
4577
|
if (input.ci?.state === "pending") return out("ci-running");
|
|
4551
4578
|
if (!input.ci || !input.approvals) return out("unknown");
|
|
4552
|
-
if (input.mergeable === null) return out("checking");
|
|
4553
4579
|
const tier = input.tier ?? 3;
|
|
4554
4580
|
if (tier >= 2) {
|
|
4555
4581
|
if (!input.approvals.design) return out("needs-design");
|
|
4556
4582
|
if (!input.approvals.tech) return out("needs-tech");
|
|
4557
4583
|
} else if (!input.approvals.approvers.length) return out("needs-approval");
|
|
4584
|
+
if (input.mergeable === null) return out("checking");
|
|
4558
4585
|
const gate = mergeGate({ tier: input.tier, ci: input.ci, approvals: input.approvals, draft: Boolean(input.draft), mergeable: input.mergeable, state: input.state ?? "open", merged_at: input.merged_at });
|
|
4559
4586
|
return gate.allowed ? out("ready") : out("unknown");
|
|
4560
4587
|
}
|
|
4588
|
+
var REVIEW_STATUS_LABEL = {
|
|
4589
|
+
merged: "Merged",
|
|
4590
|
+
closed: "Closed",
|
|
4591
|
+
draft: "Draft",
|
|
4592
|
+
"ci-failing": "Blocked by tests",
|
|
4593
|
+
"changes-requested": "Changes requested",
|
|
4594
|
+
"ci-running": "Tests running",
|
|
4595
|
+
"needs-design": "Needs design approval",
|
|
4596
|
+
"needs-tech": "Needs tech approval",
|
|
4597
|
+
"needs-approval": "Needs approval",
|
|
4598
|
+
conflict: "Conflict with main",
|
|
4599
|
+
checking: "Checking for conflicts",
|
|
4600
|
+
ready: "Ready to merge",
|
|
4601
|
+
unknown: "Status unknown"
|
|
4602
|
+
};
|
|
4603
|
+
var CI_STATE_LABEL = {
|
|
4604
|
+
pass: "Tests pass",
|
|
4605
|
+
fail: "Tests fail",
|
|
4606
|
+
pending: "Tests running",
|
|
4607
|
+
none: "No automated checks"
|
|
4608
|
+
};
|
|
4561
4609
|
var BOTS = /* @__PURE__ */ new Set(["github-actions[bot]", "app/github-actions", "changeset-bot[bot]"]);
|
|
4562
4610
|
var isReleasePull = (p) => p.head.ref.startsWith("changeset-release/");
|
|
4563
4611
|
function isAutomationPull(p) {
|
|
@@ -5400,6 +5448,10 @@ var SUGGESTION_STATE_LABELS = {
|
|
|
5400
5448
|
approved: "suggestion: approved",
|
|
5401
5449
|
applying: "suggestion: applying"
|
|
5402
5450
|
};
|
|
5451
|
+
var SUGGESTION_FINAL_LABELS = {
|
|
5452
|
+
applied: "suggestion: applied",
|
|
5453
|
+
declined: "suggestion: declined"
|
|
5454
|
+
};
|
|
5403
5455
|
var SUGGESTION_STATUS_LABEL = {
|
|
5404
5456
|
"needs-review": "Needs review",
|
|
5405
5457
|
"changes-requested": "Changes requested",
|
|
@@ -5620,8 +5672,8 @@ function suggestionSubject(p) {
|
|
|
5620
5672
|
return KIND_SUBJECT[p.kind];
|
|
5621
5673
|
}
|
|
5622
5674
|
function suggestionIssueTitle(p) {
|
|
5623
|
-
const
|
|
5624
|
-
return
|
|
5675
|
+
const names3 = [...(p.docs ?? []).map((d) => d.component), ...p.componentSpec?.component ? [p.componentSpec.component] : []];
|
|
5676
|
+
return subjectTitle(suggestionSubject(p), p.title, names3);
|
|
5625
5677
|
}
|
|
5626
5678
|
function suggestionLabels(p, opts = {}) {
|
|
5627
5679
|
return [
|
|
@@ -5655,9 +5707,22 @@ function suggestionStatus(i) {
|
|
|
5655
5707
|
if (i.outdated) return "outdated";
|
|
5656
5708
|
return i.review ?? "needs-review";
|
|
5657
5709
|
}
|
|
5710
|
+
function withOutdated(status, outdated) {
|
|
5711
|
+
return outdated && (status === "needs-review" || status === "changes-requested" || status === "approved") ? "outdated" : status;
|
|
5712
|
+
}
|
|
5658
5713
|
function suggestionStateLabel(status) {
|
|
5714
|
+
if (status === "applied" || status === "declined") return SUGGESTION_FINAL_LABELS[status];
|
|
5659
5715
|
return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
|
|
5660
5716
|
}
|
|
5717
|
+
var SUGGESTION_STATUS_LABELS = [...Object.values(SUGGESTION_STATE_LABELS), ...Object.values(SUGGESTION_FINAL_LABELS)];
|
|
5718
|
+
function suggestionLabelChanges(labels, status) {
|
|
5719
|
+
if (status === "outdated") return { add: [], remove: [] };
|
|
5720
|
+
const want = suggestionStateLabel(status);
|
|
5721
|
+
return {
|
|
5722
|
+
add: want && !labels.includes(want) ? [want] : [],
|
|
5723
|
+
remove: SUGGESTION_STATUS_LABELS.filter((l) => l !== want && labels.includes(l))
|
|
5724
|
+
};
|
|
5725
|
+
}
|
|
5661
5726
|
var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
|
|
5662
5727
|
var MARK3 = "bridge-suggestion";
|
|
5663
5728
|
var PART_SCHEMA = "bridge.suggestion-part/1";
|
|
@@ -6044,97 +6109,422 @@ function suggestionPathProblems(p, config) {
|
|
|
6044
6109
|
return [...new Set(out)];
|
|
6045
6110
|
}
|
|
6046
6111
|
|
|
6047
|
-
// ../core/src/
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6058
|
-
|
|
6059
|
-
const
|
|
6060
|
-
|
|
6061
|
-
if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
|
|
6062
|
-
return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
|
|
6112
|
+
// ../core/src/suggestion-apply.ts
|
|
6113
|
+
var SUGGESTION_APPLY_EXIT = {
|
|
6114
|
+
ready: 0,
|
|
6115
|
+
"already-applied": 0,
|
|
6116
|
+
conflict: 3,
|
|
6117
|
+
"not-approved": 1,
|
|
6118
|
+
blocked: 1,
|
|
6119
|
+
refused: 1
|
|
6120
|
+
};
|
|
6121
|
+
var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
|
|
6122
|
+
var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
|
|
6123
|
+
function tokenRoot(config) {
|
|
6124
|
+
const r = config.tokens.root.replace(/\/+$/, "");
|
|
6125
|
+
return r === "." ? "" : r;
|
|
6063
6126
|
}
|
|
6064
|
-
function
|
|
6065
|
-
|
|
6127
|
+
function tokenFileOf(config, path) {
|
|
6128
|
+
const root = tokenRoot(config);
|
|
6129
|
+
if (root && !path.startsWith(`${root}/`)) return null;
|
|
6130
|
+
if (!selectTokenFiles(config, [path]).length) return null;
|
|
6131
|
+
return root ? path.slice(root.length + 1) : path;
|
|
6066
6132
|
}
|
|
6067
|
-
|
|
6068
|
-
const
|
|
6069
|
-
const
|
|
6070
|
-
|
|
6133
|
+
function planTokenEdit(set, e, changes) {
|
|
6134
|
+
const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
|
|
6135
|
+
const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
|
|
6136
|
+
const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
|
|
6137
|
+
const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
|
|
6138
|
+
const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
|
|
6139
|
+
let result;
|
|
6140
|
+
try {
|
|
6141
|
+
result = applyTokenEdits(set, [e]);
|
|
6142
|
+
} catch (err) {
|
|
6143
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
6144
|
+
switch (e.op) {
|
|
6145
|
+
case "rename": {
|
|
6146
|
+
const hasOld = defsAt(e.path).length > 0;
|
|
6147
|
+
const hasNew = set.defs.some((d) => d.path === e.newPath);
|
|
6148
|
+
if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
|
|
6149
|
+
return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
|
|
6150
|
+
}
|
|
6151
|
+
case "remove":
|
|
6152
|
+
if (!defsAt(e.path).length) return done("Already removed.");
|
|
6153
|
+
return conflict(why);
|
|
6154
|
+
case "add": {
|
|
6155
|
+
const existing = defsAt(e.path);
|
|
6156
|
+
if (!existing.length) return conflict(why);
|
|
6157
|
+
const want = (file) => {
|
|
6158
|
+
const c = recorded(file);
|
|
6159
|
+
if (c && c.after !== void 0) return c.after;
|
|
6160
|
+
return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
|
|
6161
|
+
};
|
|
6162
|
+
const all = existing.every((d2) => same2(d2.value, want(d2.file)));
|
|
6163
|
+
if (all) return done("Already added with the suggested value.");
|
|
6164
|
+
const d = existing.find((x) => !same2(x.value, want(x.file)));
|
|
6165
|
+
return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
|
|
6166
|
+
}
|
|
6167
|
+
default:
|
|
6168
|
+
return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
|
|
6169
|
+
}
|
|
6170
|
+
}
|
|
6171
|
+
if (!result.touched.length) return done("Already has the suggested value.");
|
|
6172
|
+
if (e.op === "set" || e.op === "remove" || e.op === "rename") {
|
|
6173
|
+
for (const d of defsAt(e.path)) {
|
|
6174
|
+
const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
|
|
6175
|
+
if (!c) continue;
|
|
6176
|
+
const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
|
|
6177
|
+
if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
|
|
6178
|
+
}
|
|
6179
|
+
}
|
|
6180
|
+
const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
|
|
6181
|
+
return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
|
|
6071
6182
|
}
|
|
6072
|
-
function
|
|
6073
|
-
const
|
|
6074
|
-
const
|
|
6075
|
-
const
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
};
|
|
6183
|
+
async function planFile(ws, baseSha, f) {
|
|
6184
|
+
const base = { type: "file", path: f.path };
|
|
6185
|
+
const cur = await ws.read(f.path);
|
|
6186
|
+
const curSha = cur?.sha ?? null;
|
|
6187
|
+
const theirs = f.afterSha === null ? null : f.content ?? null;
|
|
6188
|
+
if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
|
|
6189
|
+
if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
|
|
6190
|
+
if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
|
|
6191
|
+
if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
|
|
6192
|
+
if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
|
|
6193
|
+
const original = await ws.readAt(baseSha, f.path);
|
|
6194
|
+
if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
|
|
6195
|
+
return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
|
|
6196
|
+
}
|
|
6197
|
+
const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
|
|
6198
|
+
if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
|
|
6199
|
+
if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
|
|
6200
|
+
return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
|
|
6090
6201
|
}
|
|
6091
|
-
async function
|
|
6092
|
-
const
|
|
6093
|
-
const
|
|
6094
|
-
const
|
|
6095
|
-
const
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6202
|
+
async function planSuggestionApply(input) {
|
|
6203
|
+
const { parsed, config, workspace: ws } = input;
|
|
6204
|
+
const payload = parsed.payload;
|
|
6205
|
+
const notes = [];
|
|
6206
|
+
const out = (outcome, message, rest2 = {}) => ({
|
|
6207
|
+
outcome,
|
|
6208
|
+
exitCode: SUGGESTION_APPLY_EXIT[outcome],
|
|
6209
|
+
message,
|
|
6210
|
+
payloadSha: parsed.sha,
|
|
6211
|
+
payload,
|
|
6212
|
+
gate: null,
|
|
6213
|
+
ops: [],
|
|
6214
|
+
writes: [],
|
|
6215
|
+
checks: null,
|
|
6216
|
+
problems: [],
|
|
6217
|
+
notes,
|
|
6218
|
+
...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
|
|
6219
|
+
...rest2
|
|
6104
6220
|
});
|
|
6105
|
-
|
|
6106
|
-
}
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6221
|
+
const problems = suggestionPathProblems(payload, config);
|
|
6222
|
+
if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
|
|
6223
|
+
for (const f of payload.files ?? []) {
|
|
6224
|
+
if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
|
|
6225
|
+
else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
|
|
6226
|
+
}
|
|
6227
|
+
if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
|
|
6228
|
+
if (parsed.edited && !input.acceptEdited) {
|
|
6229
|
+
return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
|
|
6230
|
+
problems: ["The payload no longer matches the sha Bridge recorded."]
|
|
6231
|
+
});
|
|
6232
|
+
}
|
|
6233
|
+
const root = tokenRoot(config);
|
|
6234
|
+
const tokenFiles = selectTokenFiles(config, await ws.list());
|
|
6235
|
+
const targets = [
|
|
6236
|
+
...(payload.files ?? []).map((f) => f.path),
|
|
6237
|
+
...(payload.docs ?? []).map((d) => d.path),
|
|
6238
|
+
...payload.componentSpec ? [payload.componentSpec.specPath] : [],
|
|
6239
|
+
...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
|
|
6240
|
+
];
|
|
6241
|
+
if (ws.isSymlink) {
|
|
6242
|
+
const links = [];
|
|
6243
|
+
for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
|
|
6244
|
+
if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
|
|
6245
|
+
}
|
|
6246
|
+
const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
|
|
6247
|
+
const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
|
|
6248
|
+
const review = suggestionReviewState(payload.tier.tier, approvals);
|
|
6249
|
+
const missing = pendingApprovals(payload.tier.tier, approvals);
|
|
6250
|
+
const approved = review === "approved";
|
|
6251
|
+
const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
|
|
6252
|
+
const ops = [];
|
|
6253
|
+
const writes = /* @__PURE__ */ new Map();
|
|
6254
|
+
const current = /* @__PURE__ */ new Map();
|
|
6255
|
+
for (const f of tokenFiles) {
|
|
6256
|
+
const r = await ws.read(root ? `${root}/${f}` : f);
|
|
6257
|
+
if (r) current.set(f, r.text);
|
|
6258
|
+
}
|
|
6259
|
+
const before = new LayeredTokenSet(config, current);
|
|
6260
|
+
let set = before;
|
|
6261
|
+
const appliedEdits = [];
|
|
6262
|
+
for (const e of payload.tokens?.edits ?? []) {
|
|
6263
|
+
const r = planTokenEdit(set, e, payload.tokens.changes);
|
|
6264
|
+
ops.push(r.op);
|
|
6265
|
+
notes.push(...r.notes);
|
|
6266
|
+
if (r.op.state === "apply" && r.files) {
|
|
6267
|
+
set = new LayeredTokenSet(config, r.files);
|
|
6268
|
+
appliedEdits.push(e);
|
|
6116
6269
|
}
|
|
6117
6270
|
}
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
const
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6271
|
+
for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
|
|
6272
|
+
for (const f of payload.files ?? []) {
|
|
6273
|
+
if (writes.has(f.path)) {
|
|
6274
|
+
notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
|
|
6275
|
+
continue;
|
|
6276
|
+
}
|
|
6277
|
+
const r = await planFile(ws, payload.base.sha, f);
|
|
6278
|
+
ops.push(r.op);
|
|
6279
|
+
if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
|
|
6280
|
+
}
|
|
6281
|
+
for (const d of payload.docs ?? []) {
|
|
6282
|
+
const base = { type: "docs", path: d.path };
|
|
6283
|
+
const docs = d.after;
|
|
6284
|
+
let text;
|
|
6285
|
+
try {
|
|
6286
|
+
const issues = validateDocs(docs);
|
|
6287
|
+
if (issues.length) {
|
|
6288
|
+
problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
|
|
6289
|
+
continue;
|
|
6290
|
+
}
|
|
6291
|
+
const cur = await ws.read(d.path);
|
|
6292
|
+
text = docsFileText(docs, cur?.text ?? null);
|
|
6293
|
+
if (cur && text === cur.text) {
|
|
6294
|
+
ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
|
|
6295
|
+
continue;
|
|
6296
|
+
}
|
|
6297
|
+
ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
|
|
6298
|
+
writes.set(d.path, text);
|
|
6299
|
+
} catch (err) {
|
|
6300
|
+
problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
|
|
6301
|
+
}
|
|
6302
|
+
}
|
|
6303
|
+
if (payload.componentSpec) {
|
|
6304
|
+
const s = payload.componentSpec;
|
|
6305
|
+
const text = `${JSON.stringify(s.spec, null, 2)}
|
|
6306
|
+
`;
|
|
6307
|
+
const cur = await ws.read(s.specPath);
|
|
6308
|
+
if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
|
|
6309
|
+
else {
|
|
6310
|
+
ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
|
|
6311
|
+
writes.set(s.specPath, text);
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
const afterFiles = new Map(set.files);
|
|
6315
|
+
for (const [p, content] of writes) {
|
|
6316
|
+
const tf = tokenFileOf(config, p);
|
|
6317
|
+
if (!tf) continue;
|
|
6318
|
+
if (content === null) afterFiles.delete(tf);
|
|
6319
|
+
else afterFiles.set(tf, content);
|
|
6320
|
+
}
|
|
6321
|
+
const after = new LayeredTokenSet(config, afterFiles);
|
|
6322
|
+
const tokenChanges = diffLayered(before, after);
|
|
6323
|
+
const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
|
|
6324
|
+
const prevErrors = new Set(validateLayered(before).map(issueKey4));
|
|
6325
|
+
const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
|
|
6326
|
+
let pairs = [];
|
|
6327
|
+
if (config.contrast && tokenChanges.length) {
|
|
6328
|
+
try {
|
|
6329
|
+
pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
|
|
6330
|
+
} catch {
|
|
6331
|
+
notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
const modes = config.contrast?.modes;
|
|
6335
|
+
const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
|
|
6336
|
+
const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
|
|
6337
|
+
const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
|
|
6338
|
+
const tier = classifyRisk({ tokenChanges: tokenChanges.map((c) => ({ kind: c.kind, path: c.path, ...c.kind === "changed" ? { descriptionOnly: c.fields.length > 0 && c.fields.every((x) => x === "description") } : {} })), files: otherFiles.map((path) => ({ path })), config });
|
|
6339
|
+
const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
|
|
6340
|
+
const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
|
|
6341
|
+
const rest = { gate, ops, checks, problems };
|
|
6342
|
+
if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
|
|
6343
|
+
const conflicts = ops.filter((o) => o.state === "conflict");
|
|
6344
|
+
if (conflicts.length) {
|
|
6345
|
+
return out("conflict", `Not applied: ${conflicts.length} change(s) conflict with the code now. Ask the author to refresh the suggestion on today's code, or apply those parts by hand.`, rest);
|
|
6346
|
+
}
|
|
6347
|
+
if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
|
|
6348
|
+
if (newErrors.length || newFailures.length) {
|
|
6349
|
+
const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
|
|
6350
|
+
return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
|
|
6351
|
+
}
|
|
6352
|
+
if (tierIncreased && !input.forceUnapproved) {
|
|
6353
|
+
return out("blocked", `Not applied: on today's code it is a tier ${tier.tier} change, it was approved as tier ${payload.tier.tier}. It needs a new approval.`, rest);
|
|
6354
|
+
}
|
|
6355
|
+
if (!approved && !input.forceUnapproved) {
|
|
6356
|
+
const who = review === "changes-requested" ? `changes requested by ${approvals.changesRequested.map((l) => `@${l}`).join(", ")}` : `missing ${missing.map((m) => m === "approval" ? "an approval" : `${m} approval`).join(" and ")}`;
|
|
6357
|
+
return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
|
|
6358
|
+
}
|
|
6359
|
+
const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
|
|
6360
|
+
return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
|
|
6361
|
+
...rest,
|
|
6362
|
+
gate: { ...gate, forced },
|
|
6363
|
+
writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
|
|
6364
|
+
});
|
|
6365
|
+
}
|
|
6366
|
+
function suggestionAppliedComment(plan, where2) {
|
|
6367
|
+
return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
|
|
6368
|
+
}
|
|
6369
|
+
function suggestionApplyTable(plan) {
|
|
6370
|
+
const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
|
|
6371
|
+
const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
6372
|
+
return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6375
|
+
// ../core/src/suggestions-github.ts
|
|
6376
|
+
async function createSuggestionOnGitHub(gh, payload, opts = {}) {
|
|
6377
|
+
const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
|
|
6378
|
+
const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
|
|
6379
|
+
for (const [i, part] of issue.parts.entries()) {
|
|
6380
|
+
try {
|
|
6381
|
+
await gh.createIssueComment(created.number, part);
|
|
6382
|
+
} catch (err) {
|
|
6383
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
6384
|
+
throw new GitHubError(err instanceof GitHubError ? err.status : 500, `Suggestion #${created.number} is incomplete: part ${i + 1} of ${issue.parts.length} could not be posted (${why}). Send it again, then close #${created.number}.`);
|
|
6385
|
+
}
|
|
6386
|
+
}
|
|
6387
|
+
const warnings = [];
|
|
6388
|
+
const missing = issue.labels.filter((l) => !created.labels.includes(l));
|
|
6389
|
+
if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
|
|
6390
|
+
return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
|
|
6391
|
+
}
|
|
6392
|
+
function linkedPullsOf(n, pulls) {
|
|
6393
|
+
return pulls.filter((p) => closingIssueNumbers(p.body).includes(n)).map((p) => ({ number: p.number, url: p.url, state: p.state === "open" ? "open" : "closed", merged: p.state === "merged" }));
|
|
6394
|
+
}
|
|
6395
|
+
async function listSuggestionsOnGitHub(gh, opts = {}) {
|
|
6396
|
+
const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
|
|
6397
|
+
const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
|
|
6398
|
+
return issues.map((i) => listItem(i, pulls));
|
|
6399
|
+
}
|
|
6400
|
+
function listItem(i, pulls) {
|
|
6401
|
+
const linkedPrs = linkedPullsOf(i.number, pulls);
|
|
6402
|
+
const cached = suggestionStateFromLabels(i.labels);
|
|
6403
|
+
const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
|
|
6404
|
+
return {
|
|
6405
|
+
number: i.number,
|
|
6406
|
+
title: i.title,
|
|
6407
|
+
url: i.url,
|
|
6408
|
+
author: i.author,
|
|
6409
|
+
createdAt: i.createdAt,
|
|
6410
|
+
state: i.state,
|
|
6411
|
+
stateReason: i.stateReason,
|
|
6412
|
+
labels: i.labels,
|
|
6413
|
+
kind: suggestionKindFromLabels(i.labels),
|
|
6414
|
+
tier: t ? Number(t) : void 0,
|
|
6415
|
+
linkedPrs,
|
|
6416
|
+
status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
|
|
6417
|
+
};
|
|
6418
|
+
}
|
|
6419
|
+
async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
|
|
6420
|
+
const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
|
|
6421
|
+
const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
|
|
6422
|
+
const linkedPrs = linkedPullsOf(n, pulls);
|
|
6423
|
+
const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
|
|
6424
|
+
const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
|
|
6425
|
+
const status = suggestionStatus({
|
|
6426
|
+
state: issue.state,
|
|
6427
|
+
stateReason: issue.stateReason,
|
|
6428
|
+
linkedPrs,
|
|
6429
|
+
applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
|
|
6430
|
+
outdated: opts.outdated,
|
|
6431
|
+
review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
|
|
6432
|
+
});
|
|
6433
|
+
return { issue, parsed, comments, approvals, linkedPrs, applied, status };
|
|
6434
|
+
}
|
|
6435
|
+
async function syncSuggestionStateLabel(gh, n, labels, status) {
|
|
6436
|
+
const { add, remove } = suggestionLabelChanges(labels, status);
|
|
6437
|
+
let ok2 = true;
|
|
6438
|
+
for (const l of remove) {
|
|
6439
|
+
try {
|
|
6440
|
+
await gh.removeLabel(n, l);
|
|
6441
|
+
} catch {
|
|
6442
|
+
ok2 = false;
|
|
6443
|
+
}
|
|
6444
|
+
}
|
|
6445
|
+
if (add.length) ok2 = await gh.addLabels(n, add) && ok2;
|
|
6446
|
+
return ok2 ? null : `The status label on #${n} could not be updated; the status shown in Bridge is still right.`;
|
|
6447
|
+
}
|
|
6448
|
+
async function markSuggestionsAppliedOnGitHub(gh, pr) {
|
|
6449
|
+
const applied = [];
|
|
6450
|
+
const warnings = [];
|
|
6451
|
+
for (const n of closingIssueNumbers(pr.body)) {
|
|
6452
|
+
try {
|
|
6453
|
+
const issue = await gh.getIssue(n);
|
|
6454
|
+
if (!issue.labels.includes(SUGGESTION_LABEL)) continue;
|
|
6455
|
+
const w = await syncSuggestionStateLabel(gh, n, issue.labels, "applied");
|
|
6456
|
+
if (w) warnings.push(w);
|
|
6457
|
+
else applied.push(n);
|
|
6458
|
+
} catch (err) {
|
|
6459
|
+
warnings.push(`Suggestion #${n} could not be marked applied (${err instanceof Error ? err.message : String(err)}).`);
|
|
6460
|
+
}
|
|
6461
|
+
}
|
|
6462
|
+
return { applied, warnings };
|
|
6463
|
+
}
|
|
6464
|
+
function githubSuggestionWorkspace(gh, ref) {
|
|
6465
|
+
let files;
|
|
6466
|
+
return {
|
|
6467
|
+
read: async (path) => await gh.getFile(path, ref) ?? null,
|
|
6468
|
+
readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
|
|
6469
|
+
list: () => files ??= gh.listFiles(ref)
|
|
6470
|
+
};
|
|
6471
|
+
}
|
|
6472
|
+
function suggestionTouches(payload, config, path) {
|
|
6473
|
+
if (path === CONFIG_FILE) return true;
|
|
6474
|
+
if ((payload.files ?? []).some((f) => f.path === path)) return true;
|
|
6475
|
+
if ((payload.docs ?? []).some((d) => d.path === path)) return true;
|
|
6476
|
+
if (payload.componentSpec?.specPath === path) return true;
|
|
6477
|
+
return Boolean(payload.tokens?.edits.length) && selectTokenFiles(config, [path]).length > 0;
|
|
6478
|
+
}
|
|
6479
|
+
var MAX_COMPARE_FILES = 300;
|
|
6480
|
+
async function suggestionStalenessOnGitHub(gh, d, input) {
|
|
6481
|
+
const none = (mainSha2, mainMoved) => ({ mainSha: mainSha2, mainMoved, outdated: false, conflicts: [], plan: null });
|
|
6482
|
+
if (!d.parsed.ok || d.issue.state !== "open") return none(null, false);
|
|
6483
|
+
const payload = d.parsed.payload;
|
|
6484
|
+
const ref = input.ref ?? (payload.base.ref || input.config.repo?.defaultBranch || "main");
|
|
6485
|
+
const mainSha = await gh.getBranchSha(ref).catch(() => void 0) ?? null;
|
|
6486
|
+
if (!mainSha) return none(null, false);
|
|
6487
|
+
if (mainSha === payload.base.sha) return none(mainSha, false);
|
|
6488
|
+
const changed = await gh.compare(payload.base.sha, mainSha).catch(() => null);
|
|
6489
|
+
if (changed && changed.files.length < MAX_COMPARE_FILES && !changed.files.some((f) => suggestionTouches(payload, input.config, f.filename))) return none(mainSha, true);
|
|
6490
|
+
try {
|
|
6491
|
+
const plan = await planSuggestionApply({
|
|
6492
|
+
parsed: d.parsed,
|
|
6493
|
+
config: input.config,
|
|
6494
|
+
comments: d.comments,
|
|
6495
|
+
governance: input.governance,
|
|
6496
|
+
author: d.issue.author || null,
|
|
6497
|
+
// Only whether it still applies: approvals and a hand edit are reported apart.
|
|
6498
|
+
forceUnapproved: true,
|
|
6499
|
+
acceptEdited: true,
|
|
6500
|
+
workspace: githubSuggestionWorkspace(gh, mainSha)
|
|
6501
|
+
});
|
|
6502
|
+
const conflicts = plan.ops.filter((o) => o.state === "conflict");
|
|
6503
|
+
return { mainSha, mainMoved: true, outdated: conflicts.length > 0, conflicts, plan };
|
|
6504
|
+
} catch {
|
|
6505
|
+
return none(mainSha, true);
|
|
6506
|
+
}
|
|
6507
|
+
}
|
|
6508
|
+
function stalenessStatus(status, staleness) {
|
|
6509
|
+
return withOutdated(status, staleness?.outdated);
|
|
6510
|
+
}
|
|
6511
|
+
async function reviewSuggestionOnGitHub(gh, n, input, gov) {
|
|
6512
|
+
if (input.event === "request-changes" && !input.message?.trim()) throw new GitHubError(422, "Say what should change, in a sentence or two: the author reads it.");
|
|
6513
|
+
const before = await getSuggestionOnGitHub(gh, n, gov);
|
|
6514
|
+
if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
|
|
6515
|
+
if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
|
|
6516
|
+
const sha = before.parsed.sha;
|
|
6517
|
+
if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
|
|
6518
|
+
const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
|
|
6519
|
+
const body = composeSuggestionReviewComment({ event: input.event, role: input.role, payloadSha: sha, at: (input.at ?? /* @__PURE__ */ new Date()).toISOString(), by: by || void 0, message: input.message });
|
|
6520
|
+
const posted = await gh.createIssueComment(n, body);
|
|
6521
|
+
const after = await getSuggestionOnGitHub(gh, n, gov);
|
|
6522
|
+
const status = after.status;
|
|
6523
|
+
const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
|
|
6524
|
+
const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
|
|
6525
|
+
const warnings = [];
|
|
6526
|
+
if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
|
|
6527
|
+
if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
|
|
6138
6528
|
const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
|
|
6139
6529
|
if (labelWarning) warnings.push(labelWarning);
|
|
6140
6530
|
return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
|
|
@@ -6143,8 +6533,12 @@ async function linkSuggestionPrOnGitHub(gh, n, link) {
|
|
|
6143
6533
|
if (!link.pr && !link.branch && !link.commit) throw new GitHubError(422, "Say where it is applied: a pull request number, a branch or a commit.");
|
|
6144
6534
|
const issue = await gh.getIssue(n);
|
|
6145
6535
|
await gh.addComment(n, composeSuggestionAppliedComment({ by: link.by, branch: link.branch, commit: link.commit, pr: link.pr, payloadSha: link.payloadSha, forced: link.forced, at: (link.at ?? /* @__PURE__ */ new Date()).toISOString() }));
|
|
6146
|
-
const
|
|
6147
|
-
|
|
6536
|
+
const status = appliedStatus(issue, link.merged);
|
|
6537
|
+
const warning = await syncSuggestionStateLabel(gh, n, issue.labels, status);
|
|
6538
|
+
return { warnings: warning ? [warning] : [], status };
|
|
6539
|
+
}
|
|
6540
|
+
function appliedStatus(issue, merged) {
|
|
6541
|
+
return merged || issue.state === "closed" && issue.stateReason === "completed" ? "applied" : "applying";
|
|
6148
6542
|
}
|
|
6149
6543
|
async function declineSuggestionOnGitHub(gh, n, reason, by) {
|
|
6150
6544
|
const issue = await gh.getIssue(n);
|
|
@@ -6675,472 +7069,209 @@ async function switchToPublishedOnGitHub(gh, pr) {
|
|
|
6675
7069
|
return { status: "switched", said: `Your branch now uses the published version from ${pull.baseRef}.` };
|
|
6676
7070
|
}
|
|
6677
7071
|
|
|
6678
|
-
// ../core/src/token-proposal.ts
|
|
6679
|
-
var issueKey2 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
|
|
6680
|
-
function planTokenProposal(input) {
|
|
6681
|
-
const { before, config } = input;
|
|
6682
|
-
const r = applyTokenEdits(before, input.edits);
|
|
6683
|
-
const after = new LayeredTokenSet(before.config, r.files);
|
|
6684
|
-
const changes = diffLayered(before, after);
|
|
6685
|
-
if (!changes.length) return null;
|
|
6686
|
-
const prevErrors = new Set(validateLayered(before).map(issueKey2));
|
|
6687
|
-
const newErrors = validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
|
|
6688
|
-
const modes = config.contrast?.modes;
|
|
6689
|
-
const contrast = input.pairs.length ? contrastDelta(runLayeredContrast(before, input.pairs, modes), runLayeredContrast(after, input.pairs, modes)) : [];
|
|
6690
|
-
const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
|
|
6691
|
-
const impact = layeredImpact(before, after, changes);
|
|
6692
|
-
const tier = classifyLayered(changes, [], config);
|
|
6693
|
-
const summary2 = input.summary?.trim() || input.title;
|
|
6694
|
-
const body = [composeProposalBody({ summary: summary2, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: input.tool ?? "Bridge MCP" }), ...input.bodyExtra ?? []].filter(Boolean).join("\n\n");
|
|
6695
|
-
const slug2 = slugify(input.title, 48) || "change";
|
|
6696
|
-
const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slug2}.md`;
|
|
6697
|
-
const touched = r.touched.map((f) => after.repoPath(f));
|
|
6698
|
-
const files = [
|
|
6699
|
-
...r.touched.map((f) => ({ path: after.repoPath(f), content: after.files.get(f) })),
|
|
6700
|
-
...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
|
|
6701
|
-
];
|
|
6702
|
-
const blocked = newErrors.length || newFailures.length ? [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ") : null;
|
|
6703
|
-
return { title: input.title, summary: summary2, edits: input.edits, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
|
|
6704
|
-
}
|
|
6705
|
-
async function writeTokenProposalOnGitHub(gh, plan, opts) {
|
|
6706
|
-
if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
|
|
6707
|
-
const jira = opts.jira ?? null;
|
|
6708
|
-
let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
|
|
6709
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
|
|
6710
|
-
const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
|
|
6711
|
-
|
|
6712
|
-
Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
|
|
6713
|
-
const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
|
|
6714
|
-
const draft = plan.tier.tier >= 2;
|
|
6715
|
-
const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
|
|
6716
|
-
await gh.addLabels(pr.number, labels);
|
|
6717
|
-
return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
|
|
6718
|
-
}
|
|
6719
|
-
var tokenLabels = (plan, before = [], after = []) => [
|
|
6720
|
-
.../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
|
|
6721
|
-
];
|
|
6722
|
-
function tokenSuggestionPayload(plan, ctx) {
|
|
6723
|
-
const tokenFiles = new Set(plan.touched);
|
|
6724
|
-
const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
|
|
6725
|
-
return {
|
|
6726
|
-
schema: SUGGESTION_SCHEMA,
|
|
6727
|
-
kind: "token-change",
|
|
6728
|
-
title: plan.title,
|
|
6729
|
-
summary: plan.summary,
|
|
6730
|
-
source: ctx.source,
|
|
6731
|
-
...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
|
|
6732
|
-
createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
6733
|
-
base: ctx.base,
|
|
6734
|
-
tier: { tier: plan.tier.tier, bump: plan.tier.bump },
|
|
6735
|
-
tokens: {
|
|
6736
|
-
edits: plan.edits,
|
|
6737
|
-
changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
|
|
6738
|
-
},
|
|
6739
|
-
...files.length ? { files } : {},
|
|
6740
|
-
checks: {
|
|
6741
|
-
newErrors: plan.newErrors.length,
|
|
6742
|
-
contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
|
|
6743
|
-
blocked: plan.blocked,
|
|
6744
|
-
impact: plan.impact.total
|
|
6745
|
-
},
|
|
6746
|
-
...ctx.links ? { links: ctx.links } : {},
|
|
6747
|
-
...ctx.previews?.length ? { previews: ctx.previews } : {}
|
|
6748
|
-
};
|
|
6749
|
-
}
|
|
6750
|
-
async function suggestTokenProposalOnGitHub(gh, plan, opts) {
|
|
6751
|
-
if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
|
|
6752
|
-
const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
|
|
6753
|
-
if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
|
|
6754
|
-
const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
|
|
6755
|
-
const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
|
|
6756
|
-
const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
|
|
6757
|
-
const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
|
|
6758
|
-
return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
|
|
6759
|
-
}
|
|
6760
|
-
|
|
6761
|
-
// ../core/src/figma-push-github.ts
|
|
6762
|
-
var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
|
|
6763
|
-
var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
|
|
6764
|
-
function componentSpecSuggestionPayload(d, plan, ctx) {
|
|
6765
|
-
const specPath = figmaSpecPath(d);
|
|
6766
|
-
const spec = JSON.parse(formatFigmaSpec(d, plan));
|
|
6767
|
-
const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
|
|
6768
|
-
const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
|
|
6769
|
-
const figma = d.figma?.fileKey || d.figma?.nodeId || d.figma?.url ? { ...d.figma.fileKey ? { fileKey: d.figma.fileKey } : {}, ...d.figma.nodeId ? { nodeId: d.figma.nodeId } : {}, ...d.figma.url ? { url: d.figma.url } : {}, ...figmaNodes(d) } : void 0;
|
|
6770
|
-
const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
|
|
6771
|
-
return {
|
|
6772
|
-
schema: SUGGESTION_SCHEMA,
|
|
6773
|
-
kind: "component-spec",
|
|
6774
|
-
title: d.title,
|
|
6775
|
-
summary: d.summary.trim() || d.title,
|
|
6776
|
-
source: pushSource(d),
|
|
6777
|
-
...by ? { suggestedBy: by } : {},
|
|
6778
|
-
createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
6779
|
-
base: ctx.base,
|
|
6780
|
-
tier: specTier(d),
|
|
6781
|
-
componentSpec: { component: d.component, specPath, spec, brief },
|
|
6782
|
-
checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
|
|
6783
|
-
...figma ? { links: { figma } } : {},
|
|
6784
|
-
...previews.length ? { previews } : {}
|
|
6785
|
-
};
|
|
6786
|
-
}
|
|
6787
|
-
function figmaNodes(d) {
|
|
6788
|
-
const f = d.figma;
|
|
6789
|
-
if (!f) return {};
|
|
6790
|
-
const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
|
|
6791
|
-
const nodes = [];
|
|
6792
|
-
if (f.before?.nodeId) nodes.push({ nodeId: f.before.nodeId, state: "before", ...url(f.before.nodeId, f.before.url) ? { url: url(f.before.nodeId, f.before.url) } : {}, ...f.before.nodeName ? { name: f.before.nodeName } : {} });
|
|
6793
|
-
if (f.nodeId) nodes.push({ nodeId: f.nodeId, state: "after", ...url(f.nodeId, f.url) ? { url: url(f.nodeId, f.url) } : {}, ...f.nodeName ? { name: f.nodeName } : {} });
|
|
6794
|
-
return nodes.length ? { nodes } : {};
|
|
6795
|
-
}
|
|
6796
|
-
async function openComponentPushOnGitHub(gh, input) {
|
|
6797
|
-
const d = input.diff;
|
|
6798
|
-
if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
|
|
6799
|
-
const plan = input.plan ?? planComponentPush(d, input.tokens);
|
|
6800
|
-
if (plan.route === "manual") return { status: "not-sent", plan, link: null };
|
|
6801
|
-
if (input.dryRun) return { status: "dry-run", plan, link: null };
|
|
6802
|
-
const mode = input.mode ?? "direct";
|
|
6803
|
-
if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a component change needs a GitHub token (issues: write)." : "Sending a component change needs a GitHub token (contents and pull requests: write).");
|
|
6804
|
-
const origin = {
|
|
6805
|
-
component: d.component,
|
|
6806
|
-
figmaName: d.figmaName,
|
|
6807
|
-
route: plan.route,
|
|
6808
|
-
kind: d.kind,
|
|
6809
|
-
...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
|
|
6810
|
-
...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
|
|
6811
|
-
...d.figma?.url ? { url: d.figma.url } : {},
|
|
6812
|
-
...d.requestedBy ? { requestedBy: d.requestedBy } : {}
|
|
6813
|
-
};
|
|
6814
|
-
if (plan.route === "token-change") {
|
|
6815
|
-
if (!input.writeTokens) throw new GitHubError(422, "This change is a token change: send it where the token files are read (the DS Bridge plugin, bridge_push_figma_component or `bridge figma push`).");
|
|
6816
|
-
const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
|
|
6817
|
-
const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
|
|
6818
|
-
await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
|
|
6819
|
-
if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
|
|
6820
|
-
return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
|
|
6821
|
-
}
|
|
6822
|
-
if (mode === "suggest") {
|
|
6823
|
-
const sha = input.baseSha ?? await gh.getBranchSha(input.base);
|
|
6824
|
-
if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
|
|
6825
|
-
const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
|
|
6826
|
-
const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
|
|
6827
|
-
return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
|
|
6828
|
-
}
|
|
6829
|
-
const spec = figmaSpecPath(d);
|
|
6830
|
-
const o = { ...origin, spec, status: "spec" };
|
|
6831
|
-
let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
|
|
6832
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
|
|
6833
|
-
await gh.commitToNewBranch({
|
|
6834
|
-
base: input.base,
|
|
6835
|
-
branch,
|
|
6836
|
-
message: `chore(figma): spec for ${figmaBranchTitle(d)}
|
|
6837
|
-
|
|
6838
|
-
Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
|
|
6839
|
-
files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
|
|
6840
|
-
});
|
|
6841
|
-
const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
|
|
6842
|
-
await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
|
|
6843
|
-
return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
|
|
6844
|
-
}
|
|
6845
|
-
async function listFigmaChangesOnGitHub(gh, opts = {}) {
|
|
6846
|
-
const pulls = await gh.listPullRequests(opts.state ?? "open");
|
|
6847
|
-
const out = [];
|
|
6848
|
-
for (const p of pulls) {
|
|
6849
|
-
if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
|
|
6850
|
-
const origin = parseFigmaOrigin(p.body);
|
|
6851
|
-
if (!origin) continue;
|
|
6852
|
-
out.push({ number: p.number, title: p.title, url: p.url, branch: p.headRef, origin, status: figmaPushStatus({ state: p.state, draft: p.draft, labels: p.labels, route: origin.route }) });
|
|
6853
|
-
}
|
|
6854
|
-
return out;
|
|
6855
|
-
}
|
|
6856
|
-
async function figmaChangeOnGitHub(gh, n, opts = {}) {
|
|
6857
|
-
const pull = await gh.getPullRequest(n);
|
|
6858
|
-
const origin = parseFigmaOrigin(pull.body);
|
|
6859
|
-
if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
|
|
6860
|
-
const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
|
|
6861
|
-
if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
|
|
6862
|
-
const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
|
|
6863
|
-
const spec = parseFigmaSpec(text);
|
|
6864
|
-
if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
|
|
6865
|
-
const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
|
|
6866
|
-
return { pull, origin, status, spec, brief };
|
|
6867
|
-
}
|
|
6868
|
-
async function markFigmaChangeDraftedOnGitHub(gh, n) {
|
|
6869
|
-
if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
|
|
6870
|
-
const pull = await gh.getPullRequest(n);
|
|
6871
|
-
const origin = parseFigmaOrigin(pull.body);
|
|
6872
|
-
if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
|
|
6873
|
-
if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
|
|
6874
|
-
const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
|
|
6875
|
-
if (!files.length) throw new GitHubError(422, `${pull.headRef} only carries the spec: write the component code there first, push it, then mark it drafted.`);
|
|
6876
|
-
await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
|
|
6877
|
-
await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
|
|
6878
|
-
await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
|
|
6879
|
-
const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
|
|
6880
|
-
return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
|
|
6881
|
-
}
|
|
6882
|
-
|
|
6883
|
-
// ../core/src/suggestion-apply.ts
|
|
6884
|
-
var SUGGESTION_APPLY_EXIT = {
|
|
6885
|
-
ready: 0,
|
|
6886
|
-
"already-applied": 0,
|
|
6887
|
-
conflict: 3,
|
|
6888
|
-
"not-approved": 1,
|
|
6889
|
-
blocked: 1,
|
|
6890
|
-
refused: 1
|
|
6891
|
-
};
|
|
6892
|
-
var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
|
|
6893
|
-
var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
|
|
6894
|
-
function tokenRoot(config) {
|
|
6895
|
-
const r = config.tokens.root.replace(/\/+$/, "");
|
|
6896
|
-
return r === "." ? "" : r;
|
|
6897
|
-
}
|
|
6898
|
-
function tokenFileOf(config, path) {
|
|
6899
|
-
const root = tokenRoot(config);
|
|
6900
|
-
if (root && !path.startsWith(`${root}/`)) return null;
|
|
6901
|
-
if (!selectTokenFiles(config, [path]).length) return null;
|
|
6902
|
-
return root ? path.slice(root.length + 1) : path;
|
|
6903
|
-
}
|
|
6904
|
-
function planTokenEdit(set, e, changes) {
|
|
6905
|
-
const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
|
|
6906
|
-
const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
|
|
6907
|
-
const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
|
|
6908
|
-
const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
|
|
6909
|
-
const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
|
|
6910
|
-
let result;
|
|
6911
|
-
try {
|
|
6912
|
-
result = applyTokenEdits(set, [e]);
|
|
6913
|
-
} catch (err) {
|
|
6914
|
-
const why = err instanceof Error ? err.message : String(err);
|
|
6915
|
-
switch (e.op) {
|
|
6916
|
-
case "rename": {
|
|
6917
|
-
const hasOld = defsAt(e.path).length > 0;
|
|
6918
|
-
const hasNew = set.defs.some((d) => d.path === e.newPath);
|
|
6919
|
-
if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
|
|
6920
|
-
return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
|
|
6921
|
-
}
|
|
6922
|
-
case "remove":
|
|
6923
|
-
if (!defsAt(e.path).length) return done("Already removed.");
|
|
6924
|
-
return conflict(why);
|
|
6925
|
-
case "add": {
|
|
6926
|
-
const existing = defsAt(e.path);
|
|
6927
|
-
if (!existing.length) return conflict(why);
|
|
6928
|
-
const want = (file) => {
|
|
6929
|
-
const c = recorded(file);
|
|
6930
|
-
if (c && c.after !== void 0) return c.after;
|
|
6931
|
-
return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
|
|
6932
|
-
};
|
|
6933
|
-
const all = existing.every((d2) => same2(d2.value, want(d2.file)));
|
|
6934
|
-
if (all) return done("Already added with the suggested value.");
|
|
6935
|
-
const d = existing.find((x) => !same2(x.value, want(x.file)));
|
|
6936
|
-
return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
|
|
6937
|
-
}
|
|
6938
|
-
default:
|
|
6939
|
-
return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
|
|
6940
|
-
}
|
|
6941
|
-
}
|
|
6942
|
-
if (!result.touched.length) return done("Already has the suggested value.");
|
|
6943
|
-
if (e.op === "set" || e.op === "remove" || e.op === "rename") {
|
|
6944
|
-
for (const d of defsAt(e.path)) {
|
|
6945
|
-
const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
|
|
6946
|
-
if (!c) continue;
|
|
6947
|
-
const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
|
|
6948
|
-
if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
|
|
6949
|
-
}
|
|
6950
|
-
}
|
|
6951
|
-
const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
|
|
6952
|
-
return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
|
|
6953
|
-
}
|
|
6954
|
-
async function planFile(ws, baseSha, f) {
|
|
6955
|
-
const base = { type: "file", path: f.path };
|
|
6956
|
-
const cur = await ws.read(f.path);
|
|
6957
|
-
const curSha = cur?.sha ?? null;
|
|
6958
|
-
const theirs = f.afterSha === null ? null : f.content ?? null;
|
|
6959
|
-
if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
|
|
6960
|
-
if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
|
|
6961
|
-
if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
|
|
6962
|
-
if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
|
|
6963
|
-
if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
|
|
6964
|
-
const original = await ws.readAt(baseSha, f.path);
|
|
6965
|
-
if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
|
|
6966
|
-
return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
|
|
6967
|
-
}
|
|
6968
|
-
const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
|
|
6969
|
-
if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
|
|
6970
|
-
if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
|
|
6971
|
-
return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
|
|
6972
|
-
}
|
|
6973
|
-
async function planSuggestionApply(input) {
|
|
6974
|
-
const { parsed, config, workspace: ws } = input;
|
|
6975
|
-
const payload = parsed.payload;
|
|
6976
|
-
const notes = [];
|
|
6977
|
-
const out = (outcome, message, rest2 = {}) => ({
|
|
6978
|
-
outcome,
|
|
6979
|
-
exitCode: SUGGESTION_APPLY_EXIT[outcome],
|
|
6980
|
-
message,
|
|
6981
|
-
payloadSha: parsed.sha,
|
|
6982
|
-
payload,
|
|
6983
|
-
gate: null,
|
|
6984
|
-
ops: [],
|
|
6985
|
-
writes: [],
|
|
6986
|
-
checks: null,
|
|
6987
|
-
problems: [],
|
|
6988
|
-
notes,
|
|
6989
|
-
...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
|
|
6990
|
-
...rest2
|
|
6991
|
-
});
|
|
6992
|
-
const problems = suggestionPathProblems(payload, config);
|
|
6993
|
-
if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
|
|
6994
|
-
for (const f of payload.files ?? []) {
|
|
6995
|
-
if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
|
|
6996
|
-
else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
|
|
6997
|
-
}
|
|
6998
|
-
if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
|
|
6999
|
-
if (parsed.edited && !input.acceptEdited) {
|
|
7000
|
-
return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
|
|
7001
|
-
problems: ["The payload no longer matches the sha Bridge recorded."]
|
|
7002
|
-
});
|
|
7003
|
-
}
|
|
7004
|
-
const root = tokenRoot(config);
|
|
7005
|
-
const tokenFiles = selectTokenFiles(config, await ws.list());
|
|
7006
|
-
const targets = [
|
|
7007
|
-
...(payload.files ?? []).map((f) => f.path),
|
|
7008
|
-
...(payload.docs ?? []).map((d) => d.path),
|
|
7009
|
-
...payload.componentSpec ? [payload.componentSpec.specPath] : [],
|
|
7010
|
-
...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
|
|
7011
|
-
];
|
|
7012
|
-
if (ws.isSymlink) {
|
|
7013
|
-
const links = [];
|
|
7014
|
-
for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
|
|
7015
|
-
if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
|
|
7016
|
-
}
|
|
7017
|
-
const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
|
|
7018
|
-
const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
|
|
7019
|
-
const review = suggestionReviewState(payload.tier.tier, approvals);
|
|
7020
|
-
const missing = pendingApprovals(payload.tier.tier, approvals);
|
|
7021
|
-
const approved = review === "approved";
|
|
7022
|
-
const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
|
|
7023
|
-
const ops = [];
|
|
7024
|
-
const writes = /* @__PURE__ */ new Map();
|
|
7025
|
-
const current = /* @__PURE__ */ new Map();
|
|
7026
|
-
for (const f of tokenFiles) {
|
|
7027
|
-
const r = await ws.read(root ? `${root}/${f}` : f);
|
|
7028
|
-
if (r) current.set(f, r.text);
|
|
7029
|
-
}
|
|
7030
|
-
const before = new LayeredTokenSet(config, current);
|
|
7031
|
-
let set = before;
|
|
7032
|
-
const appliedEdits = [];
|
|
7033
|
-
for (const e of payload.tokens?.edits ?? []) {
|
|
7034
|
-
const r = planTokenEdit(set, e, payload.tokens.changes);
|
|
7035
|
-
ops.push(r.op);
|
|
7036
|
-
notes.push(...r.notes);
|
|
7037
|
-
if (r.op.state === "apply" && r.files) {
|
|
7038
|
-
set = new LayeredTokenSet(config, r.files);
|
|
7039
|
-
appliedEdits.push(e);
|
|
7040
|
-
}
|
|
7041
|
-
}
|
|
7042
|
-
for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
|
|
7043
|
-
for (const f of payload.files ?? []) {
|
|
7044
|
-
if (writes.has(f.path)) {
|
|
7045
|
-
notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
|
|
7046
|
-
continue;
|
|
7047
|
-
}
|
|
7048
|
-
const r = await planFile(ws, payload.base.sha, f);
|
|
7049
|
-
ops.push(r.op);
|
|
7050
|
-
if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
|
|
7051
|
-
}
|
|
7052
|
-
for (const d of payload.docs ?? []) {
|
|
7053
|
-
const base = { type: "docs", path: d.path };
|
|
7054
|
-
const docs = d.after;
|
|
7055
|
-
let text;
|
|
7056
|
-
try {
|
|
7057
|
-
const issues = validateDocs(docs);
|
|
7058
|
-
if (issues.length) {
|
|
7059
|
-
problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
|
|
7060
|
-
continue;
|
|
7061
|
-
}
|
|
7062
|
-
const cur = await ws.read(d.path);
|
|
7063
|
-
text = docsFileText(docs, cur?.text ?? null);
|
|
7064
|
-
if (cur && text === cur.text) {
|
|
7065
|
-
ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
|
|
7066
|
-
continue;
|
|
7067
|
-
}
|
|
7068
|
-
ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
|
|
7069
|
-
writes.set(d.path, text);
|
|
7070
|
-
} catch (err) {
|
|
7071
|
-
problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
|
|
7072
|
-
}
|
|
7073
|
-
}
|
|
7074
|
-
if (payload.componentSpec) {
|
|
7075
|
-
const s = payload.componentSpec;
|
|
7076
|
-
const text = `${JSON.stringify(s.spec, null, 2)}
|
|
7077
|
-
`;
|
|
7078
|
-
const cur = await ws.read(s.specPath);
|
|
7079
|
-
if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
|
|
7080
|
-
else {
|
|
7081
|
-
ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
|
|
7082
|
-
writes.set(s.specPath, text);
|
|
7083
|
-
}
|
|
7084
|
-
}
|
|
7085
|
-
const afterFiles = new Map(set.files);
|
|
7086
|
-
for (const [p, content] of writes) {
|
|
7087
|
-
const tf = tokenFileOf(config, p);
|
|
7088
|
-
if (!tf) continue;
|
|
7089
|
-
if (content === null) afterFiles.delete(tf);
|
|
7090
|
-
else afterFiles.set(tf, content);
|
|
7091
|
-
}
|
|
7092
|
-
const after = new LayeredTokenSet(config, afterFiles);
|
|
7093
|
-
const tokenChanges = diffLayered(before, after);
|
|
7094
|
-
const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
|
|
7095
|
-
const prevErrors = new Set(validateLayered(before).map(issueKey4));
|
|
7096
|
-
const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
|
|
7097
|
-
let pairs = [];
|
|
7098
|
-
if (config.contrast && tokenChanges.length) {
|
|
7099
|
-
try {
|
|
7100
|
-
pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
|
|
7101
|
-
} catch {
|
|
7102
|
-
notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
|
|
7103
|
-
}
|
|
7104
|
-
}
|
|
7072
|
+
// ../core/src/token-proposal.ts
|
|
7073
|
+
var issueKey2 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
|
|
7074
|
+
function planTokenProposal(input) {
|
|
7075
|
+
const { before, config } = input;
|
|
7076
|
+
const r = applyTokenEdits(before, input.edits);
|
|
7077
|
+
const after = new LayeredTokenSet(before.config, r.files);
|
|
7078
|
+
const changes = diffLayered(before, after);
|
|
7079
|
+
if (!changes.length) return null;
|
|
7080
|
+
const prevErrors = new Set(validateLayered(before).map(issueKey2));
|
|
7081
|
+
const newErrors = validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
|
|
7105
7082
|
const modes = config.contrast?.modes;
|
|
7106
|
-
const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
|
|
7083
|
+
const contrast = input.pairs.length ? contrastDelta(runLayeredContrast(before, input.pairs, modes), runLayeredContrast(after, input.pairs, modes)) : [];
|
|
7107
7084
|
const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
|
|
7108
|
-
const
|
|
7109
|
-
const tier =
|
|
7110
|
-
const
|
|
7111
|
-
const
|
|
7112
|
-
const
|
|
7113
|
-
|
|
7114
|
-
const
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
if (
|
|
7124
|
-
|
|
7085
|
+
const impact = layeredImpact(before, after, changes);
|
|
7086
|
+
const tier = classifyLayered(changes, [], config);
|
|
7087
|
+
const summary2 = input.summary?.trim() || input.title;
|
|
7088
|
+
const body = [composeProposalBody({ summary: summary2, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: input.tool ?? "Bridge MCP" }), ...input.bodyExtra ?? []].filter(Boolean).join("\n\n");
|
|
7089
|
+
const slug2 = slugify(input.title, 48) || "change";
|
|
7090
|
+
const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slug2}.md`;
|
|
7091
|
+
const touched = r.touched.map((f) => after.repoPath(f));
|
|
7092
|
+
const files = [
|
|
7093
|
+
...r.touched.map((f) => ({ path: after.repoPath(f), content: after.files.get(f) })),
|
|
7094
|
+
...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
|
|
7095
|
+
];
|
|
7096
|
+
const blocked = newErrors.length || newFailures.length ? [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ") : null;
|
|
7097
|
+
return { title: input.title, summary: summary2, edits: input.edits, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
|
|
7098
|
+
}
|
|
7099
|
+
async function writeTokenProposalOnGitHub(gh, plan, opts) {
|
|
7100
|
+
if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
|
|
7101
|
+
const jira = opts.jira ?? null;
|
|
7102
|
+
let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
|
|
7103
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
|
|
7104
|
+
const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
|
|
7105
|
+
|
|
7106
|
+
Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
|
|
7107
|
+
const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
|
|
7108
|
+
const draft = plan.tier.tier >= 2;
|
|
7109
|
+
const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
|
|
7110
|
+
await gh.addLabels(pr.number, labels);
|
|
7111
|
+
return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
|
|
7112
|
+
}
|
|
7113
|
+
var tokenLabels = (plan, before = [], after = []) => [
|
|
7114
|
+
.../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
|
|
7115
|
+
];
|
|
7116
|
+
function tokenSuggestionPayload(plan, ctx) {
|
|
7117
|
+
const tokenFiles = new Set(plan.touched);
|
|
7118
|
+
const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
|
|
7119
|
+
return {
|
|
7120
|
+
schema: SUGGESTION_SCHEMA,
|
|
7121
|
+
kind: "token-change",
|
|
7122
|
+
title: plan.title,
|
|
7123
|
+
summary: plan.summary,
|
|
7124
|
+
source: ctx.source,
|
|
7125
|
+
...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
|
|
7126
|
+
createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
7127
|
+
base: ctx.base,
|
|
7128
|
+
tier: { tier: plan.tier.tier, bump: plan.tier.bump },
|
|
7129
|
+
tokens: {
|
|
7130
|
+
edits: plan.edits,
|
|
7131
|
+
changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
|
|
7132
|
+
},
|
|
7133
|
+
...files.length ? { files } : {},
|
|
7134
|
+
checks: {
|
|
7135
|
+
newErrors: plan.newErrors.length,
|
|
7136
|
+
contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
|
|
7137
|
+
blocked: plan.blocked,
|
|
7138
|
+
impact: plan.impact.total
|
|
7139
|
+
},
|
|
7140
|
+
...ctx.links ? { links: ctx.links } : {},
|
|
7141
|
+
...ctx.previews?.length ? { previews: ctx.previews } : {}
|
|
7142
|
+
};
|
|
7143
|
+
}
|
|
7144
|
+
async function suggestTokenProposalOnGitHub(gh, plan, opts) {
|
|
7145
|
+
if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
|
|
7146
|
+
const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
|
|
7147
|
+
if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
|
|
7148
|
+
const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
|
|
7149
|
+
const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
|
|
7150
|
+
const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
|
|
7151
|
+
const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
|
|
7152
|
+
return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
|
|
7153
|
+
}
|
|
7154
|
+
|
|
7155
|
+
// ../core/src/figma-push-github.ts
|
|
7156
|
+
var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
|
|
7157
|
+
var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
|
|
7158
|
+
function componentSpecSuggestionPayload(d, plan, ctx) {
|
|
7159
|
+
const specPath = figmaSpecPath(d);
|
|
7160
|
+
const spec = JSON.parse(formatFigmaSpec(d, plan));
|
|
7161
|
+
const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
|
|
7162
|
+
const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
|
|
7163
|
+
const figma = d.figma?.fileKey || d.figma?.nodeId || d.figma?.url ? { ...d.figma.fileKey ? { fileKey: d.figma.fileKey } : {}, ...d.figma.nodeId ? { nodeId: d.figma.nodeId } : {}, ...d.figma.url ? { url: d.figma.url } : {}, ...figmaNodes(d) } : void 0;
|
|
7164
|
+
const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
|
|
7165
|
+
return {
|
|
7166
|
+
schema: SUGGESTION_SCHEMA,
|
|
7167
|
+
kind: "component-spec",
|
|
7168
|
+
title: d.title,
|
|
7169
|
+
summary: d.summary.trim() || d.title,
|
|
7170
|
+
source: pushSource(d),
|
|
7171
|
+
...by ? { suggestedBy: by } : {},
|
|
7172
|
+
createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
7173
|
+
base: ctx.base,
|
|
7174
|
+
tier: specTier(d),
|
|
7175
|
+
componentSpec: { component: d.component, specPath, spec, brief },
|
|
7176
|
+
checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
|
|
7177
|
+
...figma ? { links: { figma } } : {},
|
|
7178
|
+
...previews.length ? { previews } : {}
|
|
7179
|
+
};
|
|
7180
|
+
}
|
|
7181
|
+
function figmaNodes(d) {
|
|
7182
|
+
const f = d.figma;
|
|
7183
|
+
if (!f) return {};
|
|
7184
|
+
const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
|
|
7185
|
+
const nodes = [];
|
|
7186
|
+
if (f.before?.nodeId) nodes.push({ nodeId: f.before.nodeId, state: "before", ...url(f.before.nodeId, f.before.url) ? { url: url(f.before.nodeId, f.before.url) } : {}, ...f.before.nodeName ? { name: f.before.nodeName } : {} });
|
|
7187
|
+
if (f.nodeId) nodes.push({ nodeId: f.nodeId, state: "after", ...url(f.nodeId, f.url) ? { url: url(f.nodeId, f.url) } : {}, ...f.nodeName ? { name: f.nodeName } : {} });
|
|
7188
|
+
return nodes.length ? { nodes } : {};
|
|
7189
|
+
}
|
|
7190
|
+
async function openComponentPushOnGitHub(gh, input) {
|
|
7191
|
+
const d = input.diff;
|
|
7192
|
+
if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
|
|
7193
|
+
const plan = input.plan ?? planComponentPush(d, input.tokens);
|
|
7194
|
+
if (plan.route === "manual") return { status: "not-sent", plan, link: null };
|
|
7195
|
+
if (input.dryRun) return { status: "dry-run", plan, link: null };
|
|
7196
|
+
const mode = input.mode ?? "direct";
|
|
7197
|
+
if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a component change needs a GitHub token (issues: write)." : "Sending a component change needs a GitHub token (contents and pull requests: write).");
|
|
7198
|
+
const origin = {
|
|
7199
|
+
component: d.component,
|
|
7200
|
+
figmaName: d.figmaName,
|
|
7201
|
+
route: plan.route,
|
|
7202
|
+
kind: d.kind,
|
|
7203
|
+
...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
|
|
7204
|
+
...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
|
|
7205
|
+
...d.figma?.url ? { url: d.figma.url } : {},
|
|
7206
|
+
...d.requestedBy ? { requestedBy: d.requestedBy } : {}
|
|
7207
|
+
};
|
|
7208
|
+
if (plan.route === "token-change") {
|
|
7209
|
+
if (!input.writeTokens) throw new GitHubError(422, "This change is a token change: send it where the token files are read (the DS Bridge plugin, bridge_push_figma_component or `bridge figma push`).");
|
|
7210
|
+
const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
|
|
7211
|
+
const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
|
|
7212
|
+
await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
|
|
7213
|
+
if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
|
|
7214
|
+
return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
|
|
7125
7215
|
}
|
|
7126
|
-
if (
|
|
7127
|
-
const
|
|
7128
|
-
|
|
7216
|
+
if (mode === "suggest") {
|
|
7217
|
+
const sha = input.baseSha ?? await gh.getBranchSha(input.base);
|
|
7218
|
+
if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
|
|
7219
|
+
const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
|
|
7220
|
+
const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
|
|
7221
|
+
return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
|
|
7129
7222
|
}
|
|
7130
|
-
const
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7223
|
+
const spec = figmaSpecPath(d);
|
|
7224
|
+
const o = { ...origin, spec, status: "spec" };
|
|
7225
|
+
let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
|
|
7226
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
|
|
7227
|
+
await gh.commitToNewBranch({
|
|
7228
|
+
base: input.base,
|
|
7229
|
+
branch,
|
|
7230
|
+
message: `chore(figma): spec for ${figmaBranchTitle(d)}
|
|
7231
|
+
|
|
7232
|
+
Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
|
|
7233
|
+
files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
|
|
7135
7234
|
});
|
|
7235
|
+
const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
|
|
7236
|
+
await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
|
|
7237
|
+
return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
|
|
7136
7238
|
}
|
|
7137
|
-
function
|
|
7138
|
-
|
|
7239
|
+
async function listFigmaChangesOnGitHub(gh, opts = {}) {
|
|
7240
|
+
const pulls = await gh.listPullRequests(opts.state ?? "open");
|
|
7241
|
+
const out = [];
|
|
7242
|
+
for (const p of pulls) {
|
|
7243
|
+
if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
|
|
7244
|
+
const origin = parseFigmaOrigin(p.body);
|
|
7245
|
+
if (!origin) continue;
|
|
7246
|
+
out.push({ number: p.number, title: p.title, url: p.url, branch: p.headRef, origin, status: figmaPushStatus({ state: p.state, draft: p.draft, labels: p.labels, route: origin.route }) });
|
|
7247
|
+
}
|
|
7248
|
+
return out;
|
|
7139
7249
|
}
|
|
7140
|
-
function
|
|
7141
|
-
const
|
|
7142
|
-
const
|
|
7143
|
-
|
|
7250
|
+
async function figmaChangeOnGitHub(gh, n, opts = {}) {
|
|
7251
|
+
const pull = await gh.getPullRequest(n);
|
|
7252
|
+
const origin = parseFigmaOrigin(pull.body);
|
|
7253
|
+
if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
|
|
7254
|
+
const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
|
|
7255
|
+
if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
|
|
7256
|
+
const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
|
|
7257
|
+
const spec = parseFigmaSpec(text);
|
|
7258
|
+
if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
|
|
7259
|
+
const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
|
|
7260
|
+
return { pull, origin, status, spec, brief };
|
|
7261
|
+
}
|
|
7262
|
+
async function markFigmaChangeDraftedOnGitHub(gh, n) {
|
|
7263
|
+
if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
|
|
7264
|
+
const pull = await gh.getPullRequest(n);
|
|
7265
|
+
const origin = parseFigmaOrigin(pull.body);
|
|
7266
|
+
if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
|
|
7267
|
+
if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
|
|
7268
|
+
const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
|
|
7269
|
+
if (!files.length) throw new GitHubError(422, `${pull.headRef} only carries the spec: write the component code there first, push it, then mark it drafted.`);
|
|
7270
|
+
await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
|
|
7271
|
+
await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
|
|
7272
|
+
await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
|
|
7273
|
+
const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
|
|
7274
|
+
return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
|
|
7144
7275
|
}
|
|
7145
7276
|
|
|
7146
7277
|
// ../core/src/node/git-history.ts
|
|
@@ -10424,21 +10555,7 @@ async function branchFacts(ctx, gh, n, viewer) {
|
|
|
10424
10555
|
const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
|
|
10425
10556
|
return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo, solo: who.solo };
|
|
10426
10557
|
}
|
|
10427
|
-
var STATUS_WORDS =
|
|
10428
|
-
merged: "Merged",
|
|
10429
|
-
closed: "Closed",
|
|
10430
|
-
draft: "Draft",
|
|
10431
|
-
"ci-failing": "Blocked by tests",
|
|
10432
|
-
"changes-requested": "Changes requested",
|
|
10433
|
-
"ci-running": "Tests running",
|
|
10434
|
-
"needs-design": "Needs design approval",
|
|
10435
|
-
"needs-tech": "Needs tech approval",
|
|
10436
|
-
"needs-approval": "Needs approval",
|
|
10437
|
-
conflict: "Conflict with main",
|
|
10438
|
-
checking: "Checking",
|
|
10439
|
-
ready: "Ready to merge",
|
|
10440
|
-
unknown: "Status unknown"
|
|
10441
|
-
};
|
|
10558
|
+
var STATUS_WORDS = REVIEW_STATUS_LABEL;
|
|
10442
10559
|
async function branchStatus(ctx, a) {
|
|
10443
10560
|
const n = branchNumber(a.pr);
|
|
10444
10561
|
const gh = await reader(ctx);
|
|
@@ -10454,7 +10571,7 @@ async function branchStatus(ctx, a) {
|
|
|
10454
10571
|
status: { kind: f.status, label: STATUS_WORDS[f.status] },
|
|
10455
10572
|
approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
|
|
10456
10573
|
approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech, ...f.solo && (f.tier ?? 3) > 1 ? { soloOwner: soloOwnerSentence(f.solo, Boolean(viewer && viewer.toLowerCase() === f.solo)) } : {} },
|
|
10457
|
-
tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
|
|
10574
|
+
tests: { state: f.ci.state, label: CI_STATE_LABEL[f.ci.state], failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
|
|
10458
10575
|
main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
|
|
10459
10576
|
merge: {
|
|
10460
10577
|
allowed: f.gate.allowed,
|
|
@@ -10502,6 +10619,7 @@ async function whatNeedsMe(ctx, a = {}) {
|
|
|
10502
10619
|
else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
|
|
10503
10620
|
else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
|
|
10504
10621
|
}
|
|
10622
|
+
if (viewer && !needsYou && f.gate.overridable && !reviewersToAsk({ tier: f.tier, approvals: f.approvals, owners: who.lists, author: p.author, viewer }).length) needsYou = "Only the owner override can merge it";
|
|
10505
10623
|
return { pr: p.number, title: p.title, url: p.url, author: p.author, product: f.product.name, status: f.status, statusLabel: STATUS_WORDS[f.status], yours: mine, needsYou };
|
|
10506
10624
|
})
|
|
10507
10625
|
);
|
|
@@ -10673,6 +10791,7 @@ async function listComments(ctx, a) {
|
|
|
10673
10791
|
howToReply: "bridge_comment with replyTo: the thread id."
|
|
10674
10792
|
};
|
|
10675
10793
|
}
|
|
10794
|
+
var appliedLine = (ns) => ns.length ? ` ${ns.length === 1 ? "Suggestion" : "Suggestions"} ${ns.map((x) => `#${x}`).join(", ")} ${ns.length === 1 ? "is" : "are"} marked applied.` : "";
|
|
10676
10795
|
async function mergeBranch(ctx, a) {
|
|
10677
10796
|
const n = branchNumber(a.pr);
|
|
10678
10797
|
const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
|
|
@@ -10684,7 +10803,8 @@ async function mergeBranch(ctx, a) {
|
|
|
10684
10803
|
if (f.gate.allowed) {
|
|
10685
10804
|
if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
|
|
10686
10805
|
const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
|
|
10687
|
-
|
|
10806
|
+
const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
|
|
10807
|
+
return { pr: n, status: "merged", sha: r.sha, said: `Merged. It goes out with the next library release.${appliedLine(s.applied)}`, ...s.warnings.length ? { warnings: s.warnings } : {} };
|
|
10688
10808
|
}
|
|
10689
10809
|
if (!reason) {
|
|
10690
10810
|
const hint = f.gate.overridable ? ` Only approvals are missing and @${viewer} may use the owner override: pass overrideReason (at least ${policy.minReasonLength} characters), it is posted on the branch.` : "";
|
|
@@ -10698,7 +10818,8 @@ async function mergeBranch(ctx, a) {
|
|
|
10698
10818
|
const method = allowedMergeMethods(f.repo)[0] ?? "squash";
|
|
10699
10819
|
try {
|
|
10700
10820
|
const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
|
|
10701
|
-
|
|
10821
|
+
const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
|
|
10822
|
+
return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.${appliedLine(s.applied)}`, ...s.warnings.length ? { warnings: s.warnings } : {} };
|
|
10702
10823
|
} catch (err) {
|
|
10703
10824
|
if (err instanceof OverrideMergeError) throw new Error(err.message);
|
|
10704
10825
|
throw err;
|
|
@@ -11067,11 +11188,33 @@ async function suggestionGovernance(ctx, gh) {
|
|
|
11067
11188
|
return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {}, ...config.governance.soloOwnerCanApproveBoth ? { soloOwnerCanApproveBoth: true } : {} } };
|
|
11068
11189
|
}
|
|
11069
11190
|
var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
|
|
11191
|
+
var REVIEW_STATUSES = ["needs-review", "changes-requested", "approved"];
|
|
11192
|
+
var MAX_STALENESS_READS = 30;
|
|
11193
|
+
var mainRef = (ctx, config) => ctx.ref ?? baseBranchOf(config);
|
|
11194
|
+
async function stalenessOf(ctx, gh, d, config, gov) {
|
|
11195
|
+
if (d.issue.state !== "open" || !REVIEW_STATUSES.includes(d.status)) return null;
|
|
11196
|
+
return suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
|
|
11197
|
+
}
|
|
11070
11198
|
async function listSuggestionsOp(ctx, a = {}) {
|
|
11071
11199
|
const gh = await reader(ctx);
|
|
11072
11200
|
const status = a.status ?? "all";
|
|
11073
11201
|
const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
|
|
11074
|
-
|
|
11202
|
+
const pulls = await gh.listPullRequests("all").catch(() => []);
|
|
11203
|
+
let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100, pulls });
|
|
11204
|
+
const open = rows.filter((r) => r.state === "open" && REVIEW_STATUSES.includes(r.status)).slice(0, MAX_STALENESS_READS);
|
|
11205
|
+
if (open.length) {
|
|
11206
|
+
const { config, gov } = await suggestionGovernance(ctx, gh);
|
|
11207
|
+
const outdated = /* @__PURE__ */ new Set();
|
|
11208
|
+
await Promise.all(
|
|
11209
|
+
open.map(async (r) => {
|
|
11210
|
+
const d = await getSuggestionOnGitHub(gh, r.number, gov, { pulls }).catch(() => null);
|
|
11211
|
+
if (!d?.parsed.ok) return;
|
|
11212
|
+
const st = await suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
|
|
11213
|
+
if (st?.outdated) outdated.add(r.number);
|
|
11214
|
+
})
|
|
11215
|
+
);
|
|
11216
|
+
rows = rows.map((r) => outdated.has(r.number) ? { ...r, status: stalenessStatus(r.status, { outdated: true }) } : r);
|
|
11217
|
+
}
|
|
11075
11218
|
if (status !== "all") rows = rows.filter((r) => r.status === status);
|
|
11076
11219
|
if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
|
|
11077
11220
|
const total = rows.length;
|
|
@@ -11101,8 +11244,10 @@ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
|
|
|
11101
11244
|
async function getSuggestionOp(ctx, a) {
|
|
11102
11245
|
const n = suggestionNumber(a.number);
|
|
11103
11246
|
const gh = await reader(ctx);
|
|
11104
|
-
const { gov } = await suggestionGovernance(ctx, gh);
|
|
11247
|
+
const { config, gov } = await suggestionGovernance(ctx, gh);
|
|
11105
11248
|
const d = await getSuggestionOnGitHub(gh, n, gov);
|
|
11249
|
+
const stale = d.parsed.ok ? await stalenessOf(ctx, gh, d, config, gov) : null;
|
|
11250
|
+
d.status = stalenessStatus(d.status, stale);
|
|
11106
11251
|
const base = { number: n, url: d.issue.url, title: d.issue.title, state: d.issue.state, status: d.status, said: SUGGESTION_STATUS_LABEL[d.status], author: d.issue.author, labels: d.issue.labels, linkedPrs: d.linkedPrs };
|
|
11107
11252
|
if (!d.parsed.ok) {
|
|
11108
11253
|
if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
|
|
@@ -11143,8 +11288,9 @@ async function getSuggestionOp(ctx, a) {
|
|
|
11143
11288
|
...solo && p.tier.tier > 1 ? { soloOwner: soloOwnerSentence(solo) } : {}
|
|
11144
11289
|
},
|
|
11145
11290
|
applied: d.applied,
|
|
11291
|
+
...stale?.outdated ? { outdated: { mainSha: stale.mainSha, conflicts: stale.conflicts.map((c) => ({ path: c.path, detail: c.detail })) } } : {},
|
|
11146
11292
|
...p.componentSpec ? { brief: p.componentSpec.brief } : {},
|
|
11147
|
-
next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ?
|
|
11293
|
+
next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ? `Outdated: ${stale?.conflicts[0] ? stale.conflicts[0].detail.replace(/\.$/, "") : "main changed what it changes since it was suggested"}. Ask the author to send it again on today's code.` : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
|
|
11148
11294
|
};
|
|
11149
11295
|
}
|
|
11150
11296
|
async function reviewSuggestionOp(ctx, a) {
|
|
@@ -11187,12 +11333,7 @@ async function reviewSuggestionOp(ctx, a) {
|
|
|
11187
11333
|
};
|
|
11188
11334
|
}
|
|
11189
11335
|
function githubWorkspace(gh, ref) {
|
|
11190
|
-
|
|
11191
|
-
return {
|
|
11192
|
-
read: async (path) => await gh.getFile(path, ref) ?? null,
|
|
11193
|
-
readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
|
|
11194
|
-
list: () => files ??= gh.listFiles(ref)
|
|
11195
|
-
};
|
|
11336
|
+
return githubSuggestionWorkspace(gh, ref);
|
|
11196
11337
|
}
|
|
11197
11338
|
var MAX_DIFF = 12e3;
|
|
11198
11339
|
var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
|
|
@@ -11228,6 +11369,14 @@ async function applySuggestionOp(ctx, a) {
|
|
|
11228
11369
|
return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
|
|
11229
11370
|
}
|
|
11230
11371
|
if (d.status === "declined") return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: #${n} was declined. Reopen it on GitHub first if it should go in after all.` };
|
|
11372
|
+
if (d.status === "applied") {
|
|
11373
|
+
const warnings2 = [];
|
|
11374
|
+
if (ctx.canWriteRemote) {
|
|
11375
|
+
const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applied").catch((e) => `The status label on #${n} could not be updated (${e.message}).`);
|
|
11376
|
+
if (w) warnings2.push(w);
|
|
11377
|
+
}
|
|
11378
|
+
return { number: n, status: "already-applied", outcome: "already-applied", exitCode: 0, message: `#${n} is already applied and closed: nothing to write.`, ...warnings2.length ? { warnings: warnings2 } : {} };
|
|
11379
|
+
}
|
|
11231
11380
|
const parsed = d.parsed;
|
|
11232
11381
|
const dryRun = a.dryRun || a.mode === "dry-run";
|
|
11233
11382
|
const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
|
|
@@ -11297,15 +11446,18 @@ async function linkSuggestionOp(ctx, a) {
|
|
|
11297
11446
|
if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
|
|
11298
11447
|
const warnings = [];
|
|
11299
11448
|
let branch = a.branch;
|
|
11449
|
+
let merged = false;
|
|
11300
11450
|
if (a.pr) {
|
|
11301
11451
|
const pr = await gh.getPullRequest(a.pr);
|
|
11302
11452
|
branch ??= pr.headRef;
|
|
11303
|
-
|
|
11453
|
+
merged = pr.state === "merged";
|
|
11454
|
+
if (!merged && !closingIssueNumbers(pr.body).includes(n)) warnings.push(`#${a.pr} does not say "Closes #${n}": add it to the pull request description, so the suggestion closes when it merges.`);
|
|
11304
11455
|
}
|
|
11305
11456
|
const viewer = await viewerOf(ctx, gh);
|
|
11306
|
-
const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced });
|
|
11457
|
+
const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced, merged });
|
|
11307
11458
|
warnings.push(...r.warnings);
|
|
11308
|
-
|
|
11459
|
+
const status = r.status;
|
|
11460
|
+
return { number: n, status, said: SUGGESTION_STATUS_LABEL[status], ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: status === "applied" ? "Applied: nothing left to do." : "It closes when the pull request merges." };
|
|
11309
11461
|
}
|
|
11310
11462
|
async function createSuggestionOp(ctx, a) {
|
|
11311
11463
|
const { config } = await ctx.config();
|
|
@@ -11935,7 +12087,7 @@ async function visualImpact(ctx, a, run2) {
|
|
|
11935
12087
|
if (ctx.mode !== "local" || !ctx.root) throw new Error("Visual impact needs a local checkout (start Bridge with --root): it builds Storybook and the app at both refs.");
|
|
11936
12088
|
if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
|
|
11937
12089
|
const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
|
|
11938
|
-
const runImpact = run2 ?? (await import("./src-
|
|
12090
|
+
const runImpact = run2 ?? (await import("./src-DRJFSLRV.js")).runImpact;
|
|
11939
12091
|
const r = await runImpact({
|
|
11940
12092
|
root: ctx.root,
|
|
11941
12093
|
base: a.base ?? "main",
|
|
@@ -13003,7 +13155,7 @@ async function pushFigmaComponentOp(ctx, a) {
|
|
|
13003
13155
|
}
|
|
13004
13156
|
|
|
13005
13157
|
// src/server.ts
|
|
13006
|
-
var VERSION = true ? "0.8.
|
|
13158
|
+
var VERSION = true ? "0.8.5" : "dev";
|
|
13007
13159
|
var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
|
13008
13160
|
var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
|
|
13009
13161
|
var run = (fn) => async (a) => {
|
|
@@ -13847,7 +13999,7 @@ function createServer(ctx) {
|
|
|
13847
13999
|
"bridge_list_suggestions",
|
|
13848
14000
|
{
|
|
13849
14001
|
title: "List suggestions",
|
|
13850
|
-
description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels; bridge_get_suggestion counts the approvals.",
|
|
14002
|
+
description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels, except that an open one whose change no longer applies to main reads outdated; bridge_get_suggestion counts the approvals.",
|
|
13851
14003
|
inputSchema: {
|
|
13852
14004
|
status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
|
|
13853
14005
|
state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),
|