tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// The board's **entry column**: the first configured column that is not the
/// triage inbox (#1711).
///
/// Not simply the first column. A board whose columns start with the inbox —
/// `tix init`'s own board does — would otherwise scope the default listing to
/// **unaccepted candidates**, and this repository's recorded workflow reads
/// `tix list --status todo`. The inbox has its own surface (`tix triage list`), and
/// `tix create` still files into the board's first column, because deciding a new
/// issue is exactly what the inbox is for.
///
/// With no `[triage]` section, or one that names no `entry_status`, the first
/// configured column *is* the entry column, and a board whose only column is the
/// inbox falls back to it rather than to nothing.
pub fn entry_column(config: &crate::config::Config) -> Option<String> {
    let inbox = config
        .triage
        .as_ref()
        .and_then(|triage| triage.entry_status.as_deref());
    let columns = &config.default_config().columns;
    columns
        .iter()
        .find(|column| Some(column.as_str()) != inbox)
        .or_else(|| columns.first())
        .cloned()
}

/// The scope a listing uses when the caller asked for no other (#1711).
///
/// `status` is the board's entry column and `me` is the git author identity, each
/// `None` when it cannot be derived: a board with no configured columns scopes
/// nothing, and so does a checkout with no git identity — the listing then falls
/// back to every issue rather than to an empty table that would read as "you have
/// no work".
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListScope {
    pub status: Option<String>,
    pub me: Option<crate::assignee::AuthorIdentity>,
}

/// The default scope: my issues in the board's entry column, or nothing at all
/// under `--all`.
pub fn default_scope(config: &crate::config::Config, all: bool) -> ListScope {
    if all {
        return ListScope::default();
    }
    ListScope {
        status: entry_column(config),
        me: crate::assignee::git_identity(),
    }
}

/// Whether an issue is assigned to `me`.
///
/// The exact rendering is the case that matters: `tix create` without `--assignee`
/// stores precisely [`AuthorIdentity`](crate::assignee::AuthorIdentity)'s
/// `Display`. The tolerant forms cover what a human types by hand — the bare name,
/// the email address, and its local part — so an issue assigned `@thyseus` is not
/// silently "not mine" while an issue assigned to somebody else never matches.
pub(super) fn is_mine(issue: &Issue, me: &crate::assignee::AuthorIdentity) -> bool {
    let Some(assignee) = issue.assignee.as_deref() else {
        return false;
    };
    if assignee == me.to_string() {
        return true;
    }
    let handle = assignee.strip_prefix('@').unwrap_or(assignee);
    if handle.eq_ignore_ascii_case(&me.name) {
        return true;
    }
    me.email.as_deref().is_some_and(|email| {
        handle.eq_ignore_ascii_case(email)
            || handle.eq_ignore_ascii_case(email.split('@').next().unwrap_or_default())
    })
}

/// One listing request's **selection**: the flags that choose which issues are
/// asked for, as opposed to the ones that choose how they are shown.
///
/// Grouped into a type because the default scope's rule is about the *set* of
/// them, not about any one: [`ListSelection::names_a_selection`] is the sentence
/// the whole feature turns on, and four separate booleans at a call site is how
/// that sentence gets written four times and drifts.
#[derive(Debug, Clone, Copy, Default)]
pub struct ListSelection<'a> {
    pub status: Option<&'a str>,
    pub filter: Option<&'a str>,
    pub project: Option<&'a str>,
    pub epic: Option<&'a str>,
    /// `--all`: the wide board, stated without naming anything.
    pub all: bool,
}

impl ListSelection<'_> {
    /// Whether the request names a selection of its own.
    ///
    /// The default scope is what a listing means with **no arguments**. The moment
    /// the caller names a column, a query, a project or an epic, that is the
    /// request — and composing it with an unstated default is how
    /// `tix list --filter 'scope:triage'` returns an empty table: two scopes, one
    /// of them never asked for. `--sort`, `--group`, `--columns`, `--show` and
    /// `--format` order and print the listing rather than choosing it, so they
    /// leave the scope alone.
    pub(super) fn names_a_selection(&self) -> bool {
        self.all
            || self.status.is_some()
            || self.filter.is_some()
            || self.project.is_some()
            || self.epic.is_some()
    }
}

/// [`query`], with the default scope applied (#1711).
///
/// The one seam the CLI and MCP share, so `tix list` and MCP's `list_issues` with
/// no arguments are the same listing rather than two readings of "the default".
pub fn scoped_query(
    store: &Store,
    selection: &ListSelection<'_>,
    with_archived: bool,
) -> Result<Vec<Issue>> {
    let scope = default_scope(&store.config, selection.names_a_selection());
    let effective = selection.status.map(str::to_string).or(scope.status);
    let mut issues = query(store, effective.as_deref(), selection.filter, with_archived)?;
    if let Some(me) = scope.me.as_ref() {
        issues.retain(|issue| is_mine(issue, me));
    }
    Ok(issues)
}

