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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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` });
|
||||
|
||||
Reference in New Issue
Block a user