-
Notifications
You must be signed in to change notification settings - Fork 0
Add wave ci command group (wave-ci gateway client) #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { Command } from "commander"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { registerCiCommands } from "./index.js"; | ||
|
|
||
| function buildProgram(): Command { | ||
| const program = new Command(); | ||
| program.exitOverride(); | ||
| program.option("-o, --output <format>", "", "json"); | ||
| registerCiCommands(program); | ||
| return program; | ||
| } | ||
|
|
||
| describe("wave ci", () => { | ||
| let fetchMock: ReturnType<typeof vi.fn>; | ||
|
|
||
| beforeEach(() => { | ||
| vi.spyOn(console, "log").mockImplementation(() => undefined); | ||
| vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| process.env.WAVE_CI_GATEWAY_SECRET = "test-secret"; | ||
| delete process.env.WAVE_CI_BASE; | ||
| fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.restoreAllMocks(); | ||
| delete process.env.WAVE_CI_GATEWAY_SECRET; | ||
| }); | ||
|
|
||
| it("status hits the gateway with the trust secret", async () => { | ||
| await buildProgram().parseAsync(["ci", "status"], { from: "user" }); | ||
| expect(fetchMock).toHaveBeenCalledOnce(); | ||
| const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; | ||
| expect(url).toBe("https://ci.wave.online/v1/ci/status"); | ||
| expect((init.headers as Record<string, string>)["x-wave-gateway-secret"]).toBe("test-secret"); | ||
| }); | ||
|
|
||
| it("dispatch posts repo/workflow/ref", async () => { | ||
| await buildProgram().parseAsync( | ||
| ["ci", "dispatch", "--repo", "wave-av/cli", "--workflow", "smoke-install.yml", "--ref", "main"], | ||
| { from: "user" }, | ||
| ); | ||
| const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; | ||
| expect(url).toBe("https://ci.wave.online/v1/ci/dispatch"); | ||
| expect(JSON.parse(init.body as string)).toMatchObject({ repo: "wave-av/cli", workflow: "smoke-install.yml", ref: "main" }); | ||
| }); | ||
|
|
||
| it("rwx-dispatch posts key/ref", async () => { | ||
| await buildProgram().parseAsync(["ci", "rwx-dispatch", "--key", "deploy", "--ref", "main"], { from: "user" }); | ||
| const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; | ||
| expect(url).toBe("https://ci.wave.online/v1/ci/rwx/dispatch"); | ||
| }); | ||
|
|
||
| it("captain-suites hits the suites route", async () => { | ||
| await buildProgram().parseAsync(["ci", "captain-suites"], { from: "user" }); | ||
| const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; | ||
| expect(url).toBe("https://ci.wave.online/v1/ci/captain/suites"); | ||
| }); | ||
|
|
||
| it("refuses without the gateway secret", async () => { | ||
| delete process.env.WAVE_CI_GATEWAY_SECRET; | ||
| const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); | ||
| await buildProgram().parseAsync(["ci", "status"], { from: "user" }); | ||
| expect(fetchMock).not.toHaveBeenCalled(); | ||
| expect(exit).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it("surfaces gateway errors", async () => { | ||
| fetchMock.mockResolvedValueOnce( | ||
| new Response(JSON.stringify({ error: { code: "CI_AUTH_REQUIRED", message: "nope" } }), { status: 401 }), | ||
| ); | ||
| const err = vi.mocked(console.error).mock.calls; | ||
| const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); | ||
| await buildProgram().parseAsync(["ci", "status"], { from: "user" }); | ||
| expect(exit).toHaveBeenCalledWith(1); | ||
| expect(err.map((c) => String(c[0])).join(" ")).toMatch(/CI_AUTH_REQUIRED/); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,116 @@ | ||||||||||||||||||||||||||||
| import { Command } from "commander"; | ||||||||||||||||||||||||||||
| import chalk from "chalk"; | ||||||||||||||||||||||||||||
| import { wrapCommand } from "../../lib/errors.js"; | ||||||||||||||||||||||||||||
| import { formatOutput } from "../../lib/output/index.js"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const CI_BASE = process.env.WAVE_CI_BASE?.replace(/\/+$/, "") ?? "https://ci.wave.online"; | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: Internal Require HTTPS before sending the gateway secret. The default endpoint uses HTTPS, but 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| function ciHeaders(): Record<string, string> { | ||||||||||||||||||||||||||||
| const secret = process.env.WAVE_CI_GATEWAY_SECRET; | ||||||||||||||||||||||||||||
| if (!secret) { | ||||||||||||||||||||||||||||
| throw new Error("WAVE_CI_GATEWAY_SECRET is not set (server-side gateway secret; see wave-ci docs)."); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return { "x-wave-gateway-secret": secret, "x-wave-org": process.env.WAVE_CI_ORG ?? "wave-av" }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| async function ciCall<T>(method: string, path: string, body?: unknown): Promise<T> { | ||||||||||||||||||||||||||||
| const res = await fetch(CI_BASE + path, { | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The gateway request has no timeout or abort signal, so an unreachable CI service can leave the command hanging indefinitely instead of failing. [possible bug] Assessment: 🟠 Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/commands/ci/index.ts
**Line:** 17:17
**Comment:**
*Possible Bug: The gateway request has no timeout or abort signal, so an unreachable CI service can leave the command hanging indefinitely instead of failing.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fixThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Bound the CI gateway request.
Proposed fix const res = await fetch(CI_BASE + path, {
+ signal: AbortSignal.timeout(30_000),
method,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| method, | ||||||||||||||||||||||||||||
| headers: body !== undefined ? { ...ciHeaders(), "content-type": "application/json" } : ciHeaders(), | ||||||||||||||||||||||||||||
| body: body !== undefined ? JSON.stringify(body) : undefined, | ||||||||||||||||||||||||||||
|
Comment on lines
+6
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Quality: New ci module bypasses the shared SDK client used by every other commandAll other command modules (voice, phone, collab, captions, etc.) go through Was this helpful? React with 👍 / 👎 |
||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| const data = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }; | ||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- src/commands/ci/index.ts ---'
cat -n src/commands/ci/index.tsRepository: wave-av/cli Length of output: 5463 🤖 get_repo_knowledge executed:
Length of output: 765 Sensitive Data Exposure Reachability: Internal Reject redirects before sending the gateway secret. Proposed fix const res = await fetch(CI_BASE + path, {
+ redirect: "error",
method,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||||
| throw new Error(`${res.status} ${data.error?.code ?? "UNKNOWN"}: ${data.error?.message ?? "request failed"}`); | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target file outline ---'
ast-grep outline src/commands/ci/index.ts
printf '%s\n' '--- target file ---'
cat -n src/commands/ci/index.ts
printf '%s\n' '--- related references ---'
rg -n --glob '!node_modules' 'ciCall|request failed|data\.error|WAVE_CI' src test tests 2>/dev/null || trueRepository: wave-av/cli Length of output: 7950 Guard non-object gateway error bodies. const raw: unknown = await res.json().catch(() => ({}));
const data =
raw !== null && typeof raw === "object"
? (raw as { error?: { code?: string; message?: string } })
: {};🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return data as T; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export function registerCiCommands(program: Command): void { | ||||||||||||||||||||||||||||
| const ci = program.command("ci").description("Inspect the wave-ci plane (Depot/RWX engines, billing, census)"); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("status") | ||||||||||||||||||||||||||||
| .description("wave-ci health probe (engine configuration)") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async () => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("GET", "/v1/ci/status"), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("dispatch") | ||||||||||||||||||||||||||||
| .description("Dispatch a Depot workflow run") | ||||||||||||||||||||||||||||
| .requiredOption("--repo <repo>", "Repository slug (org/name)") | ||||||||||||||||||||||||||||
| .requiredOption("--workflow <workflow>", "Workflow file") | ||||||||||||||||||||||||||||
| .option("--ref <ref>", "Git ref", "main") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (opts: { repo: string; workflow: string; ref: string }) => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("POST", "/v1/ci/dispatch", opts), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("get-run") | ||||||||||||||||||||||||||||
| .description("Poll one CI run's status by runId") | ||||||||||||||||||||||||||||
| .argument("<run-id>", "Run ID") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (runId: string) => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("GET", `/v1/ci/runs/${encodeURIComponent(runId)}`), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("metrics") | ||||||||||||||||||||||||||||
| .description("CPU/mem/duration metrics for one CI run") | ||||||||||||||||||||||||||||
| .argument("<run-id>", "Run ID") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (runId: string) => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("GET", `/v1/ci/runs/${encodeURIComponent(runId)}/metrics`), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("rerun") | ||||||||||||||||||||||||||||
| .description("Re-run a finished CI workflow") | ||||||||||||||||||||||||||||
| .argument("<run-id>", "Run ID") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (runId: string) => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("POST", `/v1/ci/runs/${encodeURIComponent(runId)}/rerun`), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("rwx-dispatch") | ||||||||||||||||||||||||||||
| .description("Trigger an RWX dispatch trigger run") | ||||||||||||||||||||||||||||
| .requiredOption("--key <key>", "Dispatch trigger key") | ||||||||||||||||||||||||||||
| .requiredOption("--ref <ref>", "Git ref") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (opts: { key: string; ref: string }) => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("POST", "/v1/ci/rwx/dispatch", opts), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("captain-suites") | ||||||||||||||||||||||||||||
| .description("Captain Cloud test suites with flake counters") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async () => { | ||||||||||||||||||||||||||||
| formatOutput(await ciCall("GET", "/v1/ci/captain/suites"), program.opts()); | ||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ci.command("billing") | ||||||||||||||||||||||||||||
| .description("GitHub org billing snapshot for a period") | ||||||||||||||||||||||||||||
| .option("--year <yyyy>", "Year") | ||||||||||||||||||||||||||||
| .option("--month <m>", "Month 1-12") | ||||||||||||||||||||||||||||
| .action( | ||||||||||||||||||||||||||||
| wrapCommand(async (opts: { year?: string; month?: string }) => { | ||||||||||||||||||||||||||||
| const qs = | ||||||||||||||||||||||||||||
| opts.year || opts.month | ||||||||||||||||||||||||||||
| ? `?${[opts.year && `year=${opts.year}`, opts.month && `month=${opts.month}`].filter(Boolean).join("&")}` | ||||||||||||||||||||||||||||
| : ""; | ||||||||||||||||||||||||||||
|
Comment on lines
+102
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||
| const data = await ciCall<Record<string, unknown>>("GET", `/v1/ci/billing${qs}`); | ||||||||||||||||||||||||||||
| if (program.opts().output && program.opts().output !== "table") { | ||||||||||||||||||||||||||||
| formatOutput(data, program.opts()); | ||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const mtd = data.mtd as { actionsNetUsd?: number } | undefined; | ||||||||||||||||||||||||||||
| console.log(chalk.bold("Actions MTD: ") + chalk.green(`$${mtd?.actionsNetUsd ?? "?"}`)); | ||||||||||||||||||||||||||||
| formatOutput(data, { ...program.opts(), output: "json" }); | ||||||||||||||||||||||||||||
|
Comment on lines
+112
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: With the default table output, billing prints a table-style summary followed by forced JSON, producing mixed output that breaks consumers expecting one format. [api mismatch] Assessment: 🟠 Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/commands/ci/index.ts
**Line:** 112:113
**Comment:**
*Api Mismatch: With the default table output, billing prints a table-style summary followed by forced JSON, producing mixed output that breaks consumers expecting one format.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fixThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Preserve the selected output format. When the global output mode is the default Proposed fix- formatOutput(data, { ...program.opts(), output: "json" });
+ formatOutput(data, program.opts());📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Quality: get-run, metrics, rerun, and billing paths are untested
index.test.ts only covers
status,dispatch,rwx-dispatch,captain-suites, the missing-secret path, and generic error propagation. Thebillingcommand's query-string construction (including the now-flagged encoding bug) and its table-vs-json branching, plusget-run/metrics/rerun's URL construction, have no test coverage, so regressions in those paths won't be caught by CI.Was this helpful? React with 👍 / 👎