a73x

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