07f643c1
Show closing commit and linked patches in issue detail
a73x 2026-03-21 08:12
Issue detail view now shows: - The commit OID that closed the issue - Backlinks to patches that reference the issue via --fixes - Labels and assignees - Patch status (open/closed/merged) in linked patches section TUI navigation: - g: follow links between issues and patches bidirectionally. Issue → linked patch, patch → linked issue. - o: check out the relevant commit locally. On a patch, checks out the head commit. On an issue, checks out the linked patch's head. Exits TUI and prints return instructions. Adds closed_by field to IssueState, populated from the close event's commit OID. Reopen clears the field. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/tui.rs b/src/tui.rs index cc1bf92..88eb978 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -132,6 +132,73 @@ impl App { self.pane = Pane::ItemList; } fn follow_link(&mut self) { match self.tab { Tab::Issues => { // From an issue, jump to the first patch that fixes it let visible = self.visible_issues(); if let Some(idx) = self.list_state.selected() { if let Some(issue) = visible.get(idx) { let issue_id = issue.id.clone(); // Find first patch with fixes == this issue let target = self .patches .iter() .enumerate() .find(|(_, p)| p.fixes.as_deref() == Some(&issue_id)); if let Some((patch_idx, _)) = target { // Need to find index in visible patches let visible_patches = self.visible_patches(); let visible_idx = visible_patches .iter() .position(|p| p.fixes.as_deref() == Some(&issue_id)); // If the patch isn't visible (filtered), toggle show_all if visible_idx.is_none() && !self.show_all { self.show_all = true; } let visible_patches = self.visible_patches(); if let Some(vi) = visible_patches .iter() .position(|p| p.id == self.patches[patch_idx].id) { self.tab = Tab::Patches; self.list_state.select(Some(vi)); self.scroll = 0; self.mode = ViewMode::Details; } } } } } Tab::Patches => { // From a patch, jump to the linked issue (fixes field) let visible = self.visible_patches(); if let Some(idx) = self.list_state.selected() { if let Some(patch) = visible.get(idx) { if let Some(ref fixes_id) = patch.fixes { let fixes_id = fixes_id.clone(); let visible_issues = self.visible_issues(); let visible_idx = visible_issues.iter().position(|i| i.id == fixes_id); if visible_idx.is_none() && !self.show_all { self.show_all = true; } let visible_issues = self.visible_issues(); if let Some(vi) = visible_issues.iter().position(|i| i.id == fixes_id) { self.tab = Tab::Issues; self.list_state.select(Some(vi)); self.scroll = 0; self.mode = ViewMode::Details; } } } } } } } fn reload(&mut self, repo: &Repository) { if let Ok(issues) = state::list_issues(repo) { self.issues = issues; @@ -244,6 +311,56 @@ fn run_loop( app.list_state .select(if count > 0 { Some(0) } else { None }); } KeyCode::Char('g') => { // Follow cross-references between issues and patches app.follow_link(); } KeyCode::Char('o') => { // Check out the relevant commit for local browsing let checkout_target = match app.tab { Tab::Patches => { let visible = app.visible_patches(); app.list_state .selected() .and_then(|idx| visible.get(idx)) .map(|p| p.head_commit.clone()) } Tab::Issues => { // Find linked patch's head commit let visible = app.visible_issues(); app.list_state .selected() .and_then(|idx| visible.get(idx)) .and_then(|issue| { app.patches .iter() .find(|p| p.fixes.as_deref() == Some(&issue.id)) .map(|p| p.head_commit.clone()) }) } }; if let Some(head) = checkout_target { // Exit TUI, checkout, and return terminal::disable_raw_mode()?; stdout().execute(LeaveAlternateScreen)?; let status = std::process::Command::new("git") .args(["checkout", &head]) .status(); match status { Ok(s) if s.success() => { println!("Checked out commit: {:.8}", head); println!("Use 'git checkout -' to return."); } Ok(s) => { eprintln!("git checkout exited with {}", s); } Err(e) => { eprintln!("Failed to run git checkout: {}", e); } } return Ok(()); } } KeyCode::Char('r') => { app.reload(repo); } @@ -853,7 +970,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { "a:show all" }; let text = format!( " 1:issues 2:patches j/k:navigate Tab:pane {}{} r:refresh q:quit", " 1:issues 2:patches j/k:navigate Tab:pane {}{} g:follow o:checkout r:refresh q:quit", filter_hint, mode_hint ); let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));