/// `tix ready`'s preset: the listing that answers "what do I pick up next".
///
/// It rides [`run`]'s one pipeline rather than duplicating the query, the column
/// resolution, the derived reads and the rendering — `tix ready` is a *scope* on
/// the listing, and a second implementation of the listing is how the two would
/// start disagreeing about what an issue's blocked state is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadyOptions {
    /// Cap the listing after it is ordered, so `--limit 1` is the single next
    /// issue rather than "the first one in file order".
    pub limit: Option<usize>,
}

/// Whether `tix ready` should treat `issue` as work that has not begun.
///
/// Two halves, because a board can answer the question either way:
///
/// * the **category** the board maps the column to. `backlog` and `unstarted` are
///   the two that mean "not begun"; an active column is already under way and a
///   terminal one is out of the running.
/// * the **board order**, bounded by the entry column. `tix init`'s board leaves
///   its working columns unmapped *deliberately* — an explicit map turns on
///   adjacency enforcement, which would refuse the daily `in progress -> todo`
///   move — so on such a board every non-terminal column reads as `unstarted` and
///   the category alone cannot tell work under way from work not started. The
///   order can: nothing is "next" after the column new work arrives in.
///
/// A column the board does not configure has no category, so it is not work this
/// listing can place. `tix lint` is where that misconfiguration is reported.
pub(super) fn is_startable(
    issue: &Issue,
    resolution: &crate::workflow::CategoryResolution,
    board: &[String],
    entry: Option<&str>,
) -> bool {
    let Some(category) = crate::workflow::category_of(issue, resolution) else {
        return false;
    };
    if !matches!(
        category,
        crate::workflow::WorkflowCategory::Backlog | crate::workflow::WorkflowCategory::Unstarted
    ) {
        return false;
    }
    // No entry column (an empty board, or one whose entry is not in its own order)
    // leaves the category as the whole answer.
    let Some(entry_index) = entry.and_then(|entry| board.iter().position(|column| column == entry))
    else {
        return true;
    };
    board
        .iter()
        .position(|column| column == issue.status.display())
        .is_none_or(|index| index <= entry_index)
}

/// The columns `tix ready` shows: the identity pair, the priority it is ordered
/// by, and the title (#1711).
///
/// Priority is in the set because a ready list is *about* priority — dropping the
/// column the rows were just sorted on would hide the reason for the order.
pub(super) fn ready_columns(issues: &[Issue]) -> Vec<ListColumn> {
    let mut columns = vec![
        ListColumn::Num,
        ListColumn::Id,
        ListColumn::Priority,
        ListColumn::Title,
    ];
    if issues.iter().any(|issue| issue.archived) {
        columns.push(ListColumn::Archived);
    }
    columns
}

/// `tix ready`'s order: highest priority first, then oldest, then by number.
///
/// The tie-breaks matter more than the first key does. A repository where every
/// ready issue is `medium` would otherwise list in file order, which changes when a
/// file is renamed; `created` is stable and answers "what has been waiting longest".
pub(super) fn sort_ready(issues: &mut [Issue]) {
    issues.sort_by(|a, b| {
        priority_rank(&a.priority)
            .cmp(&priority_rank(&b.priority))
            .then(a.created.cmp(&b.created))
            .then(a.number.cmp(&b.number))
    });
}

/// Where a priority sits in [`sort_ready`]'s order: lower is picked up sooner.
///
/// Priority is free-form on purpose (`urgent`, `blocker`, a project's own word), so
/// the rank is a **documented policy**, not a comparison of the stored strings. The
/// words this project's own surfaces write come first, then the built-ins, then an
/// unset priority, then anything unrecognized — last, never guessed at. A `ready`
/// list that silently promoted `P0` to the top would be inventing an order the
/// repository never stated, and every unrecognized word would compete to be first.
pub(super) fn priority_rank(priority: &crate::model::Priority) -> u8 {
    match priority.to_string().to_ascii_lowercase().as_str() {
        "critical" | "urgent" | "blocker" | "blocking" => 0,
        "high" => 1,
        "medium" => 2,
        "low" => 3,
        // Written `priority: ''` — explicitly no priority, which is not the same as
        // the `medium` default and not high enough to pick up first either.
        "" => 4,
        _ => 5,
    }
}

/// `tix ready`: the unblocked, unstarted work, ordered so the next issue is first.
///
/// A thin wrapper over [`run`] with the preset below — the flags it takes are the
/// ones that still make sense on a ready list (which columns, how many, one
/// project's or one epic's slice, and the structured formats).
pub fn ready(
    store: &Store,
    columns: Option<&str>,
    limit: Option<usize>,
    project: Option<&str>,
    epic: Option<&str>,
    format: Option<&str>,
) -> Result<()> {
    run(
        store,
        None,
        None,
        project,
        epic,
        None,
        None,
        columns,
        None,
        false,
        true,
        format,
        Some(ReadyOptions { limit }),
    )
}