@bridge-ds/mcp 0.8.2 → 0.8.4

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.
@@ -79,7 +79,7 @@ import {
79
79
  validateLayered,
80
80
  variantKeyOf,
81
81
  whoCanShipSentence
82
- } from "./chunk-DPK46GA2.js";
82
+ } from "./chunk-PMZGEZGX.js";
83
83
 
84
84
  // src/lib.ts
85
85
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -3923,6 +3923,33 @@ function explorationRules(branch) {
3923
3923
  ];
3924
3924
  }
3925
3925
 
3926
+ // ../core/src/titles.ts
3927
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3928
+ function properWord(word, names3) {
3929
+ const bare = word.replace(/[^\p{L}\p{N}]+$/u, "");
3930
+ if (!bare) return false;
3931
+ if (names3.some((n) => n === bare)) return true;
3932
+ return new RegExp("\\p{Lu}", "u").test(bare.slice(1));
3933
+ }
3934
+ function lowerFirstWord(text, names3 = []) {
3935
+ const first = text.split(/\s+/)[0] ?? "";
3936
+ if (!first || properWord(first, names3)) return text;
3937
+ return `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
3938
+ }
3939
+ function subjectTitle(subject, title, names3 = []) {
3940
+ const t = title.trim().replace(/\.$/, "");
3941
+ const keep = [subject, ...names3];
3942
+ const s = subject.trim();
3943
+ if (!s) return t;
3944
+ const m = new RegExp(`^${escape(s)}(?=$|[^\\p{L}\\p{N}])\\s*(:)?\\s*`, "iu").exec(t);
3945
+ if (m) {
3946
+ const rest = t.slice(m[0].length);
3947
+ if (!rest) return s;
3948
+ return m[1] ? `${s}: ${lowerFirstWord(rest, keep)}` : `${s} ${rest}`;
3949
+ }
3950
+ return `${s}: ${lowerFirstWord(t, keep)}`;
3951
+ }
3952
+
3926
3953
  // ../core/src/requests.ts
3927
3954
  var REQUEST_KINDS = [
3928
3955
  { value: "new-component", label: "New component", hint: "A component the design system does not have yet." },
@@ -3939,8 +3966,7 @@ var MARK2 = "bridge-request";
3939
3966
  var kindLabel = (k) => REQUEST_KINDS.find((x) => x.value === k)?.label ?? "Other";
3940
3967
  var kindSlug = (k) => `request: ${k}`;
3941
3968
  function requestIssueTitle(r) {
3942
- const t = r.title.trim().replace(/\.$/, "");
3943
- return `${r.component}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
3969
+ return subjectTitle(r.component, r.title);
3944
3970
  }
