diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48926665..a6d72329 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: node-version: 22 cache: npm - run: npm ci --prefix packages/dashboard + - run: npm test --prefix packages/dashboard - run: npm run dashboard:build - name: Smoke test dashboard workflow build run: | diff --git a/packages/dashboard/workflow/persist-notes.mjs b/packages/dashboard/workflow/persist-notes.mjs index cd20925f..4cc571c1 100644 --- a/packages/dashboard/workflow/persist-notes.mjs +++ b/packages/dashboard/workflow/persist-notes.mjs @@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url"; const DEFAULT_DASHBOARD_NOTES_DIR = ".agentnote-dashboard-notes"; const DASHBOARD_DIR_NAME = "dashboard"; const ENV_EVENT_NAME = "EVENT_NAME"; +const ENV_GITHUB_WORKSPACE = "GITHUB_WORKSPACE"; const ENV_NOTES_DIR = "NOTES_DIR"; const ENV_PR_NUMBER = "PR_NUMBER"; const EVENT_PULL_REQUEST = "pull_request"; @@ -19,6 +20,7 @@ const NOTES_DIR_NAME = "notes"; const PERSIST_COMMIT_MESSAGE = "chore: update Dashboard notes"; const PERSIST_TEMP_DIR_PREFIX = "agentnote-dashboard-persist-"; const TEXT_ENCODING = "utf-8"; +const workspace = process.env[ENV_GITHUB_WORKSPACE] || process.cwd(); const notesDir = process.env[ENV_NOTES_DIR] || DEFAULT_DASHBOARD_NOTES_DIR; const eventName = process.env[ENV_EVENT_NAME] || ""; const prNumber = Number(process.env[ENV_PR_NUMBER] || ""); @@ -152,7 +154,7 @@ export function mergeDashboardNotes(snapshotDir, dashboardNotesDir, options = {} function git(args, options = {}) { return execFileSync("git", args, { - cwd: options.cwd, + cwd: options.cwd ?? workspace, stdio: options.stdio || "pipe", encoding: TEXT_ENCODING, }); diff --git a/packages/dashboard/workflow/restore-notes.mjs b/packages/dashboard/workflow/restore-notes.mjs index f3977aed..f1c74add 100644 --- a/packages/dashboard/workflow/restore-notes.mjs +++ b/packages/dashboard/workflow/restore-notes.mjs @@ -6,12 +6,14 @@ import { pathToFileURL } from "node:url"; const DASHBOARD_DIR_NAME = "dashboard"; const DEFAULT_DASHBOARD_NOTES_DIR = ".agentnote-dashboard-notes"; +const ENV_GITHUB_WORKSPACE = "GITHUB_WORKSPACE"; const ENV_NOTES_DIR = "NOTES_DIR"; const FETCH_HEAD_REF = "FETCH_HEAD"; const GITHUB_PAGES_BRANCH = "gh-pages"; const NOTES_DIR_NAME = "notes"; const RESTORE_TEMP_DIR_PREFIX = "agentnote-dashboard-restore-"; const TEXT_ENCODING = "utf-8"; +const workspace = process.env[ENV_GITHUB_WORKSPACE] || process.cwd(); const notesDir = process.env[ENV_NOTES_DIR] || DEFAULT_DASHBOARD_NOTES_DIR; function copyDirectoryContents(sourceDir, targetDir) { @@ -41,7 +43,7 @@ export function restoreDashboardNotes(sourceNotesDir, targetNotesDir) { function git(args, options = {}) { return execFileSync("git", args, { - cwd: options.cwd, + cwd: options.cwd ?? workspace, stdio: options.stdio || "pipe", encoding: TEXT_ENCODING, }); diff --git a/packages/dashboard/workflow/sync-notes.mjs b/packages/dashboard/workflow/sync-notes.mjs index eb21859f..da2701be 100644 --- a/packages/dashboard/workflow/sync-notes.mjs +++ b/packages/dashboard/workflow/sync-notes.mjs @@ -15,6 +15,7 @@ const ENV_DEFAULT_BRANCH = "DEFAULT_BRANCH"; const ENV_EVENT_NAME = "EVENT_NAME"; const ENV_GITHUB_OUTPUT = "GITHUB_OUTPUT"; const ENV_GITHUB_REPOSITORY = "GITHUB_REPOSITORY"; +const ENV_GITHUB_WORKSPACE = "GITHUB_WORKSPACE"; const ENV_HEAD_SHA = "HEAD_SHA"; const ENV_NOTES_DIR = "NOTES_DIR"; const ENV_PR_HEAD_REPO = "PR_HEAD_REPO"; @@ -29,6 +30,7 @@ const PR_STATE_OPEN = "open"; const TEXT_ENCODING = "utf-8"; const UNKNOWN_DIFF_PATH = "(unknown)"; const ZERO_SHA_PATTERN = /^0+$/; +const workspace = process.env[ENV_GITHUB_WORKSPACE] || process.cwd(); const notesDir = process.env[ENV_NOTES_DIR] || DEFAULT_DASHBOARD_NOTES_DIR; const eventName = process.env[ENV_EVENT_NAME] || ""; const before = process.env[ENV_BEFORE_SHA] || ""; @@ -46,6 +48,7 @@ export const MAX_DIFF_TOTAL_LINES = 3000; function run(command, args) { return execFileSync(command, args, { + cwd: workspace, encoding: TEXT_ENCODING, stdio: ["pipe", "pipe", "pipe"], env: process.env, @@ -364,10 +367,7 @@ function main() { mkdirSync(notesDir, { recursive: true }); try { - execFileSync("git", ["fetch", "origin", `${AGENTNOTE_NOTES_REF}:${AGENTNOTE_NOTES_REF}`], { - stdio: "pipe", - encoding: TEXT_ENCODING, - }); + run("git", ["fetch", "origin", `${AGENTNOTE_NOTES_REF}:${AGENTNOTE_NOTES_REF}`]); } catch { // A repository may not have Agent Note git notes before the first recorded commit. } diff --git a/packages/dashboard/workflow/sync-notes.test.mjs b/packages/dashboard/workflow/sync-notes.test.mjs index 650d092b..2af519ac 100644 --- a/packages/dashboard/workflow/sync-notes.test.mjs +++ b/packages/dashboard/workflow/sync-notes.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -9,8 +11,9 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { mergeDashboardNotes, pruneDashboardNotes } from "./persist-notes.mjs"; import { restoreDashboardNotes } from "./restore-notes.mjs"; import { @@ -186,6 +189,106 @@ test("restoreDashboardNotes replaces local notes with the persisted gh-pages not } }); +test("Dashboard note workflows run git in GITHUB_WORKSPACE from an external action path", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agentnote-dashboard-external-action-test-")); + const workspaceDir = join(tempDir, "workspace"); + const actionDir = join(tempDir, "action-package"); + const binDir = join(tempDir, "bin"); + const remoteDir = join(tempDir, "remote.git"); + const notesDir = join(workspaceDir, ".agentnote-dashboard-notes"); + const fakeGhPath = join(binDir, "gh"); + const persistScript = fileURLToPath(new URL("./persist-notes.mjs", import.meta.url)); + const restoreScript = fileURLToPath(new URL("./restore-notes.mjs", import.meta.url)); + const syncScript = fileURLToPath(new URL("./sync-notes.mjs", import.meta.url)); + + try { + mkdirSync(workspaceDir, { recursive: true }); + mkdirSync(actionDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + mkdirSync(notesDir, { recursive: true }); + writeFileSync(fakeGhPath, "#!/bin/sh\nprintf '[]\\n'\n"); + chmodSync(fakeGhPath, 0o755); + execFileSync("git", ["init", "--bare", remoteDir]); + execFileSync("git", ["init"], { cwd: workspaceDir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: workspaceDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: workspaceDir }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: workspaceDir }); + execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: workspaceDir }); + + const sha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: workspaceDir, + encoding: "utf-8", + }).trim(); + const shortSha = sha.slice(0, 7); + const dashboardNotePath = join(notesDir, `${shortSha}.json`); + const persistedNotePath = `gh-pages:dashboard/notes/${shortSha}.json`; + const baseEnv = { + ...process.env, + GITHUB_WORKSPACE: workspaceDir, + NOTES_DIR: notesDir, + PATH: `${binDir}${delimiter}${process.env.PATH || ""}`, + }; + const runWorkflow = (script, env = {}) => { + execFileSync(process.execPath, [script], { + cwd: actionDir, + env: { ...baseEnv, ...env }, + stdio: "pipe", + }); + }; + const writeAgentNote = (model) => { + const note = JSON.stringify({ + v: 1, + commit: { sha, short_sha: shortSha }, + attribution: { ai_ratio: 100, method: "file" }, + files: [], + interactions: [], + model, + }); + execFileSync("git", ["notes", "--ref=agentnote", "add", "-f", "-m", note, "HEAD"], { + cwd: workspaceDir, + stdio: "pipe", + }); + execFileSync("git", ["push", "--force", "--no-verify", "origin", "refs/notes/agentnote"], { + cwd: workspaceDir, + stdio: "pipe", + }); + }; + const runSync = () => { + runWorkflow(syncScript, { + BEFORE_SHA: "0".repeat(40), + DEFAULT_BRANCH: "main", + EVENT_NAME: "push", + GITHUB_REPOSITORY: "example/repository", + HEAD_SHA: sha, + REF_NAME: "main", + }); + }; + const readPersistedModel = () => { + const note = execFileSync("git", ["--git-dir", remoteDir, "show", persistedNotePath], { + encoding: "utf-8", + }); + return JSON.parse(note).model; + }; + + writeAgentNote("first"); + runSync(); + assert.equal(JSON.parse(readFileSync(dashboardNotePath, "utf-8")).model, "first"); + runWorkflow(persistScript, { EVENT_NAME: "push" }); + assert.equal(readPersistedModel(), "first"); + + rmSync(notesDir, { recursive: true, force: true }); + runWorkflow(restoreScript); + assert.equal(JSON.parse(readFileSync(dashboardNotePath, "utf-8")).model, "first"); + + writeAgentNote("updated"); + runSync(); + runWorkflow(persistScript, { EVENT_NAME: "push" }); + assert.equal(readPersistedModel(), "updated"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + function writeNote(path, prNumber, shortSha, date = "2026-04-01T00:00:00Z") { writeFileSync( path,