use std::io::Write;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use crate::client::{self, Client};
use crate::commands::resolve_repo_slug;
use crate::editor;
use crate::output::{render_record, render_table, FormatChoice};
#[derive(Debug, Subcommand)]
pub enum IssueCmd {
List(ListArgs),
View(ViewArgs),
Create(CreateArgs),
Comment(CommentArgs),
Close(NumberArgs),
Reopen(NumberArgs),
SetState(SetStateArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
pub repo: String,
#[arg(long, default_value = "open")]
pub state: String,
#[arg(long)]
pub label: Vec<String>,
#[arg(long)]
pub milestone: Option<String>,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
pub repo: String,
pub number: i64,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
pub repo: String,
#[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
pub title: String,
#[arg(long)]
pub body: Option<String>,
#[arg(long)]
pub label: Vec<String>,
#[arg(long)]
pub milestone: Option<String>,
#[arg(long)]
pub assignee: Vec<String>,
}
#[derive(Debug, Args)]
pub struct CommentArgs {
pub repo: String,
pub number: i64,
#[arg(long)]
pub body: Option<String>,
}
#[derive(Debug, Args)]
pub struct NumberArgs {
pub repo: String,
pub number: i64,
}
#[derive(Debug, Args)]
pub struct SetStateArgs {
pub repo: String,
pub number: i64,
#[arg(long)]
pub state: String,
}
pub fn run(cmd: IssueCmd) -> Result<()> {
match cmd {
IssueCmd::List(a) => list(a),
IssueCmd::View(a) => view(a),
IssueCmd::Create(a) => create(a),
IssueCmd::Comment(a) => comment(a),
IssueCmd::Close(a) => close(a),
IssueCmd::Reopen(a) => reopen(a),
IssueCmd::SetState(a) => set_state(a),
}
}
fn list(args: ListArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (_repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(ISSUE_LIST, json!({}))?;
let issues = data["repositories"]
.as_array()
.and_then(|repos| {
repos.iter().find(|r| {
r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
})
})
.and_then(|r| r["issues"].as_array())
.cloned()
.unwrap_or_default();
let rows = issue_rows(&issues, &args.state, &args.label, args.milestone.as_deref());
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
let out = render_table(&["#", "status", "title", "author"], &rows, fmt);
std::io::stdout().lock().write_all(out.as_bytes())?;
Ok(())
}
fn view(args: ViewArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (_id, owner, name) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(ISSUE_LIST, json!({}))?;
let issue = data["repositories"]
.as_array()
.and_then(|repos| {
repos.iter().find(|r| {
r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
})
})
.and_then(|r| r["issues"].as_array())
.and_then(|issues| {
issues
.iter()
.find(|i| i["number"].as_i64() == Some(args.number))
})
.ok_or_else(|| anyhow!("no issue #{} in {}/{}", args.number, owner, name))?;
let pairs = vec![
("Issue", format!("#{}", args.number)),
("Title", issue["title"].as_str().unwrap_or("").to_string()),
("Status", issue["status"].as_str().unwrap_or("").to_string()),
(
"Author",
issue["authorUsername"].as_str().unwrap_or("").to_string(),
),
(
"Updated",
issue["updatedAt"].as_str().unwrap_or("").to_string(),
),
("Body", issue["body"].as_str().unwrap_or("").to_string()),
];
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
std::io::stdout()
.lock()
.write_all(render_record(&pairs, fmt).as_bytes())?;
Ok(())
}
fn create(args: CreateArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
let body = match args.body {
Some(b) => b,
None => editor::compose("")?,
};
let data = cli.execute(
CREATE_ISSUE,
json!({ "repoId": repo_id, "title": args.title, "body": body }),
)?;
let issue_id = data["createIssue"]["id"]
.as_str()
.ok_or_else(|| anyhow!("createIssue returned no id"))?
.to_string();
let number = data["createIssue"]["number"].as_i64().unwrap_or(0);
if !args.label.is_empty() {
let labels = cli.execute(REPO_LABELS, json!({}))?;
let label_rows = labels["repositories"]
.as_array()
.and_then(|repos| {
repos.iter().find(|r| {
r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
})
})
.and_then(|r| r["labels"].as_array())
.cloned()
.unwrap_or_default();
for wanted in &args.label {
let label_id = label_rows
.iter()
.find(|l| l["name"].as_str() == Some(wanted.as_str()))
.and_then(|l| l["id"].as_str())
.ok_or_else(|| anyhow!("unknown label `{}` in {}/{}", wanted, owner, name))?;
cli.execute(
ADD_LABEL,
json!({ "issueId": issue_id, "labelId": label_id }),
)?;
}
}
if let Some(m) = &args.milestone {
let milestones = cli.execute(REPO_MILESTONES, json!({}))?;
let milestone_id = milestones["repositories"]
.as_array()
.and_then(|repos| {
repos.iter().find(|r| {
r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
})
})
.and_then(|r| r["milestones"].as_array())
.and_then(|ms| ms.iter().find(|x| x["title"].as_str() == Some(m.as_str())))
.and_then(|x| x["id"].as_str())
.ok_or_else(|| anyhow!("unknown milestone `{}`", m))?
.to_string();
cli.execute(
SET_MILESTONE,
json!({ "issueId": issue_id, "milestoneId": milestone_id }),
)?;
}
for username in &args.assignee {
let accounts = cli.execute(ACCOUNTS, json!({}))?;
let account_id = accounts["accounts"]
.as_array()
.and_then(|a| {
a.iter()
.find(|x| x["username"].as_str() == Some(username.as_str()))
})
.and_then(|x| x["id"].as_str())
.ok_or_else(|| anyhow!("unknown user `{}`", username))?
.to_string();
cli.execute(
ADD_ASSIGNEE,
json!({ "issueId": issue_id, "accountId": account_id }),
)?;
}
writeln!(
std::io::stdout().lock(),
"Created issue #{} in {}/{}",
number,
owner,
name
)?;
Ok(())
}
fn comment(args: CommentArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
let body = match args.body {
Some(b) => b,
None => editor::compose("")?,
};
cli.execute(
ADD_COMMENT,
json!({ "input": { "issueId": issue_id, "body": body } }),
)?;
writeln!(
std::io::stdout().lock(),
"Comment posted to {}#{}",
args.repo,
args.number
)?;
Ok(())
}
fn close(args: NumberArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
cli.execute(CLOSE_ISSUE, json!({ "id": issue_id }))?;
writeln!(
std::io::stdout().lock(),
"Closed {}#{}",
args.repo,
args.number
)?;
Ok(())
}
fn reopen(args: NumberArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
cli.execute(REOPEN_ISSUE, json!({ "id": issue_id }))?;
writeln!(
std::io::stdout().lock(),
"Reopened {}#{}",
args.repo,
args.number
)?;
Ok(())
}
fn set_state(args: SetStateArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
let states = cli.execute(REPO_WORKFLOW_STATES, json!({ "id": repo_id }))?;
let states_arr = states["repository"]["workflowStates"]
.as_array()
.cloned()
.unwrap_or_default();
let state_id = find_workflow_state_id(&states_arr, &args.state)
.ok_or_else(|| {
anyhow!(
"no workflow state named {:?} on {}/{} (available: {})",
args.state,
owner,
name,
workflow_state_names(&states_arr).join(", ")
)
})?
.to_string();
let resp = cli.execute(
SET_ISSUE_WORKFLOW_STATE,
json!({ "issueId": issue_id, "stateId": state_id }),
)?;
let new_state = resp["setIssueWorkflowState"]["workflowState"]["name"]
.as_str()
.unwrap_or(&args.state);
let new_status = resp["setIssueWorkflowState"]["status"]
.as_str()
.unwrap_or("");
writeln!(
std::io::stdout().lock(),
"{}#{} → state \"{}\" (status: {})",
args.repo,
args.number,
new_state,
new_status
)?;
Ok(())
}
pub(crate) fn resolve_repo(cli: &Client, slug: &str) -> Result<(String, String, String)> {
let (owner, name) = resolve_repo_slug(slug)?;
let data = cli.execute("query { repositories { id ownerSlug name } }", json!({}))?;
let repos = data["repositories"].as_array().cloned().unwrap_or_default();
let id = repos
.iter()
.find(|r| r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name))
.and_then(|r| r["id"].as_str())
.ok_or_else(|| anyhow!("no repository {}/{}", owner, name))?
.to_string();
Ok((id, owner, name))
}
fn find_workflow_state_id<'a>(states: &'a [Value], wanted: &str) -> Option<&'a str> {
let wanted = wanted.trim().to_lowercase();
states
.iter()
.find(|s| {
s["name"]
.as_str()
.map(|n| n.to_lowercase() == wanted)
.unwrap_or(false)
})
.and_then(|s| s["id"].as_str())
}
fn workflow_state_names(states: &[Value]) -> Vec<String> {
states
.iter()
.filter_map(|s| s["name"].as_str().map(|n| n.to_string()))
.collect()
}
fn issue_rows(
issues: &[Value],
state: &str,
label_filter: &[String],
milestone: Option<&str>,
) -> Vec<Vec<String>> {
let state_filter = state.to_ascii_lowercase();
let label_filter: Vec<String> = label_filter.iter().map(|s| s.to_lowercase()).collect();
issues
.iter()
.filter(|i| match state_filter.as_str() {
"all" => true,
"closed" => matches!(i["status"].as_str(), Some("CLOSED")),
_ => matches!(i["status"].as_str(), Some("OPEN")),
})
.filter(|i| {
if label_filter.is_empty() {
return true;
}
let labels: Vec<String> = i["labels"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|l| l["name"].as_str().map(|s| s.to_lowercase()))
.collect()
})
.unwrap_or_default();
label_filter
.iter()
.all(|wanted| labels.iter().any(|got| got == wanted))
})
.filter(|i| match milestone {
None => true,
Some(m) => i["milestone"]["title"].as_str() == Some(m),
})
.map(|i| {
vec![
format!("#{}", i["number"].as_i64().unwrap_or(0)),
i["status"].as_str().unwrap_or("").to_string(),
i["title"].as_str().unwrap_or("").to_string(),
i["authorUsername"].as_str().unwrap_or("").to_string(),
]
})
.collect()
}
fn resolve_issue_id(cli: &Client, slug: &str, number: i64) -> Result<String> {
let (_id, owner, name) = resolve_repo(cli, slug)?;
let data: Value = cli.execute(ISSUE_LIST, json!({}))?;
data["repositories"]
.as_array()
.and_then(|repos| {
repos.iter().find(|r| {
r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
})
})
.and_then(|r| r["issues"].as_array())
.and_then(|issues| issues.iter().find(|i| i["number"].as_i64() == Some(number)))
.and_then(|i| i["id"].as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("no issue #{} in {}/{}", number, owner, name))
}
const ISSUE_LIST: &str = "{ \
repositories { \
id ownerSlug name \
issues { \
id number title status authorUsername updatedAt body \
labels { name } \
milestone { title } \
} \
} \
}";
const CREATE_ISSUE: &str = "mutation($repoId: UUID!, $title: String!, $body: String!) { \
createIssue(repositoryId: $repoId, title: $title, body: $body) { id number } \
}";
const ADD_COMMENT: &str = "mutation($input: AddIssueCommentInput!) { \
addIssueComment(input: $input) { id } \
}";
const CLOSE_ISSUE: &str = "mutation($id: UUID!) { closeIssue(issueId: $id) { id } }";
const REOPEN_ISSUE: &str = "mutation($id: UUID!) { reopenIssue(issueId: $id) { id } }";
const REPO_WORKFLOW_STATES: &str = "query($id: UUID!) { \
repository(id: $id) { workflowStates { id name category } } \
}";
const SET_ISSUE_WORKFLOW_STATE: &str = "mutation($issueId: UUID!, $stateId: UUID!) { \
setIssueWorkflowState(issueId: $issueId, stateId: $stateId) { \
id number title status \
workflowState { name category } \
} \
}";
const REPO_LABELS: &str = "{ \
repositories { ownerSlug name labels { id name } } \
}";
const REPO_MILESTONES: &str = "{ \
repositories { ownerSlug name milestones { id title } } \
}";
const ADD_LABEL: &str = "mutation($issueId: UUID!, $labelId: UUID!) { \
addLabelToIssue(issueId: $issueId, labelId: $labelId) \
}";
const SET_MILESTONE: &str = "mutation($issueId: UUID!, $milestoneId: UUID) { \
setIssueMilestone(issueId: $issueId, milestoneId: $milestoneId) { id } \
}";
const ADD_ASSIGNEE: &str = "mutation($issueId: UUID!, $accountId: UUID!) { \
addIssueAssignee(issueId: $issueId, accountId: $accountId) \
}";
const ACCOUNTS: &str = "{ accounts { id username } }";
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn workflow_state_lookup_matches_trimmed_names_case_insensitively() {
let states = vec![
json!({ "id": "todo-id", "name": "Todo" }),
json!({ "id": "progress-id", "name": "In Progress" }),
json!({ "id": "done-id", "name": "Done" }),
json!({ "id": "nameless-id" }),
];
assert_eq!(
find_workflow_state_id(&states, " in progress "),
Some("progress-id")
);
assert_eq!(find_workflow_state_id(&states, "DONE"), Some("done-id"));
assert_eq!(find_workflow_state_id(&states, "Blocked"), None);
}
#[test]
fn workflow_state_names_lists_only_named_states() {
let states = vec![
json!({ "id": "todo-id", "name": "Todo" }),
json!({ "id": "progress-id", "name": "In Progress" }),
json!({ "id": "missing-name" }),
];
assert_eq!(
workflow_state_names(&states),
vec!["Todo".to_string(), "In Progress".to_string()]
);
}
#[test]
fn issue_rows_filters_by_state_labels_and_milestone() {
let issues = vec![
json!({
"number": 1,
"status": "OPEN",
"title": "Wire local runner",
"authorUsername": "bri",
"labels": [{"name": "ci"}, {"name": "Runtime"}],
"milestone": {"title": "single-box"}
}),
json!({
"number": 2,
"status": "CLOSED",
"title": "Document deploy",
"authorUsername": "alex",
"labels": [{"name": "docs"}],
"milestone": {"title": "single-box"}
}),
json!({
"number": 3,
"status": "OPEN",
"title": "Polish registry",
"authorUsername": "sam",
"labels": [{"name": "registry"}],
"milestone": {"title": "registry"}
}),
];
assert_eq!(
issue_rows(&issues, "open", &[], None),
vec![
vec!["#1", "OPEN", "Wire local runner", "bri"],
vec!["#3", "OPEN", "Polish registry", "sam"],
]
);
assert_eq!(
issue_rows(
&issues,
"all",
&["CI".to_string(), "runtime".to_string()],
Some("single-box")
),
vec![vec!["#1", "OPEN", "Wire local runner", "bri"]]
);
assert_eq!(
issue_rows(&issues, "closed", &["docs".to_string()], Some("single-box")),
vec![vec!["#2", "CLOSED", "Document deploy", "alex"]]
);
assert!(issue_rows(&issues, "closed", &["ci".to_string()], None).is_empty());
}
#[test]
fn issue_rows_defaults_missing_fields_to_empty_values() {
let issues = vec![json!({})];
assert_eq!(
issue_rows(&issues, "all", &[], None),
vec![vec!["#0", "", "", ""]]
);
}
}