feat(KB-093): add PR-first merge mode with configurable merge strategies

- Add mergeStrategy setting (fast-forward, squash, merge-commit) to config
- Implement PR-first auto-completion flow that monitors PR merge status
- Wire PR monitoring service to detect merge completion and trigger auto-close
- Add PR status UI to dashboard with merge progress indicator
- Update settings modal with merge strategy selector
- Add changeset for PR-first merge mode feature
This commit is contained in:
gsxdsm
2026-03-29 23:13:24 -07:00
parent 94f599a3df
commit fd9a1b186a
27 changed files with 1715 additions and 109 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { GitHubClient, CreatePrParams, PrComment } from "./github.js";
import { GitHubClient, CreatePrParams, PrComment, isPrMergeReady } from "./github.js";
// Mock the gh-cli module from @kb/core
vi.mock("@kb/core", async () => {
@@ -554,6 +554,242 @@ describe("GitHubClient", () => {
});
});
describe("findPrForBranch", () => {
it("finds an existing PR for a head branch via gh CLI", async () => {
mockRunGhJsonAsync.mockResolvedValue([
{
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Existing PR",
state: "OPEN",
baseRefName: "main",
headRefName: "kb/kb-093",
mergedAt: null,
},
]);
const result = await client.findPrForBranch({ owner: "owner", repo: "repo", head: "kb/kb-093", state: "all" });
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"pr", "list",
"--repo", "owner/repo",
"--head", "kb/kb-093",
"--state", "all",
"--json", "number,url,title,state,baseRefName,headRefName,mergedAt",
]);
expect(result).toEqual(expect.objectContaining({ number: 42, status: "open" }));
});
it("falls back to REST API for branch lookup when gh CLI fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{
number: 5,
html_url: "https://github.com/owner/repo/pull/5",
title: "API PR",
state: "open",
merged_at: null,
head: { ref: "kb/kb-093" },
base: { ref: "main" },
comments: 2,
},
]),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.findPrForBranch({ owner: "owner", repo: "repo", head: "kb/kb-093" });
expect(mockFetch).toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ number: 5, commentCount: 2 }));
vi.restoreAllMocks();
});
});
describe("getPrMergeStatus", () => {
it("returns merge-ready status only when required checks pass and review is non-blocking", async () => {
mockRunGhJsonAsync
.mockResolvedValueOnce({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Ready PR",
state: "OPEN",
reviewDecision: "APPROVED",
baseRefName: "main",
headRefName: "kb/kb-093",
})
.mockResolvedValueOnce([
{ name: "ci", state: "SUCCESS" },
{ name: "lint", state: "SUCCESS" },
]);
const result = await client.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeReady).toBe(true);
expect(result.blockingReasons).toEqual([]);
expect(result.checks).toEqual([
{ name: "ci", required: true, state: "success" },
{ name: "lint", required: true, state: "success" },
]);
});
it("falls back to GraphQL API when gh CLI merge-status lookup fails and token is available", async () => {
mockRunGhJsonAsync.mockRejectedValue(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
data: {
repository: {
pullRequest: {
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Fallback PR",
state: "OPEN",
reviewDecision: null,
baseRefName: "main",
headRefName: "kb/kb-093",
comments: { totalCount: 0 },
commits: {
nodes: [
{
commit: {
statusCheckRollup: {
contexts: {
nodes: [
{
__typename: "CheckRun",
name: "ci",
status: "COMPLETED",
conclusion: "SUCCESS",
isRequired: true,
},
{
__typename: "CheckRun",
name: "optional-preview",
status: "COMPLETED",
conclusion: "FAILURE",
isRequired: false,
},
],
},
},
},
},
],
},
},
},
},
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.getPrMergeStatus("owner", "repo", 42);
expect(result.mergeReady).toBe(true);
expect(result.checks).toEqual([{ name: "ci", required: true, state: "success" }]);
vi.restoreAllMocks();
});
});
describe("mergePr", () => {
it("merges a PR with gh CLI and refetches merged status", async () => {
mockRunGh.mockReturnValue("Merged pull request");
mockRunGhJsonAsync.mockResolvedValue({
number: 42,
url: "https://github.com/owner/repo/pull/42",
title: "Merged PR",
state: "MERGED",
baseRefName: "main",
headRefName: "kb/kb-093",
});
const result = await client.mergePr({ owner: "owner", repo: "repo", number: 42, method: "squash" });
expect(mockRunGh).toHaveBeenCalledWith([
"pr", "merge", "42",
"--repo", "owner/repo",
"--squash",
"--delete-branch",
]);
expect(result.status).toBe("merged");
});
it("falls back to REST API merge when gh CLI fails and token is available", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ merged: true }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "Merged PR",
state: "closed",
merged: true,
head: { ref: "kb/kb-093" },
base: { ref: "main" },
comments: 0,
updated_at: "2024-01-01T00:00:00Z",
}),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.mergePr({ owner: "owner", repo: "repo", number: 42 });
expect(result.status).toBe("merged");
vi.restoreAllMocks();
});
});
describe("isPrMergeReady", () => {
it("blocks closed PRs", () => {
expect(isPrMergeReady({ status: "closed", reviewDecision: null, checks: [] })).toEqual({
ready: false,
blockingReasons: ["PR is closed"],
});
});
it("blocks changes requested review even when checks pass", () => {
expect(isPrMergeReady({
status: "open",
reviewDecision: "CHANGES_REQUESTED",
checks: [{ name: "ci", required: true, state: "success" }],
})).toEqual({
ready: false,
blockingReasons: ["changes requested review is active"],
});
});
it("blocks pending required checks", () => {
expect(isPrMergeReady({
status: "open",
reviewDecision: null,
checks: [{ name: "ci", required: true, state: "pending" }],
})).toEqual({
ready: false,
blockingReasons: ["required checks not successful: ci (pending)"],
});
});
it("ignores optional checks when determining readiness", () => {
expect(isPrMergeReady({
status: "open",
reviewDecision: "REVIEW_REQUIRED",
checks: [
{ name: "required-ci", required: true, state: "success" },
{ name: "optional-preview", required: false, state: "failure" },
],
})).toEqual({ ready: true, blockingReasons: [] });
});
});
describe("error handling when gh CLI not available", () => {
it("throws error when gh CLI not available and no token", async () => {
mockIsGhAvailable.mockReturnValue(false);

View File

@@ -2,7 +2,6 @@ import type { PrInfo } from "@kb/core";
import {
isGhAvailable,
isGhAuthenticated,
runGhJson,
runGhJsonAsync,
getGhErrorMessage,
getCurrentRepo,
@@ -27,12 +26,55 @@ export interface PrComment {
html_url: string;
}
export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
export type PrCheckState =
| "success"
| "pending"
| "failure"
| "cancelled"
| "timed_out"
| "action_required"
| "neutral"
| "skipped"
| "stale"
| "startup_failure";
export interface PrCheckStatus {
name: string;
required: boolean;
state: PrCheckState;
}
export interface PrMergeStatus {
prInfo: PrInfo;
reviewDecision: ReviewDecision;
checks: PrCheckStatus[];
mergeReady: boolean;
blockingReasons: string[];
}
export interface FindPrParams {
owner?: string;
repo?: string;
head: string;
state?: "open" | "closed" | "all";
}
export interface MergePrParams {
owner?: string;
repo?: string;
number: number;
method?: "merge" | "squash" | "rebase";
}
// gh CLI JSON output types
interface GhPrViewJson {
id?: string;
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
reviewDecision?: ReviewDecision;
baseRefName: string;
headRefName: string;
comments: Array<{
@@ -45,6 +87,22 @@ interface GhPrViewJson {
}>;
}
interface GhPrListJson {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
baseRefName: string;
headRefName: string;
isCrossRepository?: boolean;
mergedAt?: string | null;
}
interface GhPrCheckJson {
name: string;
state: string;
}
interface GhIssueViewJson {
number: number;
url: string;
@@ -53,6 +111,94 @@ interface GhIssueViewJson {
stateReason?: "completed" | "not_planned" | "reopened";
}
function normalizeCheckState(state: string | null | undefined): PrCheckState {
switch ((state ?? "").toLowerCase()) {
case "success":
return "success";
case "pending":
case "queued":
case "in_progress":
case "expected":
return "pending";
case "failure":
case "failed":
case "error":
return "failure";
case "cancelled":
return "cancelled";
case "timed_out":
return "timed_out";
case "action_required":
return "action_required";
case "neutral":
return "neutral";
case "skipped":
return "skipped";
case "stale":
return "stale";
case "startup_failure":
return "startup_failure";
default:
return "failure";
}
}
function toPrInfo(input: {
url: string;
number: number;
title: string;
status: PrInfo["status"];
headBranch: string;
baseBranch: string;
commentCount?: number;
lastCommentAt?: string;
lastCheckedAt?: string;
}): PrInfo {
return {
url: input.url,
number: input.number,
status: input.status,
title: input.title,
headBranch: input.headBranch,
baseBranch: input.baseBranch,
commentCount: input.commentCount ?? 0,
lastCommentAt: input.lastCommentAt,
lastCheckedAt: input.lastCheckedAt,
};
}
export function isPrMergeReady(input: {
status: PrInfo["status"];
reviewDecision: ReviewDecision;
checks: PrCheckStatus[];
}): { ready: boolean; blockingReasons: string[] } {
const blockingReasons: string[] = [];
if (input.status !== "open") {
blockingReasons.push(`PR is ${input.status}`);
}
if (input.reviewDecision === "CHANGES_REQUESTED") {
blockingReasons.push("changes requested review is active");
}
const blockingChecks = input.checks.filter(
(check) => check.required && check.state !== "success",
);
if (blockingChecks.length > 0) {
blockingReasons.push(
`required checks not successful: ${blockingChecks
.map((check) => `${check.name} (${check.state})`)
.join(", ")}`,
);
}
return {
ready: blockingReasons.length === 0,
blockingReasons,
};
}
export class GitHubClient {
private token: string | undefined;
private baseUrl = "https://api.github.com";
@@ -65,13 +211,32 @@ export class GitHubClient {
this.token = token;
}
private hasGhAuth(): boolean {
return isGhAvailable() && isGhAuthenticated();
}
private resolveRepo(owner?: string, repo?: string): { owner: string; repo: string } {
if (owner && repo) {
return { owner, repo };
}
const currentRepo = getCurrentRepo();
if (!currentRepo) {
throw new Error(
"Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote.",
);
}
return currentRepo;
}
/**
* Try to create a PR using the `gh` CLI if available, otherwise fall back
* to the REST API. Returns the created PR info.
*/
async createPr(params: CreatePrParams): Promise<PrInfo> {
// Try gh CLI first (preferred for auth handling)
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return this.createPrWithGh(params);
} catch (err) {
@@ -92,24 +257,7 @@ export class GitHubClient {
private createPrWithGh(params: CreatePrParams): PrInfo {
const { owner: paramOwner, repo: paramRepo, title, body, head, base } = params;
// Get owner/repo from params or current repo context
let owner = paramOwner;
let repo = paramRepo;
if (!owner || !repo) {
const currentRepo = getCurrentRepo();
if (!currentRepo) {
throw new Error("Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote.");
}
owner = currentRepo.owner;
repo = currentRepo.repo;
}
// Type guard: owner and repo are now guaranteed to be strings
if (!owner || !repo) {
throw new Error("Could not determine repository.");
}
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
// Build gh pr create command arguments (as array for safety)
const args = [
@@ -138,7 +286,7 @@ export class GitHubClient {
const number = parseInt(match[1], 10);
return {
return toPrInfo({
url: prUrl,
number,
status: "open",
@@ -146,29 +294,12 @@ export class GitHubClient {
headBranch: head,
baseBranch: base || "main",
commentCount: 0,
};
});
}
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main" } = params;
// Get owner/repo from params or current repo context
let owner = paramOwner;
let repo = paramRepo;
if (!owner || !repo) {
const currentRepo = getCurrentRepo();
if (!currentRepo) {
throw new Error("Could not determine repository. Specify owner/repo in params or run from a git repository with a GitHub remote.");
}
owner = currentRepo.owner;
repo = currentRepo.repo;
}
// Type guard: owner and repo are now guaranteed to be strings
if (!owner || !repo) {
throw new Error("Could not determine repository.");
}
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
@@ -200,7 +331,7 @@ export class GitHubClient {
comments: number;
};
return {
return toPrInfo({
url: data.html_url,
number: data.number,
status: this.mapPrState(data.state),
@@ -208,14 +339,339 @@ export class GitHubClient {
headBranch: data.head.ref,
baseBranch: data.base.ref,
commentCount: data.comments,
});
}
async findPrForBranch(params: FindPrParams): Promise<PrInfo | null> {
if (this.hasGhAuth()) {
try {
return await this.findPrForBranchWithGh(params);
} catch (err) {
if (this.token) {
return this.findPrForBranchWithApi(params);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.findPrForBranchWithApi(params);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async findPrForBranchWithGh(params: FindPrParams): Promise<PrInfo | null> {
const { owner, repo } = this.resolveRepo(params.owner, params.repo);
const prs = await runGhJsonAsync<GhPrListJson[]>([
"pr", "list",
"--repo", `${owner}/${repo}`,
"--head", params.head,
"--state", params.state ?? "all",
"--json", "number,url,title,state,baseRefName,headRefName,mergedAt",
]);
const pr = prs[0];
if (!pr) return null;
return toPrInfo({
url: pr.url,
number: pr.number,
status: pr.mergedAt ? "merged" : this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
baseBranch: pr.baseRefName,
commentCount: 0,
});
}
private async findPrForBranchWithApi(params: FindPrParams): Promise<PrInfo | null> {
const { owner, repo } = this.resolveRepo(params.owner, params.repo);
const searchParams = new URLSearchParams();
searchParams.set("head", `${owner}:${params.head}`);
searchParams.set("state", params.state ?? "all");
searchParams.set("per_page", "1");
const response = await fetch(
`${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${searchParams}`,
{ headers: this.buildHeaders() },
);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
const pulls = (await response.json()) as Array<{
number: number;
html_url: string;
title: string;
state: string;
merged_at: string | null;
head: { ref: string };
base: { ref: string };
comments: number;
}>;
const pr = pulls[0];
if (!pr) return null;
return toPrInfo({
url: pr.html_url,
number: pr.number,
status: pr.merged_at ? "merged" : this.mapPrState(pr.state),
title: pr.title,
headBranch: pr.head.ref,
baseBranch: pr.base.ref,
commentCount: pr.comments,
});
}
async getPrMergeStatus(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
if (this.hasGhAuth()) {
try {
return await this.getPrMergeStatusWithGh(owner, repo, number);
} catch (err) {
if (this.token) {
return this.getPrMergeStatusWithApi(owner, repo, number);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getPrMergeStatusWithApi(owner, repo, number);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getPrMergeStatusWithGh(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
const resolved = this.resolveRepo(owner, repo);
const pr = await runGhJsonAsync<GhPrViewJson>([
"pr", "view", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--json", "number,url,title,state,baseRefName,headRefName,reviewDecision",
]);
const checks = await runGhJsonAsync<GhPrCheckJson[]>([
"pr", "checks", String(number),
"--repo", `${resolved.owner}/${resolved.repo}`,
"--required",
"--json", "name,state",
]).catch(() => []);
const prInfo = toPrInfo({
url: pr.url,
number: pr.number,
status: this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
baseBranch: pr.baseRefName,
commentCount: 0,
});
const normalizedChecks = checks.map((check) => ({
name: check.name,
required: true,
state: normalizeCheckState(check.state),
} satisfies PrCheckStatus));
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
});
return {
prInfo,
reviewDecision: pr.reviewDecision ?? null,
checks: normalizedChecks,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
};
}
private async getPrMergeStatusWithApi(owner: string | undefined, repo: string | undefined, number: number): Promise<PrMergeStatus> {
const resolved = this.resolveRepo(owner, repo);
const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
query: `query PullRequestMergeStatus($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
number
url
title
state
reviewDecision
baseRefName
headRefName
comments { totalCount }
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first: 100) {
nodes {
__typename
... on CheckRun {
name
status
conclusion
isRequired(pullRequestNumber: $number)
}
... on StatusContext {
context
state
isRequired(pullRequestNumber: $number)
}
}
}
}
}
}
}
}
}
}`,
variables: { owner: resolved.owner, repo: resolved.repo, number },
}),
});
const payload = await response.json() as {
data?: {
repository?: {
pullRequest?: {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
reviewDecision: ReviewDecision;
baseRefName: string;
headRefName: string;
comments: { totalCount: number };
commits: {
nodes: Array<{
commit: {
statusCheckRollup?: {
contexts?: {
nodes?: Array<
| { __typename: "CheckRun"; name: string; status: string; conclusion: string | null; isRequired?: boolean }
| { __typename: "StatusContext"; context: string; state: string; isRequired?: boolean }
| null
>;
};
} | null;
};
}>;
};
};
};
};
errors?: Array<{ message: string }>;
};
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.[0]?.message || response.statusText;
throw new Error(`GitHub API error: ${response.status} ${message}`);
}
const pr = payload.data?.repository?.pullRequest;
if (!pr) {
throw new Error(`PR #${number} not found in ${resolved.owner}/${resolved.repo}`);
}
const nodes = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts?.nodes ?? [];
const checks = nodes.flatMap((node) => {
if (!node || !node.isRequired) return [];
if (node.__typename === "CheckRun") {
return [{
name: node.name,
required: true,
state: normalizeCheckState(node.conclusion ?? node.status),
} satisfies PrCheckStatus];
}
return [{
name: node.context,
required: true,
state: normalizeCheckState(node.state),
} satisfies PrCheckStatus];
});
const prInfo = toPrInfo({
url: pr.url,
number: pr.number,
status: this.mapGhPrState(pr.state),
title: pr.title,
headBranch: pr.headRefName,
baseBranch: pr.baseRefName,
commentCount: pr.comments.totalCount,
});
const readiness = isPrMergeReady({
status: prInfo.status,
reviewDecision: pr.reviewDecision,
checks,
});
return {
prInfo,
reviewDecision: pr.reviewDecision,
checks,
mergeReady: readiness.ready,
blockingReasons: readiness.blockingReasons,
};
}
async mergePr(params: MergePrParams): Promise<PrInfo> {
if (this.hasGhAuth()) {
try {
return await this.mergePrWithGh(params);
} catch (err) {
if (this.token) {
return this.mergePrWithApi(params);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.mergePrWithApi(params);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async mergePrWithGh(params: MergePrParams): Promise<PrInfo> {
const resolved = this.resolveRepo(params.owner, params.repo);
runGh([
"pr", "merge", String(params.number),
"--repo", `${resolved.owner}/${resolved.repo}`,
`--${params.method ?? "squash"}`,
"--delete-branch",
]);
return this.getPrStatus(resolved.owner, resolved.repo, params.number);
}
private async mergePrWithApi(params: MergePrParams): Promise<PrInfo> {
const resolved = this.resolveRepo(params.owner, params.repo);
const response = await fetch(
`${this.baseUrl}/repos/${encodeURIComponent(resolved.owner)}/${encodeURIComponent(resolved.repo)}/pulls/${params.number}/merge`,
{
method: "PUT",
headers: this.buildHeaders(),
body: JSON.stringify({ merge_method: params.method ?? "squash" }),
},
);
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
return this.getPrStatus(resolved.owner, resolved.repo, params.number);
}
/**
* Fetch current PR status using gh CLI if available, otherwise REST API.
*/
async getPrStatus(owner: string, repo: string, number: number): Promise<PrInfo> {
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return await this.getPrStatusWithGh(owner, repo, number);
} catch (err) {
@@ -298,7 +754,7 @@ export class GitHubClient {
number: number,
since?: string,
): Promise<PrComment[]> {
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return await this.listPrCommentsWithGh(owner, repo, number, since);
} catch (err) {
@@ -383,7 +839,7 @@ export class GitHubClient {
repo: string,
number: number,
): Promise<Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null> {
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return await this.getIssueStatusWithGh(owner, repo, number);
} catch (err) {
@@ -525,7 +981,7 @@ export class GitHubClient {
html_url: string;
labels: Array<{ name: string }>;
}>> {
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return await this.listIssuesWithGh(owner, repo, options);
} catch (err) {
@@ -652,7 +1108,7 @@ export class GitHubClient {
state: "open" | "closed";
stateReason?: "completed" | "not_planned" | "reopened";
} | null> {
if (isGhAvailable() && isGhAuthenticated()) {
if (this.hasGhAuth()) {
try {
return await this.getIssueWithGh(owner, repo, number);
} catch (err) {

View File

@@ -1,2 +1,3 @@
export { createServer, type ServerOptions } from "./server.js";
export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams } from "./github.js";
export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import type { TaskStore, TaskAttachment } from "@kb/core";
import type { TaskDetail } from "@kb/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
@@ -1409,6 +1410,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(200);
expect(res.body.prInfo).toEqual(mockPrInfo);
expect(res.body.stale).toBe(false);
expect(res.body.automationStatus).toBeNull();
});
it("returns 404 when task has no PR", async () => {
@@ -1460,6 +1462,20 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.stale).toBe(true);
});
it("returns automationStatus so the UI can reflect PR-first waiting states", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
status: "awaiting-pr-checks",
prInfo: mockPrInfo,
updatedAt: new Date().toISOString(),
});
const res = await GET(buildApp(), "/api/tasks/KB-001/pr/status");
expect(res.status).toBe(200);
expect(res.body.automationStatus).toBe("awaiting-pr-checks");
});
it("marks data as fresh when lastCheckedAt is recent", async () => {
const recentCheck = new Date(Date.now() - 2 * 60 * 1000).toISOString(); // 2 minutes ago
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -1504,6 +1520,44 @@ describe("Pause/Unpause endpoints", () => {
commentCount: 3,
};
it("returns merge readiness details for PR-first UI refreshes", async () => {
const originalRepo = process.env.GITHUB_REPOSITORY;
process.env.GITHUB_REPOSITORY = "owner/repo";
vi.spyOn(GitHubClient.prototype, "getPrMergeStatus").mockResolvedValue({
prInfo: mockPrInfo,
mergeReady: false,
blockingReasons: ["required checks not successful: ci (pending)"],
reviewDecision: "CHANGES_REQUESTED",
checks: [{ name: "ci", required: true, state: "pending" }],
});
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
status: "awaiting-pr-checks",
prInfo: mockPrInfo,
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks/KB-001/pr/refresh",
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.prInfo.number).toBe(42);
expect(res.body.mergeReady).toBe(false);
expect(res.body.blockingReasons).toEqual(["required checks not successful: ci (pending)"]);
expect(res.body.reviewDecision).toBe("CHANGES_REQUESTED");
expect(res.body.automationStatus).toBe("awaiting-pr-checks");
if (originalRepo) {
process.env.GITHUB_REPOSITORY = originalRepo;
} else {
delete process.env.GITHUB_REPOSITORY;
}
});
it("returns 404 when task has no PR", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(FAKE_TASK_DETAIL);

View File

@@ -693,6 +693,31 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// Create refinement task from a completed or in-review task
router.post("/tasks/:id/refine", async (req, res) => {
try {
const { feedback } = req.body;
if (!feedback || typeof feedback !== "string") {
res.status(400).json({ error: "feedback is required and must be a string" });
return;
}
if (feedback.length === 0 || feedback.length > 2000) {
res.status(400).json({ error: "feedback must be between 1 and 2000 characters" });
return;
}
const refinedTask = await store.refineTask(req.params.id, feedback);
await store.logEntry(req.params.id, "Refinement requested", feedback);
res.status(201).json(refinedTask);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404
: err.message?.includes("must be in 'done' or 'in-review'") ? 400
: err.message?.includes("Feedback is required") ? 400
: 500;
res.status(status).json({ error: err.message });
}
});
// Archive task (done → archived)
router.post("/tasks/:id/archive", async (req, res) => {
try {
@@ -1576,6 +1601,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.json({
prInfo: task.prInfo,
stale: isStale,
automationStatus: task.status ?? null,
});
// Trigger background refresh if stale (don't await, let it run)
@@ -1635,18 +1661,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
// Fetch fresh PR status
// Fetch fresh PR status + merge readiness
const client = new GitHubClient(githubToken);
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
const prInfo = await client.getPrStatus(owner, repo, task.prInfo.number);
// Add lastCheckedAt timestamp
prInfo.lastCheckedAt = new Date().toISOString();
const prInfo = {
...mergeStatus.prInfo,
lastCheckedAt: new Date().toISOString(),
};
// Update stored PR info
await store.updatePrInfo(task.id, prInfo);
res.json(prInfo);
res.json({
prInfo,
mergeReady: mergeStatus.mergeReady,
blockingReasons: mergeStatus.blockingReasons,
reviewDecision: mergeStatus.reviewDecision,
checks: mergeStatus.checks,
automationStatus: task.status ?? null,
});
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });