a73x

68da52b8

Show closing commit and linked patches in issue detail

a73x   2026-03-21 08:05

Issue detail view (CLI and TUI) now shows:
- The commit OID that closed the issue
- Backlinks to patches that reference the issue via --fixes
- Patch status (open/closed/merged) in the linked patches section

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/state.rs b/src/state.rs
index 6445b04..e7823a0 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -25,6 +25,7 @@ pub struct IssueState {
    pub body: String,
    pub status: IssueStatus,
    pub close_reason: Option<String>,
    pub closed_by: Option<Oid>,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub comments: Vec<Comment>,
@@ -96,6 +97,7 @@ impl IssueState {
                        body,
                        status: IssueStatus::Open,
                        close_reason: None,
                        closed_by: None,
                        labels: Vec::new(),
                        assignees: Vec::new(),
                        comments: Vec::new(),
@@ -118,6 +120,7 @@ impl IssueState {
                        if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
                            s.status = IssueStatus::Closed;
                            s.close_reason = reason;
                            s.closed_by = Some(oid);
                            status_ts = Some(event.timestamp.clone());
                        }
                    }
@@ -160,6 +163,8 @@ impl IssueState {
                    if let Some(ref mut s) = state {
                        if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
                            s.status = IssueStatus::Open;
                            s.close_reason = None;
                            s.closed_by = None;
                            status_ts = Some(event.timestamp.clone());
                        }
                    }
diff --git a/src/tui.rs b/src/tui.rs
index 013a4e8..cc1bf92 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -403,7 +403,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
            let visible = app.visible_issues();
            let selected_idx = app.list_state.selected().unwrap_or(0);
            match visible.get(selected_idx) {
                Some(issue) => build_issue_detail(issue),
                Some(issue) => build_issue_detail(issue, &app.patches),
                None => Text::raw("No issues to display."),
            }
        }
@@ -440,7 +440,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) {
    frame.render_widget(para, area);
}

fn build_issue_detail(issue: &IssueState) -> Text<'static> {
fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> {
    let status = match issue.status {
        IssueStatus::Open => "open",
        IssueStatus::Closed => "closed",
@@ -492,6 +492,47 @@ fn build_issue_detail(issue: &IssueState) -> Text<'static> {
        ]));
    }

    if let Some(ref oid) = issue.closed_by {
        lines.push(Line::from(vec![
            Span::styled("Commit:  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{:.8}", oid),
                Style::default().fg(Color::Cyan),
            ),
        ]));
    }

    // Show patches that reference this issue via --fixes
    let fixing_patches: Vec<&PatchState> = patches
        .iter()
        .filter(|p| p.fixes.as_deref() == Some(&issue.id))
        .collect();
    if !fixing_patches.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Linked Patches ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for p in &fixing_patches {
            let status = match p.status {
                PatchStatus::Open => ("open", Color::Green),
                PatchStatus::Closed => ("closed", Color::Red),
                PatchStatus::Merged => ("merged", Color::Cyan),
            };
            lines.push(Line::from(vec![
                Span::styled(
                    format!("{:.8}", p.id),
                    Style::default().fg(Color::Yellow),
                ),
                Span::raw("  "),
                Span::styled(status.0, Style::default().fg(status.1)),
                Span::raw(format!("  {}", p.title)),
            ]));
        }
    }

    if !issue.body.is_empty() {
        lines.push(Line::raw(""));
        for l in issue.body.lines() {