Skip to content

Detect missing gh aw in dashboard and surface install guidance - #42270

Merged
pelikhan merged 7 commits into
mainfrom
copilot/update-github-extensions-dashboard
Jun 29, 2026
Merged

Detect missing gh aw in dashboard and surface install guidance#42270
pelikhan merged 7 commits into
mainfrom
copilot/update-github-extensions-dashboard

Conversation

Copilot AI commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

The dashboard currently assumes gh aw is available, which leaves the canvas in a broken state when the extension is not installed. This change adds explicit CLI availability detection, shows install guidance when missing, and displays the detected version when available.

  • CLI detection

    • Detects whether the dashboard can use a local dev binary or an installed gh aw GitHub CLI extension.
    • Resolves CLI status up front via version before loading workflow data.
    • Distinguishes:
      • local repo binary
      • installed gh aw extension
      • missing extension / unavailable CLI
  • Dashboard UX

    • Shows a warning banner with the install command when gh aw is not installed.
    • Shows the detected gh aw version and source in the header when available.
    • Avoids loading runs/usage/experiments when the CLI is unavailable, so the canvas fails closed instead of surfacing noisy downstream errors.
  • Command execution

    • Runs all dashboard CLI invocations with CI=1 to suppress interactive prompts.
    • Keeps command output routing unchanged, but seeds the command panel with version/install-state output so the dashboard explains itself immediately.
  • Code shape

    • Extends the CLI runner with status probing and normalized version extraction from command output.
    • Adds a dedicated /api/cli-status endpoint and corresponding frontend state/model updates.
    • Adds focused tests around status detection and missing-extension handling.

Example of the new detection flow:

const status = await fetch("/api/cli-status").then(r => r.json());

if (!status.available) {
  // show banner: gh extension install github/gh-aw
} else {
  // show: gh aw <version> · <source>
}

Copilot AI and others added 5 commits June 29, 2026 15:03
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title Add gh aw install detection to dashboard Detect missing gh aw in dashboard and surface install guidance Jun 29, 2026
Copilot AI requested a review from pelikhan June 29, 2026 15:13
@pelikhan
pelikhan marked this pull request as ready for review June 29, 2026 15:16
Copilot AI review requested due to automatic review settings June 29, 2026 15:16
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR does not have the 'implementation' label and has ≤100 new lines of code in business logic directories (0 additions detected in src/, lib/, pkg/, internal/, app/, core/, domain/, services/, api/).

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the agentic-workflows dashboard canvas by proactively detecting whether gh aw is available (dev binary vs installed GitHub CLI extension vs missing) and surfacing clear install/version status in the UI, preventing downstream dashboard calls when the CLI is unavailable.

Changes:

  • Add server-side gh aw availability probing (version) and expose it via a new /api/cli-status endpoint.
  • Update the dashboard UI/state to fetch CLI status up front, show install guidance when missing, and show the detected version/source when available.
  • Add Vitest coverage for CLI status detection and missing-extension handling.
Show a summary per file
File Description
.github/extensions/agentic-workflows-dashboard/web/styles.css Adds styling to keep install-command text from wrapping in the new banner.
.github/extensions/agentic-workflows-dashboard/web/index.html Surfaces CLI status in the header and adds an install guidance banner + quick “gh aw version” command.
.github/extensions/agentic-workflows-dashboard/web/app.js Updates compiled frontend runtime to fetch CLI status before loading data and seed the command panel.
.github/extensions/agentic-workflows-dashboard/test/dashboard-cli.test.ts Adds tests for version detection, env seeding (CI=1), and missing-extension behavior.
.github/extensions/agentic-workflows-dashboard/src/models.ts Aligns frontend models to CLI JSON payloads and introduces CLIStatus/usage item interfaces.
.github/extensions/agentic-workflows-dashboard/src/app.ts Updates TypeScript source for frontend state machine to include CLI probing and gated fetches.
.github/extensions/agentic-workflows-dashboard/extension.mjs Adds /api/cli-status route and wires the new runner with status probing.
.github/extensions/agentic-workflows-dashboard/dashboard-cli.mjs Extends the CLI runner to detect dev binary vs extension, normalize version parsing, and provide install guidance.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment on lines +236 to +238
this.commandInput = this.cliStatus?.command ?? "gh aw version";
this.commandOutput = this.cliStatus?.message ?? "gh aw is not installed.";
},
Comment on lines +348 to +358
this.definitions = [];
this.runs = [];
this.usage = [];
this.experiments = [];
this.loadDefinitionPage(1);
this.loadRunPage(1);
this.loadUsagePage(1);
this.loadExperimentPage(1);
if (!this.commandOutput) {
this.runCommand();
}
},

