Skip to main content

wsb_github/
cli.rs

1//! `wsb github` — the machine's GitHub mirror from the command line.
2//!
3//! Every verb is an operation of [`crate::service::Service`], the same one any
4//! other surface performs: reads come from the machine's mirror, brought
5//! current first when they are due, and writes go to GitHub first and are
6//! mirrored from GitHub's answer.
7//!
8//! # Names, not ids
9//!
10//! A board is named by its number or its title, a field and an option by
11//! their names, a card by its id, its title or the URL it points at. Ids still
12//! work everywhere. A name that matches two things is refused with both,
13//! never picked: moving the wrong card is not something the answer would
14//! reveal.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use crate::boards::{self, BoardRef, ProjectGitHub};
19use crate::checkout;
20use crate::client::{
21    GitHubClient, GitHubProject, GitHubProjectField, GitHubProjectItem, GitHubProjectValue,
22    ProjectFieldWrite,
23};
24use crate::mirror::{Mirror, Scope};
25use crate::output::{Output, ProjectOption, ProjectValue, Record};
26use crate::service::{Failure, IssueState, Service};
27use crate::sync;
28use crate::targets::Targets;
29use crate::token;
30
31pub const USAGE: &str = "\
32Usage: wsb github <command> [options]
33
34The machine's GitHub mirror, in $PRJ/.state. Reads answer from it, brought
35current first when due; writes go to GitHub first and the mirror takes GitHub's
36answer. The token is the GitHub CLI's login (gh auth token), else GITHUB_TOKEN.
37
38Mirror
39  sync [--force]         bring everything every project needs up to date
40  status                 where the mirror and the token are, and what is failing
41
42A project's boards (references only; the boards live in the mirror)
43  boards                 the boards this project references
44  boards add <owner>/<number>...
45  boards remove <owner>/<number>...
46
47Issues
48  issues [--repo <owner>/<repo>] [--state open|closed|all]
49                         this worktree's repositories' issues, newest first
50
51Boards
52  project [list]         this project's boards
53  project show <board>   one board's fields, and the options of each
54  project items <board> [--status <column>] [--where <field>=<value>]... [--archived]
55                         the board's cards, by column
56  project item <board> <card>
57                         one card, whole: its fields and its description
58  project add <board> --title <text> [--body <text> | --body-file <path>] [--set <field>=<value>]...
59  project add <board> --url <issue or pull request URL> [--set <field>=<value>]...
60  project set <board> <card> <field>=<value>...
61  project clear <board> <card> <field>...
62  project move <board> <card> --after <card> | --top
63  project edit <board> <card> [--title <text>] [--body <text> | --body-file <path>]
64  project archive <board> <card>, project unarchive <board> <card>
65  project remove <board> <card>
66  project column add <board> <name> [--field <field>] [--color <COLOR>] [--description <text>]
67  project column remove <board> <name> [--field <field>]
68
69A <board> is its number, owner/number, its title or its id. A <card> is its id,
70the start of its id, the URL it points at, or its exact title. A name that
71matches more than one is refused with every match.
72
73Every command acts on the project the current directory is in; --project <name>
74names another. --fresh, on a reading project command, reads GitHub again first
75and refuses if it could not. --body-file - reads the body from standard input.
76
77--jsonl makes every line one JSON record: project_board, project_field,
78project_item, project_item_removed, board_reference, issue, sync_scope,
79sync_problem, sync_summary, status, message, failure.";
80
81/// How a command ended short of doing what was asked.
82///
83/// A script must be able to tell "you called this wrongly" (2) from "it did
84/// not work" (1).
85#[derive(Debug, PartialEq)]
86pub(crate) enum Refusal {
87    Usage(String),
88    Failed(String),
89}
90
91impl From<String> for Refusal {
92    fn from(message: String) -> Self {
93        Refusal::Failed(message)
94    }
95}
96
97impl From<Failure> for Refusal {
98    fn from(failure: Failure) -> Self {
99        match failure {
100            Failure::Invalid(message) => Refusal::Usage(message),
101            other => Refusal::Failed(other.to_string()),
102        }
103    }
104}
105
106impl From<anyhow::Error> for Refusal {
107    fn from(error: anyhow::Error) -> Self {
108        Refusal::Failed(format!("{error:#}"))
109    }
110}
111
112fn usage(message: impl Into<String>) -> Refusal {
113    Refusal::Usage(message.into())
114}
115
116/// Run `wsb github` with the words after it. Returns the exit status.
117pub fn run(args: Vec<String>) -> u8 {
118    let jsonl = args.iter().any(|arg| arg == "--jsonl");
119    let args: Vec<String> = args.into_iter().filter(|arg| arg != "--jsonl").collect();
120    let out = Output::new(jsonl);
121
122    if matches!(args.first().map(String::as_str), None | Some("help" | "--help" | "-h")) {
123        out.say([USAGE.to_string()]);
124        return if args.is_empty() { 2 } else { 0 };
125    }
126
127    let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
128        Ok(runtime) => runtime,
129        Err(error) => {
130            out.fail(&format!("cannot start: {error}"));
131            return 1;
132        }
133    };
134    let result = runtime.block_on(dispatch(&out, &args));
135    match result {
136        Ok(()) => 0,
137        Err(Refusal::Usage(message)) => {
138            out.fail(&message);
139            if !out.is_jsonl() {
140                eprintln!("\n{USAGE}");
141            }
142            2
143        }
144        Err(Refusal::Failed(message)) => {
145            out.fail(&message);
146            1
147        }
148    }
149}
150
151async fn dispatch(out: &Output, args: &[String]) -> Result<(), Refusal> {
152    let verb = args[0].as_str();
153    let rest = &args[1..];
154    match verb {
155        "sync" => sync_command(out, rest).await,
156        "status" => status(out, rest),
157        "boards" => boards_command(out, rest),
158        "issues" => issues(out, rest),
159        "project" => project_command(out, rest).await,
160        other => Err(usage(format!("unknown command '{other}'"))),
161    }
162}
163
164// ---------------------------------------------------------------------------
165// The command line
166// ---------------------------------------------------------------------------
167
168/// A command's arguments, sorted into what they are.
169#[derive(Debug, Default, PartialEq)]
170pub(crate) struct Arguments {
171    pub(crate) positionals: Vec<String>,
172    /// Every value given for an option, in order: `--set` may be repeated.
173    values: BTreeMap<String, Vec<String>>,
174    switches: BTreeSet<String>,
175}
176
177impl Arguments {
178    /// Read `args` against the options a command takes.
179    ///
180    /// An option the command does not take is refused. Reading it as a
181    /// positional would make `--titel x` a card called `--titel`, and dropping
182    /// it would run the command without something the caller asked for.
183    pub(crate) fn read(args: &[String], valued: &[&str], switches: &[&str]) -> Result<Self, Refusal> {
184        let mut read = Arguments::default();
185        let mut index = 0;
186        while index < args.len() {
187            let arg = args[index].as_str();
188            // `--project` is every command's: it says whose these are.
189            if valued.contains(&arg) || arg == "--project" {
190                let value = args
191                    .get(index + 1)
192                    .ok_or_else(|| usage(format!("{arg} needs a value")))?;
193                read.values.entry(arg.to_string()).or_default().push(value.clone());
194                index += 2;
195                continue;
196            }
197            if switches.contains(&arg) {
198                read.switches.insert(arg.to_string());
199            } else if arg.starts_with("--") {
200                return Err(usage(format!("this command takes no option {arg}")));
201            } else {
202                read.positionals.push(arg.to_string());
203            }
204            index += 1;
205        }
206        Ok(read)
207    }
208
209    /// The one value of an option given at most once.
210    fn value(&self, name: &str) -> Result<Option<&str>, Refusal> {
211        match self.values.get(name).map(Vec::as_slice) {
212            None => Ok(None),
213            Some([one]) => Ok(Some(one.as_str())),
214            Some(several) => Err(usage(format!(
215                "{name} was given {} times and is taken once",
216                several.len()
217            ))),
218        }
219    }
220
221    fn all(&self, name: &str) -> &[String] {
222        self.values.get(name).map(Vec::as_slice).unwrap_or(&[])
223    }
224
225    fn switch(&self, name: &str) -> bool {
226        self.switches.contains(name)
227    }
228
229    fn positional(&self, index: usize, what: &str) -> Result<&str, Refusal> {
230        self.positionals
231            .get(index)
232            .map(String::as_str)
233            .ok_or_else(|| usage(format!("{what} is required")))
234    }
235
236    /// Exactly `count` positionals: one more is a mistake, not something to
237    /// ignore — `remove 1 a b` must not remove `a` and say nothing of `b`.
238    fn exactly(&self, count: usize) -> Result<(), Refusal> {
239        match self.positionals.get(count) {
240            None => Ok(()),
241            Some(extra) => Err(usage(format!("unexpected argument '{extra}'"))),
242        }
243    }
244
245    /// The positionals a command takes, each named, all present and no more.
246    ///
247    /// Checked before anything is read: a mistake at the command line must be
248    /// refused as one, whatever the mirror or the network would have said.
249    fn named(&self, names: &[&str]) -> Result<(), Refusal> {
250        for (index, what) in names.iter().enumerate() {
251            self.positional(index, what)?;
252        }
253        self.exactly(names.len())
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Where this is, and what it can reach
259// ---------------------------------------------------------------------------
260
261/// `$PRJ`, the project these arguments mean, and the worktree when the
262/// current directory is in one.
263struct Place {
264    prj: wsb_prj::Prj,
265    project: wsb_prj::Project,
266    /// The checkout whose repositories a command means: the worktree the
267    /// current directory is in, or the main worktree of a named project.
268    checkout: std::path::PathBuf,
269}
270
271fn place(arguments: &Arguments) -> Result<Place, Refusal> {
272    let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
273    if let Some(name) = arguments.value("--project")? {
274        let project = prj.project(name).map_err(|e| Refusal::Failed(e.to_string()))?;
275        let checkout = project.main_worktree();
276        return Ok(Place {
277            prj,
278            project,
279            checkout,
280        });
281    }
282    let cwd = std::env::current_dir()
283        .map_err(|e| Refusal::Failed(format!("cannot determine the current directory: {e}")))?;
284    let location = prj.locate(&cwd).map_err(|e| {
285        Refusal::Failed(format!("{e}. Run this inside a project, or name one with --project <name>"))
286    })?;
287    let checkout = match &location.worktree {
288        Some(worktree) => location.project.dir().join(wsb_prj::worktree_folder(worktree)),
289        None => location.project.main_worktree(),
290    };
291    Ok(Place {
292        prj,
293        project: location.project,
294        checkout,
295    })
296}
297
298fn service_of(prj: &wsb_prj::Prj) -> Result<Service, Refusal> {
299    Ok(Service::new(Mirror::of(prj)?))
300}
301
302fn client() -> Result<GitHubClient, token::NoToken> {
303    token::resolve().map(|token| GitHubClient::new(token.value()))
304}
305
306/// Bring `scopes` current before they are read: when due, or whether due or
307/// not with `fresh`.
308///
309/// `fresh` is a request for the truth as of now, so answering it from the old
310/// mirror because the read failed would be the one thing it was asked not to
311/// do. Without it, a read that could not refresh answers from what is held
312/// and says so — unless nothing was ever held, which is no answer at all.
313async fn refresh(out: &Output, service: &Service, scopes: &[Scope], fresh: bool) -> Result<(), Refusal> {
314    let never_read = |scope: &Scope| -> Result<bool, Refusal> {
315        Ok(!service.mirror().state(scope)?.ever_synced())
316    };
317    let client = match client() {
318        Ok(client) => client,
319        Err(no_token) => {
320            if fresh {
321                return Err(Refusal::Failed(no_token.to_string()));
322            }
323            for scope in scopes {
324                if never_read(scope)? {
325                    return Err(Refusal::Failed(format!(
326                        "{} has never been read, and {no_token}",
327                        scope.label()
328                    )));
329                }
330            }
331            out.warn(&format!("{no_token}; answering from the mirror"));
332            return Ok(());
333        }
334    };
335    let report = sync::sync_scopes(service.mirror(), &client, scopes, fresh).await;
336    for (scope, error) in &report.failed {
337        let readable = Scope::parse(scope).map(|s| s.label()).unwrap_or_else(|| scope.clone());
338        let unread = match Scope::parse(scope) {
339            Some(parsed) => never_read(&parsed)?,
340            None => true,
341        };
342        if fresh || unread {
343            return Err(Refusal::Failed(format!("{readable} could not be read from GitHub: {error}")));
344        }
345        out.warn(&format!(
346            "{readable} could not be refreshed ({error}); answering from what was read before"
347        ));
348    }
349    Ok(())
350}
351
352// ---------------------------------------------------------------------------
353// sync, status
354// ---------------------------------------------------------------------------
355
356async fn sync_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
357    let arguments = Arguments::read(args, &[], &["--force"])?;
358    arguments.exactly(0)?;
359    let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
360    let service = service_of(&prj)?;
361    let targets = Targets::of(&prj)?;
362    let client = client();
363    let report = sync::sync(
364        service.mirror(),
365        client.as_ref().ok(),
366        &targets,
367        arguments.switch("--force"),
368    )
369    .await;
370
371    for scope in &report.changed {
372        out.emit(Record::SyncScope {
373            scope: scope.clone(),
374            outcome: "changed".to_string(),
375            error: None,
376        });
377    }
378    for scope in &report.unchanged {
379        out.emit(Record::SyncScope {
380            scope: scope.clone(),
381            outcome: "unchanged".to_string(),
382            error: None,
383        });
384    }
385    for (scope, error) in &report.failed {
386        out.emit(Record::SyncScope {
387            scope: scope.clone(),
388            outcome: "failed".to_string(),
389            error: Some(error.clone()),
390        });
391    }
392    for problem in &report.problems {
393        out.emit(Record::SyncProblem {
394            problem: problem.clone(),
395        });
396    }
397    out.emit(Record::SyncSummary {
398        changed: report.changed.len(),
399        unchanged: report.unchanged.len(),
400        failed: report.failed.len(),
401        no_token: report.no_token,
402        problems: report.problems.len(),
403    });
404
405    let mut lines = vec![format!(
406        "{} repositories, {} boards: {} changed, {} unchanged, {} failed",
407        targets.repositories.len(),
408        targets.boards.len(),
409        report.changed.len(),
410        report.unchanged.len(),
411        report.failed.len()
412    )];
413    lines.extend(report.failed.iter().map(|(scope, error)| format!("  failed  {scope}: {error}")));
414    lines.extend(report.problems.iter().map(|problem| format!("  problem {problem}")));
415    out.say(lines);
416
417    if let Err(no_token) = client {
418        return Err(Refusal::Failed(no_token.to_string()));
419    }
420    if !report.problems.is_empty() {
421        return Err(Refusal::Failed(format!(
422            "{} problem(s) with what to mirror; nothing was forgotten until they are fixed",
423            report.problems.len()
424        )));
425    }
426    Ok(())
427}
428
429fn status(out: &Output, args: &[String]) -> Result<(), Refusal> {
430    let arguments = Arguments::read(args, &[], &[])?;
431    arguments.exactly(0)?;
432    let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
433    let service = service_of(&prj)?;
434    let states = service.mirror().states()?;
435    let failing: Vec<(&String, &String)> = states
436        .iter()
437        .filter_map(|(scope, state)| state.last_error.as_ref().map(|error| (scope, error)))
438        .collect();
439    let never_synced = states.values().filter(|state| !state.ever_synced()).count();
440    let token = token::resolve();
441    out.emit(Record::Status {
442        mirror: service.mirror().path().display().to_string(),
443        token: token.as_ref().ok().map(|token| token.source.to_string()),
444        scopes: states.len(),
445        failing: failing.len(),
446        never_synced,
447    });
448    let mut lines = vec![
449        format!("mirror  {}", service.mirror().path().display()),
450        match &token {
451            Ok(token) => format!("token   {}", token.source),
452            Err(no_token) => format!("token   none: {no_token}"),
453        },
454        format!(
455            "scopes  {} held, {} never read, {} failing",
456            states.len(),
457            never_synced,
458            failing.len()
459        ),
460    ];
461    lines.extend(failing.iter().map(|(scope, error)| format!("  {scope}: {error}")));
462    if let Some(limit) = service.mirror().only(&Scope::RateLimit)? {
463        lines.push(format!(
464            "rate    {} of {} left",
465            limit["remaining"], limit["limit"]
466        ));
467    }
468    out.say(lines);
469    Ok(())
470}
471
472// ---------------------------------------------------------------------------
473// boards: a project's references
474// ---------------------------------------------------------------------------
475
476fn boards_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
477    let (action, rest) = match args.first().map(String::as_str) {
478        Some("add") => ("add", &args[1..]),
479        Some("remove") => ("remove", &args[1..]),
480        Some("list") => ("list", &args[1..]),
481        _ => ("list", args),
482    };
483    let arguments = Arguments::read(rest, &[], &[])?;
484    let named: Vec<BoardRef> = match action {
485        "list" => {
486            arguments.exactly(0)?;
487            Vec::new()
488        }
489        _ => {
490            if arguments.positionals.is_empty() {
491                return Err(usage(format!("boards {action} takes at least one <owner>/<number>")));
492            }
493            arguments
494                .positionals
495                .iter()
496                .map(|text| text.parse::<BoardRef>().map_err(usage))
497                .collect::<Result<_, _>>()?
498        }
499    };
500    let place = place(&arguments)?;
501    let mut declared = boards::read(&place.project)?;
502    match action {
503        "list" => {}
504        "add" | "remove" => {
505            for board in named {
506                let held = declared.boards.iter().position(|held| *held == board);
507                match (action, held) {
508                    ("add", None) => declared.boards.push(board),
509                    ("add", Some(_)) => {
510                        return Err(Refusal::Failed(format!(
511                            "{} already references {board}",
512                            place.project.name()
513                        )))
514                    }
515                    (_, Some(index)) => {
516                        declared.boards.remove(index);
517                    }
518                    (_, None) => {
519                        return Err(Refusal::Failed(format!(
520                            "{} does not reference {board}",
521                            place.project.name()
522                        )))
523                    }
524                }
525            }
526            boards::write(&place.project, &declared)?;
527        }
528        _ => unreachable!("the actions are the three matched above"),
529    }
530    report_references(out, &place.project, &declared);
531    Ok(())
532}
533
534fn report_references(out: &Output, project: &wsb_prj::Project, declared: &ProjectGitHub) {
535    for board in &declared.boards {
536        out.emit(Record::BoardReference {
537            project: project.name().to_string(),
538            board: board.to_string(),
539        });
540    }
541    if declared.boards.is_empty() {
542        out.say([format!("{} references no boards", project.name())]);
543    } else {
544        out.say(declared.boards.iter().map(|board| board.to_string()));
545    }
546}
547
548// ---------------------------------------------------------------------------
549// issues
550// ---------------------------------------------------------------------------
551
552fn issues(out: &Output, args: &[String]) -> Result<(), Refusal> {
553    let arguments = Arguments::read(args, &["--repo", "--state"], &[])?;
554    arguments.exactly(0)?;
555    let wanted: IssueState = arguments.value("--state")?.unwrap_or("open").parse().map_err(usage)?;
556    let place = place(&arguments)?;
557    let mut repos: Vec<String> = checkout::repositories(&place.checkout)?
558        .into_iter()
559        .map(|repository| repository.name)
560        .collect::<BTreeSet<_>>()
561        .into_iter()
562        .collect();
563    if let Some(repo) = arguments.value("--repo")? {
564        if !repos.iter().any(|held| held == repo) {
565            return Err(Refusal::Failed(format!(
566                "{repo} is not a repository of {}",
567                place.checkout.display()
568            )));
569        }
570        repos = vec![repo.to_string()];
571    }
572    let service = service_of(&place.prj)?;
573    let list = service.issues(&repos, wanted)?;
574    let mut lines = Vec::new();
575    for issue in &list.issues {
576        let text = |key: &str| issue.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string();
577        let number = issue.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
578        out.emit(Record::Issue {
579            repo: text("repo"),
580            number,
581            title: text("title"),
582            state: text("state"),
583            url: text("html_url"),
584            updated_at: text("updated_at"),
585        });
586        lines.push(format!("{}#{number}  {}  [{}]", text("repo"), text("title"), text("state")));
587    }
588    for (repo, standing) in &list.sync {
589        if !standing.ever_synced {
590            out.warn(&format!("{repo} has not been read yet; run `wsb github sync`"));
591        }
592    }
593    for failure in &list.failures {
594        out.warn(&format!("{}: the last refresh failed: {}", failure.repo, failure.error));
595    }
596    if lines.is_empty() {
597        lines.push("no issues".to_string());
598    }
599    out.say(lines);
600    Ok(())
601}
602
603// ---------------------------------------------------------------------------
604// Naming boards, cards, fields and options
605// ---------------------------------------------------------------------------
606
607/// The board a person named: by id, by number, or by title.
608pub(crate) fn find_board<'a>(boards: &'a [GitHubProject], name: &str) -> Result<&'a GitHubProject, String> {
609    if let Some(board) = boards.iter().find(|board| board.id == name) {
610        return Ok(board);
611    }
612    // `owner/3`, for the project whose boards belong to two owners.
613    let (owner, bare) = match name.split_once('/') {
614        Some((owner, bare)) => (Some(owner), bare),
615        None => (None, name),
616    };
617    let owned = |board: &&GitHubProject| owner.is_none_or(|owner| board.owner.eq_ignore_ascii_case(owner));
618    let matches: Vec<&GitHubProject> = match bare.trim_start_matches('#').parse::<i64>() {
619        Ok(number) => boards.iter().filter(owned).filter(|board| board.number == number).collect(),
620        Err(_) => {
621            let exact: Vec<&GitHubProject> = boards
622                .iter()
623                .filter(owned)
624                .filter(|board| board.title.eq_ignore_ascii_case(bare))
625                .collect();
626            if exact.is_empty() {
627                let lowered = bare.to_lowercase();
628                boards
629                    .iter()
630                    .filter(owned)
631                    .filter(|board| board.title.to_lowercase().starts_with(&lowered))
632                    .collect()
633            } else {
634                exact
635            }
636        }
637    };
638    match matches.as_slice() {
639        [one] => Ok(*one),
640        [] => Err(format!(
641            "no board is called '{name}'. This project's boards: {}",
642            if boards.is_empty() {
643                "none".to_string()
644            } else {
645                boards.iter().map(board_label).collect::<Vec<_>>().join(", ")
646            }
647        )),
648        several => Err(format!(
649            "'{name}' names {} boards: {}. Say which by owner/number or by id",
650            several.len(),
651            several.iter().map(|board| board_label(board)).collect::<Vec<_>>().join(", ")
652        )),
653    }
654}
655
656fn board_label(board: &GitHubProject) -> String {
657    format!("{}/{} '{}'", board.owner, board.number, board.title)
658}
659
660/// The card a person named: by id, by the URL of what it points at, by its
661/// exact title, or by the start of its id — in that order, and the first
662/// reading that names anything decides.
663pub(crate) fn find_item<'a>(items: &'a [GitHubProjectItem], name: &str) -> Result<&'a GitHubProjectItem, String> {
664    if let Some(item) = items.iter().find(|item| item.id == name) {
665        return Ok(item);
666    }
667    let by_url: Vec<&GitHubProjectItem> =
668        items.iter().filter(|item| item.url.as_deref() == Some(name)).collect();
669    let by_title: Vec<&GitHubProjectItem> = items.iter().filter(|item| item.title == name).collect();
670    let by_prefix: Vec<&GitHubProjectItem> = items.iter().filter(|item| item.id.starts_with(name)).collect();
671    let matches = [by_url, by_title, by_prefix]
672        .into_iter()
673        .find(|found| !found.is_empty())
674        .unwrap_or_default();
675    match matches.as_slice() {
676        [one] => Ok(*one),
677        [] => Err(format!(
678            "no card on this board is '{name}' — by id, by the start of its id, by URL, or by its \
679             exact title (`wsb github project items <board>` lists them)"
680        )),
681        several => Err(format!(
682            "'{name}' names {} cards: {}. Say which by id",
683            several.len(),
684            several
685                .iter()
686                .map(|item| format!("{} '{}'", item.id, item.title))
687                .collect::<Vec<_>>()
688                .join(", ")
689        )),
690    }
691}
692
693/// The field a person named on a board.
694pub(crate) fn find_field<'a>(board: &'a GitHubProject, name: &str) -> Result<&'a GitHubProjectField, String> {
695    if let Some(field) = board.fields.iter().find(|field| field.id == name) {
696        return Ok(field);
697    }
698    let matches: Vec<&GitHubProjectField> = board
699        .fields
700        .iter()
701        .filter(|field| field.name.eq_ignore_ascii_case(name))
702        .collect();
703    match matches.as_slice() {
704        [one] => Ok(*one),
705        [] => Err(format!(
706            "the board '{}' has no field '{name}'. Its fields: {}",
707            board.title,
708            board.fields.iter().map(|field| field.name.as_str()).collect::<Vec<_>>().join(", ")
709        )),
710        several => Err(format!(
711            "the board '{}' has {} fields called '{name}'; name one by id: {}",
712            board.title,
713            several.len(),
714            several.iter().map(|field| field.id.as_str()).collect::<Vec<_>>().join(", ")
715        )),
716    }
717}
718
719/// The option of a single-select field a person named.
720pub(crate) fn find_option<'a>(field: &'a GitHubProjectField, name: &str) -> Result<&'a str, String> {
721    if let Some(option) = field.options.iter().find(|option| option.id == name) {
722        return Ok(&option.id);
723    }
724    let matches: Vec<&str> = field
725        .options
726        .iter()
727        .filter(|option| option.name.eq_ignore_ascii_case(name))
728        .map(|option| option.id.as_str())
729        .collect();
730    match matches.as_slice() {
731        [one] => Ok(*one),
732        [] => Err(format!(
733            "'{}' has no option '{name}'. Its options: {}",
734            field.name,
735            field.options.iter().map(|option| option.name.as_str()).collect::<Vec<_>>().join(", ")
736        )),
737        _ => Err(format!(
738            "'{}' has more than one option called '{name}'; name one by id",
739            field.name
740        )),
741    }
742}
743
744/// `field=value`, as the value GitHub takes for that field's kind.
745///
746/// The kind is the BOARD's, never guessed from how the value looks: `2026` in
747/// a text field is the text "2026", in a number field the number, and a date
748/// written into a text field is accepted by GitHub and shown by nothing.
749pub(crate) fn field_write(board: &GitHubProject, assignment: &str) -> Result<(String, ProjectFieldWrite), Refusal> {
750    let (name, value) = assignment
751        .split_once('=')
752        .ok_or_else(|| usage(format!("'{assignment}' is not <field>=<value>")))?;
753    let field = find_field(board, name.trim())?;
754    let write = match field.kind.as_str() {
755        "single_select" => ProjectFieldWrite::SingleSelect {
756            option_id: find_option(field, value.trim())?.to_string(),
757        },
758        "text" => ProjectFieldWrite::Text {
759            text: value.to_string(),
760        },
761        "number" => {
762            let number: f64 = value.trim().parse().map_err(|_| {
763                format!("'{}' is a number field, and '{value}' is not a number", field.name)
764            })?;
765            if !number.is_finite() {
766                return Err(Refusal::Failed(format!(
767                    "'{}' is a number field, and '{value}' is not a finite number",
768                    field.name
769                )));
770            }
771            ProjectFieldWrite::Number { number }
772        }
773        "date" => {
774            let date = value.trim();
775            if !is_calendar_date(date) {
776                return Err(Refusal::Failed(format!(
777                    "'{}' is a date field, and '{value}' is not a date written YYYY-MM-DD",
778                    field.name
779                )));
780            }
781            ProjectFieldWrite::Date {
782                date: date.to_string(),
783            }
784        }
785        "iteration" => {
786            return Err(Refusal::Failed(format!(
787                "'{}' is an iteration field: an iteration is chosen by its id, which a board does \
788                 not list here — set it in GitHub",
789                field.name
790            )))
791        }
792        "title" => {
793            return Err(Refusal::Failed(
794                "a card's title is not a field value: `project edit` changes a draft's, and an \
795                 issue's belongs to the issue"
796                    .to_string(),
797            ))
798        }
799        other => {
800            return Err(Refusal::Failed(format!(
801                "'{}' is GitHub's own {other} field, kept on the issue or pull request rather \
802                 than on the board; it is not set from here",
803                field.name
804            )))
805        }
806    };
807    Ok((field.id.clone(), write))
808}
809
810/// Whether `text` is a date as GitHub's `Date` scalar takes one.
811pub(crate) fn is_calendar_date(text: &str) -> bool {
812    let parts: Vec<&str> = text.split('-').collect();
813    let [year, month, day] = parts.as_slice() else {
814        return false;
815    };
816    if year.len() != 4 || month.len() != 2 || day.len() != 2 {
817        return false;
818    }
819    let (Ok(year), Ok(month), Ok(day)) = (year.parse::<u32>(), month.parse::<u32>(), day.parse::<u32>()) else {
820        return false;
821    };
822    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
823    let days = match month {
824        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
825        4 | 6 | 9 | 11 => 30,
826        2 if leap => 29,
827        2 => 28,
828        _ => return false,
829    };
830    (1..=days).contains(&day)
831}
832
833// ---------------------------------------------------------------------------
834// What is said about a board
835// ---------------------------------------------------------------------------
836
837/// A field value as a person reads it.
838fn value_text(value: &GitHubProjectValue) -> String {
839    match value {
840        GitHubProjectValue::SingleSelect { name, .. } => name.clone(),
841        GitHubProjectValue::Text { text } => text.clone(),
842        // 3, not 3.0: an estimate of three is three.
843        GitHubProjectValue::Number { number } if number.fract() == 0.0 && number.abs() < 1e15 => {
844            format!("{}", *number as i64)
845        }
846        GitHubProjectValue::Number { number } => number.to_string(),
847        GitHubProjectValue::Date { date } => date.clone(),
848        GitHubProjectValue::Iteration { title, .. } => title.clone(),
849    }
850}
851
852fn value_kind(value: &GitHubProjectValue) -> &'static str {
853    match value {
854        GitHubProjectValue::SingleSelect { .. } => "single_select",
855        GitHubProjectValue::Text { .. } => "text",
856        GitHubProjectValue::Number { .. } => "number",
857        GitHubProjectValue::Date { .. } => "date",
858        GitHubProjectValue::Iteration { .. } => "iteration",
859    }
860}
861
862/// A card's field values by field NAME, in the board's own field order.
863///
864/// The title is left out: it is the card's `title`, and a second copy under a
865/// field name is a second place for the two to differ.
866pub(crate) fn named_values(board: &GitHubProject, item: &GitHubProjectItem) -> Vec<ProjectValue> {
867    board
868        .fields
869        .iter()
870        .filter(|field| field.kind != "title")
871        .filter_map(|field| {
872            let value = item.field_values.get(&field.id)?;
873            Some(ProjectValue {
874                field: field.name.clone(),
875                kind: value_kind(value).to_string(),
876                value: value_text(value),
877                option: match value {
878                    GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id.clone()),
879                    _ => None,
880                },
881            })
882        })
883        .collect()
884}
885
886fn board_record(board: &GitHubProject) -> Record {
887    Record::ProjectBoard {
888        board: board.id.clone(),
889        number: board.number,
890        title: board.title.clone(),
891        owner: board.owner.clone(),
892        url: board.url.clone(),
893        closed: board.closed,
894    }
895}
896
897fn field_record(board: &GitHubProject, field: &GitHubProjectField) -> Record {
898    Record::ProjectField {
899        board: board.id.clone(),
900        field: field.id.clone(),
901        name: field.name.clone(),
902        kind: field.kind.clone(),
903        options: field
904            .options
905            .iter()
906            .map(|option| ProjectOption {
907                id: option.id.clone(),
908                name: option.name.clone(),
909            })
910            .collect(),
911    }
912}
913
914pub(crate) fn item_record(board: &GitHubProject, item: &GitHubProjectItem) -> Record {
915    Record::ProjectItem {
916        board: board.id.clone(),
917        item: item.id.clone(),
918        kind: item.kind.clone(),
919        title: item.title.clone(),
920        url: item.url.clone(),
921        repository: item.repository.clone(),
922        number: item.number,
923        state: item.state.clone(),
924        archived: item.archived,
925        body: item.body.clone().unwrap_or_default(),
926        fields: named_values(board, item),
927    }
928}
929
930/// One card on one line: its id, its title, and what it carries besides its
931/// column.
932fn item_line(board: &GitHubProject, item: &GitHubProjectItem, column_field: Option<&str>) -> String {
933    let others: Vec<String> = named_values(board, item)
934        .into_iter()
935        .filter(|value| Some(value.field.as_str()) != column_field)
936        .map(|value| format!("{}: {}", value.field, value.value))
937        .collect();
938    let mut line = format!("  {}  {}", item.id, item.title);
939    if let Some(number) = item.number {
940        line.push_str(&format!(
941            "  ({}#{number}{})",
942            item.repository.as_deref().unwrap_or(""),
943            item.state.as_deref().map(|state| format!(", {}", state.to_lowercase())).unwrap_or_default()
944        ));
945    }
946    if item.archived {
947        line.push_str("  [archived]");
948    }
949    if !others.is_empty() {
950        line.push_str(&format!("  — {}", others.join(", ")));
951    }
952    line
953}
954
955/// The cards of a board as a person reads them: by column, in the board's
956/// column order, with the cards in no column last.
957fn items_prose(board: &GitHubProject, items: &[&GitHubProjectItem]) -> Vec<String> {
958    let mut lines = Vec::new();
959    let Some(column) = board.column_field() else {
960        lines.push(format!("{} — {} card(s)", board.title, items.len()));
961        lines.extend(items.iter().map(|item| item_line(board, item, None)));
962        return lines;
963    };
964    lines.push(format!("{} — {} card(s), by {}", board.title, items.len(), column.name));
965    let mut groups: Vec<(String, Option<&str>)> = column
966        .options
967        .iter()
968        .map(|option| (option.name.clone(), Some(option.id.as_str())))
969        .collect();
970    groups.push((format!("No {}", column.name), None));
971    for (name, option) in groups {
972        let held: Vec<&&GitHubProjectItem> =
973            items.iter().filter(|item| item.column(&column.id) == option).collect();
974        if held.is_empty() {
975            continue;
976        }
977        lines.push(format!("{name} ({})", held.len()));
978        lines.extend(held.iter().map(|item| item_line(board, item, Some(column.name.as_str()))));
979    }
980    lines
981}
982
983/// One card, whole.
984fn item_prose(board: &GitHubProject, item: &GitHubProjectItem) -> Vec<String> {
985    let mut lines = vec![item.title.clone(), format!("  id          {}", item.id)];
986    lines.push(format!(
987        "  kind        {}",
988        match item.kind.as_str() {
989            "DRAFT_ISSUE" => "draft (lives only on the board)",
990            "ISSUE" => "issue",
991            "PULL_REQUEST" => "pull request",
992            other => other,
993        }
994    ));
995    if let Some(url) = &item.url {
996        lines.push(format!("  url         {url}"));
997    }
998    if let Some(state) = &item.state {
999        lines.push(format!("  state       {}", state.to_lowercase()));
1000    }
1001    if item.archived {
1002        lines.push("  archived    yes".to_string());
1003    }
1004    for value in named_values(board, item) {
1005        lines.push(format!("  {:<11} {}", value.field, value.value));
1006    }
1007    let body = item.body.as_deref().unwrap_or("").trim_end();
1008    if !body.is_empty() {
1009        lines.push(String::new());
1010        lines.extend(body.lines().map(str::to_string));
1011    }
1012    lines
1013}
1014
1015// ---------------------------------------------------------------------------
1016// project: one project's boards
1017// ---------------------------------------------------------------------------
1018
1019/// Everything a board command stands on.
1020struct Boards {
1021    service: Service,
1022    project: wsb_prj::Project,
1023    references: Vec<BoardRef>,
1024}
1025
1026fn project_boards(arguments: &Arguments) -> Result<Boards, Refusal> {
1027    let place = place(arguments)?;
1028    let references = boards::read(&place.project)?.boards;
1029    if references.is_empty() {
1030        return Err(Refusal::Failed(format!(
1031            "{} references no boards; add one with `wsb github boards add <owner>/<number>`",
1032            place.project.name()
1033        )));
1034    }
1035    Ok(Boards {
1036        service: service_of(&place.prj)?,
1037        project: place.project,
1038        references,
1039    })
1040}
1041
1042/// The project's boards, their owners' lists brought current first.
1043async fn listed_boards(out: &Output, boards: &Boards, fresh: bool) -> Result<Vec<GitHubProject>, Refusal> {
1044    let owners: BTreeSet<String> = boards.references.iter().map(|board| board.owner.clone()).collect();
1045    let scopes: Vec<Scope> = owners.into_iter().map(Scope::Projects).collect();
1046    refresh(out, &boards.service, &scopes, fresh).await?;
1047    let list = boards.service.boards(&boards.references)?;
1048    for problem in &list.problems {
1049        out.warn(problem);
1050    }
1051    Ok(list.boards)
1052}
1053
1054/// The board a command names.
1055async fn named_board(out: &Output, boards: &Boards, arguments: &Arguments) -> Result<GitHubProject, Refusal> {
1056    let name = arguments.positional(0, "a board")?;
1057    let all = listed_boards(out, boards, arguments.switch("--fresh")).await?;
1058    Ok(find_board(&all, name)?.clone())
1059}
1060
1061/// One board's cards, brought current first; refused when never read.
1062async fn board_cards(
1063    out: &Output,
1064    boards: &Boards,
1065    board: &GitHubProject,
1066    fresh: bool,
1067) -> Result<Vec<GitHubProjectItem>, Refusal> {
1068    refresh(out, &boards.service, &[Scope::ProjectItems(board.id.clone())], fresh).await?;
1069    let cards = boards.service.cards(&board.id)?;
1070    if !cards.sync.ever_synced {
1071        return Err(Refusal::Failed(format!(
1072            "the cards of '{}' have not been read from GitHub{}",
1073            board.title,
1074            cards.sync.last_error.map(|error| format!(": {error}")).unwrap_or_default()
1075        )));
1076    }
1077    Ok(cards.items)
1078}
1079
1080/// The board and the card a command names.
1081async fn named_card(
1082    out: &Output,
1083    boards: &Boards,
1084    arguments: &Arguments,
1085) -> Result<(GitHubProject, GitHubProjectItem), Refusal> {
1086    // Asked for before anything is read: a command with no card named is a
1087    // mistake at the command line, whatever the board turns out to hold.
1088    let name = arguments.positional(1, "a card")?;
1089    let board = named_board(out, boards, arguments).await?;
1090    let all = board_cards(out, boards, &board, arguments.switch("--fresh")).await?;
1091    let card = find_item(&all, name)?.clone();
1092    Ok((board, card))
1093}
1094
1095fn writing_client() -> Result<GitHubClient, Refusal> {
1096    client().map_err(|no_token| Refusal::Failed(no_token.to_string()))
1097}
1098
1099/// Report a card as GitHub returned it after a write.
1100fn written(out: &Output, board: &GitHubProject, card: &GitHubProjectItem, what: &str) {
1101    out.emit(item_record(board, card));
1102    out.say([format!("{what}: {}  {}", card.id, card.title)]);
1103}
1104
1105/// The text of `--body` or `--body-file`, when either was given.
1106fn body_text(arguments: &Arguments) -> Result<Option<String>, Refusal> {
1107    match (arguments.value("--body")?, arguments.value("--body-file")?) {
1108        (Some(_), Some(_)) => Err(usage("give --body or --body-file, not both")),
1109        (Some(text), None) => Ok(Some(text.to_string())),
1110        (None, Some("-")) => {
1111            let mut text = String::new();
1112            std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
1113                .map_err(|e| format!("cannot read the body from standard input: {e}"))?;
1114            Ok(Some(text))
1115        }
1116        (None, Some(path)) => std::fs::read_to_string(path)
1117            .map(Some)
1118            .map_err(|e| Refusal::Failed(format!("cannot read the body from {path}: {e}"))),
1119        (None, None) => Ok(None),
1120    }
1121}
1122
1123async fn project_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
1124    let verb = args.first().map(String::as_str);
1125    let rest: &[String] = if args.len() > 1 { &args[1..] } else { &[] };
1126    match verb {
1127        None => list(out, rest).await,
1128        // `project --fresh` is the overview with an option, not a verb.
1129        Some(flag) if flag.starts_with("--") => list(out, args).await,
1130        Some("list") => list(out, rest).await,
1131        Some("show") => show(out, rest).await,
1132        Some("items") => items(out, rest).await,
1133        Some("item") => item(out, rest).await,
1134        Some("add") => add(out, rest).await,
1135        Some("set") => set(out, rest).await,
1136        Some("clear") => clear(out, rest).await,
1137        Some("move") => move_card(out, rest).await,
1138        Some("edit") => edit(out, rest).await,
1139        Some("archive") => archive(out, rest, true).await,
1140        Some("unarchive") => archive(out, rest, false).await,
1141        Some("remove") => remove(out, rest).await,
1142        Some("column") => column(out, rest).await,
1143        Some(other) => Err(usage(format!("unknown project command '{other}'"))),
1144    }
1145}
1146
1147async fn list(out: &Output, args: &[String]) -> Result<(), Refusal> {
1148    let arguments = Arguments::read(args, &[], &["--fresh"])?;
1149    arguments.exactly(0)?;
1150    let boards = project_boards(&arguments)?;
1151    let all = listed_boards(out, &boards, arguments.switch("--fresh")).await?;
1152    for board in &all {
1153        out.emit(board_record(board));
1154    }
1155    if all.is_empty() {
1156        out.say([format!("no board {} references could be read", boards.project.name())]);
1157        return Ok(());
1158    }
1159    out.say(all.iter().map(|board| {
1160        format!(
1161            "{}/{}  {}{}  {}",
1162            board.owner,
1163            board.number,
1164            board.title,
1165            if board.closed { "  [closed]" } else { "" },
1166            board.url
1167        )
1168    }));
1169    Ok(())
1170}
1171
1172async fn show(out: &Output, args: &[String]) -> Result<(), Refusal> {
1173    let arguments = Arguments::read(args, &[], &["--fresh"])?;
1174    arguments.named(&["a board"])?;
1175    let boards = project_boards(&arguments)?;
1176    let board = named_board(out, &boards, &arguments).await?;
1177    out.emit(board_record(&board));
1178    for field in &board.fields {
1179        out.emit(field_record(&board, field));
1180    }
1181    let mut lines = vec![
1182        format!("{}/{}  {}", board.owner, board.number, board.title),
1183        format!("  id   {}", board.id),
1184        format!("  url  {}", board.url),
1185        "Fields".to_string(),
1186    ];
1187    for field in &board.fields {
1188        let options: Vec<&str> = field.options.iter().map(|option| option.name.as_str()).collect();
1189        lines.push(format!(
1190            "  {:<24} {}{}",
1191            field.name,
1192            field.kind,
1193            if options.is_empty() { String::new() } else { format!(": {}", options.join(", ")) }
1194        ));
1195    }
1196    out.say(lines);
1197    Ok(())
1198}
1199
1200/// Whether a card passes `--status` and every `--where`, compared by what a
1201/// person reads and without regard to case. A card with no value for a field
1202/// matches no value of it.
1203pub(crate) fn passes(board: &GitHubProject, item: &GitHubProjectItem, wanted: &[(String, String)]) -> bool {
1204    let values = named_values(board, item);
1205    wanted.iter().all(|(field, value)| {
1206        values
1207            .iter()
1208            .any(|held| held.field.eq_ignore_ascii_case(field) && held.value.eq_ignore_ascii_case(value))
1209    })
1210}
1211
1212async fn items(out: &Output, args: &[String]) -> Result<(), Refusal> {
1213    let arguments = Arguments::read(args, &["--status", "--where"], &["--fresh", "--archived"])?;
1214    arguments.named(&["a board"])?;
1215    for condition in arguments.all("--where") {
1216        if !condition.contains('=') {
1217            return Err(usage(format!("--where '{condition}' is not <field>=<value>")));
1218        }
1219    }
1220    let boards = project_boards(&arguments)?;
1221    let board = named_board(out, &boards, &arguments).await?;
1222
1223    // Checked against the board before any card is read: a filter on a field
1224    // the board does not have would otherwise match nothing and read as an
1225    // empty column.
1226    let mut wanted: Vec<(String, String)> = Vec::new();
1227    if let Some(status) = arguments.value("--status")? {
1228        let column = board.column_field().ok_or_else(|| {
1229            format!("the board '{}' has no column field, so it has no status to filter by", board.title)
1230        })?;
1231        find_option(column, status)?;
1232        wanted.push((column.name.clone(), status.to_string()));
1233    }
1234    for condition in arguments.all("--where") {
1235        let (field, value) = condition
1236            .split_once('=')
1237            .ok_or_else(|| usage(format!("--where '{condition}' is not <field>=<value>")))?;
1238        let field = find_field(&board, field.trim())?;
1239        wanted.push((field.name.clone(), value.trim().to_string()));
1240    }
1241
1242    let all = board_cards(out, &boards, &board, arguments.switch("--fresh")).await?;
1243    let kept: Vec<&GitHubProjectItem> = all
1244        .iter()
1245        .filter(|item| arguments.switch("--archived") || !item.archived)
1246        .filter(|item| passes(&board, item, &wanted))
1247        .collect();
1248    for item in &kept {
1249        out.emit(item_record(&board, item));
1250    }
1251    out.say(items_prose(&board, &kept));
1252    Ok(())
1253}
1254
1255async fn item(out: &Output, args: &[String]) -> Result<(), Refusal> {
1256    let arguments = Arguments::read(args, &[], &["--fresh"])?;
1257    arguments.named(&["a board", "a card"])?;
1258    let boards = project_boards(&arguments)?;
1259    let (board, card) = named_card(out, &boards, &arguments).await?;
1260    out.emit(item_record(&board, &card));
1261    out.say(item_prose(&board, &card));
1262    Ok(())
1263}
1264
1265/// Apply field writes to one card, one after another, and answer with the card
1266/// as GitHub returned it after the last.
1267async fn apply(
1268    boards: &Boards,
1269    client: &GitHubClient,
1270    board: &GitHubProject,
1271    card: GitHubProjectItem,
1272    writes: &[(String, Option<ProjectFieldWrite>)],
1273) -> Result<GitHubProjectItem, Refusal> {
1274    let mut card = card;
1275    for (field, value) in writes {
1276        card = boards
1277            .service
1278            .set_field(client, &board.id, &card.id, field, value.as_ref())
1279            .await?;
1280    }
1281    Ok(card)
1282}
1283
1284async fn add(out: &Output, args: &[String]) -> Result<(), Refusal> {
1285    let arguments = Arguments::read(args, &["--title", "--body", "--body-file", "--url", "--set"], &[])?;
1286    arguments.named(&["a board"])?;
1287    for assignment in arguments.all("--set") {
1288        if !assignment.contains('=') {
1289            return Err(usage(format!("'{assignment}' is not <field>=<value>")));
1290        }
1291    }
1292    let title = arguments.value("--title")?;
1293    let url = arguments.value("--url")?;
1294    if arguments.value("--body")?.is_some() && arguments.value("--body-file")?.is_some() {
1295        return Err(usage("give --body or --body-file, not both"));
1296    }
1297    let body = body_text(&arguments)?;
1298    match (title, url) {
1299        (Some(_), Some(_)) => {
1300            return Err(usage("give --title for a draft or --url for something that exists, not both"))
1301        }
1302        (None, None) => return Err(usage("a card needs --title (a draft) or --url (an issue or pull request)")),
1303        (Some(title), None) if title.trim().is_empty() => return Err(usage("a draft card needs a title")),
1304        (None, Some(_)) if body.is_some() => {
1305            return Err(usage(
1306                "a card for an issue or pull request has that item's description; --body is a draft's",
1307            ))
1308        }
1309        _ => {}
1310    }
1311
1312    let boards = project_boards(&arguments)?;
1313    let board = named_board(out, &boards, &arguments).await?;
1314    // Every assignment is read against the board BEFORE the card exists: a
1315    // card created and then refused its second field would be left on the
1316    // board half-set, with nothing saying which half.
1317    let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments
1318        .all("--set")
1319        .iter()
1320        .map(|assignment| field_write(&board, assignment).map(|(field, value)| (field, Some(value))))
1321        .collect::<Result<_, _>>()?;
1322
1323    let client = writing_client()?;
1324    let created = match (title, url) {
1325        (Some(title), None) => {
1326            boards
1327                .service
1328                .add_draft(&client, &board.id, title, &body.unwrap_or_default())
1329                .await?
1330        }
1331        (None, Some(url)) => boards.service.add_existing(&client, &board.id, url).await?,
1332        _ => unreachable!("exactly one of --title and --url was checked above"),
1333    };
1334    let card = apply(&boards, &client, &board, created, &writes).await?;
1335    written(out, &board, &card, "added");
1336    Ok(())
1337}
1338
1339async fn set(out: &Output, args: &[String]) -> Result<(), Refusal> {
1340    let arguments = Arguments::read(args, &[], &[])?;
1341    if arguments.positionals.len() < 3 {
1342        return Err(usage("set takes a board, a card, and at least one <field>=<value>"));
1343    }
1344    for assignment in &arguments.positionals[2..] {
1345        if !assignment.contains('=') {
1346            return Err(usage(format!("'{assignment}' is not <field>=<value>")));
1347        }
1348    }
1349    let boards = project_boards(&arguments)?;
1350    let (board, card) = named_card(out, &boards, &arguments).await?;
1351    let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments.positionals[2..]
1352        .iter()
1353        .map(|assignment| field_write(&board, assignment).map(|(field, value)| (field, Some(value))))
1354        .collect::<Result<_, _>>()?;
1355    let client = writing_client()?;
1356    let card = apply(&boards, &client, &board, card, &writes).await?;
1357    written(out, &board, &card, "set");
1358    Ok(())
1359}
1360
1361async fn clear(out: &Output, args: &[String]) -> Result<(), Refusal> {
1362    let arguments = Arguments::read(args, &[], &[])?;
1363    if arguments.positionals.len() < 3 {
1364        return Err(usage("clear takes a board, a card, and at least one field"));
1365    }
1366    let boards = project_boards(&arguments)?;
1367    let (board, card) = named_card(out, &boards, &arguments).await?;
1368    let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments.positionals[2..]
1369        .iter()
1370        .map(|name| find_field(&board, name).map(|field| (field.id.clone(), None)))
1371        .collect::<Result<_, _>>()?;
1372    let client = writing_client()?;
1373    let card = apply(&boards, &client, &board, card, &writes).await?;
1374    written(out, &board, &card, "cleared");
1375    Ok(())
1376}
1377
1378async fn move_card(out: &Output, args: &[String]) -> Result<(), Refusal> {
1379    let arguments = Arguments::read(args, &["--after"], &["--top"])?;
1380    arguments.named(&["a board", "a card"])?;
1381    let after = arguments.value("--after")?;
1382    if after.is_some() == arguments.switch("--top") {
1383        return Err(usage("say where: --after <card>, or --top"));
1384    }
1385    let boards = project_boards(&arguments)?;
1386    let (board, card) = named_card(out, &boards, &arguments).await?;
1387    let after = match after {
1388        Some(name) => {
1389            let all = boards.service.cards(&board.id)?.items;
1390            let target = find_item(&all, name)?;
1391            Some(target.id.clone())
1392        }
1393        None => None,
1394    };
1395    let client = writing_client()?;
1396    let moved = boards
1397        .service
1398        .move_card(&client, &board.id, &card.id, after.as_deref())
1399        .await?;
1400    written(out, &board, &moved, "moved");
1401    Ok(())
1402}
1403
1404async fn edit(out: &Output, args: &[String]) -> Result<(), Refusal> {
1405    let arguments = Arguments::read(args, &["--title", "--body", "--body-file"], &[])?;
1406    arguments.named(&["a board", "a card"])?;
1407    let title = arguments.value("--title")?;
1408    if arguments.value("--body")?.is_some() && arguments.value("--body-file")?.is_some() {
1409        return Err(usage("give --body or --body-file, not both"));
1410    }
1411    let body = body_text(&arguments)?;
1412    if title.is_none() && body.is_none() {
1413        return Err(usage("say what to change: --title, --body or --body-file"));
1414    }
1415    if title.is_some_and(|title| title.trim().is_empty()) {
1416        return Err(usage("a draft card needs a title"));
1417    }
1418    let boards = project_boards(&arguments)?;
1419    let (board, card) = named_card(out, &boards, &arguments).await?;
1420    let content = match (card.kind.as_str(), &card.content_id) {
1421        ("DRAFT_ISSUE", Some(content)) => content.clone(),
1422        ("DRAFT_ISSUE", None) => {
1423            return Err(Refusal::Failed(format!(
1424                "the draft '{}' was read without the id its text is edited by; read the board \
1425                 again with --fresh",
1426                card.title
1427            )))
1428        }
1429        _ => {
1430            return Err(Refusal::Failed(format!(
1431                "'{}' is not a draft: its title and description belong to {}, and are changed there",
1432                card.title,
1433                card.url.as_deref().unwrap_or("the issue or pull request")
1434            )))
1435        }
1436    };
1437    let client = writing_client()?;
1438    // What is not being changed is sent as it stands: the draft is replaced
1439    // whole, and leaving the body out would erase it.
1440    let edited = boards
1441        .service
1442        .edit_draft(
1443            &client,
1444            &board.id,
1445            &card.id,
1446            &content,
1447            title.unwrap_or(&card.title),
1448            &body.unwrap_or_else(|| card.body.clone().unwrap_or_default()),
1449        )
1450        .await?;
1451    written(out, &board, &edited, "edited");
1452    Ok(())
1453}
1454
1455async fn archive(out: &Output, args: &[String], archived: bool) -> Result<(), Refusal> {
1456    let arguments = Arguments::read(args, &[], &[])?;
1457    arguments.named(&["a board", "a card"])?;
1458    let boards = project_boards(&arguments)?;
1459    let (board, card) = named_card(out, &boards, &arguments).await?;
1460    let client = writing_client()?;
1461    let changed = boards
1462        .service
1463        .set_archived(&client, &board.id, &card.id, archived)
1464        .await?;
1465    written(out, &board, &changed, if archived { "archived" } else { "unarchived" });
1466    Ok(())
1467}
1468
1469async fn remove(out: &Output, args: &[String]) -> Result<(), Refusal> {
1470    let arguments = Arguments::read(args, &[], &[])?;
1471    arguments.named(&["a board", "a card"])?;
1472    let boards = project_boards(&arguments)?;
1473    let (board, card) = named_card(out, &boards, &arguments).await?;
1474    let client = writing_client()?;
1475    boards.service.remove_card(&client, &board.id, &card.id).await?;
1476    out.emit(Record::ProjectItemRemoved {
1477        board: board.id.clone(),
1478        item: card.id.clone(),
1479    });
1480    out.say([format!("removed: {}  {}", card.id, card.title)]);
1481    Ok(())
1482}
1483
1484async fn column(out: &Output, args: &[String]) -> Result<(), Refusal> {
1485    let Some(action) = args.first().map(String::as_str) else {
1486        return Err(usage("column add or column remove"));
1487    };
1488    let rest = &args[1..];
1489    let arguments = match action {
1490        "add" => Arguments::read(rest, &["--field", "--color", "--description"], &[])?,
1491        "remove" => Arguments::read(rest, &["--field"], &[])?,
1492        other => return Err(usage(format!("unknown column command '{other}'"))),
1493    };
1494    arguments.named(&["a board", "a column name"])?;
1495    let name = arguments.positional(1, "a column name")?;
1496    let boards = project_boards(&arguments)?;
1497    let board = named_board(out, &boards, &arguments).await?;
1498    let field = match arguments.value("--field")? {
1499        Some(named) => find_field(&board, named)?,
1500        None => board.column_field().ok_or_else(|| {
1501            format!("the board '{}' has no single-select field to hold columns", board.title)
1502        })?,
1503    };
1504    if field.kind != "single_select" {
1505        return Err(Refusal::Failed(format!(
1506            "'{}' is a {} field; only a single-select field has columns",
1507            field.name, field.kind
1508        )));
1509    }
1510    let client = writing_client()?;
1511    let (updated, _items) = if action == "add" {
1512        if field.options.iter().any(|option| option.name.eq_ignore_ascii_case(name)) {
1513            return Err(Refusal::Failed(format!("'{}' already has a column '{name}'", field.name)));
1514        }
1515        let color = arguments.value("--color")?.unwrap_or("GRAY").to_uppercase();
1516        boards
1517            .service
1518            .add_column(
1519                &client,
1520                &board.id,
1521                &field.id,
1522                name,
1523                &color,
1524                arguments.value("--description")?.unwrap_or(""),
1525            )
1526            .await?
1527    } else {
1528        let option = find_option(field, name)?.to_string();
1529        boards
1530            .service
1531            .remove_column(&client, &board.id, &field.id, &option)
1532            .await?
1533    };
1534    let changed = find_field(&updated, &field.id)?;
1535    out.emit(field_record(&updated, changed));
1536    out.say([format!(
1537        "{}: {}",
1538        changed.name,
1539        changed.options.iter().map(|option| option.name.as_str()).collect::<Vec<_>>().join(", ")
1540    )]);
1541    Ok(())
1542}
1543
1544#[cfg(test)]
1545#[path = "cli_tests.rs"]
1546mod tests;