feat(FN-4993): complete Step 1 — add draft and reviewer support in GitHubClient
Fusion-Task-Id: FN-4993 Fusion-Task-Lineage: 20ce6d5f-ef33-49e6-8519-648d769cc473
This commit is contained in:
committed by
gsxdsm
parent
d5b83b168d
commit
09491671ea
@@ -316,6 +316,113 @@ describe("GitHubClient", () => {
|
||||
|
||||
await expect(client.createPr(mockPrParams)).rejects.toThrow("gh auth login");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "draft true with reviewers",
|
||||
draft: true,
|
||||
reviewers: ["alice", "bob"],
|
||||
expectedFlags: ["--draft", "--reviewer", "alice,bob"],
|
||||
},
|
||||
{
|
||||
name: "no draft and empty reviewers",
|
||||
draft: undefined,
|
||||
reviewers: [],
|
||||
expectedFlags: [],
|
||||
},
|
||||
])("passes optional gh create flags: $name", async ({ draft, reviewers, expectedFlags }) => {
|
||||
mockRunGh.mockReturnValue("https://github.com/test-owner/test-repo/pull/42\n");
|
||||
|
||||
await client.createPr({
|
||||
...mockPrParams,
|
||||
draft,
|
||||
reviewers,
|
||||
});
|
||||
|
||||
const callArgs = mockRunGh.mock.calls[0][0];
|
||||
if (expectedFlags.length > 0) {
|
||||
expect(callArgs).toEqual(expect.arrayContaining(expectedFlags));
|
||||
} else {
|
||||
expect(callArgs).not.toContain("--draft");
|
||||
expect(callArgs).not.toContain("--reviewer");
|
||||
}
|
||||
});
|
||||
|
||||
it("sends draft body + reviewer request via REST API", async () => {
|
||||
mockRunGh.mockImplementation(() => {
|
||||
throw new Error("gh command failed");
|
||||
});
|
||||
const clientWithToken = new GitHubClient("ghp_token");
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
number: 42,
|
||||
html_url: "https://github.com/test-owner/test-repo/pull/42",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
draft: true,
|
||||
head: { ref: "feature-branch" },
|
||||
base: { ref: "main" },
|
||||
comments: 0,
|
||||
}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any);
|
||||
|
||||
const result = await clientWithToken.createPr({
|
||||
...mockPrParams,
|
||||
draft: true,
|
||||
reviewers: ["alice", "bob"],
|
||||
});
|
||||
|
||||
const createPrRequest = fetchSpy.mock.calls[0];
|
||||
expect(createPrRequest[0]).toContain("/repos/test-owner/test-repo/pulls");
|
||||
expect(createPrRequest[1]?.body).toContain('"draft":true');
|
||||
|
||||
const reviewersRequest = fetchSpy.mock.calls[1];
|
||||
expect(reviewersRequest[0]).toContain("/repos/test-owner/test-repo/pulls/42/requested_reviewers");
|
||||
expect(reviewersRequest[1]?.body).toBe(JSON.stringify({ reviewers: ["alice", "bob"] }));
|
||||
expect(result.draft).toBe(true);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns PR result when REST reviewer request fails", async () => {
|
||||
mockRunGh.mockImplementation(() => {
|
||||
throw new Error("gh command failed");
|
||||
});
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
const clientWithToken = new GitHubClient("ghp_token");
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
number: 99,
|
||||
html_url: "https://github.com/test-owner/test-repo/pull/99",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
head: { ref: "feature-branch" },
|
||||
base: { ref: "main" },
|
||||
comments: 0,
|
||||
}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: "Unprocessable",
|
||||
json: async () => ({ message: "Reviewers are invalid" }),
|
||||
} as any);
|
||||
|
||||
const result = await clientWithToken.createPr({
|
||||
...mockPrParams,
|
||||
reviewers: ["alice", "bob"],
|
||||
});
|
||||
|
||||
expect(result.number).toBe(99);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("failed to request reviewers for PR #99"));
|
||||
fetchSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrStatus", () => {
|
||||
|
||||
@@ -58,6 +58,10 @@ export interface CreatePrParams {
|
||||
body?: string;
|
||||
head: string;
|
||||
base?: string;
|
||||
/** Open the PR in draft state (gh `--draft`, REST `draft: true`). Default false. */
|
||||
draft?: boolean;
|
||||
/** GitHub login handles to request review from. Empty/undefined → no reviewers requested. */
|
||||
reviewers?: string[];
|
||||
}
|
||||
|
||||
export interface CreateIssueParams {
|
||||
@@ -625,7 +629,7 @@ export class GitHubClient {
|
||||
}
|
||||
|
||||
private createPrWithGh(params: CreatePrParams): PrInfo {
|
||||
const { owner: paramOwner, repo: paramRepo, title, body, head, base } = params;
|
||||
const { owner: paramOwner, repo: paramRepo, title, body, head, base, draft, reviewers } = params;
|
||||
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
|
||||
|
||||
// Build gh pr create command arguments (as array for safety)
|
||||
@@ -642,6 +646,13 @@ export class GitHubClient {
|
||||
if (base) {
|
||||
args.push("--base", base);
|
||||
}
|
||||
if (draft) {
|
||||
args.push("--draft");
|
||||
}
|
||||
if (reviewers && reviewers.length > 0) {
|
||||
// Prefer single create call: gh supports `pr create --reviewer <login[,login...]>`.
|
||||
args.push("--reviewer", reviewers.join(","));
|
||||
}
|
||||
|
||||
// Use gh-cli module to execute
|
||||
const result = runGh(args);
|
||||
@@ -667,7 +678,7 @@ export class GitHubClient {
|
||||
}
|
||||
|
||||
private async createPrWithApi(params: CreatePrParams): Promise<PrInfo> {
|
||||
const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main" } = params;
|
||||
const { owner: paramOwner, repo: paramRepo, title, body, head, base = "main", draft, reviewers } = params;
|
||||
const { owner, repo } = this.resolveRepo(paramOwner, paramRepo);
|
||||
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
|
||||
@@ -682,6 +693,7 @@ export class GitHubClient {
|
||||
body: body || "",
|
||||
head,
|
||||
base,
|
||||
draft: draft === true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -695,11 +707,32 @@ export class GitHubClient {
|
||||
html_url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
draft?: boolean;
|
||||
head: { ref: string };
|
||||
base: { ref: string };
|
||||
comments: number;
|
||||
};
|
||||
|
||||
if (reviewers && reviewers.length > 0) {
|
||||
const requestedReviewersUrl = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${data.number}/requested_reviewers`;
|
||||
try {
|
||||
const requestedReviewersResponse = await fetch(requestedReviewersUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ reviewers }),
|
||||
});
|
||||
if (!requestedReviewersResponse.ok) {
|
||||
const reviewerError = await requestedReviewersResponse.json().catch(() => ({ message: requestedReviewersResponse.statusText }));
|
||||
process.stderr.write(
|
||||
`[github] failed to request reviewers for PR #${data.number}: ${requestedReviewersResponse.status} ${reviewerError.message || requestedReviewersResponse.statusText}\n`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`[github] failed to request reviewers for PR #${data.number}: ${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return toPrInfo({
|
||||
url: data.html_url,
|
||||
number: data.number,
|
||||
@@ -708,6 +741,7 @@ export class GitHubClient {
|
||||
headBranch: data.head.ref,
|
||||
baseBranch: data.base.ref,
|
||||
commentCount: data.comments,
|
||||
isDraft: data.draft,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user