@bridge-ds/mcp 0.7.2 → 0.8.2

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.
@@ -60,6 +60,7 @@ export declare function branchFacts(ctx: BridgeContext, gh: GitHubClient, n: num
60
60
  push?: boolean;
61
61
  };
62
62
  } | null;
63
+ solo: string | null;
63
64
  }>;
64
65
  /** One branch, the way Studio shows it: status, what blocks the merge and who acts, how it stands against main. */
65
66
  export declare function branchStatus(ctx: BridgeContext, a: {
@@ -80,6 +81,7 @@ export declare function branchStatus(ctx: BridgeContext, a: {
80
81
  };
81
82
  approvalNeeded: string;
82
83
  approvals: {
84
+ soloOwner?: string | undefined;
83
85
  approvedBy: string[];
84
86
  changesRequestedBy: string[];
85
87
  design: boolean;
@@ -424,6 +424,19 @@ function approverEntries(owners, rights) {
424
424
  if (!rights) return { design: [...owners.design], tech: [...owners.tech] };
425
425
  return { design: holdersOf("approveDesign", owners, rights), tech: holdersOf("approveTech", owners, rights) };
426
426
  }
427
+ function soloApprover(owners) {
428
+ const d = [...new Set(owners.design.map(norm2))];
429
+ const t = [...new Set(owners.tech.map(norm2))];
430
+ if (d.length !== 1 || t.length !== 1 || d[0] !== t[0] || !d[0] || d[0].includes("/")) return null;
431
+ return d[0];
432
+ }
433
+ function soloOwnerOf(gov, teams) {
434
+ if (!gov.soloOwnerCanApproveBoth) return null;
435
+ return soloApprover(approverLists(gov.owners, gov.rights ?? null, teams));
436
+ }
437
+ function soloOwnerSentence(login, you = false) {
438
+ return you ? "As the only owner, your approval covers design and code." : `As the only owner, @${norm2(login)} approves design and code in one review.`;
439
+ }
427
440
  function shipPolicy(owners, rights, teams) {
428
441
  if (!rights) return null;
429
442
  const entries = holdersOf("ship", owners, rights);
@@ -583,7 +596,7 @@ var DEFAULT_GOVERNANCE = {
583
596
  owners: { design: [], tech: [] }
584
597
  };
585
598
  var FLAT_TOKEN_KEYS = ["primitives", "brands", "semantic", "base", "density", "components", "contrastChecks"];
586
- var CANONICAL_TOKEN_KEYS = ["root", "include", "exclude", "tiers", "layers"];
599
+ var CANONICAL_TOKEN_KEYS = ["root", "include", "exclude", "tiers", "layers", "usage"];
587
600
  var FLAT_MODE_KEYS = ["brands", "themes", "densities", "defaults"];
588
601
  var AXIS_OF = { brands: "brand", themes: "theme", densities: "density" };
589
602
  var ConfigError = class extends Error {
@@ -671,7 +684,7 @@ function defineConfig(input) {
671
684
  const contrastFile = contrastPath ?? [root === "." ? "" : root, paths.contrastChecks].filter(Boolean).join("/");
672
685
  return {
673
686
  repo: parseRepoInput(input.repo, input.branch),
674
- tokens: { ...paths, include: input.tokens?.include ?? ["**/*.tokens.json"], exclude: input.tokens?.exclude ?? [], tiers, layers },
687
+ tokens: { ...paths, include: input.tokens?.include ?? ["**/*.tokens.json"], exclude: input.tokens?.exclude ?? [], tiers, layers, ...t.usage ? { usage: trimSlashes(t.usage) } : {} },
675
688
  axes,
676
689
  modes,
677
690
  contrast: { path: contrastFile, modes: input.contrast?.modes },
@@ -700,11 +713,13 @@ function defineConfig(input) {
700
713
  minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
701
714
  }
702
715
  } : {},
703
- ...g.rights ? { rights: normalizeRights(g.rights) } : {}
716
+ ...g.rights ? { rights: normalizeRights(g.rights) } : {},
717
+ ...g.soloOwnerCanApproveBoth === true ? { soloOwnerCanApproveBoth: true } : {}
704
718
  },
705
719
  products: input.products?.length ? input.products.map((p) => ({ ...p })) : DEFAULT_PRODUCTS.map((p) => ({ ...p })),
706
720
  changes: input.changes === "direct" ? "direct" : DEFAULT_CHANGES_MODE,
707
721
  ...input.storybook?.url ? { storybook: { url: input.storybook.url.trim().replace(/\/+$/, "") } } : {},
722
+ ...input.library?.public === true ? { library: { public: true } } : {},
708
723
  ...input.audit ? { audit: { ...input.audit } } : {},
709
724
  ...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
710
725
  ...input.integrations ? { integrations: { ...input.integrations } } : {},
@@ -749,7 +764,7 @@ function loadConfig(source) {
749
764
  if (!isObj(tokens)) problems.push('"tokens" must be an object');
750
765
  else
751
766
  for (const [k, v] of Object.entries(tokens)) {
752
- if (FLAT_TOKEN_KEYS.includes(k) || k === "root") {
767
+ if (FLAT_TOKEN_KEYS.includes(k) || k === "root" || k === "usage") {
753
768
  if (!isStr(v)) problems.push(`"tokens.${k}" must be a non-empty string`);
754
769
  } else if (k === "include" || k === "exclude") {
755
770
  if (!isStrList(v)) problems.push(`"tokens.${k}" must be a list of globs`);
@@ -824,6 +839,7 @@ function loadConfig(source) {
824
839
  }
825
840
  }
826
841
  problems.push(...rightsProblems(gov.rights));
842
+ if (gov.soloOwnerCanApproveBoth !== void 0 && typeof gov.soloOwnerCanApproveBoth !== "boolean") problems.push('"governance.soloOwnerCanApproveBoth" must be true or false');
827
843
  }
828
844
  }
829
845
  problems.push(...visualImpactProblems(d.visualImpact));
@@ -834,6 +850,14 @@ function loadConfig(source) {
834
850
  else if (!/^https:\/\/[^\s/]+/.test(sb.url.trim())) problems.push('"storybook.url" must start with https://, for example https://storybook.example.com');
835
851
  else for (const k of Object.keys(sb)) if (k !== "url") problems.push(`"storybook.${k}" is not a known key`);
836
852
  }
853
+ const lib = d.library;
854
+ if (lib !== void 0) {
855
+ if (!isObj(lib)) problems.push('"library" must be an object, for example { "public": true }');
856
+ else {
857
+ if (lib.public !== void 0 && typeof lib.public !== "boolean") problems.push('"library.public" must be true or false');
858
+ for (const k of Object.keys(lib)) if (k !== "public") problems.push(`"library.${k}" is not a known key`);
859
+ }
860
+ }
837
861
  problems.push(...productProblems(d.products));
838
862
  const audit = d.audit;
839
863
  if (audit !== void 0) {
@@ -872,7 +896,7 @@ function toConfigJson(config, schema = "./packages/core/bridge.config.schema.jso
872
896
  $schema: schema,
873
897
  version: 1,
874
898
  ...config.repo ? { repo: config.repo } : {},
875
- tokens: { root: config.tokens.root, include: config.tokens.include, ...config.tokens.exclude.length ? { exclude: config.tokens.exclude } : {}, tiers: config.tokens.tiers, layers: config.tokens.layers.map((l) => l.optional ? l : { files: l.files }) },
899
+ tokens: { root: config.tokens.root, include: config.tokens.include, ...config.tokens.exclude.length ? { exclude: config.tokens.exclude } : {}, tiers: config.tokens.tiers, layers: config.tokens.layers.map((l) => l.optional ? l : { files: l.files }), ...config.tokens.usage ? { usage: config.tokens.usage } : {} },
876
900
  modes: Object.fromEntries(Object.entries(config.axes).map(([k, a]) => [k, a.parity ? a : { values: a.values, default: a.default }])),
877
901
  ...config.contrast ? { contrast: config.contrast.modes ? config.contrast : { path: config.contrast.path } } : {},
878
902
  ...config.components ? { components: Object.fromEntries(Object.entries(config.components).filter(([, v]) => v !== void 0)) } : {},
@@ -885,6 +909,7 @@ function toConfigJson(config, schema = "./packages/core/bridge.config.schema.jso
885
909
  ...config.visualImpact ? { visualImpact: config.visualImpact } : {},
886
910
  changes: changesMode(config),
887
911
  ...config.storybook ? { storybook: config.storybook } : {},
912
+ ...config.library ? { library: config.library } : {},
888
913
  ...config.products && !sameProducts(config.products, DEFAULT_PRODUCTS) ? { products: config.products } : {}
889
914
  };
890
915
  }
@@ -2010,6 +2035,9 @@ export {
2010
2035
  DEFAULT_RIGHTS,
2011
2036
  approverLists,
2012
2037
  approverEntries,
2038
+ soloApprover,
2039
+ soloOwnerOf,
2040
+ soloOwnerSentence,
2013
2041
  shipPolicy,
2014
2042
  unreadableShipTeams,
2015
2043
  canShip,
@@ -68,6 +68,9 @@ import {
68
68
  selectTokenFiles,
69
69
  shipPolicy,
70
70
  showFile,
71
+ soloApprover,
72
+ soloOwnerOf,
73
+ soloOwnerSentence,
71
74
  threeWayDiff,
72
75
  toConfigJson,
73
76
  tokensPath,
@@ -76,7 +79,7 @@ import {
76
79
  validateLayered,
77
80
  variantKeyOf,
78
81
  whoCanShipSentence
79
- } from "./chunk-BZ3KXXMM.js";
82
+ } from "./chunk-DPK46GA2.js";
80
83
 
81
84
  // src/lib.ts
82
85
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1260,6 +1263,44 @@ function classifyAuth(status, detail, opts = {}) {
1260
1263
  }
1261
1264
  return {};
1262
1265
  }
1266
+ function memoryEtagStore(max = 500) {
1267
+ const map = /* @__PURE__ */ new Map();
1268
+ return {
1269
+ get(key) {
1270
+ const e = map.get(key);
1271
+ if (e) {
1272
+ map.delete(key);
1273
+ map.set(key, e);
1274
+ }
1275
+ return e;
1276
+ },
1277
+ set(key, entry) {
1278
+ map.delete(key);
1279
+ map.set(key, entry);
1280
+ while (map.size > max) map.delete(map.keys().next().value);
1281
+ },
1282
+ clear: () => map.clear()
1283
+ };
1284
+ }
1285
+ var sharedEtags = memoryEtagStore();
1286
+ function tokenFingerprint(token) {
1287
+ if (!token) return "anon";
1288
+ let a = 2166136261;
1289
+ let b = 16777619 ^ token.length;
1290
+ for (let i = 0; i < token.length; i++) {
1291
+ const c = token.charCodeAt(i);
1292
+ a = Math.imul(a ^ c, 16777619) >>> 0;
1293
+ b = Math.imul(b ^ c ^ i & 255, 1540483477) >>> 0;
1294
+ }
1295
+ return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
1296
+ }
1297
+ function installationFor(list, owner) {
1298
+ const inst = list.find((i) => i.account?.login?.toLowerCase() === owner.toLowerCase());
1299
+ if (!inst) return { reach: "no" };
1300
+ const selection = inst.repositorySelection ?? inst.repository_selection;
1301
+ return selection === "all" ? { reach: "yes", id: inst.id } : { reach: "check", id: inst.id };
1302
+ }
1303
+ var MAX_PAGES = 10;
1263
1304
  function rateLimitMessage(x) {
1264
1305
  const when = x.resetsAt ? ` It resets at ${x.resetsAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}.` : "";
1265
1306
  if (x.withToken) return `GitHub's hourly request limit for this token is used up.${when} Try again after that.`;
@@ -1267,8 +1308,11 @@ function rateLimitMessage(x) {
1267
1308
  return `GitHub's hourly limit for requests without a token (60 an hour) is used up.${when} To keep going now, ${hint}: a token gets 5,000 requests an hour.`;
1268
1309
  }
1269
1310
  var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 600, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
1311
+ var hourlyLimitErrors = /* @__PURE__ */ new WeakSet();
1270
1312
  var isTransient = (status, detail) => status === 0 || status >= 500 || status === 429 || status === 403 && /secondary rate limit/i.test(detail);
1271
1313
  var API = "https://api.github.com";
1314
+ var ACCEPT_JSON = "application/vnd.github+json";
1315
+ var ACCEPT_RAW = "application/vnd.github.raw";
1272
1316
  var RAW = "https://raw.githubusercontent.com";
1273
1317
  function parseRepo(repo) {
1274
1318
  const m = /^\s*(?:https?:\/\/github\.com\/|git@github\.com:)?([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?\s*$/.exec(repo);
@@ -1287,6 +1331,7 @@ function base64ToUtf8(b64) {
1287
1331
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1288
1332
  return new TextDecoder("utf-8").decode(bytes);
1289
1333
  }
1334
+ var copy = (value) => value === null || typeof value !== "object" ? value : typeof structuredClone === "function" ? structuredClone(value) : JSON.parse(JSON.stringify(value));
1290
1335
  var encodeRef = (ref) => ref.split("/").map(encodeURIComponent).join("/");
1291
1336
  var GitHubClient = class _GitHubClient {
1292
1337
  constructor(repo, token, fetchImpl = (url, init) => fetch(url, init), retry = DEFAULT_RETRY, options = {}) {
@@ -1299,6 +1344,8 @@ var GitHubClient = class _GitHubClient {
1299
1344
  this.name = r.name;
1300
1345
  this.api = (options.apiUrl ?? API).replace(/\/$/, "");
1301
1346
  this.protectedBranches = options.protectedBranches ?? DEFAULT_PROTECTED_BRANCHES;
1347
+ this.etags = options.etags === false ? void 0 : options.etags ?? sharedEtags;
1348
+ this.etagScope = options.etagScope ?? tokenFingerprint(token);
1302
1349
  }
1303
1350
  token;
1304
1351
  fetchImpl;
@@ -1309,6 +1356,15 @@ var GitHubClient = class _GitHubClient {
1309
1356
  blobCache = /* @__PURE__ */ new Map();
1310
1357
  api;
1311
1358
  protectedBranches;
1359
+ etags;
1360
+ etagScope;
1361
+ rate;
1362
+ /** What the conditional request cache did for this client. */
1363
+ etagStats = { conditional: 0, notModified: 0, stale: 0 };
1364
+ /** GitHub's rate-limit headers from the last API response, or undefined before any response carried them. */
1365
+ get rateLimit() {
1366
+ return this.rate;
1367
+ }
1312
1368
  /** "owner/name". */
1313
1369
  get fullName() {
1314
1370
  return `${this.owner}/${this.name}`;
@@ -1332,45 +1388,77 @@ var GitHubClient = class _GitHubClient {
1332
1388
  get repoPath() {
1333
1389
  return `/repos/${this.owner}/${this.name}`;
1334
1390
  }
1335
- async request(method, path, body, retryable = method === "GET") {
1391
+ async request(method, path, body, retryable = method === "GET", opts = {}) {
1336
1392
  const attempts = retryable ? Math.max(1, this.retry.attempts) : 1;
1337
1393
  for (let attempt = 1; ; attempt++) {
1338
1394
  try {
1339
- return await this.once(method, path, body);
1395
+ return await this.once(method, path, body, opts);
1340
1396
  } catch (err) {
1341
1397
  const e = err;
1342
- if (attempt >= attempts || !(e instanceof GitHubError) || !isTransient(e.status, e.detail ?? "")) throw err;
1398
+ if (attempt >= attempts || !(e instanceof GitHubError) || hourlyLimitErrors.has(e) || !isTransient(e.status, e.detail ?? "")) throw err;
1343
1399
  await this.retry.sleep(this.retry.baseDelayMs * 2 ** (attempt - 1));
1344
1400
  }
1345
1401
  }
1346
1402
  }
1347
- async once(method, path, body) {
1403
+ noteRateLimit(headers) {
1404
+ const remaining = headers?.get("x-ratelimit-remaining");
1405
+ if (remaining == null) return;
1406
+ const limit = headers?.get("x-ratelimit-limit");
1407
+ const reset = headers?.get("x-ratelimit-reset");
1408
+ this.rate = {
1409
+ limit: limit != null ? Number(limit) : void 0,
1410
+ remaining: Number(remaining),
1411
+ resetsAt: reset ? new Date(Number(reset) * 1e3) : void 0,
1412
+ resource: headers?.get("x-ratelimit-resource") ?? void 0
1413
+ };
1414
+ }
1415
+ async once(method, path, body, opts = {}) {
1416
+ const accept = opts.accept ?? ACCEPT_JSON;
1348
1417
  const headers = {
1349
- Accept: "application/vnd.github+json",
1418
+ Accept: accept,
1350
1419
  "X-GitHub-Api-Version": "2022-11-28"
1351
1420
  };
1352
1421
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
1353
1422
  if (body !== void 0) headers["Content-Type"] = "application/json";
1423
+ const key = method === "GET" && this.etags && opts.etag !== false ? `${this.etagScope} ${accept} ${this.api}${path}` : void 0;
1424
+ const cached = key ? this.etags.get(key) : void 0;
1425
+ if (cached && this.token) {
1426
+ headers["If-None-Match"] = cached.etag;
1427
+ this.etagStats.conditional++;
1428
+ }
1354
1429
  let res;
1355
1430
  try {
1356
1431
  res = await this.fetchImpl(`${this.api}${path}`, { method, headers, ...body !== void 0 ? { body: JSON.stringify(body) } : {} });
1357
1432
  } catch (err) {
1433
+ if (err instanceof GitHubError) throw err;
1358
1434
  throw new GitHubError(0, "Could not reach GitHub. Check your connection, then try again.", err.message);
1359
1435
  }
1436
+ this.noteRateLimit(res.headers);
1437
+ if (res.status === 304 && cached && key && this.token) {
1438
+ this.etagStats.notModified++;
1439
+ this.etags.set(key, { ...cached, at: Date.now() });
1440
+ return copy(cached.body);
1441
+ }
1360
1442
  if (res.status === 204) return void 0;
1361
1443
  if (!res.ok) {
1362
1444
  let detail = "";
1363
1445
  try {
1364
- const data = await res.json();
1365
- detail = [data.message, ...(data.errors ?? []).map((e) => typeof e === "string" ? e : e.message)].filter(Boolean).join(" ");
1446
+ const data2 = await res.json();
1447
+ detail = [data2.message, ...(data2.errors ?? []).map((e) => typeof e === "string" ? e : e.message)].filter(Boolean).join(" ");
1366
1448
  } catch {
1367
1449
  detail = "";
1368
1450
  }
1369
1451
  const remaining = res.headers?.get("x-ratelimit-remaining");
1370
1452
  const reset = res.headers?.get("x-ratelimit-reset");
1371
1453
  if ((res.status === 403 || res.status === 429) && remaining === "0") {
1454
+ if (cached && this.options.serveStaleOnRateLimit) {
1455
+ this.etagStats.stale++;
1456
+ return copy(cached.body);
1457
+ }
1372
1458
  const resetsAt = reset ? new Date(Number(reset) * 1e3) : void 0;
1373
- throw new GitHubError(429, rateLimitMessage({ withToken: Boolean(this.token), resetsAt, tokenHint: this.options.tokenHint }), detail);
1459
+ const limited = new GitHubError(429, rateLimitMessage({ withToken: Boolean(this.token), resetsAt, tokenHint: this.options.tokenHint }), detail);
1460
+ hourlyLimitErrors.add(limited);
1461
+ throw limited;
1374
1462
  }
1375
1463
  const info = classifyAuth(res.status, detail, {
1376
1464
  path,
@@ -1379,7 +1467,24 @@ var GitHubClient = class _GitHubClient {
1379
1467
  });
1380
1468
  throw new GitHubError(res.status, friendlyMessage(res.status, detail, info), detail, info);
1381
1469
  }
1382
- return await res.json();
1470
+ const data = opts.text ? await res.text() : await res.json();
1471
+ const etag = key ? res.headers?.get("etag") : null;
1472
+ if (key && etag) this.etags.set(key, { etag, body: copy(data), at: Date.now() });
1473
+ return data;
1474
+ }
1475
+ /**
1476
+ * Every item of a paged listing (`per_page=100`), at most 10 pages. `pick` takes the items out of one page (the
1477
+ * installation endpoints wrap them in an object with `total_count`).
1478
+ */
1479
+ async paged(path, pick) {
1480
+ const out = [];
1481
+ const sep2 = path.includes("?") ? "&" : "?";
1482
+ for (let page = 1; page <= MAX_PAGES; page++) {
1483
+ const { items, total } = pick(await this.request("GET", `${path}${sep2}per_page=100&page=${page}`));
1484
+ out.push(...items);
1485
+ if (items.length < 100 || total !== void 0 && out.length >= total) break;
1486
+ }
1487
+ return out;
1383
1488
  }
1384
1489
  async getRepo() {
1385
1490
  const r = await this.request(
@@ -1762,6 +1867,14 @@ var GitHubClient = class _GitHubClient {
1762
1867
  */
1763
1868
  async readFile(path, ref) {
1764
1869
  if (this.token) return this.getFileText(path, ref);
1870
+ return this.readRawFile(path, ref);
1871
+ }
1872
+ /**
1873
+ * Text of a file from the raw host (raw.githubusercontent.com), sent without any header so the browser makes a
1874
+ * simple CORS request (no preflight) and nothing counts against the API limit. Works for public repositories only:
1875
+ * a private file answers 404 there, like a missing one. Undefined on 404.
1876
+ */
1877
+ async readRawFile(path, ref) {
1765
1878
  const url = `${(this.options.rawUrl ?? RAW).replace(/\/$/, "")}/${this.owner}/${this.name}/${encodeRef(ref)}/${encodeRef(path)}`;
1766
1879
  let res;
1767
1880
  try {
@@ -1773,6 +1886,76 @@ var GitHubClient = class _GitHubClient {
1773
1886
  if (!res.ok) throw new GitHubError(res.status, friendlyMessage(res.status, ""), `raw ${path}@${ref}`);
1774
1887
  return res.text();
1775
1888
  }
1889
+ /**
1890
+ * Text of a file at a ref through the contents API with the raw media type: the way to read a private file (the raw
1891
+ * host cannot take the token without a preflight). Counts against the API limit; conditional when cached.
1892
+ * Undefined when the file does not exist.
1893
+ */
1894
+ async getRawText(path, ref) {
1895
+ try {
1896
+ return await this.request("GET", `${this.repoPath}/contents/${encodeRef(path)}?ref=${encodeURIComponent(ref)}`, void 0, true, { accept: ACCEPT_RAW, text: true });
1897
+ } catch (err) {
1898
+ if (err instanceof GitHubError && err.status === 404) return void 0;
1899
+ throw err;
1900
+ }
1901
+ }
1902
+ /**
1903
+ * Text of a blob by its sha through the git blobs API with the raw media type: how a signed-in reader loads a file
1904
+ * listed by a tree (the raw host refuses any request that carries a token). A blob sha names its content, so the
1905
+ * answer is cached for the client's life and never revalidated. Undefined on 404.
1906
+ */
1907
+ async getBlobRaw(sha) {
1908
+ const cached = this.blobCache.get(sha);
1909
+ if (cached !== void 0) return cached;
1910
+ try {
1911
+ const text = await this.request("GET", `${this.repoPath}/git/blobs/${encodeURIComponent(sha)}`, void 0, true, { accept: ACCEPT_RAW, text: true, etag: false });
1912
+ this.blobCache.set(sha, text);
1913
+ return text;
1914
+ } catch (err) {
1915
+ if (err instanceof GitHubError && err.status === 404) return void 0;
1916
+ throw err;
1917
+ }
1918
+ }
1919
+ // --- GitHub App installations (user-to-server tokens) ---------------------------------------
1920
+ /**
1921
+ * Installations of the signing-in GitHub App that the signed-in user can reach (GET /user/installations, every
1922
+ * page). Needs a user-to-server token of that same App: a personal or OAuth token is refused (403), and each App
1923
+ * sees only its own installations. GitHub lists only installations on accounts and repositories the user has
1924
+ * explicit permission on: a public repository where the user is not a collaborator is never listed, installed or
1925
+ * not. (GET /repos/{o}/{r}/installation would say, but it needs the App's private key, which Bridge never holds.)
1926
+ */
1927
+ async listUserInstallations() {
1928
+ this.assertToken("Reading where Bridge is installed");
1929
+ const list = await this.paged("/user/installations", (p) => ({ items: p.installations ?? [], total: p.total_count }));
1930
+ return list.map((i) => ({
1931
+ id: i.id,
1932
+ account: i.account?.login ? { login: i.account.login, type: i.account.type ?? "User" } : null,
1933
+ repositorySelection: i.repository_selection ?? "selected",
1934
+ appSlug: i.app_slug
1935
+ }));
1936
+ }
1937
+ /** Repositories of one installation the signed-in user can reach (GET /user/installations/{id}/repositories, every page). */
1938
+ async listInstallationRepos(installationId) {
1939
+ this.assertToken("Reading where Bridge is installed");
1940
+ const list = await this.paged(`/user/installations/${encodeURIComponent(String(installationId))}/repositories`, (p) => ({
1941
+ items: p.repositories ?? [],
1942
+ total: p.total_count
1943
+ }));
1944
+ return list.map((r) => ({ owner: r.owner?.login ?? r.full_name.split("/")[0], name: r.name, fullName: r.full_name, private: Boolean(r.private) }));
1945
+ }
1946
+ /**
1947
+ * The App installation that gives the signed-in user this repository (this client's by default): its id, or null
1948
+ * when the App is not installed there or the user cannot reach it. One call when the installation covers all of
1949
+ * the owner's repositories, two (paged) when it covers selected ones.
1950
+ */
1951
+ async findInstallation(owner = this.owner, repo = this.name) {
1952
+ const found = installationFor(await this.listUserInstallations(), owner);
1953
+ if (found.reach === "no") return null;
1954
+ if (found.reach === "yes") return { id: found.id, selection: "all" };
1955
+ const repos = await this.listInstallationRepos(found.id);
1956
+ const hit = repos.some((r) => r.owner.toLowerCase() === owner.toLowerCase() && r.name.toLowerCase() === repo.toLowerCase());
1957
+ return hit ? { id: found.id, selection: "selected" } : null;
1958
+ }
1776
1959
  /** The repository's default branch. */
1777
1960
  async getDefaultBranch() {
1778
1961
  return (await this.getRepo()).defaultBranch;
@@ -4246,7 +4429,7 @@ function summarizeCi(runs, statuses = []) {
4246
4429
  const state = total === 0 ? "none" : failed.length ? "fail" : pending.length ? "pending" : "pass";
4247
4430
  return { state, total, passed, failed, pending };
4248
4431
  }
4249
- function summarizeApprovals(reviews, owners, author) {
4432
+ function summarizeApprovals(reviews, owners, author, rules = {}) {
4250
4433
  const latest = /* @__PURE__ */ new Map();
4251
4434
  const sorted = [...reviews].sort((a2, b) => String(a2.submitted_at ?? "").localeCompare(String(b.submitted_at ?? "")));
4252
4435
  for (const r of sorted) {
@@ -4264,7 +4447,8 @@ function summarizeApprovals(reviews, owners, author) {
4264
4447
  let tech;
4265
4448
  if (d.length || t.length) {
4266
4449
  design = d.length ? a.some((x) => d.includes(x)) : a.length > 0;
4267
- const soloBoth = (x) => a.length === 1 && d.includes(x);
4450
+ const solo = rules.soloOwnerCanApproveBoth ? soloApprover(owners) : null;
4451
+ const soloBoth = (x) => a.length === 1 && d.includes(x) && x !== solo;
4268
4452
  tech = t.length ? a.some((x) => t.includes(x) && !soloBoth(x)) : a.length > 1;
4269
4453
  } else {
4270
4454
  design = a.length >= 1;
@@ -4735,7 +4919,7 @@ function summarizeSuggestionApprovals(comments, gov, opts) {
4735
4919
  latestOld.delete(c.author);
4736
4920
  reviews.push({ user: { login: c.author }, state: r.event === "approve" ? "APPROVED" : "CHANGES_REQUESTED", submitted_at: at });
4737
4921
  });
4738
- const summary2 = summarizeApprovals(reviews, lists, opts.author ?? void 0);
4922
+ const summary2 = summarizeApprovals(reviews, lists, opts.author ?? void 0, { soloOwnerCanApproveBoth: gov.soloOwnerCanApproveBoth });
4739
4923
  const stale = [...latestOld.keys()].filter((l) => !summary2.approvers.includes(l) && !summary2.changesRequested.includes(l));
4740
4924
  return { ...summary2, stale, ignored: [...ignored].filter((l) => !summary2.approvers.includes(l)) };
4741
4925
  }
@@ -6251,6 +6435,7 @@ function shareMessage(x) {
6251
6435
  // ../core/src/try-in-product.ts
6252
6436
  function tryInProductUrl(preview, pr, screen) {
6253
6437
  const url = new URL(preview);
6438
+ if (url.protocol !== "https:" && url.protocol !== "http:") return "";
6254
6439
  url.pathname = "/";
6255
6440
  url.search = "";
6256
6441
  url.searchParams.set("bridge", String(pr));
@@ -6829,7 +7014,7 @@ async function planSuggestionApply(input) {
6829
7014
  for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
6830
7015
  if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6831
7016
  }
6832
- const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null };
7017
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
6833
7018
  const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6834
7019
  const review = suggestionReviewState(payload.tier.tier, approvals);
6835
7020
  const missing = pendingApprovals(payload.tier.tier, approvals);
@@ -10200,7 +10385,7 @@ async function people(gh, config) {
10200
10385
  const { owners, rights } = config.governance;
10201
10386
  const teams = await new TeamDirectory(gh).resolve(owners).catch(() => void 0);
10202
10387
  const lists = expandOwners(approverEntries(owners, rights), teams);
10203
- return { lists, ship: shipPolicy(owners, rights, teams) };
10388
+ return { lists, ship: shipPolicy(owners, rights, teams), solo: config.governance.soloOwnerCanApproveBoth ? soloApprover(lists) : null };
10204
10389
  }
10205
10390
  async function viewerOf(ctx, gh) {
10206
10391
  if (!ctx.canWriteRemote) return null;
@@ -10226,7 +10411,8 @@ async function branchFacts(ctx, gh, n, viewer) {
10226
10411
  const approvals = summarizeApprovals(
10227
10412
  reviews.map((r) => ({ user: r.user ? { login: r.user } : null, state: r.state, submitted_at: r.submittedAt ?? null })),
10228
10413
  who.lists,
10229
- pr.author
10414
+ pr.author,
10415
+ { soloOwnerCanApproveBoth: Boolean(who.solo) }
10230
10416
  );
10231
10417
  const tier = effectiveTier(labels, pr.body, files);
10232
10418
  const merged = pr.state === "merged";
@@ -10236,7 +10422,7 @@ async function branchFacts(ctx, gh, n, viewer) {
10236
10422
  const status = reviewStatusKind({ ...pullLike, ci, approvals, tier, mergeable: pr.mergeable });
10237
10423
  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" });
10238
10424
  const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
10239
- return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo };
10425
+ return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo, solo: who.solo };
10240
10426
  }
10241
10427
  var STATUS_WORDS = {
10242
10428
  merged: "Merged",
@@ -10267,7 +10453,7 @@ async function branchStatus(ctx, a) {
10267
10453
  lifecycle: f.lifecycle,
10268
10454
  status: { kind: f.status, label: STATUS_WORDS[f.status] },
10269
10455
  approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
10270
- approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech },
10456
+ approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech, ...f.solo && (f.tier ?? 3) > 1 ? { soloOwner: soloOwnerSentence(f.solo, Boolean(viewer && viewer.toLowerCase() === f.solo)) } : {} },
10271
10457
  tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
10272
10458
  main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
10273
10459
  merge: {
@@ -10878,7 +11064,7 @@ async function suggestionGovernance(ctx, gh) {
10878
11064
  const { owners, rights } = config.governance;
10879
11065
  const hasTeams = [...owners.design, ...owners.tech].some((o) => o.includes("/"));
10880
11066
  const teams = hasTeams ? await new TeamDirectory(gh).resolve(owners).catch(() => void 0) : void 0;
10881
- return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {} } };
11067
+ return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {}, ...config.governance.soloOwnerCanApproveBoth ? { soloOwnerCanApproveBoth: true } : {} } };
10882
11068
  }
10883
11069
  var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
10884
11070
  async function listSuggestionsOp(ctx, a = {}) {
@@ -10925,6 +11111,7 @@ async function getSuggestionOp(ctx, a) {
10925
11111
  const p = d.parsed.payload;
10926
11112
  const approvals = d.approvals;
10927
11113
  const missing = pendingApprovals(p.tier.tier, approvals);
11114
+ const solo = soloOwnerOf(gov, gov.teams);
10928
11115
  return {
10929
11116
  ...base,
10930
11117
  kind: p.kind,
@@ -10952,7 +11139,8 @@ async function getSuggestionOp(ctx, a) {
10952
11139
  missing: missing.map(roleText),
10953
11140
  changesRequestedBy: approvals.changesRequested,
10954
11141
  ...approvals.stale.length ? { staleReviews: approvals.stale } : {},
10955
- ...approvals.ignored.length ? { notCounted: approvals.ignored } : {}
11142
+ ...approvals.ignored.length ? { notCounted: approvals.ignored } : {},
11143
+ ...solo && p.tier.tier > 1 ? { soloOwner: soloOwnerSentence(solo) } : {}
10956
11144
  },
10957
11145
  applied: d.applied,
10958
11146
  ...p.componentSpec ? { brief: p.componentSpec.brief } : {},
@@ -11701,9 +11889,11 @@ async function reviewRoles(gh, config, approvers) {
11701
11889
  });
11702
11890
  const ship = shipPolicy(owners, rights, teams);
11703
11891
  const unreadable = unreadableTeams(teams);
11892
+ const solo = soloOwnerOf(config.governance, teams);
11704
11893
  return {
11705
11894
  design: role("design"),
11706
11895
  tech: role("tech"),
11896
+ ...solo ? { soloOwner: soloOwnerSentence(solo) } : {},
11707
11897
  ship: ship ? whoCanShipSentence(ship) : "Anyone with write access can ship.",
11708
11898
  ...unreadable.length ? { unreadableTeams: unreadable.map((t) => t.message) } : {}
11709
11899
  };
@@ -11745,7 +11935,7 @@ async function visualImpact(ctx, a, run2) {
11745
11935
  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.");
11746
11936
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
11747
11937
  const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11748
- const runImpact = run2 ?? (await import("./src-D6YUOJC7.js")).runImpact;
11938
+ const runImpact = run2 ?? (await import("./src-UNWS2OVM.js")).runImpact;
11749
11939
  const r = await runImpact({
11750
11940
  root: ctx.root,
11751
11941
  base: a.base ?? "main",
@@ -12525,6 +12715,7 @@ async function getSettings(ctx) {
12525
12715
  const protection = gh ? await loadProtection(gh, baseBranchOf(config)).catch(() => null) : null;
12526
12716
  const products = productsOf(config);
12527
12717
  const unreadable = teams ? unreadableTeams(teams) : [];
12718
+ const solo = soloOwnerOf(config.governance, teams);
12528
12719
  return {
12529
12720
  repo: config.repo ? `${config.repo.owner}/${config.repo.name}` : gh?.fullName ?? null,
12530
12721
  main: baseBranchOf(config),
@@ -12532,6 +12723,7 @@ async function getSettings(ctx) {
12532
12723
  designApproval: entries.design.map((e) => entryLabel(e, teams)),
12533
12724
  techApproval: entries.tech.map((e) => entryLabel(e, teams)),
12534
12725
  ship: ship ? whoCanShipSentence(ship) : "Anyone with write access can merge.",
12726
+ ...solo ? { soloOwner: soloOwnerSentence(solo) } : {},
12535
12727
  ...unreadable.length ? { teamsNotRead: unreadable.map((t) => t.message) } : {}
12536
12728
  },
12537
12729
  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." },
@@ -12811,7 +13003,7 @@ async function pushFigmaComponentOp(ctx, a) {
12811
13003
  }
12812
13004
 
12813
13005
  // src/server.ts
12814
- var VERSION = true ? "0.7.2" : "dev";
13006
+ var VERSION = true ? "0.8.2" : "dev";
12815
13007
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
12816
13008
  var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
12817
13009
  var run = (fn) => async (a) => {
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  VERSION,
4
4
  optionsFromArgv,
5
5
  serveStdio
6
- } from "./chunk-FAB4HMPJ.js";
7
- import "./chunk-BZ3KXXMM.js";
6
+ } from "./chunk-QCPYJXK4.js";
7
+ import "./chunk-DPK46GA2.js";
8
8
 
9
9
  // src/index.ts
10
10
  var argv = process.argv.slice(2);
package/dist/lib.js CHANGED
@@ -28,12 +28,12 @@ import {
28
28
  serveStdio,
29
29
  studio_exports,
30
30
  suggestions_exports
31
- } from "./chunk-FAB4HMPJ.js";
31
+ } from "./chunk-QCPYJXK4.js";
32
32
  import {
33
33
  CONFIG_FILE,
34
34
  loadConfig,
35
35
  toConfigJson
36
- } from "./chunk-BZ3KXXMM.js";
36
+ } from "./chunk-DPK46GA2.js";
37
37
  export {
38
38
  BridgeContext,
39
39
  CONFIG_FILE,
package/dist/ops.d.ts CHANGED
@@ -736,6 +736,8 @@ export declare function getReview(ctx: BridgeContext, a: {
736
736
  */
737
737
  export declare function reviewRoles(gh: GitHubClient, config: BridgeConfig, approvers: string[]): Promise<{
738
738
  unreadableTeams?: string[] | undefined;
739
+ ship: string;
740
+ soloOwner?: string | undefined;
739
741
  design: {
740
742
  owners: string[];
741
743
  approvedBy: string[];
@@ -744,7 +746,6 @@ export declare function reviewRoles(gh: GitHubClient, config: BridgeConfig, appr
744
746
  owners: string[];
745
747
  approvedBy: string[];
746
748
  };
747
- ship: string;
748
749
  }>;
749
750
  export declare function reviewPr(ctx: BridgeContext, a: {
750
751
  pr: number;
@@ -11,7 +11,7 @@ import {
11
11
  referenceImagePath,
12
12
  selectTokenFiles,
13
13
  showFile
14
- } from "./chunk-BZ3KXXMM.js";
14
+ } from "./chunk-DPK46GA2.js";
15
15
 
16
16
  // ../visual/src/run.ts
17
17
  import { existsSync as existsSync3 } from "node:fs";
package/dist/studio.d.ts CHANGED
@@ -94,6 +94,7 @@ export declare function getSettings(ctx: BridgeContext): Promise<{
94
94
  main: string;
95
95
  roles: {
96
96
  teamsNotRead?: string[] | undefined;
97
+ soloOwner?: string | undefined;
97
98
  designApproval: string[];
98
99
  techApproval: string[];
99
100
  ship: string;
@@ -137,6 +137,7 @@ export declare function getSuggestionOp(ctx: BridgeContext, a: {
137
137
  next: string;
138
138
  brief?: string | undefined;
139
139
  approvals: {
140
+ soloOwner?: string | undefined;
140
141
  notCounted?: string[] | undefined;
141
142
  staleReviews?: string[] | undefined;
142
143
  approvedBy: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge-ds/mcp",
3
- "version": "0.7.2",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "description": "Bridge MCP server: lets Claude (Claude Code, Claude Desktop or any MCP client) read, check and propose changes to a DTCG design system repo through pull requests.",
6
6
  "license": "MIT",
@@ -54,8 +54,8 @@
54
54
  "build:package": "node ../../scripts/build-packages.mjs mcp"
55
55
  },
56
56
  "dependencies": {
57
- "@bridge-ds/core": "^0.12.0",
58
- "@bridge-ds/visual": "^0.3.6",
57
+ "@bridge-ds/core": "^0.15.0",
58
+ "@bridge-ds/visual": "^0.3.9",
59
59
  "@modelcontextprotocol/sdk": "1.31.0",
60
60
  "typescript": "5.9.3",
61
61
  "zod": "3.25.76"