@bridge-ds/mcp 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/branches.d.ts +443 -0
- package/dist/{chunk-X455XY4E.js → chunk-7DBLDJIL.js} +333 -2
- package/dist/{chunk-MTQCDV65.js → chunk-NYFH322K.js} +2202 -41
- package/dist/explorations.d.ts +5 -0
- package/dist/index.js +2 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +7 -3
- package/dist/ops.d.ts +70 -1
- package/dist/requests.d.ts +2 -0
- package/dist/{src-XSBCZV4W.js → src-2YRRAUUM.js} +101 -3
- package/dist/studio.d.ts +107 -0
- package/package.json +3 -3
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONFIG_FILE,
|
|
3
|
+
DEFAULT_COMPONENT_SPECS_FILE,
|
|
4
|
+
DEFAULT_OVERRIDE,
|
|
3
5
|
DEFAULT_PROTECTED_BRANCHES,
|
|
4
6
|
DEFAULT_RIGHTS,
|
|
7
|
+
DEFAULT_TOKEN_PATHS,
|
|
8
|
+
FIGMA_REFERENCES_BRANCH,
|
|
9
|
+
FIGMA_REFERENCES_MANIFEST,
|
|
5
10
|
LayeredTokenSet,
|
|
6
11
|
ProtectedBranchError,
|
|
7
12
|
RISK_LABELS,
|
|
@@ -12,6 +17,7 @@ import {
|
|
|
12
17
|
assertSafeBranch,
|
|
13
18
|
baseBranchOf,
|
|
14
19
|
branchExists,
|
|
20
|
+
canShip,
|
|
15
21
|
changeTable,
|
|
16
22
|
checkLayeredPair,
|
|
17
23
|
classifyLayered,
|
|
@@ -22,11 +28,15 @@ import {
|
|
|
22
28
|
contrastDelta,
|
|
23
29
|
contrastDeltaTable,
|
|
24
30
|
contrastPair,
|
|
31
|
+
coverageSummary,
|
|
25
32
|
currentBranch,
|
|
33
|
+
defaultProduct,
|
|
26
34
|
diffLayered,
|
|
27
35
|
entryLabel,
|
|
28
36
|
expandOwners,
|
|
37
|
+
figmaCell,
|
|
29
38
|
findPreviewComment,
|
|
39
|
+
findProduct,
|
|
30
40
|
git,
|
|
31
41
|
globToRegExp,
|
|
32
42
|
hexToRgba,
|
|
@@ -36,9 +46,15 @@ import {
|
|
|
36
46
|
listFilesAt,
|
|
37
47
|
loadConfig,
|
|
38
48
|
originRepo,
|
|
49
|
+
parseComponentSpecs,
|
|
39
50
|
parseContrastChecks,
|
|
40
51
|
previewLinks,
|
|
52
|
+
previewUrl,
|
|
41
53
|
previewsSettled,
|
|
54
|
+
productForPlace,
|
|
55
|
+
productLabel,
|
|
56
|
+
productOfBranch,
|
|
57
|
+
productsOf,
|
|
42
58
|
protectedBranchesOf,
|
|
43
59
|
readPreviewComment,
|
|
44
60
|
renderedColor,
|
|
@@ -49,12 +65,14 @@ import {
|
|
|
49
65
|
selectTokenFiles,
|
|
50
66
|
shipPolicy,
|
|
51
67
|
showFile,
|
|
68
|
+
threeWayDiff,
|
|
52
69
|
toConfigJson,
|
|
53
70
|
tokensPath,
|
|
71
|
+
unreadableShipTeams,
|
|
54
72
|
unreadableTeams,
|
|
55
73
|
validateLayered,
|
|
56
74
|
whoCanShipSentence
|
|
57
|
-
} from "./chunk-
|
|
75
|
+
} from "./chunk-7DBLDJIL.js";
|
|
58
76
|
|
|
59
77
|
// src/lib.ts
|
|
60
78
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -62,6 +80,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
62
80
|
// src/context.ts
|
|
63
81
|
import { resolve as resolve2 } from "node:path";
|
|
64
82
|
|
|
83
|
+
// ../core/src/types.ts
|
|
84
|
+
var EXT_ID = "com.dsbridge.id";
|
|
85
|
+
var EXT_SCOPES = "com.figma.scopes";
|
|
86
|
+
var PRIMITIVE_MODE = "value";
|
|
87
|
+
var SINGLE_MODE = PRIMITIVE_MODE;
|
|
88
|
+
|
|
65
89
|
// ../core/src/json-ast.ts
|
|
66
90
|
var JsonParseError = class extends Error {
|
|
67
91
|
constructor(message, offset) {
|
|
@@ -319,6 +343,24 @@ function serializeTokenFile(doc) {
|
|
|
319
343
|
}
|
|
320
344
|
var isTokenNode = (node) => node.kind === "object" && getEntry(node, "$value") !== void 0;
|
|
321
345
|
var isGroupKey = (key) => !key.startsWith("$");
|
|
346
|
+
function flattenTokens(doc) {
|
|
347
|
+
const out = [];
|
|
348
|
+
const walk2 = (node, path, inheritedType) => {
|
|
349
|
+
const ownGroupType = stringEntry(node, "$type");
|
|
350
|
+
const groupType = ownGroupType ?? inheritedType;
|
|
351
|
+
for (const entry of node.entries) {
|
|
352
|
+
if (!isGroupKey(entry.key) || entry.value.kind !== "object") continue;
|
|
353
|
+
const childPath = [...path, entry.key];
|
|
354
|
+
if (isTokenNode(entry.value)) {
|
|
355
|
+
out.push(readToken(entry.value, childPath.join("."), groupType, doc.file));
|
|
356
|
+
} else {
|
|
357
|
+
walk2(entry.value, childPath, groupType);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
walk2(doc.json.root, [], void 0);
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
322
364
|
function readToken(node, path, inheritedType, file) {
|
|
323
365
|
const ownType = stringEntry(node, "$type");
|
|
324
366
|
const ext = getEntry(node, "$extensions")?.value;
|
|
@@ -548,6 +590,368 @@ function moveToken(doc, from, to) {
|
|
|
548
590
|
return true;
|
|
549
591
|
}
|
|
550
592
|
|
|
593
|
+
// ../core/src/token-set.ts
|
|
594
|
+
var isTokenFile = (file) => file.endsWith(".tokens.json");
|
|
595
|
+
var pathId = (path) => `path:${path}`;
|
|
596
|
+
var overrideKey = (brand, theme, path) => `${brand}|${theme}|${path}`;
|
|
597
|
+
function layoutOptions(config) {
|
|
598
|
+
const first = (list, def) => def ? [def, ...list.filter((m) => m !== def)] : list;
|
|
599
|
+
return {
|
|
600
|
+
dirs: config.tokens,
|
|
601
|
+
order: {
|
|
602
|
+
brand: first(config.modes.brands, config.modes.defaults.brand),
|
|
603
|
+
theme: first(config.modes.themes, config.modes.defaults.theme),
|
|
604
|
+
density: first(config.modes.densities, config.modes.defaults.density)
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
var FALLBACK_ORDER = { theme: ["light", "dark"], brand: [], density: [] };
|
|
609
|
+
var stem = (file) => file.slice(file.lastIndexOf("/") + 1).replace(/\.tokens\.json$/, "");
|
|
610
|
+
function ordered(list, pref) {
|
|
611
|
+
const rank2 = (m) => pref.indexOf(m) === -1 ? 99 : pref.indexOf(m);
|
|
612
|
+
return list.sort((a, b) => rank2(a.mode) - rank2(b.mode) || a.mode.localeCompare(b.mode));
|
|
613
|
+
}
|
|
614
|
+
function detectLayout(files, options = {}) {
|
|
615
|
+
const d = { ...DEFAULT_TOKEN_PATHS, ...options.dirs };
|
|
616
|
+
const inDir = (file, dir) => file.startsWith(`${dir}/`) && !file.slice(dir.length + 1).includes("/");
|
|
617
|
+
const layout = { primitiveFiles: [], brands: [], baseFiles: [], themes: [], overrides: [], densities: [], componentFiles: [] };
|
|
618
|
+
for (const file of files.filter(isTokenFile).sort()) {
|
|
619
|
+
const name = stem(file);
|
|
620
|
+
if (inDir(file, d.brands)) {
|
|
621
|
+
const parts = name.split(".");
|
|
622
|
+
if (parts.length === 2) layout.overrides.push({ brand: parts[0], theme: parts[1], file });
|
|
623
|
+
else layout.brands.push({ mode: name, file });
|
|
624
|
+
} else if (inDir(file, d.semantic)) {
|
|
625
|
+
if (name === d.base) layout.baseFiles.push(file);
|
|
626
|
+
else layout.themes.push({ mode: name, file });
|
|
627
|
+
} else if (inDir(file, d.density)) layout.densities.push({ mode: name, file });
|
|
628
|
+
else if (file.startsWith(`${d.components}/`)) layout.componentFiles.push(file);
|
|
629
|
+
else layout.primitiveFiles.push(file);
|
|
630
|
+
}
|
|
631
|
+
ordered(layout.brands, options.order?.brand ?? FALLBACK_ORDER.brand);
|
|
632
|
+
ordered(layout.themes, options.order?.theme ?? FALLBACK_ORDER.theme);
|
|
633
|
+
ordered(layout.densities, options.order?.density ?? FALLBACK_ORDER.density);
|
|
634
|
+
return layout;
|
|
635
|
+
}
|
|
636
|
+
function readScopes(ext) {
|
|
637
|
+
const s = ext?.[EXT_SCOPES];
|
|
638
|
+
return Array.isArray(s) && s.every((x) => typeof x === "string") ? s : void 0;
|
|
639
|
+
}
|
|
640
|
+
function readId(ext) {
|
|
641
|
+
const id = ext?.[EXT_ID];
|
|
642
|
+
return typeof id === "string" && id ? id : void 0;
|
|
643
|
+
}
|
|
644
|
+
var TIER_LABEL = {
|
|
645
|
+
primitive: "a primitive",
|
|
646
|
+
brand: "a brand token",
|
|
647
|
+
base: "a base semantic token",
|
|
648
|
+
semantic: "a semantic token",
|
|
649
|
+
density: "a density token",
|
|
650
|
+
component: "a component token"
|
|
651
|
+
};
|
|
652
|
+
function buildTokenSet(files, options = {}) {
|
|
653
|
+
const problems = [];
|
|
654
|
+
const docs = /* @__PURE__ */ new Map();
|
|
655
|
+
for (const [file, text] of Object.entries(files)) {
|
|
656
|
+
if (!isTokenFile(file)) continue;
|
|
657
|
+
try {
|
|
658
|
+
docs.set(file, parseTokenFile(file, text));
|
|
659
|
+
} catch (err) {
|
|
660
|
+
problems.push(`${file} is not valid JSON: ${err.message}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const layout = detectLayout([...docs.keys()], options);
|
|
664
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
665
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
666
|
+
const add = (t) => {
|
|
667
|
+
if (tokens.has(t.id)) {
|
|
668
|
+
problems.push(`Two tokens share the id "${t.id}" (${tokens.get(t.id).path} and ${t.path}).`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
tokens.set(t.id, t);
|
|
672
|
+
byPath.set(t.path, t);
|
|
673
|
+
};
|
|
674
|
+
const single = (tier, fileList) => {
|
|
675
|
+
for (const file of fileList) {
|
|
676
|
+
for (const flat of flattenTokens(docs.get(file))) {
|
|
677
|
+
const prev = byPath.get(flat.path);
|
|
678
|
+
if (prev) {
|
|
679
|
+
problems.push(
|
|
680
|
+
prev.tier === tier ? `${flat.path} is defined twice (${prev.files[SINGLE_MODE]} and ${file}).` : `${flat.path} exists both as ${TIER_LABEL[prev.tier]} and as ${TIER_LABEL[tier]} (${file}).`
|
|
681
|
+
);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (!flat.type) problems.push(`${flat.path} in ${file} has no $type.`);
|
|
685
|
+
add({
|
|
686
|
+
id: readId(flat.extensions) ?? pathId(flat.path),
|
|
687
|
+
path: flat.path,
|
|
688
|
+
type: flat.type ?? "unknown",
|
|
689
|
+
tier,
|
|
690
|
+
values: { [SINGLE_MODE]: flat.value },
|
|
691
|
+
description: flat.description,
|
|
692
|
+
scopes: readScopes(flat.extensions),
|
|
693
|
+
files: { [SINGLE_MODE]: file }
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
const multi = (tier, modes) => {
|
|
699
|
+
const merged = /* @__PURE__ */ new Map();
|
|
700
|
+
for (const { mode, file } of modes) {
|
|
701
|
+
for (const flat of flattenTokens(docs.get(file))) {
|
|
702
|
+
let t = merged.get(flat.path);
|
|
703
|
+
if (!t) {
|
|
704
|
+
const prev = byPath.get(flat.path);
|
|
705
|
+
if (prev) {
|
|
706
|
+
problems.push(`${flat.path} exists both as ${TIER_LABEL[prev.tier]} and as ${TIER_LABEL[tier]} (${file}).`);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
t = {
|
|
710
|
+
id: readId(flat.extensions) ?? pathId(flat.path),
|
|
711
|
+
path: flat.path,
|
|
712
|
+
type: flat.type ?? "unknown",
|
|
713
|
+
tier,
|
|
714
|
+
values: {},
|
|
715
|
+
description: flat.description,
|
|
716
|
+
scopes: readScopes(flat.extensions),
|
|
717
|
+
files: {}
|
|
718
|
+
};
|
|
719
|
+
merged.set(flat.path, t);
|
|
720
|
+
if (!flat.type) problems.push(`${flat.path} in ${file} has no $type.`);
|
|
721
|
+
} else if (flat.type && t.type !== flat.type) {
|
|
722
|
+
problems.push(`${flat.path} has type ${t.type} in one mode and ${flat.type} in ${file}.`);
|
|
723
|
+
}
|
|
724
|
+
t.values[mode] = flat.value;
|
|
725
|
+
t.files[mode] = file;
|
|
726
|
+
t.description ??= flat.description;
|
|
727
|
+
t.scopes ??= readScopes(flat.extensions);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
merged.forEach(add);
|
|
731
|
+
};
|
|
732
|
+
single("primitive", layout.primitiveFiles);
|
|
733
|
+
multi("brand", layout.brands);
|
|
734
|
+
single("base", layout.baseFiles);
|
|
735
|
+
multi("semantic", layout.themes);
|
|
736
|
+
multi("density", layout.densities);
|
|
737
|
+
single("component", layout.componentFiles);
|
|
738
|
+
const overrides = [];
|
|
739
|
+
const overrideIndex = /* @__PURE__ */ new Map();
|
|
740
|
+
for (const o of layout.overrides) {
|
|
741
|
+
for (const flat of flattenTokens(docs.get(o.file))) {
|
|
742
|
+
overrides.push({ brand: o.brand, theme: o.theme, path: flat.path, type: flat.type, value: flat.value, file: o.file });
|
|
743
|
+
overrideIndex.set(overrideKey(o.brand, o.theme, flat.path), flat.value);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return { tokens, byPath, docs, layout, options, overrides, overrideIndex, problems };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ../core/src/token-merge.ts
|
|
750
|
+
var idOf = (f) => {
|
|
751
|
+
const id = f.extensions?.[EXT_ID];
|
|
752
|
+
return typeof id === "string" && id ? id : pathId(f.path);
|
|
753
|
+
};
|
|
754
|
+
function logical(f) {
|
|
755
|
+
const scopes = f.extensions?.[EXT_SCOPES];
|
|
756
|
+
return {
|
|
757
|
+
id: idOf(f),
|
|
758
|
+
path: f.path,
|
|
759
|
+
type: f.type ?? "unknown",
|
|
760
|
+
// One file holds one tier: the tier only feeds default scopes, the same on every side.
|
|
761
|
+
tier: "primitive",
|
|
762
|
+
values: { [SINGLE_MODE]: f.value },
|
|
763
|
+
description: f.description,
|
|
764
|
+
scopes: Array.isArray(scopes) ? scopes.map(String) : void 0,
|
|
765
|
+
files: { [SINGLE_MODE]: f.file }
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function readSide(file, text, problems, label) {
|
|
769
|
+
const side = { doc: void 0, flat: /* @__PURE__ */ new Map(), logical: /* @__PURE__ */ new Map() };
|
|
770
|
+
if (text === void 0) return side;
|
|
771
|
+
try {
|
|
772
|
+
side.doc = parseTokenFile(file, text);
|
|
773
|
+
} catch (err) {
|
|
774
|
+
problems.push(`${file} (${label}) is not valid JSON: ${err.message}`);
|
|
775
|
+
return side;
|
|
776
|
+
}
|
|
777
|
+
for (const f of flattenTokens(side.doc)) {
|
|
778
|
+
const id = idOf(f);
|
|
779
|
+
side.flat.set(id, f);
|
|
780
|
+
side.logical.set(id, logical(f));
|
|
781
|
+
}
|
|
782
|
+
return side;
|
|
783
|
+
}
|
|
784
|
+
function diffFile(input, file, problems) {
|
|
785
|
+
const base = readSide(file, input.base[file], problems, "shared start");
|
|
786
|
+
const mine = readSide(file, input.mine[file], problems, "this change");
|
|
787
|
+
const theirs = readSide(file, input.theirs[file], problems, "latest library");
|
|
788
|
+
const r = threeWayDiff(base.logical, mine.logical, theirs.logical);
|
|
789
|
+
return { file, base, mine, theirs, local: r.local, remote: r.remote, conflicts: r.conflicts };
|
|
790
|
+
}
|
|
791
|
+
function removedSide(c) {
|
|
792
|
+
if (!c.base) return null;
|
|
793
|
+
if (!c.mine && c.theirs) return "mine";
|
|
794
|
+
if (!c.theirs && c.mine) return "theirs";
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
var defaultChoiceFor = (c) => removedSide(c) ? null : "mine";
|
|
798
|
+
function planTokenMerge(input) {
|
|
799
|
+
const problems = [];
|
|
800
|
+
const files = [...new Set(input.files)].sort();
|
|
801
|
+
const conflicts = [];
|
|
802
|
+
let mineOnly = 0;
|
|
803
|
+
let theirsOnly = 0;
|
|
804
|
+
for (const file of files) {
|
|
805
|
+
const d = diffFile(input, file, problems);
|
|
806
|
+
mineOnly += d.local.length;
|
|
807
|
+
theirsOnly += d.remote.length;
|
|
808
|
+
for (const e of d.conflicts) {
|
|
809
|
+
const c = { base: d.base.flat.get(e.id), mine: d.mine.flat.get(e.id), theirs: d.theirs.flat.get(e.id) };
|
|
810
|
+
conflicts.push({ key: `${file}#${e.id}`, file, path: (c.mine ?? c.theirs ?? c.base).path, ...c, removedOn: removedSide(c), defaultChoice: defaultChoiceFor(c) });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { files, conflicts, mineOnly, theirsOnly, problems };
|
|
814
|
+
}
|
|
815
|
+
var choiceFor = (c, choices) => choices[c.key] ?? c.defaultChoice;
|
|
816
|
+
function missingChoices(plan, choices) {
|
|
817
|
+
return plan.conflicts.filter((c) => choiceFor(c, choices) === null);
|
|
818
|
+
}
|
|
819
|
+
function takeMine(doc, mine, existing) {
|
|
820
|
+
if (!mine) return existing ? removeToken(doc, existing.path) : false;
|
|
821
|
+
let changed = false;
|
|
822
|
+
if (existing && existing.path !== mine.path) changed = removeToken(doc, existing.path) || changed;
|
|
823
|
+
const extensions = {};
|
|
824
|
+
for (const k of Object.keys(existing?.path === mine.path ? existing.extensions ?? {} : {})) extensions[k] = void 0;
|
|
825
|
+
Object.assign(extensions, mine.extensions ?? {});
|
|
826
|
+
return upsertToken(doc, mine.path, { type: mine.type, value: mine.value, description: mine.description, extensions }) || changed;
|
|
827
|
+
}
|
|
828
|
+
function applyTokenMerge(input, choices) {
|
|
829
|
+
const plan = planTokenMerge(input);
|
|
830
|
+
if (plan.problems.length) throw new Error(plan.problems[0]);
|
|
831
|
+
const missing = missingChoices(plan, choices);
|
|
832
|
+
if (missing.length) throw new Error(`Pick a value for ${missing.map((c) => c.path).join(", ")} first.`);
|
|
833
|
+
const byKey = new Map(plan.conflicts.map((c) => [c.key, c]));
|
|
834
|
+
const kept = { mine: 0, theirs: 0 };
|
|
835
|
+
const files = {};
|
|
836
|
+
for (const file of plan.files) {
|
|
837
|
+
const d = diffFile(input, file, []);
|
|
838
|
+
const doc = d.theirs.doc ?? emptyTokenFile(file);
|
|
839
|
+
for (const e of d.local) takeMine(doc, d.mine.flat.get(e.id), d.theirs.flat.get(e.id) ?? d.base.flat.get(e.id));
|
|
840
|
+
for (const e of d.conflicts) {
|
|
841
|
+
const side = choiceFor(byKey.get(`${file}#${e.id}`), choices);
|
|
842
|
+
kept[side]++;
|
|
843
|
+
if (side === "mine") takeMine(doc, d.mine.flat.get(e.id), d.theirs.flat.get(e.id));
|
|
844
|
+
}
|
|
845
|
+
const empty = flattenTokens(doc).length === 0;
|
|
846
|
+
files[file] = empty && (input.theirs[file] === void 0 || input.mine[file] === void 0) ? null : serializeTokenFile(doc);
|
|
847
|
+
}
|
|
848
|
+
return { files, kept };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ../core/src/token-combine.ts
|
|
852
|
+
var idOf2 = (f) => {
|
|
853
|
+
const id = f.extensions?.[EXT_ID];
|
|
854
|
+
return typeof id === "string" && id ? id : pathId(f.path);
|
|
855
|
+
};
|
|
856
|
+
function tokensOf(file, text, problems, label) {
|
|
857
|
+
const out = /* @__PURE__ */ new Map();
|
|
858
|
+
if (text === void 0) return out;
|
|
859
|
+
try {
|
|
860
|
+
for (const t of flattenTokens(parseTokenFile(file, text))) out.set(idOf2(t), t);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
problems.push(`${file} (${label}) is not valid JSON: ${err.message}`);
|
|
863
|
+
}
|
|
864
|
+
return out;
|
|
865
|
+
}
|
|
866
|
+
var shape = (t) => t ? JSON.stringify([t.path, t.type ?? null, t.value, t.description ?? null]) : "removed";
|
|
867
|
+
var look = (t) => t ? JSON.stringify([t.path, t.type ?? null, t.value]) : "removed";
|
|
868
|
+
function changeEdits(src) {
|
|
869
|
+
const problems = [];
|
|
870
|
+
const edits = [];
|
|
871
|
+
for (const file of [...new Set(src.files)].sort()) {
|
|
872
|
+
const before = tokensOf(file, src.base[file], problems, "start");
|
|
873
|
+
const after = tokensOf(file, src.head[file], problems, `change ${src.number}`);
|
|
874
|
+
for (const id of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
|
|
875
|
+
const a = before.get(id);
|
|
876
|
+
const b = after.get(id);
|
|
877
|
+
if (shape(a) === shape(b)) continue;
|
|
878
|
+
edits.push({ key: `${file}#${id}`, file, path: (b ?? a).path, change: src.number, token: b });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { number: src.number, edits, problems };
|
|
882
|
+
}
|
|
883
|
+
function byToken(changes) {
|
|
884
|
+
const out = /* @__PURE__ */ new Map();
|
|
885
|
+
for (const c of changes) for (const e of c.edits) out.set(e.key, [...out.get(e.key) ?? [], e]);
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
function optionsOf(edits) {
|
|
889
|
+
const out = [];
|
|
890
|
+
for (const e of edits) {
|
|
891
|
+
const l = look(e.token);
|
|
892
|
+
const hit = out.find((o) => o.look === l);
|
|
893
|
+
if (hit) hit.option.changes.push(e.change);
|
|
894
|
+
else out.push({ look: l, option: { changes: [e.change], token: e.token } });
|
|
895
|
+
}
|
|
896
|
+
return out.map((o) => o.option);
|
|
897
|
+
}
|
|
898
|
+
function pickedOption(c, picks) {
|
|
899
|
+
const n = picks[c.key];
|
|
900
|
+
return n === void 0 ? null : c.options.find((o) => o.changes.includes(n)) ?? null;
|
|
901
|
+
}
|
|
902
|
+
function combineChanges(latest, changes, picks = {}, opts = {}) {
|
|
903
|
+
const on = opts.include ? changes.filter((c) => opts.include.includes(c.number)) : changes;
|
|
904
|
+
const problems = on.flatMap((c) => c.problems);
|
|
905
|
+
const groups = byToken(on);
|
|
906
|
+
const conflicts = [];
|
|
907
|
+
const missing = [];
|
|
908
|
+
const from = {};
|
|
909
|
+
const docs = /* @__PURE__ */ new Map();
|
|
910
|
+
const docOf = (file) => {
|
|
911
|
+
if (docs.has(file)) return docs.get(file);
|
|
912
|
+
const text = latest[file];
|
|
913
|
+
let doc;
|
|
914
|
+
try {
|
|
915
|
+
doc = text === void 0 ? emptyTokenFile(file) : parseTokenFile(file, text);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
problems.push(`${file} (latest library) is not valid JSON: ${err.message}`);
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
docs.set(file, doc);
|
|
921
|
+
return doc;
|
|
922
|
+
};
|
|
923
|
+
for (const [key, edits] of groups) {
|
|
924
|
+
const options = optionsOf(edits);
|
|
925
|
+
let use = options[0];
|
|
926
|
+
if (options.length > 1) {
|
|
927
|
+
const conflict = { key, file: edits[0].file, path: edits[0].path, options };
|
|
928
|
+
conflicts.push(conflict);
|
|
929
|
+
use = pickedOption(conflict, picks);
|
|
930
|
+
if (!use) {
|
|
931
|
+
missing.push(conflict);
|
|
932
|
+
use = opts.unpicked === "first" ? options[0] : null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!use) continue;
|
|
936
|
+
const doc = docOf(edits[0].file);
|
|
937
|
+
if (!doc) continue;
|
|
938
|
+
const existing = flattenTokens(doc).find((t) => idOf2(t) === key.slice(key.indexOf("#") + 1));
|
|
939
|
+
takeMine(doc, use.token, existing);
|
|
940
|
+
const path = use.token?.path ?? edits[0].path;
|
|
941
|
+
from[path] = [.../* @__PURE__ */ new Set([...from[path] ?? [], ...use.changes])];
|
|
942
|
+
}
|
|
943
|
+
const files = { ...latest };
|
|
944
|
+
for (const [file, doc] of docs) {
|
|
945
|
+
if (latest[file] === void 0 && flattenTokens(doc).length === 0) continue;
|
|
946
|
+
files[file] = serializeTokenFile(doc);
|
|
947
|
+
}
|
|
948
|
+
const tokens = new Set([...groups.values()].map((e) => e[0].path)).size;
|
|
949
|
+
return { files, included: on.map((c) => c.number), edits: groups.size, tokens, conflicts, missing, from, problems };
|
|
950
|
+
}
|
|
951
|
+
function encodePicks(picks) {
|
|
952
|
+
return Object.entries(picks).filter(([, n]) => Number.isSafeInteger(n) && n > 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, n]) => `${n}:${key}`).join("~");
|
|
953
|
+
}
|
|
954
|
+
|
|
551
955
|
// ../core/src/pr-composer.ts
|
|
552
956
|
var RISK_TIER = RISK_LABELS[3];
|
|
553
957
|
function slugify(text, max = 40) {
|
|
@@ -711,6 +1115,84 @@ function applyTokenEdits(base, edits) {
|
|
|
711
1115
|
return { files, touched: [...touched].filter((f) => files.get(f) !== base.files.get(f)).sort(), notes };
|
|
712
1116
|
}
|
|
713
1117
|
|
|
1118
|
+
// ../core/src/protection.ts
|
|
1119
|
+
var PROTECTION_PARTS = ["review", "checks", "history", "deletion"];
|
|
1120
|
+
var protectionRulesetName = (defaultBranch) => `Protect the library (${defaultBranch})`;
|
|
1121
|
+
function refPattern(pattern) {
|
|
1122
|
+
const src = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
|
|
1123
|
+
return new RegExp(`^${src}$`);
|
|
1124
|
+
}
|
|
1125
|
+
function rulesetTargetsDefault(ruleset, defaultBranch) {
|
|
1126
|
+
if (ruleset.target && ruleset.target !== "branch") return false;
|
|
1127
|
+
const ref = `refs/heads/${defaultBranch}`;
|
|
1128
|
+
const hit = (p) => p === "~ALL" || p === "~DEFAULT_BRANCH" || p === ref || p === defaultBranch || refPattern(p.startsWith("refs/") ? p : `refs/heads/${p}`).test(ref);
|
|
1129
|
+
const cond = ruleset.conditions?.ref_name;
|
|
1130
|
+
if (!cond) return false;
|
|
1131
|
+
if ((cond.exclude ?? []).some(hit)) return false;
|
|
1132
|
+
return (cond.include ?? []).some(hit);
|
|
1133
|
+
}
|
|
1134
|
+
var count = (v) => typeof v === "number" ? v : 0;
|
|
1135
|
+
function rulesetParts(ruleset) {
|
|
1136
|
+
const parts = /* @__PURE__ */ new Set();
|
|
1137
|
+
if (ruleset.enforcement !== "active") return parts;
|
|
1138
|
+
for (const rule of ruleset.rules ?? []) {
|
|
1139
|
+
if (rule.type === "deletion") parts.add("deletion");
|
|
1140
|
+
if (rule.type === "non_fast_forward") parts.add("history");
|
|
1141
|
+
if (rule.type === "pull_request" && count(rule.parameters?.required_approving_review_count) >= 1) parts.add("review");
|
|
1142
|
+
if (rule.type === "required_status_checks" && Array.isArray(rule.parameters?.required_status_checks) && rule.parameters.required_status_checks.length > 0) parts.add("checks");
|
|
1143
|
+
}
|
|
1144
|
+
return parts;
|
|
1145
|
+
}
|
|
1146
|
+
function classicParts(p) {
|
|
1147
|
+
const parts = /* @__PURE__ */ new Set();
|
|
1148
|
+
if (count(p.required_pull_request_reviews?.required_approving_review_count) >= 1) parts.add("review");
|
|
1149
|
+
if (((p.required_status_checks?.contexts?.length ?? 0) || (p.required_status_checks?.checks?.length ?? 0)) > 0) parts.add("checks");
|
|
1150
|
+
if (!p.allow_force_pushes?.enabled) parts.add("history");
|
|
1151
|
+
if (!p.allow_deletions?.enabled) parts.add("deletion");
|
|
1152
|
+
return parts;
|
|
1153
|
+
}
|
|
1154
|
+
var rulesetUrl = (r) => r.html_url ?? r._links?.html?.href;
|
|
1155
|
+
function protectionState(rulesets, branchProtection, defaultBranch) {
|
|
1156
|
+
const bridge = rulesets?.find((r) => r.name === protectionRulesetName(defaultBranch));
|
|
1157
|
+
const extra = bridge?.id !== void 0 ? { bridgeRulesetId: bridge.id } : {};
|
|
1158
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1159
|
+
let url;
|
|
1160
|
+
for (const r of rulesets ?? []) {
|
|
1161
|
+
if (!rulesetTargetsDefault(r, defaultBranch)) continue;
|
|
1162
|
+
const parts = rulesetParts(r);
|
|
1163
|
+
if (parts.size && !url) url = rulesetUrl(r);
|
|
1164
|
+
parts.forEach((p) => covered.add(p));
|
|
1165
|
+
}
|
|
1166
|
+
if (branchProtection) classicParts(branchProtection).forEach((p) => covered.add(p));
|
|
1167
|
+
const missing = PROTECTION_PARTS.filter((p) => !covered.has(p));
|
|
1168
|
+
const withUrl = url ? { url } : {};
|
|
1169
|
+
if (!missing.length) return { kind: "protected", missing, ...extra, ...withUrl };
|
|
1170
|
+
const unreadable = rulesets == null || branchProtection === void 0;
|
|
1171
|
+
if (covered.size === 0) return { kind: unreadable ? "unknown" : "unprotected", missing, ...extra };
|
|
1172
|
+
return { kind: "partial", missing, ...extra, ...withUrl };
|
|
1173
|
+
}
|
|
1174
|
+
var PROTECTION_PART_PHRASE = {
|
|
1175
|
+
review: "changes can skip review",
|
|
1176
|
+
checks: "checks do not have to pass",
|
|
1177
|
+
history: "past changes can be rewritten",
|
|
1178
|
+
deletion: "the library can be deleted"
|
|
1179
|
+
};
|
|
1180
|
+
var joinAnd = (xs) => xs.length <= 1 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
|
|
1181
|
+
function protectionSentence(state) {
|
|
1182
|
+
switch (state.kind) {
|
|
1183
|
+
case "protected":
|
|
1184
|
+
return "Protected: every change goes through review.";
|
|
1185
|
+
case "unprotected":
|
|
1186
|
+
return "Your library can be changed without review.";
|
|
1187
|
+
case "partial": {
|
|
1188
|
+
const s = joinAnd(state.missing.map((p) => PROTECTION_PART_PHRASE[p]));
|
|
1189
|
+
return `Partly protected: ${s}.`;
|
|
1190
|
+
}
|
|
1191
|
+
default:
|
|
1192
|
+
return "Protection status unknown: GitHub did not let this token read it.";
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
714
1196
|
// ../core/src/github-client.ts
|
|
715
1197
|
var GitHubError = class extends Error {
|
|
716
1198
|
constructor(status, message, detail, info = {}) {
|
|
@@ -1333,7 +1815,7 @@ var GitHubClient = class _GitHubClient {
|
|
|
1333
1815
|
}
|
|
1334
1816
|
async getPullRequest(n) {
|
|
1335
1817
|
const r = await this.request("GET", `${this.repoPath}/pulls/${n}`);
|
|
1336
|
-
return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null };
|
|
1818
|
+
return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null, requestedReviewers: (r.requested_reviewers ?? []).map((x) => x.login) };
|
|
1337
1819
|
}
|
|
1338
1820
|
async listPullRequestFiles(n) {
|
|
1339
1821
|
const r = await this.request("GET", `${this.repoPath}/pulls/${n}/files?per_page=100`);
|
|
@@ -1410,6 +1892,100 @@ var GitHubClient = class _GitHubClient {
|
|
|
1410
1892
|
async removeLabel(issueNumber, label) {
|
|
1411
1893
|
await this.request("DELETE", `${this.repoPath}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`);
|
|
1412
1894
|
}
|
|
1895
|
+
// --- branch actions shared by Studio, the MCP server and the CLI (branch-actions.ts) ---
|
|
1896
|
+
/** The account the token belongs to (GET /user). Needs a token. */
|
|
1897
|
+
async getViewer() {
|
|
1898
|
+
this.assertToken("Reading who you are on GitHub");
|
|
1899
|
+
const r = await this.request("GET", "/user");
|
|
1900
|
+
return { login: r.login };
|
|
1901
|
+
}
|
|
1902
|
+
/** The repository's merge settings and the viewer's permissions (unknown settings count as allowed). */
|
|
1903
|
+
async getMergeSettings() {
|
|
1904
|
+
return this.request("GET", this.repoPath);
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Merges a pull request, only at `sha` (GitHub refuses when the branch moved meanwhile). The repository's branch
|
|
1908
|
+
* rules still apply: GitHub refuses a merge they block, whatever Bridge says.
|
|
1909
|
+
*/
|
|
1910
|
+
async mergePullRequest(n, input) {
|
|
1911
|
+
this.assertToken("Merging a branch");
|
|
1912
|
+
const r = await this.request("PUT", `${this.repoPath}/pulls/${n}/merge`, {
|
|
1913
|
+
merge_method: input.method,
|
|
1914
|
+
sha: input.sha,
|
|
1915
|
+
...input.commitMessage !== void 0 && input.method !== "rebase" ? { commit_message: input.commitMessage } : {}
|
|
1916
|
+
});
|
|
1917
|
+
return { sha: r?.sha ?? "", merged: r?.merged ?? true };
|
|
1918
|
+
}
|
|
1919
|
+
/** GitHub's "Update branch": merges the base into the pull request, only when its head is still `expectedHeadSha`. */
|
|
1920
|
+
async updatePullRequestBranch(n, expectedHeadSha) {
|
|
1921
|
+
this.assertToken("Updating a branch from main");
|
|
1922
|
+
await this.request("PUT", `${this.repoPath}/pulls/${n}/update-branch`, { expected_head_sha: expectedHeadSha });
|
|
1923
|
+
}
|
|
1924
|
+
/** Posts a comment on an issue or pull request and returns its id and link. */
|
|
1925
|
+
async createIssueComment(n, body) {
|
|
1926
|
+
this.assertToken("Commenting");
|
|
1927
|
+
const r = await this.request("POST", `${this.repoPath}/issues/${n}/comments`, { body });
|
|
1928
|
+
return { id: r.id, url: r.html_url };
|
|
1929
|
+
}
|
|
1930
|
+
/** Replaces the text of a comment. */
|
|
1931
|
+
async updateIssueComment(id, body) {
|
|
1932
|
+
this.assertToken("Editing a comment");
|
|
1933
|
+
await this.request("PATCH", `${this.repoPath}/issues/comments/${id}`, { body });
|
|
1934
|
+
}
|
|
1935
|
+
/** Submits a review on a given commit (the head the reviewer saw); the body may be empty for an approval. */
|
|
1936
|
+
async submitReview(n, input) {
|
|
1937
|
+
this.assertToken("Reviewing a branch");
|
|
1938
|
+
const r = await this.request("POST", `${this.repoPath}/pulls/${n}/reviews`, {
|
|
1939
|
+
event: input.event,
|
|
1940
|
+
...input.body ? { body: input.body } : {},
|
|
1941
|
+
...input.commitId ? { commit_id: input.commitId } : {}
|
|
1942
|
+
});
|
|
1943
|
+
return { id: r.id, user: r.user?.login, state: r.state, body: r.body ?? "" };
|
|
1944
|
+
}
|
|
1945
|
+
/** Line comments of a pull request (review threads), oldest first. */
|
|
1946
|
+
async listReviewComments(n) {
|
|
1947
|
+
const r = await this.request(
|
|
1948
|
+
"GET",
|
|
1949
|
+
`${this.repoPath}/pulls/${n}/comments?per_page=100`
|
|
1950
|
+
);
|
|
1951
|
+
return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", path: c.path, line: c.line ?? null, inReplyTo: c.in_reply_to_id ?? null, url: c.html_url, createdAt: c.created_at }));
|
|
1952
|
+
}
|
|
1953
|
+
/** Asks these people to review a pull request. */
|
|
1954
|
+
async requestReviewers(n, logins) {
|
|
1955
|
+
this.assertToken("Asking for a review");
|
|
1956
|
+
await this.request("POST", `${this.repoPath}/pulls/${n}/requested_reviewers`, { reviewers: logins });
|
|
1957
|
+
}
|
|
1958
|
+
/** Takes these people off the requested reviewers. */
|
|
1959
|
+
async removeRequestedReviewers(n, logins) {
|
|
1960
|
+
this.assertToken("Changing the requested reviewers");
|
|
1961
|
+
await this.request("DELETE", `${this.repoPath}/pulls/${n}/requested_reviewers`, { reviewers: logins });
|
|
1962
|
+
}
|
|
1963
|
+
/** Replies in the thread of a line comment. */
|
|
1964
|
+
async replyToReviewComment(n, commentId, body) {
|
|
1965
|
+
this.assertToken("Replying to a comment");
|
|
1966
|
+
const r = await this.request("POST", `${this.repoPath}/pulls/${n}/comments/${commentId}/replies`, { body });
|
|
1967
|
+
return { id: r.id, url: r.html_url };
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* What `head` adds on top of `base`, with what library sync needs: the shared start (merge base), the base
|
|
1971
|
+
* commit and its tree, how far behind, and the files with their previous names.
|
|
1972
|
+
*/
|
|
1973
|
+
async compareDetailed(base, head) {
|
|
1974
|
+
const r = await this.request("GET", `${this.repoPath}/compare/${encodeRef(base)}...${encodeRef(head)}`);
|
|
1975
|
+
return {
|
|
1976
|
+
behindBy: r.behind_by,
|
|
1977
|
+
aheadBy: r.ahead_by,
|
|
1978
|
+
mergeBase: r.merge_base_commit?.sha ?? null,
|
|
1979
|
+
baseSha: r.base_commit?.sha ?? null,
|
|
1980
|
+
baseTree: r.base_commit?.commit?.tree?.sha ?? null,
|
|
1981
|
+
files: (r.files ?? []).map((f) => ({ filename: f.filename, status: f.status, ...f.previous_filename ? { previous_filename: f.previous_filename } : {} }))
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
/** A tree on top of `baseTree` from entries with their own file mode (blob sha, inline content, or sha null to delete). */
|
|
1985
|
+
async createTreeEntries(baseTree, entries) {
|
|
1986
|
+
const r = await this.request("POST", `${this.repoPath}/git/trees`, { base_tree: baseTree, tree: entries.map((e) => ({ ...e, type: "blob" })) }, true);
|
|
1987
|
+
return r.sha;
|
|
1988
|
+
}
|
|
1413
1989
|
};
|
|
1414
1990
|
function toCommitSummary(c) {
|
|
1415
1991
|
return {
|
|
@@ -1477,6 +2053,13 @@ function toIssue(r) {
|
|
|
1477
2053
|
};
|
|
1478
2054
|
}
|
|
1479
2055
|
|
|
2056
|
+
// ../core/src/protection-github.ts
|
|
2057
|
+
async function loadProtection(client2, defaultBranch) {
|
|
2058
|
+
const rulesets = await client2.listRulesets().then((list) => Promise.all(list.map((r) => r.enforcement === "disabled" || r.id === void 0 ? r : client2.getRuleset(r.id)))).catch(() => null);
|
|
2059
|
+
const classic = await client2.getBranchProtection(defaultBranch).catch(() => void 0);
|
|
2060
|
+
return protectionState(rulesets, classic, defaultBranch);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1480
2063
|
// ../core/src/history.ts
|
|
1481
2064
|
var BULK_LIMIT = 60;
|
|
1482
2065
|
var UNRELEASED = "Unreleased";
|
|
@@ -1668,11 +2251,11 @@ function modeLabel(modes) {
|
|
|
1668
2251
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
1669
2252
|
}
|
|
1670
2253
|
function tokenHistory(entries, path, config) {
|
|
1671
|
-
const
|
|
2254
|
+
const names2 = /* @__PURE__ */ new Set([path]);
|
|
1672
2255
|
const out = [];
|
|
1673
2256
|
const sorted = [...entries].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
|
1674
2257
|
for (const e of sorted) {
|
|
1675
|
-
const hits = e.changes.filter((c) =>
|
|
2258
|
+
const hits = e.changes.filter((c) => names2.has(c.path));
|
|
1676
2259
|
for (const c of hits) {
|
|
1677
2260
|
const modes = config ? fileModes(config, c.file) : {};
|
|
1678
2261
|
out.push({
|
|
@@ -1700,7 +2283,7 @@ function tokenHistory(entries, path, config) {
|
|
|
1700
2283
|
...c.descriptionAfter !== void 0 ? { descriptionAfter: c.descriptionAfter } : {}
|
|
1701
2284
|
});
|
|
1702
2285
|
}
|
|
1703
|
-
for (const c of hits) if (c.kind === "renamed" && c.from)
|
|
2286
|
+
for (const c of hits) if (c.kind === "renamed" && c.from) names2.add(c.from);
|
|
1704
2287
|
}
|
|
1705
2288
|
return out;
|
|
1706
2289
|
}
|
|
@@ -2732,7 +3315,7 @@ ${text.trim()}
|
|
|
2732
3315
|
] : [],
|
|
2733
3316
|
...options.rewrite ? ["Rewrite each one from the facts and the source: keep what is true in the current text, drop what the code does not support."] : []
|
|
2734
3317
|
] : [`Write the missing prose of ${prefill.component}.docs.json for the Bridge design system.`];
|
|
2735
|
-
const
|
|
3318
|
+
const shape2 = keys ? [
|
|
2736
3319
|
`- ${check.length ? "Return" : "Write"} only these keys: ${keys.join(", ")}${check.length ? ` (the checked ones, ${check.join(", ")}, only when they need a fix)` : ""}. Never return anatomy, preview or any other key: code decides them.`,
|
|
2737
3320
|
`- Answer with one JSON object holding only those keys, nothing else: ${docsSectionShapes(keys)}.`
|
|
2738
3321
|
] : [
|
|
@@ -2746,7 +3329,7 @@ ${text.trim()}
|
|
|
2746
3329
|
"- Ground every sentence in the facts and the source below. Never invent props, variants, keyboard behavior or tokens that are not there.",
|
|
2747
3330
|
"- Bridge voice: sentence case, plain words, verbs first, no em dashes, no marketing words.",
|
|
2748
3331
|
'- Keep the keys and shapes of the example docs.json. dos and donts are { "title", "text" } objects; add an "example" only with components and props that exist.',
|
|
2749
|
-
...
|
|
3332
|
+
...shape2,
|
|
2750
3333
|
"",
|
|
2751
3334
|
"Facts computed from the code:",
|
|
2752
3335
|
facts.join("\n\n"),
|
|
@@ -2778,6 +3361,30 @@ function formatDocsJson(d) {
|
|
|
2778
3361
|
return `${JSON.stringify(out, null, 2)}
|
|
2779
3362
|
`;
|
|
2780
3363
|
}
|
|
3364
|
+
function patchNode(old, next) {
|
|
3365
|
+
if (plainEqual(toPlain(old), next)) return old;
|
|
3366
|
+
if (old.kind === "object" && next && typeof next === "object" && !Array.isArray(next)) {
|
|
3367
|
+
const n = next;
|
|
3368
|
+
const kept = old.entries.filter((e) => n[e.key] !== void 0).map((e) => ({ key: e.key, value: patchNode(e.value, n[e.key]) }));
|
|
3369
|
+
const added = Object.keys(n).filter((k) => n[k] !== void 0 && !getEntry(old, k)).map((k) => ({ key: k, value: fromPlain(n[k], void 0, old.inline) }));
|
|
3370
|
+
return { kind: "object", inline: old.inline, entries: [...kept, ...added] };
|
|
3371
|
+
}
|
|
3372
|
+
if (old.kind === "array" && Array.isArray(next)) {
|
|
3373
|
+
return { kind: "array", inline: old.inline, items: next.map((v, i) => old.items[i] ? patchNode(old.items[i], v) : fromPlain(v, old.items[0], old.inline)) };
|
|
3374
|
+
}
|
|
3375
|
+
return fromPlain(next, old);
|
|
3376
|
+
}
|
|
3377
|
+
function patchDocsJson(original, next) {
|
|
3378
|
+
const plain = JSON.parse(formatDocsJson(next));
|
|
3379
|
+
if (!original) return formatDocsJson(next);
|
|
3380
|
+
try {
|
|
3381
|
+
const doc = parseJson(original);
|
|
3382
|
+
if (doc.root.kind !== "object") return formatDocsJson(next);
|
|
3383
|
+
return serializeJson({ ...doc, root: patchNode(doc.root, plain) });
|
|
3384
|
+
} catch {
|
|
3385
|
+
return formatDocsJson(next);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
2781
3388
|
|
|
2782
3389
|
// ../core/src/change-drafts.ts
|
|
2783
3390
|
function cleanDraftText(text) {
|
|
@@ -3046,8 +3653,11 @@ function explorationStatus(p) {
|
|
|
3046
3653
|
if (p.state === "open" && labelNames(p.labels).includes(EXPLORATION_LABEL)) return "active";
|
|
3047
3654
|
return "promoted";
|
|
3048
3655
|
}
|
|
3656
|
+
var isActiveExploration = (labels) => labelNames(labels).includes(EXPLORATION_LABEL);
|
|
3049
3657
|
function promotionPlan(input) {
|
|
3050
3658
|
const exploration = parseExplorationBody(input.body);
|
|
3659
|
+
const jira = /^([A-Z][A-Z0-9_]*-\d+)\s+/.exec(input.title)?.[1] ?? null;
|
|
3660
|
+
const title = jira ? input.title.slice(jira.length).trim() : input.title;
|
|
3051
3661
|
const removeFiles = input.files.filter((f) => f.path.startsWith(`${EXPLORATION_NOTES_DIR}/`) && f.status !== "removed").map((f) => f.path);
|
|
3052
3662
|
const files = input.files.filter((f) => !f.path.startsWith(`${EXPLORATION_NOTES_DIR}/`));
|
|
3053
3663
|
const risk = classifyRisk({ files, ...input.config ? { config: input.config } : {} });
|
|
@@ -3060,13 +3670,13 @@ function promotionPlan(input) {
|
|
|
3060
3670
|
const body = [
|
|
3061
3671
|
`**Promoted from an exploration on ${date}.** Review it like any change: the tier below comes from the files it touches.`,
|
|
3062
3672
|
"",
|
|
3063
|
-
kept || `## ${
|
|
3673
|
+
kept || `## ${title.replace(/^Exploration:\s*/i, "")}`,
|
|
3064
3674
|
"",
|
|
3065
3675
|
`**Risk tier:** ${risk.tier}.`,
|
|
3066
3676
|
...risk.reasons.slice(0, 8).map((r) => `- ${r}`),
|
|
3067
3677
|
...closes.length ? ["", ...closes.map((n) => `Closes #${n}`)] : []
|
|
3068
3678
|
].join("\n");
|
|
3069
|
-
const t =
|
|
3679
|
+
const t = title.replace(/^Exploration:\s*/i, "").trim() || exploration?.name || title;
|
|
3070
3680
|
return {
|
|
3071
3681
|
tier: risk.tier,
|
|
3072
3682
|
reasons: risk.reasons,
|
|
@@ -3074,7 +3684,7 @@ function promotionPlan(input) {
|
|
|
3074
3684
|
addLabels,
|
|
3075
3685
|
removeFiles,
|
|
3076
3686
|
body,
|
|
3077
|
-
title: t.charAt(0).toUpperCase()
|
|
3687
|
+
title: `${jira ? `${jira} ` : ""}${t.charAt(0).toUpperCase()}${t.slice(1)}`,
|
|
3078
3688
|
markReady: true
|
|
3079
3689
|
};
|
|
3080
3690
|
}
|
|
@@ -3435,7 +4045,7 @@ function explorationSummary(p, _owner, _repo, s) {
|
|
|
3435
4045
|
url: p.url,
|
|
3436
4046
|
branch: p.headRef,
|
|
3437
4047
|
slug: slug2,
|
|
3438
|
-
name: e?.name ?? p.title.replace(/^Exploration:\s*/i, ""),
|
|
4048
|
+
name: e?.name ?? p.title.replace(/^[A-Z][A-Z0-9_]*-\d+\s+/, "").replace(/^Exploration:\s*/i, ""),
|
|
3439
4049
|
purpose: e?.purpose ?? "",
|
|
3440
4050
|
...e?.useCase ? { useCase: e.useCase } : {},
|
|
3441
4051
|
status: explorationStatus({ state: p.state, labels: p.labels, headRef: p.headRef, body: p.body }),
|
|
@@ -3472,7 +4082,8 @@ Exploration: never merged as is. Promote it to review or close it.`,
|
|
|
3472
4082
|
files: [{ path: explorationNotePath(slug2), content: explorationNote(full) }]
|
|
3473
4083
|
});
|
|
3474
4084
|
const ctx = { owner: gh.owner, repo: gh.name, packages: s.packages, apps: s.apps, ...s.requests?.length ? { requests: s.requests } : {} };
|
|
3475
|
-
const
|
|
4085
|
+
const title = full.jira ? `${full.jira.trim().toUpperCase()} ${explorationPrTitle(full)}` : explorationPrTitle(full);
|
|
4086
|
+
const pr = await x.createPullRequest({ title, head: branch, base: s.base, body: explorationPrBody(full, ctx), draft: true });
|
|
3476
4087
|
await x.addLabels(pr.number, [EXPLORATION_LABEL]);
|
|
3477
4088
|
await x.updatePullRequest(pr.number, { body: explorationPrBody(full, { ...ctx, pr: pr.number }) }).catch(() => void 0);
|
|
3478
4089
|
return {
|
|
@@ -3535,6 +4146,699 @@ async function closeExplorationOnGitHub(gh, ref) {
|
|
|
3535
4146
|
return { number: p.number, url: p.url, branch: p.headRef };
|
|
3536
4147
|
}
|
|
3537
4148
|
|
|
4149
|
+
// ../core/src/review-gate.ts
|
|
4150
|
+
function parseTier(labels, body) {
|
|
4151
|
+
const found = [];
|
|
4152
|
+
for (const l of labels) {
|
|
4153
|
+
const m = l.name.match(/^(?:risk[\s:-]*)?tier[\s:_-]*([123])\b/i) ?? l.name.match(/^risk[\s:_-]*([123])$/i);
|
|
4154
|
+
if (m) found.push(Number(m[1]));
|
|
4155
|
+
}
|
|
4156
|
+
for (const m of (body ?? "").matchAll(/-\s*\[[xX]\]\s*\**\s*Tier\s*([123])\b/g)) found.push(Number(m[1]));
|
|
4157
|
+
for (const m of (body ?? "").matchAll(/risk tier\W{0,6}(?:tier\s*)?([123])\b/gi)) found.push(Number(m[1]));
|
|
4158
|
+
return found.length ? Math.max(...found) : null;
|
|
4159
|
+
}
|
|
4160
|
+
function pullState(p) {
|
|
4161
|
+
if (p.merged_at || p.merged) return "merged";
|
|
4162
|
+
if (p.state && p.state !== "open") return "closed";
|
|
4163
|
+
if (p.draft) return "draft";
|
|
4164
|
+
return "open";
|
|
4165
|
+
}
|
|
4166
|
+
function openPulls(pulls) {
|
|
4167
|
+
return pulls.filter((p) => {
|
|
4168
|
+
const s = pullState(p);
|
|
4169
|
+
return s === "open" || s === "draft";
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
var OK = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
|
|
4173
|
+
function summarizeCi(runs, statuses = []) {
|
|
4174
|
+
const failed = [];
|
|
4175
|
+
const pending = [];
|
|
4176
|
+
let passed = 0;
|
|
4177
|
+
for (const r of runs) {
|
|
4178
|
+
if (r.status !== "completed") pending.push({ name: r.name, url: r.html_url });
|
|
4179
|
+
else if (r.conclusion && OK.has(r.conclusion)) passed++;
|
|
4180
|
+
else failed.push({ name: r.name, url: r.html_url });
|
|
4181
|
+
}
|
|
4182
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4183
|
+
for (const s of statuses) {
|
|
4184
|
+
if (seen.has(s.context)) continue;
|
|
4185
|
+
seen.add(s.context);
|
|
4186
|
+
if (s.state === "success") passed++;
|
|
4187
|
+
else if (s.state === "pending") pending.push({ name: s.context, url: s.target_url ?? void 0 });
|
|
4188
|
+
else failed.push({ name: s.context, url: s.target_url ?? void 0 });
|
|
4189
|
+
}
|
|
4190
|
+
const total = passed + failed.length + pending.length;
|
|
4191
|
+
const state = total === 0 ? "none" : failed.length ? "fail" : pending.length ? "pending" : "pass";
|
|
4192
|
+
return { state, total, passed, failed, pending };
|
|
4193
|
+
}
|
|
4194
|
+
function summarizeApprovals(reviews, owners, author) {
|
|
4195
|
+
const latest = /* @__PURE__ */ new Map();
|
|
4196
|
+
const sorted = [...reviews].sort((a2, b) => String(a2.submitted_at ?? "").localeCompare(String(b.submitted_at ?? "")));
|
|
4197
|
+
for (const r of sorted) {
|
|
4198
|
+
const login = r.user?.login;
|
|
4199
|
+
if (!login || login === author) continue;
|
|
4200
|
+
if (r.state === "APPROVED" || r.state === "CHANGES_REQUESTED" || r.state === "DISMISSED") latest.set(login, r.state);
|
|
4201
|
+
}
|
|
4202
|
+
const approvers = [...latest].filter(([, s]) => s === "APPROVED").map(([l]) => l);
|
|
4203
|
+
const changesRequested = [...latest].filter(([, s]) => s === "CHANGES_REQUESTED").map(([l]) => l);
|
|
4204
|
+
const lower = (xs) => xs.map((x) => x.toLowerCase().replace(/^@/, ""));
|
|
4205
|
+
const d = lower(owners.design);
|
|
4206
|
+
const t = lower(owners.tech);
|
|
4207
|
+
const a = lower(approvers);
|
|
4208
|
+
let design;
|
|
4209
|
+
let tech;
|
|
4210
|
+
if (d.length || t.length) {
|
|
4211
|
+
design = d.length ? a.some((x) => d.includes(x)) : a.length > 0;
|
|
4212
|
+
const soloBoth = (x) => a.length === 1 && d.includes(x);
|
|
4213
|
+
tech = t.length ? a.some((x) => t.includes(x) && !soloBoth(x)) : a.length > 1;
|
|
4214
|
+
} else {
|
|
4215
|
+
design = a.length >= 1;
|
|
4216
|
+
tech = a.length >= 2;
|
|
4217
|
+
}
|
|
4218
|
+
return { approvers, changesRequested, design, tech };
|
|
4219
|
+
}
|
|
4220
|
+
var normLogin = (x) => x.trim().toLowerCase().replace(/^@/, "");
|
|
4221
|
+
function canOverride(login, policy) {
|
|
4222
|
+
if (!login || !policy?.allowed.length) return false;
|
|
4223
|
+
return policy.allowed.map(normLogin).includes(normLogin(login));
|
|
4224
|
+
}
|
|
4225
|
+
function mergeGate(input) {
|
|
4226
|
+
const blocking = [];
|
|
4227
|
+
const missing = [];
|
|
4228
|
+
const approvalReasons = [];
|
|
4229
|
+
const lifecycle = pullState(input);
|
|
4230
|
+
if (lifecycle === "merged") blocking.push("This branch is already merged.");
|
|
4231
|
+
else if (lifecycle === "closed") blocking.push("This branch is closed.");
|
|
4232
|
+
if (input.exploration && lifecycle !== "merged" && lifecycle !== "closed") blocking.push("It is an exploration: promote it to review first.");
|
|
4233
|
+
if (input.draft) blocking.push("It is a draft. Mark it ready for review on GitHub first.");
|
|
4234
|
+
if (input.ci.state === "fail") blocking.push(`Tests fail: ${input.ci.failed.map((f) => f.name).join(", ")}.`);
|
|
4235
|
+
if (input.ci.state === "pending") blocking.push("Tests are still running.");
|
|
4236
|
+
if (input.approvals.changesRequested.length) blocking.push(`Changes requested by @${input.approvals.changesRequested.join(", @")}.`);
|
|
4237
|
+
const tier = input.tier ?? 3;
|
|
4238
|
+
if (tier >= 2) {
|
|
4239
|
+
if (!input.approvals.design) {
|
|
4240
|
+
approvalReasons.push("It needs a design owner approval.");
|
|
4241
|
+
missing.push("design approval");
|
|
4242
|
+
}
|
|
4243
|
+
if (!input.approvals.tech) {
|
|
4244
|
+
approvalReasons.push("It needs a tech owner approval.");
|
|
4245
|
+
missing.push("tech approval");
|
|
4246
|
+
}
|
|
4247
|
+
} else if (!input.approvals.approvers.length) {
|
|
4248
|
+
approvalReasons.push("It needs one approval.");
|
|
4249
|
+
missing.push("approval");
|
|
4250
|
+
}
|
|
4251
|
+
const conflict = input.mergeable === false ? [CONFLICT_REASON] : [];
|
|
4252
|
+
const shipper = !input.viewer || canShip(input.viewer, input.ship);
|
|
4253
|
+
const ship = !shipper && lifecycle !== "merged" && lifecycle !== "closed" ? [whoCanShipSentence(input.ship), ...unreadableShipTeams(input.ship).map((t) => t.status === "unreadable" ? t.message : "")] : [];
|
|
4254
|
+
const reasons = [...blocking, ...approvalReasons, ...ship, ...conflict];
|
|
4255
|
+
const allowed = reasons.length === 0;
|
|
4256
|
+
const overridable = !allowed && blocking.length + conflict.length + ship.length === 0 && missing.length > 0 && canOverride(input.viewer, input.override);
|
|
4257
|
+
return { allowed, reasons, overridable, missing, canShip: shipper };
|
|
4258
|
+
}
|
|
4259
|
+
var CONFLICT_REASON = "It conflicts with main: resolve it with Update from main on the branch.";
|
|
4260
|
+
var OVERRIDE_HEADING = "**Bridge owner override**";
|
|
4261
|
+
function overrideReasonProblem(reason, policy) {
|
|
4262
|
+
if (!policy.requireReason) return null;
|
|
4263
|
+
const n = reason.trim().length;
|
|
4264
|
+
if (n === 0) return `Write why this merges without the missing approvals, in at least ${policy.minReasonLength} characters.`;
|
|
4265
|
+
if (n < policy.minReasonLength) return `Write at least ${policy.minReasonLength} characters (${n} so far), so the history says why.`;
|
|
4266
|
+
return null;
|
|
4267
|
+
}
|
|
4268
|
+
function overrideComment(login, reason, missing) {
|
|
4269
|
+
const why = reason.trim().replace(/\s+/g, " ").replace(/[.\s]+$/, "");
|
|
4270
|
+
return `${OVERRIDE_HEADING}
|
|
4271
|
+
|
|
4272
|
+
Owner override by @${normLogin(login)}: ${why || "no reason given"}. Missing: ${missing.join(", ")}. Merged from Bridge.`;
|
|
4273
|
+
}
|
|
4274
|
+
function overrideRefusedComment(body, message) {
|
|
4275
|
+
return body.replace(/ Merged from Bridge\.$/, ` Merge refused by GitHub: ${message.trim().replace(/[.\s]+$/, "")}. Not merged.`);
|
|
4276
|
+
}
|
|
4277
|
+
function allowedMergeMethods(repo) {
|
|
4278
|
+
const out = [];
|
|
4279
|
+
if (repo?.allow_squash_merge !== false) out.push("squash");
|
|
4280
|
+
if (repo?.allow_merge_commit !== false) out.push("merge");
|
|
4281
|
+
if (repo?.allow_rebase_merge !== false) out.push("rebase");
|
|
4282
|
+
return out;
|
|
4283
|
+
}
|
|
4284
|
+
function hasWriteAccess(perms) {
|
|
4285
|
+
return Boolean(perms && (perms.admin || perms.maintain || perms.push));
|
|
4286
|
+
}
|
|
4287
|
+
var REVIEW_STATUS_ORDER = {
|
|
4288
|
+
"ci-failing": 0,
|
|
4289
|
+
conflict: 1,
|
|
4290
|
+
"changes-requested": 2,
|
|
4291
|
+
"needs-design": 3,
|
|
4292
|
+
"needs-tech": 4,
|
|
4293
|
+
"needs-approval": 5,
|
|
4294
|
+
ready: 6,
|
|
4295
|
+
"ci-running": 7,
|
|
4296
|
+
checking: 8,
|
|
4297
|
+
unknown: 9,
|
|
4298
|
+
draft: 10,
|
|
4299
|
+
merged: 11,
|
|
4300
|
+
closed: 12
|
|
4301
|
+
};
|
|
4302
|
+
function reviewStatusKind(input) {
|
|
4303
|
+
const out = (kind) => kind;
|
|
4304
|
+
const lifecycle = pullState(input);
|
|
4305
|
+
if (lifecycle === "merged") return out("merged");
|
|
4306
|
+
if (lifecycle === "closed") return out("closed");
|
|
4307
|
+
if (lifecycle === "draft") return out("draft");
|
|
4308
|
+
if (input.ci?.state === "fail") return out("ci-failing");
|
|
4309
|
+
if (input.approvals?.changesRequested.length) return out("changes-requested");
|
|
4310
|
+
if (input.mergeable === false) return out("conflict");
|
|
4311
|
+
if (input.ci?.state === "pending") return out("ci-running");
|
|
4312
|
+
if (!input.ci || !input.approvals) return out("unknown");
|
|
4313
|
+
if (input.mergeable === null) return out("checking");
|
|
4314
|
+
const tier = input.tier ?? 3;
|
|
4315
|
+
if (tier >= 2) {
|
|
4316
|
+
if (!input.approvals.design) return out("needs-design");
|
|
4317
|
+
if (!input.approvals.tech) return out("needs-tech");
|
|
4318
|
+
} else if (!input.approvals.approvers.length) return out("needs-approval");
|
|
4319
|
+
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 });
|
|
4320
|
+
return gate.allowed ? out("ready") : out("unknown");
|
|
4321
|
+
}
|
|
4322
|
+
var BOTS = /* @__PURE__ */ new Set(["github-actions[bot]", "app/github-actions", "changeset-bot[bot]"]);
|
|
4323
|
+
var isReleasePull = (p) => p.head.ref.startsWith("changeset-release/");
|
|
4324
|
+
function isAutomationPull(p) {
|
|
4325
|
+
return isReleasePull(p) || BOTS.has((p.user?.login ?? "").toLowerCase());
|
|
4326
|
+
}
|
|
4327
|
+
var runningExploration = (p) => p.state === "open" && isActiveExploration(p.labels ?? []);
|
|
4328
|
+
function reviewQueue(pulls) {
|
|
4329
|
+
return openPulls(pulls).filter((p) => !isAutomationPull(p) && !runningExploration(p));
|
|
4330
|
+
}
|
|
4331
|
+
function effectiveTier(labels, body, files) {
|
|
4332
|
+
const labelled = parseTier(labels, body);
|
|
4333
|
+
if (!files) return labelled;
|
|
4334
|
+
const computed = classifyRisk({ files: files.map((f) => ({ path: f.filename, status: f.status })) }).tier;
|
|
4335
|
+
return Math.max(labelled ?? 0, computed);
|
|
4336
|
+
}
|
|
4337
|
+
var norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
|
|
4338
|
+
function reopenEligibility(x) {
|
|
4339
|
+
if (x.state !== "closed" || !x.viewer) return { kind: "none" };
|
|
4340
|
+
const isAuthor = norm(x.viewer) === norm(x.author);
|
|
4341
|
+
const isOwner = x.owners.some((o) => norm(o) === norm(x.viewer));
|
|
4342
|
+
if (!isAuthor && !isOwner) return { kind: "none" };
|
|
4343
|
+
if (!x.canWrite && !isAuthor) return { kind: "none" };
|
|
4344
|
+
if (x.branch === "exists") return { kind: "reopen" };
|
|
4345
|
+
if (x.branch === "gone") return { kind: "gone" };
|
|
4346
|
+
return { kind: "none" };
|
|
4347
|
+
}
|
|
4348
|
+
var NO_CI = { state: "pass", total: 0, passed: 0, failed: [], pending: [] };
|
|
4349
|
+
var ROLE_OF_MISSING = { "design approval": "design", "tech approval": "tech", approval: "approval" };
|
|
4350
|
+
function pendingApprovals(tier, approvals) {
|
|
4351
|
+
if (tier == null || !approvals) return [];
|
|
4352
|
+
return mergeGate({ tier, ci: NO_CI, approvals, draft: false, state: "open" }).missing.map((m) => ROLE_OF_MISSING[m]);
|
|
4353
|
+
}
|
|
4354
|
+
var uniqLogins = (xs) => xs.filter((x, i) => xs.findIndex((y) => normLogin(y) === normLogin(x)) === i);
|
|
4355
|
+
function eligibleReviewers(role, p) {
|
|
4356
|
+
const raw = role === "design" ? p.owners.design : role === "tech" ? p.owners.tech : uniqLogins([...p.owners.design, ...p.owners.tech]);
|
|
4357
|
+
const listed = raw.map((x) => x.replace(/^@/, ""));
|
|
4358
|
+
const approvers = (p.approvers ?? []).map(normLogin);
|
|
4359
|
+
const logins = listed.filter((l) => normLogin(l) !== normLogin(p.author) && !approvers.includes(normLogin(l)));
|
|
4360
|
+
return { open: listed.length === 0, logins, listed };
|
|
4361
|
+
}
|
|
4362
|
+
function reviewersToAsk(x) {
|
|
4363
|
+
const roles = pendingApprovals(x.tier, x.approvals);
|
|
4364
|
+
const all = roles.flatMap((r) => eligibleReviewers(r, { owners: x.owners, author: x.author, approvers: x.approvals.approvers }).logins);
|
|
4365
|
+
return uniqLogins(all).filter((l) => normLogin(l) !== normLogin(x.viewer ?? "") && normLogin(l) !== normLogin(x.author));
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
// ../core/src/library-update.ts
|
|
4369
|
+
var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4370
|
+
var names = (files) => files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
|
|
4371
|
+
function overlap(changeFiles, libraryFiles, isTokenFile2) {
|
|
4372
|
+
const lib = new Set(names(libraryFiles));
|
|
4373
|
+
const both = [...new Set(names(changeFiles).filter((f) => lib.has(f)))].sort();
|
|
4374
|
+
return { tokenFiles: both.filter(isTokenFile2), codeFiles: both.filter((f) => !isTokenFile2(f)) };
|
|
4375
|
+
}
|
|
4376
|
+
var COMPARE_FILE_LIMIT = 300;
|
|
4377
|
+
function conflictRoute(o, opts = {}) {
|
|
4378
|
+
if (opts.truncated || !o.tokenFiles.length && !o.codeFiles.length) return { kind: "code", alsoTokens: false, files: [] };
|
|
4379
|
+
if (o.codeFiles.length) return { kind: "code", alsoTokens: o.tokenFiles.length > 0, files: o.codeFiles };
|
|
4380
|
+
return { kind: "tokens", files: o.tokenFiles };
|
|
4381
|
+
}
|
|
4382
|
+
function resolutionMessage(input) {
|
|
4383
|
+
const lines = input.conflicts.map((c) => `- ${c.path} (${c.file}): ${choiceFor(c, input.choices) === "theirs" ? `used the ${input.library} value` : "kept this change"}`);
|
|
4384
|
+
const title = input.conflicts.length ? `Bring in the latest ${input.library} and resolve ${plural(input.conflicts.length, "token conflict")}` : `Bring in the latest ${input.library}`;
|
|
4385
|
+
const by = input.who ? `
|
|
4386
|
+
|
|
4387
|
+
Resolved in Bridge by @${input.who.replace(/^@/, "")}.` : "\n\nResolved in Bridge.";
|
|
4388
|
+
return `${title}${lines.length ? `
|
|
4389
|
+
|
|
4390
|
+
${lines.join("\n")}` : ""}${by}`;
|
|
4391
|
+
}
|
|
4392
|
+
function freshness(input) {
|
|
4393
|
+
const behindBy = typeof input.compare?.behind_by === "number" ? input.compare.behind_by : null;
|
|
4394
|
+
const mergeBase = input.compare?.merge_base_commit?.sha ?? null;
|
|
4395
|
+
if (!input.open) return { state: "current", behindBy, mergeBase };
|
|
4396
|
+
if (input.mergeable === false) return { state: "conflict", behindBy, mergeBase };
|
|
4397
|
+
if (behindBy === null) return { state: "unknown", behindBy, mergeBase };
|
|
4398
|
+
return { state: behindBy > 0 ? "behind" : "current", behindBy, mergeBase };
|
|
4399
|
+
}
|
|
4400
|
+
var withoutRoot = (root, p) => root && p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p;
|
|
4401
|
+
async function treeAt(gh, sha) {
|
|
4402
|
+
const t = await gh.getTree(await gh.getCommitTree(sha));
|
|
4403
|
+
if (t.truncated) throw new Error("This repository is too big for Bridge to read in one go. A developer resolves the conflict on GitHub.");
|
|
4404
|
+
return new Map(t.entries.filter((e) => e.type === "blob").map((e) => [e.path, e]));
|
|
4405
|
+
}
|
|
4406
|
+
async function textAt(gh, sha, path) {
|
|
4407
|
+
return await gh.readFile(path, sha) ?? "";
|
|
4408
|
+
}
|
|
4409
|
+
async function tokenFilesAt(gh, sha, root, tree) {
|
|
4410
|
+
const prefix = root ? `${root}/` : "";
|
|
4411
|
+
const paths = [...tree.keys()].filter((p) => p.startsWith(prefix) && p.endsWith(".tokens.json"));
|
|
4412
|
+
return Object.fromEntries(await Promise.all(paths.map(async (p) => [withoutRoot(root, p), await textAt(gh, sha, p)])));
|
|
4413
|
+
}
|
|
4414
|
+
async function configAt(gh, sha, tree) {
|
|
4415
|
+
if (!tree.has("bridge.config.json")) return null;
|
|
4416
|
+
try {
|
|
4417
|
+
return loadConfig(await textAt(gh, sha, "bridge.config.json"));
|
|
4418
|
+
} catch {
|
|
4419
|
+
return null;
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
async function readLibraryConflicts(gh, compare, headSha, opts = {}) {
|
|
4423
|
+
const { mergeBase, baseSha: mainSha, baseTree: mainTree } = compare;
|
|
4424
|
+
const changeFiles = compare.files;
|
|
4425
|
+
if (!mergeBase || !mainSha || !mainTree) return { route: conflictRoute({ tokenFiles: [], codeFiles: [] }) };
|
|
4426
|
+
const library = (await gh.compareDetailed(mergeBase, mainSha)).files;
|
|
4427
|
+
const truncated = changeFiles.length >= COMPARE_FILE_LIMIT || library.length >= COMPARE_FILE_LIMIT;
|
|
4428
|
+
const guess = opts.tokensRoot ?? DEFAULT_TOKEN_PATHS.root;
|
|
4429
|
+
const route = conflictRoute(
|
|
4430
|
+
overlap(changeFiles, library, (p) => p.endsWith(".tokens.json") && p.startsWith(`${guess}/`)),
|
|
4431
|
+
{ truncated }
|
|
4432
|
+
);
|
|
4433
|
+
if (route.kind !== "tokens") return { route };
|
|
4434
|
+
const [baseTree, headTree, theirsTree] = await Promise.all([treeAt(gh, mergeBase), treeAt(gh, headSha), treeAt(gh, mainSha)]);
|
|
4435
|
+
const config = await configAt(gh, mainSha, theirsTree) ?? await configAt(gh, mergeBase, baseTree);
|
|
4436
|
+
const root = config?.tokens.root ?? guess;
|
|
4437
|
+
const options = config ? layoutOptions(config) : {};
|
|
4438
|
+
const checksPath = `${root}/${config?.tokens.contrastChecks ?? DEFAULT_TOKEN_PATHS.contrastChecks}`;
|
|
4439
|
+
const [baseFiles, mineFiles, theirsFiles, checks] = await Promise.all([
|
|
4440
|
+
tokenFilesAt(gh, mergeBase, root, baseTree),
|
|
4441
|
+
tokenFilesAt(gh, headSha, root, headTree),
|
|
4442
|
+
tokenFilesAt(gh, mainSha, root, theirsTree),
|
|
4443
|
+
theirsTree.has(checksPath) ? textAt(gh, mainSha, checksPath) : Promise.resolve("")
|
|
4444
|
+
]);
|
|
4445
|
+
const input = { base: baseFiles, mine: mineFiles, theirs: theirsFiles, files: route.files.map((f) => withoutRoot(root, f)) };
|
|
4446
|
+
return {
|
|
4447
|
+
route,
|
|
4448
|
+
tokens: {
|
|
4449
|
+
root,
|
|
4450
|
+
plan: planTokenMerge(input),
|
|
4451
|
+
input,
|
|
4452
|
+
sets: { base: buildTokenSet(baseFiles, options), mine: buildTokenSet(mineFiles, options), theirs: buildTokenSet(theirsFiles, options) },
|
|
4453
|
+
mineFiles,
|
|
4454
|
+
theirsFiles,
|
|
4455
|
+
pairs: parseContrastChecks(checks || opts.contrastFallback),
|
|
4456
|
+
options
|
|
4457
|
+
},
|
|
4458
|
+
write: { mainSha, mainTree, headSha, headTree, changeFiles }
|
|
4459
|
+
};
|
|
4460
|
+
}
|
|
4461
|
+
function resolvedTokenFiles(data, choices) {
|
|
4462
|
+
const t = data.tokens;
|
|
4463
|
+
const merged = applyTokenMerge(t.input, choices).files;
|
|
4464
|
+
const all = { ...t.theirsFiles };
|
|
4465
|
+
for (const f of data.write.changeFiles) {
|
|
4466
|
+
for (const p of [f.previous_filename, f.filename]) {
|
|
4467
|
+
if (!p || !p.endsWith(".tokens.json") || !p.startsWith(`${t.root}/`)) continue;
|
|
4468
|
+
const rel = withoutRoot(t.root, p);
|
|
4469
|
+
if (rel in merged) continue;
|
|
4470
|
+
if (t.mineFiles[rel] === void 0) delete all[rel];
|
|
4471
|
+
else all[rel] = t.mineFiles[rel];
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
for (const [rel, text] of Object.entries(merged)) {
|
|
4475
|
+
if (text === null) delete all[rel];
|
|
4476
|
+
else all[rel] = text;
|
|
4477
|
+
}
|
|
4478
|
+
return { all, merged };
|
|
4479
|
+
}
|
|
4480
|
+
async function commitLibraryResolution(gh, pull, data, choices, who) {
|
|
4481
|
+
const t = data.tokens;
|
|
4482
|
+
const w = data.write;
|
|
4483
|
+
if (!t || !w) throw new Error("Only a token conflict can be resolved in Bridge. A developer resolves code conflicts on GitHub.");
|
|
4484
|
+
if (w.headSha !== pull.head.sha) throw new Error("The branch moved while you were choosing. Read it again and resolve again.");
|
|
4485
|
+
const { merged } = resolvedTokenFiles(data, choices);
|
|
4486
|
+
const shared = new Set(data.route.files);
|
|
4487
|
+
const tree = [];
|
|
4488
|
+
for (const f of w.changeFiles) {
|
|
4489
|
+
if (shared.has(f.filename)) continue;
|
|
4490
|
+
if (f.previous_filename && !shared.has(f.previous_filename)) tree.push({ path: f.previous_filename, mode: "100644", sha: null });
|
|
4491
|
+
if (f.status === "removed") {
|
|
4492
|
+
tree.push({ path: f.filename, mode: "100644", sha: null });
|
|
4493
|
+
continue;
|
|
4494
|
+
}
|
|
4495
|
+
const e = w.headTree.get(f.filename);
|
|
4496
|
+
if (!e) throw new Error(`Bridge could not read ${f.filename} in the branch. Read it again and try again.`);
|
|
4497
|
+
tree.push({ path: e.path, mode: e.mode, sha: e.sha });
|
|
4498
|
+
}
|
|
4499
|
+
for (const [rel, text] of Object.entries(merged)) {
|
|
4500
|
+
const path = t.root ? `${t.root}/${rel}` : rel;
|
|
4501
|
+
const mode = w.headTree.get(path)?.mode ?? "100644";
|
|
4502
|
+
tree.push(text === null ? { path, mode, sha: null } : { path, mode, content: text });
|
|
4503
|
+
}
|
|
4504
|
+
const newTree = await gh.createTreeEntries(w.mainTree, tree);
|
|
4505
|
+
const message = resolutionMessage({ library: pull.base.ref, conflicts: t.plan.conflicts, choices, who });
|
|
4506
|
+
const commit = await gh.createCommit(message, newTree, [w.headSha, w.mainSha]);
|
|
4507
|
+
await gh.updateBranch(pull.head.ref, commit);
|
|
4508
|
+
return commit;
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
// ../core/src/branch-actions.ts
|
|
4512
|
+
async function mergeOnGitHub(gh, input) {
|
|
4513
|
+
const r = await gh.mergePullRequest(input.number, { method: input.method ?? "squash", sha: input.headSha });
|
|
4514
|
+
return { sha: r.sha };
|
|
4515
|
+
}
|
|
4516
|
+
var OverrideMergeError = class extends Error {
|
|
4517
|
+
constructor(stage, message, cause, commentUrl) {
|
|
4518
|
+
super(message);
|
|
4519
|
+
this.stage = stage;
|
|
4520
|
+
this.cause = cause;
|
|
4521
|
+
this.commentUrl = commentUrl;
|
|
4522
|
+
this.name = "OverrideMergeError";
|
|
4523
|
+
}
|
|
4524
|
+
stage;
|
|
4525
|
+
cause;
|
|
4526
|
+
commentUrl;
|
|
4527
|
+
};
|
|
4528
|
+
async function overrideMergeOnGitHub(gh, input) {
|
|
4529
|
+
const body = overrideComment(input.login, input.reason, input.missing);
|
|
4530
|
+
let comment;
|
|
4531
|
+
try {
|
|
4532
|
+
comment = await gh.createIssueComment(input.number, body);
|
|
4533
|
+
} catch (err) {
|
|
4534
|
+
throw new OverrideMergeError("comment", `GitHub did not take the comment, so nothing merged: ${err.message}`, err);
|
|
4535
|
+
}
|
|
4536
|
+
try {
|
|
4537
|
+
const r = await gh.mergePullRequest(input.number, { method: input.method, sha: input.headSha, commitMessage: body.replace(OVERRIDE_HEADING, "").trim() });
|
|
4538
|
+
return { sha: r.sha, commentUrl: comment.url, comment: body };
|
|
4539
|
+
} catch (err) {
|
|
4540
|
+
const msg = err.message.trim().replace(/[.\s]+$/, "");
|
|
4541
|
+
await gh.updateIssueComment(comment.id, overrideRefusedComment(body, msg)).catch(() => void 0);
|
|
4542
|
+
throw new OverrideMergeError(
|
|
4543
|
+
"merge",
|
|
4544
|
+
`GitHub refused to merge it: ${msg}. Your reason stays on the branch, marked not merged. The protection of main may need an approving review or an admin bypass.`,
|
|
4545
|
+
err,
|
|
4546
|
+
comment.url
|
|
4547
|
+
);
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
async function reviewOnGitHub(gh, input) {
|
|
4551
|
+
const text = input.body?.trim() ?? "";
|
|
4552
|
+
if (input.event === "REQUEST_CHANGES" && !text) throw new Error("Add a comment that says what to change.");
|
|
4553
|
+
if (input.event === "COMMENT" && !text) throw new Error("Write a comment first.");
|
|
4554
|
+
return gh.submitReview(input.number, { event: input.event, body: text || void 0, commitId: input.headSha });
|
|
4555
|
+
}
|
|
4556
|
+
async function commentOnGitHub(gh, n, body) {
|
|
4557
|
+
const text = body.trim();
|
|
4558
|
+
if (!text) throw new Error("Write the comment first.");
|
|
4559
|
+
return gh.createIssueComment(n, text);
|
|
4560
|
+
}
|
|
4561
|
+
async function askOthersToReviewOnGitHub(gh, input) {
|
|
4562
|
+
if (!input.others.length) throw new Error("Nobody else can give the missing approval. Review it, or ask an owner in Settings to add a reviewer.");
|
|
4563
|
+
await gh.requestReviewers(input.number, input.others);
|
|
4564
|
+
if (input.viewer && input.viewerRequested) await gh.removeRequestedReviewers(input.number, [input.viewer]).catch(() => void 0);
|
|
4565
|
+
return input.others;
|
|
4566
|
+
}
|
|
4567
|
+
async function closeOnGitHub(gh, n) {
|
|
4568
|
+
await gh.updatePullRequest(n, { state: "closed" });
|
|
4569
|
+
}
|
|
4570
|
+
async function reopenOnGitHub(gh, n) {
|
|
4571
|
+
await gh.updatePullRequest(n, { state: "open" });
|
|
4572
|
+
}
|
|
4573
|
+
var sameLogin = (a, b) => Boolean(a && b && a.toLowerCase().replace(/^@/, "") === b.toLowerCase().replace(/^@/, ""));
|
|
4574
|
+
function canAddTo(p, viewer, write) {
|
|
4575
|
+
if (!viewer) return false;
|
|
4576
|
+
return write || sameLogin(p.user?.login, viewer);
|
|
4577
|
+
}
|
|
4578
|
+
function addedComment(paths, viewer, from = "the Bridge token editor") {
|
|
4579
|
+
const lines = paths.slice(0, 20).map((p) => `- \`${p}\``);
|
|
4580
|
+
if (paths.length > 20) lines.push(`- and ${paths.length - 20} more`);
|
|
4581
|
+
return `${viewer ? `@${viewer} added` : "Added"} token edits from ${from}:
|
|
4582
|
+
|
|
4583
|
+
${lines.join("\n")}`;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
// ../core/src/jira.ts
|
|
4587
|
+
var JIRA_KEY = /^[A-Z][A-Z0-9_]{0,9}-[1-9]\d{0,6}$/;
|
|
4588
|
+
function parseJiraKey(input) {
|
|
4589
|
+
const t = (input ?? "").trim().toUpperCase();
|
|
4590
|
+
if (!t) return null;
|
|
4591
|
+
if (!JIRA_KEY.test(t)) throw new Error("Write the Jira ticket as its key, like DS-42, or leave it empty.");
|
|
4592
|
+
return t;
|
|
4593
|
+
}
|
|
4594
|
+
var LEADING_KEY = /^\[?([A-Z][A-Z0-9_]{0,9}-[1-9]\d{0,6})\]?[:\s]\s*/;
|
|
4595
|
+
var withoutJiraKey = (title) => title.trim().replace(LEADING_KEY, "");
|
|
4596
|
+
function titleWithJiraKey(title, key) {
|
|
4597
|
+
if (!key) return title;
|
|
4598
|
+
return `${key} ${withoutJiraKey(title)}`;
|
|
4599
|
+
}
|
|
4600
|
+
function branchWithJiraKey(branch, key) {
|
|
4601
|
+
if (!key || branch.includes(key)) return branch;
|
|
4602
|
+
const i = branch.lastIndexOf("/");
|
|
4603
|
+
return i < 0 ? `${key}-${branch}` : `${branch.slice(0, i + 1)}${key}-${branch.slice(i + 1)}`;
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
// ../core/src/docs-proposal.ts
|
|
4607
|
+
var DOCS_EDITOR_ORIGIN = {
|
|
4608
|
+
where: "in the Bridge docs editor",
|
|
4609
|
+
footer: 'Proposed with the Bridge docs editor by a person (no AI involved in this text unless the docs say "draftedBy").',
|
|
4610
|
+
label: "source: bridge-web"
|
|
4611
|
+
};
|
|
4612
|
+
var DOCS_MCP_ORIGIN = {
|
|
4613
|
+
where: "with Claude through the Bridge MCP server",
|
|
4614
|
+
footer: 'Proposed with the Bridge MCP server on behalf of a person. Docs Claude drafted say "draftedBy": "claude" until a reviewer accepts them.',
|
|
4615
|
+
label: "source: mcp"
|
|
4616
|
+
};
|
|
4617
|
+
var EM_DASH = /\u2014/;
|
|
4618
|
+
function validateDocs(d) {
|
|
4619
|
+
const out = [];
|
|
4620
|
+
if (!d.summary.trim()) out.push({ field: "summary", message: "Add a summary: one or two sentences on what it is for." });
|
|
4621
|
+
const lists = ["whenToUse", "whenNotToUse", "accessibility", "contentGuidelines"];
|
|
4622
|
+
for (const k of lists) (d[k] ?? []).forEach((x, i) => !x.trim() && out.push({ field: `${k}.${i}`, message: "Fill or remove the empty item." }));
|
|
4623
|
+
for (const k of ["dos", "donts"]) d[k].forEach((g, i) => (!g.title.trim() || !g.text.trim()) && out.push({ field: `${k}.${i}`, message: "Give every item a title and a sentence." }));
|
|
4624
|
+
const prose = JSON.stringify([d.summary, d.whenToUse, d.whenNotToUse, d.dos, d.donts, d.accessibility, d.contentGuidelines ?? [], d.anatomy ?? {}]);
|
|
4625
|
+
if (EM_DASH.test(prose)) out.push({ field: "voice", message: "Replace the em dashes with a colon, a comma or two sentences." });
|
|
4626
|
+
return out;
|
|
4627
|
+
}
|
|
4628
|
+
function cleanDocs(d) {
|
|
4629
|
+
const list = (xs) => (xs ?? []).map((x) => x.trim()).filter(Boolean);
|
|
4630
|
+
const guide = (xs) => xs.map((g) => ({ ...g, title: g.title.trim(), text: g.text.trim() })).filter((g) => g.title || g.text);
|
|
4631
|
+
return {
|
|
4632
|
+
...d,
|
|
4633
|
+
summary: d.summary.trim(),
|
|
4634
|
+
whenToUse: list(d.whenToUse),
|
|
4635
|
+
whenNotToUse: list(d.whenNotToUse),
|
|
4636
|
+
dos: guide(d.dos),
|
|
4637
|
+
donts: guide(d.donts),
|
|
4638
|
+
accessibility: list(d.accessibility),
|
|
4639
|
+
contentGuidelines: list(d.contentGuidelines),
|
|
4640
|
+
anatomy: Object.fromEntries(Object.entries(d.anatomy ?? {}).filter(([k, v]) => k.trim() && v.trim()))
|
|
4641
|
+
};
|
|
4642
|
+
}
|
|
4643
|
+
var docsFileText = (d, original) => patchDocsJson(original, cleanDocs(d));
|
|
4644
|
+
var slugDate = (d) => d.toISOString().slice(0, 10).replace(/-/g, "");
|
|
4645
|
+
function docsProposal(edits, opts) {
|
|
4646
|
+
const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
|
|
4647
|
+
if (!edits.length) throw new Error("No docs edits in the draft.");
|
|
4648
|
+
const names2 = edits.map((e) => e.component);
|
|
4649
|
+
const list = names2.length === 1 ? names2[0] : `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
|
|
4650
|
+
const title = opts.title?.trim() || `docs(${names2.length === 1 ? edits[0].slug : "components"}): usage guidance for ${list}`;
|
|
4651
|
+
const slug2 = `${names2.length === 1 ? edits[0].slug : `${edits[0].slug}-and-${names2.length - 1}-more`}`.toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
|
|
4652
|
+
const branch = `docs/${slug2}-${slugDate(opts.date)}-${opts.date.getTime().toString(36).slice(-4)}`;
|
|
4653
|
+
const csPath = `${opts.changesetDir ?? ".changeset"}/docs-${slug2}-${opts.date.getTime().toString(36)}.md`;
|
|
4654
|
+
const pkg = opts.packageName ?? "@bridge-ds/react";
|
|
4655
|
+
const changeset = `---
|
|
4656
|
+
"${pkg}": patch
|
|
4657
|
+
---
|
|
4658
|
+
|
|
4659
|
+
Usage guidance for ${list}.
|
|
4660
|
+
`;
|
|
4661
|
+
const files = [...edits.map((e) => ({ path: e.path, content: e.after })), { path: csPath, content: changeset }];
|
|
4662
|
+
const risk = classifyRisk({ files: files.map((f) => ({ path: f.path, status: "modified" })) });
|
|
4663
|
+
const rows = edits.map((e) => {
|
|
4664
|
+
let state = "edited";
|
|
4665
|
+
try {
|
|
4666
|
+
const d = JSON.parse(e.after);
|
|
4667
|
+
const gaps = missingProse(d);
|
|
4668
|
+
state = gaps.length ? `still missing: ${gaps.join(", ")}` : "every section filled";
|
|
4669
|
+
} catch {
|
|
4670
|
+
state = "not valid JSON";
|
|
4671
|
+
}
|
|
4672
|
+
return `- \`${e.path}\` (${e.before === null ? "new" : "changed"}, ${state})`;
|
|
4673
|
+
});
|
|
4674
|
+
const body = [
|
|
4675
|
+
`## Docs change`,
|
|
4676
|
+
"",
|
|
4677
|
+
`Usage guidance for ${list}, written ${origin.where}.`,
|
|
4678
|
+
"",
|
|
4679
|
+
...rows,
|
|
4680
|
+
"",
|
|
4681
|
+
`**Risk:** ${risk.label}. Docs only: the published components do not change. Changeset: patch.`,
|
|
4682
|
+
"",
|
|
4683
|
+
opts.notes?.trim() ? `## Notes
|
|
4684
|
+
|
|
4685
|
+
${opts.notes.trim()}
|
|
4686
|
+
` : "",
|
|
4687
|
+
"---",
|
|
4688
|
+
origin.footer
|
|
4689
|
+
].filter((x, i, a) => !(x === "" && a[i - 1] === "")).join("\n");
|
|
4690
|
+
return { branch, title, body, files, risk, bump: "patch", labels: ["docs", origin.label, `tier-${risk.tier}`] };
|
|
4691
|
+
}
|
|
4692
|
+
async function proposeDocsOnGitHub(gh, edits, opts) {
|
|
4693
|
+
const p = docsProposal(edits, { title: opts.title, notes: opts.notes, date: opts.date ?? /* @__PURE__ */ new Date(), origin: opts.origin });
|
|
4694
|
+
const prTitle = titleWithJiraKey(p.title, opts.jira ?? null);
|
|
4695
|
+
const branch = branchWithJiraKey(p.branch, opts.jira ?? null);
|
|
4696
|
+
const mainSha = await gh.getBranchSha(opts.base);
|
|
4697
|
+
if (!mainSha) throw new Error(`Branch ${opts.base} not found.`);
|
|
4698
|
+
const tree = await gh.createTree(await gh.getCommitTree(mainSha), p.files.map((f) => ({ path: f.path, content: f.content })));
|
|
4699
|
+
const commit = await gh.createCommit(`${prTitle}
|
|
4700
|
+
|
|
4701
|
+
${opts.commitNote ?? "Proposed with the Bridge docs editor."}`, tree, [mainSha]);
|
|
4702
|
+
await gh.createBranch(branch, commit);
|
|
4703
|
+
const pr = await gh.createPullRequest({ title: prTitle, head: branch, base: opts.base, body: p.body, draft: false });
|
|
4704
|
+
await gh.addLabels(pr.number, [...p.labels, ...opts.extraLabels ?? []]).catch(() => false);
|
|
4705
|
+
return { number: pr.number, url: pr.url, branch, title: prTitle };
|
|
4706
|
+
}
|
|
4707
|
+
async function commitFileOnGitHub(gh, input) {
|
|
4708
|
+
const cur = await gh.getFile(input.path, input.branch);
|
|
4709
|
+
if (!cur) throw new Error(`${input.path} is not on ${input.branch}. Check the component name and the branch.`);
|
|
4710
|
+
const content = input.next(cur.text);
|
|
4711
|
+
if (content === cur.text) return "";
|
|
4712
|
+
return gh.putFile({ path: input.path, content, branch: input.branch, message: input.message, sha: cur.sha });
|
|
4713
|
+
}
|
|
4714
|
+
|
|
4715
|
+
// ../core/src/share-branch.ts
|
|
4716
|
+
var packageDir = (name) => `packages/${name.replace(/^@[^/]+\//, "")}/`;
|
|
4717
|
+
function touchesPackages(files, packages) {
|
|
4718
|
+
const dirs = packages.map(packageDir);
|
|
4719
|
+
return files.some((f) => dirs.some((d) => f.startsWith(d)));
|
|
4720
|
+
}
|
|
4721
|
+
function shareState(x) {
|
|
4722
|
+
if (!touchesPackages(x.files, x.packages)) return { kind: "no-packages" };
|
|
4723
|
+
const c = readPreviewComment(x.comment);
|
|
4724
|
+
if (x.lifecycle === "merged" || x.lifecycle === "closed") {
|
|
4725
|
+
const kind = x.lifecycle === "merged" ? "shipped" : "closed";
|
|
4726
|
+
if (!c || !c.command || c.source === "none") return { kind, stillWorks: null, command: null };
|
|
4727
|
+
return { kind, stillWorks: c.source === "pkg.pr.new" ? true : c.source === "release" ? false : null, command: c.command };
|
|
4728
|
+
}
|
|
4729
|
+
if (!c) return { kind: "building" };
|
|
4730
|
+
if (c.sha7 && !x.headSha.startsWith(c.sha7)) return { kind: "building" };
|
|
4731
|
+
if (!c.command || c.source === "none") return { kind: "failed" };
|
|
4732
|
+
return { kind: "ready", command: c.command, source: c.source };
|
|
4733
|
+
}
|
|
4734
|
+
var stableCommand = (packages) => `npm i ${packages.map((p) => `${p}@latest`).join(" ")}`;
|
|
4735
|
+
function stateSentence(s) {
|
|
4736
|
+
switch (s.kind) {
|
|
4737
|
+
case "ready":
|
|
4738
|
+
return null;
|
|
4739
|
+
case "building":
|
|
4740
|
+
return "The preview version is being prepared. It is usually ready a couple of minutes after each update to the branch.";
|
|
4741
|
+
case "failed":
|
|
4742
|
+
return "The preview version could not be prepared for the latest update. Update the branch to try again, or ask a developer to run it again on GitHub.";
|
|
4743
|
+
case "no-packages":
|
|
4744
|
+
return "This branch does not touch the library tokens or components, so there is no preview version to install.";
|
|
4745
|
+
case "shipped":
|
|
4746
|
+
return s.stillWorks === true ? "This branch was merged. Its preview version still installs, and products get it with the next library release." : s.stillWorks === false ? "This branch was merged, so its preview version was removed. Products get it with the next library release." : "This branch was merged. Products get it with the next library release.";
|
|
4747
|
+
case "closed":
|
|
4748
|
+
return s.stillWorks === true ? "This branch was closed without merging. Its preview version still installs, but it will not reach the library." : s.stillWorks === false ? "This branch was closed without merging, so its preview version was removed." : "This branch was closed without merging.";
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
var quote = (t) => `"${t.trim().replace(/"/g, "'")}"`;
|
|
4752
|
+
function shareMessage(x) {
|
|
4753
|
+
const back = stableCommand(x.packages);
|
|
4754
|
+
const s = x.state;
|
|
4755
|
+
if (s.kind === "ready") {
|
|
4756
|
+
const lasts = s.source === "pkg.pr.new" ? "This preview stays available. Each update to the change makes a new one: ask me for the latest command." : "This preview is replaced on each update to the change and removed when it ships or closes: ask me for the latest command if the install fails.";
|
|
4757
|
+
return {
|
|
4758
|
+
subject: `Try the design system change ${quote(x.title)} before it ships`,
|
|
4759
|
+
body: [
|
|
4760
|
+
"Hi,",
|
|
4761
|
+
"",
|
|
4762
|
+
`Could you try the design system change ${quote(x.title)} in our product before it ships?`,
|
|
4763
|
+
...x.tryUrl ? ["", `See it in the demo product first: ${x.tryUrl}`] : [],
|
|
4764
|
+
"",
|
|
4765
|
+
"Install the preview version in a staging branch (not production):",
|
|
4766
|
+
s.command,
|
|
4767
|
+
"",
|
|
4768
|
+
lasts,
|
|
4769
|
+
"",
|
|
4770
|
+
"To go back to the stable version:",
|
|
4771
|
+
back,
|
|
4772
|
+
"",
|
|
4773
|
+
`Details for developers: ${x.url}`
|
|
4774
|
+
].join("\n")
|
|
4775
|
+
};
|
|
4776
|
+
}
|
|
4777
|
+
if ((s.kind === "shipped" || s.kind === "closed") && s.command) {
|
|
4778
|
+
const what = s.kind === "shipped" ? `The design system change ${quote(x.title)} has shipped. You get it with the next library release.` : `The design system change ${quote(x.title)} was closed without shipping.`;
|
|
4779
|
+
const preview = s.stillWorks === false ? "Its preview version was removed, so reinstalling it fails." : s.stillWorks === true ? "Its preview version still installs, but it is not the released version." : "";
|
|
4780
|
+
return {
|
|
4781
|
+
subject: `Design system change ${quote(x.title)} ${s.kind === "shipped" ? "has shipped" : "was closed"}`,
|
|
4782
|
+
body: ["Hi,", "", [what, preview].filter(Boolean).join(" "), "", "If you installed its preview, go back to the stable version:", back, "", `Details for developers: ${x.url}`].join("\n")
|
|
4783
|
+
};
|
|
4784
|
+
}
|
|
4785
|
+
return null;
|
|
4786
|
+
}
|
|
4787
|
+
|
|
4788
|
+
// ../core/src/try-in-product.ts
|
|
4789
|
+
function tryInProductUrl(preview, pr, screen) {
|
|
4790
|
+
const url = new URL(preview);
|
|
4791
|
+
url.pathname = "/";
|
|
4792
|
+
url.search = "";
|
|
4793
|
+
url.searchParams.set("bridge", String(pr));
|
|
4794
|
+
if (screen) {
|
|
4795
|
+
url.searchParams.set("tenant", screen.tenant);
|
|
4796
|
+
url.searchParams.set("theme", screen.theme);
|
|
4797
|
+
}
|
|
4798
|
+
url.hash = screen ? screen.route : "";
|
|
4799
|
+
return url.href;
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
// ../core/src/combine-branches.ts
|
|
4803
|
+
var MIN_TOGETHER = 2;
|
|
4804
|
+
var MAX_TOGETHER = 4;
|
|
4805
|
+
function onlyTokens(files, tokensRoot) {
|
|
4806
|
+
const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
|
|
4807
|
+
return files.length > 0 && files.every((f) => f.startsWith(root) && f.endsWith(".tokens.json") || /contrast\.checks\.json$/.test(f) || f.startsWith(".changeset/"));
|
|
4808
|
+
}
|
|
4809
|
+
function tokenFilesOf(files, tokensRoot) {
|
|
4810
|
+
const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
|
|
4811
|
+
return files.filter((f) => f.startsWith(root) && f.endsWith(".tokens.json")).map((f) => f.slice(root.length));
|
|
4812
|
+
}
|
|
4813
|
+
var changeList = (numbers) => numbers.join(",");
|
|
4814
|
+
function tryTogetherUrl(admin, numbers, picks) {
|
|
4815
|
+
const url = new URL(admin);
|
|
4816
|
+
url.pathname = "/";
|
|
4817
|
+
url.hash = "";
|
|
4818
|
+
const use = encodePicks(picks);
|
|
4819
|
+
url.search = `?bridge=${changeList(numbers)}${use ? `&bridge-use=${encodeURIComponent(use)}` : ""}`;
|
|
4820
|
+
return url.href;
|
|
4821
|
+
}
|
|
4822
|
+
var COMBINE_COMPARE_LIMIT = 300;
|
|
4823
|
+
async function readCombineBranch(gh, pull, opts) {
|
|
4824
|
+
const compare = await gh.compareDetailed(opts.base, pull.headSha);
|
|
4825
|
+
const files = compare.files;
|
|
4826
|
+
const names2 = files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
|
|
4827
|
+
const start = compare.mergeBase;
|
|
4828
|
+
if (!start || files.length >= COMBINE_COMPARE_LIMIT || !onlyTokens(names2, opts.tokensRoot)) return { number: pull.number, title: pull.title, tokens: false, edits: null };
|
|
4829
|
+
const root = opts.tokensRoot.replace(/^\/+|\/+$/g, "");
|
|
4830
|
+
const tokenFiles = files.filter((f) => tokenFilesOf([f.filename], root).length);
|
|
4831
|
+
const source = { number: pull.number, base: {}, head: {}, files: tokenFiles.map((f) => tokenFilesOf([f.filename], root)[0]) };
|
|
4832
|
+
await Promise.all(
|
|
4833
|
+
tokenFiles.map(async (f) => {
|
|
4834
|
+
const rel = tokenFilesOf([f.filename], root)[0];
|
|
4835
|
+
source.base[rel] = f.status === "added" ? void 0 : await gh.readFile(f.previous_filename ?? f.filename, start);
|
|
4836
|
+
source.head[rel] = f.status === "removed" ? void 0 : await gh.readFile(f.filename, pull.headSha);
|
|
4837
|
+
})
|
|
4838
|
+
);
|
|
4839
|
+
return { number: pull.number, title: pull.title, tokens: true, edits: changeEdits(source) };
|
|
4840
|
+
}
|
|
4841
|
+
|
|
3538
4842
|
// ../core/src/node/git-history.ts
|
|
3539
4843
|
var US = "";
|
|
3540
4844
|
var RS = "";
|
|
@@ -4344,7 +5648,8 @@ function classifyTag(t, imports, ds) {
|
|
|
4344
5648
|
if (name === "a" && !t.hasHref) return {};
|
|
4345
5649
|
if (t.inRenderProp) return {};
|
|
4346
5650
|
const suggestion = nativeEquivalent(name, t.type, ds.components);
|
|
4347
|
-
|
|
5651
|
+
if (!suggestion) return {};
|
|
5652
|
+
return { component: { ...at, name, kind: "native", library: "native", suggestion } };
|
|
4348
5653
|
}
|
|
4349
5654
|
const root = name.split(".")[0];
|
|
4350
5655
|
const imp = imports.get(root);
|
|
@@ -5398,9 +6703,9 @@ var DesignSystemIndex = class {
|
|
|
5398
6703
|
return generic ? this.make(generic, 1, 0.3) : void 0;
|
|
5399
6704
|
}
|
|
5400
6705
|
case "shadow": {
|
|
5401
|
-
const
|
|
6706
|
+
const norm2 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
|
|
5402
6707
|
const cands = this.entries(["shadow"]);
|
|
5403
|
-
const exact = cands.find((e) =>
|
|
6708
|
+
const exact = cands.find((e) => norm2(e.css) === norm2(value));
|
|
5404
6709
|
if (exact) return this.make(exact, 0, 1);
|
|
5405
6710
|
const t = shadowNums(value);
|
|
5406
6711
|
const scored = cands.map((e) => {
|
|
@@ -5826,11 +7131,11 @@ async function listComponentNames(source, config) {
|
|
|
5826
7131
|
}
|
|
5827
7132
|
var findFamily = (stories) => stories ? /title:\s*['"]([^'"/]+)\//.exec(stories)?.[1] : void 0;
|
|
5828
7133
|
async function listComponents(source, config, withSummaries) {
|
|
5829
|
-
const
|
|
5830
|
-
if (!withSummaries) return
|
|
7134
|
+
const names2 = await listComponentNames(source, config);
|
|
7135
|
+
if (!withSummaries) return names2.map((n) => ({ name: n.name }));
|
|
5831
7136
|
const dir = config.components.dir;
|
|
5832
7137
|
return Promise.all(
|
|
5833
|
-
|
|
7138
|
+
names2.map(async ({ name, files }) => {
|
|
5834
7139
|
const docsPath = `${dir}/${name}/${name}${config.components.docsSuffix}`;
|
|
5835
7140
|
const storiesPath = files.find((f) => /\.stories\.\w+$/.test(f));
|
|
5836
7141
|
const [docs, stories] = await Promise.all([files.includes(docsPath) ? source.read(docsPath) : null, storiesPath ? source.read(storiesPath) : null]);
|
|
@@ -6285,6 +7590,454 @@ __export(explorations_exports, {
|
|
|
6285
7590
|
});
|
|
6286
7591
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
6287
7592
|
import { dirname as dirname2, join as join3 } from "node:path";
|
|
7593
|
+
|
|
7594
|
+
// src/branches.ts
|
|
7595
|
+
var branches_exports = {};
|
|
7596
|
+
__export(branches_exports, {
|
|
7597
|
+
RIGHTS: () => RIGHTS,
|
|
7598
|
+
addableBranch: () => addableBranch,
|
|
7599
|
+
askSomeoneElse: () => askSomeoneElse,
|
|
7600
|
+
backToDraft: () => backToDraft,
|
|
7601
|
+
branchFacts: () => branchFacts,
|
|
7602
|
+
branchNumber: () => branchNumber,
|
|
7603
|
+
branchStatus: () => branchStatus,
|
|
7604
|
+
closeBranch: () => closeBranch,
|
|
7605
|
+
combineBranches: () => combineBranches,
|
|
7606
|
+
commentOnBranch: () => commentOnBranch,
|
|
7607
|
+
listComments: () => listComments,
|
|
7608
|
+
listProducts: () => listProducts,
|
|
7609
|
+
mergeBranch: () => mergeBranch,
|
|
7610
|
+
productMarks: () => productMarks,
|
|
7611
|
+
reopenBranch: () => reopenBranch,
|
|
7612
|
+
resolveProduct: () => resolveProduct,
|
|
7613
|
+
reviewBranch: () => reviewBranch,
|
|
7614
|
+
sendForReview: () => sendForReview,
|
|
7615
|
+
tryBranch: () => tryBranch,
|
|
7616
|
+
updateFromMain: () => updateFromMain,
|
|
7617
|
+
whatNeedsMe: () => whatNeedsMe
|
|
7618
|
+
});
|
|
7619
|
+
import { relative as relative2 } from "node:path";
|
|
7620
|
+
var RIGHTS = {
|
|
7621
|
+
review: "Pull requests: read and write",
|
|
7622
|
+
comment: "Pull requests (or Issues): read and write",
|
|
7623
|
+
merge: "Contents and Pull requests: read and write, and the right to ship in bridge.config.json (governance.rights)",
|
|
7624
|
+
update: "Contents and Pull requests: read and write",
|
|
7625
|
+
draft: "Pull requests: read and write (you must be the author, as in Studio)",
|
|
7626
|
+
docs: "Contents and Pull requests: read and write"
|
|
7627
|
+
};
|
|
7628
|
+
var NO_REPO = 'No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.';
|
|
7629
|
+
async function reader(ctx) {
|
|
7630
|
+
const gh = await ctx.github();
|
|
7631
|
+
if (!gh) throw new Error(NO_REPO);
|
|
7632
|
+
return gh;
|
|
7633
|
+
}
|
|
7634
|
+
async function writer(ctx, what, need) {
|
|
7635
|
+
const gh = await reader(ctx);
|
|
7636
|
+
if (!ctx.canWriteRemote) throw new Error(`${what} needs a GitHub token with ${need} on ${gh.fullName}. Set GITHUB_TOKEN (or BRIDGE_GITHUB_TOKEN) where the Bridge MCP server or the CLI runs, or do it in Bridge Studio.`);
|
|
7637
|
+
return gh.withBranchPrefix(void 0);
|
|
7638
|
+
}
|
|
7639
|
+
function branchNumber(input) {
|
|
7640
|
+
if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
|
|
7641
|
+
const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/pull\/(\d+)/.exec(String(input));
|
|
7642
|
+
if (!m) throw new Error(`"${input}" is not a branch number. Pass the number Studio shows (#12) or the link of the branch on GitHub.`);
|
|
7643
|
+
return Number(m[1]);
|
|
7644
|
+
}
|
|
7645
|
+
var lifecycleOf = (state, draft) => state === "merged" ? "merged" : state === "closed" ? "closed" : draft ? "draft" : "open";
|
|
7646
|
+
async function people(gh, config) {
|
|
7647
|
+
const { owners, rights } = config.governance;
|
|
7648
|
+
const teams = await new TeamDirectory(gh).resolve(owners).catch(() => void 0);
|
|
7649
|
+
const lists = expandOwners(approverEntries(owners, rights), teams);
|
|
7650
|
+
return { lists, ship: shipPolicy(owners, rights, teams) };
|
|
7651
|
+
}
|
|
7652
|
+
async function viewerOf(ctx, gh) {
|
|
7653
|
+
if (!ctx.canWriteRemote) return null;
|
|
7654
|
+
return gh.getViewer().then((v) => v.login, () => null);
|
|
7655
|
+
}
|
|
7656
|
+
async function branchFacts(ctx, gh, n, viewer) {
|
|
7657
|
+
const { config } = await ctx.config();
|
|
7658
|
+
const pr = await gh.getPullRequest(n);
|
|
7659
|
+
const [files, reviews, runs, statuses, who, compare, repo] = await Promise.all([
|
|
7660
|
+
gh.listPullRequestFiles(n),
|
|
7661
|
+
gh.listReviews(n).catch(() => []),
|
|
7662
|
+
gh.listCheckRuns(pr.headSha).catch(() => []),
|
|
7663
|
+
gh.listCommitStatuses(pr.headSha).catch(() => []),
|
|
7664
|
+
people(gh, config),
|
|
7665
|
+
pr.state === "open" ? gh.compareDetailed(pr.baseRef, pr.headSha).catch(() => null) : Promise.resolve(null),
|
|
7666
|
+
viewer ? gh.getMergeSettings().catch(() => null) : Promise.resolve(null)
|
|
7667
|
+
]);
|
|
7668
|
+
const labels = pr.labels.map((name) => ({ name }));
|
|
7669
|
+
const ci = summarizeCi(
|
|
7670
|
+
runs.map((r) => ({ name: r.name, status: r.status, conclusion: r.conclusion, ...r.url ? { html_url: r.url } : {} })),
|
|
7671
|
+
statuses.map((s) => ({ context: s.context, state: s.state, target_url: s.target_url }))
|
|
7672
|
+
);
|
|
7673
|
+
const approvals = summarizeApprovals(
|
|
7674
|
+
reviews.map((r) => ({ user: r.user ? { login: r.user } : null, state: r.state, submitted_at: r.submittedAt ?? null })),
|
|
7675
|
+
who.lists,
|
|
7676
|
+
pr.author
|
|
7677
|
+
);
|
|
7678
|
+
const tier = effectiveTier(labels, pr.body, files);
|
|
7679
|
+
const merged = pr.state === "merged";
|
|
7680
|
+
const pullLike = { state: merged ? "closed" : pr.state, draft: pr.draft, merged_at: merged ? "merged" : null };
|
|
7681
|
+
const exploration = isActiveExploration(pr.labels);
|
|
7682
|
+
const gate = mergeGate({ tier, ci, approvals, draft: pr.draft, mergeable: pr.mergeable, state: pullLike.state, merged_at: pullLike.merged_at, viewer, override: config.governance.override, exploration, ship: who.ship });
|
|
7683
|
+
const status = reviewStatusKind({ ...pullLike, ci, approvals, tier, mergeable: pr.mergeable });
|
|
7684
|
+
const fresh = freshness({ compare: compare ? { behind_by: compare.behindBy, merge_base_commit: compare.mergeBase ? { sha: compare.mergeBase } : null } : null, mergeable: pr.mergeable, open: pr.state === "open" });
|
|
7685
|
+
const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
|
|
7686
|
+
return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo };
|
|
7687
|
+
}
|
|
7688
|
+
var STATUS_WORDS = {
|
|
7689
|
+
merged: "Merged",
|
|
7690
|
+
closed: "Closed",
|
|
7691
|
+
draft: "Draft",
|
|
7692
|
+
"ci-failing": "Blocked by tests",
|
|
7693
|
+
"changes-requested": "Changes requested",
|
|
7694
|
+
"ci-running": "Tests running",
|
|
7695
|
+
"needs-design": "Needs design approval",
|
|
7696
|
+
"needs-tech": "Needs tech approval",
|
|
7697
|
+
"needs-approval": "Needs approval",
|
|
7698
|
+
conflict: "Conflict with main",
|
|
7699
|
+
checking: "Checking",
|
|
7700
|
+
ready: "Ready to merge",
|
|
7701
|
+
unknown: "Status unknown"
|
|
7702
|
+
};
|
|
7703
|
+
async function branchStatus(ctx, a) {
|
|
7704
|
+
const n = branchNumber(a.pr);
|
|
7705
|
+
const gh = await reader(ctx);
|
|
7706
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7707
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7708
|
+
return {
|
|
7709
|
+
pr: n,
|
|
7710
|
+
title: f.pr.title,
|
|
7711
|
+
url: f.pr.url,
|
|
7712
|
+
author: f.pr.author,
|
|
7713
|
+
product: { key: f.product.key, name: f.product.name },
|
|
7714
|
+
lifecycle: f.lifecycle,
|
|
7715
|
+
status: { kind: f.status, label: STATUS_WORDS[f.status] },
|
|
7716
|
+
approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
|
|
7717
|
+
approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech },
|
|
7718
|
+
tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
|
|
7719
|
+
main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
|
|
7720
|
+
merge: {
|
|
7721
|
+
allowed: f.gate.allowed,
|
|
7722
|
+
blockedBy: f.gate.reasons,
|
|
7723
|
+
...viewer ? { you: viewer, youCanShip: f.gate.canShip, ownerOverride: f.gate.overridable } : { you: null, note: "Without a GitHub token Bridge cannot tell whether you may merge." }
|
|
7724
|
+
},
|
|
7725
|
+
next: nextStep(f, viewer)
|
|
7726
|
+
};
|
|
7727
|
+
}
|
|
7728
|
+
function nextStep(f, viewer) {
|
|
7729
|
+
if (f.lifecycle === "merged") return "Merged. It goes out with the next library release.";
|
|
7730
|
+
if (f.lifecycle === "closed") return "Closed without merging. bridge_reopen_branch reopens it when its work is still there.";
|
|
7731
|
+
if (f.exploration) return "It is an exploration: promote it with bridge_promote_exploration when the idea is validated, or close it.";
|
|
7732
|
+
if (f.lifecycle === "draft") return "Draft: bridge_send_for_review asks for review when it is ready.";
|
|
7733
|
+
if (f.fresh.state === "conflict") return "It conflicts with main: bridge_update_from_main shows the token conflicts to pick (code conflicts stay with a developer).";
|
|
7734
|
+
if (f.gate.allowed) return viewer ? "Ready: bridge_merge_branch merges it when the person asks." : "Ready to merge by someone who may ship.";
|
|
7735
|
+
if (f.gate.overridable) return `Only approvals are missing and you may use the owner override: bridge_merge_branch with a reason of at least ${(f.config.governance.override ?? DEFAULT_OVERRIDE).minReasonLength} characters.`;
|
|
7736
|
+
return f.gate.reasons[0] ?? "Waiting.";
|
|
7737
|
+
}
|
|
7738
|
+
async function whatNeedsMe(ctx, a = {}) {
|
|
7739
|
+
const gh = await reader(ctx);
|
|
7740
|
+
const { config } = await ctx.config();
|
|
7741
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7742
|
+
const pulls = await gh.listPullRequests("open");
|
|
7743
|
+
const queue = reviewQueue(pulls.map((p) => ({ ...p, state: p.state, head: { ref: p.headRef }, user: { login: p.author }, labels: p.labels.map((name) => ({ name })) }))).slice(0, a.limit ?? 20);
|
|
7744
|
+
const who = await people(gh, config);
|
|
7745
|
+
const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
|
|
7746
|
+
const rows = await Promise.all(
|
|
7747
|
+
queue.map(async (p) => {
|
|
7748
|
+
const f = await branchFacts(ctx, gh, p.number, viewer).catch(() => null);
|
|
7749
|
+
if (!f) return { pr: p.number, title: p.title, url: p.url, author: p.author, status: "unknown", statusLabel: STATUS_WORDS.unknown, yours: same2(p.author, viewer), needsYou: null };
|
|
7750
|
+
const mine = same2(p.author, viewer);
|
|
7751
|
+
const inDesign = viewer ? who.lists.design.some((x) => same2(x, viewer)) : false;
|
|
7752
|
+
const inTech = viewer ? who.lists.tech.some((x) => same2(x, viewer)) : false;
|
|
7753
|
+
let needsYou = null;
|
|
7754
|
+
if (viewer && !mine) {
|
|
7755
|
+
if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
|
|
7756
|
+
else if (f.status === "needs-tech" && inTech) needsYou = "Your tech approval";
|
|
7757
|
+
else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same2(x, viewer))) needsYou = "Your approval";
|
|
7758
|
+
else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
|
|
7759
|
+
}
|
|
7760
|
+
if (viewer && mine) {
|
|
7761
|
+
if (f.status === "changes-requested") needsYou = "Changes requested on your branch";
|
|
7762
|
+
else if (f.status === "ci-failing") needsYou = "Tests fail on your branch";
|
|
7763
|
+
else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
|
|
7764
|
+
else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
|
|
7765
|
+
}
|
|
7766
|
+
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 };
|
|
7767
|
+
})
|
|
7768
|
+
);
|
|
7769
|
+
rows.sort((x, y) => Number(Boolean(y.needsYou)) - Number(Boolean(x.needsYou)) || REVIEW_STATUS_ORDER[x.status] - REVIEW_STATUS_ORDER[y.status]);
|
|
7770
|
+
const mineCount = rows.filter((r) => r.needsYou).length;
|
|
7771
|
+
return {
|
|
7772
|
+
viewer,
|
|
7773
|
+
summary: viewer ? `${mineCount ? `${mineCount} ${mineCount === 1 ? "branch needs" : "branches need"} you` : "Nothing needs you right now"}; ${rows.length} in review.` : `${rows.length} in review. Set a GitHub token so Bridge knows which ones need you.`,
|
|
7774
|
+
branches: rows
|
|
7775
|
+
};
|
|
7776
|
+
}
|
|
7777
|
+
async function listProducts(ctx) {
|
|
7778
|
+
const { config } = await ctx.config();
|
|
7779
|
+
const products = productsOf(config);
|
|
7780
|
+
const here = await productHere(ctx, config);
|
|
7781
|
+
return {
|
|
7782
|
+
products: products.map((p) => ({ key: p.key, name: p.name, ...p.repo ? { repo: p.repo } : {}, ...p.path ? { path: p.path } : {}, ...p.preview ? { preview: p.preview } : {}, ...p.framework ? { framework: p.framework } : {}, default: p === defaultProduct(products) })),
|
|
7783
|
+
current: { key: here.product.key, name: here.product.name, how: here.how },
|
|
7784
|
+
howToPick: 'Pass "product" (key or name) to the tools that start work. Without it Bridge uses the product of the current folder or repo, else the default product. Ask the person when it is not obvious.'
|
|
7785
|
+
};
|
|
7786
|
+
}
|
|
7787
|
+
async function productHere(ctx, config) {
|
|
7788
|
+
const products = productsOf(config);
|
|
7789
|
+
if (ctx.mode !== "local" || !ctx.root) return productForPlace(products, {});
|
|
7790
|
+
const cwd = process.cwd();
|
|
7791
|
+
const rel = relative2(ctx.root, cwd);
|
|
7792
|
+
const repo = await originRepo(cwd).catch(() => void 0);
|
|
7793
|
+
const ds = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(ctx.root).catch(() => void 0);
|
|
7794
|
+
return productForPlace(products, { relPath: rel.startsWith("..") ? null : rel, repo: repo ?? null, designSystemRepo: ds ?? null });
|
|
7795
|
+
}
|
|
7796
|
+
async function resolveProduct(ctx, name) {
|
|
7797
|
+
const { config } = await ctx.config();
|
|
7798
|
+
const products = productsOf(config);
|
|
7799
|
+
if (name?.trim()) {
|
|
7800
|
+
const p = findProduct(products, name);
|
|
7801
|
+
if (!p) throw new Error(`Unknown product "${name}". Pick one of: ${products.map((x) => `${x.key} (${x.name})`).join(", ")}. Products are listed in bridge.config.json "products".`);
|
|
7802
|
+
return p;
|
|
7803
|
+
}
|
|
7804
|
+
return (await productHere(ctx, config)).product;
|
|
7805
|
+
}
|
|
7806
|
+
function productMarks(products, p) {
|
|
7807
|
+
if (p.key === defaultProduct(products).key) return { labels: [], line: "" };
|
|
7808
|
+
return { labels: [productLabel(p.key)], line: `**Product:** ${p.name}` };
|
|
7809
|
+
}
|
|
7810
|
+
async function tryBranch(ctx, a) {
|
|
7811
|
+
const n = branchNumber(a.pr);
|
|
7812
|
+
const gh = await reader(ctx);
|
|
7813
|
+
const { config } = await ctx.config();
|
|
7814
|
+
const pr = await gh.getPullRequest(n);
|
|
7815
|
+
const [files, comments] = await Promise.all([gh.listPullRequestFiles(n), gh.listIssueComments(n).catch(() => [])]);
|
|
7816
|
+
const previews = await branchPreviewUrls(gh, n, config, { branch: pr.headRef, sha: pr.headSha, comments }).catch(() => []);
|
|
7817
|
+
const apps = config.previews?.apps ?? {};
|
|
7818
|
+
const products = productsOf(config).map((p) => {
|
|
7819
|
+
const app = Object.entries(apps).find(([, t]) => t && t === p.preview)?.[0];
|
|
7820
|
+
const hit = app ? previews.find((x) => x.app === app && x.url && x.source !== "template" && x.state !== "failed") : void 0;
|
|
7821
|
+
const reported = hit?.url ?? null;
|
|
7822
|
+
const pattern = !app && p.preview ? previewUrl(p.preview, { branch: pr.headRef, pr: n, sha: pr.headSha }) : null;
|
|
7823
|
+
const url = reported ? app === "admin" ? tryInProductUrl(reported, n, null) : reported : pattern;
|
|
7824
|
+
return { key: p.key, name: p.name, url, how: reported ? "reported by Vercel" : pattern ? "from the preview pattern in bridge.config.json (it may not exist yet)" : "no preview reported yet" };
|
|
7825
|
+
});
|
|
7826
|
+
const lifecycle = lifecycleOf(pr.state, pr.draft);
|
|
7827
|
+
const packages = config.previews?.packages ?? [];
|
|
7828
|
+
const share = shareState({ lifecycle, files: files.map((f) => f.filename), packages, comment: comments.find((c) => c.body.includes("<!-- bridge-branch-preview -->"))?.body, headSha: pr.headSha });
|
|
7829
|
+
const tryUrl = products.find((p) => p.url && p.how === "reported by Vercel")?.url ?? null;
|
|
7830
|
+
const message = shareMessage({ title: pr.title, state: share, packages, tryUrl, url: pr.url });
|
|
7831
|
+
return {
|
|
7832
|
+
pr: n,
|
|
7833
|
+
title: pr.title,
|
|
7834
|
+
tryInProduct: products,
|
|
7835
|
+
previews: previews.map((p) => ({ app: p.app, label: p.label, url: p.url, state: p.state })),
|
|
7836
|
+
share: {
|
|
7837
|
+
state: share.kind,
|
|
7838
|
+
install: share.kind === "ready" ? share.command : null,
|
|
7839
|
+
sentence: stateSentence(share) ?? "Product teams can install this branch in their app before it is merged, and remove it any time.",
|
|
7840
|
+
...message ? { message } : {}
|
|
7841
|
+
}
|
|
7842
|
+
};
|
|
7843
|
+
}
|
|
7844
|
+
async function combineBranches(ctx, a) {
|
|
7845
|
+
const numbers = [...new Set(a.prs.map(branchNumber))];
|
|
7846
|
+
if (numbers.length < MIN_TOGETHER || numbers.length > MAX_TOGETHER) throw new Error(`Pick ${MIN_TOGETHER} to ${MAX_TOGETHER} branches to try together (you gave ${numbers.length}).`);
|
|
7847
|
+
const gh = await reader(ctx);
|
|
7848
|
+
const { config } = await ctx.config();
|
|
7849
|
+
const base = baseBranchOf(config);
|
|
7850
|
+
const root = config.tokens.root;
|
|
7851
|
+
const pulls = await Promise.all(numbers.map((n) => gh.getPullRequest(n)));
|
|
7852
|
+
const branches = await Promise.all(pulls.map((p) => readCombineBranch(gh, { number: p.number, title: p.title, headSha: p.headSha }, { base, tokensRoot: root })));
|
|
7853
|
+
const code2 = branches.filter((b) => !b.tokens);
|
|
7854
|
+
if (code2.length) throw new Error(`#${code2.map((b) => b.number).join(", #")} ${code2.length === 1 ? "changes" : "change"} code, so ${code2.length === 1 ? "it" : "they"} can't be combined here. Pick branches that only change tokens.`);
|
|
7855
|
+
const latest = {};
|
|
7856
|
+
const files = (await gh.listFiles(base)).filter((f) => f.startsWith(`${root}/`) && f.endsWith(".tokens.json"));
|
|
7857
|
+
await Promise.all(files.map(async (f) => latest[f.slice(root.length + 1)] = await gh.readFile(f, base) ?? ""));
|
|
7858
|
+
const picks = a.picks ?? {};
|
|
7859
|
+
const combo = combineChanges(latest, branches.map((b) => b.edits), picks);
|
|
7860
|
+
const titles = new Map(pulls.map((p) => [p.number, p.title]));
|
|
7861
|
+
const admin = config.previews?.production?.admin;
|
|
7862
|
+
const docs = config.previews?.production?.docs;
|
|
7863
|
+
return {
|
|
7864
|
+
branches: pulls.map((p) => ({ pr: p.number, title: p.title })),
|
|
7865
|
+
tokens: combo.tokens,
|
|
7866
|
+
conflicts: combo.conflicts.map((c) => ({ key: c.key, token: c.path, file: c.file, options: c.options.map((o) => ({ use: o.changes[0], titles: o.changes.map((x) => titles.get(x) ?? `#${x}`) })), picked: picks[c.key] ?? null })),
|
|
7867
|
+
missingPicks: combo.missing.length,
|
|
7868
|
+
problems: combo.problems,
|
|
7869
|
+
open: {
|
|
7870
|
+
...admin ? { product: tryTogetherUrl(admin, numbers, picks) } : {},
|
|
7871
|
+
...docs ? { library: `${docs.replace(/\/$/, "")}/?change=${numbers.join(",")}` } : {}
|
|
7872
|
+
},
|
|
7873
|
+
next: combo.missing.length ? `Pick a value for ${combo.missing.length} token${combo.missing.length === 1 ? "" : "s"}: pass picks {"<key>": <branch number>}.` : "Open the links to see them together. Nothing was written."
|
|
7874
|
+
};
|
|
7875
|
+
}
|
|
7876
|
+
async function sendForReview(ctx, a) {
|
|
7877
|
+
const n = branchNumber(a.pr);
|
|
7878
|
+
const gh = await writer(ctx, "Sending a branch for review", RIGHTS.draft);
|
|
7879
|
+
const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
|
|
7880
|
+
if (pr.author.toLowerCase() !== viewer.login.toLowerCase()) throw new Error(`Only its author (@${pr.author}) sends #${n} for review, as in Studio. Ask them, or comment with bridge_comment.`);
|
|
7881
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: promote it with bridge_promote_exploration instead.`);
|
|
7882
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: reopen it first with bridge_reopen_branch.`);
|
|
7883
|
+
if (!pr.draft) return { pr: n, status: "already-in-review", said: "It is already in review. Reviewers can see it." };
|
|
7884
|
+
await gh.markReadyForReview(n);
|
|
7885
|
+
return { pr: n, status: "in-review", said: "Review requested. Reviewers can see it now.", url: pr.url };
|
|
7886
|
+
}
|
|
7887
|
+
async function backToDraft(ctx, a) {
|
|
7888
|
+
const n = branchNumber(a.pr);
|
|
7889
|
+
const gh = await writer(ctx, "Taking a branch back to draft", RIGHTS.draft);
|
|
7890
|
+
const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
|
|
7891
|
+
if (pr.author.toLowerCase() !== viewer.login.toLowerCase()) throw new Error(`Only its author (@${pr.author}) takes #${n} back to draft, as in Studio.`);
|
|
7892
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: it is a draft until promoted.`);
|
|
7893
|
+
await gh.convertToDraft(n);
|
|
7894
|
+
return { pr: n, status: "draft", said: "Back to draft. Reviewers are not asked until you send it for review again." };
|
|
7895
|
+
}
|
|
7896
|
+
async function reviewBranch(ctx, a) {
|
|
7897
|
+
const n = branchNumber(a.pr);
|
|
7898
|
+
const gh = await writer(ctx, "Reviewing a branch", RIGHTS.review);
|
|
7899
|
+
const pr = await gh.getPullRequest(n);
|
|
7900
|
+
const event = { approve: "APPROVE", request_changes: "REQUEST_CHANGES", comment: "COMMENT" }[a.decision];
|
|
7901
|
+
const r = await reviewOnGitHub(gh, { number: n, event, body: a.body, headSha: pr.headSha });
|
|
7902
|
+
const said = { APPROVE: "Approved.", REQUEST_CHANGES: "Changes requested.", COMMENT: "Comment posted." }[event];
|
|
7903
|
+
return { pr: n, decision: a.decision, id: r.id, said, note: event === "APPROVE" ? "Recorded under your GitHub account, on the version you saw." : void 0 };
|
|
7904
|
+
}
|
|
7905
|
+
async function askSomeoneElse(ctx, a) {
|
|
7906
|
+
const n = branchNumber(a.pr);
|
|
7907
|
+
const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Asking someone else to review", RIGHTS.review);
|
|
7908
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7909
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7910
|
+
if (f.lifecycle !== "open") throw new Error(`#${n} is ${f.lifecycle === "draft" ? "a draft: send it for review first" : f.lifecycle}, so there is no review to ask for.`);
|
|
7911
|
+
const { lists } = await people(gh, f.config);
|
|
7912
|
+
const others = reviewersToAsk({ tier: f.tier, approvals: f.approvals, owners: lists, author: f.pr.author, viewer });
|
|
7913
|
+
if (!others.length) throw new Error("Nobody else can give the missing approval. Review it, or ask an owner in Settings to add a reviewer.");
|
|
7914
|
+
if (a.dryRun) return { pr: n, status: "would-ask", asks: others.map((l) => `@${l}`), said: `Would ask ${others.map((l) => `@${l}`).join(", ")} to review.` };
|
|
7915
|
+
await askOthersToReviewOnGitHub(gh, { number: n, others, viewer, viewerRequested: Boolean(viewer && f.pr.requestedReviewers.some((r) => r.toLowerCase() === viewer.toLowerCase())) });
|
|
7916
|
+
return { pr: n, status: "asked", asked: others.map((l) => `@${l}`), said: `Asked ${others.map((l) => `@${l}`).join(", ")} to review.` };
|
|
7917
|
+
}
|
|
7918
|
+
async function commentOnBranch(ctx, a) {
|
|
7919
|
+
const n = branchNumber(a.pr);
|
|
7920
|
+
const text = a.body.trim();
|
|
7921
|
+
if (!text) throw new Error("Write the comment first.");
|
|
7922
|
+
const gh = await writer(ctx, "Commenting on a branch", RIGHTS.comment);
|
|
7923
|
+
if (a.replyTo) {
|
|
7924
|
+
const r2 = await gh.replyToReviewComment(n, a.replyTo, text);
|
|
7925
|
+
return { pr: n, replied: a.replyTo, url: r2.url, said: "Reply posted in the thread." };
|
|
7926
|
+
}
|
|
7927
|
+
const r = await commentOnGitHub(gh, n, text);
|
|
7928
|
+
return { pr: n, url: r.url, said: "Comment posted." };
|
|
7929
|
+
}
|
|
7930
|
+
async function listComments(ctx, a) {
|
|
7931
|
+
const n = branchNumber(a.pr);
|
|
7932
|
+
const gh = await reader(ctx);
|
|
7933
|
+
const [conversation, lines] = await Promise.all([gh.listIssueComments(n), gh.listReviewComments(n).catch(() => [])]);
|
|
7934
|
+
return {
|
|
7935
|
+
pr: n,
|
|
7936
|
+
conversation: conversation.filter((c) => !c.body.includes("<!-- bridge-branch-preview -->")).map((c) => ({ id: c.id, author: c.author, body: c.body, at: c.updatedAt })),
|
|
7937
|
+
threads: lines.filter((c) => !c.inReplyTo).map((c) => ({ id: c.id, author: c.author, file: c.path, line: c.line, body: c.body, url: c.url, replies: lines.filter((r) => r.inReplyTo === c.id).map((r) => ({ id: r.id, author: r.author, body: r.body })) })),
|
|
7938
|
+
howToReply: "bridge_comment with replyTo: the thread id."
|
|
7939
|
+
};
|
|
7940
|
+
}
|
|
7941
|
+
async function mergeBranch(ctx, a) {
|
|
7942
|
+
const n = branchNumber(a.pr);
|
|
7943
|
+
const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
|
|
7944
|
+
const viewer = (await gh.getViewer()).login;
|
|
7945
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7946
|
+
const policy = f.config.governance.override ?? DEFAULT_OVERRIDE;
|
|
7947
|
+
const reason = a.overrideReason?.trim();
|
|
7948
|
+
if (f.write === false) throw new Error(`@${viewer} cannot write to ${gh.fullName}, so GitHub refuses the merge. Ask someone with write access.`);
|
|
7949
|
+
if (f.gate.allowed) {
|
|
7950
|
+
if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
|
|
7951
|
+
const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
|
|
7952
|
+
return { pr: n, status: "merged", sha: r.sha, said: "Merged. It goes out with the next library release." };
|
|
7953
|
+
}
|
|
7954
|
+
if (!reason) {
|
|
7955
|
+
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.` : "";
|
|
7956
|
+
throw new Error(`#${n} cannot be merged yet: ${f.gate.reasons.join(" ")}${hint}`);
|
|
7957
|
+
}
|
|
7958
|
+
if (!canOverride(viewer, policy)) throw new Error(`@${viewer} is not allowed to use the owner override (governance.override.allowed in bridge.config.json). It still needs: ${f.gate.missing.join(", ") || f.gate.reasons.join(" ")}`);
|
|
7959
|
+
if (!f.gate.overridable) throw new Error(`The owner override only skips missing approvals. #${n} is blocked by: ${f.gate.reasons.join(" ")}`);
|
|
7960
|
+
const problem = overrideReasonProblem(reason, policy);
|
|
7961
|
+
if (problem) throw new Error(problem);
|
|
7962
|
+
if (a.dryRun) return { pr: n, status: "would-override", skips: f.gate.missing, said: "The override would post your reason on the branch, then merge." };
|
|
7963
|
+
const method = allowedMergeMethods(f.repo)[0] ?? "squash";
|
|
7964
|
+
try {
|
|
7965
|
+
const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
|
|
7966
|
+
return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.` };
|
|
7967
|
+
} catch (err) {
|
|
7968
|
+
if (err instanceof OverrideMergeError) throw new Error(err.message);
|
|
7969
|
+
throw err;
|
|
7970
|
+
}
|
|
7971
|
+
}
|
|
7972
|
+
async function closeBranch(ctx, a) {
|
|
7973
|
+
const n = branchNumber(a.pr);
|
|
7974
|
+
const gh = await writer(ctx, "Closing a branch", RIGHTS.review);
|
|
7975
|
+
const pr = await gh.getPullRequest(n);
|
|
7976
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: close it with bridge_close_exploration.`);
|
|
7977
|
+
if (pr.state !== "open") return { pr: n, status: pr.state, said: `It is already ${pr.state}.` };
|
|
7978
|
+
await closeOnGitHub(gh, n);
|
|
7979
|
+
return { pr: n, status: "closed", said: "Closed. It can be reopened while its work is still there." };
|
|
7980
|
+
}
|
|
7981
|
+
async function reopenBranch(ctx, a) {
|
|
7982
|
+
const n = branchNumber(a.pr);
|
|
7983
|
+
const gh = await writer(ctx, "Reopening a branch", RIGHTS.review);
|
|
7984
|
+
const { config } = await ctx.config();
|
|
7985
|
+
const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
|
|
7986
|
+
if (pr.state === "merged") throw new Error(`#${n} is merged: it cannot be reopened. Undo it with bridge_plan_revert and bridge_propose_revert.`);
|
|
7987
|
+
const exists = await gh.getBranchSha(pr.headRef).then((s) => s ? "exists" : "gone", () => "unknown");
|
|
7988
|
+
const owners = [...config.governance.owners.design, ...config.governance.owners.tech];
|
|
7989
|
+
const ok2 = reopenEligibility({ state: pr.state === "closed" ? "closed" : "open", viewer: viewer.login, author: pr.author, owners, canWrite: hasWriteAccess(repo?.permissions), branch: exists });
|
|
7990
|
+
if (pr.state === "open") return { pr: n, status: "open", said: "It is already open." };
|
|
7991
|
+
if (ok2.kind === "gone") throw new Error(`#${n} can't be reopened: its work was removed. Start a new branch with its token edits (bridge_propose_token_change).`);
|
|
7992
|
+
if (ok2.kind !== "reopen") throw new Error(`Only its author (@${pr.author}) or an owner with write access reopens #${n}, as in Studio.`);
|
|
7993
|
+
await reopenOnGitHub(gh, n);
|
|
7994
|
+
return { pr: n, status: "open", said: "Reopened. It is back in review with its earlier approvals and checks." };
|
|
7995
|
+
}
|
|
7996
|
+
async function updateFromMain(ctx, a) {
|
|
7997
|
+
const n = branchNumber(a.pr);
|
|
7998
|
+
const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Updating a branch from main", RIGHTS.update);
|
|
7999
|
+
const { config } = await ctx.config();
|
|
8000
|
+
const pr = await gh.getPullRequest(n);
|
|
8001
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch is updated from main.`);
|
|
8002
|
+
const compare = await gh.compareDetailed(pr.baseRef, pr.headSha);
|
|
8003
|
+
const fresh = freshness({ compare: { behind_by: compare.behindBy, merge_base_commit: compare.mergeBase ? { sha: compare.mergeBase } : null }, mergeable: pr.mergeable, open: true });
|
|
8004
|
+
if (fresh.state === "current") return { pr: n, status: "up-to-date", said: `It already has the latest ${pr.baseRef}.` };
|
|
8005
|
+
if (fresh.state === "behind") {
|
|
8006
|
+
if (a.dryRun) return { pr: n, status: "would-update", behindBy: fresh.behindBy, said: `${pr.baseRef} has ${fresh.behindBy} new update${fresh.behindBy === 1 ? "" : "s"}; nothing conflicts.` };
|
|
8007
|
+
await gh.updatePullRequestBranch(n, pr.headSha);
|
|
8008
|
+
return { pr: n, status: "updated", said: `Updated from ${pr.baseRef}. Checks run again; approvals may need renewing.` };
|
|
8009
|
+
}
|
|
8010
|
+
if (fresh.state === "unknown") throw new Error(`GitHub has not said yet whether #${n} merges cleanly. Try again in a minute.`);
|
|
8011
|
+
const data = await readLibraryConflicts(gh, compare, pr.headSha, { tokensRoot: config.tokens.root });
|
|
8012
|
+
if (data.route.kind === "code" || !data.tokens) {
|
|
8013
|
+
throw new Error(`#${n} conflicts with ${pr.baseRef} in code${data.route.files.length ? ` (${data.route.files.slice(0, 5).join(", ")})` : ""}. A developer resolves that on GitHub: ${pr.url}`);
|
|
8014
|
+
}
|
|
8015
|
+
const conflicts = data.tokens.plan.conflicts.map((c) => ({ key: c.key, token: c.path, file: c.file, mine: c.mine ?? null, theirs: c.theirs ?? null, default: c.defaultChoice }));
|
|
8016
|
+
const choices = a.choices ?? {};
|
|
8017
|
+
const missing = missingChoices(data.tokens.plan, choices);
|
|
8018
|
+
if (a.dryRun || missing.length) {
|
|
8019
|
+
return {
|
|
8020
|
+
pr: n,
|
|
8021
|
+
status: "token-conflicts",
|
|
8022
|
+
conflicts,
|
|
8023
|
+
missing: missing.map((c) => c.key),
|
|
8024
|
+
said: missing.length ? `${missing.length} token${missing.length === 1 ? "" : "s"} changed here and on ${pr.baseRef} without a default: pick "mine" or "theirs" for each key in choices.` : `${conflicts.length} token conflict${conflicts.length === 1 ? "" : "s"}; pass choices ("mine" keeps this branch, "theirs" uses ${pr.baseRef}) or run without dryRun to keep the defaults.`
|
|
8025
|
+
};
|
|
8026
|
+
}
|
|
8027
|
+
const viewer = await gh.getViewer().then((v) => v.login, () => null);
|
|
8028
|
+
const sha = await commitLibraryResolution(gh, { head: { ref: pr.headRef, sha: pr.headSha }, base: { ref: pr.baseRef } }, data, choices, viewer);
|
|
8029
|
+
return { pr: n, status: "resolved", commit: sha, said: `Brought in the latest ${pr.baseRef} with your token choices. Checks run again.` };
|
|
8030
|
+
}
|
|
8031
|
+
async function addableBranch(ctx, n) {
|
|
8032
|
+
const gh = await writer(ctx, "Adding edits to a branch", RIGHTS.update);
|
|
8033
|
+
const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
|
|
8034
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch takes more edits. Start a new one instead.`);
|
|
8035
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: pass branch "${pr.headRef}" instead of pr.`);
|
|
8036
|
+
if (!canAddTo({ user: { login: pr.author } }, viewer.login, hasWriteAccess(repo?.permissions))) throw new Error(`Only its author (@${pr.author}) or someone with write access adds edits to #${n}.`);
|
|
8037
|
+
return { gh, pr, viewer: viewer.login, comment: (paths) => addedComment(paths, viewer.login, "Claude through the Bridge MCP server") };
|
|
8038
|
+
}
|
|
8039
|
+
|
|
8040
|
+
// src/explorations.ts
|
|
6288
8041
|
var USE_CASES = new Set(EXPLORATION_USE_CASES.map((u) => u.value));
|
|
6289
8042
|
var settingsOf = (config) => ({ base: baseBranchOf(config), packages: config.previews?.packages ?? [], apps: config.previews?.apps ?? {} });
|
|
6290
8043
|
async function requireGitHub(ctx) {
|
|
@@ -6319,8 +8072,11 @@ async function startExploration(ctx, a) {
|
|
|
6319
8072
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6320
8073
|
};
|
|
6321
8074
|
const gh = await ctx.github().catch(() => null);
|
|
8075
|
+
const product = await resolveProduct(ctx, a.product);
|
|
8076
|
+
const marks = productMarks(productsOf(config), product);
|
|
6322
8077
|
const preview = {
|
|
6323
8078
|
branch,
|
|
8079
|
+
product: product.name,
|
|
6324
8080
|
slug: slug2,
|
|
6325
8081
|
base: s.base,
|
|
6326
8082
|
title: explorationPrTitle(e),
|
|
@@ -6332,6 +8088,7 @@ async function startExploration(ctx, a) {
|
|
|
6332
8088
|
const work = `Work on it: \`git fetch origin && git switch ${branch}\`. Token edits: bridge_propose_token_change with "branch": "${branch}". Requests: bridge_create_request with "exploration": "${branch}", then bridge_get_request with "branch": "${branch}". It is never merged as is: promote it (\`bridge explore promote ${slug2}\`) or close it.`;
|
|
6333
8089
|
if (gh && ctx.canWriteRemote) {
|
|
6334
8090
|
const r = await startExplorationOnGitHub(gh, e, s);
|
|
8091
|
+
if (marks.labels.length) await gh.addLabels(r.pr, marks.labels);
|
|
6335
8092
|
return {
|
|
6336
8093
|
status: "started",
|
|
6337
8094
|
...preview,
|
|
@@ -6793,6 +8550,10 @@ async function getGuidelines(ctx, a) {
|
|
|
6793
8550
|
}
|
|
6794
8551
|
async function proposeTokenChange(ctx, a) {
|
|
6795
8552
|
if (!a.edits?.length) throw new Error("Pass at least one edit.");
|
|
8553
|
+
if (a.pr !== void 0 && a.branch) throw new Error("Pass pr (a branch in review) or branch (an exploration), not both.");
|
|
8554
|
+
if (a.pr !== void 0) return addToBranch(ctx, { ...a, pr: a.pr });
|
|
8555
|
+
const jira = parseJiraKey(a.jira);
|
|
8556
|
+
const product = await resolveProduct(ctx, a.product);
|
|
6796
8557
|
const target = a.branch ? await explorationContext(ctx, a.branch, { requireCheckout: !a.dryRun }) : null;
|
|
6797
8558
|
if (target) ctx = target.ctx;
|
|
6798
8559
|
const { config } = await ctx.config();
|
|
@@ -6807,7 +8568,8 @@ async function proposeTokenChange(ctx, a) {
|
|
|
6807
8568
|
const imp = core.impact(before, after, changes);
|
|
6808
8569
|
const tier = core.classify(changes, [], config);
|
|
6809
8570
|
const summary2 = a.summary?.trim() || a.title;
|
|
6810
|
-
const
|
|
8571
|
+
const marks = productMarks(productsOf(config), product);
|
|
8572
|
+
const body = [core.prBody({ summary: summary2, changes, contrast: delta2, impact: imp, tier, validationErrors: newErrors.length, tool: "Bridge MCP" }), marks.line].filter(Boolean).join("\n\n");
|
|
6811
8573
|
const slugTitle = core.slugify(a.title);
|
|
6812
8574
|
const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slugTitle}.md`;
|
|
6813
8575
|
const files = [
|
|
@@ -6827,7 +8589,7 @@ async function proposeTokenChange(ctx, a) {
|
|
|
6827
8589
|
if (newErrors.length || newFailures.length) {
|
|
6828
8590
|
return { status: "blocked", why: [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and "), newErrors, ...report, hint: "Fix the edits (or add edits for the aliases they break). Bridge never opens a PR that breaks the gate." };
|
|
6829
8591
|
}
|
|
6830
|
-
let branch = target ? target.branch : `${config.governance.branchPrefix}${slugTitle}
|
|
8592
|
+
let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
|
|
6831
8593
|
const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
|
|
6832
8594
|
if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
|
|
6833
8595
|
if (target) {
|
|
@@ -6841,7 +8603,7 @@ Exploration ${target.branch}: not merged as is. Proposed through Bridge MCP (sou
|
|
|
6841
8603
|
if (ctx.mode === "local") {
|
|
6842
8604
|
if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...preview, fileContents: files };
|
|
6843
8605
|
const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
|
|
6844
|
-
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}
|
|
8606
|
+
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
|
|
6845
8607
|
const base2 = await currentBranch(ctx.root);
|
|
6846
8608
|
const message = `${tier.bump === "major" ? "feat(tokens)!" : "feat(tokens)"}: ${a.title}
|
|
6847
8609
|
|
|
@@ -6866,12 +8628,46 @@ Tier ${tier.tier}. Proposed through Bridge MCP (source: mcp).`;
|
|
|
6866
8628
|
|
|
6867
8629
|
Proposed through Bridge MCP (source: mcp). Tier ${tier.tier}.`;
|
|
6868
8630
|
const base = ctx.ref ?? baseBranchOf(config);
|
|
6869
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}
|
|
8631
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
|
|
6870
8632
|
const commit = await gh.commitToNewBranch({ base, branch, message: commitMsg, files });
|
|
6871
|
-
const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : []];
|
|
6872
|
-
const pr = await gh.createPullRequest({ base, head: branch, title: `feat(tokens): ${a.title}`, body, draft: tier.tier >= 2 });
|
|
8633
|
+
const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : [], ...marks.labels];
|
|
8634
|
+
const pr = await gh.createPullRequest({ base, head: branch, title: titleWithJiraKey(`feat(tokens): ${a.title}`, jira), body, draft: tier.tier >= 2 });
|
|
6873
8635
|
await gh.addLabels(pr.number, labels);
|
|
6874
|
-
return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2 };
|
|
8636
|
+
return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2, product: product.name };
|
|
8637
|
+
}
|
|
8638
|
+
async function addToBranch(ctx, a) {
|
|
8639
|
+
const n = branchNumber(a.pr);
|
|
8640
|
+
const target = await addableBranch(ctx, n);
|
|
8641
|
+
const at = await BridgeContext.create({ ...ctx.opts, repo: target.gh.fullName, ref: target.pr.headRef });
|
|
8642
|
+
const { config } = await at.config();
|
|
8643
|
+
const { before, after, touched, notes } = await withEdits(at, a.edits);
|
|
8644
|
+
const changes = core.diff(before, after);
|
|
8645
|
+
if (!changes.length) return { status: "no-change", pr: n, message: `#${n} already has these values.` };
|
|
8646
|
+
const prevErrors = new Set(core.validate(before).map(issueKey2));
|
|
8647
|
+
const newErrors = core.validate(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
|
|
8648
|
+
const pairs = await core.readContrastPairs(at.source, config).catch(() => []);
|
|
8649
|
+
const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
|
|
8650
|
+
const newFailures = delta2.filter((d) => d.passBefore && !d.passAfter);
|
|
8651
|
+
const tier = core.classify(changes, [], config);
|
|
8652
|
+
const summary2 = a.summary?.trim() || a.title;
|
|
8653
|
+
const report = { pr: n, branch: target.pr.headRef, tier: tier.tier, changes: changes.map((c) => ({ kind: c.kind, path: c.path, file: prefixPath(after, c.file), before: c.beforeCss, after: c.afterCss })), contrast: { moved: delta2.length, newFailures }, notes };
|
|
8654
|
+
if (newErrors.length || newFailures.length) {
|
|
8655
|
+
return { status: "blocked", why: [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and "), newErrors, ...report, hint: "Fix the edits. Bridge never adds edits that break the gate." };
|
|
8656
|
+
}
|
|
8657
|
+
const paths = changes.map((c) => c.path);
|
|
8658
|
+
const files = [
|
|
8659
|
+
...touched.map((f) => ({ path: prefixPath(after, f), content: after.files.get(f) })),
|
|
8660
|
+
...config.changesets ? [{ path: `${config.changesets.dir ?? ".changeset"}/bridge-${core.slugify(a.title)}-${n}-${Date.now().toString(36)}.md`, content: core.changeset(config.changesets.package, tier.bump, summary2) }] : []
|
|
8661
|
+
];
|
|
8662
|
+
if (a.dryRun) return { status: "dry-run", ...report, files: files.map((f) => f.path) };
|
|
8663
|
+
const message = `tokens: ${a.title}
|
|
8664
|
+
|
|
8665
|
+
${paths.map((p) => `- ${p}`).join("\n")}
|
|
8666
|
+
|
|
8667
|
+
Added with Claude through the Bridge MCP server.`;
|
|
8668
|
+
const res = await target.gh.commitToBranch({ branch: target.pr.headRef, message, files });
|
|
8669
|
+
await target.gh.createIssueComment(n, target.comment(paths)).catch(() => void 0);
|
|
8670
|
+
return { status: "added", ...report, commit: res.sha, url: target.pr.url, said: `Added ${paths.length} edit${paths.length === 1 ? "" : "s"} to #${n}. Reviewers see ${paths.length === 1 ? "it" : "them"} now; GitHub may ask for approvals again.` };
|
|
6875
8671
|
}
|
|
6876
8672
|
async function listReviews(ctx, a) {
|
|
6877
8673
|
const { config } = await ctx.config();
|
|
@@ -6967,7 +8763,7 @@ async function getReview(ctx, a) {
|
|
|
6967
8763
|
impact: imp
|
|
6968
8764
|
};
|
|
6969
8765
|
}
|
|
6970
|
-
var
|
|
8766
|
+
var sameLogin2 = (a, b) => a.trim().toLowerCase().replace(/^@/, "") === b.trim().toLowerCase().replace(/^@/, "");
|
|
6971
8767
|
async function reviewRoles(gh, config, approvers) {
|
|
6972
8768
|
const { owners, rights } = config.governance;
|
|
6973
8769
|
const teams = await new TeamDirectory(gh).resolve(owners);
|
|
@@ -6975,7 +8771,7 @@ async function reviewRoles(gh, config, approvers) {
|
|
|
6975
8771
|
const lists = expandOwners(entries, teams);
|
|
6976
8772
|
const role = (k) => ({
|
|
6977
8773
|
owners: entries[k].map((e) => entryLabel(e, teams)),
|
|
6978
|
-
approvedBy: approvers.filter((a) => lists[k].some((x) =>
|
|
8774
|
+
approvedBy: approvers.filter((a) => lists[k].some((x) => sameLogin2(x, a)))
|
|
6979
8775
|
});
|
|
6980
8776
|
const ship = shipPolicy(owners, rights, teams);
|
|
6981
8777
|
const unreadable = unreadableTeams(teams);
|
|
@@ -6992,10 +8788,11 @@ async function reviewPr(ctx, a) {
|
|
|
6992
8788
|
if (!ctx.canWriteRemote) throw new Error("Reviewing needs GITHUB_TOKEN. Without it Bridge is read-only.");
|
|
6993
8789
|
if (!a.body.trim()) throw new Error("A review needs a body that says what to fix or why it is fine.");
|
|
6994
8790
|
const event = { comment: "COMMENT", approve: "APPROVE", request_changes: "REQUEST_CHANGES" }[a.event];
|
|
6995
|
-
const
|
|
8791
|
+
const head = (await gh.getPullRequest(a.pr)).headSha;
|
|
8792
|
+
const r = await reviewOnGitHub(gh, { number: a.pr, event, body: `${a.body.trim()}
|
|
6996
8793
|
|
|
6997
|
-
_Review drafted with Bridge MCP by an AI agent on behalf of the token owner._
|
|
6998
|
-
return { pr: a.pr, event, id: r.id, state: r.state, note: event === "APPROVE" ? "Approval recorded under your GitHub account.
|
|
8794
|
+
_Review drafted with Bridge MCP by an AI agent on behalf of the token owner._`, headSha: head });
|
|
8795
|
+
return { pr: a.pr, event, id: r.id, state: r.state, note: event === "APPROVE" ? "Approval recorded under your GitHub account. Merging is a separate step (bridge_merge_branch), only when the person asks." : void 0 };
|
|
6999
8796
|
}
|
|
7000
8797
|
async function scaffoldComponentOp(ctx, a) {
|
|
7001
8798
|
const { config } = await ctx.config();
|
|
@@ -7022,7 +8819,7 @@ async function visualImpact(ctx, a, run2) {
|
|
|
7022
8819
|
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.");
|
|
7023
8820
|
if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
|
|
7024
8821
|
const out = a.out ? resolve3(ctx.root, a.out) : join4(ctx.root, "visual-impact");
|
|
7025
|
-
const runImpact = run2 ?? (await import("./src-
|
|
8822
|
+
const runImpact = run2 ?? (await import("./src-2YRRAUUM.js")).runImpact;
|
|
7026
8823
|
const r = await runImpact({
|
|
7027
8824
|
root: ctx.root,
|
|
7028
8825
|
base: a.base ?? "main",
|
|
@@ -7416,8 +9213,8 @@ async function proposeBrand(ctx, a) {
|
|
|
7416
9213
|
}
|
|
7417
9214
|
const files = plan.files.map((f) => ({ path: f.path, content: f.content }));
|
|
7418
9215
|
const prefix = config.governance.branchPrefix;
|
|
7419
|
-
const
|
|
7420
|
-
let branch =
|
|
9216
|
+
const stem2 = `${prefix}brand-${plan.slug}`;
|
|
9217
|
+
let branch = stem2;
|
|
7421
9218
|
if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
|
|
7422
9219
|
const message = `feat(tokens): add the ${plan.name} brand
|
|
7423
9220
|
|
|
@@ -7426,7 +9223,7 @@ New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${
|
|
|
7426
9223
|
Tier 3. Proposed through Bridge MCP (source: mcp).`;
|
|
7427
9224
|
if (ctx.mode === "local") {
|
|
7428
9225
|
if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...report, branch, fileContents: files };
|
|
7429
|
-
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${
|
|
9226
|
+
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${stem2}-${i}`;
|
|
7430
9227
|
const base2 = await currentBranch(ctx.root);
|
|
7431
9228
|
const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
|
|
7432
9229
|
const res = await commitToNewLocalBranch(ctx.root, { base: "HEAD", branch, message, files, protectedBranches, branchPrefix: prefix });
|
|
@@ -7442,7 +9239,7 @@ Tier 3. Proposed through Bridge MCP (source: mcp).`;
|
|
|
7442
9239
|
if (!ctx.canWriteRemote) return { status: "preview", message: "Read-only: set GITHUB_TOKEN to open this as a pull request.", ...report, branch, fileContents: files };
|
|
7443
9240
|
const gh = await ctx.github();
|
|
7444
9241
|
const base = ctx.ref ?? baseBranchOf(config);
|
|
7445
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${
|
|
9242
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${stem2}-${i}`;
|
|
7446
9243
|
const commit = await gh.commitToNewBranch({ base, branch, message, files });
|
|
7447
9244
|
const pr = await gh.createPullRequest({ base, head: branch, title: `feat(tokens): ${plan.title.charAt(0).toLowerCase()}${plan.title.slice(1)}`, body: report.prBody, draft: true });
|
|
7448
9245
|
const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, "tier: 3", "tokens"])];
|
|
@@ -7573,7 +9370,12 @@ async function createRequest(ctx, a) {
|
|
|
7573
9370
|
...a.exploration ? { exploration: toExplorationBranch(a.exploration) } : {}
|
|
7574
9371
|
};
|
|
7575
9372
|
const settings = config.integrations?.requests;
|
|
7576
|
-
const
|
|
9373
|
+
const product = await resolveProduct(ctx, a.product);
|
|
9374
|
+
const marks = productMarks(productsOf(config), product);
|
|
9375
|
+
const composed = composeRequestIssue(req, { labels: settings?.labels ?? [], assignees: settings?.assignees ?? [] });
|
|
9376
|
+
const issue = marks.labels.length ? { ...composed, body: `${composed.body}
|
|
9377
|
+
|
|
9378
|
+
${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
|
|
7577
9379
|
if (a.dryRun) return { status: "dry-run", issue };
|
|
7578
9380
|
const gh = await requireGitHub2(ctx);
|
|
7579
9381
|
if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN (issues: write on this repository). Without it Bridge is read-only.");
|
|
@@ -7651,8 +9453,131 @@ async function writeDrafts(ctx, a) {
|
|
|
7651
9453
|
};
|
|
7652
9454
|
}
|
|
7653
9455
|
|
|
9456
|
+
// src/studio.ts
|
|
9457
|
+
var studio_exports = {};
|
|
9458
|
+
__export(studio_exports, {
|
|
9459
|
+
figmaCoverage: () => figmaCoverage,
|
|
9460
|
+
getSettings: () => getSettings,
|
|
9461
|
+
saveDocs: () => saveDocs
|
|
9462
|
+
});
|
|
9463
|
+
var DOC_KEYS = ["summary", "preview", "whenToUse", "whenNotToUse", "dos", "donts", "accessibility", "contentGuidelines", "anatomy"];
|
|
9464
|
+
async function saveDocs(ctx, a) {
|
|
9465
|
+
const { config } = await ctx.config();
|
|
9466
|
+
if (!config.components) throw new Error("No components.dir in bridge.config.json: Bridge does not know where component docs live.");
|
|
9467
|
+
const name = a.component.trim();
|
|
9468
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`"${a.component}" is not a component name. Use the exported name, like Button (bridge_list_components lists them).`);
|
|
9469
|
+
const unknown = Object.keys(a.docs).filter((k) => !DOC_KEYS.includes(k));
|
|
9470
|
+
if (unknown.length) throw new Error(`Unknown docs sections: ${unknown.join(", ")}. Use ${DOC_KEYS.join(", ")}.`);
|
|
9471
|
+
const path = `${config.components.dir}/${name}/${name}${config.components.docsSuffix}`;
|
|
9472
|
+
const jira = parseJiraKey(a.jira);
|
|
9473
|
+
const product = await resolveProduct(ctx, a.product);
|
|
9474
|
+
const next = (current) => {
|
|
9475
|
+
let base = {};
|
|
9476
|
+
if (current) {
|
|
9477
|
+
try {
|
|
9478
|
+
base = JSON.parse(current);
|
|
9479
|
+
} catch {
|
|
9480
|
+
throw new Error(`${path} is not valid JSON on that branch. Fix it there first.`);
|
|
9481
|
+
}
|
|
9482
|
+
}
|
|
9483
|
+
const merged = { summary: "", whenToUse: [], whenNotToUse: [], dos: [], donts: [], accessibility: [], ...base, ...a.docs };
|
|
9484
|
+
const issues = validateDocs(merged);
|
|
9485
|
+
if (issues.length) throw new Error(`The docs are not ready: ${issues.map((i) => `${i.field}: ${i.message}`).join(" ")}`);
|
|
9486
|
+
return docsFileText(cleanDocs(merged), current);
|
|
9487
|
+
};
|
|
9488
|
+
const gh = await ctx.github();
|
|
9489
|
+
if (a.pr !== void 0) {
|
|
9490
|
+
const n = branchNumber(a.pr);
|
|
9491
|
+
if (!gh) throw new Error('No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.');
|
|
9492
|
+
const pr = await gh.getPullRequest(n);
|
|
9493
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: save the docs on a new branch instead (leave pr out).`);
|
|
9494
|
+
const current = (await gh.getFile(path, pr.headRef))?.text ?? null;
|
|
9495
|
+
if (current === null) throw new Error(`${path} is not on #${n}. Save the docs on a new branch instead (leave pr out), or check the component name.`);
|
|
9496
|
+
const text2 = next(current);
|
|
9497
|
+
if (a.dryRun) return { status: "dry-run", pr: n, path, content: text2 };
|
|
9498
|
+
if (!ctx.canWriteRemote) throw new Error(`Saving docs on #${n} needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN where the Bridge MCP server runs, or save it in the Library edit mode.`);
|
|
9499
|
+
const sha = await commitFileOnGitHub(gh.withBranchPrefix(void 0), { path, branch: pr.headRef, message: `docs(${name.toLowerCase()}): edit usage guidance with Claude (Bridge MCP)`, next });
|
|
9500
|
+
return { status: sha ? "committed" : "unchanged", pr: n, path, commit: sha || null, said: sha ? `Saved the ${name} docs on #${n}. Reviewers see them now.` : "The docs on that branch already say this." };
|
|
9501
|
+
}
|
|
9502
|
+
const original = await ctx.source.read(path);
|
|
9503
|
+
const text = next(original);
|
|
9504
|
+
if (original === text) return { status: "unchanged", path, said: `The ${name} docs on main already say this.` };
|
|
9505
|
+
const edit = { path, component: name, slug: name.toLowerCase(), before: original, after: text };
|
|
9506
|
+
const marks = productMarks(productsOf(config), product);
|
|
9507
|
+
const notes = [a.notes?.trim(), marks.line].filter(Boolean).join("\n\n");
|
|
9508
|
+
if (a.dryRun || !gh || !ctx.canWriteRemote) {
|
|
9509
|
+
return {
|
|
9510
|
+
status: a.dryRun ? "dry-run" : "preview",
|
|
9511
|
+
path,
|
|
9512
|
+
content: text,
|
|
9513
|
+
product: product.name,
|
|
9514
|
+
...a.dryRun ? {} : { said: `Nothing sent: saving docs for review needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN, or write the file yourself on a branch.` }
|
|
9515
|
+
};
|
|
9516
|
+
}
|
|
9517
|
+
const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
|
|
9518
|
+
base: ctx.ref ?? baseBranchOf(config),
|
|
9519
|
+
title: a.title,
|
|
9520
|
+
notes,
|
|
9521
|
+
jira,
|
|
9522
|
+
origin: DOCS_MCP_ORIGIN,
|
|
9523
|
+
extraLabels: marks.labels,
|
|
9524
|
+
commitNote: "Proposed with Claude through the Bridge MCP server."
|
|
9525
|
+
});
|
|
9526
|
+
return { status: "sent-for-review", pr: r.number, url: r.url, title: r.title, path, product: product.name, said: `Sent the ${name} docs for review as #${r.number}. Docs only: one approval ships them.` };
|
|
9527
|
+
}
|
|
9528
|
+
async function getSettings(ctx) {
|
|
9529
|
+
const { config } = await ctx.config();
|
|
9530
|
+
const { owners, rights } = config.governance;
|
|
9531
|
+
const override = config.governance.override ?? DEFAULT_OVERRIDE;
|
|
9532
|
+
const gh = await ctx.github();
|
|
9533
|
+
const teams = gh ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
|
|
9534
|
+
const entries = approverEntries(owners, rights);
|
|
9535
|
+
const ship = shipPolicy(owners, rights, teams);
|
|
9536
|
+
const protection = gh ? await loadProtection(gh, baseBranchOf(config)).catch(() => null) : null;
|
|
9537
|
+
const products = productsOf(config);
|
|
9538
|
+
const unreadable = teams ? unreadableTeams(teams) : [];
|
|
9539
|
+
return {
|
|
9540
|
+
repo: config.repo ? `${config.repo.owner}/${config.repo.name}` : gh?.fullName ?? null,
|
|
9541
|
+
main: baseBranchOf(config),
|
|
9542
|
+
roles: {
|
|
9543
|
+
designApproval: entries.design.map((e) => entryLabel(e, teams)),
|
|
9544
|
+
techApproval: entries.tech.map((e) => entryLabel(e, teams)),
|
|
9545
|
+
ship: ship ? whoCanShipSentence(ship) : "Anyone with write access can merge.",
|
|
9546
|
+
...unreadable.length ? { teamsNotRead: unreadable.map((t) => t.message) } : {}
|
|
9547
|
+
},
|
|
9548
|
+
ownerOverride: override.allowed.length ? { on: true, who: override.allowed.map((l) => `@${l}`), reason: override.requireReason ? `A reason of at least ${override.minReasonLength} characters, posted on the branch.` : "No reason required." } : { on: false, who: [], reason: "Nobody can merge without the approvals." },
|
|
9549
|
+
protection: protection ? protectionSentence(protection) : "Not read (no GitHub repo known, or GitHub did not answer).",
|
|
9550
|
+
products: products.map((p) => ({ key: p.key, name: p.name, default: p === defaultProduct(products) })),
|
|
9551
|
+
previews: Object.entries(config.previews?.apps ?? {}).filter(([, t]) => Boolean(t)).map(([app]) => app),
|
|
9552
|
+
visualImpact: config.visualImpact?.mode ?? "auto",
|
|
9553
|
+
note: "Settings change in bridge.config.json through a reviewed branch, or in Studio Settings. Tokens and secrets are never shown."
|
|
9554
|
+
};
|
|
9555
|
+
}
|
|
9556
|
+
async function figmaCoverage(ctx, a = {}) {
|
|
9557
|
+
const text = await ctx.source.read(DEFAULT_COMPONENT_SPECS_FILE);
|
|
9558
|
+
if (!text) return { components: [], summary: `No ${DEFAULT_COMPONENT_SPECS_FILE} in this repo: run \`npm run specs\` (or the DS Bridge Figma plugin) to record what is in Figma.` };
|
|
9559
|
+
const file = parseComponentSpecs(text);
|
|
9560
|
+
const s = coverageSummary(file);
|
|
9561
|
+
const gh = await ctx.github();
|
|
9562
|
+
const manifest = gh ? await gh.readFile(FIGMA_REFERENCES_MANIFEST, FIGMA_REFERENCES_BRANCH).catch(() => void 0) : void 0;
|
|
9563
|
+
let references = null;
|
|
9564
|
+
try {
|
|
9565
|
+
const m = manifest ? JSON.parse(manifest) : null;
|
|
9566
|
+
if (m?.components) references = { components: Object.keys(m.components).length, variants: Object.values(m.components).reduce((n, c) => n + Object.keys(c.variants ?? {}).length, 0), sha: m.sha };
|
|
9567
|
+
} catch {
|
|
9568
|
+
references = null;
|
|
9569
|
+
}
|
|
9570
|
+
const list = (file.coverage ?? []).filter((c) => !a.component || c.name.toLowerCase() === a.component.toLowerCase());
|
|
9571
|
+
if (a.component && !list.length) throw new Error(`${a.component} is not in the coverage list. bridge_list_components lists the components.`);
|
|
9572
|
+
return {
|
|
9573
|
+
summary: `${s.inFigma} of ${s.visual} components are in Figma${s.drafted ? ` (${s.drafted} drafted with Claude)` : ""}; ${s.layout} layout helpers use auto layout.`,
|
|
9574
|
+
components: list.map((c) => ({ name: c.name, inFigma: figmaCell(c)?.label ?? "Unknown", ...c.reason ? { why: c.reason } : {} })),
|
|
9575
|
+
fidelity: references === null ? "No code images for the fidelity check yet (they live on the bridge-visual branch). The DS Bridge Figma plugin compares Figma with them." : `Code images of ${references.variants} variants (${references.components} components, commit ${references.sha.slice(0, 7)}) are ready for the fidelity check. The DS Bridge Figma plugin compares each Figma variant with them; the scores live in Figma.`
|
|
9576
|
+
};
|
|
9577
|
+
}
|
|
9578
|
+
|
|
7654
9579
|
// src/server.ts
|
|
7655
|
-
var VERSION = true ? "0.
|
|
9580
|
+
var VERSION = true ? "0.4.0" : "dev";
|
|
7656
9581
|
var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
|
7657
9582
|
var fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
|
|
7658
9583
|
var run = (fn) => async (a) => {
|
|
@@ -7690,6 +9615,8 @@ function createServer(ctx) {
|
|
|
7690
9615
|
"To undo a merged token change use bridge_plan_revert first (it shows conflicts with later changes), then bridge_propose_revert; never revert by hand-editing files.",
|
|
7691
9616
|
'Component changes (new component, variant, prop, structure, visual, token binding) are requests: GitHub issues labeled component-change. bridge_list_requests finds them, bridge_get_request returns a brief to implement one in a pull request that says "Closes #n", bridge_create_request files a new one.',
|
|
7692
9617
|
`Change texts from Figma and docs from Bridge Studio: when the user says "${DRAFT_PHRASE}" (or asks you to draft their Bridge changes or docs), call bridge_pending_drafts. For change items, look at the before and after images and write each title, summary and change line in the Bridge voice. For docs items (scope "docs"), follow the brief: write only the sections it asks for, grounded in the props, CSS module classes and source it quotes, never an invented prop or behavior, never anatomy. Then call bridge_write_drafts. The designer reviews them in the DS Bridge Figma plugin or on the Library page.`,
|
|
9618
|
+
"Studio parity: a designer can do in Claude what they do in Bridge Studio. Branches in review: bridge_what_needs_me (the queue), bridge_branch_status, bridge_send_for_review, bridge_back_to_draft, bridge_review_branch, bridge_ask_someone_else, bridge_comment, bridge_update_from_main (token conflicts too), bridge_try_branch (try it in a product, share it), bridge_combine_branches, bridge_close_branch, bridge_reopen_branch, bridge_merge_branch (same gate as Studio, owner override with a reason). Docs: bridge_save_docs. Writes act under the person's GitHub token: only do them when the person asked, and merge only when they explicitly asked to merge.",
|
|
9619
|
+
'Products: work starts for a product (bridge_list_products). Tools that start work take "product"; without it Bridge uses the product of the current folder or repo. Ask the person when it is not obvious.',
|
|
7693
9620
|
'Explorations try experimental variables and components in a product staging branch without touching main: an explore/<slug> branch with a draft pull request labeled exploration, previews and a snapshot package. bridge_start_exploration starts one, bridge_list_explorations lists them. To work on one, pass "branch": "explore/<slug>" to bridge_propose_token_change, bridge_draft_docs and bridge_get_request, and never open a pull request to main from it: a person promotes it to review or closes it.'
|
|
7694
9621
|
].join(" ")
|
|
7695
9622
|
}
|
|
@@ -7824,7 +9751,10 @@ function createServer(ctx) {
|
|
|
7824
9751
|
summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
|
|
7825
9752
|
edits: z.array(EditArg).min(1),
|
|
7826
9753
|
dryRun: z.boolean().optional().describe("Return the diff, tier, files and PR body without writing."),
|
|
7827
|
-
branch: z.string().optional().describe("Exploration branch (explore/<slug>) to commit the edits to instead of opening a new pull request. GitHub: one commit on that branch. Local: written to the working tree, which must have that branch checked out. Never main.")
|
|
9754
|
+
branch: z.string().optional().describe("Exploration branch (explore/<slug>) to commit the edits to instead of opening a new pull request. GitHub: one commit on that branch. Local: written to the working tree, which must have that branch checked out. Never main."),
|
|
9755
|
+
pr: z.union([z.number().int().positive(), z.string().min(1)]).optional().describe('A branch already in review (12 or "#12") to add the edits to, as "Edit tokens in this branch" in Studio: one commit there and a comment for the reviewers. Its author or someone with write access; needs GITHUB_TOKEN.'),
|
|
9756
|
+
jira: z.string().optional().describe("Jira key like DS-42, as in Studio's Create branch: first in the branch name and title so Jira links them."),
|
|
9757
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products) the change is for. Default: the product of the current folder or repo. Ask the person when it is not obvious.")
|
|
7828
9758
|
},
|
|
7829
9759
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
7830
9760
|
},
|
|
@@ -7892,7 +9822,7 @@ function createServer(ctx) {
|
|
|
7892
9822
|
"bridge_review_pr",
|
|
7893
9823
|
{
|
|
7894
9824
|
title: "Review pull request",
|
|
7895
|
-
description: "Post a review on a PR as the token owner: comment, approve or request changes. Needs GITHUB_TOKEN. Only do this when the user asked for it;
|
|
9825
|
+
description: "Post a review on a PR as the token owner: comment, approve or request changes, with a body. Kept for older prompts: bridge_review_branch does the same in designer words (an approval may have no text). Needs GITHUB_TOKEN. Only do this when the user asked for it; merging is a separate step (bridge_merge_branch), only when the person asks.",
|
|
7896
9826
|
inputSchema: { pr: z.number().int().positive(), event: z.enum(["comment", "approve", "request_changes"]), body: z.string().min(1).describe("What to fix or why it is fine. Plain sentences.") },
|
|
7897
9827
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
7898
9828
|
},
|
|
@@ -8064,6 +9994,7 @@ function createServer(ctx) {
|
|
|
8064
9994
|
changes: z.array(z.object({ field: z.string().min(1).describe('A prop, variant axis, token or part: "size", "radius", "color.action.primary.bg".'), before: z.string().optional(), after: z.string().optional() })).optional().describe("What changes, from what to what."),
|
|
8065
9995
|
figmaUrl: z.string().url().optional().describe("Link to the design in Figma, when there is one."),
|
|
8066
9996
|
exploration: z.string().optional().describe("Exploration branch (explore/<slug>) to implement it on instead of a pull request to main. Adds the exploration label."),
|
|
9997
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products) it is for. Default: the product of the current folder or repo."),
|
|
8067
9998
|
dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
|
|
8068
9999
|
},
|
|
8069
10000
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
@@ -8143,6 +10074,7 @@ function createServer(ctx) {
|
|
|
8143
10074
|
purpose: z.string().min(1).describe("What the team wants to learn, one or two plain sentences. No em dash."),
|
|
8144
10075
|
useCase: z.enum(EXPLORATION_USE_CASES.map((u) => u.value)).optional().describe(EXPLORATION_USE_CASES.map((u) => `${u.value}: ${u.hint}`).join(" ")),
|
|
8145
10076
|
experimental: z.array(z.string()).optional().describe('What is experimental, one line each: "color.action.primary.bg", "Button: pill radius".'),
|
|
10077
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products) it is tried in. Default: the product of the current folder or repo."),
|
|
8146
10078
|
dryRun: z.boolean().optional().describe("Return the branch, note and pull request body without writing.")
|
|
8147
10079
|
},
|
|
8148
10080
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
@@ -8159,6 +10091,233 @@ function createServer(ctx) {
|
|
|
8159
10091
|
},
|
|
8160
10092
|
run((a) => listExplorations(ctx, a))
|
|
8161
10093
|
);
|
|
10094
|
+
server.registerTool(
|
|
10095
|
+
"bridge_promote_exploration",
|
|
10096
|
+
{
|
|
10097
|
+
title: "Promote an exploration to review",
|
|
10098
|
+
description: "Promote an exploration (explore/<name>) to review, as in Studio: the draft becomes ready for review with its tier labels, the exploration label and note go, and the component requests it implements are linked so they close when it merges. Never merges. Needs GITHUB_TOKEN (contents and pull requests: write). Only when the person asked; dryRun shows what changes.",
|
|
10099
|
+
inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42)."), dryRun: z.boolean().optional() },
|
|
10100
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
10101
|
+
},
|
|
10102
|
+
run((a) => promoteExploration(ctx, { ref: a.exploration, dryRun: a.dryRun }))
|
|
10103
|
+
);
|
|
10104
|
+
server.registerTool(
|
|
10105
|
+
"bridge_close_exploration",
|
|
10106
|
+
{
|
|
10107
|
+
title: "Close an exploration",
|
|
10108
|
+
description: "Close an exploration without merging it, as in Studio. Its branch and previews stay on GitHub. Needs GITHUB_TOKEN (pull requests: write). Only when the person asked.",
|
|
10109
|
+
inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42).") },
|
|
10110
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
10111
|
+
},
|
|
10112
|
+
run((a) => closeExploration(ctx, { ref: a.exploration }))
|
|
10113
|
+
);
|
|
10114
|
+
const Pr = z.union([z.number().int().positive(), z.string().min(1)]).describe('The branch number Studio shows (12 or "#12"), or its GitHub link.');
|
|
10115
|
+
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
10116
|
+
const READ = { readOnlyHint: true, openWorldHint: true };
|
|
10117
|
+
server.registerTool(
|
|
10118
|
+
"bridge_list_products",
|
|
10119
|
+
{
|
|
10120
|
+
title: "List products",
|
|
10121
|
+
description: 'The products of this design system (bridge.config.json "products": name, repo or folder, preview link pattern, framework) and the one the current folder or repo belongs to, which tools that start work use when no product is given. Ask the person which product when it is not obvious.',
|
|
10122
|
+
inputSchema: {},
|
|
10123
|
+
annotations: RO
|
|
10124
|
+
},
|
|
10125
|
+
run(async () => listProducts(ctx))
|
|
10126
|
+
);
|
|
10127
|
+
server.registerTool(
|
|
10128
|
+
"bridge_what_needs_me",
|
|
10129
|
+
{
|
|
10130
|
+
title: "What needs me",
|
|
10131
|
+
description: 'The review queue, as Studio counts it (open branches, without running explorations and the release automation), each with its status in designer words, and first the ones where the person acts now: an approval they can give, their own branch blocked, ready for them to merge. Needs GITHUB_TOKEN to know who "me" is; without it, the queue only.',
|
|
10132
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("Branches read (default 20, each costs a few GitHub reads).") },
|
|
10133
|
+
annotations: READ
|
|
10134
|
+
},
|
|
10135
|
+
run((a) => whatNeedsMe(ctx, a))
|
|
10136
|
+
);
|
|
10137
|
+
server.registerTool(
|
|
10138
|
+
"bridge_branch_status",
|
|
10139
|
+
{
|
|
10140
|
+
title: "Branch status",
|
|
10141
|
+
description: "One branch in review as Studio shows it: its product, status (draft, in review, needs design or tech approval, blocked by tests, conflict, ready to merge, merged, closed), approvals, tests, how it stands against main, whether the person may merge it (same gate as the Merge button), and the next step. For the token diff and contrast, use bridge_get_review.",
|
|
10142
|
+
inputSchema: { pr: Pr },
|
|
10143
|
+
annotations: READ
|
|
10144
|
+
},
|
|
10145
|
+
run((a) => branchStatus(ctx, a))
|
|
10146
|
+
);
|
|
10147
|
+
server.registerTool(
|
|
10148
|
+
"bridge_list_comments",
|
|
10149
|
+
{
|
|
10150
|
+
title: "List comments",
|
|
10151
|
+
description: "The conversation on a branch and its line comment threads, with the ids to reply to (bridge_comment with replyTo).",
|
|
10152
|
+
inputSchema: { pr: Pr },
|
|
10153
|
+
annotations: READ
|
|
10154
|
+
},
|
|
10155
|
+
run((a) => listComments(ctx, a))
|
|
10156
|
+
);
|
|
10157
|
+
server.registerTool(
|
|
10158
|
+
"bridge_try_branch",
|
|
10159
|
+
{
|
|
10160
|
+
title: "Try it in a product, share it",
|
|
10161
|
+
description: 'Try a branch before it merges: the preview link of each product Vercel reported for it (the admin demo opens with the Bridge toolbar on, as "Try it in the product" in Studio), the other previews, and "Share with a product team": the install command a product team runs in a staging branch, or why there is none yet, with a ready-to-paste message. Never a guessed link. Writes nothing.',
|
|
10162
|
+
inputSchema: { pr: Pr },
|
|
10163
|
+
annotations: READ
|
|
10164
|
+
},
|
|
10165
|
+
run((a) => tryBranch(ctx, a))
|
|
10166
|
+
);
|
|
10167
|
+
server.registerTool(
|
|
10168
|
+
"bridge_combine_branches",
|
|
10169
|
+
{
|
|
10170
|
+
title: "Try 2 to 4 branches together",
|
|
10171
|
+
description: `Combined preview of 2 to 4 token branches, as "Try together" in Studio: each branch's own token edits on the latest main, the tokens two branches set differently (pick one per key with picks), and the links that open the combination (the admin demo with the Bridge toolbar, the library views). Branches that change code are refused. Writes nothing.`,
|
|
10172
|
+
inputSchema: {
|
|
10173
|
+
prs: z.array(Pr).min(2).max(4).describe("2 to 4 branch numbers."),
|
|
10174
|
+
picks: z.record(z.string(), z.number().int().positive()).optional().describe('For each conflict key, the branch whose value to use: {"semantic/light.tokens.json#color.text.muted": 118}.')
|
|
10175
|
+
},
|
|
10176
|
+
annotations: READ
|
|
10177
|
+
},
|
|
10178
|
+
run((a) => combineBranches(ctx, a))
|
|
10179
|
+
);
|
|
10180
|
+
server.registerTool(
|
|
10181
|
+
"bridge_get_settings",
|
|
10182
|
+
{
|
|
10183
|
+
title: "Get settings",
|
|
10184
|
+
description: "What Studio Settings shows that is safe to read: who approves design and tech (GitHub teams named with their size), who may merge, the owner override (who, what reason), whether main is protected, the products, the preview apps and the visual impact mode. Never tokens or secrets.",
|
|
10185
|
+
inputSchema: {},
|
|
10186
|
+
annotations: READ
|
|
10187
|
+
},
|
|
10188
|
+
run(async () => getSettings(ctx))
|
|
10189
|
+
);
|
|
10190
|
+
server.registerTool(
|
|
10191
|
+
"bridge_figma_coverage",
|
|
10192
|
+
{
|
|
10193
|
+
title: "Figma coverage",
|
|
10194
|
+
description: "Which components are in Figma, in Studio's words (Yes, Drafted, Auto layout, Not yet with the reason), and whether code images exist for the fidelity check the DS Bridge Figma plugin runs (its scores live in Figma).",
|
|
10195
|
+
inputSchema: { component: z.string().optional().describe("One component, e.g. Button.") },
|
|
10196
|
+
annotations: READ
|
|
10197
|
+
},
|
|
10198
|
+
run((a) => figmaCoverage(ctx, a))
|
|
10199
|
+
);
|
|
10200
|
+
server.registerTool(
|
|
10201
|
+
"bridge_send_for_review",
|
|
10202
|
+
{
|
|
10203
|
+
title: "Send for review",
|
|
10204
|
+
description: 'Ask for review: a draft branch becomes ready for review, as "Mark as ready" in Studio. Its author only. Needs GITHUB_TOKEN (pull requests: write).',
|
|
10205
|
+
inputSchema: { pr: Pr },
|
|
10206
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10207
|
+
},
|
|
10208
|
+
run((a) => sendForReview(ctx, a))
|
|
10209
|
+
);
|
|
10210
|
+
server.registerTool(
|
|
10211
|
+
"bridge_back_to_draft",
|
|
10212
|
+
{
|
|
10213
|
+
title: "Back to draft",
|
|
10214
|
+
description: "Take a branch out of review back to draft, as in Studio's More menu. Its author only. Needs GITHUB_TOKEN (pull requests: write).",
|
|
10215
|
+
inputSchema: { pr: Pr },
|
|
10216
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10217
|
+
},
|
|
10218
|
+
run((a) => backToDraft(ctx, a))
|
|
10219
|
+
);
|
|
10220
|
+
server.registerTool(
|
|
10221
|
+
"bridge_review_branch",
|
|
10222
|
+
{
|
|
10223
|
+
title: "Review a branch",
|
|
10224
|
+
description: "Approve, request changes or comment on a branch, as the review box in Studio, on the version the person saw, under their GitHub account. Request changes and comment need a text that says what to change; an approval may have none. Only when the person asked. Needs GITHUB_TOKEN (pull requests: write).",
|
|
10225
|
+
inputSchema: {
|
|
10226
|
+
pr: Pr,
|
|
10227
|
+
decision: z.enum(["approve", "request_changes", "comment"]),
|
|
10228
|
+
body: z.string().optional().describe("What to change, or why it is fine. Plain sentences, no em dash.")
|
|
10229
|
+
},
|
|
10230
|
+
annotations: WRITE
|
|
10231
|
+
},
|
|
10232
|
+
run((a) => reviewBranch(ctx, a))
|
|
10233
|
+
);
|
|
10234
|
+
server.registerTool(
|
|
10235
|
+
"bridge_ask_someone_else",
|
|
10236
|
+
{
|
|
10237
|
+
title: "Ask someone else to review",
|
|
10238
|
+
description: 'Ask the other people who can give the missing approvals to review a branch, as "Ask someone else to review" in the Studio review queue, and take the person off its requested reviewers. Never the person or the author. Only when the person asked; dryRun says who would be asked. Needs GITHUB_TOKEN (pull requests: write).',
|
|
10239
|
+
inputSchema: { pr: Pr, dryRun: z.boolean().optional() },
|
|
10240
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10241
|
+
},
|
|
10242
|
+
run((a) => askSomeoneElse(ctx, a))
|
|
10243
|
+
);
|
|
10244
|
+
server.registerTool(
|
|
10245
|
+
"bridge_comment",
|
|
10246
|
+
{
|
|
10247
|
+
title: "Comment on a branch",
|
|
10248
|
+
description: "Post a comment on a branch, or reply in the thread of a line comment (replyTo: its id from bridge_list_comments). Needs GITHUB_TOKEN (pull requests: write).",
|
|
10249
|
+
inputSchema: { pr: Pr, body: z.string().min(1).describe("Plain sentences, no em dash."), replyTo: z.number().int().positive().optional().describe("Line comment thread to reply in.") },
|
|
10250
|
+
annotations: WRITE
|
|
10251
|
+
},
|
|
10252
|
+
run((a) => commentOnBranch(ctx, a))
|
|
10253
|
+
);
|
|
10254
|
+
server.registerTool(
|
|
10255
|
+
"bridge_merge_branch",
|
|
10256
|
+
{
|
|
10257
|
+
title: "Merge a branch",
|
|
10258
|
+
description: "Merge a branch into main through the same gate as Studio's Merge button: tests pass, the approvals its tier needs, no changes requested, no conflict, not a draft or an exploration, and the person holds the right to ship (governance.rights). When it is blocked, the error says by what. Owner override: pass overrideReason when only approvals are missing and the person is in governance.override.allowed; the reason is posted on the branch first. Only when the person explicitly asked to merge; dryRun checks without merging. Needs GITHUB_TOKEN (contents and pull requests: write); GitHub's own branch rules still apply.",
|
|
10259
|
+
inputSchema: {
|
|
10260
|
+
pr: Pr,
|
|
10261
|
+
overrideReason: z.string().optional().describe("Owner override only: why it merges without the missing approvals (at least the configured length). Posted on the branch."),
|
|
10262
|
+
dryRun: z.boolean().optional().describe("Check the gate without merging.")
|
|
10263
|
+
},
|
|
10264
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
|
|
10265
|
+
},
|
|
10266
|
+
run((a) => mergeBranch(ctx, a))
|
|
10267
|
+
);
|
|
10268
|
+
server.registerTool(
|
|
10269
|
+
"bridge_close_branch",
|
|
10270
|
+
{
|
|
10271
|
+
title: "Close a branch",
|
|
10272
|
+
description: "Close a branch without merging it, as in Studio's More menu. Its work stays on GitHub and it can be reopened. Explorations close with bridge_close_exploration. Only when the person asked. Needs GITHUB_TOKEN (pull requests: write).",
|
|
10273
|
+
inputSchema: { pr: Pr },
|
|
10274
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
10275
|
+
},
|
|
10276
|
+
run((a) => closeBranch(ctx, a))
|
|
10277
|
+
);
|
|
10278
|
+
server.registerTool(
|
|
10279
|
+
"bridge_reopen_branch",
|
|
10280
|
+
{
|
|
10281
|
+
title: "Reopen a branch",
|
|
10282
|
+
description: "Reopen a closed branch, with Studio's rule: its author or an owner with write access, and only while its work is still on GitHub. Needs GITHUB_TOKEN (pull requests: write).",
|
|
10283
|
+
inputSchema: { pr: Pr },
|
|
10284
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10285
|
+
},
|
|
10286
|
+
run((a) => reopenBranch(ctx, a))
|
|
10287
|
+
);
|
|
10288
|
+
server.registerTool(
|
|
10289
|
+
"bridge_update_from_main",
|
|
10290
|
+
{
|
|
10291
|
+
title: "Update from main",
|
|
10292
|
+
description: `Bring the latest main into a branch, as "Update from main" in Studio. Nothing conflicts: GitHub updates it. Only token files conflict: the tokens changed on both sides come back with keys; pass choices ("mine" keeps the branch value, "theirs" uses main's; tokens with a default keep the branch value) and Bridge commits the resolution. Code conflicts stay with a developer on GitHub. dryRun shows what would happen. Needs GITHUB_TOKEN (contents and pull requests: write).`,
|
|
10293
|
+
inputSchema: {
|
|
10294
|
+
pr: Pr,
|
|
10295
|
+
choices: z.record(z.string(), z.enum(["mine", "theirs"])).optional().describe('Per conflict key from a dry run: "mine" or "theirs".'),
|
|
10296
|
+
dryRun: z.boolean().optional()
|
|
10297
|
+
},
|
|
10298
|
+
annotations: WRITE
|
|
10299
|
+
},
|
|
10300
|
+
run((a) => updateFromMain(ctx, a))
|
|
10301
|
+
);
|
|
10302
|
+
server.registerTool(
|
|
10303
|
+
"bridge_save_docs",
|
|
10304
|
+
{
|
|
10305
|
+
title: "Save component docs",
|
|
10306
|
+
description: `Save a component's usage guidance (docs.json sections: summary, whenToUse, whenNotToUse, dos, donts, accessibility, contentGuidelines, anatomy, preview) the way the Library edit mode does: merged into the docs on the branch, cleaned and checked (a summary, no empty items, no em dash). Without pr: a new branch sent for review (docs only: one approval, patch release note), with an optional Jira key and product. With pr: one commit on that open branch. dryRun returns the file. Needs GITHUB_TOKEN (contents and pull requests: write). Prose you drafted carries "draftedBy": "claude" and "needsReview": true so a reviewer accepts it in Studio.`,
|
|
10307
|
+
inputSchema: {
|
|
10308
|
+
component: z.string().min(1).describe("Component name, e.g. Button."),
|
|
10309
|
+
docs: z.record(z.string(), z.unknown()).describe('The sections to set, in docs.json shapes: summary a string; whenToUse, whenNotToUse, accessibility, contentGuidelines lists of strings; dos and donts lists of {"title","text"}.'),
|
|
10310
|
+
pr: Pr.optional().describe("An open branch to save the docs on, instead of a new one."),
|
|
10311
|
+
title: z.string().optional().describe('Title of the new branch. Default: "docs(<component>): usage guidance for <Component>".'),
|
|
10312
|
+
notes: z.string().optional().describe("Why, for the reviewers."),
|
|
10313
|
+
jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
|
|
10314
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products). Default: the product of the current folder or repo."),
|
|
10315
|
+
dryRun: z.boolean().optional()
|
|
10316
|
+
},
|
|
10317
|
+
annotations: WRITE
|
|
10318
|
+
},
|
|
10319
|
+
run((a) => saveDocs(ctx, a))
|
|
10320
|
+
);
|
|
8162
10321
|
server.registerResource("resolved-tokens", "bridge://tokens/resolved", { title: "Resolved tokens", description: "Every token resolved in the default mode, plus only the values that differ in each other mode combination.", mimeType: "application/json" }, async (uri) => {
|
|
8163
10322
|
const set = await ctx.tokens();
|
|
8164
10323
|
const def = set.defaultCombo;
|
|
@@ -8191,8 +10350,8 @@ function createServer(ctx) {
|
|
|
8191
10350
|
new ResourceTemplate("bridge://components/{name}", {
|
|
8192
10351
|
list: async () => {
|
|
8193
10352
|
const { config } = await ctx.config();
|
|
8194
|
-
const
|
|
8195
|
-
return { resources:
|
|
10353
|
+
const names2 = await listComponentNames(ctx.source, config);
|
|
10354
|
+
return { resources: names2.map((n) => ({ uri: `bridge://components/${n.name}`, name: n.name, mimeType: "application/json" })) };
|
|
8196
10355
|
},
|
|
8197
10356
|
complete: {
|
|
8198
10357
|
name: async (value) => {
|
|
@@ -8372,12 +10531,14 @@ export {
|
|
|
8372
10531
|
GitHubSource,
|
|
8373
10532
|
BridgeContext,
|
|
8374
10533
|
auditSnippet,
|
|
10534
|
+
branches_exports,
|
|
8375
10535
|
explorations_exports,
|
|
8376
10536
|
ops_exports,
|
|
8377
10537
|
history_exports,
|
|
8378
10538
|
brand_exports,
|
|
8379
10539
|
requests_exports,
|
|
8380
10540
|
drafts_exports,
|
|
10541
|
+
studio_exports,
|
|
8381
10542
|
VERSION,
|
|
8382
10543
|
createServer,
|
|
8383
10544
|
serveStdio,
|