use std::collections::{BTreeMap, BTreeSet};
use crate::boards::{self, BoardRef, ProjectGitHub};
use crate::checkout;
use crate::client::{
GitHubClient, GitHubProject, GitHubProjectField, GitHubProjectItem, GitHubProjectValue,
ProjectFieldWrite,
};
use crate::mirror::{Mirror, Scope};
use crate::output::{Output, ProjectOption, ProjectValue, Record, Stream};
use crate::service::{Failure, IssueState, Service};
use crate::sync;
use crate::targets::Targets;
use crate::token;
pub const USAGE: &str = "\
Usage: wsb github <command> [options]
The machine's GitHub mirror, in $PRJ/.state. Reads answer from it, brought
current first when due; writes go to GitHub first and the mirror takes GitHub's
answer. The token is the GitHub CLI's login (gh auth token), else GITHUB_TOKEN.
Mirror
sync [--force] bring everything every project needs up to date
status where the mirror and the token are, and what is failing
A project's boards (references only; the boards live in the mirror)
boards the boards this project references
boards add <owner>/<number>...
boards remove <owner>/<number>...
Issues
issues [--repo <owner>/<repo>] [--state open|closed|all]
this worktree's repositories' issues, newest first
Boards
project [list] this project's boards
project show <board> one board's fields, and the options of each
project items <board> [--status <column>] [--where <field>=<value>]... [--archived]
the board's cards, by column
project item <board> <card>
one card, whole: its fields and its description
project add <board> --title <text> [--body <text> | --body-file <path>] [--set <field>=<value>]...
project add <board> --url <issue or pull request URL> [--set <field>=<value>]...
project set <board> <card> <field>=<value>...
project clear <board> <card> <field>...
project move <board> <card> --after <card> | --top
project edit <board> <card> [--title <text>] [--body <text> | --body-file <path>]
project archive <board> <card>, project unarchive <board> <card>
project remove <board> <card>
project column add <board> <name> [--field <field>] [--color <COLOR>] [--description <text>]
project column remove <board> <name> [--field <field>]
A <board> is its number, owner/number, its title or its id. A <card> is its id,
the start of its id, the URL it points at, or its exact title. A name that
matches more than one is refused with every match.
Every command acts on the project the current directory is in; --project <name>
names another. --fresh, on a reading project command, reads GitHub again first
and refuses if it could not. --body-file - reads the body from standard input.
--jsonl makes every line one JSON record: project_board, project_field,
project_item, project_item_removed, board_reference, issue, sync_scope,
sync_problem, sync_summary, status, message, failure.";
#[derive(Debug, PartialEq)]
pub(crate) enum Refusal {
Usage(String),
Failed(String),
}
impl From<String> for Refusal {
fn from(message: String) -> Self {
Refusal::Failed(message)
}
}
impl From<Failure> for Refusal {
fn from(failure: Failure) -> Self {
match failure {
Failure::Invalid(message) => Refusal::Usage(message),
other => Refusal::Failed(other.to_string()),
}
}
}
impl From<anyhow::Error> for Refusal {
fn from(error: anyhow::Error) -> Self {
Refusal::Failed(format!("{error:#}"))
}
}
fn usage(message: impl Into<String>) -> Refusal {
Refusal::Usage(message.into())
}
pub fn run(args: Vec<String>) -> u8 {
let jsonl = args.iter().any(|arg| arg == "--jsonl");
let args: Vec<String> = args.into_iter().filter(|arg| arg != "--jsonl").collect();
run_with(&Output::new(jsonl), args)
}
pub fn run_through(args: Vec<String>, host: impl Fn(Stream, &str) + Send + Sync + 'static) -> u8 {
run_with(&Output::through(host), args)
}
fn run_with(out: &Output, args: Vec<String>) -> u8 {
match args.first().map(String::as_str) {
Some("help" | "--help" | "-h") => {
out.say([USAGE.to_string()]);
return 0;
}
None => {
out.fail("a command is needed");
out.explain(&format!("\n{USAGE}"));
return 2;
}
Some(_) => {}
}
let runtime = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(runtime) => runtime,
Err(error) => {
out.fail(&format!("cannot start: {error}"));
return 1;
}
};
let result = runtime.block_on(dispatch(out, &args));
match result {
Ok(()) => 0,
Err(Refusal::Usage(message)) => {
out.fail(&message);
out.explain(&format!("\n{USAGE}"));
2
}
Err(Refusal::Failed(message)) => {
out.fail(&message);
1
}
}
}
async fn dispatch(out: &Output, args: &[String]) -> Result<(), Refusal> {
let verb = args[0].as_str();
let rest = &args[1..];
match verb {
"sync" => sync_command(out, rest).await,
"status" => status(out, rest),
"boards" => boards_command(out, rest),
"issues" => issues(out, rest),
"project" => project_command(out, rest).await,
other => Err(usage(format!("unknown command '{other}'"))),
}
}
#[derive(Debug, Default, PartialEq)]
pub(crate) struct Arguments {
pub(crate) positionals: Vec<String>,
values: BTreeMap<String, Vec<String>>,
switches: BTreeSet<String>,
}
impl Arguments {
pub(crate) fn read(args: &[String], valued: &[&str], switches: &[&str]) -> Result<Self, Refusal> {
let mut read = Arguments::default();
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
if valued.contains(&arg) || arg == "--project" {
let value = args
.get(index + 1)
.ok_or_else(|| usage(format!("{arg} needs a value")))?;
read.values.entry(arg.to_string()).or_default().push(value.clone());
index += 2;
continue;
}
if switches.contains(&arg) {
read.switches.insert(arg.to_string());
} else if arg.starts_with("--") {
return Err(usage(format!("this command takes no option {arg}")));
} else {
read.positionals.push(arg.to_string());
}
index += 1;
}
Ok(read)
}
fn value(&self, name: &str) -> Result<Option<&str>, Refusal> {
match self.values.get(name).map(Vec::as_slice) {
None => Ok(None),
Some([one]) => Ok(Some(one.as_str())),
Some(several) => Err(usage(format!(
"{name} was given {} times and is taken once",
several.len()
))),
}
}
fn all(&self, name: &str) -> &[String] {
self.values.get(name).map(Vec::as_slice).unwrap_or(&[])
}
fn switch(&self, name: &str) -> bool {
self.switches.contains(name)
}
fn positional(&self, index: usize, what: &str) -> Result<&str, Refusal> {
self.positionals
.get(index)
.map(String::as_str)
.ok_or_else(|| usage(format!("{what} is required")))
}
fn exactly(&self, count: usize) -> Result<(), Refusal> {
match self.positionals.get(count) {
None => Ok(()),
Some(extra) => Err(usage(format!("unexpected argument '{extra}'"))),
}
}
fn named(&self, names: &[&str]) -> Result<(), Refusal> {
for (index, what) in names.iter().enumerate() {
self.positional(index, what)?;
}
self.exactly(names.len())
}
}
struct Place {
prj: wsb_prj::Prj,
project: wsb_prj::Project,
checkout: std::path::PathBuf,
}
fn place(arguments: &Arguments) -> Result<Place, Refusal> {
let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
if let Some(name) = arguments.value("--project")? {
let project = prj.project(name).map_err(|e| Refusal::Failed(e.to_string()))?;
let checkout = project.main_worktree();
return Ok(Place {
prj,
project,
checkout,
});
}
let cwd = std::env::current_dir()
.map_err(|e| Refusal::Failed(format!("cannot determine the current directory: {e}")))?;
let location = prj.locate(&cwd).map_err(|e| {
Refusal::Failed(format!("{e}. Run this inside a project, or name one with --project <name>"))
})?;
let checkout = match &location.worktree {
Some(worktree) => location.project.dir().join(wsb_prj::worktree_folder(worktree)),
None => location.project.main_worktree(),
};
Ok(Place {
prj,
project: location.project,
checkout,
})
}
fn service_of(prj: &wsb_prj::Prj) -> Result<Service, Refusal> {
Ok(Service::new(Mirror::of(prj)?))
}
fn client() -> Result<GitHubClient, token::NoToken> {
token::resolve().map(|token| GitHubClient::new(token.value()))
}
async fn refresh(out: &Output, service: &Service, scopes: &[Scope], fresh: bool) -> Result<(), Refusal> {
let never_read = |scope: &Scope| -> Result<bool, Refusal> {
Ok(!service.mirror().state(scope)?.ever_synced())
};
let client = match client() {
Ok(client) => client,
Err(no_token) => {
if fresh {
return Err(Refusal::Failed(no_token.to_string()));
}
for scope in scopes {
if never_read(scope)? {
return Err(Refusal::Failed(format!(
"{} has never been read, and {no_token}",
scope.label()
)));
}
}
out.warn(&format!("{no_token}; answering from the mirror"));
return Ok(());
}
};
let report = sync::sync_scopes(service.mirror(), &client, scopes, fresh).await;
for (scope, error) in &report.failed {
let readable = Scope::parse(scope).map(|s| s.label()).unwrap_or_else(|| scope.clone());
let unread = match Scope::parse(scope) {
Some(parsed) => never_read(&parsed)?,
None => true,
};
if fresh || unread {
return Err(Refusal::Failed(format!("{readable} could not be read from GitHub: {error}")));
}
out.warn(&format!(
"{readable} could not be refreshed ({error}); answering from what was read before"
));
}
Ok(())
}
async fn sync_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &["--force"])?;
arguments.exactly(0)?;
let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
let service = service_of(&prj)?;
let targets = Targets::of(&prj)?;
let client = client();
let report = sync::sync(
service.mirror(),
client.as_ref().ok(),
&targets,
arguments.switch("--force"),
)
.await;
for scope in &report.changed {
out.emit(Record::SyncScope {
scope: scope.clone(),
outcome: "changed".to_string(),
error: None,
});
}
for scope in &report.unchanged {
out.emit(Record::SyncScope {
scope: scope.clone(),
outcome: "unchanged".to_string(),
error: None,
});
}
for (scope, error) in &report.failed {
out.emit(Record::SyncScope {
scope: scope.clone(),
outcome: "failed".to_string(),
error: Some(error.clone()),
});
}
for problem in &report.problems {
out.emit(Record::SyncProblem {
problem: problem.clone(),
});
}
out.emit(Record::SyncSummary {
changed: report.changed.len(),
unchanged: report.unchanged.len(),
failed: report.failed.len(),
no_token: report.no_token,
problems: report.problems.len(),
});
let mut lines = vec![format!(
"{} repositories, {} boards: {} changed, {} unchanged, {} failed",
targets.repositories.len(),
targets.boards.len(),
report.changed.len(),
report.unchanged.len(),
report.failed.len()
)];
lines.extend(report.failed.iter().map(|(scope, error)| format!(" failed {scope}: {error}")));
lines.extend(report.problems.iter().map(|problem| format!(" problem {problem}")));
out.say(lines);
if let Err(no_token) = client {
return Err(Refusal::Failed(no_token.to_string()));
}
if !report.problems.is_empty() {
return Err(Refusal::Failed(format!(
"{} problem(s) with what to mirror; nothing was forgotten until they are fixed",
report.problems.len()
)));
}
Ok(())
}
fn status(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &[])?;
arguments.exactly(0)?;
let prj = wsb_prj::Prj::from_env().map_err(|e| Refusal::Failed(e.to_string()))?;
let service = service_of(&prj)?;
let states = service.mirror().states()?;
let failing: Vec<(&String, &String)> = states
.iter()
.filter_map(|(scope, state)| state.last_error.as_ref().map(|error| (scope, error)))
.collect();
let never_synced = states.values().filter(|state| !state.ever_synced()).count();
let token = token::resolve();
out.emit(Record::Status {
mirror: service.mirror().path().display().to_string(),
token: token.as_ref().ok().map(|token| token.source.to_string()),
scopes: states.len(),
failing: failing.len(),
never_synced,
});
let mut lines = vec![
format!("mirror {}", service.mirror().path().display()),
match &token {
Ok(token) => format!("token {}", token.source),
Err(no_token) => format!("token none: {no_token}"),
},
format!(
"scopes {} held, {} never read, {} failing",
states.len(),
never_synced,
failing.len()
),
];
lines.extend(failing.iter().map(|(scope, error)| format!(" {scope}: {error}")));
if let Some(limit) = service.mirror().only(&Scope::RateLimit)? {
lines.push(format!(
"rate {} of {} left",
limit["remaining"], limit["limit"]
));
}
out.say(lines);
Ok(())
}
fn boards_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
let (action, rest) = match args.first().map(String::as_str) {
Some("add") => ("add", &args[1..]),
Some("remove") => ("remove", &args[1..]),
Some("list") => ("list", &args[1..]),
_ => ("list", args),
};
let arguments = Arguments::read(rest, &[], &[])?;
let named: Vec<BoardRef> = match action {
"list" => {
arguments.exactly(0)?;
Vec::new()
}
_ => {
if arguments.positionals.is_empty() {
return Err(usage(format!("boards {action} takes at least one <owner>/<number>")));
}
arguments
.positionals
.iter()
.map(|text| text.parse::<BoardRef>().map_err(usage))
.collect::<Result<_, _>>()?
}
};
let place = place(&arguments)?;
let mut declared = boards::read(&place.project)?;
match action {
"list" => {}
"add" | "remove" => {
for board in named {
let held = declared.boards.iter().position(|held| *held == board);
match (action, held) {
("add", None) => declared.boards.push(board),
("add", Some(_)) => {
return Err(Refusal::Failed(format!(
"{} already references {board}",
place.project.name()
)))
}
(_, Some(index)) => {
declared.boards.remove(index);
}
(_, None) => {
return Err(Refusal::Failed(format!(
"{} does not reference {board}",
place.project.name()
)))
}
}
}
boards::write(&place.project, &declared)?;
}
_ => unreachable!("the actions are the three matched above"),
}
report_references(out, &place.project, &declared);
Ok(())
}
fn report_references(out: &Output, project: &wsb_prj::Project, declared: &ProjectGitHub) {
for board in &declared.boards {
out.emit(Record::BoardReference {
project: project.name().to_string(),
board: board.to_string(),
});
}
if declared.boards.is_empty() {
out.say([format!("{} references no boards", project.name())]);
} else {
out.say(declared.boards.iter().map(|board| board.to_string()));
}
}
fn issues(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &["--repo", "--state"], &[])?;
arguments.exactly(0)?;
let wanted: IssueState = arguments.value("--state")?.unwrap_or("open").parse().map_err(usage)?;
let place = place(&arguments)?;
let mut repos: Vec<String> = checkout::repositories(&place.checkout)?
.into_iter()
.map(|repository| repository.name)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if let Some(repo) = arguments.value("--repo")? {
if !repos.iter().any(|held| held == repo) {
return Err(Refusal::Failed(format!(
"{repo} is not a repository of {}",
place.checkout.display()
)));
}
repos = vec![repo.to_string()];
}
let service = service_of(&place.prj)?;
let list = service.issues(&repos, wanted)?;
let mut lines = Vec::new();
for issue in &list.issues {
let text = |key: &str| issue.get(key).and_then(|v| v.as_str()).unwrap_or("").to_string();
let number = issue.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
out.emit(Record::Issue {
repo: text("repo"),
number,
title: text("title"),
state: text("state"),
url: text("html_url"),
updated_at: text("updated_at"),
});
lines.push(format!("{}#{number} {} [{}]", text("repo"), text("title"), text("state")));
}
for (repo, standing) in &list.sync {
if !standing.ever_synced {
out.warn(&format!("{repo} has not been read yet; run `wsb github sync`"));
}
}
for failure in &list.failures {
out.warn(&format!("{}: the last refresh failed: {}", failure.repo, failure.error));
}
if lines.is_empty() {
lines.push("no issues".to_string());
}
out.say(lines);
Ok(())
}
pub(crate) fn find_board<'a>(boards: &'a [GitHubProject], name: &str) -> Result<&'a GitHubProject, String> {
if let Some(board) = boards.iter().find(|board| board.id == name) {
return Ok(board);
}
let (owner, bare) = match name.split_once('/') {
Some((owner, bare)) => (Some(owner), bare),
None => (None, name),
};
let owned = |board: &&GitHubProject| owner.is_none_or(|owner| board.owner.eq_ignore_ascii_case(owner));
let matches: Vec<&GitHubProject> = match bare.trim_start_matches('#').parse::<i64>() {
Ok(number) => boards.iter().filter(owned).filter(|board| board.number == number).collect(),
Err(_) => {
let exact: Vec<&GitHubProject> = boards
.iter()
.filter(owned)
.filter(|board| board.title.eq_ignore_ascii_case(bare))
.collect();
if exact.is_empty() {
let lowered = bare.to_lowercase();
boards
.iter()
.filter(owned)
.filter(|board| board.title.to_lowercase().starts_with(&lowered))
.collect()
} else {
exact
}
}
};
match matches.as_slice() {
[one] => Ok(*one),
[] => Err(format!(
"no board is called '{name}'. This project's boards: {}",
if boards.is_empty() {
"none".to_string()
} else {
boards.iter().map(board_label).collect::<Vec<_>>().join(", ")
}
)),
several => Err(format!(
"'{name}' names {} boards: {}. Say which by owner/number or by id",
several.len(),
several.iter().map(|board| board_label(board)).collect::<Vec<_>>().join(", ")
)),
}
}
fn board_label(board: &GitHubProject) -> String {
format!("{}/{} '{}'", board.owner, board.number, board.title)
}
pub(crate) fn find_item<'a>(items: &'a [GitHubProjectItem], name: &str) -> Result<&'a GitHubProjectItem, String> {
if let Some(item) = items.iter().find(|item| item.id == name) {
return Ok(item);
}
let by_url: Vec<&GitHubProjectItem> =
items.iter().filter(|item| item.url.as_deref() == Some(name)).collect();
let by_title: Vec<&GitHubProjectItem> = items.iter().filter(|item| item.title == name).collect();
let by_prefix: Vec<&GitHubProjectItem> = items.iter().filter(|item| item.id.starts_with(name)).collect();
let matches = [by_url, by_title, by_prefix]
.into_iter()
.find(|found| !found.is_empty())
.unwrap_or_default();
match matches.as_slice() {
[one] => Ok(*one),
[] => Err(format!(
"no card on this board is '{name}' — by id, by the start of its id, by URL, or by its \
exact title (`wsb github project items <board>` lists them)"
)),
several => Err(format!(
"'{name}' names {} cards: {}. Say which by id",
several.len(),
several
.iter()
.map(|item| format!("{} '{}'", item.id, item.title))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
pub(crate) fn find_field<'a>(board: &'a GitHubProject, name: &str) -> Result<&'a GitHubProjectField, String> {
if let Some(field) = board.fields.iter().find(|field| field.id == name) {
return Ok(field);
}
let matches: Vec<&GitHubProjectField> = board
.fields
.iter()
.filter(|field| field.name.eq_ignore_ascii_case(name))
.collect();
match matches.as_slice() {
[one] => Ok(*one),
[] => Err(format!(
"the board '{}' has no field '{name}'. Its fields: {}",
board.title,
board.fields.iter().map(|field| field.name.as_str()).collect::<Vec<_>>().join(", ")
)),
several => Err(format!(
"the board '{}' has {} fields called '{name}'; name one by id: {}",
board.title,
several.len(),
several.iter().map(|field| field.id.as_str()).collect::<Vec<_>>().join(", ")
)),
}
}
pub(crate) fn find_option<'a>(field: &'a GitHubProjectField, name: &str) -> Result<&'a str, String> {
if let Some(option) = field.options.iter().find(|option| option.id == name) {
return Ok(&option.id);
}
let matches: Vec<&str> = field
.options
.iter()
.filter(|option| option.name.eq_ignore_ascii_case(name))
.map(|option| option.id.as_str())
.collect();
match matches.as_slice() {
[one] => Ok(*one),
[] => Err(format!(
"'{}' has no option '{name}'. Its options: {}",
field.name,
field.options.iter().map(|option| option.name.as_str()).collect::<Vec<_>>().join(", ")
)),
_ => Err(format!(
"'{}' has more than one option called '{name}'; name one by id",
field.name
)),
}
}
pub(crate) fn field_write(board: &GitHubProject, assignment: &str) -> Result<(String, ProjectFieldWrite), Refusal> {
let (name, value) = assignment
.split_once('=')
.ok_or_else(|| usage(format!("'{assignment}' is not <field>=<value>")))?;
let field = find_field(board, name.trim())?;
let write = match field.kind.as_str() {
"single_select" => ProjectFieldWrite::SingleSelect {
option_id: find_option(field, value.trim())?.to_string(),
},
"text" => ProjectFieldWrite::Text {
text: value.to_string(),
},
"number" => {
let number: f64 = value.trim().parse().map_err(|_| {
format!("'{}' is a number field, and '{value}' is not a number", field.name)
})?;
if !number.is_finite() {
return Err(Refusal::Failed(format!(
"'{}' is a number field, and '{value}' is not a finite number",
field.name
)));
}
ProjectFieldWrite::Number { number }
}
"date" => {
let date = value.trim();
if !is_calendar_date(date) {
return Err(Refusal::Failed(format!(
"'{}' is a date field, and '{value}' is not a date written YYYY-MM-DD",
field.name
)));
}
ProjectFieldWrite::Date {
date: date.to_string(),
}
}
"iteration" => {
return Err(Refusal::Failed(format!(
"'{}' is an iteration field: an iteration is chosen by its id, which a board does \
not list here — set it in GitHub",
field.name
)))
}
"title" => {
return Err(Refusal::Failed(
"a card's title is not a field value: `project edit` changes a draft's, and an \
issue's belongs to the issue"
.to_string(),
))
}
other => {
return Err(Refusal::Failed(format!(
"'{}' is GitHub's own {other} field, kept on the issue or pull request rather \
than on the board; it is not set from here",
field.name
)))
}
};
Ok((field.id.clone(), write))
}
pub(crate) fn is_calendar_date(text: &str) -> bool {
let parts: Vec<&str> = text.split('-').collect();
let [year, month, day] = parts.as_slice() else {
return false;
};
if year.len() != 4 || month.len() != 2 || day.len() != 2 {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (year.parse::<u32>(), month.parse::<u32>(), day.parse::<u32>()) else {
return false;
};
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let days = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return false,
};
(1..=days).contains(&day)
}
fn value_text(value: &GitHubProjectValue) -> String {
match value {
GitHubProjectValue::SingleSelect { name, .. } => name.clone(),
GitHubProjectValue::Text { text } => text.clone(),
GitHubProjectValue::Number { number } if number.fract() == 0.0 && number.abs() < 1e15 => {
format!("{}", *number as i64)
}
GitHubProjectValue::Number { number } => number.to_string(),
GitHubProjectValue::Date { date } => date.clone(),
GitHubProjectValue::Iteration { title, .. } => title.clone(),
}
}
fn value_kind(value: &GitHubProjectValue) -> &'static str {
match value {
GitHubProjectValue::SingleSelect { .. } => "single_select",
GitHubProjectValue::Text { .. } => "text",
GitHubProjectValue::Number { .. } => "number",
GitHubProjectValue::Date { .. } => "date",
GitHubProjectValue::Iteration { .. } => "iteration",
}
}
pub(crate) fn named_values(board: &GitHubProject, item: &GitHubProjectItem) -> Vec<ProjectValue> {
board
.fields
.iter()
.filter(|field| field.kind != "title")
.filter_map(|field| {
let value = item.field_values.get(&field.id)?;
Some(ProjectValue {
field: field.name.clone(),
kind: value_kind(value).to_string(),
value: value_text(value),
option: match value {
GitHubProjectValue::SingleSelect { option_id, .. } => Some(option_id.clone()),
_ => None,
},
})
})
.collect()
}
fn board_record(board: &GitHubProject) -> Record {
Record::ProjectBoard {
board: board.id.clone(),
number: board.number,
title: board.title.clone(),
owner: board.owner.clone(),
url: board.url.clone(),
closed: board.closed,
}
}
fn field_record(board: &GitHubProject, field: &GitHubProjectField) -> Record {
Record::ProjectField {
board: board.id.clone(),
field: field.id.clone(),
name: field.name.clone(),
kind: field.kind.clone(),
options: field
.options
.iter()
.map(|option| ProjectOption {
id: option.id.clone(),
name: option.name.clone(),
})
.collect(),
}
}
pub(crate) fn item_record(board: &GitHubProject, item: &GitHubProjectItem) -> Record {
Record::ProjectItem {
board: board.id.clone(),
item: item.id.clone(),
kind: item.kind.clone(),
title: item.title.clone(),
url: item.url.clone(),
repository: item.repository.clone(),
number: item.number,
state: item.state.clone(),
archived: item.archived,
body: item.body.clone().unwrap_or_default(),
fields: named_values(board, item),
}
}
fn item_line(board: &GitHubProject, item: &GitHubProjectItem, column_field: Option<&str>) -> String {
let others: Vec<String> = named_values(board, item)
.into_iter()
.filter(|value| Some(value.field.as_str()) != column_field)
.map(|value| format!("{}: {}", value.field, value.value))
.collect();
let mut line = format!(" {} {}", item.id, item.title);
if let Some(number) = item.number {
line.push_str(&format!(
" ({}#{number}{})",
item.repository.as_deref().unwrap_or(""),
item.state.as_deref().map(|state| format!(", {}", state.to_lowercase())).unwrap_or_default()
));
}
if item.archived {
line.push_str(" [archived]");
}
if !others.is_empty() {
line.push_str(&format!(" — {}", others.join(", ")));
}
line
}
fn items_prose(board: &GitHubProject, items: &[&GitHubProjectItem]) -> Vec<String> {
let mut lines = Vec::new();
let Some(column) = board.column_field() else {
lines.push(format!("{} — {} card(s)", board.title, items.len()));
lines.extend(items.iter().map(|item| item_line(board, item, None)));
return lines;
};
lines.push(format!("{} — {} card(s), by {}", board.title, items.len(), column.name));
let mut groups: Vec<(String, Option<&str>)> = column
.options
.iter()
.map(|option| (option.name.clone(), Some(option.id.as_str())))
.collect();
groups.push((format!("No {}", column.name), None));
for (name, option) in groups {
let held: Vec<&&GitHubProjectItem> =
items.iter().filter(|item| item.column(&column.id) == option).collect();
if held.is_empty() {
continue;
}
lines.push(format!("{name} ({})", held.len()));
lines.extend(held.iter().map(|item| item_line(board, item, Some(column.name.as_str()))));
}
lines
}
fn item_prose(board: &GitHubProject, item: &GitHubProjectItem) -> Vec<String> {
let mut lines = vec![item.title.clone(), format!(" id {}", item.id)];
lines.push(format!(
" kind {}",
match item.kind.as_str() {
"DRAFT_ISSUE" => "draft (lives only on the board)",
"ISSUE" => "issue",
"PULL_REQUEST" => "pull request",
other => other,
}
));
if let Some(url) = &item.url {
lines.push(format!(" url {url}"));
}
if let Some(state) = &item.state {
lines.push(format!(" state {}", state.to_lowercase()));
}
if item.archived {
lines.push(" archived yes".to_string());
}
for value in named_values(board, item) {
lines.push(format!(" {:<11} {}", value.field, value.value));
}
let body = item.body.as_deref().unwrap_or("").trim_end();
if !body.is_empty() {
lines.push(String::new());
lines.extend(body.lines().map(str::to_string));
}
lines
}
struct Boards {
service: Service,
project: wsb_prj::Project,
references: Vec<BoardRef>,
}
fn project_boards(arguments: &Arguments) -> Result<Boards, Refusal> {
let place = place(arguments)?;
let references = boards::read(&place.project)?.boards;
if references.is_empty() {
return Err(Refusal::Failed(format!(
"{} references no boards; add one with `wsb github boards add <owner>/<number>`",
place.project.name()
)));
}
Ok(Boards {
service: service_of(&place.prj)?,
project: place.project,
references,
})
}
async fn listed_boards(out: &Output, boards: &Boards, fresh: bool) -> Result<Vec<GitHubProject>, Refusal> {
let owners: BTreeSet<String> = boards.references.iter().map(|board| board.owner.clone()).collect();
let scopes: Vec<Scope> = owners.into_iter().map(Scope::Projects).collect();
refresh(out, &boards.service, &scopes, fresh).await?;
let list = boards.service.boards(&boards.references)?;
for problem in &list.problems {
out.warn(problem);
}
Ok(list.boards)
}
async fn named_board(out: &Output, boards: &Boards, arguments: &Arguments) -> Result<GitHubProject, Refusal> {
let name = arguments.positional(0, "a board")?;
let all = listed_boards(out, boards, arguments.switch("--fresh")).await?;
Ok(find_board(&all, name)?.clone())
}
async fn board_cards(
out: &Output,
boards: &Boards,
board: &GitHubProject,
fresh: bool,
) -> Result<Vec<GitHubProjectItem>, Refusal> {
refresh(out, &boards.service, &[Scope::ProjectItems(board.id.clone())], fresh).await?;
let cards = boards.service.cards(&board.id)?;
if !cards.sync.ever_synced {
return Err(Refusal::Failed(format!(
"the cards of '{}' have not been read from GitHub{}",
board.title,
cards.sync.last_error.map(|error| format!(": {error}")).unwrap_or_default()
)));
}
Ok(cards.items)
}
async fn named_card(
out: &Output,
boards: &Boards,
arguments: &Arguments,
) -> Result<(GitHubProject, GitHubProjectItem), Refusal> {
let name = arguments.positional(1, "a card")?;
let board = named_board(out, boards, arguments).await?;
let all = board_cards(out, boards, &board, arguments.switch("--fresh")).await?;
let card = find_item(&all, name)?.clone();
Ok((board, card))
}
fn writing_client() -> Result<GitHubClient, Refusal> {
client().map_err(|no_token| Refusal::Failed(no_token.to_string()))
}
fn written(out: &Output, board: &GitHubProject, card: &GitHubProjectItem, what: &str) {
out.emit(item_record(board, card));
out.say([format!("{what}: {} {}", card.id, card.title)]);
}
fn body_text(arguments: &Arguments) -> Result<Option<String>, Refusal> {
match (arguments.value("--body")?, arguments.value("--body-file")?) {
(Some(_), Some(_)) => Err(usage("give --body or --body-file, not both")),
(Some(text), None) => Ok(Some(text.to_string())),
(None, Some("-")) => {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.map_err(|e| format!("cannot read the body from standard input: {e}"))?;
Ok(Some(text))
}
(None, Some(path)) => std::fs::read_to_string(path)
.map(Some)
.map_err(|e| Refusal::Failed(format!("cannot read the body from {path}: {e}"))),
(None, None) => Ok(None),
}
}
async fn project_command(out: &Output, args: &[String]) -> Result<(), Refusal> {
let verb = args.first().map(String::as_str);
let rest: &[String] = if args.len() > 1 { &args[1..] } else { &[] };
match verb {
None => list(out, rest).await,
Some(flag) if flag.starts_with("--") => list(out, args).await,
Some("list") => list(out, rest).await,
Some("show") => show(out, rest).await,
Some("items") => items(out, rest).await,
Some("item") => item(out, rest).await,
Some("add") => add(out, rest).await,
Some("set") => set(out, rest).await,
Some("clear") => clear(out, rest).await,
Some("move") => move_card(out, rest).await,
Some("edit") => edit(out, rest).await,
Some("archive") => archive(out, rest, true).await,
Some("unarchive") => archive(out, rest, false).await,
Some("remove") => remove(out, rest).await,
Some("column") => column(out, rest).await,
Some(other) => Err(usage(format!("unknown project command '{other}'"))),
}
}
async fn list(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &["--fresh"])?;
arguments.exactly(0)?;
let boards = project_boards(&arguments)?;
let all = listed_boards(out, &boards, arguments.switch("--fresh")).await?;
for board in &all {
out.emit(board_record(board));
}
if all.is_empty() {
out.say([format!("no board {} references could be read", boards.project.name())]);
return Ok(());
}
out.say(all.iter().map(|board| {
format!(
"{}/{} {}{} {}",
board.owner,
board.number,
board.title,
if board.closed { " [closed]" } else { "" },
board.url
)
}));
Ok(())
}
async fn show(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &["--fresh"])?;
arguments.named(&["a board"])?;
let boards = project_boards(&arguments)?;
let board = named_board(out, &boards, &arguments).await?;
out.emit(board_record(&board));
for field in &board.fields {
out.emit(field_record(&board, field));
}
let mut lines = vec![
format!("{}/{} {}", board.owner, board.number, board.title),
format!(" id {}", board.id),
format!(" url {}", board.url),
"Fields".to_string(),
];
for field in &board.fields {
let options: Vec<&str> = field.options.iter().map(|option| option.name.as_str()).collect();
lines.push(format!(
" {:<24} {}{}",
field.name,
field.kind,
if options.is_empty() { String::new() } else { format!(": {}", options.join(", ")) }
));
}
out.say(lines);
Ok(())
}
pub(crate) fn passes(board: &GitHubProject, item: &GitHubProjectItem, wanted: &[(String, String)]) -> bool {
let values = named_values(board, item);
wanted.iter().all(|(field, value)| {
values
.iter()
.any(|held| held.field.eq_ignore_ascii_case(field) && held.value.eq_ignore_ascii_case(value))
})
}
async fn items(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &["--status", "--where"], &["--fresh", "--archived"])?;
arguments.named(&["a board"])?;
for condition in arguments.all("--where") {
if !condition.contains('=') {
return Err(usage(format!("--where '{condition}' is not <field>=<value>")));
}
}
let boards = project_boards(&arguments)?;
let board = named_board(out, &boards, &arguments).await?;
let mut wanted: Vec<(String, String)> = Vec::new();
if let Some(status) = arguments.value("--status")? {
let column = board.column_field().ok_or_else(|| {
format!("the board '{}' has no column field, so it has no status to filter by", board.title)
})?;
find_option(column, status)?;
wanted.push((column.name.clone(), status.to_string()));
}
for condition in arguments.all("--where") {
let (field, value) = condition
.split_once('=')
.ok_or_else(|| usage(format!("--where '{condition}' is not <field>=<value>")))?;
let field = find_field(&board, field.trim())?;
wanted.push((field.name.clone(), value.trim().to_string()));
}
let all = board_cards(out, &boards, &board, arguments.switch("--fresh")).await?;
let kept: Vec<&GitHubProjectItem> = all
.iter()
.filter(|item| arguments.switch("--archived") || !item.archived)
.filter(|item| passes(&board, item, &wanted))
.collect();
for item in &kept {
out.emit(item_record(&board, item));
}
out.say(items_prose(&board, &kept));
Ok(())
}
async fn item(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &["--fresh"])?;
arguments.named(&["a board", "a card"])?;
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
out.emit(item_record(&board, &card));
out.say(item_prose(&board, &card));
Ok(())
}
async fn apply(
boards: &Boards,
client: &GitHubClient,
board: &GitHubProject,
card: GitHubProjectItem,
writes: &[(String, Option<ProjectFieldWrite>)],
) -> Result<GitHubProjectItem, Refusal> {
let mut card = card;
for (field, value) in writes {
card = boards
.service
.set_field(client, &board.id, &card.id, field, value.as_ref())
.await?;
}
Ok(card)
}
async fn add(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &["--title", "--body", "--body-file", "--url", "--set"], &[])?;
arguments.named(&["a board"])?;
for assignment in arguments.all("--set") {
if !assignment.contains('=') {
return Err(usage(format!("'{assignment}' is not <field>=<value>")));
}
}
let title = arguments.value("--title")?;
let url = arguments.value("--url")?;
if arguments.value("--body")?.is_some() && arguments.value("--body-file")?.is_some() {
return Err(usage("give --body or --body-file, not both"));
}
let body = body_text(&arguments)?;
match (title, url) {
(Some(_), Some(_)) => {
return Err(usage("give --title for a draft or --url for something that exists, not both"))
}
(None, None) => return Err(usage("a card needs --title (a draft) or --url (an issue or pull request)")),
(Some(title), None) if title.trim().is_empty() => return Err(usage("a draft card needs a title")),
(None, Some(_)) if body.is_some() => {
return Err(usage(
"a card for an issue or pull request has that item's description; --body is a draft's",
))
}
_ => {}
}
let boards = project_boards(&arguments)?;
let board = named_board(out, &boards, &arguments).await?;
let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments
.all("--set")
.iter()
.map(|assignment| field_write(&board, assignment).map(|(field, value)| (field, Some(value))))
.collect::<Result<_, _>>()?;
let client = writing_client()?;
let created = match (title, url) {
(Some(title), None) => {
boards
.service
.add_draft(&client, &board.id, title, &body.unwrap_or_default())
.await?
}
(None, Some(url)) => boards.service.add_existing(&client, &board.id, url).await?,
_ => unreachable!("exactly one of --title and --url was checked above"),
};
let card = apply(&boards, &client, &board, created, &writes).await?;
written(out, &board, &card, "added");
Ok(())
}
async fn set(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &[])?;
if arguments.positionals.len() < 3 {
return Err(usage("set takes a board, a card, and at least one <field>=<value>"));
}
for assignment in &arguments.positionals[2..] {
if !assignment.contains('=') {
return Err(usage(format!("'{assignment}' is not <field>=<value>")));
}
}
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments.positionals[2..]
.iter()
.map(|assignment| field_write(&board, assignment).map(|(field, value)| (field, Some(value))))
.collect::<Result<_, _>>()?;
let client = writing_client()?;
let card = apply(&boards, &client, &board, card, &writes).await?;
written(out, &board, &card, "set");
Ok(())
}
async fn clear(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &[])?;
if arguments.positionals.len() < 3 {
return Err(usage("clear takes a board, a card, and at least one field"));
}
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let writes: Vec<(String, Option<ProjectFieldWrite>)> = arguments.positionals[2..]
.iter()
.map(|name| find_field(&board, name).map(|field| (field.id.clone(), None)))
.collect::<Result<_, _>>()?;
let client = writing_client()?;
let card = apply(&boards, &client, &board, card, &writes).await?;
written(out, &board, &card, "cleared");
Ok(())
}
async fn move_card(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &["--after"], &["--top"])?;
arguments.named(&["a board", "a card"])?;
let after = arguments.value("--after")?;
if after.is_some() == arguments.switch("--top") {
return Err(usage("say where: --after <card>, or --top"));
}
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let after = match after {
Some(name) => {
let all = boards.service.cards(&board.id)?.items;
let target = find_item(&all, name)?;
Some(target.id.clone())
}
None => None,
};
let client = writing_client()?;
let moved = boards
.service
.move_card(&client, &board.id, &card.id, after.as_deref())
.await?;
written(out, &board, &moved, "moved");
Ok(())
}
async fn edit(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &["--title", "--body", "--body-file"], &[])?;
arguments.named(&["a board", "a card"])?;
let title = arguments.value("--title")?;
if arguments.value("--body")?.is_some() && arguments.value("--body-file")?.is_some() {
return Err(usage("give --body or --body-file, not both"));
}
let body = body_text(&arguments)?;
if title.is_none() && body.is_none() {
return Err(usage("say what to change: --title, --body or --body-file"));
}
if title.is_some_and(|title| title.trim().is_empty()) {
return Err(usage("a draft card needs a title"));
}
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let content = match (card.kind.as_str(), &card.content_id) {
("DRAFT_ISSUE", Some(content)) => content.clone(),
("DRAFT_ISSUE", None) => {
return Err(Refusal::Failed(format!(
"the draft '{}' was read without the id its text is edited by; read the board \
again with --fresh",
card.title
)))
}
_ => {
return Err(Refusal::Failed(format!(
"'{}' is not a draft: its title and description belong to {}, and are changed there",
card.title,
card.url.as_deref().unwrap_or("the issue or pull request")
)))
}
};
let client = writing_client()?;
let edited = boards
.service
.edit_draft(
&client,
&board.id,
&card.id,
&content,
title.unwrap_or(&card.title),
&body.unwrap_or_else(|| card.body.clone().unwrap_or_default()),
)
.await?;
written(out, &board, &edited, "edited");
Ok(())
}
async fn archive(out: &Output, args: &[String], archived: bool) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &[])?;
arguments.named(&["a board", "a card"])?;
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let client = writing_client()?;
let changed = boards
.service
.set_archived(&client, &board.id, &card.id, archived)
.await?;
written(out, &board, &changed, if archived { "archived" } else { "unarchived" });
Ok(())
}
async fn remove(out: &Output, args: &[String]) -> Result<(), Refusal> {
let arguments = Arguments::read(args, &[], &[])?;
arguments.named(&["a board", "a card"])?;
let boards = project_boards(&arguments)?;
let (board, card) = named_card(out, &boards, &arguments).await?;
let client = writing_client()?;
boards.service.remove_card(&client, &board.id, &card.id).await?;
out.emit(Record::ProjectItemRemoved {
board: board.id.clone(),
item: card.id.clone(),
});
out.say([format!("removed: {} {}", card.id, card.title)]);
Ok(())
}
async fn column(out: &Output, args: &[String]) -> Result<(), Refusal> {
let Some(action) = args.first().map(String::as_str) else {
return Err(usage("column add or column remove"));
};
let rest = &args[1..];
let arguments = match action {
"add" => Arguments::read(rest, &["--field", "--color", "--description"], &[])?,
"remove" => Arguments::read(rest, &["--field"], &[])?,
other => return Err(usage(format!("unknown column command '{other}'"))),
};
arguments.named(&["a board", "a column name"])?;
let name = arguments.positional(1, "a column name")?;
let boards = project_boards(&arguments)?;
let board = named_board(out, &boards, &arguments).await?;
let field = match arguments.value("--field")? {
Some(named) => find_field(&board, named)?,
None => board.column_field().ok_or_else(|| {
format!("the board '{}' has no single-select field to hold columns", board.title)
})?,
};
if field.kind != "single_select" {
return Err(Refusal::Failed(format!(
"'{}' is a {} field; only a single-select field has columns",
field.name, field.kind
)));
}
let client = writing_client()?;
let (updated, _items) = if action == "add" {
if field.options.iter().any(|option| option.name.eq_ignore_ascii_case(name)) {
return Err(Refusal::Failed(format!("'{}' already has a column '{name}'", field.name)));
}
let color = arguments.value("--color")?.unwrap_or("GRAY").to_uppercase();
boards
.service
.add_column(
&client,
&board.id,
&field.id,
name,
&color,
arguments.value("--description")?.unwrap_or(""),
)
.await?
} else {
let option = find_option(field, name)?.to_string();
boards
.service
.remove_column(&client, &board.id, &field.id, &option)
.await?
};
let changed = find_field(&updated, &field.id)?;
out.emit(field_record(&updated, changed));
out.say([format!(
"{}: {}",
changed.name,
changed.options.iter().map(|option| option.name.as_str()).collect::<Vec<_>>().join(", ")
)]);
Ok(())
}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod tests;