diff --git a/src-tauri/src/git/forge.rs b/src-tauri/src/git/forge.rs index d46a8e6..7ee7230 100644 --- a/src-tauri/src/git/forge.rs +++ b/src-tauri/src/git/forge.rs @@ -1,6 +1,7 @@ use super::repo::{remote_target, RemoteProvider}; use crate::error::{AppError, AppResult}; use git2::Repository; +use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; @@ -74,9 +75,234 @@ pub fn create_pr(repo: &Repository, title: &str, body: &str, base: &str) -> AppR Ok(url) } +/// A pull request (GitHub) / merge request (GitLab), normalized across providers. +#[derive(Serialize)] +pub struct PullRequest { + pub number: u64, + pub title: String, + pub url: String, + /// Source branch (`headRefName` / `source_branch`) - links a PR to a branch. + pub branch: String, + pub draft: bool, + pub author: String, + /// Author avatar URL, or None when the provider doesn't hand one out cheaply. + pub author_avatar: Option, + /// Rolled-up CI: "success" | "failure" | "pending" | "none". + pub checks: String, + /// "approved" | "changes_requested" | "review_required" | "none". + pub review: String, +} + +/// Open pull/merge requests for the repo's remote, via the user's `gh`/`glab` +/// CLI. Returns an empty list for non-forge remotes rather than erroring, so the +/// UI just hides the section. GitLab yields a degraded row (no CI/review/avatar - +/// `glab mr list` doesn't include them). +pub fn list_prs(repo: &Repository) -> AppResult> { + let Some(target) = remote_target(repo) else { + return Ok(Vec::new()); + }; + let dir = super::workdir(repo)?; + match target.provider { + RemoteProvider::Github => list_github(dir), + RemoteProvider::Gitlab => list_gitlab(dir), + } +} + +#[derive(Deserialize)] +struct GhPr { + number: u64, + title: String, + url: String, + #[serde(rename = "headRefName")] + head_ref_name: String, + #[serde(rename = "isDraft")] + is_draft: bool, + author: GhAuthor, + #[serde(rename = "statusCheckRollup", default)] + status_check_rollup: Vec, + #[serde(rename = "reviewDecision", default)] + review_decision: Option, +} +#[derive(Deserialize)] +struct GhAuthor { + #[serde(default)] + login: String, +} +/// Heterogeneous rollup entry: a CheckRun (status+conclusion) or a StatusContext +/// (state). All optional so unknown shapes just don't contribute. +#[derive(Deserialize)] +struct GhCheck { + #[serde(default)] + status: Option, + #[serde(default)] + conclusion: Option, + #[serde(default)] + state: Option, +} + +fn list_github(dir: &Path) -> AppResult> { + let out = run_cli( + dir, + &[ + "gh", "pr", "list", "--state", "open", "--limit", "50", "--json", + "number,title,url,headRefName,isDraft,author,statusCheckRollup,reviewDecision", + ], + )?; + let prs: Vec = serde_json::from_str(&out) + .map_err(|e| AppError::Msg(format!("could not parse `gh pr list` output: {e}")))?; + Ok(prs + .into_iter() + .map(|p| { + let author_avatar = (!p.login().is_empty()) + .then(|| format!("https://github.com/{}.png?size=40", p.author.login)); + PullRequest { + number: p.number, + title: p.title, + url: p.url, + branch: p.head_ref_name, + draft: p.is_draft, + author: p.author.login.clone(), + author_avatar, + checks: rollup_checks(&p.status_check_rollup), + review: normalize_review(p.review_decision.as_deref()), + } + }) + .collect()) +} + +impl GhPr { + fn login(&self) -> &str { + &self.author.login + } +} + +/// Roll a GitHub statusCheckRollup up to one word: any failure wins, then any +/// still-running, then success; empty/all-unknown -> "none". +fn rollup_checks(checks: &[GhCheck]) -> String { + let mut any_pending = false; + let mut any_success = false; + for c in checks { + // CheckRun: not yet COMPLETED means it's still running. + if let Some(s) = &c.status { + if s != "COMPLETED" { + any_pending = true; + continue; + } + } + // Normalized outcome from either a CheckRun conclusion or a StatusContext state. + let outcome = c.conclusion.as_deref().or(c.state.as_deref()).unwrap_or(""); + match outcome.to_ascii_uppercase().as_str() { + "FAILURE" | "ERROR" | "CANCELLED" | "TIMED_OUT" | "ACTION_REQUIRED" + | "STARTUP_FAILURE" => return "failure".into(), + "SUCCESS" | "NEUTRAL" | "SKIPPED" => any_success = true, + "PENDING" | "EXPECTED" | "IN_PROGRESS" | "QUEUED" => any_pending = true, + _ => {} + } + } + if any_pending { + "pending".into() + } else if any_success { + "success".into() + } else { + "none".into() + } +} + +fn normalize_review(decision: Option<&str>) -> String { + match decision { + Some("APPROVED") => "approved", + Some("CHANGES_REQUESTED") => "changes_requested", + Some("REVIEW_REQUIRED") => "review_required", + _ => "none", + } + .to_string() +} + +#[derive(Deserialize)] +struct GlMr { + iid: u64, + title: String, + web_url: String, + source_branch: String, + #[serde(default)] + draft: bool, + #[serde(default)] + work_in_progress: bool, + #[serde(default)] + author: GlAuthor, +} +#[derive(Deserialize, Default)] +struct GlAuthor { + #[serde(default)] + username: String, +} + +fn list_gitlab(dir: &Path) -> AppResult> { + let out = run_cli(dir, &["glab", "mr", "list", "--output", "json"])?; + let mrs: Vec = serde_json::from_str(&out) + .map_err(|e| AppError::Msg(format!("could not parse `glab mr list` output: {e}")))?; + Ok(mrs + .into_iter() + .map(|m| PullRequest { + number: m.iid, + title: m.title, + url: m.web_url, + branch: m.source_branch, + draft: m.draft || m.work_in_progress, + author: m.author.username, + author_avatar: None, // glab doesn't hand out an avatar URL in list output + checks: "none".into(), + review: "none".into(), + }) + .collect()) +} + #[cfg(test)] mod tests { - use super::first_url; + use super::{first_url, normalize_review, rollup_checks, GhCheck}; + + fn check(status: Option<&str>, conclusion: Option<&str>, state: Option<&str>) -> GhCheck { + GhCheck { + status: status.map(String::from), + conclusion: conclusion.map(String::from), + state: state.map(String::from), + } + } + + #[test] + fn rolls_up_checks_failure_wins() { + assert_eq!(rollup_checks(&[]), "none"); + assert_eq!( + rollup_checks(&[check(Some("COMPLETED"), Some("SUCCESS"), None)]), + "success" + ); + // A still-running check makes the whole thing pending... + assert_eq!( + rollup_checks(&[ + check(Some("COMPLETED"), Some("SUCCESS"), None), + check(Some("IN_PROGRESS"), None, None), + ]), + "pending" + ); + // ...but any failure wins outright. + assert_eq!( + rollup_checks(&[ + check(Some("IN_PROGRESS"), None, None), + check(Some("COMPLETED"), Some("FAILURE"), None), + ]), + "failure" + ); + // StatusContext form (state, no CheckRun status). + assert_eq!(rollup_checks(&[check(None, None, Some("success"))]), "success"); + } + + #[test] + fn normalizes_review_decision() { + assert_eq!(normalize_review(Some("APPROVED")), "approved"); + assert_eq!(normalize_review(Some("CHANGES_REQUESTED")), "changes_requested"); + assert_eq!(normalize_review(None), "none"); + assert_eq!(normalize_review(Some("weird")), "none"); + } #[test] fn extracts_the_pr_url_from_cli_output() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a6b8f4..61406d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,21 @@ fn create_pr(repo: String, title: String, body: String, base: String) -> AppResu forge::create_pr(&open(&repo)?, &title, &body, &base) } +#[tauri::command(async)] +fn list_prs(repo: String) -> AppResult> { + forge::list_prs(&open(&repo)?) +} + +#[tauri::command(async)] +fn open_url(url: String) -> AppResult<()> { + // Only web URLs - never a file:// or an arbitrary scheme, since the URL comes + // from CLI output; the OS opener must not be handed anything else. + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err(error::AppError::Msg("refusing to open a non-web URL".into())); + } + files::open_url(&url) +} + #[tauri::command] fn commit(repo: String, message: String) -> AppResult { ops::commit(&open(&repo)?, &message) @@ -503,6 +518,8 @@ pub fn run() { file_history, file_blame, create_pr, + list_prs, + open_url, commit, commit_amend, checkout, diff --git a/src/api.ts b/src/api.ts index 1c6f1d6..0bffc40 100644 --- a/src/api.ts +++ b/src/api.ts @@ -8,6 +8,7 @@ import type { FileContent, FileDiff, FileHistoryEntry, + PullRequest, ReflogNode, RepoInfo, SequencerState, @@ -79,6 +80,10 @@ export const fileBlame = (repo: string, path: string, rev: string | null) => /// Create a PR (GitHub) / MR (GitLab) via the gh/glab CLI; resolves to its URL. export const createPr = (repo: string, title: string, body: string, base: string) => invoke("create_pr", { repo, title, body, base }); +/// Open pull/merge requests for the repo's remote (empty for non-forge remotes). +export const listPrs = (repo: string) => invoke("list_prs", { repo }); +/// Open a web URL in the default browser (backend validates it's http/https). +export const openUrl = (url: string) => invoke("open_url", { url }); export const commit = (repo: string, message: string) => invoke("commit", { repo, message }); diff --git a/src/components/GraphView.tsx b/src/components/GraphView.tsx index 7e39650..70e5174 100644 --- a/src/components/GraphView.tsx +++ b/src/components/GraphView.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperti import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; import type { CommitNode, RefKind, RefLabel, WorkStats } from "../types"; import { avatarUrl, type AvatarContext, edgePath, LANE_COLORS, laneColor, relativeTime } from "../util"; -import { HeadIcon, LocalIcon, RemoteIcon, StashIcon, TagIcon } from "../icons"; +import { BranchIcon, HeadIcon, LocalIcon, RemoteIcon, StashIcon, TagIcon } from "../icons"; import { getGraphColumnVisibility, getGraphCols, @@ -82,6 +82,8 @@ interface Props { canLoadMore: boolean; onLoadMore: () => void; avatarCtx: AvatarContext; + /// Branch names (short) with an open PR/MR - their badge shows the fork icon. + prBranches: ReadonlySet; } /// Renders the commit DAG: an SVG of lanes/edges/dots on the left, aligned @@ -106,6 +108,7 @@ export default function GraphView({ canLoadMore, onLoadMore, avatarCtx, + prBranches, }: Props) { // Resizable column widths (persisted) + sort direction. const [cols, setCols] = useState(getGraphCols); @@ -758,6 +761,7 @@ export default function GraphView({ onBranchMenu(branchName, isRemote, n.id) } @@ -840,6 +844,7 @@ interface BranchRefGroup { function CommitRefs({ refs, color, + prBranches, onBranchMenu, onTagMenu, }: { @@ -847,6 +852,8 @@ function CommitRefs({ /// Lane color of the commit these refs sit on; branch/remote/HEAD badges are /// tinted with it so a badge matches the line it belongs to (GitKraken). color: string; + /// Branch names with an open PR/MR - those badges show the fork icon. + prBranches: ReadonlySet; onBranchMenu: (branchName: string, isRemote: boolean) => void; onTagMenu: (tagName: string) => void; }) { @@ -870,6 +877,7 @@ function CommitRefs({ title={branchGroupTitle(g)} current={g.locals.length > 0 && isHead} color={color} + hasPr={prBranches.has(g.name)} onContextMenu={() => onBranchMenu(g.locals[0] ?? g.remotes[0] ?? g.name, !g.locals.length) } @@ -927,6 +935,7 @@ function RefBadge({ title, current, color, + hasPr, onContextMenu, }: { kinds: RefKind[]; @@ -934,6 +943,8 @@ function RefBadge({ title?: string; current?: boolean; color?: string; + /// The branch has an open PR/MR - show the fork icon instead of the monitor. + hasPr?: boolean; onContextMenu?: () => void; }) { const primary = kinds.includes("branch") ? "branch" : kinds[0]; @@ -974,17 +985,21 @@ function RefBadge({ )} {name} - {kinds.map((kind) => ( - - ))} + {hasPr ? ( + // A branch with an open PR shows a single fork, not monitor+cloud - for + // local, remote-only (origin/x you haven't checked out), or both. + + ) : ( + kinds.map((kind) => ) + )} ); } /// Icon per ref kind, from the shared icon set so badges match the left sidebar. -/// A local branch shows the same monitor/PC glyph as the sidebar's "Local" -/// section. (The fork glyph is reserved for branches with an open PR/MR later.) -function RefIcon({ kind }: { kind: RefKind }) { +/// A local branch shows the monitor/PC glyph (like the sidebar's "Local" section), +/// or the fork glyph when it has an open PR/MR. +function RefIcon({ kind, hasPr }: { kind: RefKind; hasPr?: boolean }) { switch (kind) { case "tag": return ; @@ -995,6 +1010,6 @@ function RefIcon({ kind }: { kind: RefKind }) { case "head": return ; default: - return ; + return hasPr ? : ; } } diff --git a/src/components/RepoView.tsx b/src/components/RepoView.tsx index e36c8f3..6b45758 100644 --- a/src/components/RepoView.tsx +++ b/src/components/RepoView.tsx @@ -11,6 +11,7 @@ import type { CommitNode, FileContent, FileDiff, + PullRequest, RepoInfo, SequencerState, StatusResult, @@ -62,6 +63,7 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props const [worktrees, setWorktrees] = useState([]); const [submodules, setSubmodules] = useState([]); const [stashes, setStashes] = useState([]); + const [prs, setPrs] = useState([]); const [wips, setWips] = useState>({}); const [status, setStatus] = useState(EMPTY_STATUS); const [workStats, setWorkStats] = useState(null); @@ -237,6 +239,26 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props }; }, [selectedCommit, path]); + // Open PRs/MRs for the sidebar section + the badge fork-icon. A network CLI hit, + // so it's OFF the hot refresh() path: fetched on load / provider change and via + // the section's manual refresh, never after every git op. Non-forge repo -> []. + const refreshPrs = useCallback(() => { + if (repo?.provider !== "github" && repo?.provider !== "gitlab") { + setPrs([]); + return; + } + api.listPrs(path).then(setPrs).catch(() => {}); + }, [path, repo?.provider]); + useEffect(() => refreshPrs(), [refreshPrs]); + + // Branch name -> its open PR, for the graph badge icon + "Open pull request" menu. + const prByBranch = useMemo(() => { + const m = new Map(); + for (const pr of prs) if (!m.has(pr.branch)) m.set(pr.branch, pr); + return m; + }, [prs]); + const prBranchSet = useMemo(() => new Set(prByBranch.keys()), [prByBranch]); + // Cmd/Ctrl+F opens commit search; Cmd/Ctrl+K opens the command palette (active // tab only). useEffect(() => { @@ -362,6 +384,15 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props await (await Menu.new({ items })).popup(); }; + const openPr = (url: string) => run(async () => void (await api.openUrl(url))); + const showPrMenu = async (pr: PullRequest) => { + const items = await Promise.all([ + MenuItem.new({ text: `Open #${pr.number} in browser`, action: () => openPr(pr.url) }), + MenuItem.new({ text: "Copy URL", action: () => run(async () => (await api.copyText(pr.url), notify("URL copied"))) }), + ]); + await (await Menu.new({ items })).popup(); + }; + // Load another page of commits into the graph (search beyond the window). const loadMore = () => run(async () => { @@ -1010,8 +1041,15 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props const pl = providerLabel(); // Remote branches carry the "origin/" prefix; the web tree URL wants the bare name. const webRef = branch.is_remote ? shortRemoteBranchName(branch.name) : branch.name; + const prForBranch = prByBranch.get(webRef); const topItems = await Promise.all([ + ...(prForBranch + ? [ + MenuItem.new({ text: `Open pull request #${prForBranch.number}`, action: () => openPr(prForBranch.url) }), + PredefinedMenuItem.new({ item: "Separator" }), + ] + : []), ...(isCurrent ? [ MenuItem.new({ text: "Pull (fast-forward if possible)", action: () => onPullAction("ff") }), @@ -1633,9 +1671,13 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props worktrees={worktrees} submodules={submodules} stashes={stashes} + prs={prs} wips={wips} selectedCommit={selectedCommit} onSelectBranch={goToCommit} + onOpenPr={openPr} + onPrMenu={showPrMenu} + onRefreshPrs={refreshPrs} onCheckout={onCheckout} onMerge={onMerge} onBranchMenu={showBranchMenu} @@ -1758,6 +1800,7 @@ export default function RepoView({ path, isActive, onLoaded, onOpenPath }: Props canLoadMore={nodes.length >= graphLimit} onLoadMore={loadMore} avatarCtx={avatarCtx} + prBranches={prBranchSet} /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 49b650f..cc79df1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,8 +1,8 @@ import { useState, type ReactNode } from "react"; -import type { BranchInfo, StashInfo, SubmoduleInfo, TagInfo, WorktreeInfo } from "../types"; +import type { BranchInfo, PullRequest, StashInfo, SubmoduleInfo, TagInfo, WorktreeInfo } from "../types"; import { relativeTime } from "../util"; import { getSidebarGroups, setSidebarGroups } from "../storage"; -import { LocalIcon, LockIcon, RemoteIcon, StashIcon, TagIcon } from "../icons"; +import { CheckIcon, CloseIcon, LocalIcon, LockIcon, PullRequestIcon, RemoteIcon, StashIcon, TagIcon } from "../icons"; const WorktreeIcon = () => ( + + + ); + return (
} count={local.length} open={open.local} onToggle={() => toggle("local")} onMenu={() => onSectionMenu("local")}> @@ -205,6 +229,50 @@ export default function Sidebar({ ))} + } count={prs.length} open={open.pullRequests} onToggle={() => toggle("pullRequests")} actions={prs.length ? prActions : undefined}> + {prs.length === 0 &&
No open pull requests
} + {prs.map((pr) => ( +
onOpenPr(pr.url)} + onContextMenu={(e) => { + e.preventDefault(); + onPrMenu(pr); + }} + > + {pr.author_avatar ? ( + + ) : ( + + )} + #{pr.number} + {pr.checks !== "none" && ( + + )} + {pr.review === "approved" && ( + + + + )} + {pr.review === "changes_requested" && ( + + + + )} + {pr.title} + {pr.draft && ( + + draft + + )} +
+ ))} +
+ } count={tags.length} open={open.tags} onToggle={() => toggle("tags")} onMenu={() => onSectionMenu("tags")}> {tags.length === 0 &&
No tags
} {tags.map((t) => ( diff --git a/src/icons.tsx b/src/icons.tsx index 68a39c2..629d59e 100644 --- a/src/icons.tsx +++ b/src/icons.tsx @@ -69,3 +69,19 @@ export const CloseIcon = ({ size = 13 }: IconProps) => ( ); +/// Git pull/merge request glyph - the Pull Requests sidebar section. +export const PullRequestIcon = ({ size = 13 }: IconProps) => ( + + + + + + + + +); +export const CheckIcon = ({ size = 13 }: IconProps) => ( + + + +); diff --git a/src/storage.ts b/src/storage.ts index 9672d21..aca3381 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -24,6 +24,7 @@ const SIDEBAR_KEY = "gitchef.sidebarGroups"; export interface SidebarGroups { local: boolean; remote: boolean; + pullRequests: boolean; tags: boolean; worktrees: boolean; submodules: boolean; @@ -36,6 +37,7 @@ export function getSidebarGroups(): SidebarGroups { return { local: true, remote: true, + pullRequests: true, tags: true, worktrees: true, submodules: true, diff --git a/src/styles.css b/src/styles.css index 9e54f75..9d6ab2f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -690,6 +690,62 @@ button { .branch-row.submodule.uninit .branch-name { color: var(--text-dim); } + +/* ---- Pull Requests section ---- */ +.branch-row.pr { + gap: 6px; +} +.pr-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + flex: 0 0 auto; +} +.pr-avatar-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-elev2); + color: var(--text-dim); + font-size: 9px; + font-weight: 600; +} +.pr-num { + flex: 0 0 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--text-dim); +} +/* CI rollup dot: green pass / red fail / amber running. */ +.pr-ci { + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: 50%; +} +.pr-ci-success { + background: var(--add); +} +.pr-ci-failure { + background: var(--del); +} +.pr-ci-pending { + background: #e0af68; +} +.pr-review { + flex: 0 0 auto; + display: inline-flex; + align-items: center; +} +.pr-review.approved { + color: var(--add); +} +.pr-review.changes { + color: var(--del); +} +.pr-title { + flex: 1 1 auto; +} .stash-time { color: var(--text-dim); font-size: 11px; diff --git a/src/types.ts b/src/types.ts index cfb0c7d..3816057 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,20 @@ export interface BranchInfo { target: string | null; } +/// A pull request (GitHub) / merge request (GitLab), normalized across providers. +/// GitLab rows are degraded: checks/review are "none" and author_avatar is null. +export interface PullRequest { + number: number; + title: string; + url: string; + branch: string; // source branch - links a PR to a branch row/badge + draft: boolean; + author: string; + author_avatar: string | null; + checks: "success" | "failure" | "pending" | "none"; + review: "approved" | "changes_requested" | "review_required" | "none"; +} + export interface TagInfo { name: string; target: string; // commit SHA the tag points at