3945
3971
  function composeRequestIssue(r, opts = {}) {
3946
3972
  const rows = r.changes.length ? ["| What | Now | Requested |", "| --- | --- | --- |", ...r.changes.map((c) => `| ${cell2(c.field)} | ${cell2(c.before ?? "")} | ${cell2(c.after ?? "")} |`)] : [];
@@ -4549,15 +4575,36 @@ function reviewStatusKind(input) {
4549
4575
  if (input.mergeable === false) return out("conflict");
4550
4576
  if (input.ci?.state === "pending") return out("ci-running");
4551
4577
  if (!input.ci || !input.approvals) return out("unknown");
4552
- if (input.mergeable === null) return out("checking");
4553
4578
  const tier = input.tier ?? 3;
4554
4579
  if (tier >= 2) {
4555
4580
  if (!input.approvals.design) return out("needs-design");
4556
4581
  if (!input.approvals.tech) return out("needs-tech");
4557
4582
  } else if (!input.approvals.approvers.length) return out("needs-approval");
4583
+ if (input.mergeable === null) return out("checking");
4558
4584
  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
4585
  return gate.allowed ? out("ready") : out("unknown");
4560
4586
  }
4587
+ var REVIEW_STATUS_LABEL = {
4588
+ merged: "Merged",
4589
+ closed: "Closed",
4590
+ draft: "Draft",
4591
+ "ci-failing": "Blocked by tests",
4592
+ "changes-requested": "Changes requested",
4593
+ "ci-running": "Tests running",
4594
+ "needs-design": "Needs design approval",
4595
+ "needs-tech": "Needs tech approval",
4596
+ "needs-approval": "Needs approval",
4597
+ conflict: "Conflict with main",
4598
+ checking: "Checking for conflicts",
4599
+ ready: "Ready to merge",
4600
+ unknown: "Status unknown"
4601
+ };
4602
+ var CI_STATE_LABEL = {
4603
+ pass: "Tests pass",
4604
+ fail: "Tests fail",
4605
+ pending: "Tests running",
4606
+ none: "No automated checks"
4607
+ };
4561
4608
  var BOTS = /* @__PURE__ */ new Set(["github-actions[bot]", "app/github-actions", "changeset-bot[bot]"]);
4562
4609
  var isReleasePull = (p) => p.head.ref.startsWith("changeset-release/");
4563
4610
  function isAutomationPull(p) {
@@ -5400,6 +5447,10 @@ var SUGGESTION_STATE_LABELS = {
5400
5447
  approved: "suggestion: approved",
5401
5448
  applying: "suggestion: applying"
5402
5449
  };
5450
+ var SUGGESTION_FINAL_LABELS = {
5451
+ applied: "suggestion: applied",
5452
+ declined: "suggestion: declined"
5453
+ };
5403
5454
  var SUGGESTION_STATUS_LABEL = {
5404
5455
  "needs-review": "Needs review",
5405
5456
  "changes-requested": "Changes requested",
@@ -5620,8 +5671,8 @@ function suggestionSubject(p) {
5620
5671
  return KIND_SUBJECT[p.kind];
5621
5672
  }
5622
5673
  function suggestionIssueTitle(p) {
5623
- const t = p.title.trim().replace(/\.$/, "");
5624
- return `${suggestionSubject(p)}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
5674
+ const names3 = [...(p.docs ?? []).map((d) => d.component), ...p.componentSpec?.component ? [p.componentSpec.component] : []];
5675
+ return subjectTitle(suggestionSubject(p), p.title, names3);
5625
5676
  }
5626
5677
  function suggestionLabels(p, opts = {}) {
5627
5678
  return [
@@ -5655,9 +5706,22 @@ function suggestionStatus(i) {
5655
5706
  if (i.outdated) return "outdated";
5656
5707
  return i.review ?? "needs-review";
5657
5708
  }
5709
+ function withOutdated(status, outdated) {
5710
+ return outdated && (status === "needs-review" || status === "changes-requested" || status === "approved") ? "outdated" : status;
5711
+ }
5658
5712
  function suggestionStateLabel(status) {
5713
+ if (status === "applied" || status === "declined") return SUGGESTION_FINAL_LABELS[status];
5659
5714
  return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
5660
5715
  }
5716
+ var SUGGESTION_STATUS_LABELS = [...Object.values(SUGGESTION_STATE_LABELS), ...Object.values(SUGGESTION_FINAL_LABELS)];
5717
+ function suggestionLabelChanges(labels, status) {
5718
+ if (status === "outdated") return { add: [], remove: [] };
5719
+ const want = suggestionStateLabel(status);
5720
+ return {
5721
+ add: want && !labels.includes(want) ? [want] : [],
5722
+ remove: SUGGESTION_STATUS_LABELS.filter((l) => l !== want && labels.includes(l))
5723
+ };
5724
+ }
5661
5725
  var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
5662
5726
  var MARK3 = "bridge-suggestion";
5663
5727
  var PART_SCHEMA = "bridge.suggestion-part/1";
@@ -6044,97 +6108,422 @@ function suggestionPathProblems(p, config) {
6044
6108
  return [...new Set(out)];
6045
6109
  }
6046
6110
 
6047
- // ../core/src/suggestions-github.ts
6048
- async function createSuggestionOnGitHub(gh, payload, opts = {}) {
6049
- const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
6050
- const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
6051
- for (const [i, part] of issue.parts.entries()) {
6052
- try {
6053
- await gh.createIssueComment(created.number, part);
6054
- } catch (err) {
6055
- const why = err instanceof Error ? err.message : String(err);
6056
- 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}.`);
6057
- }
6058
- }
6059
- const warnings = [];
6060
- const missing = issue.labels.filter((l) => !created.labels.includes(l));
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 };
6111
+ // ../core/src/suggestion-apply.ts
6112
+ var SUGGESTION_APPLY_EXIT = {
6113
+ ready: 0,
6114
+ "already-applied": 0,
6115
+ conflict: 3,
6116
+ "not-approved": 1,
6117
+ blocked: 1,
6118
+ refused: 1
6119
+ };
6120
+ var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6121
+ var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6122
+ function tokenRoot(config) {
6123
+ const r = config.tokens.root.replace(/\/+$/, "");
6124
+ return r === "." ? "" : r;
6063
6125
  }
6064
- function linkedPullsOf(n, pulls) {
6065
- 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" }));
6126
+ function tokenFileOf(config, path) {
6127
+ const root = tokenRoot(config);
6128
+ if (root && !path.startsWith(`${root}/`)) return null;
6129
+ if (!selectTokenFiles(config, [path]).length) return null;
6130
+ return root ? path.slice(root.length + 1) : path;
6066
6131
  }
6067
- async function listSuggestionsOnGitHub(gh, opts = {}) {
6068
- const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
6069
- const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
6070
- return issues.map((i) => listItem(i, pulls));
6132
+ function planTokenEdit(set, e, changes) {
6133
+ const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6134
+ const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6135
+ const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6136
+ const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6137
+ const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6138
+ let result;
6139
+ try {
6140
+ result = applyTokenEdits(set, [e]);
6141
+ } catch (err) {
6142
+ const why = err instanceof Error ? err.message : String(err);
6143
+ switch (e.op) {
6144
+ case "rename": {
6145
+ const hasOld = defsAt(e.path).length > 0;
6146
+ const hasNew = set.defs.some((d) => d.path === e.newPath);
6147
+ if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6148
+ return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6149
+ }
6150
+ case "remove":
6151
+ if (!defsAt(e.path).length) return done("Already removed.");
6152
+ return conflict(why);
6153
+ case "add": {
6154
+ const existing = defsAt(e.path);
6155
+ if (!existing.length) return conflict(why);
6156
+ const want = (file) => {
6157
+ const c = recorded(file);
6158
+ if (c && c.after !== void 0) return c.after;
6159
+ return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6160
+ };
6161
+ const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6162
+ if (all) return done("Already added with the suggested value.");
6163
+ const d = existing.find((x) => !same2(x.value, want(x.file)));
6164
+ return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6165
+ }
6166
+ default:
6167
+ return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6168
+ }
6169
+ }
6170
+ if (!result.touched.length) return done("Already has the suggested value.");
6171
+ if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6172
+ for (const d of defsAt(e.path)) {
6173
+ const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6174
+ if (!c) continue;
6175
+ const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6176
+ 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 });
6177
+ }
6178
+ }
6179
+ const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6180
+ return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6071
6181
  }
