Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
54 changes: 47 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import {
type StatusResult
} from "./core/diagnostics/status.js";
import { createWebhookStatusNotifier } from "./core/notifiers/statusNotifiers.js";
import {
ISSUE_TRACKER_PROVIDER_NAMES,
type IssueTrackerProviderName
} from "./core/trackers/contracts.js";

interface CliWriter {
write(chunk: string): boolean | void;
Expand Down Expand Up @@ -88,7 +92,7 @@ Examples:
Explain an artifact using deterministic evidence from related inputs.

$ specforge status --repo iKwesi/SpecForge --pr 123
Report pull request state and CI outcomes from GitHub.
Report review-request state and CI outcomes from the configured issue tracker.

Workflow guide:
1. Run 'specforge doctor' before making changes to confirm your environment is ready.
Expand All @@ -99,7 +103,7 @@ Workflow guide:
Artifacts:
- 'inspect' writes repository profile and architecture summary artifacts.
- 'explain' reads one or more artifact files plus optional policy/schedule evidence.
- 'status' reads pull request state and status checks from GitHub.
- 'status' reads review-request state and status checks from the configured issue tracker.
- 'doctor' reports readiness and exits non-zero when blocking issues are found.
`
);
Expand Down Expand Up @@ -192,9 +196,19 @@ The command reads artifact inputs and optional policy/schedule context, then pri

program
.command("status")
.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")
.description("Report issue-tracker review request state and CI outcomes. Example: specforge status --repo iKwesi/SpecForge --pr 123")
.requiredOption(
"--pr <ref>",
"GitHub: pull request number, URL, or branch. GitLab: merge request number or URL."
)
.option(
"--repo <path>",
"Issue tracker repository/project path when --pr is not a review request URL"
)
.option(
"--provider <name>",
`Issue tracker provider (${ISSUE_TRACKER_PROVIDER_NAMES.join(" or ")})`
)
.option(
"--notify-webhook <url>",
"Emit the status event to a webhook; delivery failures are reported without failing the status command, but invalid webhook configuration is still an error",
Expand All @@ -207,16 +221,22 @@ The command reads artifact inputs and optional policy/schedule context, then pri
Examples:
$ specforge status --repo iKwesi/SpecForge --pr 123
$ specforge status --pr https://github.com/iKwesi/SpecForge/pull/123
$ specforge status --repo iKwesi/SpecForge --pr feat/task-1
$ specforge status --provider gitlab --repo gitlab-org/cli --pr 42
$ 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.
Use this after handoff when you need the latest review-request merge state and status checks.
GitHub accepts pull request numbers, URLs, or branch refs when --repo is provided.
GitLab accepts merge request numbers or merge request URLs.
`
)
.action(async (options: { pr: string; repo?: string; notifyWebhook: string[] }) => {
.action(async (options: { pr: string; repo?: string; provider?: string; notifyWebhook: string[] }) => {
try {
const provider = normalizeIssueTrackerProvider(options.provider);
const result = await statusRunner({
pull_request: options.pr,
...(options.repo ? { repository: options.repo } : {}),
...(provider ? { provider } : {}),
...(options.notifyWebhook.length > 0
? {
notifiers: options.notifyWebhook.map((webhookUrl, index) =>
Expand Down Expand Up @@ -335,3 +355,23 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
function collectOptionValues(value: string, previous: string[]): string[] {
return [...previous, value];
}

function normalizeIssueTrackerProvider(
value: string | undefined
): IssueTrackerProviderName | undefined {
if (value === undefined) {
return undefined;
}

if (isIssueTrackerProviderName(value)) {
return value;
}

throw new Error(
`provider must be one of ${ISSUE_TRACKER_PROVIDER_NAMES.join(", ")}.`
);
}

function isIssueTrackerProviderName(value: string): value is IssueTrackerProviderName {
return ISSUE_TRACKER_PROVIDER_NAMES.includes(value as IssueTrackerProviderName);
}
33 changes: 22 additions & 11 deletions src/core/diagnostics/status.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import {
createGitHubProvider,
createIssueTrackerProvider,
type GetPullRequestStatusInput,
type GitHubProvider,
type GitHubPullRequestStatus
} from "../github/provider.js";
type IssueTrackerProvider,
type IssueTrackerProviderName,
type IssueTrackerPullRequestStatus
} from "../trackers/provider.js";
import {
emitStatusNotification,
type StatusNotificationDelivery,
type StatusNotifier
} from "../notifiers/statusNotifiers.js";

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

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

/**
* Report the current GitHub pull request status using the configured provider.
* Report the current review-request status using the configured issue tracker provider.
*
* This stays intentionally narrow for v1: it reads pull request state and status
* checks without trying to infer broader run orchestration from GitHub alone.
* This stays intentionally narrow for v1: it reads review-request state and status
* checks without trying to infer broader run orchestration from the tracker alone.
*/
export async function runStatus(input: RunStatusInput): Promise<StatusResult> {
const provider = input.github_provider ?? createGitHubProvider();
const provider =
input.issue_tracker_provider ??
createIssueTrackerProvider({
...(input.provider ? { provider: input.provider } : {}),
pull_request: input.pull_request
});
const pullRequest = await provider.getPullRequestStatus({
pull_request: input.pull_request,
...(input.repository ? { repository: input.repository } : {})
Expand All @@ -50,10 +57,14 @@ export async function runStatus(input: RunStatusInput): Promise<StatusResult> {
}

export function formatStatusReport(result: StatusResult): string {
const requestLabel =
result.pull_request.request_kind === "merge_request" ? "Merge Request" : "Pull Request";
const lines = [
"SpecForge Status",
"",
`Pull Request: #${result.pull_request.number}`,
`Provider: ${result.pull_request.provider}`,
`Request Kind: ${result.pull_request.request_kind}`,
`${requestLabel}: #${result.pull_request.number}`,
`URL: ${result.pull_request.url}`,
`Title: ${result.pull_request.title}`,
`State: ${result.pull_request.state}`,
Expand Down
23 changes: 1 addition & 22 deletions src/core/github/provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { deriveOverallStatus } from "../trackers/contracts.js";

const execFileAsync = promisify(execFile);
const PR_VIEW_FIELDS = [
Expand Down Expand Up @@ -478,28 +479,6 @@ function normalizeStatusContextConclusion(value: string | undefined): GitHubStat
}
}

function deriveOverallStatus(statusChecks: GitHubStatusCheck[]): GitHubOverallStatus {
if (statusChecks.length === 0) {
return "no_checks";
}

if (
statusChecks.some((check) =>
["failure", "timed_out", "cancelled", "action_required", "unknown"].includes(
check.conclusion
)
)
) {
return "failure";
}

if (statusChecks.some((check) => check.conclusion === "pending" || check.status !== "completed")) {
return "pending";
}

return "success";
}

function isPullRequestUrl(value: string): boolean {
return /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/.test(value.trim());
}
Loading
Loading