From 31f6e8c6ef6a168120b0ce33df9c9e3fe2423ce0 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Thu, 9 Jul 2026 00:41:15 +0900 Subject: [PATCH 1/3] fix(action): resolve the real Pages URL for Dashboard links and assets Why Dashboard builds and PR report links derived the Pages URL from the repository name, assuming the canonical .github.io/ layout. Private and Enterprise Pages sites are served from obfuscated *.pages.github.io domains at the root path, so every asset reference, notes fetch, and Open Dashboard link pointed at a // prefix that does not exist and returned 404. The action now resolves the actual Pages URL from the Pages API with the workflow token, callers can override it with the new pages_base_url input, and the previous repository-name layout remains the fallback when neither source is available. User impact Dashboards deployed from private or GitHub Enterprise Cloud repositories load with styles, scripts, and note data, and PR report Open Dashboard links point at the real Pages domain. Public repositories on the canonical layout are unaffected. Verification npm test --prefix packages/dashboard (38 passed) npm run dashboard:build npm -w packages/pr-report test (51 passed) npm -w packages/pr-report run build npm -w packages/cli run build npm -w packages/cli run typecheck npm -w packages/cli run lint npm -w packages/cli test (491 passed) Release note: Dashboards published from private or Enterprise GitHub Pages now load correctly, and a new pages_base_url action input can override the resolved Pages URL. Agentnote-Session: 4a6d322d-5afe-45e3-a11b-cf15d0e740a9 --- action.yml | 8 ++ packages/cli/dist/cli.js | 16 ++- packages/dashboard/workflow/build-pages.mjs | 12 +- .../workflow/resolve-pages-target.mjs | 91 ++++++++++++- .../workflow/resolve-pages-target.test.mjs | 126 ++++++++++++++++++ packages/pr-report/dist/index.js | 72 +++++++++- packages/pr-report/src/github.ts | 22 ++- packages/pr-report/src/index.test.ts | 25 ++++ packages/pr-report/src/index.ts | 43 ++++++ packages/pr-report/src/report.ts | 3 +- 10 files changed, 397 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index 268ce98a..5ffdc06c 100644 --- a/action.yml +++ b/action.yml @@ -19,6 +19,10 @@ inputs: description: "PR Report prompt detail: compact or full (default: compact)" required: false default: "compact" + pages_base_url: + description: "Override the GitHub Pages base URL used for Dashboard links and asset paths (default: resolved from the Pages API, falling back to https://.github.io//)" + required: false + default: "" outputs: overall_ai_ratio: description: "PR-wide AI ratio (0-100)" @@ -62,6 +66,7 @@ runs: INPUT_BASE: ${{ inputs.base }} INPUT_PR_OUTPUT: ${{ inputs.pr_output }} INPUT_PROMPT_DETAIL: ${{ inputs.prompt_detail }} + INPUT_PAGES_BASE_URL: ${{ inputs.pages_base_url }} run: node "$AGENT_NOTE_ACTION_PATH/packages/pr-report/dist/index.js" - name: Detect Dashboard checkout @@ -103,6 +108,8 @@ runs: shell: bash env: DASHBOARD_ACTION_PATH: ${{ github.action_path }}/packages/dashboard + GH_TOKEN: ${{ github.token }} + PAGES_BASE_URL: ${{ inputs.pages_base_url }} run: npm --prefix "$DASHBOARD_ACTION_PATH" run workflow:resolve-pages-target - name: Restore Dashboard notes from gh-pages @@ -140,6 +147,7 @@ runs: NOTES_DIR: ${{ steps.dashboard_paths.outputs.notes_dir }} PAGES_DIR: ${{ steps.dashboard_paths.outputs.pages_dir }} DASHBOARD_PAGES_MODE: ${{ steps.dashboard_paths.outputs.publish_mode }} + PAGES_BASE_URL: ${{ steps.dashboard_paths.outputs.pages_base_url }} PUBLIC_REPO: ${{ github.repository }} run: npm --prefix "$DASHBOARD_ACTION_PATH" run workflow:build-pages diff --git a/packages/cli/dist/cli.js b/packages/cli/dist/cli.js index 8bb618bc..86b1ec32 100755 --- a/packages/cli/dist/cli.js +++ b/packages/cli/dist/cli.js @@ -6277,7 +6277,19 @@ ${section}${after}`; ${section}`; } -function inferDashboardUrl(repoUrl, prNumber) { +function parseUrlOrNull(value) { + try { + return new URL(value); + } catch { + return null; + } +} +function inferDashboardUrl(repoUrl, prNumber, pagesBaseUrl) { + const parsedBase = pagesBaseUrl ? parseUrlOrNull(pagesBaseUrl) : null; + if (parsedBase) { + const basePath = parsedBase.pathname.replace(/\/+$/, ""); + return appendPrNumber(`${parsedBase.origin}${basePath}/dashboard/`, prNumber); + } if (!repoUrl) return null; const normalized = repoUrl.replace(/\.git$/, ""); const match = normalized.match(GITHUB_REPOSITORY_URL_PATTERN); @@ -6549,7 +6561,7 @@ async function collectReport(base, headRef = "HEAD", opts = {}) { const hasDashboardWorkflow = existsSync14( join14(repoRoot3, ".github", "workflows", "agentnote-dashboard.yml") ); - const dashboardUrl = hasDashboardWorkflow ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber) : null; + const dashboardUrl = hasDashboardWorkflow ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber, opts.pagesBaseUrl) : null; return { base, head, diff --git a/packages/dashboard/workflow/build-pages.mjs b/packages/dashboard/workflow/build-pages.mjs index ef98da86..0d0a8a22 100644 --- a/packages/dashboard/workflow/build-pages.mjs +++ b/packages/dashboard/workflow/build-pages.mjs @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PUBLISH_MODE_INTEGRATED } from "./resolve-pages-target.mjs"; +import { derivePagesPaths, PUBLISH_MODE_INTEGRATED } from "./resolve-pages-target.mjs"; const DASHBOARD_SUBDIRECTORY = "dashboard"; const DEFAULT_DASHBOARD_NOTES_DIR = ".agentnote-dashboard-notes"; @@ -12,9 +12,9 @@ const DIST_DIR_NAME = "dist"; const ENV_DASHBOARD_PAGES_MODE = "DASHBOARD_PAGES_MODE"; const ENV_GITHUB_REPOSITORY = "GITHUB_REPOSITORY"; const ENV_NOTES_DIR = "NOTES_DIR"; +const ENV_PAGES_BASE_URL = "PAGES_BASE_URL"; const ENV_PAGES_DIR = "PAGES_DIR"; const ENV_PUBLIC_REPO = "PUBLIC_REPO"; -const GITHUB_PAGES_HOST_SUFFIX = ".github.io"; const NOTES_DIR_NAME = "notes"; const ORIGINAL_NOTES_DIR_NAME = "original-notes"; const PUBLIC_DIR_NAME = "public"; @@ -22,8 +22,8 @@ const TEMP_DIR_PREFIX = "agentnote-dashboard-build-"; const notesDir = process.env[ENV_NOTES_DIR] || DEFAULT_DASHBOARD_NOTES_DIR; const pagesDir = process.env[ENV_PAGES_DIR] || DEFAULT_PAGES_DIR; const pagesMode = process.env[ENV_DASHBOARD_PAGES_MODE] || DEFAULT_PAGES_MODE; +const pagesBaseUrl = process.env[ENV_PAGES_BASE_URL] || ""; const repository = process.env[ENV_PUBLIC_REPO] || process.env[ENV_GITHUB_REPOSITORY] || ""; -const [repositoryOwner = "", repositoryName = ""] = repository.split("/"); const dashboardDir = process.cwd(); const publicNotesDir = join(dashboardDir, PUBLIC_DIR_NAME, NOTES_DIR_NAME); @@ -50,11 +50,7 @@ if (existsSync(publicNotesDir)) { copyDirectoryContents(publicNotesDir, originalNotesDir); } -const site = `https://${repositoryOwner}.github.io`; -const base = - repositoryName === `${repositoryOwner}${GITHUB_PAGES_HOST_SUFFIX}` - ? `/${DASHBOARD_SUBDIRECTORY}` - : `/${repositoryName}/${DASHBOARD_SUBDIRECTORY}`; +const { site, base } = derivePagesPaths({ pagesBaseUrl, repository }); try { // Astro reads dashboard notes from public/notes only during the static build. diff --git a/packages/dashboard/workflow/resolve-pages-target.mjs b/packages/dashboard/workflow/resolve-pages-target.mjs index f20463a0..d0e7fd33 100644 --- a/packages/dashboard/workflow/resolve-pages-target.mjs +++ b/packages/dashboard/workflow/resolve-pages-target.mjs @@ -8,15 +8,22 @@ export const PUBLISH_MODE_STANDALONE = "standalone"; const ACTIONS_FALSE = "false"; const ACTIONS_TRUE = "true"; +const DASHBOARD_SUBDIRECTORY = "dashboard"; const DEFAULT_DASHBOARD_NOTES_DIR = ".agentnote-dashboard-notes"; +const DEFAULT_GITHUB_API_URL = "https://api.github.com"; const DEFAULT_PAGES_ARTIFACT_PATH = "_site"; const DEFAULT_STANDALONE_PAGES_DIR = ".agentnote-pages"; +const ENV_GH_TOKEN = "GH_TOKEN"; +const ENV_GITHUB_API_URL = "GITHUB_API_URL"; const ENV_GITHUB_JOB = "GITHUB_JOB"; const ENV_GITHUB_OUTPUT = "GITHUB_OUTPUT"; const ENV_GITHUB_REPOSITORY = "GITHUB_REPOSITORY"; const ENV_GITHUB_WORKFLOW_REF = "GITHUB_WORKFLOW_REF"; const ENV_GITHUB_WORKSPACE = "GITHUB_WORKSPACE"; +const ENV_PAGES_BASE_URL = "PAGES_BASE_URL"; +const GITHUB_PAGES_HOST_SUFFIX = ".github.io"; const GITHUB_WORKFLOWS_DIR = ".github/workflows/"; +const PAGES_API_TIMEOUT_MS = 10_000; const REASON_DYNAMIC_PATH = "dynamic-path"; const REASON_OTHER_JOB = "other-job"; const REASON_OTHER_WORKFLOW = "other-workflow"; @@ -45,6 +52,77 @@ function leadingSpaces(line) { return line.match(/^\s*/)?.[0].length ?? 0; } +function parseUrlOrNull(value) { + try { + return new URL(value); + } catch { + return null; + } +} + +/** + * Resolve the real GitHub Pages base URL for the current repository. + * + * Private and Enterprise Pages sites are served from obfuscated domains at the + * root path, so the URL cannot be derived from the repository name alone. The + * explicit override wins, then the Pages API, and callers fall back to the + * repository-name heuristic when neither is available. + */ +export async function resolvePagesBaseUrl({ + override = process.env[ENV_PAGES_BASE_URL] || "", + repository = process.env[ENV_GITHUB_REPOSITORY] || "", + token = process.env[ENV_GH_TOKEN] || "", + apiUrl = process.env[ENV_GITHUB_API_URL] || DEFAULT_GITHUB_API_URL, + fetcher = fetch, +} = {}) { + if (override) { + if (parseUrlOrNull(override)) return override; + console.log(`Ignoring invalid pages_base_url override: ${override}`); + } + + if (!repository || !token) return null; + + try { + const response = await fetcher(`${apiUrl}/repos/${repository}/pages`, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + }, + signal: AbortSignal.timeout(PAGES_API_TIMEOUT_MS), + }); + if (!response.ok) return null; + const pages = await response.json(); + const htmlUrl = typeof pages?.html_url === "string" ? pages.html_url : ""; + return htmlUrl && parseUrlOrNull(htmlUrl) ? htmlUrl : null; + } catch { + // Pages metadata is an enhancement; resolution failures fall back to the heuristic. + return null; + } +} + +/** + * Derive the Astro site origin and dashboard base path. + * + * A resolved Pages URL takes precedence; otherwise the canonical + * `https://.github.io//` layout is assumed. + */ +export function derivePagesPaths({ pagesBaseUrl = "", repository = "" } = {}) { + const parsed = pagesBaseUrl ? parseUrlOrNull(pagesBaseUrl) : null; + if (parsed) { + const basePath = parsed.pathname.replace(/\/+$/, ""); + return { site: parsed.origin, base: `${basePath}/${DASHBOARD_SUBDIRECTORY}` }; + } + + const [owner = "", name = ""] = repository.split("/"); + return { + site: `https://${owner}${GITHUB_PAGES_HOST_SUFFIX}`, + base: + name === `${owner}${GITHUB_PAGES_HOST_SUFFIX}` + ? `/${DASHBOARD_SUBDIRECTORY}` + : `/${name}/${DASHBOARD_SUBDIRECTORY}`, + }; +} + /** * Resolve the caller workflow path from GitHub Actions metadata. * @@ -301,7 +379,7 @@ function readOtherWorkflowTexts(currentWorkflowPath) { .filter(Boolean); } -function main() { +async function main() { const workflowPath = resolveWorkflowPath(); const workflowText = workflowPath && existsSync(workflowPath) ? readFileSync(workflowPath, TEXT_ENCODING) @@ -311,6 +389,14 @@ function main() { otherWorkflowTexts: readOtherWorkflowTexts(workflowPath), workspaceDir: workspace, }); + const pagesBaseUrl = await resolvePagesBaseUrl(); + if (pagesBaseUrl) { + console.log(`Resolved GitHub Pages base URL: ${pagesBaseUrl}`); + } else { + console.log( + "GitHub Pages base URL is not resolvable; falling back to the repository-name layout.", + ); + } if (target.publishMode === PUBLISH_MODE_INTEGRATED) { console.log(`Detected existing Pages artifact path: ${target.pagesDir}`); @@ -330,8 +416,9 @@ function main() { setOutput("internal_upload", target.internalUpload); setOutput("can_build", target.canBuild); setOutput("skip_reason", target.reason); + setOutput("pages_base_url", pagesBaseUrl ?? ""); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(); + await main(); } diff --git a/packages/dashboard/workflow/resolve-pages-target.test.mjs b/packages/dashboard/workflow/resolve-pages-target.test.mjs index cc04d967..2355ddbe 100644 --- a/packages/dashboard/workflow/resolve-pages-target.test.mjs +++ b/packages/dashboard/workflow/resolve-pages-target.test.mjs @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import { join } from "node:path"; import test from "node:test"; import { + derivePagesPaths, extractJobBlock, hasPagesPublishStep, parseUploadPagesArtifactPath, + resolvePagesBaseUrl, resolvePagesTarget, resolveWorkflowPath, } from "./resolve-pages-target.mjs"; @@ -292,3 +294,127 @@ test("resolveWorkflowPath resolves only workflow files in the caller repository" null, ); }); + +test("derivePagesPaths uses a Pages URL served at the domain root", () => { + assert.deepEqual( + derivePagesPaths({ + pagesBaseUrl: "https://improved-guacamole-abc123.pages.github.io/", + repository: "acme/apps", + }), + { + site: "https://improved-guacamole-abc123.pages.github.io", + base: "/dashboard", + }, + ); +}); + +test("derivePagesPaths uses a Pages URL with a project path", () => { + assert.deepEqual( + derivePagesPaths({ + pagesBaseUrl: "https://acme.github.io/apps/", + repository: "acme/apps", + }), + { site: "https://acme.github.io", base: "/apps/dashboard" }, + ); +}); + +test("derivePagesPaths falls back to the repository layout without a Pages URL", () => { + assert.deepEqual(derivePagesPaths({ repository: "wasabeef/AgentNote" }), { + site: "https://wasabeef.github.io", + base: "/AgentNote/dashboard", + }); + assert.deepEqual(derivePagesPaths({ repository: "wasabeef/wasabeef.github.io" }), { + site: "https://wasabeef.github.io", + base: "/dashboard", + }); +}); + +test("derivePagesPaths falls back when the Pages URL is invalid", () => { + assert.deepEqual( + derivePagesPaths({ pagesBaseUrl: "not a url", repository: "acme/apps" }), + { site: "https://acme.github.io", base: "/apps/dashboard" }, + ); +}); + +test("resolvePagesBaseUrl prefers a valid explicit override", async () => { + const result = await resolvePagesBaseUrl({ + override: "https://pages.example.com/site/", + repository: "acme/apps", + token: "token", + fetcher: () => { + throw new Error("fetch must not run when the override is valid"); + }, + }); + assert.equal(result, "https://pages.example.com/site/"); +}); + +test("resolvePagesBaseUrl resolves html_url from the Pages API", async () => { + const requests = []; + const result = await resolvePagesBaseUrl({ + override: "", + repository: "acme/apps", + token: "token", + apiUrl: "https://api.example.com", + fetcher: async (url, options) => { + requests.push({ url, authorization: options.headers.authorization }); + return { + ok: true, + json: async () => ({ html_url: "https://improved-guacamole-abc123.pages.github.io/" }), + }; + }, + }); + assert.equal(result, "https://improved-guacamole-abc123.pages.github.io/"); + assert.deepEqual(requests, [ + { url: "https://api.example.com/repos/acme/apps/pages", authorization: "Bearer token" }, + ]); +}); + +test("resolvePagesBaseUrl ignores an invalid override and falls back to the API", async () => { + const result = await resolvePagesBaseUrl({ + override: "not a url", + repository: "acme/apps", + token: "token", + fetcher: async () => ({ + ok: true, + json: async () => ({ html_url: "https://acme.github.io/apps/" }), + }), + }); + assert.equal(result, "https://acme.github.io/apps/"); +}); + +test("resolvePagesBaseUrl returns null when the API is unavailable", async () => { + assert.equal( + await resolvePagesBaseUrl({ + override: "", + repository: "acme/apps", + token: "token", + fetcher: async () => ({ ok: false, json: async () => ({}) }), + }), + null, + ); + assert.equal( + await resolvePagesBaseUrl({ + override: "", + repository: "acme/apps", + token: "token", + fetcher: async () => { + throw new Error("network unreachable"); + }, + }), + null, + ); +}); + +test("resolvePagesBaseUrl returns null without a repository or token", async () => { + const fetcher = () => { + throw new Error("fetch must not run without credentials"); + }; + assert.equal( + await resolvePagesBaseUrl({ override: "", repository: "", token: "token", fetcher }), + null, + ); + assert.equal( + await resolvePagesBaseUrl({ override: "", repository: "acme/apps", token: "", fetcher }), + null, + ); +}); diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index dc76e645..e82f1c8f 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -36324,13 +36324,29 @@ function upsertDescription(existingBody, markdown) { function shouldRetryNotesFetch(report) { return (report.total_commits ?? 0) > 0 && (report.tracked_commits ?? 0) === 0; } +function parseUrlOrNull(value) { + try { + return new URL(value); + } + catch { + return null; + } +} /** * Infer the public Dashboard URL from a GitHub remote URL. * - * The PR number is appended only for PR reports so the general dashboard route - * can remain a team-level entry point without forced `?pr=` redirects. + * A resolved Pages base URL takes precedence because private and Enterprise + * Pages sites are served from obfuscated domains that cannot be derived from + * the repository name. The PR number is appended only for PR reports so the + * general dashboard route can remain a team-level entry point without forced + * `?pr=` redirects. */ -function inferDashboardUrl(repoUrl, prNumber) { +function inferDashboardUrl(repoUrl, prNumber, pagesBaseUrl) { + const parsedBase = pagesBaseUrl ? parseUrlOrNull(pagesBaseUrl) : null; + if (parsedBase) { + const basePath = parsedBase.pathname.replace(/\/+$/, ""); + return appendPrNumber(`${parsedBase.origin}${basePath}/dashboard/`, prNumber); + } if (!repoUrl) return null; const normalized = repoUrl.replace(/\.git$/, ""); @@ -36439,8 +36455,10 @@ const CLI_JS_HOOK_COMMAND = "cli.js hook"; const constants_NOTES_REF = "agentnote"; /** Fully qualified git-notes ref name used for push/fetch operations. */ const NOTES_REF_FULL = `refs/notes/${constants_NOTES_REF}`; -/** Fetch refspec that syncs Agent Note notes into the local notes ref. */ -const NOTES_FETCH_REFSPEC = (/* unused pure expression or super */ null && (`+${NOTES_REF_FULL}:${NOTES_REF_FULL}`)); +/** Legacy exact refspec that fails while the remote notes ref does not exist. */ +const LEGACY_NOTES_FETCH_REFSPEC = (/* unused pure expression or super */ null && (`+${NOTES_REF_FULL}:${NOTES_REF_FULL}`)); +/** Pattern refspec that tolerates a missing remote notes ref. */ +const NOTES_FETCH_REFSPEC = (/* unused pure expression or super */ null && (`+${NOTES_REF_FULL}*:${NOTES_REF_FULL}*`)); // ─── Directory names ─── /** Directory under `.git/` where local Agent Note session state is stored. */ const AGENTNOTE_DIR = "agentnote"; @@ -37522,7 +37540,7 @@ async function collectReport(base, headRef = "HEAD", opts = {}) { const repoRoot = await git(["rev-parse", "--show-toplevel"]); const hasDashboardWorkflow = (0,external_node_fs_namespaceObject.existsSync)((0,external_node_path_namespaceObject.join)(repoRoot, ".github", "workflows", "agentnote-dashboard.yml")); const dashboardUrl = hasDashboardWorkflow - ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber) + ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber, opts.pagesBaseUrl) : null; return { base, @@ -37951,6 +37969,7 @@ const DEFAULT_BASE_BRANCH = "main"; const EVENT_PULL_REQUEST = "pull_request"; const GITHUB_PAGES_ENVIRONMENT = "github-pages"; const GITHUB_TOKEN_ENV = "GITHUB_TOKEN"; +const PAGES_BASE_URL_INPUT = "pages_base_url"; const JSON_INDENT_SPACES = 2; const MAX_NOTES_FETCH_ATTEMPTS = 3; const RETRY_DELAY_BASE_MS = 1000; @@ -38035,6 +38054,45 @@ async function postPrReport(outputMode, markdown) { } info("Agent Note report posted as PR comment."); } +/** + * Resolve the real GitHub Pages base URL for Dashboard links. + * + * Private and Enterprise Pages sites are served from obfuscated domains at the + * root path. The explicit input wins, then the Pages API, and callers fall + * back to the repository-name heuristic when neither is available. + */ +async function resolvePagesBaseUrl(token) { + const override = getInput(PAGES_BASE_URL_INPUT); + if (override) { + try { + new URL(override); + return override; + } + catch { + warning(`Ignoring invalid pages_base_url input: ${override}`); + } + } + if (!token) + return null; + try { + const octokit = getOctokit(token); + const { owner, repo } = github_context.repo; + const { data } = await octokit.request("GET /repos/{owner}/{repo}/pages", { + owner, + repo, + }); + const htmlUrl = data.html_url; + if (typeof htmlUrl === "string" && htmlUrl) { + new URL(htmlUrl); + return htmlUrl; + } + } + catch { + // Reading Pages metadata needs pages:read; without it the report keeps + // the repository-name heuristic instead of failing. + } + return null; +} /** * Explain delayed Dashboard previews caused by Pages environment protection. * @@ -38082,12 +38140,14 @@ async function run() { const prOutputMode = resolvePrOutputMode(getInput("pr_output")); const promptDetail = parsePromptDetail(getInput("prompt_detail")); const token = process.env[GITHUB_TOKEN_ENV] || ""; + const pagesBaseUrl = await resolvePagesBaseUrl(token); let report = null; for (let attempt = 1; attempt <= MAX_NOTES_FETCH_ATTEMPTS; attempt++) { // Fetch/retry covers the race between code push and refs/notes/agentnote push. fetchAgentnoteNotes(); report = await collectReport(base, headSha, { dashboardPrNumber: prNumber, + pagesBaseUrl, }); if (!report) { info("No agent-note data found for this PR."); diff --git a/packages/pr-report/src/github.ts b/packages/pr-report/src/github.ts index df952e6c..2f14d406 100644 --- a/packages/pr-report/src/github.ts +++ b/packages/pr-report/src/github.ts @@ -85,16 +85,34 @@ export function shouldRetryNotesFetch(report: { return (report.total_commits ?? 0) > 0 && (report.tracked_commits ?? 0) === 0; } +function parseUrlOrNull(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + /** * Infer the public Dashboard URL from a GitHub remote URL. * - * The PR number is appended only for PR reports so the general dashboard route - * can remain a team-level entry point without forced `?pr=` redirects. + * A resolved Pages base URL takes precedence because private and Enterprise + * Pages sites are served from obfuscated domains that cannot be derived from + * the repository name. The PR number is appended only for PR reports so the + * general dashboard route can remain a team-level entry point without forced + * `?pr=` redirects. */ export function inferDashboardUrl( repoUrl: string | null, prNumber?: number | string | null, + pagesBaseUrl?: string | null, ): string | null { + const parsedBase = pagesBaseUrl ? parseUrlOrNull(pagesBaseUrl) : null; + if (parsedBase) { + const basePath = parsedBase.pathname.replace(/\/+$/, ""); + return appendPrNumber(`${parsedBase.origin}${basePath}/dashboard/`, prNumber); + } + if (!repoUrl) return null; const normalized = repoUrl.replace(/\.git$/, ""); diff --git a/packages/pr-report/src/index.test.ts b/packages/pr-report/src/index.test.ts index d0139894..90c8cae8 100644 --- a/packages/pr-report/src/index.test.ts +++ b/packages/pr-report/src/index.test.ts @@ -172,6 +172,31 @@ describe("inferDashboardUrl", () => { it("returns null for non-GitHub remotes", () => { assert.equal(inferDashboardUrl("https://gitlab.com/example/project"), null); }); + + it("prefers a resolved Pages base URL served at the domain root", () => { + assert.equal( + inferDashboardUrl( + "https://github.com/acme/apps", + 48, + "https://improved-guacamole-abc123.pages.github.io/", + ), + "https://improved-guacamole-abc123.pages.github.io/dashboard/?pr=48", + ); + }); + + it("prefers a resolved Pages base URL with a project path", () => { + assert.equal( + inferDashboardUrl("https://github.com/acme/apps", null, "https://acme.github.io/apps/"), + "https://acme.github.io/apps/dashboard/", + ); + }); + + it("falls back to the repository heuristic for an invalid Pages base URL", () => { + assert.equal( + inferDashboardUrl("https://github.com/wasabeef/AgentNote", null, "not a url"), + "https://wasabeef.github.io/AgentNote/dashboard/", + ); + }); }); describe("hasDeploymentBranchProtection", () => { diff --git a/packages/pr-report/src/index.ts b/packages/pr-report/src/index.ts index 3664fba5..7b35e3fe 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -30,6 +30,7 @@ const DEFAULT_BASE_BRANCH = "main"; const EVENT_PULL_REQUEST = "pull_request"; const GITHUB_PAGES_ENVIRONMENT = "github-pages"; const GITHUB_TOKEN_ENV = "GITHUB_TOKEN"; +const PAGES_BASE_URL_INPUT = "pages_base_url"; const JSON_INDENT_SPACES = 2; const MAX_NOTES_FETCH_ATTEMPTS = 3; const RETRY_DELAY_BASE_MS = 1000; @@ -126,6 +127,46 @@ async function postPrReport( core.info("Agent Note report posted as PR comment."); } +/** + * Resolve the real GitHub Pages base URL for Dashboard links. + * + * Private and Enterprise Pages sites are served from obfuscated domains at the + * root path. The explicit input wins, then the Pages API, and callers fall + * back to the repository-name heuristic when neither is available. + */ +async function resolvePagesBaseUrl(token: string): Promise { + const override = core.getInput(PAGES_BASE_URL_INPUT); + if (override) { + try { + new URL(override); + return override; + } catch { + core.warning(`Ignoring invalid pages_base_url input: ${override}`); + } + } + + if (!token) return null; + + try { + const octokit = github.getOctokit(token); + const { owner, repo } = github.context.repo; + const { data } = await octokit.request("GET /repos/{owner}/{repo}/pages", { + owner, + repo, + }); + const htmlUrl = (data as { html_url?: string | null }).html_url; + if (typeof htmlUrl === "string" && htmlUrl) { + new URL(htmlUrl); + return htmlUrl; + } + } catch { + // Reading Pages metadata needs pages:read; without it the report keeps + // the repository-name heuristic instead of failing. + } + + return null; +} + /** * Explain delayed Dashboard previews caused by Pages environment protection. * @@ -182,6 +223,7 @@ async function run(): Promise { const prOutputMode = resolvePrOutputMode(core.getInput("pr_output")); const promptDetail = parsePromptDetail(core.getInput("prompt_detail")); const token = process.env[GITHUB_TOKEN_ENV] || ""; + const pagesBaseUrl = await resolvePagesBaseUrl(token); let report: Awaited> = null; @@ -191,6 +233,7 @@ async function run(): Promise { report = await collectReport(base, headSha, { dashboardPrNumber: prNumber, + pagesBaseUrl, }); if (!report) { core.info("No agent-note data found for this PR."); diff --git a/packages/pr-report/src/report.ts b/packages/pr-report/src/report.ts index 806094e9..de4d4b0a 100644 --- a/packages/pr-report/src/report.ts +++ b/packages/pr-report/src/report.ts @@ -266,6 +266,7 @@ export interface RenderMarkdownOptions { /** Collection options supplied by the GitHub Action wrapper. */ export interface CollectReportOptions { dashboardPrNumber?: number | string | null; + pagesBaseUrl?: string | null; } /** Collect commits, git notes, AI ratio, and dashboard links for one PR range. */ @@ -398,7 +399,7 @@ export async function collectReport( join(repoRoot, ".github", "workflows", "agentnote-dashboard.yml"), ); const dashboardUrl = hasDashboardWorkflow - ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber) + ? inferDashboardUrl(repoUrl, opts.dashboardPrNumber, opts.pagesBaseUrl) : null; return { From 048e00d6ad9a7b8daf76102cb393ad2f80ed7d5a Mon Sep 17 00:00:00 2001 From: wasabeef Date: Thu, 9 Jul 2026 00:48:33 +0900 Subject: [PATCH 2/3] fix(action): normalize resolved Pages URLs before writing step outputs Re-serialize pages_base_url values through the URL parser so control characters cannot corrupt the GITHUB_OUTPUT file, and document the new input in the README. Release note: skip Agentnote-Session: 4a6d322d-5afe-45e3-a11b-cf15d0e740a9 --- README.md | 9 +++++++++ packages/dashboard/workflow/resolve-pages-target.mjs | 11 ++++++----- .../dashboard/workflow/resolve-pages-target.test.mjs | 12 ++++++++++++ packages/pr-report/dist/index.js | 7 +++---- packages/pr-report/src/index.ts | 7 +++---- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 54301afb..9265423f 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,15 @@ Dashboard Mode uses the same action with `dashboard: true`: prompt_detail: compact ``` +The action resolves the published GitHub Pages URL automatically, including private and Enterprise Pages sites served from `*.pages.github.io` domains. Set `pages_base_url` only when the resolved URL needs an explicit override, for example behind a custom proxy: + +```yaml +- uses: wasabeef/AgentNote@v1 + with: + dashboard: true + pages_base_url: https://pages.example.com/apps/ +``` + ### Dashboard Data For most repositories, you do not need to hand-write the workflow. Generate it with `init`: diff --git a/packages/dashboard/workflow/resolve-pages-target.mjs b/packages/dashboard/workflow/resolve-pages-target.mjs index d0e7fd33..4c492a40 100644 --- a/packages/dashboard/workflow/resolve-pages-target.mjs +++ b/packages/dashboard/workflow/resolve-pages-target.mjs @@ -75,10 +75,11 @@ export async function resolvePagesBaseUrl({ apiUrl = process.env[ENV_GITHUB_API_URL] || DEFAULT_GITHUB_API_URL, fetcher = fetch, } = {}) { - if (override) { - if (parseUrlOrNull(override)) return override; - console.log(`Ignoring invalid pages_base_url override: ${override}`); - } + // Re-serializing through URL both validates the value and strips characters + // that would corrupt the GITHUB_OUTPUT file, such as newlines. + const parsedOverride = override ? parseUrlOrNull(override) : null; + if (parsedOverride) return parsedOverride.toString(); + if (override) console.log(`Ignoring invalid pages_base_url override: ${override}`); if (!repository || !token) return null; @@ -93,7 +94,7 @@ export async function resolvePagesBaseUrl({ if (!response.ok) return null; const pages = await response.json(); const htmlUrl = typeof pages?.html_url === "string" ? pages.html_url : ""; - return htmlUrl && parseUrlOrNull(htmlUrl) ? htmlUrl : null; + return htmlUrl ? (parseUrlOrNull(htmlUrl)?.toString() ?? null) : null; } catch { // Pages metadata is an enhancement; resolution failures fall back to the heuristic. return null; diff --git a/packages/dashboard/workflow/resolve-pages-target.test.mjs b/packages/dashboard/workflow/resolve-pages-target.test.mjs index 2355ddbe..93644a1e 100644 --- a/packages/dashboard/workflow/resolve-pages-target.test.mjs +++ b/packages/dashboard/workflow/resolve-pages-target.test.mjs @@ -348,6 +348,18 @@ test("resolvePagesBaseUrl prefers a valid explicit override", async () => { assert.equal(result, "https://pages.example.com/site/"); }); +test("resolvePagesBaseUrl re-serializes overrides so GITHUB_OUTPUT stays single-line", async () => { + const result = await resolvePagesBaseUrl({ + override: "https://pages.example.com/site/\ninjected_output=oops", + repository: "acme/apps", + token: "token", + fetcher: () => { + throw new Error("fetch must not run when the override is valid"); + }, + }); + assert.equal(result, "https://pages.example.com/site/injected_output=oops"); +}); + test("resolvePagesBaseUrl resolves html_url from the Pages API", async () => { const requests = []; const result = await resolvePagesBaseUrl({ diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index e82f1c8f..6310f8f2 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -38064,9 +38064,9 @@ async function postPrReport(outputMode, markdown) { async function resolvePagesBaseUrl(token) { const override = getInput(PAGES_BASE_URL_INPUT); if (override) { + // Re-serializing through URL both validates and normalizes the value. try { - new URL(override); - return override; + return new URL(override).toString(); } catch { warning(`Ignoring invalid pages_base_url input: ${override}`); @@ -38083,8 +38083,7 @@ async function resolvePagesBaseUrl(token) { }); const htmlUrl = data.html_url; if (typeof htmlUrl === "string" && htmlUrl) { - new URL(htmlUrl); - return htmlUrl; + return new URL(htmlUrl).toString(); } } catch { diff --git a/packages/pr-report/src/index.ts b/packages/pr-report/src/index.ts index 7b35e3fe..bcbc73d4 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -137,9 +137,9 @@ async function postPrReport( async function resolvePagesBaseUrl(token: string): Promise { const override = core.getInput(PAGES_BASE_URL_INPUT); if (override) { + // Re-serializing through URL both validates and normalizes the value. try { - new URL(override); - return override; + return new URL(override).toString(); } catch { core.warning(`Ignoring invalid pages_base_url input: ${override}`); } @@ -156,8 +156,7 @@ async function resolvePagesBaseUrl(token: string): Promise { }); const htmlUrl = (data as { html_url?: string | null }).html_url; if (typeof htmlUrl === "string" && htmlUrl) { - new URL(htmlUrl); - return htmlUrl; + return new URL(htmlUrl).toString(); } } catch { // Reading Pages metadata needs pages:read; without it the report keeps From 8c70e3b34e7defd29d5a51b98cb735743e405391 Mon Sep 17 00:00:00 2001 From: wasabeef Date: Thu, 9 Jul 2026 00:53:51 +0900 Subject: [PATCH 3/3] fix(action): bound the PR report Pages API lookup to 10 seconds Octokit requests have no default timeout, so a hung Pages API call could block the PR report until the job timeout. Pass an abort signal matching the dashboard script's bound. Addresses CodeRabbit review feedback on PR #87. Release note: skip Agentnote-Session: 4a6d322d-5afe-45e3-a11b-cf15d0e740a9 --- packages/pr-report/dist/index.js | 4 ++++ packages/pr-report/src/index.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/pr-report/dist/index.js b/packages/pr-report/dist/index.js index 6310f8f2..f59f38e8 100644 --- a/packages/pr-report/dist/index.js +++ b/packages/pr-report/dist/index.js @@ -37969,6 +37969,7 @@ const DEFAULT_BASE_BRANCH = "main"; const EVENT_PULL_REQUEST = "pull_request"; const GITHUB_PAGES_ENVIRONMENT = "github-pages"; const GITHUB_TOKEN_ENV = "GITHUB_TOKEN"; +const PAGES_API_TIMEOUT_MS = 10_000; const PAGES_BASE_URL_INPUT = "pages_base_url"; const JSON_INDENT_SPACES = 2; const MAX_NOTES_FETCH_ATTEMPTS = 3; @@ -38080,6 +38081,9 @@ async function resolvePagesBaseUrl(token) { const { data } = await octokit.request("GET /repos/{owner}/{repo}/pages", { owner, repo, + request: { + signal: AbortSignal.timeout(PAGES_API_TIMEOUT_MS), + }, }); const htmlUrl = data.html_url; if (typeof htmlUrl === "string" && htmlUrl) { diff --git a/packages/pr-report/src/index.ts b/packages/pr-report/src/index.ts index bcbc73d4..6b287f94 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -30,6 +30,7 @@ const DEFAULT_BASE_BRANCH = "main"; const EVENT_PULL_REQUEST = "pull_request"; const GITHUB_PAGES_ENVIRONMENT = "github-pages"; const GITHUB_TOKEN_ENV = "GITHUB_TOKEN"; +const PAGES_API_TIMEOUT_MS = 10_000; const PAGES_BASE_URL_INPUT = "pages_base_url"; const JSON_INDENT_SPACES = 2; const MAX_NOTES_FETCH_ATTEMPTS = 3; @@ -153,6 +154,9 @@ async function resolvePagesBaseUrl(token: string): Promise { const { data } = await octokit.request("GET /repos/{owner}/{repo}/pages", { owner, repo, + request: { + signal: AbortSignal.timeout(PAGES_API_TIMEOUT_MS), + }, }); const htmlUrl = (data as { html_url?: string | null }).html_url; if (typeof htmlUrl === "string" && htmlUrl) {