diff --git a/docs/bridge.md b/docs/bridge.md index d7fcf3b..010833c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -134,7 +134,7 @@ node .\tools\bifrost-bridge.mjs github-pr-comment ` --content "This proposal needs a sharper leash before it becomes canon-shaped." ``` -The command leaves a signed PR comment through `gh` and prints a JSON receipt. Repo Personas should use this when the argument belongs on the review artifact instead of dissolving into Discord scrollback. +The command leaves a signed PR comment through GitHub's issue-comment API via `gh api` and prints a JSON receipt with GitHub's own comment id and `html_url`. Repo Personas should use this when the argument belongs on the review artifact instead of dissolving into Discord scrollback. ### Discord Post diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index b716c4f..16bff27 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -44,6 +44,40 @@ public sealed class BridgeCliTests Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); } + [Fact] + public async Task GitHub_pr_comment_returns_concrete_github_receipt() + { + var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}"); + Directory.CreateDirectory(fakeToolsDir); + var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js"); + await File.WriteAllTextAsync(fakeGhPath, """ +console.log(JSON.stringify({ + id: 123456, + node_id: "IC_kwDOTest", + html_url: "https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456" +})); +""", Encoding.UTF8); + + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "github-pr-comment", + "--repo-root", RepoRoot, + "--identity", "nibu", + "--pr", "1", + "--content", "test comment", + "--target-repository-full-name", "GameCult/Bifrost", + "--gh-executable", "node", + "--gh-exec-args", fakeGhPath, + "--allow-ungated-github", "true", + ]); + + Assert.Equal(0, result.ExitCode); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString()); + Assert.Equal("https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456", payload.RootElement.GetProperty("receiptUrl").GetString()); + Assert.Equal("123456", payload.RootElement.GetProperty("externalReceiptId").GetString()); + } + [Fact] public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate() { @@ -176,7 +210,7 @@ public sealed class BridgeCliTests private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); - private static async Task RunNodeAsync(string[] args) + private static async Task RunNodeAsync(string[] args, IReadOnlyDictionary? environmentOverrides = null) { using var process = new Process { @@ -199,6 +233,13 @@ public sealed class BridgeCliTests process.StartInfo.Environment["BIFROST_BRIDGE_TOKEN"] = string.Empty; process.StartInfo.Environment["BIFROST_ALLOW_UNGATED_GITHUB"] = string.Empty; process.StartInfo.Environment["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = string.Empty; + if (environmentOverrides is not null) + { + foreach (var pair in environmentOverrides) + { + process.StartInfo.Environment[pair.Key] = pair.Value ?? string.Empty; + } + } process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index c79d309..8e50bc7 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -59,7 +59,7 @@ async function createGitHubDraftPr(options) { const dryRun = options["dry-run"] === "true"; const targetRepositoryFullName = optionalString(options["target-repository-full-name"]) ?? - detectGitHubRepositoryFullName(repoRoot); + detectGitHubRepositoryFullName(options, repoRoot); const originalBranch = git(["branch", "--show-current"], repoRoot).stdout.trim(); const status = git(["status", "--short"], repoRoot).stdout.trim(); @@ -113,7 +113,7 @@ async function createGitHubDraftPr(options) { git(["push", "-u", "origin", branch], repoRoot); pushed = true; - const pr = run("gh", [ + const pr = runGitHubCli(options, [ "pr", "create", "--draft", @@ -172,7 +172,7 @@ async function commentGitHubPr(options) { const body = `${identity} says:\n\n${content.trim()}`; const targetRepositoryFullName = optionalString(options["target-repository-full-name"]) ?? - detectGitHubRepositoryFullName(repoRoot); + detectGitHubRepositoryFullName(options, repoRoot); if (dryRun) { printJson({ @@ -199,28 +199,58 @@ async function commentGitHubPr(options) { try { await bridgeAction?.start(); - run("gh", ["pr", "comment", pr, "--body", body], repoRoot); + const payloadPath = resolve(repoRoot, ".bifrost", `github-pr-comment-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); + await mkdir(dirname(payloadPath), { recursive: true }); + await writeFile(payloadPath, JSON.stringify({ body }), "utf8"); + let comment; + try { + comment = parseRequiredJson( + runGitHubCli( + options, + [ + "api", + `repos/${targetRepositoryFullName}/issues/${pr}/comments`, + "--method", "POST", + "--input", payloadPath, + ], + repoRoot, + ).stdout, + "GitHub PR comment response", + ); + } finally { + await unlink(payloadPath).catch(() => {}); + } + const receiptUrl = optionalString(comment.html_url); + const externalReceiptId = comment.id === undefined || comment.id === null ? "" : String(comment.id); + if (!receiptUrl || !externalReceiptId) { + throw new Error("GitHub PR comment response returned no concrete receipt URL or comment id."); + } await bridgeAction?.complete({ - receiptUrl: `https://github.com/${targetRepositoryFullName}/pull/${pr}#issuecomment-bridge`, - externalReceiptId: String(pr), + receiptUrl, + externalReceiptId, receiptPayload: JSON.stringify({ pullRequestNumber: Number(pr), repositoryFullName: targetRepositoryFullName, + issueCommentId: comment.id, + issueCommentNodeId: optionalString(comment.node_id) ?? "", + issueCommentUrl: receiptUrl, author: identity, body, }), }); + printJson({ + action: "github-pr-comment", + ok: true, + repoRoot, + identity, + pr, + receiptUrl, + externalReceiptId, + }); } catch (error) { await bridgeAction?.fail(error); throw error; } - printJson({ - action: "github-pr-comment", - ok: true, - repoRoot, - identity, - pr, - }); } async function postDiscordMessage(options) { @@ -836,6 +866,11 @@ function git(args, cwd, options = {}) { return run("git", args, cwd, options); } +function runGitHubCli(options, args, cwd, runOptions = {}) { + const command = resolveGitHubCommand(options); + return run(command.exe, [...command.args, ...args], cwd, runOptions); +} + function run(command, args, cwd, options = {}) { const result = spawnSync(command, args, { cwd, @@ -955,6 +990,15 @@ async function beginBridgeAction(options, action) { }; } +function parseRequiredJson(text, label) { + try { + return JSON.parse(text); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${label} was not valid JSON: ${detail}`); + } +} + function ensureGitHubBridgeGate(options) { const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); @@ -1012,9 +1056,9 @@ function ensureTrailingSlash(value) { return value.endsWith("/") ? value : `${value}/`; } -function detectGitHubRepositoryFullName(repoRoot) { - const result = run( - "gh", +function detectGitHubRepositoryFullName(options, repoRoot) { + const result = runGitHubCli( + options, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], repoRoot, { allowFailure: true }, @@ -1022,6 +1066,28 @@ function detectGitHubRepositoryFullName(repoRoot) { return optionalString(result.stdout) ?? ""; } +function resolveGitHubExecutable(options) { + return resolveGitHubCommand(options).exe; +} + +function resolveGitHubCommand(options) { + return { + exe: optionalString(options["gh-executable"]) ?? optionalString(process.env.BIFROST_GH_EXECUTABLE) ?? "gh", + args: splitCommandArgs(options["gh-exec-args"] ?? process.env.BIFROST_GH_EXEC_ARGS ?? ""), + }; +} + +function splitCommandArgs(value) { + if (typeof value !== "string" || value.trim().length === 0) { + return []; + } + + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + function extractGitHubNumber(value) { const match = optionalString(value)?.match(/\/(\d+)(?:[#?].*)?$/); return match ? match[1] : undefined;