diff --git a/README.md b/README.md index 54301af..9265423 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/action.yml b/action.yml index 268ce98..5ffdc06 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 8bb618b..86b1ec3 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 ef98da8..0d0a8a2 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 f20463a..4c492a4 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,78 @@ 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, +} = {}) { + // 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; + + 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)?.toString() ?? null) : 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 +380,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 +390,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 +417,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 cc04d96..93644a1 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,139 @@ 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 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({ + 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 dc76e64..f59f38e 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,8 @@ 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; const RETRY_DELAY_BASE_MS = 1000; @@ -38035,6 +38055,47 @@ 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) { + // Re-serializing through URL both validates and normalizes the value. + try { + return new URL(override).toString(); + } + 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, + request: { + signal: AbortSignal.timeout(PAGES_API_TIMEOUT_MS), + }, + }); + const htmlUrl = data.html_url; + if (typeof htmlUrl === "string" && htmlUrl) { + return new URL(htmlUrl).toString(); + } + } + 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 +38143,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 df952e6..2f14d40 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 d013989..90c8cae 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 3664fba..6b287f9 100644 --- a/packages/pr-report/src/index.ts +++ b/packages/pr-report/src/index.ts @@ -30,6 +30,8 @@ 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; const RETRY_DELAY_BASE_MS = 1000; @@ -126,6 +128,48 @@ 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) { + // Re-serializing through URL both validates and normalizes the value. + try { + return new URL(override).toString(); + } 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, + request: { + signal: AbortSignal.timeout(PAGES_API_TIMEOUT_MS), + }, + }); + const htmlUrl = (data as { html_url?: string | null }).html_url; + if (typeof htmlUrl === "string" && htmlUrl) { + return new URL(htmlUrl).toString(); + } + } 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 +226,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 +236,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 806094e..de4d4b0 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 {