6072
- function listItem(i, pulls) {
6073
- const linkedPrs = linkedPullsOf(i.number, pulls);
6074
- const cached = suggestionStateFromLabels(i.labels);
6075
- const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
6076
- return {
6077
- number: i.number,
6078
- title: i.title,
6079
- url: i.url,
6080
- author: i.author,
6081
- createdAt: i.createdAt,
6082
- state: i.state,
6083
- stateReason: i.stateReason,
6084
- labels: i.labels,
6085
- kind: suggestionKindFromLabels(i.labels),
6086
- tier: t ? Number(t) : void 0,
6087
- linkedPrs,
6088
- status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
6089
- };
6182
+ async function planFile(ws, baseSha, f) {
6183
+ const base = { type: "file", path: f.path };
6184
+ const cur = await ws.read(f.path);
6185
+ const curSha = cur?.sha ?? null;
6186
+ const theirs = f.afterSha === null ? null : f.content ?? null;
6187
+ if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6188
+ 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 };
6189
+ if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6190
+ if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6191
+ if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6192
+ const original = await ws.readAt(baseSha, f.path);
6193
+ if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6194
+ 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." } };
6195
+ }
6196
+ const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6197
+ if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6198
+ if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6199
+ return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6090
6200
  }
6091
- async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
6092
- const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
6093
- const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
6094
- const linkedPrs = linkedPullsOf(n, pulls);
6095
- const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
6096
- const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
6097
- const status = suggestionStatus({
6098
- state: issue.state,
6099
- stateReason: issue.stateReason,
6100
- linkedPrs,
6101
- applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
6102
- outdated: opts.outdated,
6103
- review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
6201
+ async function planSuggestionApply(input) {
6202
+ const { parsed, config, workspace: ws } = input;
6203
+ const payload = parsed.payload;
6204
+ const notes = [];
6205
+ const out = (outcome, message, rest2 = {}) => ({
6206
+ outcome,
6207
+ exitCode: SUGGESTION_APPLY_EXIT[outcome],
6208
+ message,
6209
+ payloadSha: parsed.sha,
6210
+ payload,
6211
+ gate: null,
6212
+ ops: [],
6213
+ writes: [],
6214
+ checks: null,
6215
+ problems: [],
6216
+ notes,
6217
+ ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6218
+ ...rest2
6104
6219
  });
6105
- return { issue, parsed, comments, approvals, linkedPrs, applied, status };
6106
- }
6107
- async function syncSuggestionStateLabel(gh, n, labels, status) {
6108
- const want = suggestionStateLabel(status);
6109
- let ok2 = true;
6110
- for (const l of Object.values(SUGGESTION_STATE_LABELS)) {
6111
- if (l === want || !labels.includes(l)) continue;
6112
- try {
6113
- await gh.removeLabel(n, l);
6114
- } catch {
6115
- ok2 = false;
6220
+ const problems = suggestionPathProblems(payload, config);
6221
+ 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.`);
6222
+ for (const f of payload.files ?? []) {
6223
+ 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.`);
6224
+ 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.`);
6225
+ }
6226
+ if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6227
+ if (parsed.edited && !input.acceptEdited) {
6228
+ 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.', {
6229
+ problems: ["The payload no longer matches the sha Bridge recorded."]
6230
+ });
6231
+ }
6232
+ const root = tokenRoot(config);
6233
+ const tokenFiles = selectTokenFiles(config, await ws.list());
6234
+ const targets = [
6235
+ ...(payload.files ?? []).map((f) => f.path),
6236
+ ...(payload.docs ?? []).map((d) => d.path),
6237
+ ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
6238
+ ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
6239
+ ];
6240
+ if (ws.isSymlink) {
6241
+ const links = [];
6242
+ 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.`);
6243
+ if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6244
+ }
6245
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
6246
+ const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6247
+ const review = suggestionReviewState(payload.tier.tier, approvals);
6248
+ const missing = pendingApprovals(payload.tier.tier, approvals);
6249
+ const approved = review === "approved";
6250
+ const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
6251
+ const ops = [];
6252
+ const writes = /* @__PURE__ */ new Map();
6253
+ const current = /* @__PURE__ */ new Map();
6254
+ for (const f of tokenFiles) {
6255
+ const r = await ws.read(root ? `${root}/${f}` : f);
6256
+ if (r) current.set(f, r.text);
6257
+ }
6258
+ const before = new LayeredTokenSet(config, current);
6259
+ let set = before;
6260
+ const appliedEdits = [];
6261
+ for (const e of payload.tokens?.edits ?? []) {
6262
+ const r = planTokenEdit(set, e, payload.tokens.changes);
6263
+ ops.push(r.op);
6264
+ notes.push(...r.notes);
6265
+ if (r.op.state === "apply" && r.files) {
6266
+ set = new LayeredTokenSet(config, r.files);
6267
+ appliedEdits.push(e);
6116
6268
  }
6117
6269
  }
6118
- if (want && !labels.includes(want)) ok2 = await gh.addLabels(n, [want]) && ok2;
6119
- return ok2 ? null : `The state label on #${n} could not be updated; the status shown in Bridge is still right.`;
6120
- }
6121
- async function reviewSuggestionOnGitHub(gh, n, input, gov) {
6122
- 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.");
6123
- const before = await getSuggestionOnGitHub(gh, n, gov);
6124
- if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
6125
- if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
6126
- const sha = before.parsed.sha;
6127
- if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
6128
- const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
6129
- 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 });
6130
- const posted = await gh.createIssueComment(n, body);
6131
- const after = await getSuggestionOnGitHub(gh, n, gov);
6132
- const status = after.status;
6133
- const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
6134
- const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
6135
- const warnings = [];
6136
- if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
6137
- if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
6270
+ for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
6271
+ for (const f of payload.files ?? []) {
6272
+ if (writes.has(f.path)) {
6273
+ notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
6274
+ continue;
6275
+ }
6276
+ const r = await planFile(ws, payload.base.sha, f);
6277
+ ops.push(r.op);
6278
+ if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
6279
+ }
6280
+ for (const d of payload.docs ?? []) {
6281
+ const base = { type: "docs", path: d.path };
6282
+ const docs = d.after;
6283
+ let text;
6284
+ try {
6285
+ const issues = validateDocs(docs);
6286
+ if (issues.length) {
6287
+ problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
6288
+ continue;
6289
+ }
6290
+ const cur = await ws.read(d.path);
6291
+ text = docsFileText(docs, cur?.text ?? null);
6292
+ if (cur && text === cur.text) {
6293
+ ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
6294
+ continue;
6295
+ }
6296
+ ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
6297
+ writes.set(d.path, text);
6298
+ } catch (err) {
6299
+ problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
6300
+ }
6301
+ }
6302
+ if (payload.componentSpec) {
6303
+ const s = payload.componentSpec;
6304
+ const text = `${JSON.stringify(s.spec, null, 2)}
6305
+ `;
6306
+ const cur = await ws.read(s.specPath);
6307
+ if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
6308
+ else {
6309
+ ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
6310
+ writes.set(s.specPath, text);
6311
+ }
6312
+ }
6313
+ const afterFiles = new Map(set.files);
6314
+ for (const [p, content] of writes) {
6315
+ const tf = tokenFileOf(config, p);
6316
+ if (!tf) continue;
6317
+ if (content === null) afterFiles.delete(tf);
6318
+ else afterFiles.set(tf, content);
6319
+ }
6320
+ const after = new LayeredTokenSet(config, afterFiles);
6321
+ const tokenChanges = diffLayered(before, after);
6322
+ const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6323
+ const prevErrors = new Set(validateLayered(before).map(issueKey4));
6324
+ const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
6325
+ let pairs = [];
6326
+ if (config.contrast && tokenChanges.length) {
6327
+ try {
6328
+ pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
6329
+ } catch {
6330
+ notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
6331
+ }
6332
+ }
6333
+ const modes = config.contrast?.modes;
6334
+ const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
6335
+ const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6336
+ const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
6337
+ 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 });
6338
+ const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
6339
+ const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
6340
+ const rest = { gate, ops, checks, problems };
6341
+ if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
6342
+ const conflicts = ops.filter((o) => o.state === "conflict");
6343
+ if (conflicts.length) {
6344
+ 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);
6345
+ }
6346
+ if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
6347
+ if (newErrors.length || newFailures.length) {
6348
+ const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
6349
+ return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
6350
+ }
6351
+ if (tierIncreased && !input.forceUnapproved) {
6352
+ 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);
6353
+ }
6354
+ if (!approved && !input.forceUnapproved) {
6355
+ 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 ")}`;
6356
+ return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
6357
+ }
6358
+ const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
6359
+ return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
6360
+ ...rest,
6361
+ gate: { ...gate, forced },
6362
+ writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
6363
+ });
6364
+ }
6365
+ function suggestionAppliedComment(plan, where2) {
6366
+ return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
6367
+ }
6368
+ function suggestionApplyTable(plan) {
6369
+ const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
6370
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
6371
+ return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
6372
+ }
6373
+
6374
+ // ../core/src/suggestions-github.ts
6375
+ async function createSuggestionOnGitHub(gh, payload, opts = {}) {
6376
+ const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
6377
+ const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
6378
+ for (const [i, part] of issue.parts.entries()) {
6379
+ try {
6380
+ await gh.createIssueComment(created.number, part);
6381
+ } catch (err) {
6382
+ const why = err instanceof Error ? err.message : String(err);
6383
+ 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}.`);
6384
+ }
6385
+ }
6386
+ const warnings = [];
6387
+ const missing = issue.labels.filter((l) => !created.labels.includes(l));
6388
+ if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
6389
+ return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
6390
+ }
6391
+ function linkedPullsOf(n, pulls) {
6392
+ 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" }));
6393
+ }
6394
+ async function listSuggestionsOnGitHub(gh, opts = {}) {
6395
+ const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
6396
+ const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
6397
+ return issues.map((i) => listItem(i, pulls));
6398
+ }
6399
+ function listItem(i, pulls) {
6400
+ const linkedPrs = linkedPullsOf(i.number, pulls);
6401
+ const cached = suggestionStateFromLabels(i.labels);
6402
+ const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
6403
+ return {
6404
+ number: i.number,
6405
+ title: i.title,
6406
+ url: i.url,
6407
+ author: i.author,
6408
+ createdAt: i.createdAt,
6409
+ state: i.state,
6410
+ stateReason: i.stateReason,
6411
+ labels: i.labels,
6412
+ kind: suggestionKindFromLabels(i.labels),
6413
+ tier: t ? Number(t) : void 0,
6414
+ linkedPrs,
6415
+ status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
6416
+ };
6417
+ }
6418
+ async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
6419
+ const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
6420
+ const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
6421
+ const linkedPrs = linkedPullsOf(n, pulls);
6422
+ const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
6423
+ const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
6424
+ const status = suggestionStatus({
6425
+ state: issue.state,
6426
+ stateReason: issue.stateReason,
6427
+ linkedPrs,
6428
+ applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
6429
+ outdated: opts.outdated,
6430
+ review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
6431
+ });
6432
+ return { issue, parsed, comments, approvals, linkedPrs, applied, status };
6433
+ }
6434
+ async function syncSuggestionStateLabel(gh, n, labels, status) {
6435
+ const { add, remove } = suggestionLabelChanges(labels, status);
6436
+ let ok2 = true;
6437
+ for (const l of remove) {
6438
+ try {
6439
+ await gh.removeLabel(n, l);
6440
+ } catch {
6441
+ ok2 = false;
6442
+ }
6443
+ }
6444
+ if (add.length) ok2 = await gh.addLabels(n, add) && ok2;
6445
+ return ok2 ? null : `The status label on #${n} could not be updated; the status shown in Bridge is still right.`;
6446
+ }
6447
+ async function markSuggestionsAppliedOnGitHub(gh, pr) {
6448
+ const applied = [];
6449
+ const warnings = [];
6450
+ for (const n of closingIssueNumbers(pr.body)) {
6451
+ try {
6452
+ const issue = await gh.getIssue(n);
6453
+ if (!issue.labels.includes(SUGGESTION_LABEL)) continue;
6454
+ const w = await syncSuggestionStateLabel(gh, n, issue.labels, "applied");
6455
+ if (w) warnings.push(w);
6456
+ else applied.push(n);
6457
+ } catch (err) {
6458
+ warnings.push(`Suggestion #${n} could not be marked applied (${err instanceof Error ? err.message : String(err)}).`);
6459
+ }
6460
+ }
6461
+ return { applied, warnings };
6462
+ }
6463
+ function githubSuggestionWorkspace(gh, ref) {
6464
+ let files;
6465
+ return {
6466
+ read: async (path) => await gh.getFile(path, ref) ?? null,
6467
+ readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
6468
+ list: () => files ??= gh.listFiles(ref)
6469
+ };
6470
+ }
6471
+ function suggestionTouches(payload, config, path) {
6472
+ if (path === CONFIG_FILE) return true;
6473
+ if ((payload.files ?? []).some((f) => f.path === path)) return true;
6474
+ if ((payload.docs ?? []).some((d) => d.path === path)) return true;
6475
+ if (payload.componentSpec?.specPath === path) return true;
6476
+ return Boolean(payload.tokens?.edits.length) && selectTokenFiles(config, [path]).length > 0;
6477
+ }
6478
+ var MAX_COMPARE_FILES = 300;
6479
+ async function suggestionStalenessOnGitHub(gh, d, input) {
6480
+ const none = (mainSha2, mainMoved) => ({ mainSha: mainSha2, mainMoved, outdated: false, conflicts: [], plan: null });
6481
+ if (!d.parsed.ok || d.issue.state !== "open") return none(null, false);
6482
+ const payload = d.parsed.payload;
6483
+ const ref = input.ref ?? (payload.base.ref || input.config.repo?.defaultBranch || "main");
6484
+ const mainSha = await gh.getBranchSha(ref).catch(() => void 0) ?? null;
6485
+ if (!mainSha) return none(null, false);
6486
+ if (mainSha === payload.base.sha) return none(mainSha, false);
6487
+ const changed = await gh.compare(payload.base.sha, mainSha).catch(() => null);
6488
+ if (changed && changed.files.length < MAX_COMPARE_FILES && !changed.files.some((f) => suggestionTouches(payload, input.config, f.filename))) return none(mainSha, true);
6489
+ try {
6490
+ const plan = await planSuggestionApply({
6491
+ parsed: d.parsed,
6492
+ config: input.config,
6493
+ comments: d.comments,
6494
+ governance: input.governance,
6495
+ author: d.issue.author || null,
6496
+ // Only whether it still applies: approvals and a hand edit are reported apart.
6497
+ forceUnapproved: true,
6498
+ acceptEdited: true,
6499
+ workspace: githubSuggestionWorkspace(gh, mainSha)
6500
+ });
6501
+ const conflicts = plan.ops.filter((o) => o.state === "conflict");
6502
+ return { mainSha, mainMoved: true, outdated: conflicts.length > 0, conflicts, plan };
6503
+ } catch {
6504
+ return none(mainSha, true);
6505
+ }
6506
+ }
6507
+ function stalenessStatus(status, staleness) {
6508
+ return withOutdated(status, staleness?.outdated);
6509
+ }
6510
+ async function reviewSuggestionOnGitHub(gh, n, input, gov) {
6511
+ 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.");
6512
+ const before = await getSuggestionOnGitHub(gh, n, gov);
6513
+ if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
6514
+ if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
6515
+ const sha = before.parsed.sha;
6516
+ if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
6517
+ const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
6518
+ 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 });
6519
+ const posted = await gh.createIssueComment(n, body);
6520
+ const after = await getSuggestionOnGitHub(gh, n, gov);
6521
+ const status = after.status;
6522
+ const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
6523
+ const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
6524
+ const warnings = [];
6525
+ if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
6526
+ if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
6138
6527
  const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
