Skip to main content

ytcli/cli/
worklog.rs

1//! Worklogs across issues.
2//!
3//! `issue worklogs PROJ-1` answers "what went into this issue"; this answers
4//! "where did my week go", which used to cost one request per issue and
5//! knowing which issues to ask about in the first place.
6//!
7//! A group of its own rather than a verb under `issue`, because `issue worklog`
8//! is the writing group and a host allowlists by prefix. `ytcli worklog find`
9//! shares no prefix with anything that writes.
10
11use std::io::Write as _;
12
13use clap::Subcommand;
14
15use crate::cli::{Session, emit, report};
16use crate::exit::ExitCode;
17use crate::render::{Format, machine, text};
18
19#[derive(Debug, Subcommand)]
20pub enum WorklogCommand {
21    /// Find worklog entries across every issue.
22    #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_FIND))]
23    Find {
24        /// Whose time: a login, or `me`.
25        #[arg(long, short = 'b')]
26        by: Option<String>,
27        /// From this date, or a span back from today: `7d`, `2w`, `2026-08-01`.
28        #[arg(long)]
29        since: Option<String>,
30        /// Up to this date. Same forms as --since.
31        #[arg(long)]
32        until: Option<String>,
33        /// How many entries to fetch.
34        #[arg(long, default_value_t = 100)]
35        limit: u32,
36    },
37}
38
39pub async fn run(command: &WorklogCommand, session: &Session) -> ExitCode {
40    let client = match session.client() {
41        Ok(client) => client,
42        Err(code) => return code,
43    };
44
45    let WorklogCommand::Find {
46        by,
47        since,
48        until,
49        limit,
50    } = command;
51
52    // `me` is not a login Tracker knows: `createdBy=me` answers 422 saying no
53    // such user exists. Resolving it here costs one request, and is the reason
54    // the convenience can exist at all.
55    let who = match by.as_deref() {
56        Some("me") => match client.myself().await {
57            Ok(user) => match user.login.or(Some(user.id)).filter(|id| !id.is_empty()) {
58                Some(login) => Some(login),
59                None => return report(&"this token has no login to search by", ExitCode::Auth),
60            },
61            Err(error) => {
62                let code = error.exit_code();
63                return report(&error, code);
64            }
65        },
66        other => other.map(ToOwned::to_owned),
67    };
68
69    let since = match since.as_deref().map(as_date) {
70        Some(Ok(date)) => Some(date),
71        Some(Err(error)) => return report(&error, ExitCode::ConfirmationRequired),
72        None => None,
73    };
74    let until = match until.as_deref().map(as_date) {
75        Some(Ok(date)) => Some(date),
76        Some(Err(error)) => return report(&error, ExitCode::ConfirmationRequired),
77        None => None,
78    };
79
80    match client
81        .worklog_search(who.as_deref(), since.as_deref(), until.as_deref(), *limit)
82        .await
83    {
84        Ok(entries) => {
85            let rendered = match session.render.format {
86                Format::Text => Ok(text::worklog_search(&entries, &session.render)),
87                Format::JsonRaw => machine(&entries, Format::Json),
88                other => machine(&entries, other),
89            };
90            match rendered {
91                Ok(text) => {
92                    emit(&text);
93                    // A page is a page here too, and this endpoint reports no
94                    // total to compare against — so the ceiling is named rather
95                    // than left to look like the whole answer.
96                    if u32::try_from(entries.len()).is_ok_and(|count| count >= *limit) {
97                        let mut err = anstream::stderr();
98                        let _ =
99                            writeln!(err, "stopped at --limit {limit}; there may be more entries");
100                    }
101                    ExitCode::Success
102                }
103                Err(error) => report(&error, ExitCode::Failure),
104            }
105        }
106        Err(error) => {
107            let code = error.exit_code();
108            report(&error, code)
109        }
110    }
111}
112
113/// A date, from a date or from a span back from today.
114///
115/// `7d` is what somebody asking about their week types, and turning it into a
116/// date here keeps the API parameter to the one form Tracker documents.
117fn as_date(value: &str) -> Result<String, String> {
118    let Some((count, unit)) = value.split_at_checked(value.len().saturating_sub(1)) else {
119        return Err(format!("cannot read `{value}` as a date or a span"));
120    };
121
122    let span = match (count.parse::<i64>(), unit) {
123        (Ok(count), "d") => jiff::Span::new().try_days(count),
124        (Ok(count), "w") => jiff::Span::new().try_weeks(count),
125        (Ok(count), "m") => jiff::Span::new().try_months(count),
126        // Not a span: a date, passed through for Tracker to accept or refuse.
127        // Guessing at date formats here would only add a second opinion.
128        _ => return Ok(value.to_owned()),
129    }
130    .map_err(|_| format!("`{value}` is too large a span"))?;
131
132    jiff::Zoned::now()
133        .checked_sub(span)
134        .map(|then| then.strftime("%Y-%m-%d").to_string())
135        .map_err(|_| format!("`{value}` lands outside the range of dates"))
136}
137
138#[cfg(test)]
139#[allow(clippy::expect_used)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn a_span_becomes_a_date_in_the_past() {
145        let today = jiff::Zoned::now().strftime("%Y-%m-%d").to_string();
146        let week = as_date("7d").expect("a week");
147
148        assert_eq!(week.len(), today.len());
149        assert!(week < today, "{week} is not before {today}");
150    }
151
152    /// Anything that is not a span is Tracker's to judge; a second opinion here
153    /// would only be a second thing to be wrong.
154    #[test]
155    fn a_date_passes_through_untouched() {
156        assert_eq!(as_date("2026-08-01").as_deref(), Ok("2026-08-01"));
157    }
158}