@cldmv/gh-broker 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
package/src/config.ts DELETED
@@ -1,130 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/config.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { readFileSync } from "node:fs";
17
- import type { Config, Mode, ToolRule } from "./types.ts";
18
-
19
- const MODES: readonly Mode[] = ["read", "write"];
20
-
21
- /** Load and validate the JSON config. Throws with a precise message on any problem. */
22
- export function loadConfig(path: string): Config {
23
- let raw: unknown;
24
- try {
25
- raw = JSON.parse(readFileSync(path, "utf8"));
26
- } catch (err) {
27
- throw new Error(`config: cannot read/parse ${path}: ${(err as Error).message}`);
28
- }
29
- if (typeof raw !== "object" || raw === null) throw new Error("config: root must be an object");
30
- const c = raw as Record<string, unknown>;
31
-
32
- if (typeof c.listen !== "string" || !c.listen.startsWith("unix:")) {
33
- throw new Error('config.listen: must be a "unix:<path>" string');
34
- }
35
- if (typeof c.upstream !== "string" || !/^https?:\/\//.test(c.upstream)) {
36
- throw new Error("config.upstream: must be an http(s) URL");
37
- }
38
- if (!Array.isArray(c.tools) || c.tools.length === 0) {
39
- throw new Error("config.tools: must be a non-empty array");
40
- }
41
-
42
- const seen = new Set<string>();
43
- const tools: ToolRule[] = c.tools.map((t, i) => {
44
- const o = t as Record<string, unknown>;
45
- if (typeof o.name !== "string" || !o.name) throw new Error(`config.tools[${i}].name: required`);
46
- if (!MODES.includes(o.mode as Mode)) throw new Error(`config.tools[${i}].mode: must be read|write`);
47
- if (seen.has(o.name)) throw new Error(`config.tools: duplicate tool "${o.name}"`);
48
- seen.add(o.name);
49
- const custom = o.custom === true;
50
- const augment = o.augment === true;
51
- if (custom && augment) throw new Error(`config.tools[${i}] ("${o.name}"): custom and augment are mutually exclusive`);
52
- return { name: o.name, mode: o.mode as Mode, custom, augment };
53
- });
54
-
55
- if (typeof c.owners !== "object" || c.owners === null) throw new Error("config.owners: must be an object");
56
- const owners: Config["owners"] = {};
57
- for (const [owner, v] of Object.entries(c.owners as Record<string, unknown>)) {
58
- const o = v as Record<string, unknown>;
59
- if (typeof o.read !== "string" || typeof o.write !== "string") {
60
- throw new Error(`config.owners.${owner}: needs "read" and "write" credential refs`);
61
- }
62
- if (o.installationId !== undefined && typeof o.installationId !== "number") {
63
- throw new Error(`config.owners.${owner}.installationId: must be a number`);
64
- }
65
- owners[owner.toLowerCase()] = {
66
- read: o.read,
67
- write: o.write,
68
- ...(typeof o.installationId === "number" ? { installationId: o.installationId } : {})
69
- };
70
- }
71
- if (Object.keys(owners).length === 0) throw new Error("config.owners: at least one owner required");
72
-
73
- let defaultOwner: string | undefined;
74
- if (c.defaultOwner !== undefined) {
75
- if (typeof c.defaultOwner !== "string" || !c.defaultOwner) {
76
- throw new Error("config.defaultOwner: must be a non-empty string naming a configured owner");
77
- }
78
- defaultOwner = c.defaultOwner.toLowerCase();
79
- if (!owners[defaultOwner]) {
80
- throw new Error(`config.defaultOwner "${c.defaultOwner}" is not a key of config.owners`);
81
- }
82
- }
83
-
84
- let app: Config["app"];
85
- if (c.app !== undefined) {
86
- const a = c.app as Record<string, unknown>;
87
- if (typeof a.appId !== "number") throw new Error("config.app.appId: must be a number");
88
- if (typeof a.privateKey !== "string") throw new Error('config.app.privateKey: must be a "cred:<name>" ref');
89
- app = { appId: a.appId, privateKey: a.privateKey };
90
- }
91
-
92
- let secrets: Config["secrets"];
93
- if (c.secrets !== undefined) {
94
- if (typeof c.secrets !== "object" || c.secrets === null || Array.isArray(c.secrets)) throw new Error("config.secrets: must be an object");
95
- const s = c.secrets as Record<string, unknown>;
96
- if (s.provider !== undefined && s.provider !== "file" && s.provider !== "systemd-creds") {
97
- throw new Error('config.secrets.provider: must be "systemd-creds" or "file"');
98
- }
99
- // Reject an empty path: resolveSecretsPath treats a falsy explicit path as
100
- // "not provided" and falls back to env/default, so "" would be silently ignored.
101
- if (s.path !== undefined && (typeof s.path !== "string" || s.path.length === 0)) {
102
- throw new Error("config.secrets.path: must be a non-empty string");
103
- }
104
- // path only means anything to the file provider; the systemd-creds provider
105
- // (the default) reads $CREDENTIALS_DIRECTORY and would silently ignore it.
106
- // Reject the combination so an operator can't think a file path is in effect.
107
- if (s.path !== undefined && (s.provider ?? "systemd-creds") !== "file") {
108
- throw new Error('config.secrets.path is only used by the "file" provider — set provider to "file" or remove path');
109
- }
110
- secrets = {
111
- ...(s.provider === "file" || s.provider === "systemd-creds" ? { provider: s.provider } : {}),
112
- ...(typeof s.path === "string" ? { path: s.path } : {})
113
- };
114
- }
115
-
116
- return {
117
- listen: c.listen,
118
- upstream: c.upstream,
119
- tools,
120
- owners,
121
- ...(app ? { app } : {}),
122
- ...(secrets ? { secrets } : {}),
123
- ...(defaultOwner ? { defaultOwner } : {})
124
- };
125
- }
126
-
127
- /** Build a name→rule index for O(1) allowlist checks. */
128
- export function indexTools(tools: ToolRule[]): Map<string, ToolRule> {
129
- return new Map(tools.map((t) => [t.name, t]));
130
- }
package/src/index.ts DELETED
@@ -1,107 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/index.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { loadConfig } from "./config.ts";
17
- import { loadSecretStore } from "./secrets.ts";
18
- import { UpstreamPool } from "./upstream.ts";
19
- import { Router } from "./router.ts";
20
- import { AppAuth } from "./appauth.ts";
21
- import { buildCatalog } from "./catalog.ts";
22
- import { startServer } from "./server.ts";
23
- import { resolveConfigPath } from "./paths.ts";
24
-
25
- function argConfig(): string {
26
- const i = process.argv.indexOf("--config");
27
- if (i >= 0) {
28
- // Fail fast on `--config` with no value (or a flag as the "value") rather than
29
- // silently falling back to env/default and hiding the operator's mistake.
30
- const explicit = process.argv[i + 1];
31
- if (!explicit || explicit.startsWith("-")) throw new Error("--config requires a path argument");
32
- return resolveConfigPath(explicit);
33
- }
34
- return resolveConfigPath(undefined);
35
- }
36
-
37
- async function main(): Promise<void> {
38
- const configPath = argConfig();
39
-
40
- // --print-paths: emit the resolved config + secrets paths and exit. The
41
- // installer uses this so there is one path resolver (src/paths.ts), no drift.
42
- // Honor a deployed config's secrets.path override when the config already exists
43
- // (upgrades); fall back to the default when it doesn't (first install) or is
44
- // unparseable — this must not require a valid config to run.
45
- if (process.argv.includes("--print-paths")) {
46
- const { resolveSecretsPath } = await import("./paths.ts");
47
- let secretsOverride: string | undefined;
48
- try {
49
- secretsOverride = loadConfig(configPath).secrets?.path;
50
- } catch {
51
- secretsOverride = undefined;
52
- }
53
- console.log(JSON.stringify({ config: configPath, secrets: resolveSecretsPath(secretsOverride) }));
54
- return;
55
- }
56
-
57
- const config = loadConfig(configPath);
58
-
59
- // --check: validate the config parses (and, where possible, that credentials
60
- // resolve), then exit WITHOUT starting the server. The installer runs this to
61
- // fail an install/upgrade early. The file provider validates here; the
62
- // systemd-creds provider can only resolve under systemd (needs
63
- // $CREDENTIALS_DIRECTORY), so it's checked at service start / by the smoke test.
64
- if (process.argv.includes("--check")) {
65
- const provider = config.secrets?.provider ?? "systemd-creds";
66
- // Truthy, not just "defined": the systemd-creds store treats an empty
67
- // $CREDENTIALS_DIRECTORY as unset (if (!dir)), so --check must too — otherwise
68
- // it would attempt secret resolution and fail where it should report unchecked.
69
- const secretsChecked = provider === "file" || Boolean(process.env.CREDENTIALS_DIRECTORY);
70
- if (secretsChecked) loadSecretStore(config);
71
- console.log(JSON.stringify({ event: "config-ok", config: configPath, provider, secretsChecked, owners: Object.keys(config.owners), tools: config.tools.length }));
72
- return;
73
- }
74
-
75
- const secrets = loadSecretStore(config); // eagerly validates every referenced credential
76
- const upstream = new UpstreamPool(config);
77
-
78
- // Optional GitHub App (for link_issue). Non-fatal: a misconfigured App disables
79
- // link_issue but never takes down the read/write path.
80
- let appAuth: AppAuth | undefined;
81
- if (config.app) {
82
- try {
83
- appAuth = new AppAuth(config, secrets);
84
- } catch (err) {
85
- console.error(`gh-broker: app-auth disabled (link_issue unavailable): ${(err as Error).message}`);
86
- }
87
- }
88
-
89
- const router = new Router(config, secrets, upstream, appAuth);
90
- const catalog = await buildCatalog(config, secrets, upstream);
91
- await startServer(config, router, catalog);
92
- console.log(
93
- JSON.stringify({
94
- ts: new Date().toISOString(),
95
- event: "listening",
96
- listen: config.listen,
97
- owners: Object.keys(config.owners),
98
- app: appAuth ? "enabled" : "none",
99
- tools: catalog.map((t) => t.name)
100
- })
101
- );
102
- }
103
-
104
- main().catch((err: unknown) => {
105
- console.error(`gh-broker: fatal: ${(err as Error).message}`);
106
- process.exit(1);
107
- });
package/src/paths.ts DELETED
@@ -1,60 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/paths.ts
5
- * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { platform, env } from "node:process";
17
- import { join } from "node:path";
18
-
19
- /**
20
- * Platform-neutral path resolution for the config and secrets files.
21
- *
22
- * Nothing is hardcoded to /etc. Both files resolve through the same order:
23
- * 1. an explicit path (a `--config` flag, or GH_BROKER_CONFIG / GH_BROKER_SECRETS),
24
- * 2. the platform's system config dir (below),
25
- * 3. the packaged default under that dir.
26
- *
27
- * Only the Linux defaults are exercised today; the macOS/Windows entries are
28
- * wired so a later port is a table entry, not surgery. See docs/deploy-architecture.md.
29
- */
30
-
31
- /** The system-wide config directory for the current platform (env-overridable). */
32
- export function configDir(): string {
33
- const override = env.GH_BROKER_CONFIG_DIR;
34
- if (override) return override;
35
- switch (platform) {
36
- case "darwin":
37
- return "/Library/Application Support/gh-broker";
38
- case "win32":
39
- return join(env.ProgramData ?? "C:\\ProgramData", "gh-broker");
40
- default: // linux and other unixes
41
- return "/etc/gh-broker";
42
- }
43
- }
44
-
45
- /**
46
- * Resolve the config file path: an explicit argument (from `--config`) wins,
47
- * then GH_BROKER_CONFIG, then `<configDir>/config.json`.
48
- */
49
- export function resolveConfigPath(explicit?: string): string {
50
- if (explicit) return explicit;
51
- if (env.GH_BROKER_CONFIG) return env.GH_BROKER_CONFIG;
52
- return join(configDir(), "config.json");
53
- }
54
-
55
- /** Resolve the secrets file path: an explicit argument wins, then GH_BROKER_SECRETS, then `<configDir>/secrets.json`. */
56
- export function resolveSecretsPath(explicit?: string): string {
57
- if (explicit) return explicit;
58
- if (env.GH_BROKER_SECRETS) return env.GH_BROKER_SECRETS;
59
- return join(configDir(), "secrets.json");
60
- }
package/src/router.ts DELETED
@@ -1,258 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/router.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-16T17:17:03-07:00 (1792196223)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
17
- import type { Config, SecretStore, ToolRule } from "./types.ts";
18
- import type { AppAuth } from "./appauth.ts";
19
- import { Octokit } from "@octokit/core";
20
- import { indexTools } from "./config.ts";
21
- import { audit } from "./audit.ts";
22
- // DEFERRED: link_issue is App-token-only and not wired in — see docs.
23
- // import { linkIssue, type LinkIssueInput } from "./verbs/link_issue.ts";
24
- import {
25
- resolveReviewThread,
26
- replyReviewThread,
27
- type ResolveReviewThreadInput,
28
- type ReplyReviewThreadInput
29
- } from "./verbs/review_thread.ts";
30
- import { reportUnlabeled, type ReportUnlabeledInput } from "./verbs/report_unlabeled.ts";
31
- import { reportSecurityAlerts, type ReportSecurityAlertsInput } from "./verbs/report_security_alerts.ts";
32
- import { reportReleasePrs, type ReportReleasePrsInput } from "./verbs/report_release_prs.ts";
33
- import { issueLabels, type IssueLabelsInput } from "./verbs/issue_labels.ts";
34
- import { reportCiStatus, ciStatusForHeadSha, type ReportCiStatusInput, type FailedJobLogsFetcher } from "./verbs/report_ci_status.ts";
35
-
36
- /** What the router needs from the upstream (hosted-MCP) client pool. */
37
- export interface Upstream {
38
- forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult>;
39
- }
40
-
41
- /**
42
- * Enforces the default-deny allowlist, resolves the (owner, mode) → PAT, and either
43
- * forwards to the hosted MCP or dispatches a custom verb. The token never leaves here.
44
- */
45
- export class Router {
46
- private readonly config: Config;
47
- private readonly secrets: SecretStore;
48
- private readonly upstream: Upstream;
49
- private readonly appAuth?: AppAuth;
50
- private readonly rules: Map<string, ToolRule>;
51
-
52
- constructor(config: Config, secrets: SecretStore, upstream: Upstream, appAuth?: AppAuth) {
53
- this.config = config;
54
- this.secrets = secrets;
55
- this.upstream = upstream;
56
- this.appAuth = appAuth;
57
- this.rules = indexTools(config.tools);
58
- }
59
-
60
- /** Tools advertised to the agent (used to build tools/list). */
61
- advertisedTools(): ToolRule[] {
62
- return this.config.tools;
63
- }
64
-
65
- async call(tool: string, args: Record<string, unknown>): Promise<CallToolResult> {
66
- const rule = this.rules.get(tool);
67
- if (!rule) {
68
- audit({ tool, decision: "deny", detail: "not in allowlist" });
69
- throw new Error(`tool "${tool}" is not allowed`);
70
- }
71
- // Owner-less reads (global advisory DB, get_me, notifications) fall back to
72
- // the configured defaultOwner's READ PAT. Writes never fall back — a write
73
- // must always name the owner it targets.
74
- let owner = extractOwner(args);
75
- if (!owner && rule.mode === "read" && this.config.defaultOwner && this.config.owners[this.config.defaultOwner]) {
76
- owner = this.config.defaultOwner.toLowerCase();
77
- }
78
- if (!owner) {
79
- audit({ tool, decision: "error", detail: "no owner in args" });
80
- throw new Error(`tool "${tool}": could not determine repo owner from arguments`);
81
- }
82
- const creds = this.config.owners[owner];
83
- if (!creds) {
84
- audit({ tool, owner, decision: "error", detail: "no creds for owner" });
85
- throw new Error(`no credentials configured for owner "${owner}" — add it to config.owners + creds`);
86
- }
87
- const token = this.secrets.resolve(rule.mode === "write" ? creds.write : creds.read);
88
- const repo = typeof args.repo === "string" ? `${owner}/${args.repo}` : owner;
89
-
90
- const decision = rule.custom ? "custom" : rule.augment ? "augment" : "forward";
91
- try {
92
- const out = rule.custom
93
- ? await this.dispatchCustom(tool, args, owner, token)
94
- : rule.augment
95
- ? await this.dispatchAugmented(tool, args, owner, token)
96
- : await this.upstream.forward(tool, args, token);
97
- audit({ tool, owner, mode: rule.mode, repo, decision, ok: true });
98
- return out;
99
- } catch (err) {
100
- audit({ tool, owner, mode: rule.mode, repo, decision, ok: false, detail: (err as Error).message });
101
- throw err;
102
- }
103
- }
104
-
105
- private async dispatchCustom(
106
- tool: string,
107
- args: Record<string, unknown>,
108
- owner: string,
109
- token: string
110
- ): Promise<CallToolResult> {
111
- switch (tool) {
112
- // DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
113
- // Re-enable by restoring this case + the import above + the catalog block, adding a
114
- // link_issue allowlist entry to config, and configuring config.app + owner installationId.
115
- // case "link_issue": {
116
- // const octokit = this.appAuth?.octokitFor(owner);
117
- // if (!octokit) {
118
- // throw new Error(`link_issue needs a GitHub App for "${owner}": set config.app + owners.${owner}.installationId + the app key cred (addClosingIssueReference is App-token-only — not available to fine-grained PATs)`);
119
- // }
120
- // const out = await linkIssue(octokit, args as unknown as LinkIssueInput);
121
- // return { content: [{ type: "text", text: `linked: PR ${out.pullRequestId} → issue ${out.issueId}` }] };
122
- // }
123
- case "resolve_review_thread": {
124
- // PAT-usable (Pull requests: write) — run on the resolved write PAT, not the App token.
125
- const octokit = new Octokit({ auth: token });
126
- const out = await resolveReviewThread(octokit, args as unknown as ResolveReviewThreadInput);
127
- return { content: [{ type: "text", text: `thread ${out.threadId}: isResolved=${out.isResolved}` }] };
128
- }
129
- case "reply_review_thread": {
130
- const octokit = new Octokit({ auth: token });
131
- const out = await replyReviewThread(octokit, args as unknown as ReplyReviewThreadInput);
132
- return { content: [{ type: "text", text: `replied: ${out.url}` }] };
133
- }
134
- case "issue_labels": {
135
- // Per-label REST endpoints (Issues: write) on the resolved write PAT.
136
- const octokit = new Octokit({ auth: token });
137
- const out = await issueLabels(octokit, args as unknown as IssueLabelsInput);
138
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
139
- }
140
- case "report_unlabeled": {
141
- const octokit = new Octokit({ auth: token });
142
- const out = await reportUnlabeled(octokit, args as unknown as ReportUnlabeledInput);
143
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
144
- }
145
- case "report_security_alerts": {
146
- const octokit = new Octokit({ auth: token });
147
- const out = await reportSecurityAlerts(octokit, args as unknown as ReportSecurityAlertsInput);
148
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
149
- }
150
- case "report_release_prs": {
151
- const octokit = new Octokit({ auth: token });
152
- const out = await reportReleasePrs(octokit, args as unknown as ReportReleasePrsInput);
153
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
154
- }
155
- case "report_ci_status": {
156
- const octokit = new Octokit({ auth: token });
157
- const reportArgs = args as unknown as ReportCiStatusInput;
158
- const out = await reportCiStatus(octokit, reportArgs, this.makeJobLogsFetcher(owner, reportArgs.repo, token));
159
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
160
- }
161
- default:
162
- throw new Error(`custom verb "${tool}" not implemented`);
163
- }
164
- }
165
-
166
- /**
167
- * A failing-run's job logs, via the hosted MCP's own `get_job_logs` tool
168
- * (`failed_only: true` fetches every failing job in the run in one call) rather
169
- * than a hand-rolled REST call — see report_ci_status.ts's FailedJobLogsFetcher
170
- * doc comment for why. Best-effort: any failure here (bad shape, upstream error)
171
- * just yields an empty map — report_ci_status treats a lookup miss as "no
172
- * logTail for this job", never a hard failure of the whole report.
173
- */
174
- private makeJobLogsFetcher(owner: string, repo: string, token: string): FailedJobLogsFetcher {
175
- return async (runId, tailLines) => {
176
- const map = new Map<number, string>();
177
- try {
178
- const result = await this.upstream.forward("get_job_logs", { owner, repo, run_id: runId, failed_only: true, return_content: true, tail_lines: tailLines }, token);
179
- const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
180
- if (!textBlock?.text) return map;
181
- const parsed = JSON.parse(textBlock.text) as { logs?: Array<{ job_id?: unknown; logs_content?: unknown }> };
182
- for (const entry of parsed.logs ?? []) {
183
- if (typeof entry.job_id === "number" && typeof entry.logs_content === "string") map.set(entry.job_id, entry.logs_content);
184
- }
185
- } catch {
186
- /* best-effort — see doc comment */
187
- }
188
- return map;
189
- };
190
- }
191
-
192
- /**
193
- * Forwards to the hosted MCP like a plain proxy, then post-processes the result.
194
- * gh-broker presents its own MCP contract — a proxied tool's response is not
195
- * required to match the hosted MCP's shape verbatim (see [[broker-owns-its-mcp-contract]]
196
- * in project memory). `ciVerbosity`/`logLines` are gh-broker's own extension
197
- * params, stripped before forwarding since the upstream schema doesn't know them.
198
- *
199
- * Opt-in only: with no `ciVerbosity`, this is byte-identical to a plain forward —
200
- * augmenting every `pull_request_read` call by default would add an unrequested
201
- * API call (and cost) to every ordinary PR read.
202
- *
203
- * Best-effort: if the upstream response isn't the JSON-text shape expected (a
204
- * format this broker doesn't control), the original result is returned unmodified
205
- * rather than thrown — a shape mismatch should degrade to "no CI data added," not
206
- * break an otherwise-successful PR read.
207
- */
208
- private async dispatchAugmented(tool: string, args: Record<string, unknown>, owner: string, token: string): Promise<CallToolResult> {
209
- switch (tool) {
210
- case "pull_request_read": {
211
- const { ciVerbosity, logLines, ...upstreamArgs } = args as Record<string, unknown> & { ciVerbosity?: number; logLines?: number };
212
- const result = await this.upstream.forward(tool, upstreamArgs, token);
213
- if (ciVerbosity === undefined) return result;
214
- if ((upstreamArgs.method ?? "get") !== "get") return result; // only the single-PR "get" shape is augmentable
215
-
216
- const textBlock = result.content?.find((c) => (c as { type?: string }).type === "text") as { text?: string } | undefined;
217
- if (!textBlock?.text) return result;
218
- let pr: unknown;
219
- try {
220
- pr = JSON.parse(textBlock.text);
221
- } catch {
222
- return result;
223
- }
224
- const p = pr as { number?: unknown; head?: { sha?: unknown } };
225
- if (typeof p.number !== "number" || typeof p.head?.sha !== "string") return result;
226
-
227
- const repo = String(upstreamArgs.repo ?? "");
228
- const octokit = new Octokit({ auth: token });
229
- const ci = await ciStatusForHeadSha(octokit, owner, repo, p.number, p.head.sha, {
230
- verbosity: Number(ciVerbosity),
231
- logLines: logLines !== undefined ? Number(logLines) : undefined,
232
- fetchFailedJobLogs: this.makeJobLogsFetcher(owner, repo, token)
233
- });
234
- textBlock.text = JSON.stringify({ ...(pr as object), ci });
235
- return result;
236
- }
237
- default:
238
- return this.upstream.forward(tool, args, token);
239
- }
240
- }
241
- }
242
-
243
- /** Derive the repo owner from a tool call's arguments, so we pick the right PAT. */
244
- export function extractOwner(args: Record<string, unknown>): string | undefined {
245
- if (typeof args.owner === "string" && args.owner) return args.owner.toLowerCase();
246
- // Many org-scoped tools (list_org_repository_security_advisories, get_team_members, …)
247
- // name the org as `org` rather than `owner`; for routing, org === owner.
248
- if (typeof args.org === "string" && args.org) return args.org.toLowerCase();
249
- if (typeof args.query === "string") {
250
- const m = args.query.match(/\brepo:([^/\s]+)\/[^\s]+|\b(?:org|user):([^\s]+)/i);
251
- const hit = m?.[1] ?? m?.[2];
252
- if (hit) return hit.toLowerCase();
253
- }
254
- if (typeof args.repository === "string" && args.repository.includes("/")) {
255
- return args.repository.split("/")[0]?.toLowerCase();
256
- }
257
- return undefined;
258
- }