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:
gsxdsm
2026-03-31 06:03:45 -07:00
parent dc1cdcd3aa
commit de094990a9
7 changed files with 1096 additions and 69 deletions

View File

@@ -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
// ==========================================

View File

@@ -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);