setActiveTab(tab) {
if (this.tabs.some(item => item.id === tab)) {
this.activeTab = tab;
}
},

isActiveTab(tab) {
return this.activeTab === tab;
},

tabCount(tab) {
if (tab.counter === "definitions") {
return this.definitions.length;
}
if (tab.counter === "runs") {
return this.runs.length;
}
if (tab.counter === "experiments") {
return this.experiments.length;
}
return 0;
},

loadDefinitionPage(page) {
this.definitionPage = page;
this.definitionsPaged = paginate(this.definitions, page, this.pageSize);
},

loadRunPage(page) {
this.runPage = page;
this.runsPaged = paginate(this.runs, page, this.pageSize);
if (!this.selectedRun && this.runsPaged.items.length > 0) {
this.selectedRun = this.runsPaged.items[0] ?? null;
}
},

loadExperimentPage(page) {
this.experimentPage = page;
this.experimentsPaged = paginate(this.experiments, page, this.pageSize);
},

selectRun(id) {
this.selectedRun = this.runs.find(run => run.id === id) ?? null;
},

viewRunDetails(id) {
this.selectRun(id);
this.setActiveTab("details");
},

dispatchSelectedWorkflow() {
const definition = this.definitions.find(item => item.id === this.selectedDefinitionId);
if (!definition) {
this.flashKind = "error";
this.flashMessage = "Select a workflow definition before dispatching.";
return;
}

const sequence = this.runs.length + 1;
const now = new Date().toISOString();
const newRun: WorkflowRun = {
id: `run-${String(sequence).padStart(5, "0")}`,
definitionId: definition.id,
status: "queued",
createdAt: now,
updatedAt: now,
steps: [1, 2, 3, 4].map(step => buildStep(sequence, step, "pending")),
};

this.runs = [newRun, ...this.runs];
this.loadRunPage(1);
this.viewRunDetails(newRun.id);

this.flashKind = "success";
this.flashMessage = `Dispatched ${definition.name} as ${newRun.id}.`;
},

runCommand() {
const result = runGhCommand(this.commandInput, this.runs);
this.commandOutput = `$ ${result.command}\n${result.output}`;
},

commandQuickFill(value) {
this.commandInput = value;
this.runCommand();
},

auditDiffQuickFill() {
const selectedId = this.selectedRun?.id;
if (!selectedId) {
return "gh aw audit-diff run-00001 run-00002";
}

const firstRunId = this.runs[0]?.id ?? "run-00001";
const secondRunId = this.runs[1]?.id ?? "run-00002";
const compareId = selectedId === firstRunId ? secondRunId : firstRunId;
return `gh aw audit-diff ${selectedId} ${compareId}`;
},

renderMarkdown(markdown) {
return renderSafeMarkdown(markdown);
},

formatDate,
runStatusClass: statusClass,
stepStatusClass,
}));
});
this.commandInput = this.cliStatus?.command ?? "gh aw version";
this.commandOutput = this.cliStatus?.message ?? "gh aw is not installed.";
}
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 85/100 — Excellent

Analyzed 2 test(s) across 1 new TypeScript test file: 2 design, 0 implementation, 0 guideline violation(s).

