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