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