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