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