a73x

src/issue.rs

Ref:   Size: 7.1 KiB

use git2::Repository;

use crate::cli::{self, SortMode};
use crate::dag;
use crate::event::Action;
use crate::state::{self, IssueState};

pub fn open(
    repo: &Repository,
    title: &str,
    body: &str,
    relates_to: Option<&str>,
) -> Result<String, crate::error::Error> {
    let oid = dag::create_root_action(
        repo,
        Action::IssueOpen {
            title: title.to_string(),
            body: body.to_string(),
            relates_to: relates_to.map(|s| s.to_string()),
        },
    )?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "issue open")?;
    Ok(id)
}

pub struct ListEntry {
    pub issue: IssueState,
    pub unread: Option<usize>,
}

fn load_issues(repo: &Repository, show_archived: bool) -> Result<Vec<state::IssueState>, crate::error::Error> {
    if show_archived {
        state::list_issues_with_archived(repo)
    } else {
        state::list_issues(repo)
    }
}

pub fn list(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let issues = load_issues(repo, show_archived)?;
    let filtered = cli::filter_sort_paginate(issues, show_closed, sort, offset, limit);
    let entries = filtered
        .into_iter()
        .map(|issue| {
            let unread = count_unread(repo, &issue.id);
            ListEntry { issue, unread }
        })
        .collect();
    Ok(entries)
}

pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let entries = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if entries.is_empty() {
        writeln!(writer, "No issues found.").ok();
        return Ok(());
    }
    for e in &entries {
        let i = &e.issue;
        let status = i.status.as_str();
        let labels = if i.labels.is_empty() {
            String::new()
        } else {
            format!(" [{}]", i.labels.join(", "))
        };
        let unread = match e.unread {
            Some(n) if n > 0 => format!(" ({} new)", n),
            _ => String::new(),
        };
        writeln!(
            writer,
            "{:.8}  {:6}  {}{}  (by {}){}",
            i.id, status, i.title, labels, i.author.name, unread
        )
        .ok();
    }
    Ok(())
}

/// Count events after the last-seen mark. Returns None if never viewed.
fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
    let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
    let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
    let ref_name = format!("refs/collab/issues/{}", id);
    let tip = repo.refname_to_id(&ref_name).ok()?;

    if seen_oid == tip {
        return Some(0);
    }

    let mut revwalk = repo.revwalk().ok()?;
    revwalk
        .set_sorting(git2::Sort::TOPOLOGICAL)
        .ok()?;
    revwalk.push(tip).ok()?;
    revwalk.hide(seen_oid).ok()?;
    Some(revwalk.count())
}

pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let issues = load_issues(repo, show_archived)?;
    let filtered = cli::filter_sort_paginate(issues, show_closed, sort, None, None);
    Ok(serde_json::to_string_pretty(&filtered)?)
}

pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let issue = IssueState::from_ref(repo, &ref_name, &id)?;
    Ok(serde_json::to_string_pretty(&issue)?)
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let issue = IssueState::from_ref(repo, &ref_name, &id)?;
    // Mark as read: store current tip as seen
    let tip = repo.refname_to_id(&ref_name)?;
    let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
    repo.reference(&seen_ref, tip, true, "mark seen")?;
    Ok(issue)
}

pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueLabel {
        label: label.to_string(),
    })?;
    Ok(())
}

pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueUnlabel {
        label: label.to_string(),
    })?;
    Ok(())
}

pub fn assign(
    repo: &Repository,
    id_prefix: &str,
    assignee: &str,
) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueAssign {
        assignee: assignee.to_string(),
    })?;
    Ok(())
}

pub fn unassign(
    repo: &Repository,
    id_prefix: &str,
    assignee: &str,
) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueUnassign {
        assignee: assignee.to_string(),
    })?;
    Ok(())
}

pub fn edit(
    repo: &Repository,
    id_prefix: &str,
    title: Option<&str>,
    body: Option<&str>,
) -> Result<(), crate::error::Error> {
    if title.is_none() && body.is_none() {
        return Err(
            git2::Error::from_str("at least one of --title or --body must be provided").into(),
        );
    }
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueEdit {
        title: title.map(|s| s.to_string()),
        body: body.map(|s| s.to_string()),
    })?;
    Ok(())
}

pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueComment {
        body: body.to_string(),
    })?;
    Ok(())
}

pub fn close(
    repo: &Repository,
    id_prefix: &str,
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueClose {
        reason: reason.map(|s| s.to_string()),
    })?;
    // Archive the ref (move to refs/collab/archive/issues/)
    if ref_name.starts_with("refs/collab/issues/") {
        state::archive_issue_ref(repo, &id)?;
    }
    Ok(())
}

pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    repo.find_reference(&ref_name)?.delete()?;
    Ok(id)
}

pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::IssueReopen)?;
    Ok(())
}