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}