6139
6528
  if (labelWarning) warnings.push(labelWarning);
6140
6529
  return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
@@ -6143,8 +6532,12 @@ async function linkSuggestionPrOnGitHub(gh, n, link) {
6143
6532
  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
6533
  const issue = await gh.getIssue(n);
6145
6534
  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 warning = await syncSuggestionStateLabel(gh, n, issue.labels, "applying");
6147
- return { warnings: warning ? [warning] : [] };
6535
+ const status = appliedStatus(issue, link.merged);
6536
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, status);
6537
+ return { warnings: warning ? [warning] : [], status };
6538
+ }
6539
+ function appliedStatus(issue, merged) {
6540
+ return merged || issue.state === "closed" && issue.stateReason === "completed" ? "applied" : "applying";
6148
6541
  }
6149
6542
  async function declineSuggestionOnGitHub(gh, n, reason, by) {
6150
6543
  const issue = await gh.getIssue(n);
@@ -6675,472 +7068,209 @@ async function switchToPublishedOnGitHub(gh, pr) {
6675
7068
  return { status: "switched", said: `Your branch now uses the published version from ${pull.baseRef}.` };
6676
7069
  }
6677
7070
 
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
- }
7071
+ // ../core/src/token-proposal.ts
7072
+ var issueKey2 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
7073
+ function planTokenProposal(input) {
7074
+ const { before, config } = input;
7075
+ const r = applyTokenEdits(before, input.edits);
7076
+ const after = new LayeredTokenSet(before.config, r.files);
7077
+ const changes = diffLayered(before, after);
7078
+ if (!changes.length) return null;
7079
+ const prevErrors = new Set(validateLayered(before).map(issueKey2));
7080
+ const newErrors = validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
7105
7081
  const modes = config.contrast?.modes;
7106
- const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
7082
+ const contrast = input.pairs.length ? contrastDelta(runLayeredContrast(before, input.pairs, modes), runLayeredContrast(after, input.pairs, modes)) : [];
7107
7083
  const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
7108
- const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
7109
- 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 });
7110
- const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
7111
- const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
7112
- const rest = { gate, ops, checks, problems };
7113
- if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
7114
- const conflicts = ops.filter((o) => o.state === "conflict");
7115
- if (conflicts.length) {
7116
- 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);
7117
- }
7118
- if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
7119
- if (newErrors.length || newFailures.length) {
7120
- const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
7121
- return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
7122
- }
7123
- if (tierIncreased && !input.forceUnapproved) {
7124
- 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);
7084
+ const impact = layeredImpact(before, after, changes);
7085
+ const tier = classifyLayered(changes, [], config);
7086
+ const summary2 = input.summary?.trim() || input.title;
7087
+ const body = [composeProposalBody({ summary: summary2, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: input.tool ?? "Bridge MCP" }), ...input.bodyExtra ?? []].filter(Boolean).join("\n\n");
7088
+ const slug2 = slugify(input.title, 48) || "change";
7089
+ const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slug2}.md`;
7090
+ const touched = r.touched.map((f) => after.repoPath(f));
7091
+ const files = [
7092
+ ...r.touched.map((f) => ({ path: after.repoPath(f), content: after.files.get(f) })),
7093
+ ...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
7094
+ ];
7095
+ 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;
7096
+ 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 };
7097
+ }
7098
+ async function writeTokenProposalOnGitHub(gh, plan, opts) {
7099
+ if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
7100
+ const jira = opts.jira ?? null;
7101
+ let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
7102
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
7103
+ const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
7104
+
7105
+ Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
7106
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
7107
+ const draft = plan.tier.tier >= 2;
7108
+ const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
7109
+ await gh.addLabels(pr.number, labels);
7110
+ return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
7111
+ }
7112
+ var tokenLabels = (plan, before = [], after = []) => [
7113
+ .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
7114
+ ];
7115
+ function tokenSuggestionPayload(plan, ctx) {
7116
+ const tokenFiles = new Set(plan.touched);
7117
+ 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 }));
7118
+ return {
7119
+ schema: SUGGESTION_SCHEMA,
7120
+ kind: "token-change",
7121
+ title: plan.title,
7122
+ summary: plan.summary,
7123
+ source: ctx.source,
7124
+ ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
7125
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7126
+ base: ctx.base,
7127
+ tier: { tier: plan.tier.tier, bump: plan.tier.bump },
7128
+ tokens: {
7129
+ edits: plan.edits,
7130
+ 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 }))
7131
+ },
7132
+ ...files.length ? { files } : {},
7133
+ checks: {
7134
+ newErrors: plan.newErrors.length,
7135
+ 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 })),
7136
+ blocked: plan.blocked,
7137
+ impact: plan.impact.total
7138
+ },
7139
+ ...ctx.links ? { links: ctx.links } : {},
7140
+ ...ctx.previews?.length ? { previews: ctx.previews } : {}
7141
+ };
7142
+ }
7143
+ async function suggestTokenProposalOnGitHub(gh, plan, opts) {
7144
+ if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
7145
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
7146
+ if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
7147
+ const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
7148
+ const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
7149
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
7150
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
7151
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
7152
+ }
7153
+
7154
+ // ../core/src/figma-push-github.ts
7155
+ var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
7156
+ var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
7157
+ function componentSpecSuggestionPayload(d, plan, ctx) {
7158
+ const specPath = figmaSpecPath(d);
7159
+ const spec = JSON.parse(formatFigmaSpec(d, plan));
7160
+ const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
7161
+ const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
7162
+ 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;
7163
+ const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
7164
+ return {
7165
+ schema: SUGGESTION_SCHEMA,
7166
+ kind: "component-spec",
7167
+ title: d.title,
7168
+ summary: d.summary.trim() || d.title,
7169
+ source: pushSource(d),
7170
+ ...by ? { suggestedBy: by } : {},
7171
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7172
+ base: ctx.base,
7173
+ tier: specTier(d),
7174
+ componentSpec: { component: d.component, specPath, spec, brief },
7175
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
7176
+ ...figma ? { links: { figma } } : {},
7177
+ ...previews.length ? { previews } : {}
7178
+ };
7179
+ }
7180
+ function figmaNodes(d) {
7181
+ const f = d.figma;
7182
+ if (!f) return {};
7183
+ const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
7184
+ const nodes = [];
7185
+ 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 } : {} });
7186
+ 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 } : {} });
7187
+ return nodes.length ? { nodes } : {};
7188
+ }
7189
+ async function openComponentPushOnGitHub(gh, input) {
7190
+ const d = input.diff;
7191
+ if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
7192
+ const plan = input.plan ?? planComponentPush(d, input.tokens);
7193
+ if (plan.route === "manual") return { status: "not-sent", plan, link: null };
7194
+ if (input.dryRun) return { status: "dry-run", plan, link: null };
7195
+ const mode = input.mode ?? "direct";
7196
+ 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).");
7197
+ const origin = {
7198
+ component: d.component,
7199
+ figmaName: d.figmaName,
7200
+ route: plan.route,
7201
+ kind: d.kind,
7202
+ ...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
7203
+ ...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
7204
+ ...d.figma?.url ? { url: d.figma.url } : {},
7205
+ ...d.requestedBy ? { requestedBy: d.requestedBy } : {}
7206
+ };
7207
+ if (plan.route === "token-change") {
7208
+ 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`).");
7209
+ const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
7210
+ const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
7211
+ await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
7212
+ if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
7213
+ return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
7125
7214
  }
