@bridge-ds/mcp 0.3.3 → 0.4.1
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 +440 -0
- package/dist/{chunk-7HYTZVRW.js → chunk-MXMMPO7F.js} +2250 -37
- package/dist/{chunk-K3HY24MZ.js → chunk-QTO4EP6V.js} +336 -1
- 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-V653Z7JC.js → src-DHJOM5PQ.js} +7 -145
- package/dist/studio.d.ts +113 -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
|
+
productMarks,
|
|
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-QTO4EP6V.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,6 +3653,7 @@ 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);
|
|
3051
3659
|
const jira = /^([A-Z][A-Z0-9_]*-\d+)\s+/.exec(input.title)?.[1] ?? null;
|
|
@@ -3476,7 +4084,7 @@ Exploration: never merged as is. Promote it to review or close it.`,
|
|
|
3476
4084
|
const ctx = { owner: gh.owner, repo: gh.name, packages: s.packages, apps: s.apps, ...s.requests?.length ? { requests: s.requests } : {} };
|
|
3477
4085
|
const title = full.jira ? `${full.jira.trim().toUpperCase()} ${explorationPrTitle(full)}` : explorationPrTitle(full);
|
|
3478
4086
|
const pr = await x.createPullRequest({ title, head: branch, base: s.base, body: explorationPrBody(full, ctx), draft: true });
|
|
3479
|
-
await x.addLabels(pr.number, [EXPLORATION_LABEL]);
|
|
4087
|
+
await x.addLabels(pr.number, [EXPLORATION_LABEL, ...s.labels ?? []]);
|
|
3480
4088
|
await x.updatePullRequest(pr.number, { body: explorationPrBody(full, { ...ctx, pr: pr.number }) }).catch(() => void 0);
|
|
3481
4089
|
return {
|
|
3482
4090
|
branch,
|
|
@@ -3538,6 +4146,749 @@ async function closeExplorationOnGitHub(gh, ref) {
|
|
|
3538
4146
|
return { number: p.number, url: p.url, branch: p.headRef };
|
|
3539
4147
|
}
|
|
3540
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: "Written by a person in the Bridge docs editor. No AI involved in this text.",
|
|
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
|
+
var docsSlug = (nameOrSlug) => nameOrSlug.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40);
|
|
4646
|
+
function defaultDocsFooter(edits, origin) {
|
|
4647
|
+
if (origin.label !== DOCS_EDITOR_ORIGIN.label) return origin.footer;
|
|
4648
|
+
const claude = edits.some((e) => {
|
|
4649
|
+
try {
|
|
4650
|
+
return JSON.parse(e.after).draftedBy === "claude";
|
|
4651
|
+
} catch {
|
|
4652
|
+
return false;
|
|
4653
|
+
}
|
|
4654
|
+
});
|
|
4655
|
+
return claude ? `Drafted by Claude AI (the docs say "draftedBy": "claude"), sent by a person ${origin.where}.` : origin.footer;
|
|
4656
|
+
}
|
|
4657
|
+
function withAuthorLine(body, line) {
|
|
4658
|
+
const i = body.lastIndexOf("\n---\n");
|
|
4659
|
+
return i < 0 ? `${body.trimEnd()}
|
|
4660
|
+
|
|
4661
|
+
---
|
|
4662
|
+
${line}` : `${body.slice(0, i + 5)}${line}`;
|
|
4663
|
+
}
|
|
4664
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4665
|
+
function isDocsProposalBranch(branch, component) {
|
|
4666
|
+
const slugs = /* @__PURE__ */ new Set([docsSlug(component.slug ?? component.name), docsSlug(component.name), docsSlug(component.name.toLowerCase())]);
|
|
4667
|
+
return [...slugs].some((s) => new RegExp(`^docs/(?:[A-Z][A-Z0-9]*-\\d+-)?${escapeRe(s)}-\\d{8}-[a-z0-9]+$`).test(branch));
|
|
4668
|
+
}
|
|
4669
|
+
function findOpenDocsProposal(pulls, component, base) {
|
|
4670
|
+
const hit = pulls.filter((p) => p.state === "open" && isDocsProposalBranch(p.headRef, component) && (!base || !p.baseRef || p.baseRef === base)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
4671
|
+
return hit ? { number: hit.number, url: hit.url, branch: hit.headRef, author: hit.author, title: hit.title, updatedAt: hit.updatedAt } : null;
|
|
4672
|
+
}
|
|
4673
|
+
async function findOpenDocsProposalOnGitHub(gh, component, base) {
|
|
4674
|
+
return findOpenDocsProposal(await gh.listPullRequests("open"), component, base);
|
|
4675
|
+
}
|
|
4676
|
+
async function continueDocsProposalOnGitHub(gh, open, edit, opts = {}) {
|
|
4677
|
+
const message = opts.message ?? `docs(${docsSlug(edit.component)}): edit usage guidance in the Bridge docs editor`;
|
|
4678
|
+
const cur = await gh.getFile(edit.path, open.branch);
|
|
4679
|
+
if (cur?.text === edit.after) return "";
|
|
4680
|
+
const sha = await gh.putFile({ path: edit.path, content: edit.after, branch: open.branch, message, sha: cur?.sha });
|
|
4681
|
+
if (opts.authorLine) {
|
|
4682
|
+
const line = opts.authorLine;
|
|
4683
|
+
await gh.getPullRequest(open.number).then((pr) => gh.updatePullRequest(open.number, { body: withAuthorLine(pr.body, line) })).catch(() => void 0);
|
|
4684
|
+
}
|
|
4685
|
+
return sha;
|
|
4686
|
+
}
|
|
4687
|
+
function docsProposal(edits, opts) {
|
|
4688
|
+
const origin = opts.origin ?? DOCS_EDITOR_ORIGIN;
|
|
4689
|
+
if (!edits.length) throw new Error("No docs edits in the draft.");
|
|
4690
|
+
const names2 = edits.map((e) => e.component);
|
|
4691
|
+
const list = names2.length === 1 ? names2[0] : `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
|
|
4692
|
+
const title = opts.title?.trim() || `docs(${names2.length === 1 ? edits[0].slug : "components"}): usage guidance for ${list}`;
|
|
4693
|
+
const slug2 = docsSlug(names2.length === 1 ? edits[0].slug : `${edits[0].slug}-and-${names2.length - 1}-more`);
|
|
4694
|
+
const branch = `docs/${slug2}-${slugDate(opts.date)}-${opts.date.getTime().toString(36).slice(-4)}`;
|
|
4695
|
+
const csPath = `${opts.changesetDir ?? ".changeset"}/docs-${slug2}-${opts.date.getTime().toString(36)}.md`;
|
|
4696
|
+
const pkg = opts.packageName ?? "@bridge-ds/react";
|
|
4697
|
+
const changeset = `---
|
|
4698
|
+
"${pkg}": patch
|
|
4699
|
+
---
|
|
4700
|
+
|
|
4701
|
+
Usage guidance for ${list}.
|
|
4702
|
+
`;
|
|
4703
|
+
const files = [...edits.map((e) => ({ path: e.path, content: e.after })), { path: csPath, content: changeset }];
|
|
4704
|
+
const risk = classifyRisk({ files: files.map((f) => ({ path: f.path, status: "modified" })) });
|
|
4705
|
+
const rows = edits.map((e) => {
|
|
4706
|
+
let state = "edited";
|
|
4707
|
+
try {
|
|
4708
|
+
const d = JSON.parse(e.after);
|
|
4709
|
+
const gaps = missingProse(d);
|
|
4710
|
+
state = gaps.length ? `still missing: ${gaps.join(", ")}` : "every section filled";
|
|
4711
|
+
} catch {
|
|
4712
|
+
state = "not valid JSON";
|
|
4713
|
+
}
|
|
4714
|
+
return `- \`${e.path}\` (${e.before === null ? "new" : "changed"}, ${state})`;
|
|
4715
|
+
});
|
|
4716
|
+
const body = [
|
|
4717
|
+
`## Docs change`,
|
|
4718
|
+
"",
|
|
4719
|
+
`Usage guidance for ${list}, written ${origin.where}.`,
|
|
4720
|
+
"",
|
|
4721
|
+
...rows,
|
|
4722
|
+
"",
|
|
4723
|
+
`**Risk:** ${risk.label}. Docs only: the published components do not change. Changeset: patch.`,
|
|
4724
|
+
"",
|
|
4725
|
+
opts.notes?.trim() ? `## Notes
|
|
4726
|
+
|
|
4727
|
+
${opts.notes.trim()}
|
|
4728
|
+
` : "",
|
|
4729
|
+
"---",
|
|
4730
|
+
opts.authorLine?.trim() || defaultDocsFooter(edits, origin)
|
|
4731
|
+
].filter((x, i, a) => !(x === "" && a[i - 1] === "")).join("\n");
|
|
4732
|
+
return { branch, title, body, files, risk, bump: "patch", labels: ["docs", origin.label, `tier-${risk.tier}`] };
|
|
4733
|
+
}
|
|
4734
|
+
async function proposeDocsOnGitHub(gh, edits, opts) {
|
|
4735
|
+
if (opts.continueOpen !== false && edits.length === 1) {
|
|
4736
|
+
const e = edits[0];
|
|
4737
|
+
const open = await findOpenDocsProposalOnGitHub(gh, { name: e.component, slug: e.slug }, opts.base).catch(() => null);
|
|
4738
|
+
if (open) {
|
|
4739
|
+
const sha = await continueDocsProposalOnGitHub(gh, open, e, { authorLine: opts.authorLine?.trim() || defaultDocsFooter(edits, opts.origin ?? DOCS_EDITOR_ORIGIN) });
|
|
4740
|
+
return { number: open.number, url: open.url, branch: open.branch, title: open.title, continued: true, unchanged: !sha };
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
const p = docsProposal(edits, { title: opts.title, notes: opts.notes, date: opts.date ?? /* @__PURE__ */ new Date(), origin: opts.origin, authorLine: opts.authorLine });
|
|
4744
|
+
const prTitle = titleWithJiraKey(p.title, opts.jira ?? null);
|
|
4745
|
+
const branch = branchWithJiraKey(p.branch, opts.jira ?? null);
|
|
4746
|
+
const mainSha = await gh.getBranchSha(opts.base);
|
|
4747
|
+
if (!mainSha) throw new Error(`Branch ${opts.base} not found.`);
|
|
4748
|
+
const tree = await gh.createTree(await gh.getCommitTree(mainSha), p.files.map((f) => ({ path: f.path, content: f.content })));
|
|
4749
|
+
const commit = await gh.createCommit(`${prTitle}
|
|
4750
|
+
|
|
4751
|
+
${opts.commitNote ?? "Proposed with the Bridge docs editor."}`, tree, [mainSha]);
|
|
4752
|
+
await gh.createBranch(branch, commit);
|
|
4753
|
+
const pr = await gh.createPullRequest({ title: prTitle, head: branch, base: opts.base, body: p.body, draft: false });
|
|
4754
|
+
await gh.addLabels(pr.number, [...p.labels, ...opts.extraLabels ?? []]).catch(() => false);
|
|
4755
|
+
return { number: pr.number, url: pr.url, branch, title: prTitle, continued: false, unchanged: false };
|
|
4756
|
+
}
|
|
4757
|
+
async function commitFileOnGitHub(gh, input) {
|
|
4758
|
+
const cur = await gh.getFile(input.path, input.branch);
|
|
4759
|
+
if (!cur) throw new Error(`${input.path} is not on ${input.branch}. Check the component name and the branch.`);
|
|
4760
|
+
const content = input.next(cur.text);
|
|
4761
|
+
if (content === cur.text) return "";
|
|
4762
|
+
return gh.putFile({ path: input.path, content, branch: input.branch, message: input.message, sha: cur.sha });
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
// ../core/src/share-branch.ts
|
|
4766
|
+
var packageDir = (name) => `packages/${name.replace(/^@[^/]+\//, "")}/`;
|
|
4767
|
+
function touchesPackages(files, packages) {
|
|
4768
|
+
const dirs = packages.map(packageDir);
|
|
4769
|
+
return files.some((f) => dirs.some((d) => f.startsWith(d)));
|
|
4770
|
+
}
|
|
4771
|
+
function shareState(x) {
|
|
4772
|
+
if (!touchesPackages(x.files, x.packages)) return { kind: "no-packages" };
|
|
4773
|
+
const c = readPreviewComment(x.comment);
|
|
4774
|
+
if (x.lifecycle === "merged" || x.lifecycle === "closed") {
|
|
4775
|
+
const kind = x.lifecycle === "merged" ? "shipped" : "closed";
|
|
4776
|
+
if (!c || !c.command || c.source === "none") return { kind, stillWorks: null, command: null };
|
|
4777
|
+
return { kind, stillWorks: c.source === "pkg.pr.new" ? true : c.source === "release" ? false : null, command: c.command };
|
|
4778
|
+
}
|
|
4779
|
+
if (!c) return { kind: "building" };
|
|
4780
|
+
if (c.sha7 && !x.headSha.startsWith(c.sha7)) return { kind: "building" };
|
|
4781
|
+
if (!c.command || c.source === "none") return { kind: "failed" };
|
|
4782
|
+
return { kind: "ready", command: c.command, source: c.source };
|
|
4783
|
+
}
|
|
4784
|
+
var stableCommand = (packages) => `npm i ${packages.map((p) => `${p}@latest`).join(" ")}`;
|
|
4785
|
+
function stateSentence(s) {
|
|
4786
|
+
switch (s.kind) {
|
|
4787
|
+
case "ready":
|
|
4788
|
+
return null;
|
|
4789
|
+
case "building":
|
|
4790
|
+
return "The preview version is being prepared. It is usually ready a couple of minutes after each update to the branch.";
|
|
4791
|
+
case "failed":
|
|
4792
|
+
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.";
|
|
4793
|
+
case "no-packages":
|
|
4794
|
+
return "This branch does not touch the library tokens or components, so there is no preview version to install.";
|
|
4795
|
+
case "shipped":
|
|
4796
|
+
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.";
|
|
4797
|
+
case "closed":
|
|
4798
|
+
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.";
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
var quote = (t) => `"${t.trim().replace(/"/g, "'")}"`;
|
|
4802
|
+
function shareMessage(x) {
|
|
4803
|
+
const back = stableCommand(x.packages);
|
|
4804
|
+
const s = x.state;
|
|
4805
|
+
if (s.kind === "ready") {
|
|
4806
|
+
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.";
|
|
4807
|
+
return {
|
|
4808
|
+
subject: `Try the design system change ${quote(x.title)} before it ships`,
|
|
4809
|
+
body: [
|
|
4810
|
+
"Hi,",
|
|
4811
|
+
"",
|
|
4812
|
+
`Could you try the design system change ${quote(x.title)} in our product before it ships?`,
|
|
4813
|
+
...x.tryUrl ? ["", `See it in the demo product first: ${x.tryUrl}`] : [],
|
|
4814
|
+
"",
|
|
4815
|
+
"Install the preview version in a staging branch (not production):",
|
|
4816
|
+
s.command,
|
|
4817
|
+
"",
|
|
4818
|
+
lasts,
|
|
4819
|
+
"",
|
|
4820
|
+
"To go back to the stable version:",
|
|
4821
|
+
back,
|
|
4822
|
+
"",
|
|
4823
|
+
`Details for developers: ${x.url}`
|
|
4824
|
+
].join("\n")
|
|
4825
|
+
};
|
|
4826
|
+
}
|
|
4827
|
+
if ((s.kind === "shipped" || s.kind === "closed") && s.command) {
|
|
4828
|
+
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.`;
|
|
4829
|
+
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." : "";
|
|
4830
|
+
return {
|
|
4831
|
+
subject: `Design system change ${quote(x.title)} ${s.kind === "shipped" ? "has shipped" : "was closed"}`,
|
|
4832
|
+
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")
|
|
4833
|
+
};
|
|
4834
|
+
}
|
|
4835
|
+
return null;
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
// ../core/src/try-in-product.ts
|
|
4839
|
+
function tryInProductUrl(preview, pr, screen) {
|
|
4840
|
+
const url = new URL(preview);
|
|
4841
|
+
url.pathname = "/";
|
|
4842
|
+
url.search = "";
|
|
4843
|
+
url.searchParams.set("bridge", String(pr));
|
|
4844
|
+
if (screen) {
|
|
4845
|
+
url.searchParams.set("tenant", screen.tenant);
|
|
4846
|
+
url.searchParams.set("theme", screen.theme);
|
|
4847
|
+
}
|
|
4848
|
+
url.hash = screen ? screen.route : "";
|
|
4849
|
+
return url.href;
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
// ../core/src/combine-branches.ts
|
|
4853
|
+
var MIN_TOGETHER = 2;
|
|
4854
|
+
var MAX_TOGETHER = 4;
|
|
4855
|
+
function onlyTokens(files, tokensRoot) {
|
|
4856
|
+
const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
|
|
4857
|
+
return files.length > 0 && files.every((f) => f.startsWith(root) && f.endsWith(".tokens.json") || /contrast\.checks\.json$/.test(f) || f.startsWith(".changeset/"));
|
|
4858
|
+
}
|
|
4859
|
+
function tokenFilesOf(files, tokensRoot) {
|
|
4860
|
+
const root = `${tokensRoot.replace(/^\/+|\/+$/g, "")}/`;
|
|
4861
|
+
return files.filter((f) => f.startsWith(root) && f.endsWith(".tokens.json")).map((f) => f.slice(root.length));
|
|
4862
|
+
}
|
|
4863
|
+
var changeList = (numbers) => numbers.join(",");
|
|
4864
|
+
function tryTogetherUrl(admin, numbers, picks) {
|
|
4865
|
+
const url = new URL(admin);
|
|
4866
|
+
url.pathname = "/";
|
|
4867
|
+
url.hash = "";
|
|
4868
|
+
const use = encodePicks(picks);
|
|
4869
|
+
url.search = `?bridge=${changeList(numbers)}${use ? `&bridge-use=${encodeURIComponent(use)}` : ""}`;
|
|
4870
|
+
return url.href;
|
|
4871
|
+
}
|
|
4872
|
+
var COMBINE_COMPARE_LIMIT = 300;
|
|
4873
|
+
async function readCombineBranch(gh, pull, opts) {
|
|
4874
|
+
const compare = await gh.compareDetailed(opts.base, pull.headSha);
|
|
4875
|
+
const files = compare.files;
|
|
4876
|
+
const names2 = files.flatMap((f) => f.previous_filename ? [f.filename, f.previous_filename] : [f.filename]);
|
|
4877
|
+
const start = compare.mergeBase;
|
|
4878
|
+
if (!start || files.length >= COMBINE_COMPARE_LIMIT || !onlyTokens(names2, opts.tokensRoot)) return { number: pull.number, title: pull.title, tokens: false, edits: null };
|
|
4879
|
+
const root = opts.tokensRoot.replace(/^\/+|\/+$/g, "");
|
|
4880
|
+
const tokenFiles = files.filter((f) => tokenFilesOf([f.filename], root).length);
|
|
4881
|
+
const source = { number: pull.number, base: {}, head: {}, files: tokenFiles.map((f) => tokenFilesOf([f.filename], root)[0]) };
|
|
4882
|
+
await Promise.all(
|
|
4883
|
+
tokenFiles.map(async (f) => {
|
|
4884
|
+
const rel = tokenFilesOf([f.filename], root)[0];
|
|
4885
|
+
source.base[rel] = f.status === "added" ? void 0 : await gh.readFile(f.previous_filename ?? f.filename, start);
|
|
4886
|
+
source.head[rel] = f.status === "removed" ? void 0 : await gh.readFile(f.filename, pull.headSha);
|
|
4887
|
+
})
|
|
4888
|
+
);
|
|
4889
|
+
return { number: pull.number, title: pull.title, tokens: true, edits: changeEdits(source) };
|
|
4890
|
+
}
|
|
4891
|
+
|
|
3541
4892
|
// ../core/src/node/git-history.ts
|
|
3542
4893
|
var US = "";
|
|
3543
4894
|
var RS = "";
|
|
@@ -5402,9 +6753,9 @@ var DesignSystemIndex = class {
|
|
|
5402
6753
|
return generic ? this.make(generic, 1, 0.3) : void 0;
|
|
5403
6754
|
}
|
|
5404
6755
|
case "shadow": {
|
|
5405
|
-
const
|
|
6756
|
+
const norm2 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
|
|
5406
6757
|
const cands = this.entries(["shadow"]);
|
|
5407
|
-
const exact = cands.find((e) =>
|
|
6758
|
+
const exact = cands.find((e) => norm2(e.css) === norm2(value));
|
|
5408
6759
|
if (exact) return this.make(exact, 0, 1);
|
|
5409
6760
|
const t = shadowNums(value);
|
|
5410
6761
|
const scored = cands.map((e) => {
|
|
@@ -5830,11 +7181,11 @@ async function listComponentNames(source, config) {
|
|
|
5830
7181
|
}
|
|
5831
7182
|
var findFamily = (stories) => stories ? /title:\s*['"]([^'"/]+)\//.exec(stories)?.[1] : void 0;
|
|
5832
7183
|
async function listComponents(source, config, withSummaries) {
|
|
5833
|
-
const
|
|
5834
|
-
if (!withSummaries) return
|
|
7184
|
+
const names2 = await listComponentNames(source, config);
|
|
7185
|
+
if (!withSummaries) return names2.map((n) => ({ name: n.name }));
|
|
5835
7186
|
const dir = config.components.dir;
|
|
5836
7187
|
return Promise.all(
|
|
5837
|
-
|
|
7188
|
+
names2.map(async ({ name, files }) => {
|
|
5838
7189
|
const docsPath = `${dir}/${name}/${name}${config.components.docsSuffix}`;
|
|
5839
7190
|
const storiesPath = files.find((f) => /\.stories\.\w+$/.test(f));
|
|
5840
7191
|
const [docs, stories] = await Promise.all([files.includes(docsPath) ? source.read(docsPath) : null, storiesPath ? source.read(storiesPath) : null]);
|
|
@@ -6289,6 +7640,450 @@ __export(explorations_exports, {
|
|
|
6289
7640
|
});
|
|
6290
7641
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
6291
7642
|
import { dirname as dirname2, join as join3 } from "node:path";
|
|
7643
|
+
|
|
7644
|
+
// src/branches.ts
|
|
7645
|
+
var branches_exports = {};
|
|
7646
|
+
__export(branches_exports, {
|
|
7647
|
+
RIGHTS: () => RIGHTS,
|
|
7648
|
+
addableBranch: () => addableBranch,
|
|
7649
|
+
askSomeoneElse: () => askSomeoneElse,
|
|
7650
|
+
backToDraft: () => backToDraft,
|
|
7651
|
+
branchFacts: () => branchFacts,
|
|
7652
|
+
branchNumber: () => branchNumber,
|
|
7653
|
+
branchStatus: () => branchStatus,
|
|
7654
|
+
closeBranch: () => closeBranch,
|
|
7655
|
+
combineBranches: () => combineBranches,
|
|
7656
|
+
commentOnBranch: () => commentOnBranch,
|
|
7657
|
+
listComments: () => listComments,
|
|
7658
|
+
listProducts: () => listProducts,
|
|
7659
|
+
mergeBranch: () => mergeBranch,
|
|
7660
|
+
productMarks: () => productMarks,
|
|
7661
|
+
reopenBranch: () => reopenBranch,
|
|
7662
|
+
resolveProduct: () => resolveProduct,
|
|
7663
|
+
reviewBranch: () => reviewBranch,
|
|
7664
|
+
sendForReview: () => sendForReview,
|
|
7665
|
+
tryBranch: () => tryBranch,
|
|
7666
|
+
updateFromMain: () => updateFromMain,
|
|
7667
|
+
whatNeedsMe: () => whatNeedsMe
|
|
7668
|
+
});
|
|
7669
|
+
import { relative as relative2 } from "node:path";
|
|
7670
|
+
var RIGHTS = {
|
|
7671
|
+
review: "Pull requests: read and write",
|
|
7672
|
+
comment: "Pull requests (or Issues): read and write",
|
|
7673
|
+
merge: "Contents and Pull requests: read and write, and the right to ship in bridge.config.json (governance.rights)",
|
|
7674
|
+
update: "Contents and Pull requests: read and write",
|
|
7675
|
+
draft: "Pull requests: read and write (you must be the author, as in Studio)",
|
|
7676
|
+
docs: "Contents and Pull requests: read and write"
|
|
7677
|
+
};
|
|
7678
|
+
var NO_REPO = 'No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.';
|
|
7679
|
+
async function reader(ctx) {
|
|
7680
|
+
const gh = await ctx.github();
|
|
7681
|
+
if (!gh) throw new Error(NO_REPO);
|
|
7682
|
+
return gh;
|
|
7683
|
+
}
|
|
7684
|
+
async function writer(ctx, what, need) {
|
|
7685
|
+
const gh = await reader(ctx);
|
|
7686
|
+
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.`);
|
|
7687
|
+
return gh.withBranchPrefix(void 0);
|
|
7688
|
+
}
|
|
7689
|
+
function branchNumber(input) {
|
|
7690
|
+
if (typeof input === "number" && Number.isInteger(input) && input > 0) return input;
|
|
7691
|
+
const m = /^#?(\d+)$/.exec(String(input).trim()) ?? /\/pull\/(\d+)/.exec(String(input));
|
|
7692
|
+
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.`);
|
|
7693
|
+
return Number(m[1]);
|
|
7694
|
+
}
|
|
7695
|
+
var lifecycleOf = (state, draft) => state === "merged" ? "merged" : state === "closed" ? "closed" : draft ? "draft" : "open";
|
|
7696
|
+
async function people(gh, config) {
|
|
7697
|
+
const { owners, rights } = config.governance;
|
|
7698
|
+
const teams = await new TeamDirectory(gh).resolve(owners).catch(() => void 0);
|
|
7699
|
+
const lists = expandOwners(approverEntries(owners, rights), teams);
|
|
7700
|
+
return { lists, ship: shipPolicy(owners, rights, teams) };
|
|
7701
|
+
}
|
|
7702
|
+
async function viewerOf(ctx, gh) {
|
|
7703
|
+
if (!ctx.canWriteRemote) return null;
|
|
7704
|
+
return gh.getViewer().then((v) => v.login, () => null);
|
|
7705
|
+
}
|
|
7706
|
+
async function branchFacts(ctx, gh, n, viewer) {
|
|
7707
|
+
const { config } = await ctx.config();
|
|
7708
|
+
const pr = await gh.getPullRequest(n);
|
|
7709
|
+
const [files, reviews, runs, statuses, who, compare, repo] = await Promise.all([
|
|
7710
|
+
gh.listPullRequestFiles(n),
|
|
7711
|
+
gh.listReviews(n).catch(() => []),
|
|
7712
|
+
gh.listCheckRuns(pr.headSha).catch(() => []),
|
|
7713
|
+
gh.listCommitStatuses(pr.headSha).catch(() => []),
|
|
7714
|
+
people(gh, config),
|
|
7715
|
+
pr.state === "open" ? gh.compareDetailed(pr.baseRef, pr.headSha).catch(() => null) : Promise.resolve(null),
|
|
7716
|
+
viewer ? gh.getMergeSettings().catch(() => null) : Promise.resolve(null)
|
|
7717
|
+
]);
|
|
7718
|
+
const labels = pr.labels.map((name) => ({ name }));
|
|
7719
|
+
const ci = summarizeCi(
|
|
7720
|
+
runs.map((r) => ({ name: r.name, status: r.status, conclusion: r.conclusion, ...r.url ? { html_url: r.url } : {} })),
|
|
7721
|
+
statuses.map((s) => ({ context: s.context, state: s.state, target_url: s.target_url }))
|
|
7722
|
+
);
|
|
7723
|
+
const approvals = summarizeApprovals(
|
|
7724
|
+
reviews.map((r) => ({ user: r.user ? { login: r.user } : null, state: r.state, submitted_at: r.submittedAt ?? null })),
|
|
7725
|
+
who.lists,
|
|
7726
|
+
pr.author
|
|
7727
|
+
);
|
|
7728
|
+
const tier = effectiveTier(labels, pr.body, files);
|
|
7729
|
+
const merged = pr.state === "merged";
|
|
7730
|
+
const pullLike = { state: merged ? "closed" : pr.state, draft: pr.draft, merged_at: merged ? "merged" : null };
|
|
7731
|
+
const exploration = isActiveExploration(pr.labels);
|
|
7732
|
+
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 });
|
|
7733
|
+
const status = reviewStatusKind({ ...pullLike, ci, approvals, tier, mergeable: pr.mergeable });
|
|
7734
|
+
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" });
|
|
7735
|
+
const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
|
|
7736
|
+
return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo };
|
|
7737
|
+
}
|
|
7738
|
+
var STATUS_WORDS = {
|
|
7739
|
+
merged: "Merged",
|
|
7740
|
+
closed: "Closed",
|
|
7741
|
+
draft: "Draft",
|
|
7742
|
+
"ci-failing": "Blocked by tests",
|
|
7743
|
+
"changes-requested": "Changes requested",
|
|
7744
|
+
"ci-running": "Tests running",
|
|
7745
|
+
"needs-design": "Needs design approval",
|
|
7746
|
+
"needs-tech": "Needs tech approval",
|
|
7747
|
+
"needs-approval": "Needs approval",
|
|
7748
|
+
conflict: "Conflict with main",
|
|
7749
|
+
checking: "Checking",
|
|
7750
|
+
ready: "Ready to merge",
|
|
7751
|
+
unknown: "Status unknown"
|
|
7752
|
+
};
|
|
7753
|
+
async function branchStatus(ctx, a) {
|
|
7754
|
+
const n = branchNumber(a.pr);
|
|
7755
|
+
const gh = await reader(ctx);
|
|
7756
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7757
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7758
|
+
return {
|
|
7759
|
+
pr: n,
|
|
7760
|
+
title: f.pr.title,
|
|
7761
|
+
url: f.pr.url,
|
|
7762
|
+
author: f.pr.author,
|
|
7763
|
+
product: { key: f.product.key, name: f.product.name },
|
|
7764
|
+
lifecycle: f.lifecycle,
|
|
7765
|
+
status: { kind: f.status, label: STATUS_WORDS[f.status] },
|
|
7766
|
+
approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
|
|
7767
|
+
approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech },
|
|
7768
|
+
tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
|
|
7769
|
+
main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
|
|
7770
|
+
merge: {
|
|
7771
|
+
allowed: f.gate.allowed,
|
|
7772
|
+
blockedBy: f.gate.reasons,
|
|
7773
|
+
...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." }
|
|
7774
|
+
},
|
|
7775
|
+
next: nextStep(f, viewer)
|
|
7776
|
+
};
|
|
7777
|
+
}
|
|
7778
|
+
function nextStep(f, viewer) {
|
|
7779
|
+
if (f.lifecycle === "merged") return "Merged. It goes out with the next library release.";
|
|
7780
|
+
if (f.lifecycle === "closed") return "Closed without merging. bridge_reopen_branch reopens it when its work is still there.";
|
|
7781
|
+
if (f.exploration) return "It is an exploration: promote it with bridge_promote_exploration when the idea is validated, or close it.";
|
|
7782
|
+
if (f.lifecycle === "draft") return "Draft: bridge_send_for_review asks for review when it is ready.";
|
|
7783
|
+
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).";
|
|
7784
|
+
if (f.gate.allowed) return viewer ? "Ready: bridge_merge_branch merges it when the person asks." : "Ready to merge by someone who may ship.";
|
|
7785
|
+
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.`;
|
|
7786
|
+
return f.gate.reasons[0] ?? "Waiting.";
|
|
7787
|
+
}
|
|
7788
|
+
async function whatNeedsMe(ctx, a = {}) {
|
|
7789
|
+
const gh = await reader(ctx);
|
|
7790
|
+
const { config } = await ctx.config();
|
|
7791
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7792
|
+
const pulls = await gh.listPullRequests("open");
|
|
7793
|
+
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);
|
|
7794
|
+
const who = await people(gh, config);
|
|
7795
|
+
const same2 = (x, y) => Boolean(y && x.toLowerCase() === y.toLowerCase());
|
|
7796
|
+
const rows = await Promise.all(
|
|
7797
|
+
queue.map(async (p) => {
|
|
7798
|
+
const f = await branchFacts(ctx, gh, p.number, viewer).catch(() => null);
|
|
7799
|
+
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 };
|
|
7800
|
+
const mine = same2(p.author, viewer);
|
|
7801
|
+
const inDesign = viewer ? who.lists.design.some((x) => same2(x, viewer)) : false;
|
|
7802
|
+
const inTech = viewer ? who.lists.tech.some((x) => same2(x, viewer)) : false;
|
|
7803
|
+
let needsYou = null;
|
|
7804
|
+
if (viewer && !mine) {
|
|
7805
|
+
if (f.status === "needs-design" && inDesign) needsYou = "Your design approval";
|
|
7806
|
+
else if (f.status === "needs-tech" && inTech) needsYou = "Your tech approval";
|
|
7807
|
+
else if (f.status === "needs-approval" && !f.approvals.approvers.some((x) => same2(x, viewer))) needsYou = "Your approval";
|
|
7808
|
+
else if (f.status === "ready" && f.gate.canShip) needsYou = "Ready for you to merge";
|
|
7809
|
+
}
|
|
7810
|
+
if (viewer && mine) {
|
|
7811
|
+
if (f.status === "changes-requested") needsYou = "Changes requested on your branch";
|
|
7812
|
+
else if (f.status === "ci-failing") needsYou = "Tests fail on your branch";
|
|
7813
|
+
else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
|
|
7814
|
+
else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
|
|
7815
|
+
}
|
|
7816
|
+
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 };
|
|
7817
|
+
})
|
|
7818
|
+
);
|
|
7819
|
+
rows.sort((x, y) => Number(Boolean(y.needsYou)) - Number(Boolean(x.needsYou)) || REVIEW_STATUS_ORDER[x.status] - REVIEW_STATUS_ORDER[y.status]);
|
|
7820
|
+
const mineCount = rows.filter((r) => r.needsYou).length;
|
|
7821
|
+
return {
|
|
7822
|
+
viewer,
|
|
7823
|
+
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.`,
|
|
7824
|
+
branches: rows
|
|
7825
|
+
};
|
|
7826
|
+
}
|
|
7827
|
+
async function listProducts(ctx) {
|
|
7828
|
+
const { config } = await ctx.config();
|
|
7829
|
+
const products = productsOf(config);
|
|
7830
|
+
const here = await productHere(ctx, config);
|
|
7831
|
+
return {
|
|
7832
|
+
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) })),
|
|
7833
|
+
current: { key: here.product.key, name: here.product.name, how: here.how },
|
|
7834
|
+
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.'
|
|
7835
|
+
};
|
|
7836
|
+
}
|
|
7837
|
+
async function productHere(ctx, config) {
|
|
7838
|
+
const products = productsOf(config);
|
|
7839
|
+
if (ctx.mode !== "local" || !ctx.root) return productForPlace(products, {});
|
|
7840
|
+
const cwd = process.cwd();
|
|
7841
|
+
const rel = relative2(ctx.root, cwd);
|
|
7842
|
+
const repo = await originRepo(cwd).catch(() => void 0);
|
|
7843
|
+
const ds = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(ctx.root).catch(() => void 0);
|
|
7844
|
+
return productForPlace(products, { relPath: rel.startsWith("..") ? null : rel, repo: repo ?? null, designSystemRepo: ds ?? null });
|
|
7845
|
+
}
|
|
7846
|
+
async function resolveProduct(ctx, name) {
|
|
7847
|
+
const { config } = await ctx.config();
|
|
7848
|
+
const products = productsOf(config);
|
|
7849
|
+
if (name?.trim()) {
|
|
7850
|
+
const p = findProduct(products, name);
|
|
7851
|
+
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".`);
|
|
7852
|
+
return p;
|
|
7853
|
+
}
|
|
7854
|
+
return (await productHere(ctx, config)).product;
|
|
7855
|
+
}
|
|
7856
|
+
async function tryBranch(ctx, a) {
|
|
7857
|
+
const n = branchNumber(a.pr);
|
|
7858
|
+
const gh = await reader(ctx);
|
|
7859
|
+
const { config } = await ctx.config();
|
|
7860
|
+
const pr = await gh.getPullRequest(n);
|
|
7861
|
+
const [files, comments] = await Promise.all([gh.listPullRequestFiles(n), gh.listIssueComments(n).catch(() => [])]);
|
|
7862
|
+
const previews = await branchPreviewUrls(gh, n, config, { branch: pr.headRef, sha: pr.headSha, comments }).catch(() => []);
|
|
7863
|
+
const apps = config.previews?.apps ?? {};
|
|
7864
|
+
const products = productsOf(config).map((p) => {
|
|
7865
|
+
const app = Object.entries(apps).find(([, t]) => t && t === p.preview)?.[0];
|
|
7866
|
+
const hit = app ? previews.find((x) => x.app === app && x.url && x.source !== "template" && x.state !== "failed") : void 0;
|
|
7867
|
+
const reported = hit?.url ?? null;
|
|
7868
|
+
const pattern = !app && p.preview ? previewUrl(p.preview, { branch: pr.headRef, pr: n, sha: pr.headSha }) : null;
|
|
7869
|
+
const url = reported ? app === "admin" ? tryInProductUrl(reported, n, null) : reported : pattern;
|
|
7870
|
+
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" };
|
|
7871
|
+
});
|
|
7872
|
+
const lifecycle = lifecycleOf(pr.state, pr.draft);
|
|
7873
|
+
const packages = config.previews?.packages ?? [];
|
|
7874
|
+
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 });
|
|
7875
|
+
const tryUrl = products.find((p) => p.url && p.how === "reported by Vercel")?.url ?? null;
|
|
7876
|
+
const message = shareMessage({ title: pr.title, state: share, packages, tryUrl, url: pr.url });
|
|
7877
|
+
return {
|
|
7878
|
+
pr: n,
|
|
7879
|
+
title: pr.title,
|
|
7880
|
+
tryInProduct: products,
|
|
7881
|
+
previews: previews.map((p) => ({ app: p.app, label: p.label, url: p.url, state: p.state })),
|
|
7882
|
+
share: {
|
|
7883
|
+
state: share.kind,
|
|
7884
|
+
install: share.kind === "ready" ? share.command : null,
|
|
7885
|
+
sentence: stateSentence(share) ?? "Product teams can install this branch in their app before it is merged, and remove it any time.",
|
|
7886
|
+
...message ? { message } : {}
|
|
7887
|
+
}
|
|
7888
|
+
};
|
|
7889
|
+
}
|
|
7890
|
+
async function combineBranches(ctx, a) {
|
|
7891
|
+
const numbers = [...new Set(a.prs.map(branchNumber))];
|
|
7892
|
+
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}).`);
|
|
7893
|
+
const gh = await reader(ctx);
|
|
7894
|
+
const { config } = await ctx.config();
|
|
7895
|
+
const base = baseBranchOf(config);
|
|
7896
|
+
const root = config.tokens.root;
|
|
7897
|
+
const pulls = await Promise.all(numbers.map((n) => gh.getPullRequest(n)));
|
|
7898
|
+
const branches = await Promise.all(pulls.map((p) => readCombineBranch(gh, { number: p.number, title: p.title, headSha: p.headSha }, { base, tokensRoot: root })));
|
|
7899
|
+
const code2 = branches.filter((b) => !b.tokens);
|
|
7900
|
+
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.`);
|
|
7901
|
+
const latest = {};
|
|
7902
|
+
const files = (await gh.listFiles(base)).filter((f) => f.startsWith(`${root}/`) && f.endsWith(".tokens.json"));
|
|
7903
|
+
await Promise.all(files.map(async (f) => latest[f.slice(root.length + 1)] = await gh.readFile(f, base) ?? ""));
|
|
7904
|
+
const picks = a.picks ?? {};
|
|
7905
|
+
const combo = combineChanges(latest, branches.map((b) => b.edits), picks);
|
|
7906
|
+
const titles = new Map(pulls.map((p) => [p.number, p.title]));
|
|
7907
|
+
const admin = config.previews?.production?.admin;
|
|
7908
|
+
const docs = config.previews?.production?.docs;
|
|
7909
|
+
return {
|
|
7910
|
+
branches: pulls.map((p) => ({ pr: p.number, title: p.title })),
|
|
7911
|
+
tokens: combo.tokens,
|
|
7912
|
+
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 })),
|
|
7913
|
+
missingPicks: combo.missing.length,
|
|
7914
|
+
problems: combo.problems,
|
|
7915
|
+
open: {
|
|
7916
|
+
...admin ? { product: tryTogetherUrl(admin, numbers, picks) } : {},
|
|
7917
|
+
...docs ? { library: `${docs.replace(/\/$/, "")}/?change=${numbers.join(",")}` } : {}
|
|
7918
|
+
},
|
|
7919
|
+
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."
|
|
7920
|
+
};
|
|
7921
|
+
}
|
|
7922
|
+
async function sendForReview(ctx, a) {
|
|
7923
|
+
const n = branchNumber(a.pr);
|
|
7924
|
+
const gh = await writer(ctx, "Sending a branch for review", RIGHTS.draft);
|
|
7925
|
+
const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
|
|
7926
|
+
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.`);
|
|
7927
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: promote it with bridge_promote_exploration instead.`);
|
|
7928
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: reopen it first with bridge_reopen_branch.`);
|
|
7929
|
+
if (!pr.draft) return { pr: n, status: "already-in-review", said: "It is already in review. Reviewers can see it." };
|
|
7930
|
+
await gh.markReadyForReview(n);
|
|
7931
|
+
return { pr: n, status: "in-review", said: "Review requested. Reviewers can see it now.", url: pr.url };
|
|
7932
|
+
}
|
|
7933
|
+
async function backToDraft(ctx, a) {
|
|
7934
|
+
const n = branchNumber(a.pr);
|
|
7935
|
+
const gh = await writer(ctx, "Taking a branch back to draft", RIGHTS.draft);
|
|
7936
|
+
const [pr, viewer] = await Promise.all([gh.getPullRequest(n), gh.getViewer()]);
|
|
7937
|
+
if (pr.author.toLowerCase() !== viewer.login.toLowerCase()) throw new Error(`Only its author (@${pr.author}) takes #${n} back to draft, as in Studio.`);
|
|
7938
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: it is a draft until promoted.`);
|
|
7939
|
+
await gh.convertToDraft(n);
|
|
7940
|
+
return { pr: n, status: "draft", said: "Back to draft. Reviewers are not asked until you send it for review again." };
|
|
7941
|
+
}
|
|
7942
|
+
async function reviewBranch(ctx, a) {
|
|
7943
|
+
const n = branchNumber(a.pr);
|
|
7944
|
+
const gh = await writer(ctx, "Reviewing a branch", RIGHTS.review);
|
|
7945
|
+
const pr = await gh.getPullRequest(n);
|
|
7946
|
+
const event = { approve: "APPROVE", request_changes: "REQUEST_CHANGES", comment: "COMMENT" }[a.decision];
|
|
7947
|
+
const r = await reviewOnGitHub(gh, { number: n, event, body: a.body, headSha: pr.headSha });
|
|
7948
|
+
const said = { APPROVE: "Approved.", REQUEST_CHANGES: "Changes requested.", COMMENT: "Comment posted." }[event];
|
|
7949
|
+
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 };
|
|
7950
|
+
}
|
|
7951
|
+
async function askSomeoneElse(ctx, a) {
|
|
7952
|
+
const n = branchNumber(a.pr);
|
|
7953
|
+
const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Asking someone else to review", RIGHTS.review);
|
|
7954
|
+
const viewer = await viewerOf(ctx, gh);
|
|
7955
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7956
|
+
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.`);
|
|
7957
|
+
const { lists } = await people(gh, f.config);
|
|
7958
|
+
const others = reviewersToAsk({ tier: f.tier, approvals: f.approvals, owners: lists, author: f.pr.author, viewer });
|
|
7959
|
+
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.");
|
|
7960
|
+
if (a.dryRun) return { pr: n, status: "would-ask", asks: others.map((l) => `@${l}`), said: `Would ask ${others.map((l) => `@${l}`).join(", ")} to review.` };
|
|
7961
|
+
await askOthersToReviewOnGitHub(gh, { number: n, others, viewer, viewerRequested: Boolean(viewer && f.pr.requestedReviewers.some((r) => r.toLowerCase() === viewer.toLowerCase())) });
|
|
7962
|
+
return { pr: n, status: "asked", asked: others.map((l) => `@${l}`), said: `Asked ${others.map((l) => `@${l}`).join(", ")} to review.` };
|
|
7963
|
+
}
|
|
7964
|
+
async function commentOnBranch(ctx, a) {
|
|
7965
|
+
const n = branchNumber(a.pr);
|
|
7966
|
+
const text = a.body.trim();
|
|
7967
|
+
if (!text) throw new Error("Write the comment first.");
|
|
7968
|
+
const gh = await writer(ctx, "Commenting on a branch", RIGHTS.comment);
|
|
7969
|
+
if (a.replyTo) {
|
|
7970
|
+
const r2 = await gh.replyToReviewComment(n, a.replyTo, text);
|
|
7971
|
+
return { pr: n, replied: a.replyTo, url: r2.url, said: "Reply posted in the thread." };
|
|
7972
|
+
}
|
|
7973
|
+
const r = await commentOnGitHub(gh, n, text);
|
|
7974
|
+
return { pr: n, url: r.url, said: "Comment posted." };
|
|
7975
|
+
}
|
|
7976
|
+
async function listComments(ctx, a) {
|
|
7977
|
+
const n = branchNumber(a.pr);
|
|
7978
|
+
const gh = await reader(ctx);
|
|
7979
|
+
const [conversation, lines] = await Promise.all([gh.listIssueComments(n), gh.listReviewComments(n).catch(() => [])]);
|
|
7980
|
+
return {
|
|
7981
|
+
pr: n,
|
|
7982
|
+
conversation: conversation.filter((c) => !c.body.includes("<!-- bridge-branch-preview -->")).map((c) => ({ id: c.id, author: c.author, body: c.body, at: c.updatedAt })),
|
|
7983
|
+
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 })) })),
|
|
7984
|
+
howToReply: "bridge_comment with replyTo: the thread id."
|
|
7985
|
+
};
|
|
7986
|
+
}
|
|
7987
|
+
async function mergeBranch(ctx, a) {
|
|
7988
|
+
const n = branchNumber(a.pr);
|
|
7989
|
+
const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
|
|
7990
|
+
const viewer = (await gh.getViewer()).login;
|
|
7991
|
+
const f = await branchFacts(ctx, gh, n, viewer);
|
|
7992
|
+
const policy = f.config.governance.override ?? DEFAULT_OVERRIDE;
|
|
7993
|
+
const reason = a.overrideReason?.trim();
|
|
7994
|
+
if (f.write === false) throw new Error(`@${viewer} cannot write to ${gh.fullName}, so GitHub refuses the merge. Ask someone with write access.`);
|
|
7995
|
+
if (f.gate.allowed) {
|
|
7996
|
+
if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
|
|
7997
|
+
const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
|
|
7998
|
+
return { pr: n, status: "merged", sha: r.sha, said: "Merged. It goes out with the next library release." };
|
|
7999
|
+
}
|
|
8000
|
+
if (!reason) {
|
|
8001
|
+
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.` : "";
|
|
8002
|
+
throw new Error(`#${n} cannot be merged yet: ${f.gate.reasons.join(" ")}${hint}`);
|
|
8003
|
+
}
|
|
8004
|
+
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(" ")}`);
|
|
8005
|
+
if (!f.gate.overridable) throw new Error(`The owner override only skips missing approvals. #${n} is blocked by: ${f.gate.reasons.join(" ")}`);
|
|
8006
|
+
const problem = overrideReasonProblem(reason, policy);
|
|
8007
|
+
if (problem) throw new Error(problem);
|
|
8008
|
+
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." };
|
|
8009
|
+
const method = allowedMergeMethods(f.repo)[0] ?? "squash";
|
|
8010
|
+
try {
|
|
8011
|
+
const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
|
|
8012
|
+
return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.` };
|
|
8013
|
+
} catch (err) {
|
|
8014
|
+
if (err instanceof OverrideMergeError) throw new Error(err.message);
|
|
8015
|
+
throw err;
|
|
8016
|
+
}
|
|
8017
|
+
}
|
|
8018
|
+
async function closeBranch(ctx, a) {
|
|
8019
|
+
const n = branchNumber(a.pr);
|
|
8020
|
+
const gh = await writer(ctx, "Closing a branch", RIGHTS.review);
|
|
8021
|
+
const pr = await gh.getPullRequest(n);
|
|
8022
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: close it with bridge_close_exploration.`);
|
|
8023
|
+
if (pr.state !== "open") return { pr: n, status: pr.state, said: `It is already ${pr.state}.` };
|
|
8024
|
+
await closeOnGitHub(gh, n);
|
|
8025
|
+
return { pr: n, status: "closed", said: "Closed. It can be reopened while its work is still there." };
|
|
8026
|
+
}
|
|
8027
|
+
async function reopenBranch(ctx, a) {
|
|
8028
|
+
const n = branchNumber(a.pr);
|
|
8029
|
+
const gh = await writer(ctx, "Reopening a branch", RIGHTS.review);
|
|
8030
|
+
const { config } = await ctx.config();
|
|
8031
|
+
const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
|
|
8032
|
+
if (pr.state === "merged") throw new Error(`#${n} is merged: it cannot be reopened. Undo it with bridge_plan_revert and bridge_propose_revert.`);
|
|
8033
|
+
const exists = await gh.getBranchSha(pr.headRef).then((s) => s ? "exists" : "gone", () => "unknown");
|
|
8034
|
+
const owners = [...config.governance.owners.design, ...config.governance.owners.tech];
|
|
8035
|
+
const ok2 = reopenEligibility({ state: pr.state === "closed" ? "closed" : "open", viewer: viewer.login, author: pr.author, owners, canWrite: hasWriteAccess(repo?.permissions), branch: exists });
|
|
8036
|
+
if (pr.state === "open") return { pr: n, status: "open", said: "It is already open." };
|
|
8037
|
+
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).`);
|
|
8038
|
+
if (ok2.kind !== "reopen") throw new Error(`Only its author (@${pr.author}) or an owner with write access reopens #${n}, as in Studio.`);
|
|
8039
|
+
await reopenOnGitHub(gh, n);
|
|
8040
|
+
return { pr: n, status: "open", said: "Reopened. It is back in review with its earlier approvals and checks." };
|
|
8041
|
+
}
|
|
8042
|
+
async function updateFromMain(ctx, a) {
|
|
8043
|
+
const n = branchNumber(a.pr);
|
|
8044
|
+
const gh = a.dryRun ? await reader(ctx) : await writer(ctx, "Updating a branch from main", RIGHTS.update);
|
|
8045
|
+
const { config } = await ctx.config();
|
|
8046
|
+
const pr = await gh.getPullRequest(n);
|
|
8047
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch is updated from main.`);
|
|
8048
|
+
const compare = await gh.compareDetailed(pr.baseRef, pr.headSha);
|
|
8049
|
+
const fresh = freshness({ compare: { behind_by: compare.behindBy, merge_base_commit: compare.mergeBase ? { sha: compare.mergeBase } : null }, mergeable: pr.mergeable, open: true });
|
|
8050
|
+
if (fresh.state === "current") return { pr: n, status: "up-to-date", said: `It already has the latest ${pr.baseRef}.` };
|
|
8051
|
+
if (fresh.state === "behind") {
|
|
8052
|
+
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.` };
|
|
8053
|
+
await gh.updatePullRequestBranch(n, pr.headSha);
|
|
8054
|
+
return { pr: n, status: "updated", said: `Updated from ${pr.baseRef}. Checks run again; approvals may need renewing.` };
|
|
8055
|
+
}
|
|
8056
|
+
if (fresh.state === "unknown") throw new Error(`GitHub has not said yet whether #${n} merges cleanly. Try again in a minute.`);
|
|
8057
|
+
const data = await readLibraryConflicts(gh, compare, pr.headSha, { tokensRoot: config.tokens.root });
|
|
8058
|
+
if (data.route.kind === "code" || !data.tokens) {
|
|
8059
|
+
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}`);
|
|
8060
|
+
}
|
|
8061
|
+
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 }));
|
|
8062
|
+
const choices = a.choices ?? {};
|
|
8063
|
+
const missing = missingChoices(data.tokens.plan, choices);
|
|
8064
|
+
if (a.dryRun || missing.length) {
|
|
8065
|
+
return {
|
|
8066
|
+
pr: n,
|
|
8067
|
+
status: "token-conflicts",
|
|
8068
|
+
conflicts,
|
|
8069
|
+
missing: missing.map((c) => c.key),
|
|
8070
|
+
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.`
|
|
8071
|
+
};
|
|
8072
|
+
}
|
|
8073
|
+
const viewer = await gh.getViewer().then((v) => v.login, () => null);
|
|
8074
|
+
const sha = await commitLibraryResolution(gh, { head: { ref: pr.headRef, sha: pr.headSha }, base: { ref: pr.baseRef } }, data, choices, viewer);
|
|
8075
|
+
return { pr: n, status: "resolved", commit: sha, said: `Brought in the latest ${pr.baseRef} with your token choices. Checks run again.` };
|
|
8076
|
+
}
|
|
8077
|
+
async function addableBranch(ctx, n) {
|
|
8078
|
+
const gh = await writer(ctx, "Adding edits to a branch", RIGHTS.update);
|
|
8079
|
+
const [pr, viewer, repo] = await Promise.all([gh.getPullRequest(n), gh.getViewer(), gh.getMergeSettings().catch(() => null)]);
|
|
8080
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: only an open branch takes more edits. Start a new one instead.`);
|
|
8081
|
+
if (isActiveExploration(pr.labels)) throw new Error(`#${n} is an exploration: pass branch "${pr.headRef}" instead of pr.`);
|
|
8082
|
+
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}.`);
|
|
8083
|
+
return { gh, pr, viewer: viewer.login, comment: (paths) => addedComment(paths, viewer.login, "Claude through the Bridge MCP server") };
|
|
8084
|
+
}
|
|
8085
|
+
|
|
8086
|
+
// src/explorations.ts
|
|
6292
8087
|
var USE_CASES = new Set(EXPLORATION_USE_CASES.map((u) => u.value));
|
|
6293
8088
|
var settingsOf = (config) => ({ base: baseBranchOf(config), packages: config.previews?.packages ?? [], apps: config.previews?.apps ?? {} });
|
|
6294
8089
|
async function requireGitHub(ctx) {
|
|
@@ -6323,8 +8118,11 @@ async function startExploration(ctx, a) {
|
|
|
6323
8118
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6324
8119
|
};
|
|
6325
8120
|
const gh = await ctx.github().catch(() => null);
|
|
8121
|
+
const product = await resolveProduct(ctx, a.product);
|
|
8122
|
+
const marks = productMarks(productsOf(config), product);
|
|
6326
8123
|
const preview = {
|
|
6327
8124
|
branch,
|
|
8125
|
+
product: product.name,
|
|
6328
8126
|
slug: slug2,
|
|
6329
8127
|
base: s.base,
|
|
6330
8128
|
title: explorationPrTitle(e),
|
|
@@ -6335,7 +8133,7 @@ async function startExploration(ctx, a) {
|
|
|
6335
8133
|
if (a.dryRun) return { status: "dry-run", ...preview };
|
|
6336
8134
|
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.`;
|
|
6337
8135
|
if (gh && ctx.canWriteRemote) {
|
|
6338
|
-
const r = await startExplorationOnGitHub(gh, e, s);
|
|
8136
|
+
const r = await startExplorationOnGitHub(gh, e, { ...s, labels: marks.labels });
|
|
6339
8137
|
return {
|
|
6340
8138
|
status: "started",
|
|
6341
8139
|
...preview,
|
|
@@ -6797,6 +8595,10 @@ async function getGuidelines(ctx, a) {
|
|
|
6797
8595
|
}
|
|
6798
8596
|
async function proposeTokenChange(ctx, a) {
|
|
6799
8597
|
if (!a.edits?.length) throw new Error("Pass at least one edit.");
|
|
8598
|
+
if (a.pr !== void 0 && a.branch) throw new Error("Pass pr (a branch in review) or branch (an exploration), not both.");
|
|
8599
|
+
if (a.pr !== void 0) return addToBranch(ctx, { ...a, pr: a.pr });
|
|
8600
|
+
const jira = parseJiraKey(a.jira);
|
|
8601
|
+
const product = await resolveProduct(ctx, a.product);
|
|
6800
8602
|
const target = a.branch ? await explorationContext(ctx, a.branch, { requireCheckout: !a.dryRun }) : null;
|
|
6801
8603
|
if (target) ctx = target.ctx;
|
|
6802
8604
|
const { config } = await ctx.config();
|
|
@@ -6811,7 +8613,8 @@ async function proposeTokenChange(ctx, a) {
|
|
|
6811
8613
|
const imp = core.impact(before, after, changes);
|
|
6812
8614
|
const tier = core.classify(changes, [], config);
|
|
6813
8615
|
const summary2 = a.summary?.trim() || a.title;
|
|
6814
|
-
const
|
|
8616
|
+
const marks = productMarks(productsOf(config), product);
|
|
8617
|
+
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");
|
|
6815
8618
|
const slugTitle = core.slugify(a.title);
|
|
6816
8619
|
const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slugTitle}.md`;
|
|
6817
8620
|
const files = [
|
|
@@ -6831,7 +8634,7 @@ async function proposeTokenChange(ctx, a) {
|
|
|
6831
8634
|
if (newErrors.length || newFailures.length) {
|
|
6832
8635
|
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." };
|
|
6833
8636
|
}
|
|
6834
|
-
let branch = target ? target.branch : `${config.governance.branchPrefix}${slugTitle}
|
|
8637
|
+
let branch = target ? target.branch : branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}`, jira);
|
|
6835
8638
|
const preview = { ...report, branch, files: files.map((f) => f.path), prBody: body };
|
|
6836
8639
|
if (a.dryRun) return { status: "dry-run", ...preview, fileContents: files };
|
|
6837
8640
|
if (target) {
|
|
@@ -6845,7 +8648,7 @@ Exploration ${target.branch}: not merged as is. Proposed through Bridge MCP (sou
|
|
|
6845
8648
|
if (ctx.mode === "local") {
|
|
6846
8649
|
if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...preview, fileContents: files };
|
|
6847
8650
|
const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
|
|
6848
|
-
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}
|
|
8651
|
+
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
|
|
6849
8652
|
const base2 = await currentBranch(ctx.root);
|
|
6850
8653
|
const message = `${tier.bump === "major" ? "feat(tokens)!" : "feat(tokens)"}: ${a.title}
|
|
6851
8654
|
|
|
@@ -6870,12 +8673,46 @@ Tier ${tier.tier}. Proposed through Bridge MCP (source: mcp).`;
|
|
|
6870
8673
|
|
|
6871
8674
|
Proposed through Bridge MCP (source: mcp). Tier ${tier.tier}.`;
|
|
6872
8675
|
const base = ctx.ref ?? baseBranchOf(config);
|
|
6873
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${config.governance.branchPrefix}${slugTitle}-${i}
|
|
8676
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${config.governance.branchPrefix}${slugTitle}-${i}`, jira);
|
|
6874
8677
|
const commit = await gh.commitToNewBranch({ base, branch, message: commitMsg, files });
|
|
6875
|
-
const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : []];
|
|
6876
|
-
const pr = await gh.createPullRequest({ base, head: branch, title: `feat(tokens): ${a.title}`, body, draft: tier.tier >= 2 });
|
|
8678
|
+
const labels = [...config.governance.labels, `tier: ${tier.tier}`, ...tier.tier === 3 ? ["tokens"] : [], ...tier.bump === "major" ? ["breaking"] : [], ...marks.labels];
|
|
8679
|
+
const pr = await gh.createPullRequest({ base, head: branch, title: titleWithJiraKey(`feat(tokens): ${a.title}`, jira), body, draft: tier.tier >= 2 });
|
|
6877
8680
|
await gh.addLabels(pr.number, labels);
|
|
6878
|
-
return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2 };
|
|
8681
|
+
return { status: "pr-opened", ...preview, branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft: tier.tier >= 2, product: product.name };
|
|
8682
|
+
}
|
|
8683
|
+
async function addToBranch(ctx, a) {
|
|
8684
|
+
const n = branchNumber(a.pr);
|
|
8685
|
+
const target = await addableBranch(ctx, n);
|
|
8686
|
+
const at = await BridgeContext.create({ ...ctx.opts, repo: target.gh.fullName, ref: target.pr.headRef });
|
|
8687
|
+
const { config } = await at.config();
|
|
8688
|
+
const { before, after, touched, notes } = await withEdits(at, a.edits);
|
|
8689
|
+
const changes = core.diff(before, after);
|
|
8690
|
+
if (!changes.length) return { status: "no-change", pr: n, message: `#${n} already has these values.` };
|
|
8691
|
+
const prevErrors = new Set(core.validate(before).map(issueKey2));
|
|
8692
|
+
const newErrors = core.validate(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
|
|
8693
|
+
const pairs = await core.readContrastPairs(at.source, config).catch(() => []);
|
|
8694
|
+
const delta2 = pairs.length ? core.contrastDelta(core.runContrast(before, pairs, config.contrast?.modes), core.runContrast(after, pairs, config.contrast?.modes)) : [];
|
|
8695
|
+
const newFailures = delta2.filter((d) => d.passBefore && !d.passAfter);
|
|
8696
|
+
const tier = core.classify(changes, [], config);
|
|
8697
|
+
const summary2 = a.summary?.trim() || a.title;
|
|
8698
|
+
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 };
|
|
8699
|
+
if (newErrors.length || newFailures.length) {
|
|
8700
|
+
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." };
|
|
8701
|
+
}
|
|
8702
|
+
const paths = changes.map((c) => c.path);
|
|
8703
|
+
const files = [
|
|
8704
|
+
...touched.map((f) => ({ path: prefixPath(after, f), content: after.files.get(f) })),
|
|
8705
|
+
...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) }] : []
|
|
8706
|
+
];
|
|
8707
|
+
if (a.dryRun) return { status: "dry-run", ...report, files: files.map((f) => f.path) };
|
|
8708
|
+
const message = `tokens: ${a.title}
|
|
8709
|
+
|
|
8710
|
+
${paths.map((p) => `- ${p}`).join("\n")}
|
|
8711
|
+
|
|
8712
|
+
Added with Claude through the Bridge MCP server.`;
|
|
8713
|
+
const res = await target.gh.commitToBranch({ branch: target.pr.headRef, message, files });
|
|
8714
|
+
await target.gh.createIssueComment(n, target.comment(paths)).catch(() => void 0);
|
|
8715
|
+
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.` };
|
|
6879
8716
|
}
|
|
6880
8717
|
async function listReviews(ctx, a) {
|
|
6881
8718
|
const { config } = await ctx.config();
|
|
@@ -6971,7 +8808,7 @@ async function getReview(ctx, a) {
|
|
|
6971
8808
|
impact: imp
|
|
6972
8809
|
};
|
|
6973
8810
|
}
|
|
6974
|
-
var
|
|
8811
|
+
var sameLogin2 = (a, b) => a.trim().toLowerCase().replace(/^@/, "") === b.trim().toLowerCase().replace(/^@/, "");
|
|
6975
8812
|
async function reviewRoles(gh, config, approvers) {
|
|
6976
8813
|
const { owners, rights } = config.governance;
|
|
6977
8814
|
const teams = await new TeamDirectory(gh).resolve(owners);
|
|
@@ -6979,7 +8816,7 @@ async function reviewRoles(gh, config, approvers) {
|
|
|
6979
8816
|
const lists = expandOwners(entries, teams);
|
|
6980
8817
|
const role = (k) => ({
|
|
6981
8818
|
owners: entries[k].map((e) => entryLabel(e, teams)),
|
|
6982
|
-
approvedBy: approvers.filter((a) => lists[k].some((x) =>
|
|
8819
|
+
approvedBy: approvers.filter((a) => lists[k].some((x) => sameLogin2(x, a)))
|
|
6983
8820
|
});
|
|
6984
8821
|
const ship = shipPolicy(owners, rights, teams);
|
|
6985
8822
|
const unreadable = unreadableTeams(teams);
|
|
@@ -6996,10 +8833,11 @@ async function reviewPr(ctx, a) {
|
|
|
6996
8833
|
if (!ctx.canWriteRemote) throw new Error("Reviewing needs GITHUB_TOKEN. Without it Bridge is read-only.");
|
|
6997
8834
|
if (!a.body.trim()) throw new Error("A review needs a body that says what to fix or why it is fine.");
|
|
6998
8835
|
const event = { comment: "COMMENT", approve: "APPROVE", request_changes: "REQUEST_CHANGES" }[a.event];
|
|
6999
|
-
const
|
|
8836
|
+
const head = (await gh.getPullRequest(a.pr)).headSha;
|
|
8837
|
+
const r = await reviewOnGitHub(gh, { number: a.pr, event, body: `${a.body.trim()}
|
|
7000
8838
|
|
|
7001
|
-
_Review drafted with Bridge MCP by an AI agent on behalf of the token owner._
|
|
7002
|
-
return { pr: a.pr, event, id: r.id, state: r.state, note: event === "APPROVE" ? "Approval recorded under your GitHub account.
|
|
8839
|
+
_Review drafted with Bridge MCP by an AI agent on behalf of the token owner._`, headSha: head });
|
|
8840
|
+
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 };
|
|
7003
8841
|
}
|
|
7004
8842
|
async function scaffoldComponentOp(ctx, a) {
|
|
7005
8843
|
const { config } = await ctx.config();
|
|
@@ -7026,7 +8864,7 @@ async function visualImpact(ctx, a, run2) {
|
|
|
7026
8864
|
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.");
|
|
7027
8865
|
if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
|
|
7028
8866
|
const out = a.out ? resolve3(ctx.root, a.out) : join4(ctx.root, "visual-impact");
|
|
7029
|
-
const runImpact = run2 ?? (await import("./src-
|
|
8867
|
+
const runImpact = run2 ?? (await import("./src-DHJOM5PQ.js")).runImpact;
|
|
7030
8868
|
const r = await runImpact({
|
|
7031
8869
|
root: ctx.root,
|
|
7032
8870
|
base: a.base ?? "main",
|
|
@@ -7420,8 +9258,8 @@ async function proposeBrand(ctx, a) {
|
|
|
7420
9258
|
}
|
|
7421
9259
|
const files = plan.files.map((f) => ({ path: f.path, content: f.content }));
|
|
7422
9260
|
const prefix = config.governance.branchPrefix;
|
|
7423
|
-
const
|
|
7424
|
-
let branch =
|
|
9261
|
+
const stem2 = `${prefix}brand-${plan.slug}`;
|
|
9262
|
+
let branch = stem2;
|
|
7425
9263
|
if (a.dryRun) return { status: "dry-run", ...report, branch, fileContents: files };
|
|
7426
9264
|
const message = `feat(tokens): add the ${plan.name} brand
|
|
7427
9265
|
|
|
@@ -7430,7 +9268,7 @@ New brand "${plan.slug}", started from ${plan.from}. ${report.contrast.line} (${
|
|
|
7430
9268
|
Tier 3. Proposed through Bridge MCP (source: mcp).`;
|
|
7431
9269
|
if (ctx.mode === "local") {
|
|
7432
9270
|
if (!ctx.isGit) return { status: "preview", message: "Not a git repo: nothing written. Apply fileContents yourself.", ...report, branch, fileContents: files };
|
|
7433
|
-
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${
|
|
9271
|
+
for (let i = 2; await branchExists(ctx.root, branch); i++) branch = `${stem2}-${i}`;
|
|
7434
9272
|
const base2 = await currentBranch(ctx.root);
|
|
7435
9273
|
const protectedBranches = [.../* @__PURE__ */ new Set([...protectedBranchesOf(config), baseBranchOf(config)])];
|
|
7436
9274
|
const res = await commitToNewLocalBranch(ctx.root, { base: "HEAD", branch, message, files, protectedBranches, branchPrefix: prefix });
|
|
@@ -7446,7 +9284,7 @@ Tier 3. Proposed through Bridge MCP (source: mcp).`;
|
|
|
7446
9284
|
if (!ctx.canWriteRemote) return { status: "preview", message: "Read-only: set GITHUB_TOKEN to open this as a pull request.", ...report, branch, fileContents: files };
|
|
7447
9285
|
const gh = await ctx.github();
|
|
7448
9286
|
const base = ctx.ref ?? baseBranchOf(config);
|
|
7449
|
-
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${
|
|
9287
|
+
for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${stem2}-${i}`;
|
|
7450
9288
|
const commit = await gh.commitToNewBranch({ base, branch, message, files });
|
|
7451
9289
|
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 });
|
|
7452
9290
|
const labels = [.../* @__PURE__ */ new Set([...config.governance.labels, "tier: 3", "tokens"])];
|
|
@@ -7577,7 +9415,12 @@ async function createRequest(ctx, a) {
|
|
|
7577
9415
|
...a.exploration ? { exploration: toExplorationBranch(a.exploration) } : {}
|
|
7578
9416
|
};
|
|
7579
9417
|
const settings = config.integrations?.requests;
|
|
7580
|
-
const
|
|
9418
|
+
const product = await resolveProduct(ctx, a.product);
|
|
9419
|
+
const marks = productMarks(productsOf(config), product);
|
|
9420
|
+
const composed = composeRequestIssue(req, { labels: settings?.labels ?? [], assignees: settings?.assignees ?? [] });
|
|
9421
|
+
const issue = marks.labels.length ? { ...composed, body: `${composed.body}
|
|
9422
|
+
|
|
9423
|
+
${marks.line}`, labels: [...composed.labels, ...marks.labels] } : composed;
|
|
7581
9424
|
if (a.dryRun) return { status: "dry-run", issue };
|
|
7582
9425
|
const gh = await requireGitHub2(ctx);
|
|
7583
9426
|
if (!ctx.canWriteRemote) throw new Error("Creating a request needs GITHUB_TOKEN (issues: write on this repository). Without it Bridge is read-only.");
|
|
@@ -7655,8 +9498,142 @@ async function writeDrafts(ctx, a) {
|
|
|
7655
9498
|
};
|
|
7656
9499
|
}
|
|
7657
9500
|
|
|
9501
|
+
// src/studio.ts
|
|
9502
|
+
var studio_exports = {};
|
|
9503
|
+
__export(studio_exports, {
|
|
9504
|
+
figmaCoverage: () => figmaCoverage,
|
|
9505
|
+
getSettings: () => getSettings,
|
|
9506
|
+
saveDocs: () => saveDocs
|
|
9507
|
+
});
|
|
9508
|
+
var DOC_KEYS = ["summary", "preview", "whenToUse", "whenNotToUse", "dos", "donts", "accessibility", "contentGuidelines", "anatomy"];
|
|
9509
|
+
async function saveDocs(ctx, a) {
|
|
9510
|
+
const { config } = await ctx.config();
|
|
9511
|
+
if (!config.components) throw new Error("No components.dir in bridge.config.json: Bridge does not know where component docs live.");
|
|
9512
|
+
const name = a.component.trim();
|
|
9513
|
+
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).`);
|
|
9514
|
+
const unknown = Object.keys(a.docs).filter((k) => !DOC_KEYS.includes(k));
|
|
9515
|
+
if (unknown.length) throw new Error(`Unknown docs sections: ${unknown.join(", ")}. Use ${DOC_KEYS.join(", ")}.`);
|
|
9516
|
+
const path = `${config.components.dir}/${name}/${name}${config.components.docsSuffix}`;
|
|
9517
|
+
const jira = parseJiraKey(a.jira);
|
|
9518
|
+
const product = await resolveProduct(ctx, a.product);
|
|
9519
|
+
const next = (current) => {
|
|
9520
|
+
let base = {};
|
|
9521
|
+
if (current) {
|
|
9522
|
+
try {
|
|
9523
|
+
base = JSON.parse(current);
|
|
9524
|
+
} catch {
|
|
9525
|
+
throw new Error(`${path} is not valid JSON on that branch. Fix it there first.`);
|
|
9526
|
+
}
|
|
9527
|
+
}
|
|
9528
|
+
const merged = { summary: "", whenToUse: [], whenNotToUse: [], dos: [], donts: [], accessibility: [], ...base, ...a.docs };
|
|
9529
|
+
const issues = validateDocs(merged);
|
|
9530
|
+
if (issues.length) throw new Error(`The docs are not ready: ${issues.map((i) => `${i.field}: ${i.message}`).join(" ")}`);
|
|
9531
|
+
return docsFileText(cleanDocs(merged), current);
|
|
9532
|
+
};
|
|
9533
|
+
const gh = await ctx.github();
|
|
9534
|
+
const open = a.pr === void 0 && gh ? await findOpenDocsProposalOnGitHub(gh, { name, slug: docsSlug(name) }, ctx.ref ?? baseBranchOf(config)).catch(() => null) : null;
|
|
9535
|
+
const target = a.pr ?? open?.number;
|
|
9536
|
+
if (target !== void 0) {
|
|
9537
|
+
const n = branchNumber(target);
|
|
9538
|
+
if (!gh) throw new Error('No GitHub repo known: set "repo" in bridge.config.json or add an origin remote.');
|
|
9539
|
+
const pr = await gh.getPullRequest(n);
|
|
9540
|
+
if (pr.state !== "open") throw new Error(`#${n} is ${pr.state}: save the docs on a new branch instead (leave pr out).`);
|
|
9541
|
+
const current = (await gh.getFile(path, pr.headRef))?.text ?? null;
|
|
9542
|
+
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.`);
|
|
9543
|
+
const text2 = next(current);
|
|
9544
|
+
if (a.dryRun) return { status: "dry-run", pr: n, path, content: text2 };
|
|
9545
|
+
if (!ctx.canWriteRemote && open) return { status: "preview", pr: n, path, content: text2, said: `Nothing sent: the ${name} docs have an open proposal (#${n}) and adding to it needs a GitHub token with ${RIGHTS.docs}. Set GITHUB_TOKEN.` };
|
|
9546
|
+
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.`);
|
|
9547
|
+
const sha = await commitFileOnGitHub(gh.withBranchPrefix(void 0), { path, branch: pr.headRef, message: `docs(${name.toLowerCase()}): edit usage guidance with Claude (Bridge MCP)`, next });
|
|
9548
|
+
const joined = open ? ` It continues the open ${name} docs proposal by @${open.author} (one open proposal per component).` : "";
|
|
9549
|
+
return {
|
|
9550
|
+
status: sha ? "committed" : "unchanged",
|
|
9551
|
+
pr: n,
|
|
9552
|
+
path,
|
|
9553
|
+
commit: sha || null,
|
|
9554
|
+
...open ? { continued: true } : {},
|
|
9555
|
+
said: (sha ? `Saved the ${name} docs on #${n}. Reviewers see them now.` : "The docs on that branch already say this.") + joined
|
|
9556
|
+
};
|
|
9557
|
+
}
|
|
9558
|
+
const original = await ctx.source.read(path);
|
|
9559
|
+
const text = next(original);
|
|
9560
|
+
if (original === text) return { status: "unchanged", path, said: `The ${name} docs on main already say this.` };
|
|
9561
|
+
const edit = { path, component: name, slug: docsSlug(name), before: original, after: text };
|
|
9562
|
+
const marks = productMarks(productsOf(config), product);
|
|
9563
|
+
const notes = [a.notes?.trim(), marks.line].filter(Boolean).join("\n\n");
|
|
9564
|
+
if (a.dryRun || !gh || !ctx.canWriteRemote) {
|
|
9565
|
+
return {
|
|
9566
|
+
status: a.dryRun ? "dry-run" : "preview",
|
|
9567
|
+
path,
|
|
9568
|
+
content: text,
|
|
9569
|
+
product: product.name,
|
|
9570
|
+
...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.` }
|
|
9571
|
+
};
|
|
9572
|
+
}
|
|
9573
|
+
const r = await proposeDocsOnGitHub(gh.withBranchPrefix("docs/"), [edit], {
|
|
9574
|
+
base: ctx.ref ?? baseBranchOf(config),
|
|
9575
|
+
title: a.title,
|
|
9576
|
+
notes,
|
|
9577
|
+
jira,
|
|
9578
|
+
origin: DOCS_MCP_ORIGIN,
|
|
9579
|
+
extraLabels: marks.labels,
|
|
9580
|
+
commitNote: "Proposed with Claude through the Bridge MCP server."
|
|
9581
|
+
});
|
|
9582
|
+
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.` };
|
|
9583
|
+
}
|
|
9584
|
+
async function getSettings(ctx) {
|
|
9585
|
+
const { config } = await ctx.config();
|
|
9586
|
+
const { owners, rights } = config.governance;
|
|
9587
|
+
const override = config.governance.override ?? DEFAULT_OVERRIDE;
|
|
9588
|
+
const gh = await ctx.github();
|
|
9589
|
+
const teams = gh ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
|
|
9590
|
+
const entries = approverEntries(owners, rights);
|
|
9591
|
+
const ship = shipPolicy(owners, rights, teams);
|
|
9592
|
+
const protection = gh ? await loadProtection(gh, baseBranchOf(config)).catch(() => null) : null;
|
|
9593
|
+
const products = productsOf(config);
|
|
9594
|
+
const unreadable = teams ? unreadableTeams(teams) : [];
|
|
9595
|
+
return {
|
|
9596
|
+
repo: config.repo ? `${config.repo.owner}/${config.repo.name}` : gh?.fullName ?? null,
|
|
9597
|
+
main: baseBranchOf(config),
|
|
9598
|
+
roles: {
|
|
9599
|
+
designApproval: entries.design.map((e) => entryLabel(e, teams)),
|
|
9600
|
+
techApproval: entries.tech.map((e) => entryLabel(e, teams)),
|
|
9601
|
+
ship: ship ? whoCanShipSentence(ship) : "Anyone with write access can merge.",
|
|
9602
|
+
...unreadable.length ? { teamsNotRead: unreadable.map((t) => t.message) } : {}
|
|
9603
|
+
},
|
|
9604
|
+
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." },
|
|
9605
|
+
protection: protection ? protectionSentence(protection) : "Not read (no GitHub repo known, or GitHub did not answer).",
|
|
9606
|
+
products: products.map((p) => ({ key: p.key, name: p.name, default: p === defaultProduct(products) })),
|
|
9607
|
+
previews: Object.entries(config.previews?.apps ?? {}).filter(([, t]) => Boolean(t)).map(([app]) => app),
|
|
9608
|
+
visualImpact: config.visualImpact?.mode ?? "auto",
|
|
9609
|
+
note: "Settings change in bridge.config.json through a reviewed branch, or in Studio Settings. Tokens and secrets are never shown."
|
|
9610
|
+
};
|
|
9611
|
+
}
|
|
9612
|
+
async function figmaCoverage(ctx, a = {}) {
|
|
9613
|
+
const text = await ctx.source.read(DEFAULT_COMPONENT_SPECS_FILE);
|
|
9614
|
+
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.` };
|
|
9615
|
+
const file = parseComponentSpecs(text);
|
|
9616
|
+
const s = coverageSummary(file);
|
|
9617
|
+
const gh = await ctx.github();
|
|
9618
|
+
const manifest = gh ? await gh.readFile(FIGMA_REFERENCES_MANIFEST, FIGMA_REFERENCES_BRANCH).catch(() => void 0) : void 0;
|
|
9619
|
+
let references = null;
|
|
9620
|
+
try {
|
|
9621
|
+
const m = manifest ? JSON.parse(manifest) : null;
|
|
9622
|
+
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 };
|
|
9623
|
+
} catch {
|
|
9624
|
+
references = null;
|
|
9625
|
+
}
|
|
9626
|
+
const list = (file.coverage ?? []).filter((c) => !a.component || c.name.toLowerCase() === a.component.toLowerCase());
|
|
9627
|
+
if (a.component && !list.length) throw new Error(`${a.component} is not in the coverage list. bridge_list_components lists the components.`);
|
|
9628
|
+
return {
|
|
9629
|
+
summary: `${s.inFigma} of ${s.visual} components are in Figma${s.drafted ? ` (${s.drafted} drafted with Claude)` : ""}; ${s.layout} layout helpers use auto layout.`,
|
|
9630
|
+
components: list.map((c) => ({ name: c.name, inFigma: figmaCell(c)?.label ?? "Unknown", ...c.reason ? { why: c.reason } : {} })),
|
|
9631
|
+
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.`
|
|
9632
|
+
};
|
|
9633
|
+
}
|
|
9634
|
+
|
|
7658
9635
|
// src/server.ts
|
|
7659
|
-
var VERSION = true ? "0.
|
|
9636
|
+
var VERSION = true ? "0.4.1" : "dev";
|
|
7660
9637
|
var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
|
7661
9638
|
var fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
|
|
7662
9639
|
var run = (fn) => async (a) => {
|
|
@@ -7694,6 +9671,8 @@ function createServer(ctx) {
|
|
|
7694
9671
|
"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.",
|
|
7695
9672
|
'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.',
|
|
7696
9673
|
`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.`,
|
|
9674
|
+
"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.",
|
|
9675
|
+
'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.',
|
|
7697
9676
|
'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.'
|
|
7698
9677
|
].join(" ")
|
|
7699
9678
|
}
|
|
@@ -7828,7 +9807,10 @@ function createServer(ctx) {
|
|
|
7828
9807
|
summary: z.string().optional().describe("Why: the problem and the intent, for the reviewers. Plain sentences, no em dash."),
|
|
7829
9808
|
edits: z.array(EditArg).min(1),
|
|
7830
9809
|
dryRun: z.boolean().optional().describe("Return the diff, tier, files and PR body without writing."),
|
|
7831
|
-
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.")
|
|
9810
|
+
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."),
|
|
9811
|
+
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.'),
|
|
9812
|
+
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."),
|
|
9813
|
+
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.")
|
|
7832
9814
|
},
|
|
7833
9815
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
7834
9816
|
},
|
|
@@ -7896,7 +9878,7 @@ function createServer(ctx) {
|
|
|
7896
9878
|
"bridge_review_pr",
|
|
7897
9879
|
{
|
|
7898
9880
|
title: "Review pull request",
|
|
7899
|
-
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;
|
|
9881
|
+
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.",
|
|
7900
9882
|
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.") },
|
|
7901
9883
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }
|
|
7902
9884
|
},
|
|
@@ -8068,6 +10050,7 @@ function createServer(ctx) {
|
|
|
8068
10050
|
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."),
|
|
8069
10051
|
figmaUrl: z.string().url().optional().describe("Link to the design in Figma, when there is one."),
|
|
8070
10052
|
exploration: z.string().optional().describe("Exploration branch (explore/<slug>) to implement it on instead of a pull request to main. Adds the exploration label."),
|
|
10053
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products) it is for. Default: the product of the current folder or repo."),
|
|
8071
10054
|
dryRun: z.boolean().optional().describe("Return the issue title, body and labels without creating it.")
|
|
8072
10055
|
},
|
|
8073
10056
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
@@ -8147,6 +10130,7 @@ function createServer(ctx) {
|
|
|
8147
10130
|
purpose: z.string().min(1).describe("What the team wants to learn, one or two plain sentences. No em dash."),
|
|
8148
10131
|
useCase: z.enum(EXPLORATION_USE_CASES.map((u) => u.value)).optional().describe(EXPLORATION_USE_CASES.map((u) => `${u.value}: ${u.hint}`).join(" ")),
|
|
8149
10132
|
experimental: z.array(z.string()).optional().describe('What is experimental, one line each: "color.action.primary.bg", "Button: pill radius".'),
|
|
10133
|
+
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."),
|
|
8150
10134
|
dryRun: z.boolean().optional().describe("Return the branch, note and pull request body without writing.")
|
|
8151
10135
|
},
|
|
8152
10136
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
@@ -8163,6 +10147,233 @@ function createServer(ctx) {
|
|
|
8163
10147
|
},
|
|
8164
10148
|
run((a) => listExplorations(ctx, a))
|
|
8165
10149
|
);
|
|
10150
|
+
server.registerTool(
|
|
10151
|
+
"bridge_promote_exploration",
|
|
10152
|
+
{
|
|
10153
|
+
title: "Promote an exploration to review",
|
|
10154
|
+
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.",
|
|
10155
|
+
inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42)."), dryRun: z.boolean().optional() },
|
|
10156
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
10157
|
+
},
|
|
10158
|
+
run((a) => promoteExploration(ctx, { ref: a.exploration, dryRun: a.dryRun }))
|
|
10159
|
+
);
|
|
10160
|
+
server.registerTool(
|
|
10161
|
+
"bridge_close_exploration",
|
|
10162
|
+
{
|
|
10163
|
+
title: "Close an exploration",
|
|
10164
|
+
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.",
|
|
10165
|
+
inputSchema: { exploration: z.string().min(1).describe("Its name, explore/<name> branch or number (#42).") },
|
|
10166
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
10167
|
+
},
|
|
10168
|
+
run((a) => closeExploration(ctx, { ref: a.exploration }))
|
|
10169
|
+
);
|
|
10170
|
+
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.');
|
|
10171
|
+
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
10172
|
+
const READ = { readOnlyHint: true, openWorldHint: true };
|
|
10173
|
+
server.registerTool(
|
|
10174
|
+
"bridge_list_products",
|
|
10175
|
+
{
|
|
10176
|
+
title: "List products",
|
|
10177
|
+
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.',
|
|
10178
|
+
inputSchema: {},
|
|
10179
|
+
annotations: RO
|
|
10180
|
+
},
|
|
10181
|
+
run(async () => listProducts(ctx))
|
|
10182
|
+
);
|
|
10183
|
+
server.registerTool(
|
|
10184
|
+
"bridge_what_needs_me",
|
|
10185
|
+
{
|
|
10186
|
+
title: "What needs me",
|
|
10187
|
+
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.',
|
|
10188
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("Branches read (default 20, each costs a few GitHub reads).") },
|
|
10189
|
+
annotations: READ
|
|
10190
|
+
},
|
|
10191
|
+
run((a) => whatNeedsMe(ctx, a))
|
|
10192
|
+
);
|
|
10193
|
+
server.registerTool(
|
|
10194
|
+
"bridge_branch_status",
|
|
10195
|
+
{
|
|
10196
|
+
title: "Branch status",
|
|
10197
|
+
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.",
|
|
10198
|
+
inputSchema: { pr: Pr },
|
|
10199
|
+
annotations: READ
|
|
10200
|
+
},
|
|
10201
|
+
run((a) => branchStatus(ctx, a))
|
|
10202
|
+
);
|
|
10203
|
+
server.registerTool(
|
|
10204
|
+
"bridge_list_comments",
|
|
10205
|
+
{
|
|
10206
|
+
title: "List comments",
|
|
10207
|
+
description: "The conversation on a branch and its line comment threads, with the ids to reply to (bridge_comment with replyTo).",
|
|
10208
|
+
inputSchema: { pr: Pr },
|
|
10209
|
+
annotations: READ
|
|
10210
|
+
},
|
|
10211
|
+
run((a) => listComments(ctx, a))
|
|
10212
|
+
);
|
|
10213
|
+
server.registerTool(
|
|
10214
|
+
"bridge_try_branch",
|
|
10215
|
+
{
|
|
10216
|
+
title: "Try it in a product, share it",
|
|
10217
|
+
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.',
|
|
10218
|
+
inputSchema: { pr: Pr },
|
|
10219
|
+
annotations: READ
|
|
10220
|
+
},
|
|
10221
|
+
run((a) => tryBranch(ctx, a))
|
|
10222
|
+
);
|
|
10223
|
+
server.registerTool(
|
|
10224
|
+
"bridge_combine_branches",
|
|
10225
|
+
{
|
|
10226
|
+
title: "Try 2 to 4 branches together",
|
|
10227
|
+
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.`,
|
|
10228
|
+
inputSchema: {
|
|
10229
|
+
prs: z.array(Pr).min(2).max(4).describe("2 to 4 branch numbers."),
|
|
10230
|
+
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}.')
|
|
10231
|
+
},
|
|
10232
|
+
annotations: READ
|
|
10233
|
+
},
|
|
10234
|
+
run((a) => combineBranches(ctx, a))
|
|
10235
|
+
);
|
|
10236
|
+
server.registerTool(
|
|
10237
|
+
"bridge_get_settings",
|
|
10238
|
+
{
|
|
10239
|
+
title: "Get settings",
|
|
10240
|
+
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.",
|
|
10241
|
+
inputSchema: {},
|
|
10242
|
+
annotations: READ
|
|
10243
|
+
},
|
|
10244
|
+
run(async () => getSettings(ctx))
|
|
10245
|
+
);
|
|
10246
|
+
server.registerTool(
|
|
10247
|
+
"bridge_figma_coverage",
|
|
10248
|
+
{
|
|
10249
|
+
title: "Figma coverage",
|
|
10250
|
+
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).",
|
|
10251
|
+
inputSchema: { component: z.string().optional().describe("One component, e.g. Button.") },
|
|
10252
|
+
annotations: READ
|
|
10253
|
+
},
|
|
10254
|
+
run((a) => figmaCoverage(ctx, a))
|
|
10255
|
+
);
|
|
10256
|
+
server.registerTool(
|
|
10257
|
+
"bridge_send_for_review",
|
|
10258
|
+
{
|
|
10259
|
+
title: "Send for review",
|
|
10260
|
+
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).',
|
|
10261
|
+
inputSchema: { pr: Pr },
|
|
10262
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10263
|
+
},
|
|
10264
|
+
run((a) => sendForReview(ctx, a))
|
|
10265
|
+
);
|
|
10266
|
+
server.registerTool(
|
|
10267
|
+
"bridge_back_to_draft",
|
|
10268
|
+
{
|
|
10269
|
+
title: "Back to draft",
|
|
10270
|
+
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).",
|
|
10271
|
+
inputSchema: { pr: Pr },
|
|
10272
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10273
|
+
},
|
|
10274
|
+
run((a) => backToDraft(ctx, a))
|
|
10275
|
+
);
|
|
10276
|
+
server.registerTool(
|
|
10277
|
+
"bridge_review_branch",
|
|
10278
|
+
{
|
|
10279
|
+
title: "Review a branch",
|
|
10280
|
+
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).",
|
|
10281
|
+
inputSchema: {
|
|
10282
|
+
pr: Pr,
|
|
10283
|
+
decision: z.enum(["approve", "request_changes", "comment"]),
|
|
10284
|
+
body: z.string().optional().describe("What to change, or why it is fine. Plain sentences, no em dash.")
|
|
10285
|
+
},
|
|
10286
|
+
annotations: WRITE
|
|
10287
|
+
},
|
|
10288
|
+
run((a) => reviewBranch(ctx, a))
|
|
10289
|
+
);
|
|
10290
|
+
server.registerTool(
|
|
10291
|
+
"bridge_ask_someone_else",
|
|
10292
|
+
{
|
|
10293
|
+
title: "Ask someone else to review",
|
|
10294
|
+
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).',
|
|
10295
|
+
inputSchema: { pr: Pr, dryRun: z.boolean().optional() },
|
|
10296
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10297
|
+
},
|
|
10298
|
+
run((a) => askSomeoneElse(ctx, a))
|
|
10299
|
+
);
|
|
10300
|
+
server.registerTool(
|
|
10301
|
+
"bridge_comment",
|
|
10302
|
+
{
|
|
10303
|
+
title: "Comment on a branch",
|
|
10304
|
+
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).",
|
|
10305
|
+
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.") },
|
|
10306
|
+
annotations: WRITE
|
|
10307
|
+
},
|
|
10308
|
+
run((a) => commentOnBranch(ctx, a))
|
|
10309
|
+
);
|
|
10310
|
+
server.registerTool(
|
|
10311
|
+
"bridge_merge_branch",
|
|
10312
|
+
{
|
|
10313
|
+
title: "Merge a branch",
|
|
10314
|
+
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.",
|
|
10315
|
+
inputSchema: {
|
|
10316
|
+
pr: Pr,
|
|
10317
|
+
overrideReason: z.string().optional().describe("Owner override only: why it merges without the missing approvals (at least the configured length). Posted on the branch."),
|
|
10318
|
+
dryRun: z.boolean().optional().describe("Check the gate without merging.")
|
|
10319
|
+
},
|
|
10320
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
|
|
10321
|
+
},
|
|
10322
|
+
run((a) => mergeBranch(ctx, a))
|
|
10323
|
+
);
|
|
10324
|
+
server.registerTool(
|
|
10325
|
+
"bridge_close_branch",
|
|
10326
|
+
{
|
|
10327
|
+
title: "Close a branch",
|
|
10328
|
+
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).",
|
|
10329
|
+
inputSchema: { pr: Pr },
|
|
10330
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
10331
|
+
},
|
|
10332
|
+
run((a) => closeBranch(ctx, a))
|
|
10333
|
+
);
|
|
10334
|
+
server.registerTool(
|
|
10335
|
+
"bridge_reopen_branch",
|
|
10336
|
+
{
|
|
10337
|
+
title: "Reopen a branch",
|
|
10338
|
+
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).",
|
|
10339
|
+
inputSchema: { pr: Pr },
|
|
10340
|
+
annotations: { ...WRITE, idempotentHint: true }
|
|
10341
|
+
},
|
|
10342
|
+
run((a) => reopenBranch(ctx, a))
|
|
10343
|
+
);
|
|
10344
|
+
server.registerTool(
|
|
10345
|
+
"bridge_update_from_main",
|
|
10346
|
+
{
|
|
10347
|
+
title: "Update from main",
|
|
10348
|
+
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).`,
|
|
10349
|
+
inputSchema: {
|
|
10350
|
+
pr: Pr,
|
|
10351
|
+
choices: z.record(z.string(), z.enum(["mine", "theirs"])).optional().describe('Per conflict key from a dry run: "mine" or "theirs".'),
|
|
10352
|
+
dryRun: z.boolean().optional()
|
|
10353
|
+
},
|
|
10354
|
+
annotations: WRITE
|
|
10355
|
+
},
|
|
10356
|
+
run((a) => updateFromMain(ctx, a))
|
|
10357
|
+
);
|
|
10358
|
+
server.registerTool(
|
|
10359
|
+
"bridge_save_docs",
|
|
10360
|
+
{
|
|
10361
|
+
title: "Save component docs",
|
|
10362
|
+
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.`,
|
|
10363
|
+
inputSchema: {
|
|
10364
|
+
component: z.string().min(1).describe("Component name, e.g. Button."),
|
|
10365
|
+
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"}.'),
|
|
10366
|
+
pr: Pr.optional().describe("An open branch to save the docs on, instead of a new one."),
|
|
10367
|
+
title: z.string().optional().describe('Title of the new branch. Default: "docs(<component>): usage guidance for <Component>".'),
|
|
10368
|
+
notes: z.string().optional().describe("Why, for the reviewers."),
|
|
10369
|
+
jira: z.string().optional().describe("Jira key like DS-42: first in the branch name and title so Jira links them."),
|
|
10370
|
+
product: z.string().optional().describe("Product key or name (bridge_list_products). Default: the product of the current folder or repo."),
|
|
10371
|
+
dryRun: z.boolean().optional()
|
|
10372
|
+
},
|
|
10373
|
+
annotations: WRITE
|
|
10374
|
+
},
|
|
10375
|
+
run((a) => saveDocs(ctx, a))
|
|
10376
|
+
);
|
|
8166
10377
|
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) => {
|
|
8167
10378
|
const set = await ctx.tokens();
|
|
8168
10379
|
const def = set.defaultCombo;
|
|
@@ -8195,8 +10406,8 @@ function createServer(ctx) {
|
|
|
8195
10406
|
new ResourceTemplate("bridge://components/{name}", {
|
|
8196
10407
|
list: async () => {
|
|
8197
10408
|
const { config } = await ctx.config();
|
|
8198
|
-
const
|
|
8199
|
-
return { resources:
|
|
10409
|
+
const names2 = await listComponentNames(ctx.source, config);
|
|
10410
|
+
return { resources: names2.map((n) => ({ uri: `bridge://components/${n.name}`, name: n.name, mimeType: "application/json" })) };
|
|
8200
10411
|
},
|
|
8201
10412
|
complete: {
|
|
8202
10413
|
name: async (value) => {
|
|
@@ -8376,12 +10587,14 @@ export {
|
|
|
8376
10587
|
GitHubSource,
|
|
8377
10588
|
BridgeContext,
|
|
8378
10589
|
auditSnippet,
|
|
10590
|
+
branches_exports,
|
|
8379
10591
|
explorations_exports,
|
|
8380
10592
|
ops_exports,
|
|
8381
10593
|
history_exports,
|
|
8382
10594
|
brand_exports,
|
|
8383
10595
|
requests_exports,
|
|
8384
10596
|
drafts_exports,
|
|
10597
|
+
studio_exports,
|
|
8385
10598
|
VERSION,
|
|
8386
10599
|
createServer,
|
|
8387
10600
|
serveStdio,
|