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