7126
- if (!approved && !input.forceUnapproved) {
7127
- 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 ")}`;
7128
- return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
7215
+ if (mode === "suggest") {
7216
+ const sha = input.baseSha ?? await gh.getBranchSha(input.base);
7217
+ if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
7218
+ const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
7219
+ const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
7220
+ return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
7129
7221
  }
7130
- const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
7131
- return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
7132
- ...rest,
7133
- gate: { ...gate, forced },
7134
- writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
7222
+ const spec = figmaSpecPath(d);
7223
+ const o = { ...origin, spec, status: "spec" };
7224
+ let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
7225
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
7226
+ await gh.commitToNewBranch({
7227
+ base: input.base,
7228
+ branch,
7229
+ message: `chore(figma): spec for ${figmaBranchTitle(d)}
7230
+
7231
+ Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
7232
+ files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
7135
7233
  });
7234
+ const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
7235
+ await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
7236
+ return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
7136
7237
  }
7137
- function suggestionAppliedComment(plan, where2) {
7138
- return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
7238
+ async function listFigmaChangesOnGitHub(gh, opts = {}) {
7239
+ const pulls = await gh.listPullRequests(opts.state ?? "open");
7240
+ const out = [];
7241
+ for (const p of pulls) {
7242
+ if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
7243
+ const origin = parseFigmaOrigin(p.body);
7244
+ if (!origin) continue;
7245
+ 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 }) });
7246
+ }
7247
+ return out;
7139
7248
  }
7140
- function suggestionApplyTable(plan) {
7141
- const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
7142
- const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
7143
- return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
7249
+ async function figmaChangeOnGitHub(gh, n, opts = {}) {
7250
+ const pull = await gh.getPullRequest(n);
7251
+ const origin = parseFigmaOrigin(pull.body);
7252
+ if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
7253
+ const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
7254
+ if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
7255
+ const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
7256
+ const spec = parseFigmaSpec(text);
7257
+ if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
7258
+ const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
7259
+ return { pull, origin, status, spec, brief };
7260
+ }
7261
+ async function markFigmaChangeDraftedOnGitHub(gh, n) {
7262
+ if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
7263
+ const pull = await gh.getPullRequest(n);
7264
+ const origin = parseFigmaOrigin(pull.body);
7265
+ if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
7266
+ if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
7267
+ const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
7268
+ 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.`);
7269
+ await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
7270
+ await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
7271
+ await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
7272
+ const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
7273
+ return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
7144
7274
  }
7145
7275
 
7146
7276
  // ../core/src/node/git-history.ts
@@ -10424,21 +10554,7 @@ async function branchFacts(ctx, gh, n, viewer) {
10424
10554
  const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
10425
10555
  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
10556
  }
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
- };
10557
+ var STATUS_WORDS = REVIEW_STATUS_LABEL;
10442
10558
  async function branchStatus(ctx, a) {
10443
10559
  const n = branchNumber(a.pr);
10444
10560
  const gh = await reader(ctx);
@@ -10454,7 +10570,7 @@ async function branchStatus(ctx, a) {
10454
10570
  status: { kind: f.status, label: STATUS_WORDS[f.status] },
10455
10571
  approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
10456
10572
  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) },