📊 Metrics & Test Classification (2 tests analyzed)
Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (50%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
"detects gh aw version from the extension and sets CI=1" test/dashboard-cli.test.ts:6 ✅ Design
"returns install instructions when the gh aw extension is missing" test/dashboard-cli.test.ts:31 ✅ Design

Go: 0 (*_test.go); JavaScript/TypeScript: 2 (*.test.ts via vitest). No other languages detected.

Test inflation check: Test file adds 51 lines; dashboard-cli.mjs adds 104 lines; ratio 0.49:1 (threshold: 2:1). ✅ No inflation.

Verdict

Check passed. 0% implementation tests (threshold: 30%). Both tests verify observable behavioral contracts: the version-detection output shape when gh aw is present, and the install-guidance response when the extension is absent. execFileFn mocking is scoped to external process execution (legitimate I/O mock). The happy-path test also asserts CI=1 environment propagation, a meaningful behavioral invariant called out in the PR description.

🧪 Test quality analysis by Test Quality Sentinel · 51.9 AIC · ⌖ 22.5 AIC · ⊞ 8.1K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 85/100. Test quality is acceptable — 0% of new tests are implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Detect missing gh aw in dashboard

Good overall approach — the PR correctly gates all data loading behind a CLI availability check, and createGhAwRunnerWithStatus is a clean extension of the existing runner pattern. The install-guidance UX is clear and the tests confirm the two key detection paths.

4 inline suggestions (3 minor / 1 non-blocking):

# File Severity Topic
1 dashboard-cli.mjs:40 ⚠️ Moderate isMissingGhAwExtension may miss backtick error format
2 test/dashboard-cli.test.ts:11 🔵 Minor Version detection test uses stderr, not stdout
3 web/index.html:22-23 🔵 Minor Stale version label + loading indicator show simultaneously on refresh
4 dashboard-cli.mjs:73 🔵 Minor findDevBinary called N+1 times per dashboard load
What looks good
  • Fails closed correctlyinit() skips all data fetches when cliStatus?.available is false; noisy downstream errors are suppressed.
  • Install bannerx-show="cliStatus && !cliStatus.available" guards correctly against the null loading state.
  • CI=1 injection — Suppressing interactive prompts is the right call for a headless canvas context.
  • Dependency injection (accessFn, execFileFn) — makes the unit tests clean and avoids filesystem/process coupling.
  • Alpine.start() migration — moving Alpine.data + Alpine.start() to the module level matches the recommended Alpine 3.x ESM pattern.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 74.6 AIC · ⌖ 7.22 AIC · ⊞ 4.9K


function isMissingGhAwExtension(error) {
const output = String(error?.output ?? error?.stderr ?? error?.message ?? "");
return /extension not found:\s*aw/i.test(output) || /unknown command ["']aw["'] for ["']gh["']/i.test(output);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backtick-quoted errors may slip past isMissingGhAwExtension.

The gh CLI can use backtick notation in some versions:

unknown command `aw` for `gh`

The current regex only matches " and ', so this variant falls through to the generic source: "error" path. The dashboard still shows available: false and the install banner, but the message shows raw error text instead of the friendly guidance.

Consider adding backtick alternatives:

return /extension not found:\s*aw/i.test(output)
  || /unknown command ["'\`]aw["'\`] for ["'\`]gh["'\`]/i.test(output);

@copilot please address this.

expect(bin).toBe("gh");
expect(args).toEqual(["aw", "version"]);
expect(options.env.CI).toBe("1");
callback(null, "", "gh aw version v1.2.3\n");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test uses stderr for version output, but gh aw version writes to stdout.

The mock passes the version string as the third argument (stderr):

callback(null, "", "gh aw version v1.2.3\n");
//             ^^  ^^^^^^^^^^^^^^^^^^^^^
//           stdout          stderr

Since combineIO: true merges both streams, this works today. But it means the primary code path (version on stdout) is untested. If a future refactor changes how stdout is handled, this test would still pass while the production path breaks.

Consider adding a second assertion or a separate test that puts the version string in stdout (second argument) instead, matching the real gh aw version behavior.

@copilot please address this.

<div class="d-flex gap-2 flex-items-center">
<span class="Label Label--accent">Copilot Canvas</span>
<span class="Label Label--success" x-show="cliStatus && cliStatus.available">gh aw <span x-text="cliStatus?.version"></span> · <span x-text="cliSourceLabel(cliStatus)"></span></span>
<span class="Label Label--secondary" x-show="loadingCliStatus">Checking gh aw…</span>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version label and loading indicator both visible during refresh().

On refresh, fetchCliStatus() sets loadingCliStatus = true but doesn't clear cliStatus. This means both:

  • the green version label (x-show="cliStatus && cliStatus.available") — still shows the previous value
  • the grey loading indicator (x-show="loadingCliStatus") — now true

...render simultaneously in the header until the new status resolves.

Simple fix — clear cliStatus at the start of fetchCliStatus():

async fetchCliStatus() {
  this.loadingCliStatus = true;
  this.cliStatus = null;   // hide stale version label while re-probing
  ...
}

@copilot please address this.

const runGhAw = createGhAwRunner(options);
const getStatus = async () => {
const cwd = options.getWorkspacePath();
const devBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findDevBinary is called independently in getStatus() and inside every runGhAw() call.

createGhAwRunnerWithStatus calls createGhAwRunner(options) (which will call findDevBinary on each runGhAw invocation) and calls findDevBinary again inside getStatus(). So the filesystem stat is repeated N+1 times per dashboard load.

Since the workspace path doesn't change between getStatus() and command execution, the devBin result can be cached:

export function createGhAwRunnerWithStatus(options) {
  let cachedDevBin;  // resolved once, reused
  const resolveDevBin = async () => {
    if (cachedDevBin === undefined) {
      cachedDevBin = await findDevBinary(...) ?? null;
    }
    return cachedDevBin;
  };
  // pass resolveDevBin into both getStatus and runGhAw
}

Not blocking, but reduces redundant I/O on every command dispatch.

@github-actions

Copy link
Copy Markdown
Contributor

Skills-Based Review Summary 🧠

Applied /tdd, /zoom-out, and /grill-with-docs to this PR. The feature direction is solid — fail-closed, good DI patterns, actionable install guidance — but I've requested changes on test coverage and a couple of structural points.

📋 7 inline comments — overview
# Skill File Issue
1 /tdd test/dashboard-cli.test.ts:51 Missing tests for dev-binary and source:error paths
2 /zoom-out dashboard-cli.mjs:73 findDevBinary called twice per operation
3 /grill-with-docs src/models.ts:55 CLIStatus.source typed as string instead of a discriminated union
4 /zoom-out src/app.ts:340 refresh() silently ignores /api/refresh HTTP errors
5 /zoom-out dashboard-cli.mjs:126 getStatus attached as a property on a function value
6 /tdd dashboard-cli.mjs:38 isMissingGhAwExtension has no isolated unit tests
7 /tdd dashboard-cli.mjs:31 parseVersionFromOutput has no isolated unit tests

Blocking: items 1 (missing dev-binary test) and 3 (source weak typing) are the most important. Items 2, 4, 5, 6, 7 are improvements I'd like to see but are less critical.

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 91.6 AIC · ⌖ 8.26 AIC · ⊞ 6.6K ·
Comment /matt to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd, /zoom-out, and /grill-with-docs — requesting changes primarily on test coverage gaps and two architectural points.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gaps: The dev-binary path (primary path for local development) and the generic error path each have zero tests. The two parsing/detection helpers (parseVersionFromOutput, isMissingGhAwExtension) also lack isolated unit tests.
  • findDevBinary called twice: getStatus() and the inner runner both call findDevBinary independently on each operation, causing redundant filesystem access.
  • Weak typing on CLIStatus.source: string loses the discriminated union the rest of the code already assumes.
  • refresh() drops the HTTP response: A non-OK response from /api/refresh is silently ignored.
  • getStatus attached on a function value: Mixes callable and object semantics; a plain return object would be cleaner.

Positive Highlights

  • ✅ Clean dependency-injection pattern for accessFn, execFileFn, platform, env — makes the existing two tests very easy to write and read.
  • CI=1 addition is the right call for suppressing interactive prompts in all CLI invocations.
  • ✅ Fail-closed design in init() — skipping data loads when CLI is unavailable prevents cascading noisy errors.
  • ✅ Install banner in the HTML is concise and actionable — shows both the message and the exact install command.
  • combineOutput helper cleanly unifies stdout/stderr for display purposes.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 91.6 AIC · ⌖ 8.26 AIC · ⊞ 6.6K
Comment /matt to run again

installCommand: "gh extension install github/gh-aw",
});
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test suite covers gh-extension and missing paths but omits two other branches of getStatus: the dev-binary path and the generic error path (source: "error"). Both ship in this PR and should have explicit tests.

💡 Suggested additions
it("detects gh aw version from a local dev binary", async () => {
  const execFileFn = vi.fn((bin, args, options, callback) => {
    expect(bin).toMatch(/gh-aw$/);
    expect(args).toEqual(["version"]);
    expect(options.env.CI).toBe("1");
    callback(null, "", "gh-aw version v0.9.0\n");
  });
  const runGhAw = createGhAwRunnerWithStatus({
    getWorkspacePath: () => "/workspace",
    accessFn: vi.fn(async () => undefined), // binary exists
    execFileFn,
    platform: "linux",
  });
  await expect(runGhAw.getStatus()).resolves.toMatchObject({
    available: true,
    source: "dev-binary",
  });
});

it("returns source:error for unexpected gh failures", async () => {
  const execFileFn = vi.fn((bin, args, options, callback) => {
    callback(Object.assign(new Error("connection refused"), { code: 1 }), "", "connection refused");
  });
  const runGhAw = createGhAwRunnerWithStatus({
    getWorkspacePath: () => "/workspace",
    accessFn: vi.fn(async () => { throw new Error("missing"); }),
    execFileFn,
  });
  await expect(runGhAw.getStatus()).resolves.toMatchObject({
    available: false,
    source: "error",
  });
});

The dev-binary path is the primary path for local development. Without a test, a regression in findDevBinary detection would go unnoticed.

@copilot please address this.

const runGhAw = createGhAwRunner(options);
const getStatus = async () => {
const cwd = options.getWorkspacePath();
const devBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] findDevBinary is called here inside getStatus, and also called again on every runGhAw(args) invocation (line 60). Each init() or refresh() therefore makes two filesystem access calls — one for status and one for the actual command. Cache the resolved binary path (or share it) to avoid redundant I/O.

💡 Suggested refactor

Consider caching the result of findDevBinary at runner creation time, or computing it once in getStatus and reusing it:

export function createGhAwRunnerWithStatus(options) {
  let resolvedBin = undefined; // undefined = not yet resolved

  async function getDevBin() {
    if (resolvedBin === undefined) {
      const cwd = options.getWorkspacePath();
      resolvedBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform);
    }
    return resolvedBin;
  }

  const getStatus = async () => { /* use getDevBin() */ };
  const runGhAw = async (args) => { /* use getDevBin() */ };
  // ...
}

This also removes the duplicated options.accessFn ?? access / options.platform ?? process.platform fallbacks that already exist in createGhAwRunner's parameter defaults.

@copilot please address this.


export interface CLIStatus {
available: boolean;
source: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] source is typed as plain string, losing the discriminated union that the rest of the code relies on. Every call-site that switches on source (e.g. cliSourceLabel) would benefit from compiler-enforced exhaustiveness.

💡 Suggested fix
export interface CLIStatus {
  available: boolean;
  source: "dev-binary" | "gh-extension" | "missing" | "error";
  version: string;
  command: string;
  installCommand: string;
  message?: string;
}

This lets TypeScript flag any switch/if chain over source that misses a case, and makes the intent self-documenting.

@copilot please address this.

},

async refresh() {
await fetch("/api/refresh");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] refresh() fires fetch("/api/refresh") and discards the response entirely. If the server returns 4xx/5xx, the UI silently proceeds to re-fetch all data, giving the user no signal that the refresh trigger itself failed.

💡 Suggested fix
async refresh() {
  const res = await fetch("/api/refresh");
  if (!res.ok) {
    this.flashMessage = `Refresh failed: ${res.status}`;
    this.flashKind = "error";
    return;
  }
  // ... rest of refresh
}

At minimum, set flashKind = "error" so the user knows the operation did not succeed.

@copilot please address this.

}
};

runGhAw.getStatus = getStatus;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Attaching getStatus as a property of the returned function (runGhAw.getStatus = getStatus) mixes callable and object semantics on one value. As the API grows, consumers need to know that runGhAw is both a runner function and an object with methods.

💡 Suggested alternative

Return a plain object from createGhAwRunnerWithStatus to make the contract explicit and easier to type:

export function createGhAwRunnerWithStatus(options) {
  // ...
  return {
    run: runGhAw,      // replaces runGhAw(args) calls
    getStatus,
  };
}

In extension.mjs:

const { run: runGhAw, getStatus } = createGhAwRunnerWithStatus({ ... });
// pass runGhAw to dataAccess, use getStatus for /api/cli-status

This makes the shape explicit for both JS and TypeScript consumers.

@copilot please address this.

return match?.[1]?.trim() ?? "";
}

function isMissingGhAwExtension(error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] isMissingGhAwExtension is the key discriminator for the feature's main user-facing branch (source: "missing" vs source: "error"), but it has no isolated unit tests. The two regex patterns rely on gh CLI error message text that could differ across platforms or gh versions.

💡 Suggested tests
import { isMissingGhAwExtension } from "../dashboard-cli.mjs";
// Note: export the function or test via the runner's observable behaviour

// Via getStatus behaviour (already covered by existing tests), or export for direct testing:
describe("isMissingGhAwExtension", () => {
  it("matches 'extension not found: aw'", () => {
    expect(isMissingGhAwExtension({ output: "extension not found: aw" })).toBe(true);
  });
  it("matches \"unknown command 'aw' for 'gh'\"", () => {
    expect(isMissingGhAwExtension({ output: "unknown command 'aw' for 'gh'" })).toBe(true);
  });
  it("does not match unrelated errors", () => {
    expect(isMissingGhAwExtension({ output: "connection refused" })).toBe(false);
  });
});

Considering exporting this function so it can be tested directly, even if it remains an implementation detail.

@copilot please address this.

}

export function createGhAwRunner({ getWorkspacePath }) {
function parseVersionFromOutput(output) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] parseVersionFromOutput has no isolated unit tests. The regex (/gh(?:-aw| aw) version ([^\r\n]+)/i) needs to handle several edge cases — output from stdout only, output from stderr only, combined output, and empty strings — all of which can silently return "" or "unknown" today.

💡 Suggested tests
import { parseVersionFromOutput } from "../dashboard-cli.mjs";
// export for testing, or test indirectly via getStatus

describe("parseVersionFromOutput", () => {
  it("extracts version from 'gh aw version vX.Y.Z'", () => {
    expect(parseVersionFromOutput("gh aw version v1.2.3")).toBe("v1.2.3");
  });
  it("extracts version from 'gh-aw version vX.Y.Z'", () => {
    expect(parseVersionFromOutput("gh-aw version v0.9.0")).toBe("v0.9.0");
  });
  it("returns empty string for empty input", () => {
    expect(parseVersionFromOutput("")).toBe("");
  });
  it("returns empty string for null input", () => {
    expect(parseVersionFromOutput(null)).toBe("");
  });
  it("returns empty string when pattern does not match", () => {
    expect(parseVersionFromOutput("something else")).toBe("");
  });
});

@copilot please address this.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES — one crash-inducing bug in the new status endpoint, one misleading error message, and two testing gaps that hide those problems.

### Blocking issues

getStatus() dev-binary path has no error handling (comment on dashboard-cli.mjs:75): when findDevBinary returns a path but the binary fails to execute, the rejection propagates uncaught all the way through the /api/cli-status handler. The gh-extension path wraps its exec in try/catch; the dev-binary path — which is the less tested, higher-risk one — does not. This can crash the endpoint in any local dev checkout where the binary is mid-build or compiled for the wrong arch.

**Misleading not

🔎 Code quality review by PR Code Quality Reviewer · 136.7 AIC · ⌖ 9.86 AIC · ⊞ 5.2K
Comment /review to run again

const cwd = options.getWorkspacePath();
const devBin = await findDevBinary(cwd, options.accessFn ?? access, options.platform ?? process.platform);

if (devBin) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled rejection if dev binary exists but fails to run: getStatus() wraps the gh extension path in try/catch but leaves the dev-binary execp call completely unprotected — any failure propagates uncaught through the /api/cli-status endpoint.

💡 Suggested fix

Wrap the dev-binary branch the same way as the gh-extension branch:

if (devBin) {
  try {
    const output = await execp(devBin, ['version'], cwd, { ... });
    return { available: true, source: 'dev-binary', ... };
  } catch (error) {
    return {
      available: false,
      source: 'error',
      version: '',
      command: `${devBin} version`,
      installCommand: INSTALL_COMMAND,
      message: String(error?.output ?? error?.stderr ?? error?.message ?? 'Failed to run dev binary.'),
    };
  }
}

Concrete failure scenarios that crash the endpoint today: binary compiled for wrong arch, X_OK check passes but dynamic linker rejects it at run time, binary is mid-compilation when checked. The gh-extension path handles its own errors gracefully; the dev-binary path — the riskier one — does not.

output: "Need two valid runs for diff. Example: gh aw audit-diff run-00002 run-00003",
};
this.commandInput = this.cliStatus?.command ?? "gh aw version";
this.commandOutput = this.cliStatus?.message ?? "gh aw is not installed.";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"gh aw is not installed." displayed even when the status endpoint itself failed: when fetchCliStatus catches a network or server error it sets cliStatus = null. Both init() and refresh() then fall into the same branch as a genuine missing-extension case, printing the hardcoded fallback — actively lying to the user about the cause.

💡 Suggested fix

Distinguish null (fetch error) from { available: false } (CLI confirmed missing):

// init():
if (this.cliStatus === null) {
  // errorCliStatus is already set and shown in the banner — don't clobber commandOutput
  this.commandInput = 'gh aw version';
  this.commandOutput = 'Could not reach the status endpoint. Check the server.';
} else {
  // cliStatus is present but available === false
  this.commandInput = this.cliStatus.command;
  this.commandOutput = this.cliStatus.message ?? 'gh aw is not installed.';
}

The same split is needed in refresh() (line 357). The errorCliStatus flag is already wired to an error banner in the HTML; the commandOutput message should not contradict it.

installCommand: "gh extension install github/gh-aw",
});
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only untested code path is the one without error handling: both existing tests force accessFn to throw so findDevBinary always returns null — the dev-binary execp branch (which currently lacks a try/catch) is never exercised.

💡 Suggested tests to add
it('detects version from local dev binary', async () => {
  const execFileFn = vi.fn((bin, args, options, cb) => {
    expect(bin).toMatch(/gh-aw$/);
    expect(args).toEqual(['version']);
    cb(null, 'gh-aw version v0.9.0\n', '');
  });

  const runGhAw = createGhAwRunnerWithStatus({
    getWorkspacePath: () => '/workspace',
    accessFn: vi.fn(async () => {}), // resolves — binary found
    execFileFn,
    platform: 'linux',
  });

  await expect(runGhAw.getStatus()).resolves.toMatchObject({
    available: true,
    source: 'dev-binary',
    version: 'v0.9.0',
  });
});

it('returns source:error when dev binary execution fails', async () => {
  const execFileFn = vi.fn((bin, args, options, cb) => {
    cb(Object.assign(new Error('exec failed'), { code: 1 }), '', 'Segmentation fault');
  });

  const runGhAw = createGhAwRunnerWithStatus({
    getWorkspacePath: () => '/workspace',
    accessFn: vi.fn(async () => {}), // resolves — binary found
    execFileFn,
    platform: 'linux',
  });

  // After the missing try/catch is fixed, this should resolve rather than reject
  await expect(runGhAw.getStatus()).resolves.toMatchObject({
    available: false,
    source: 'error',
  });
});

The second test will currently fail (the promise rejects rather than resolves), which confirms the critical bug at line 75 of dashboard-cli.mjs. Add the try/catch fix first, then these tests will pass.

Also worth adding: a test for the generic-error path via the gh extension (non-missing error like permission denied) to pin the message extraction logic.


function isMissingGhAwExtension(error) {
const output = String(error?.output ?? error?.stderr ?? error?.message ?? "");
return /extension not found:\s*aw/i.test(output) || /unknown command ["']aw["'] for ["']gh["']/i.test(output);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension-missing detection is tied to undocumented gh CLI error strings that have changed between releases: both regexes match specific prose that can change in any gh update, silently downgrading a 'not installed' result to 'source:error' and showing a raw error string instead of the install banner.

💡 Suggested fix

Gate on exit code first — it is more stable than message text:

function isMissingGhAwExtension(error) {
  // exit code 1 means 'unknown command' / missing extension;
  // exit code 127 means gh itself not found — don't confuse the two
  if (error?.code !== 1) return false;
  const output = String(error?.output ?? error?.stderr ?? error?.message ?? '');
  return /extension not found:\s*aw/i.test(output) || /unknown command .aw. for .gh./i.test(output);
}

Also add a comment to the test pinning the exact error string to the gh version it was sourced from, so future maintainers know when to update it.

if (err) reject(Object.assign(err, { stderr: stderr ?? "" }));
else resolve(stdout);
const output = combineOutput(stdout ?? "", stderr ?? "");
if (err) reject(Object.assign(err, { stderr: stderr ?? "", stdout: stdout ?? "", output }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.assign mutates the error object that was handed to the callback: if the caller's execFileFn reuses or logs the error before calling the callback (e.g., a spy that stores it, or Node internals that annotate it), the added fields stdout, stderr, and output silently corrupt the original.

💡 Suggested fix

Avoid mutating arguments you did not create:

// Instead of Object.assign(err, {...})
const wrapped = Object.assign(new Error(err.message), { cause: err }, { stderr: stderr ?? '', stdout: stdout ?? '', output });
reject(wrapped);

Or simply spread into a plain object:

reject(Object.assign(Object.create(Object.getPrototypeOf(err)), err, { stderr: stderr ?? '', stdout: stdout ?? '', output }));

The current tests happen to be safe because each test constructs a fresh Error per callback invocation — the mutation goes unnoticed.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

…e run data on refresh

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…te logic

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Done. Both review issues have been addressed in the latest commits:

  1. init() misleading message — introduced cliUnavailableMessage() that falls back to errorCliStatus before the generic "gh aw is not installed." string, so a 500 from /api/cli-status shows the actual fetch error instead.

  2. Stale run/meta data in refresh() — the unavailable branch now clears selectedRun, runsMeta, and usageMeta before re-paginating, avoiding a mixed state where old run details remain visible after refresh.

  3. Duplication — the message-derivation logic was extracted into a cliUnavailableMessage() helper (declared in the interface, implemented once) used by both init() and refresh().

Copilot AI commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

PR is in a merge-ready state. All review threads were addressed in the previous pass (commits be14305 and 96fc96c). Local validation is green:

  • make fmt / make lint
  • make test-unit (Go) ✅
  • Vitest dashboard tests: 16/16 ✅
  • Working tree clean, branch up to date with origin ✅

CI must be re-triggered by a maintainer (close/reopen or workflow_dispatch) before merge.

@pelikhan
pelikhan merged commit 96ad4b4 into main Jun 29, 2026
7 checks passed
@pelikhan
pelikhan deleted the copilot/update-github-extensions-dashboard branch June 29, 2026 16:02
@github-actions github-actions Bot mentioned this pull request Jun 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants