feat(git): improve PR panel (#207)

This commit is contained in:
Bohdan Triapitsyn
2026-01-23 19:54:28 +02:00
committed by GitHub
parent 614277eaa1
commit da20c647e2
9 changed files with 306 additions and 83 deletions
+30 -14
View File
@@ -53,11 +53,13 @@ Git tab layout
## Feature A: Git Tab PR Panel (Create / Status / Merge) ## Feature A: Git Tab PR Panel (Create / Status / Merge)
Status: implemented.
### Intent ### Intent
While working on a feature branch, show PR status and actions inside the Git tab, without leaving the app. While working on a feature branch, show PR status and actions inside the Git tab, without leaving the app.
### Placement ### Placement
Insert a new section between Commit and History in `packages/ui/src/components/views/GitView.tsx`. Implemented between Commit and History in `packages/ui/src/components/views/GitView.tsx`.
### Visibility Rules ### Visibility Rules
- Show only if: - Show only if:
@@ -66,7 +68,7 @@ Insert a new section between Commit and History in `packages/ui/src/components/v
Base branch source (reuse existing config): Base branch source (reuse existing config):
- `activeProject.worktreeDefaults.baseBranch` from `packages/ui/src/stores/useProjectsStore.ts` - `activeProject.worktreeDefaults.baseBranch` from `packages/ui/src/stores/useProjectsStore.ts`
- fallback to `status.tracking` remote HEAD logic later if needed (not required for v1). - fallback default: `main`
### UI States ### UI States
1) GitHub not connected 1) GitHub not connected
@@ -95,26 +97,23 @@ Base branch source (reuse existing config):
- If user has merge permission and PR is mergeable: - If user has merge permission and PR is mergeable:
- merge method dropdown (merge/squash/rebase) - merge method dropdown (merge/squash/rebase)
- “Merge” button - “Merge” button
- If PR is draft:
- “Ready” button (mark ready for review)
- Merge disabled until ready
- If cannot merge: - If cannot merge:
- disable merge button + show “Open in GitHub” - disable merge button + show “Open in GitHub”
### AI “Generate description” ### AI “Generate description”
Mirror commit message generation approach. Implemented as a PR-specific generator (separate prompt/endpoint/command).
Inputs: Inputs:
- base branch - base branch ref (prefers `origin/<base>` when available)
- branch name - head branch ref
- git diff for base...HEAD - committed range diff: `git diff <base>...<head>` (file list from `git diff --name-only <base>...<head>`)
- optionally selected files
Output: Output:
- title suggestion (optional) - `title` (<= 80 chars, no commit-style prefixes)
- body with sections: - `body` (GFM markdown sections: Summary/Testing/Notes)
- Summary
- Testing
- Notes
Reuse the same LLM infra used by commit generation in `packages/ui/src/components/views/GitView.tsx` (search for existing generation call and parallel it).
### Required GitHub API Calls ### Required GitHub API Calls
- Resolve repo from git remote URL (origin) - Resolve repo from git remote URL (origin)
@@ -122,11 +121,28 @@ Reuse the same LLM infra used by commit generation in `packages/ui/src/component
- Create PR - Create PR
- Get PR details + checks - Get PR details + checks
- Merge PR - Merge PR
- Mark PR ready for review (GraphQL)
Checks logic (implemented):
- prefer GitHub Actions check-runs (`/commits/{sha}/check-runs`)
- fallback to classic commit statuses (`/commits/{sha}/status`)
### Implementation Notes ### Implementation Notes
- Web runtime should use server endpoints + Octokit (token stays server-side). - Web runtime should use server endpoints + Octokit (token stays server-side).
- Desktop/vscode should use their runtime handlers (similar to GitHub auth) to avoid exposing token. - Desktop/vscode should use their runtime handlers (similar to GitHub auth) to avoid exposing token.
Implemented code pointers
- UI section: `packages/ui/src/components/views/git/PullRequestSection.tsx`
- Web server endpoints:
- `GET /api/github/pr/status`
- `POST /api/github/pr/create`
- `POST /api/github/pr/merge`
- `POST /api/github/pr/ready`
- PR description generator:
- `POST /api/git/pr-description`
- Desktop: `generate_pr_description`
- VS Code: `api:git/pr-description`
## Feature B: Start Session From GitHub Issue ## Feature B: Start Session From GitHub Issue
### Intent ### Intent
+10 -1
View File
@@ -2353,7 +2353,16 @@ pub async fn generate_pr_description(
} }
// 1. Collect PR range diffs (base...head) // 1. Collect PR range diffs (base...head)
let range = format!("{}...{}", base.trim(), head.trim()); let base_ref = base.trim();
let head_ref = head.trim();
let origin_candidate = format!("refs/remotes/origin/{}", base_ref);
let resolved_base = if run_git(&["rev-parse", "--verify", &origin_candidate], &root).await.is_ok() {
format!("origin/{}", base_ref)
} else {
base_ref.to_string()
};
let range = format!("{}...{}", resolved_base, head_ref);
let files = { let files = {
let args = vec!["diff", "--name-only", range.as_str()]; let args = vec!["diff", "--name-only", range.as_str()];
let raw = run_git(&args, &root).await.unwrap_or_default(); let raw = run_git(&args, &root).await.unwrap_or_default();
@@ -254,6 +254,20 @@ struct CombinedStatusResponse {
statuses: Vec<CombinedStatusEntry>, statuses: Vec<CombinedStatusEntry>,
} }
#[derive(Debug, Deserialize)]
struct CheckRunEntry {
#[serde(default)]
status: Option<String>,
#[serde(default)]
conclusion: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CheckRunsResponse {
#[serde(default)]
check_runs: Vec<CheckRunEntry>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct PermissionResponse { struct PermissionResponse {
permission: String, permission: String,
@@ -849,41 +863,93 @@ pub async fn github_pr_status(
); );
let pr = github_get_json::<PullDetailsResponse>(&pr_url, &stored.access_token).await?; let pr = github_get_json::<PullDetailsResponse>(&pr_url, &stored.access_token).await?;
// Checks summary // Checks summary: prefer check-runs (Actions), fallback to classic statuses
let mut checks: Option<GitHubChecksSummary> = None; let mut checks: Option<GitHubChecksSummary> = None;
let status_url = format!(
"{}/{}/{}/commits/{}/status", let check_runs_url = format!(
"{}/{}/{}/commits/{}/check-runs",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha
); );
if let Ok(status) = github_get_json::<CombinedStatusResponse>(&status_url, &stored.access_token).await {
let mut success = 0; if let Ok(runs) = github_get_json::<CheckRunsResponse>(&check_runs_url, &stored.access_token).await {
let mut failure = 0; if !runs.check_runs.is_empty() {
let mut pending = 0; let mut success = 0;
for s in status.statuses.iter() { let mut failure = 0;
match s.state.as_str() { let mut pending = 0;
"success" => success += 1,
"failure" | "error" => failure += 1, for run in runs.check_runs.iter() {
"pending" => pending += 1, let status = run.status.as_deref().unwrap_or("");
_ => {} let conclusion = run.conclusion.as_deref().unwrap_or("");
if status == "queued" || status == "in_progress" {
pending += 1;
continue;
}
if conclusion.is_empty() {
pending += 1;
continue;
}
if conclusion == "success" || conclusion == "neutral" || conclusion == "skipped" {
success += 1;
} else {
failure += 1;
}
} }
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
}
}
if checks.is_none() {
let status_url = format!(
"{}/{}/{}/commits/{}/status",
API_PULLS_URL_PREFIX, repo.owner, repo.repo, pr.head.sha
);
if let Ok(status) = github_get_json::<CombinedStatusResponse>(&status_url, &stored.access_token).await {
let mut success = 0;
let mut failure = 0;
let mut pending = 0;
for s in status.statuses.iter() {
match s.state.as_str() {
"success" => success += 1,
"failure" | "error" => failure += 1,
"pending" => pending += 1,
_ => {}
}
}
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
} }
let total = success + failure + pending;
let state = if failure > 0 {
"failure"
} else if pending > 0 {
"pending"
} else if total > 0 {
"success"
} else {
"unknown"
};
checks = Some(GitHubChecksSummary {
state: state.to_string(),
total,
success,
failure,
pending,
});
} }
// Permissions (best-effort) // Permissions (best-effort)
@@ -233,9 +233,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
<div <div
ref={containerRef} ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col" className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
style={{ touchAction: 'pan-y' }}
> >
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2"> <ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" /> <RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -251,7 +251,7 @@ export const PullRequestSection: React.FC<{
{checks ? ( {checks ? (
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground"> <span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} /> <span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checks.total > 0 ? `${checks.success}/${checks.total}` : checks.state} {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`}
</span> </span>
) : null} ) : null}
</div> </div>
+24 -2
View File
@@ -699,7 +699,18 @@ export async function getGitRangeDiff(
if (!baseRef || !headRef) { if (!baseRef || !headRef) {
return { diff: '' }; return { diff: '' };
} }
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${baseRef}...${headRef}`, '--', filePath];
let resolvedBase = baseRef;
try {
const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory);
if (verify.exitCode === 0) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const args = ['diff', '--no-color', `-U${Math.max(0, contextLines)}`, `${resolvedBase}...${headRef}`, '--', filePath];
const result = await execGit(args, directory); const result = await execGit(args, directory);
return { diff: result.stdout }; return { diff: result.stdout };
} }
@@ -717,7 +728,18 @@ export async function getGitRangeFiles(
if (!baseRef || !headRef) { if (!baseRef || !headRef) {
return []; return [];
} }
const args = ['diff', '--name-only', `${baseRef}...${headRef}`];
let resolvedBase = baseRef;
try {
const verify = await execGit(['rev-parse', '--verify', `refs/remotes/origin/${baseRef}`], directory);
if (verify.exitCode === 0) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const args = ['diff', '--name-only', `${resolvedBase}...${headRef}`];
const result = await execGit(args, directory); const result = await execGit(args, directory);
if (result.exitCode !== 0) return []; if (result.exitCode !== 0) return [];
return String(result.stdout || '') return String(result.stdout || '')
+53 -11
View File
@@ -180,24 +180,66 @@ export const getPullRequestStatus = async (
let checks: GitHubChecksSummary | null = null; let checks: GitHubChecksSummary | null = null;
if (pr.headSha) { if (pr.headSha) {
const statusResp = await githubFetch( // Prefer check-runs (Actions)
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`, const runsResp = await githubFetch(
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/check-runs`,
accessToken, accessToken,
); );
const statusJson = await jsonOrNull<JsonRecord>(statusResp); const runsJson = await jsonOrNull<JsonRecord>(runsResp);
if (statusResp.ok && statusJson) { const runs = Array.isArray((runsJson as JsonRecord | null)?.check_runs)
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : []; ? ((runsJson as JsonRecord).check_runs as unknown[])
: [];
if (runsResp.ok && runs.length > 0) {
const counts = { success: 0, failure: 0, pending: 0 }; const counts = { success: 0, failure: 0, pending: 0 };
statuses.forEach((s) => { runs.forEach((r) => {
const st = readString((s as JsonRecord | null)?.state); const rec = (r && typeof r === 'object') ? (r as JsonRecord) : null;
if (st === 'success') counts.success += 1; const status = readString(rec?.status);
else if (st === 'failure' || st === 'error') counts.failure += 1; const conclusion = readString(rec?.conclusion);
else if (st === 'pending') counts.pending += 1; if (status === 'queued' || status === 'in_progress') {
counts.pending += 1;
return;
}
if (!conclusion) {
counts.pending += 1;
return;
}
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
counts.success += 1;
} else {
counts.failure += 1;
}
}); });
const total = counts.success + counts.failure + counts.pending; const total = counts.success + counts.failure + counts.pending;
const state2 = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); const state2 = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
checks = { state: state2, total, ...counts }; checks = { state: state2, total, ...counts };
} }
// Fallback: classic statuses
if (!checks) {
const statusResp = await githubFetch(
`${API_BASE}/repos/${repo.owner}/${repo.repo}/commits/${pr.headSha}/status`,
accessToken,
);
const statusJson = await jsonOrNull<JsonRecord>(statusResp);
if (statusResp.ok && statusJson) {
const statuses = Array.isArray(statusJson.statuses) ? (statusJson.statuses as unknown[]) : [];
const counts = { success: 0, failure: 0, pending: 0 };
statuses.forEach((s) => {
const st = readString((s as JsonRecord | null)?.state);
if (st === 'success') counts.success += 1;
else if (st === 'failure' || st === 'error') counts.failure += 1;
else if (st === 'pending') counts.pending += 1;
});
const total = counts.success + counts.failure + counts.pending;
const state2 = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
checks = { state: state2, total, ...counts };
}
}
} }
let canMerge = false; let canMerge = false;
+64 -20
View File
@@ -4094,26 +4094,70 @@ async function main(options = {}) {
return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false }); return res.json({ connected: true, repo, branch, pr: null, checks: null, canMerge: false });
} }
// Checks summary (combined status) // Checks summary: prefer check-runs (Actions), fallback to classic statuses.
let checks = null; let checks = null;
try { const sha = prData.head?.sha;
const combined = await octokit.rest.repos.getCombinedStatusForRef({ if (sha) {
owner: repo.owner, try {
repo: repo.repo, const runs = await octokit.rest.checks.listForRef({
ref: prData.head?.sha, owner: repo.owner,
}); repo: repo.repo,
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : []; ref: sha,
const counts = { success: 0, failure: 0, pending: 0 }; per_page: 100,
statuses.forEach((s) => { });
if (s.state === 'success') counts.success += 1; const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1; if (checkRuns.length > 0) {
else if (s.state === 'pending') counts.pending += 1; const counts = { success: 0, failure: 0, pending: 0 };
}); for (const run of checkRuns) {
const total = counts.success + counts.failure + counts.pending; const status = run?.status;
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown')); const conclusion = run?.conclusion;
checks = { state, total, ...counts }; if (status === 'queued' || status === 'in_progress') {
} catch { counts.pending += 1;
checks = null; continue;
}
if (!conclusion) {
counts.pending += 1;
continue;
}
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
counts.success += 1;
} else {
counts.failure += 1;
}
}
const total = counts.success + counts.failure + counts.pending;
const state = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
checks = { state, total, ...counts };
}
} catch {
// ignore and fall back
}
if (!checks) {
try {
const combined = await octokit.rest.repos.getCombinedStatusForRef({
owner: repo.owner,
repo: repo.repo,
ref: sha,
});
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
const counts = { success: 0, failure: 0, pending: 0 };
statuses.forEach((s) => {
if (s.state === 'success') counts.success += 1;
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
else if (s.state === 'pending') counts.pending += 1;
});
const total = counts.success + counts.failure + counts.pending;
const state = counts.failure > 0
? 'failure'
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
checks = { state, total, ...counts };
} catch {
checks = null;
}
}
} }
// Permission check (best-effort) // Permission check (best-effort)
@@ -4814,7 +4858,7 @@ async function main(options = {}) {
} }
} }
if (diffs.length === 0) { if (diffs.length === 0) {
return res.status(400).json({ error: 'No diffs available for selected files' }); return res.status(400).json({ error: 'No diffs available for base...head' });
} }
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n'); const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
+27 -2
View File
@@ -440,11 +440,24 @@ export async function getRangeDiff(directory, { base, head, path, contextLines =
throw new Error('base and head are required'); throw new Error('base and head are required');
} }
// Prefer remote-tracking base ref so merged commits don't reappear
// when local base branch is stale (common when user stays on feature branch).
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const args = ['diff', '--no-color']; const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`); args.push(`-U${Math.max(0, contextLines)}`);
} }
args.push(`${baseRef}...${headRef}`); args.push(`${resolvedBase}...${headRef}`);
if (path) { if (path) {
args.push('--', path); args.push('--', path);
} }
@@ -459,7 +472,19 @@ export async function getRangeFiles(directory, { base, head } = {}) {
if (!baseRef || !headRef) { if (!baseRef || !headRef) {
throw new Error('base and head are required'); throw new Error('base and head are required');
} }
const raw = await git.raw(['diff', '--name-only', `${baseRef}...${headRef}`]);
let resolvedBase = baseRef;
const originCandidate = `refs/remotes/origin/${baseRef}`;
try {
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
if (verified && verified.trim()) {
resolvedBase = `origin/${baseRef}`;
}
} catch {
// ignore
}
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
return String(raw || '') return String(raw || '')
.split('\n') .split('\n')
.map((l) => l.trim()) .map((l) => l.trim())