10573
+ 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
10574
  main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
10459
10575
  merge: {
10460
10576
  allowed: f.gate.allowed,
@@ -10502,6 +10618,7 @@ async function whatNeedsMe(ctx, a = {}) {
10502
10618
  else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
10503
10619
  else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
10504
10620
  }
10621
+ 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
10622
  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
10623
  })
10507
10624
  );
@@ -10673,6 +10790,7 @@ async function listComments(ctx, a) {
10673
10790
  howToReply: "bridge_comment with replyTo: the thread id."
10674
10791
  };
10675
10792
  }
10793
+ var appliedLine = (ns) => ns.length ? ` ${ns.length === 1 ? "Suggestion" : "Suggestions"} ${ns.map((x) => `#${x}`).join(", ")} ${ns.length === 1 ? "is" : "are"} marked applied.` : "";
10676
10794
  async function mergeBranch(ctx, a) {
10677
10795
  const n = branchNumber(a.pr);
10678
10796
  const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
@@ -10684,7 +10802,8 @@ async function mergeBranch(ctx, a) {
10684
10802
  if (f.gate.allowed) {
10685
10803
  if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
10686
10804
  const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
10687
- return { pr: n, status: "merged", sha: r.sha, said: "Merged. It goes out with the next library release." };
10805
+ const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
10806
+ 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
10807
  }
10689
10808
  if (!reason) {
10690
10809
  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 +10817,8 @@ async function mergeBranch(ctx, a) {
10698
10817
  const method = allowedMergeMethods(f.repo)[0] ?? "squash";
10699
10818
  try {
10700
10819
  const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
10701
- return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.` };
10820
+ const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
10821
+ 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
10822
  } catch (err) {
10703
10823
  if (err instanceof OverrideMergeError) throw new Error(err.message);
10704
10824
  throw err;
@@ -11067,11 +11187,33 @@ async function suggestionGovernance(ctx, gh) {
11067
11187
  return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {}, ...config.governance.soloOwnerCanApproveBoth ? { soloOwnerCanApproveBoth: true } : {} } };
11068
11188
  }
11069
11189
  var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
11190
+ var REVIEW_STATUSES = ["needs-review", "changes-requested", "approved"];
11191
+ var MAX_STALENESS_READS = 30;
11192
+ var mainRef = (ctx, config) => ctx.ref ?? baseBranchOf(config);
11193
+ async function stalenessOf(ctx, gh, d, config, gov) {
11194
+ if (d.issue.state !== "open" || !REVIEW_STATUSES.includes(d.status)) return null;
11195
+ return suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
11196
+ }
11070
11197
  async function listSuggestionsOp(ctx, a = {}) {
11071
11198
  const gh = await reader(ctx);
11072
11199
  const status = a.status ?? "all";
11073
11200
  const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
11074
- let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100 });
11201
+ const pulls = await gh.listPullRequests("all").catch(() => []);
11202
+ let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100, pulls });
11203
+ const open = rows.filter((r) => r.state === "open" && REVIEW_STATUSES.includes(r.status)).slice(0, MAX_STALENESS_READS);
11204
+ if (open.length) {
11205
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11206
+ const outdated = /* @__PURE__ */ new Set();
11207
+ await Promise.all(
11208
+ open.map(async (r) => {
11209
+ const d = await getSuggestionOnGitHub(gh, r.number, gov, { pulls }).catch(() => null);
11210
+ if (!d?.parsed.ok) return;
11211
+ const st = await suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
11212
+ if (st?.outdated) outdated.add(r.number);
11213
+ })
11214
+ );
11215
+ rows = rows.map((r) => outdated.has(r.number) ? { ...r, status: stalenessStatus(r.status, { outdated: true }) } : r);
11216
+ }
11075
11217
  if (status !== "all") rows = rows.filter((r) => r.status === status);
11076
11218
  if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
11077
11219
  const total = rows.length;
@@ -11101,8 +11243,10 @@ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
11101
11243
  async function getSuggestionOp(ctx, a) {
11102
11244
  const n = suggestionNumber(a.number);
11103
11245
  const gh = await reader(ctx);
11104
- const { gov } = await suggestionGovernance(ctx, gh);
11246
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11105
11247
  const d = await getSuggestionOnGitHub(gh, n, gov);
11248
+ const stale = d.parsed.ok ? await stalenessOf(ctx, gh, d, config, gov) : null;
11249
+ d.status = stalenessStatus(d.status, stale);
11106
11250
  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
11251
  if (!d.parsed.ok) {
11108
11252
  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 +11287,9 @@ async function getSuggestionOp(ctx, a) {
11143
11287
  ...solo && p.tier.tier > 1 ? { soloOwner: soloOwnerSentence(solo) } : {}
11144
11288
  },
11145
11289
  applied: d.applied,
11290
+ ...stale?.outdated ? { outdated: { mainSha: stale.mainSha, conflicts: stale.conflicts.map((c) => ({ path: c.path, detail: c.detail })) } } : {},
11146
11291
  ...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" ? "Outdated: the code changed since it was suggested. Ask the author to refresh it." : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
11292
+ 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
11293
  };
11149
11294
  }
11150
11295
  async function reviewSuggestionOp(ctx, a) {
@@ -11187,12 +11332,7 @@ async function reviewSuggestionOp(ctx, a) {
11187
11332
  };
11188
11333
  }
11189
11334
  function githubWorkspace(gh, ref) {
11190
- let files;
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
- };
11335
+ return githubSuggestionWorkspace(gh, ref);
11196
11336
  }
11197
11337
  var MAX_DIFF = 12e3;
11198
11338
  var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
@@ -11228,6 +11368,14 @@ async function applySuggestionOp(ctx, a) {
11228
11368
  return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
11229
11369
  }
11230
11370
  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.` };
11371
+ if (d.status === "applied") {
11372
+ const warnings2 = [];
11373
+ if (ctx.canWriteRemote) {
11374
+ const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applied").catch((e) => `The status label on #${n} could not be updated (${e.message}).`);
11375
+ if (w) warnings2.push(w);
11376
+ }
11377
+ 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 } : {} };
11378
+ }
11231
11379
  const parsed = d.parsed;
11232
11380
  const dryRun = a.dryRun || a.mode === "dry-run";
11233
11381
  const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
@@ -11297,15 +11445,18 @@ async function linkSuggestionOp(ctx, a) {
11297
11445
  if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
11298
11446
  const warnings = [];
11299
11447
  let branch = a.branch;
11448
+ let merged = false;
11300
11449
  if (a.pr) {
11301
11450
  const pr = await gh.getPullRequest(a.pr);
11302
11451
  branch ??= pr.headRef;
11303
- if (!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.`);
11452
+ merged = pr.state === "merged";
11453
+ 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
11454
  }
11305
11455
  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 });
11456
+ 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
11457
  warnings.push(...r.warnings);
11308
- return { number: n, status: "applying", said: SUGGESTION_STATUS_LABEL.applying, ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: "It closes when the pull request merges." };
11458
+ const status = r.status;
11459
+ 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
11460
  }
11310
11461
  async function createSuggestionOp(ctx, a) {
11311
11462
  const { config } = await ctx.config();
@@ -11935,7 +12086,7 @@ async function visualImpact(ctx, a, run2) {
11935
12086
  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
12087
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
11937
12088
  const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11938
- const runImpact = run2 ?? (await import("./src-UNWS2OVM.js")).runImpact;
12089
+ const runImpact = run2 ?? (await import("./src-DRJFSLRV.js")).runImpact;
11939
12090
  const r = await runImpact({
11940
12091
  root: ctx.root,
11941
12092
  base: a.base ?? "main",
@@ -13003,7 +13154,7 @@ async function pushFigmaComponentOp(ctx, a) {
13003
13154
  }
13004
13155
 
13005
13156
  // src/server.ts
13006
- var VERSION = true ? "0.8.2" : "dev";
13157
+ var VERSION = true ? "0.8.4" : "dev";
13007
13158
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
13008
13159
  var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
13009
13160
  var run = (fn) => async (a) => {
@@ -13847,7 +13998,7 @@ function createServer(ctx) {
13847
13998
  "bridge_list_suggestions",
13848
13999
  {
13849
14000
  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.",
14001
+ 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
14002
  inputSchema: {
13852
14003
  status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
13853
14004
  state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),