Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type RunStatusInput,
type StatusResult
} from "./core/diagnostics/status.js";
import { createWebhookStatusNotifier } from "./core/notifiers/statusNotifiers.js";

interface CliWriter {
write(chunk: string): boolean | void;
Expand Down Expand Up @@ -194,21 +195,38 @@ The command reads artifact inputs and optional policy/schedule context, then pri
.description("Report GitHub pull request state and CI outcomes. Example: specforge status --repo iKwesi/SpecForge --pr 123")
.requiredOption("--pr <ref>", "Pull request number, URL, or branch to inspect")
.option("--repo <owner/repo>", "GitHub repository slug when --pr is not a pull request URL")
.option(
"--notify-webhook <url>",
"Emit the status event to a webhook without failing the status command if delivery fails",
collectOptionValues,
[]
)
.addHelpText(
"after",
`
Examples:
$ specforge status --repo iKwesi/SpecForge --pr 123
$ specforge status --pr https://github.com/iKwesi/SpecForge/pull/123
$ specforge status --repo iKwesi/SpecForge --pr 123 --notify-webhook https://hooks.example.test/specforge

Use this after PR handoff when you need the latest GitHub merge state and status checks.
`
)
.action(async (options: { pr: string; repo?: string }) => {
.action(async (options: { pr: string; repo?: string; notifyWebhook: string[] }) => {
try {
const result = await statusRunner({
pull_request: options.pr,
...(options.repo ? { repository: options.repo } : {}),
...(options.notifyWebhook.length > 0
? {
notifiers: options.notifyWebhook.map((webhookUrl, index) =>
createWebhookStatusNotifier({
webhook_url: webhookUrl,
adapter_id: options.notifyWebhook.length > 1 ? `webhook-${index + 1}` : "webhook"
})
)
}
: {}),
...(statusInput ?? {})
});

Expand Down
33 changes: 32 additions & 1 deletion src/core/diagnostics/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ import {
type GitHubProvider,
type GitHubPullRequestStatus
} from "../github/provider.js";
import {
emitStatusNotification,
type StatusNotificationDelivery,
type StatusNotifier
} from "../notifiers/statusNotifiers.js";

export interface RunStatusInput extends GetPullRequestStatusInput {
github_provider?: GitHubProvider;
notifiers?: StatusNotifier[];
emitted_at?: Date;
}

export interface StatusResult {
pull_request: GitHubPullRequestStatus;
notification_deliveries?: StatusNotificationDelivery[];
}

/**
Expand All @@ -25,9 +33,19 @@ export async function runStatus(input: RunStatusInput): Promise<StatusResult> {
pull_request: input.pull_request,
...(input.repository ? { repository: input.repository } : {})
});
const notificationDeliveries =
input.notifiers && input.notifiers.length > 0
? await emitStatusNotification({
pull_request: pullRequest,
...(input.repository ? { repository: input.repository } : {}),
...(input.emitted_at ? { emitted_at: input.emitted_at } : {}),
notifiers: input.notifiers
})
: undefined;

return {
pull_request: pullRequest
pull_request: pullRequest,
...(notificationDeliveries ? { notification_deliveries: notificationDeliveries } : {})
};
}

Expand Down Expand Up @@ -62,5 +80,18 @@ export function formatStatusReport(result: StatusResult): string {
}
}

if (result.notification_deliveries) {
lines.push("", "Notifications");
if (result.notification_deliveries.length === 0) {
lines.push("- none");
} else {
for (const delivery of result.notification_deliveries) {
lines.push(
`- ${delivery.adapter_id} ${delivery.delivery_status}: ${delivery.message}`
);
}
}
}

return `${lines.join("\n")}\n`;
}
180 changes: 180 additions & 0 deletions src/core/notifiers/statusNotifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { GitHubPullRequestStatus } from "../github/provider.js";

export type StatusNotifierErrorCode = "invalid_notifier" | "delivery_failed";

export class StatusNotifierError extends Error {
readonly code: StatusNotifierErrorCode;
readonly details?: unknown;

constructor(code: StatusNotifierErrorCode, message: string, details?: unknown) {
super(message);
this.name = "StatusNotifierError";
this.code = code;
this.details = details;
}
}

export interface PullRequestStatusNotificationEvent {
event_kind: "pull_request_status";
emitted_at: string;
repository?: string;
pull_request: GitHubPullRequestStatus;
}

export type StatusNotificationEvent = PullRequestStatusNotificationEvent;

export interface StatusNotifier {
adapter_id: string;
notify(event: StatusNotificationEvent): Promise<void>;
}

export interface StatusNotificationDelivery {
adapter_id: string;
delivery_status: "delivered" | "failed";
message: string;
}

export interface EmitStatusNotificationInput {
pull_request: GitHubPullRequestStatus;
repository?: string;
emitted_at?: Date;
notifiers: StatusNotifier[];
}

type FetchLike = (input: string, init?: {
method?: string;
headers?: Record<string, string>;
body?: string;
}) => Promise<{ ok: boolean; status: number }>;

export function createWebhookStatusNotifier(input: {
webhook_url: string;
adapter_id?: string;
fetch?: FetchLike;
}): StatusNotifier {
const webhookUrl = normalizeWebhookUrl(input.webhook_url);
const adapterId = normalizeAdapterId(input.adapter_id ?? "webhook");
const fetchImpl = input.fetch ?? resolveGlobalFetch();

return {
adapter_id: adapterId,
async notify(event) {
try {
const response = await fetchImpl(webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-specforge-event-kind": event.event_kind
},
body: JSON.stringify(event)
});

if (!response.ok) {
throw new StatusNotifierError(
"delivery_failed",
`Webhook delivery failed with HTTP ${response.status}.`
);
}
} catch (error) {
if (error instanceof StatusNotifierError) {
throw error;
}

throw new StatusNotifierError(
"delivery_failed",
error instanceof Error ? error.message : String(error),
error
);
}
}
};
}

export async function emitStatusNotification(
input: EmitStatusNotificationInput
): Promise<StatusNotificationDelivery[]> {
const event: StatusNotificationEvent = {
event_kind: "pull_request_status",
emitted_at: (input.emitted_at ?? new Date()).toISOString(),
...(input.repository ? { repository: input.repository } : {}),
pull_request: input.pull_request
};

return Promise.all(
input.notifiers.map(async (notifier) => {
try {
await notifier.notify(event);
return {
adapter_id: notifier.adapter_id,
delivery_status: "delivered",
message: "Status event delivered."
} as StatusNotificationDelivery;
} catch (error) {
return {
adapter_id: notifier.adapter_id,
delivery_status: "failed",
message: error instanceof Error ? error.message : String(error)
} as StatusNotificationDelivery;
}
})
);
}

function normalizeWebhookUrl(value: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new StatusNotifierError(
"invalid_notifier",
"webhook_url must be a non-empty http(s) URL."
);
}
const trimmed = value.trim();

let url: URL;
try {
url = new URL(trimmed);
} catch (error) {
throw new StatusNotifierError(
"invalid_notifier",
"webhook_url must be a valid http(s) URL.",
error
);
}

if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new StatusNotifierError(
"invalid_notifier",
"webhook_url must use http or https."
);
}

return url.toString();
}

function normalizeAdapterId(value: unknown): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new StatusNotifierError(
"invalid_notifier",
"adapter_id must be a non-empty string.",
{ adapter_id: value }
);
}

return value.trim();
}

function resolveGlobalFetch(): FetchLike {
if (typeof globalThis.fetch !== "function") {
throw new StatusNotifierError(
"invalid_notifier",
"Global fetch is unavailable; provide a fetch implementation explicitly."
);
}

return async (input, init) => {
const response = await globalThis.fetch(input, init);
return {
ok: response.ok,
status: response.status
};
};
}
57 changes: 44 additions & 13 deletions tests/cli/status-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,35 +25,66 @@ function buildStatusResult(): StatusResult {
details_url: "https://example.com/build"
}
]
}
},
notification_deliveries: [
{
adapter_id: "webhook",
delivery_status: "delivered",
message: "Status event delivered."
}
]
};
}

describe("sf status command", () => {
it("prints pull request status details and exits cleanly", async () => {
let stdout = "";
let receivedInput: { repository?: string; pull_request?: string } | undefined;
let receivedInput:
| { repository?: string; pull_request: string; notifiers?: Array<{ adapter_id: string }> }
| undefined;

const exitCode = await runCli(["node", "sf", "status", "--repo", "iKwesi/SpecForge", "--pr", "123"], {
stdout: {
write(chunk: string) {
stdout += chunk;
return true;
const exitCode = await runCli(
[
"node",
"sf",
"status",
"--repo",
"iKwesi/SpecForge",
"--pr",
"123",
"--notify-webhook",
"https://hooks.example.test/specforge"
],
{
stdout: {
write(chunk: string) {
stdout += chunk;
return true;
}
},
status_runner: async (input) => {
receivedInput = {
pull_request: input.pull_request,
...(input.repository ? { repository: input.repository } : {}),
...(input.notifiers
? { notifiers: input.notifiers.map((notifier) => ({ adapter_id: notifier.adapter_id })) }
: {})
};
return buildStatusResult();
}
},
status_runner: async (input) => {
receivedInput = input;
return buildStatusResult();
}
});
);

expect(exitCode).toBe(0);
expect(receivedInput).toEqual({
repository: "iKwesi/SpecForge",
pull_request: "123"
pull_request: "123",
notifiers: [{ adapter_id: "webhook" }]
});
expect(stdout).toContain("SpecForge Status");
expect(stdout).toContain("Pull Request: #123");
expect(stdout).toContain("Notifications");
expect(stdout).toContain("- webhook delivered: Status event delivered.");
});

it("returns exit code 1 when the status command fails", async () => {
Expand Down
Loading
Loading