Skip to main content

tix/commands/list/
scope.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// The board's **entry column**: the first configured column that is not the
5/// triage inbox (#1711).
6///
7/// Not simply the first column. A board whose columns start with the inbox —
8/// `tix init`'s own board does — would otherwise scope the default listing to
9/// **unaccepted candidates**, and this repository's recorded workflow reads
10/// `tix list --status todo`. The inbox has its own surface (`tix triage list`), and
11/// `tix create` still files into the board's first column, because deciding a new
12/// issue is exactly what the inbox is for.
13///
14/// With no `[triage]` section, or one that names no `entry_status`, the first
15/// configured column *is* the entry column, and a board whose only column is the
16/// inbox falls back to it rather than to nothing.
17pub fn entry_column(config: &crate::config::Config) -> Option<String> {
18    let inbox = config
19        .triage
20        .as_ref()
21        .and_then(|triage| triage.entry_status.as_deref());
22    let columns = &config.default_config().columns;
23    columns
24        .iter()
25        .find(|column| Some(column.as_str()) != inbox)
26        .or_else(|| columns.first())
27        .cloned()
28}
29
30/// The scope a listing uses when the caller asked for no other (#1711).
31///
32/// `status` is the board's entry column and `me` is the git author identity, each
33/// `None` when it cannot be derived: a board with no configured columns scopes
34/// nothing, and so does a checkout with no git identity — the listing then falls
35/// back to every issue rather than to an empty table that would read as "you have
36/// no work".
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ListScope {
39    pub status: Option<String>,
40    pub me: Option<crate::assignee::AuthorIdentity>,
41}
42
43/// The default scope: my issues in the board's entry column, or nothing at all
44/// under `--all`.
45pub fn default_scope(config: &crate::config::Config, all: bool) -> ListScope {
46    if all {
47        return ListScope::default();
48    }
49    ListScope {
50        status: entry_column(config),
51        me: crate::assignee::git_identity(),
52    }
53}
54
55/// Whether an issue is assigned to `me`.
56///
57/// The exact rendering is the case that matters: `tix create` without `--assignee`
58/// stores precisely [`AuthorIdentity`](crate::assignee::AuthorIdentity)'s
59/// `Display`. The tolerant forms cover what a human types by hand — the bare name,
60/// the email address, and its local part — so an issue assigned `@thyseus` is not
61/// silently "not mine" while an issue assigned to somebody else never matches.
62pub(super) fn is_mine(issue: &Issue, me: &crate::assignee::AuthorIdentity) -> bool {
63    let Some(assignee) = issue.assignee.as_deref() else {
64        return false;
65    };
66    if assignee == me.to_string() {
67        return true;
68    }
69    let handle = assignee.strip_prefix('@').unwrap_or(assignee);
70    if handle.eq_ignore_ascii_case(&me.name) {
71        return true;
72    }
73    me.email.as_deref().is_some_and(|email| {
74        handle.eq_ignore_ascii_case(email)
75            || handle.eq_ignore_ascii_case(email.split('@').next().unwrap_or_default())
76    })
77}
78
79/// One listing request's **selection**: the flags that choose which issues are
80/// asked for, as opposed to the ones that choose how they are shown.
81///
82/// Grouped into a type because the default scope's rule is about the *set* of
83/// them, not about any one: [`ListSelection::names_a_selection`] is the sentence
84/// the whole feature turns on, and four separate booleans at a call site is how
85/// that sentence gets written four times and drifts.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct ListSelection<'a> {
88    pub status: Option<&'a str>,
89    pub filter: Option<&'a str>,
90    pub project: Option<&'a str>,
91    pub epic: Option<&'a str>,
92    /// `--all`: the wide board, stated without naming anything.
93    pub all: bool,
94}
95
96impl ListSelection<'_> {
97    /// Whether the request names a selection of its own.
98    ///
99    /// The default scope is what a listing means with **no arguments**. The moment
100    /// the caller names a column, a query, a project or an epic, that is the
101    /// request — and composing it with an unstated default is how
102    /// `tix list --filter 'scope:triage'` returns an empty table: two scopes, one
103    /// of them never asked for. `--sort`, `--group`, `--columns`, `--show` and
104    /// `--format` order and print the listing rather than choosing it, so they
105    /// leave the scope alone.
106    pub(super) fn names_a_selection(&self) -> bool {
107        self.all
108            || self.status.is_some()
109            || self.filter.is_some()
110            || self.project.is_some()
111            || self.epic.is_some()
112    }
113}
114
115/// [`query`], with the default scope applied (#1711).
116///
117/// The one seam the CLI and MCP share, so `tix list` and MCP's `list_issues` with
118/// no arguments are the same listing rather than two readings of "the default".
119pub fn scoped_query(
120    store: &Store,
121    selection: &ListSelection<'_>,
122    with_archived: bool,
123) -> Result<Vec<Issue>> {
124    let scope = default_scope(&store.config, selection.names_a_selection());
125    let effective = selection.status.map(str::to_string).or(scope.status);
126    let mut issues = query(store, effective.as_deref(), selection.filter, with_archived)?;
127    if let Some(me) = scope.me.as_ref() {
128        issues.retain(|issue| is_mine(issue, me));
129    }
130    Ok(issues)
131}
132
133/// `tix ready`'s preset: the listing that answers "what do I pick up next".
134///
135/// It rides [`run`]'s one pipeline rather than duplicating the query, the column
136/// resolution, the derived reads and the rendering — `tix ready` is a *scope* on
137/// the listing, and a second implementation of the listing is how the two would
138/// start disagreeing about what an issue's blocked state is.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct ReadyOptions {
141    /// Cap the listing after it is ordered, so `--limit 1` is the single next
142    /// issue rather than "the first one in file order".
143    pub limit: Option<usize>,
144}
145
146/// Whether `tix ready` should treat `issue` as work that has not begun.
147///
148/// Two halves, because a board can answer the question either way:
149///
150/// * the **category** the board maps the column to. `backlog` and `unstarted` are
151///   the two that mean "not begun"; an active column is already under way and a
152///   terminal one is out of the running.
153/// * the **board order**, bounded by the entry column. `tix init`'s board leaves
154///   its working columns unmapped *deliberately* — an explicit map turns on
155///   adjacency enforcement, which would refuse the daily `in progress -> todo`
156///   move — so on such a board every non-terminal column reads as `unstarted` and
157///   the category alone cannot tell work under way from work not started. The
158///   order can: nothing is "next" after the column new work arrives in.
159///
160/// A column the board does not configure has no category, so it is not work this
161/// listing can place. `tix lint` is where that misconfiguration is reported.
162pub(super) fn is_startable(
163    issue: &Issue,
164    resolution: &crate::workflow::CategoryResolution,
165    board: &[String],
166    entry: Option<&str>,
167) -> bool {
168    let Some(category) = crate::workflow::category_of(issue, resolution) else {
169        return false;
170    };
171    if !matches!(
172        category,
173        crate::workflow::WorkflowCategory::Backlog | crate::workflow::WorkflowCategory::Unstarted
174    ) {
175        return false;
176    }
177    // No entry column (an empty board, or one whose entry is not in its own order)
178    // leaves the category as the whole answer.
179    let Some(entry_index) = entry.and_then(|entry| board.iter().position(|column| column == entry))
180    else {
181        return true;
182    };
183    board
184        .iter()
185        .position(|column| column == issue.status.display())
186        .is_none_or(|index| index <= entry_index)
187}
188
189/// The columns `tix ready` shows: the identity pair, the priority it is ordered
190/// by, and the title (#1711).
191///
192/// Priority is in the set because a ready list is *about* priority — dropping the
193/// column the rows were just sorted on would hide the reason for the order.
194pub(super) fn ready_columns(issues: &[Issue]) -> Vec<ListColumn> {
195    let mut columns = vec![
196        ListColumn::Num,
197        ListColumn::Id,
198        ListColumn::Priority,
199        ListColumn::Title,
200    ];
201    if issues.iter().any(|issue| issue.archived) {
202        columns.push(ListColumn::Archived);
203    }
204    columns
205}
206
207/// `tix ready`'s order: highest priority first, then oldest, then by number.
208///
209/// The tie-breaks matter more than the first key does. A repository where every
210/// ready issue is `medium` would otherwise list in file order, which changes when a
211/// file is renamed; `created` is stable and answers "what has been waiting longest".
212pub(super) fn sort_ready(issues: &mut [Issue]) {
213    issues.sort_by(|a, b| {
214        priority_rank(&a.priority)
215            .cmp(&priority_rank(&b.priority))
216            .then(a.created.cmp(&b.created))
217            .then(a.number.cmp(&b.number))
218    });
219}
220
221/// Where a priority sits in [`sort_ready`]'s order: lower is picked up sooner.
222///
223/// Priority is free-form on purpose (`urgent`, `blocker`, a project's own word), so
224/// the rank is a **documented policy**, not a comparison of the stored strings. The
225/// words this project's own surfaces write come first, then the built-ins, then an
226/// unset priority, then anything unrecognized — last, never guessed at. A `ready`
227/// list that silently promoted `P0` to the top would be inventing an order the
228/// repository never stated, and every unrecognized word would compete to be first.
229pub(super) fn priority_rank(priority: &crate::model::Priority) -> u8 {
230    match priority.to_string().to_ascii_lowercase().as_str() {
231        "critical" | "urgent" | "blocker" | "blocking" => 0,
232        "high" => 1,
233        "medium" => 2,
234        "low" => 3,
235        // Written `priority: ''` — explicitly no priority, which is not the same as
236        // the `medium` default and not high enough to pick up first either.
237        "" => 4,
238        _ => 5,
239    }
240}
241
242/// `tix ready`: the unblocked, unstarted work, ordered so the next issue is first.
243///
244/// A thin wrapper over [`run`] with the preset below — the flags it takes are the
245/// ones that still make sense on a ready list (which columns, how many, one
246/// project's or one epic's slice, and the structured formats).
247pub fn ready(
248    store: &Store,
249    columns: Option<&str>,
250    limit: Option<usize>,
251    project: Option<&str>,
252    epic: Option<&str>,
253    format: Option<&str>,
254) -> Result<()> {
255    run(
256        store,
257        None,
258        None,
259        project,
260        epic,
261        None,
262        None,
263        columns,
264        None,
265        false,
266        true,
267        format,
268        Some(ReadyOptions { limit }),
269    )
270}