feat(KB-262): add Pull Requests tab to GitHub Import modal
- Add listPullRequests and getPullRequest methods to GitHubClient - Add /github/pulls/fetch and /github/pulls/import API endpoints - Add apiFetchGitHubPulls and apiImportGitHubPull frontend API functions - Add tabbed UI for Issues and Pull Requests in GitHubImportModal - Add comprehensive PR tab tests (14 new tests) - Include changeset for the new PR tab feature
This commit is contained in:
@@ -1705,6 +1705,256 @@ export class GitHubClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List open pull requests from a repository.
|
||||
* Uses gh CLI if available, otherwise falls back to REST API.
|
||||
*/
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: { limit?: number }
|
||||
): Promise<Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
}>> {
|
||||
if (this.hasGhAuth()) {
|
||||
try {
|
||||
return await this.listPullRequestsWithGh(owner, repo, options);
|
||||
} catch (err) {
|
||||
if (this.token) {
|
||||
return this.listPullRequestsWithApi(owner, repo, options);
|
||||
}
|
||||
throw new Error(getGhErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.token) {
|
||||
return this.listPullRequestsWithApi(owner, repo, options);
|
||||
}
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate.");
|
||||
}
|
||||
|
||||
private async listPullRequestsWithGh(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: { limit?: number }
|
||||
): Promise<Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
}>> {
|
||||
const limit = options?.limit ?? 30;
|
||||
|
||||
const pulls = await runGhJsonAsync<Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
}>>([
|
||||
"pr", "list",
|
||||
"--repo", `${owner}/${repo}`,
|
||||
"--state", "open",
|
||||
"--limit", String(Math.min(limit, 100)),
|
||||
"--json", "number,title,body,url,headRefName,baseRefName",
|
||||
]);
|
||||
|
||||
return pulls.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body,
|
||||
html_url: pr.url,
|
||||
headBranch: pr.headRefName,
|
||||
baseBranch: pr.baseRefName,
|
||||
}));
|
||||
}
|
||||
|
||||
private async listPullRequestsWithApi(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: { limit?: number }
|
||||
): Promise<Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
}>> {
|
||||
const limit = options?.limit ?? 30;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("state", "open");
|
||||
params.append("per_page", String(Math.min(limit, 100)));
|
||||
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${params}`;
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Repository not found: ${owner}/${repo}`);
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
head: { ref: string };
|
||||
base: { ref: string };
|
||||
}>;
|
||||
|
||||
return data.slice(0, limit).map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body,
|
||||
html_url: pr.html_url,
|
||||
headBranch: pr.head.ref,
|
||||
baseBranch: pr.base.ref,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single pull request by number.
|
||||
* Uses gh CLI if available, otherwise falls back to REST API.
|
||||
* Returns null if the pull request is not found.
|
||||
*/
|
||||
async getPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
): Promise<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
state: "open" | "closed" | "merged";
|
||||
} | null> {
|
||||
if (this.hasGhAuth()) {
|
||||
try {
|
||||
return await this.getPullRequestWithGh(owner, repo, number);
|
||||
} catch (err) {
|
||||
if (this.token) {
|
||||
return this.getPullRequestWithApi(owner, repo, number);
|
||||
}
|
||||
throw new Error(getGhErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.token) {
|
||||
return this.getPullRequestWithApi(owner, repo, number);
|
||||
}
|
||||
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided. Run 'gh auth login' to authenticate.");
|
||||
}
|
||||
|
||||
private async getPullRequestWithGh(
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
): Promise<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
state: "open" | "closed" | "merged";
|
||||
} | null> {
|
||||
try {
|
||||
const pr = await runGhJsonAsync<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
state: "OPEN" | "CLOSED" | "MERGED";
|
||||
mergedAt?: string | null;
|
||||
}>([
|
||||
"pr", "view", String(number),
|
||||
"--repo", `${owner}/${repo}`,
|
||||
"--json", "number,title,body,url,headRefName,baseRefName,state,mergedAt",
|
||||
]);
|
||||
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body,
|
||||
html_url: pr.url,
|
||||
headBranch: pr.headRefName,
|
||||
baseBranch: pr.baseRefName,
|
||||
state: pr.mergedAt ? "merged" : this.mapGhPrState(pr.state),
|
||||
};
|
||||
} catch (err) {
|
||||
// gh pr view returns error if the PR doesn't exist
|
||||
if (err instanceof Error && err.message.includes("not found")) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPullRequestWithApi(
|
||||
owner: string,
|
||||
repo: string,
|
||||
number: number,
|
||||
): Promise<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
state: "open" | "closed" | "merged";
|
||||
} | null> {
|
||||
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`;
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
state: string;
|
||||
merged: boolean;
|
||||
head: { ref: string };
|
||||
base: { ref: string };
|
||||
};
|
||||
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
html_url: data.html_url,
|
||||
headBranch: data.head.ref,
|
||||
baseBranch: data.base.ref,
|
||||
state: data.merged ? "merged" : this.mapPrState(data.state) === "open" ? "open" : "closed",
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GitHub App Installation Auth Methods
|
||||
// ==========================================
|
||||
|
||||
@@ -2403,6 +2403,162 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/github/pulls/fetch
|
||||
* Fetch open pull requests from a GitHub repository.
|
||||
* Body: { owner: string, repo: string, limit?: number }
|
||||
* Returns: Array of GitHubPull objects
|
||||
*/
|
||||
router.post("/github/pulls/fetch", async (req, res) => {
|
||||
try {
|
||||
const { owner, repo, limit = 30 } = req.body;
|
||||
|
||||
if (!owner || typeof owner !== "string") {
|
||||
res.status(400).json({ error: "owner is required" });
|
||||
return;
|
||||
}
|
||||
if (!repo || typeof repo !== "string") {
|
||||
res.status(400).json({ error: "repo is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check gh authentication
|
||||
if (!isGhAuthenticated()) {
|
||||
res.status(401).json({
|
||||
error: "Not authenticated with GitHub. Run `gh auth login`.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new GitHubClient();
|
||||
|
||||
try {
|
||||
const pulls = await client.listPullRequests(owner, repo, { limit });
|
||||
res.json(pulls);
|
||||
} catch (err: any) {
|
||||
// Handle specific error cases from gh CLI
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
||||
res.status(404).json({ error: `Repository not found: ${owner}/${repo}` });
|
||||
return;
|
||||
}
|
||||
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
||||
res.status(401).json({
|
||||
error: "Not authenticated with GitHub. Run `gh auth login`.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(502).json({ error: `GitHub CLI error: ${errorMessage}` });
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/github/pulls/import
|
||||
* Import a specific GitHub pull request as a kb review task.
|
||||
* Body: { owner: string, repo: string, prNumber: number }
|
||||
* Returns: Created Task object
|
||||
*/
|
||||
router.post("/github/pulls/import", async (req, res) => {
|
||||
try {
|
||||
const { owner, repo, prNumber } = req.body;
|
||||
|
||||
if (!owner || typeof owner !== "string") {
|
||||
res.status(400).json({ error: "owner is required" });
|
||||
return;
|
||||
}
|
||||
if (!repo || typeof repo !== "string") {
|
||||
res.status(400).json({ error: "repo is required" });
|
||||
return;
|
||||
}
|
||||
if (!prNumber || typeof prNumber !== "number" || prNumber < 1) {
|
||||
res.status(400).json({ error: "prNumber is required and must be a positive number" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check gh authentication
|
||||
if (!isGhAuthenticated()) {
|
||||
res.status(401).json({
|
||||
error: "Not authenticated with GitHub. Run `gh auth login`.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new GitHubClient();
|
||||
|
||||
let pr: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
html_url: string;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
state: "open" | "closed" | "merged";
|
||||
} | null;
|
||||
|
||||
try {
|
||||
pr = await client.getPullRequest(owner, repo, prNumber);
|
||||
|
||||
if (pr === null) {
|
||||
res.status(404).json({ error: `PR #${prNumber} not found in ${owner}/${repo}` });
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (errorMessage.includes("not found") || errorMessage.includes("404")) {
|
||||
res.status(404).json({ error: `PR #${prNumber} not found in ${owner}/${repo}` });
|
||||
return;
|
||||
}
|
||||
if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) {
|
||||
res.status(401).json({
|
||||
error: "Not authenticated with GitHub. Run `gh auth login`.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(502).json({ error: `GitHub CLI error: ${errorMessage}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already imported
|
||||
const existingTasks = await store.listTasks();
|
||||
const sourceUrl = pr.html_url;
|
||||
for (const existingTask of existingTasks) {
|
||||
if (existingTask.description.includes(sourceUrl)) {
|
||||
res.status(409).json({
|
||||
error: `PR #${prNumber} already imported as ${existingTask.id}`,
|
||||
existingTaskId: existingTask.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the task with "Review PR:" prefix
|
||||
const title = `Review PR #${pr.number}: ${pr.title.slice(0, 180)}`;
|
||||
const body = pr.body?.trim() || "(no description)";
|
||||
const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`;
|
||||
|
||||
const task = await store.createTask({
|
||||
title: title || undefined,
|
||||
description,
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
// Log the import action
|
||||
await store.logEntry(task.id, "Imported PR from GitHub", sourceUrl);
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Auth routes ----------
|
||||
registerAuthRoutes(router, options?.authStorage);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user