1use std::io::Write;
13
14use anyhow::{anyhow, Result};
15use clap::{Args, Subcommand};
16use serde_json::{json, Value};
17
18use crate::client::{self, Client};
19use crate::commands::resolve_repo_slug;
20use crate::editor;
21use crate::output::{render_record, render_table, FormatChoice};
22
23#[derive(Debug, Subcommand)]
24pub enum IssueCmd {
25 List(ListArgs),
27 View(ViewArgs),
29 Create(CreateArgs),
31 Comment(CommentArgs),
33 Close(NumberArgs),
35 Reopen(NumberArgs),
37 SetState(SetStateArgs),
43}
44
45#[derive(Debug, Args)]
46pub struct ListArgs {
47 pub repo: String,
48 #[arg(long, default_value = "open")]
50 pub state: String,
51 #[arg(long)]
52 pub label: Vec<String>,
53 #[arg(long)]
54 pub milestone: Option<String>,
55 #[arg(long, default_value_t = false)]
56 pub json: bool,
57 #[arg(long, default_value_t = false)]
58 pub tsv: bool,
59}
60
61#[derive(Debug, Args)]
62pub struct ViewArgs {
63 pub repo: String,
64 pub number: i64,
65 #[arg(long, default_value_t = false)]
66 pub json: bool,
67 #[arg(long, default_value_t = false)]
68 pub tsv: bool,
69}
70
71#[derive(Debug, Args)]
72pub struct CreateArgs {
73 pub repo: String,
74 #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
75 pub title: String,
76 #[arg(long)]
77 pub body: Option<String>,
78 #[arg(long)]
79 pub label: Vec<String>,
80 #[arg(long)]
81 pub milestone: Option<String>,
82 #[arg(long)]
83 pub assignee: Vec<String>,
84}
85
86#[derive(Debug, Args)]
87pub struct CommentArgs {
88 pub repo: String,
89 pub number: i64,
90 #[arg(long)]
91 pub body: Option<String>,
92}
93
94#[derive(Debug, Args)]
95pub struct NumberArgs {
96 pub repo: String,
97 pub number: i64,
98}
99
100#[derive(Debug, Args)]
101pub struct SetStateArgs {
102 pub repo: String,
103 pub number: i64,
104 #[arg(long)]
108 pub state: String,
109}
110
111pub fn run(cmd: IssueCmd) -> Result<()> {
112 match cmd {
113 IssueCmd::List(a) => list(a),
114 IssueCmd::View(a) => view(a),
115 IssueCmd::Create(a) => create(a),
116 IssueCmd::Comment(a) => comment(a),
117 IssueCmd::Close(a) => close(a),
118 IssueCmd::Reopen(a) => reopen(a),
119 IssueCmd::SetState(a) => set_state(a),
120 }
121}
122
123fn list(args: ListArgs) -> Result<()> {
124 let (cli, _cfg) = client::authenticated()?;
125 let (_repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
126 let data = cli.execute(ISSUE_LIST, json!({}))?;
127 let issues = data["repositories"]
128 .as_array()
129 .and_then(|repos| {
130 repos.iter().find(|r| {
131 r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
132 })
133 })
134 .and_then(|r| r["issues"].as_array())
135 .cloned()
136 .unwrap_or_default();
137 let rows = issue_rows(&issues, &args.state, &args.label, args.milestone.as_deref());
138 let fmt = FormatChoice {
139 json: args.json,
140 tsv: args.tsv,
141 }
142 .resolve();
143 let out = render_table(&["#", "status", "title", "author"], &rows, fmt);
144 std::io::stdout().lock().write_all(out.as_bytes())?;
145 Ok(())
146}
147
148fn view(args: ViewArgs) -> Result<()> {
149 let (cli, _cfg) = client::authenticated()?;
150 let (_id, owner, name) = resolve_repo(&cli, &args.repo)?;
151 let data = cli.execute(ISSUE_LIST, json!({}))?;
152 let issue = data["repositories"]
153 .as_array()
154 .and_then(|repos| {
155 repos.iter().find(|r| {
156 r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
157 })
158 })
159 .and_then(|r| r["issues"].as_array())
160 .and_then(|issues| {
161 issues
162 .iter()
163 .find(|i| i["number"].as_i64() == Some(args.number))
164 })
165 .ok_or_else(|| anyhow!("no issue #{} in {}/{}", args.number, owner, name))?;
166 let pairs = vec![
167 ("Issue", format!("#{}", args.number)),
168 ("Title", issue["title"].as_str().unwrap_or("").to_string()),
169 ("Status", issue["status"].as_str().unwrap_or("").to_string()),
170 (
171 "Author",
172 issue["authorUsername"].as_str().unwrap_or("").to_string(),
173 ),
174 (
175 "Updated",
176 issue["updatedAt"].as_str().unwrap_or("").to_string(),
177 ),
178 ("Body", issue["body"].as_str().unwrap_or("").to_string()),
179 ];
180 let fmt = FormatChoice {
181 json: args.json,
182 tsv: args.tsv,
183 }
184 .resolve();
185 std::io::stdout()
186 .lock()
187 .write_all(render_record(&pairs, fmt).as_bytes())?;
188 Ok(())
189}
190
191fn create(args: CreateArgs) -> Result<()> {
192 let (cli, _cfg) = client::authenticated()?;
193 let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
194 let body = match args.body {
195 Some(b) => b,
196 None => editor::compose("")?,
197 };
198 let data = cli.execute(
199 CREATE_ISSUE,
200 json!({ "repoId": repo_id, "title": args.title, "body": body }),
201 )?;
202 let issue_id = data["createIssue"]["id"]
203 .as_str()
204 .ok_or_else(|| anyhow!("createIssue returned no id"))?
205 .to_string();
206 let number = data["createIssue"]["number"].as_i64().unwrap_or(0);
207
208 if !args.label.is_empty() {
210 let labels = cli.execute(REPO_LABELS, json!({}))?;
211 let label_rows = labels["repositories"]
212 .as_array()
213 .and_then(|repos| {
214 repos.iter().find(|r| {
215 r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
216 })
217 })
218 .and_then(|r| r["labels"].as_array())
219 .cloned()
220 .unwrap_or_default();
221 for wanted in &args.label {
222 let label_id = label_rows
223 .iter()
224 .find(|l| l["name"].as_str() == Some(wanted.as_str()))
225 .and_then(|l| l["id"].as_str())
226 .ok_or_else(|| anyhow!("unknown label `{}` in {}/{}", wanted, owner, name))?;
227 cli.execute(
228 ADD_LABEL,
229 json!({ "issueId": issue_id, "labelId": label_id }),
230 )?;
231 }
232 }
233 if let Some(m) = &args.milestone {
234 let milestones = cli.execute(REPO_MILESTONES, json!({}))?;
235 let milestone_id = milestones["repositories"]
236 .as_array()
237 .and_then(|repos| {
238 repos.iter().find(|r| {
239 r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
240 })
241 })
242 .and_then(|r| r["milestones"].as_array())
243 .and_then(|ms| ms.iter().find(|x| x["title"].as_str() == Some(m.as_str())))
244 .and_then(|x| x["id"].as_str())
245 .ok_or_else(|| anyhow!("unknown milestone `{}`", m))?
246 .to_string();
247 cli.execute(
248 SET_MILESTONE,
249 json!({ "issueId": issue_id, "milestoneId": milestone_id }),
250 )?;
251 }
252 for username in &args.assignee {
253 let accounts = cli.execute(ACCOUNTS, json!({}))?;
254 let account_id = accounts["accounts"]
255 .as_array()
256 .and_then(|a| {
257 a.iter()
258 .find(|x| x["username"].as_str() == Some(username.as_str()))
259 })
260 .and_then(|x| x["id"].as_str())
261 .ok_or_else(|| anyhow!("unknown user `{}`", username))?
262 .to_string();
263 cli.execute(
264 ADD_ASSIGNEE,
265 json!({ "issueId": issue_id, "accountId": account_id }),
266 )?;
267 }
268
269 writeln!(
270 std::io::stdout().lock(),
271 "Created issue #{} in {}/{}",
272 number,
273 owner,
274 name
275 )?;
276 Ok(())
277}
278
279fn comment(args: CommentArgs) -> Result<()> {
280 let (cli, _cfg) = client::authenticated()?;
281 let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
282 let body = match args.body {
283 Some(b) => b,
284 None => editor::compose("")?,
285 };
286 cli.execute(
287 ADD_COMMENT,
288 json!({ "input": { "issueId": issue_id, "body": body } }),
289 )?;
290 writeln!(
291 std::io::stdout().lock(),
292 "Comment posted to {}#{}",
293 args.repo,
294 args.number
295 )?;
296 Ok(())
297}
298
299fn close(args: NumberArgs) -> Result<()> {
300 let (cli, _cfg) = client::authenticated()?;
301 let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
302 cli.execute(CLOSE_ISSUE, json!({ "id": issue_id }))?;
303 writeln!(
304 std::io::stdout().lock(),
305 "Closed {}#{}",
306 args.repo,
307 args.number
308 )?;
309 Ok(())
310}
311
312fn reopen(args: NumberArgs) -> Result<()> {
313 let (cli, _cfg) = client::authenticated()?;
314 let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
315 cli.execute(REOPEN_ISSUE, json!({ "id": issue_id }))?;
316 writeln!(
317 std::io::stdout().lock(),
318 "Reopened {}#{}",
319 args.repo,
320 args.number
321 )?;
322 Ok(())
323}
324
325fn set_state(args: SetStateArgs) -> Result<()> {
326 let (cli, _cfg) = client::authenticated()?;
327 let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
328 let issue_id = resolve_issue_id(&cli, &args.repo, args.number)?;
329
330 let states = cli.execute(REPO_WORKFLOW_STATES, json!({ "id": repo_id }))?;
332 let states_arr = states["repository"]["workflowStates"]
333 .as_array()
334 .cloned()
335 .unwrap_or_default();
336 let state_id = find_workflow_state_id(&states_arr, &args.state)
337 .ok_or_else(|| {
338 anyhow!(
339 "no workflow state named {:?} on {}/{} (available: {})",
340 args.state,
341 owner,
342 name,
343 workflow_state_names(&states_arr).join(", ")
344 )
345 })?
346 .to_string();
347
348 let resp = cli.execute(
349 SET_ISSUE_WORKFLOW_STATE,
350 json!({ "issueId": issue_id, "stateId": state_id }),
351 )?;
352 let new_state = resp["setIssueWorkflowState"]["workflowState"]["name"]
353 .as_str()
354 .unwrap_or(&args.state);
355 let new_status = resp["setIssueWorkflowState"]["status"]
356 .as_str()
357 .unwrap_or("");
358 writeln!(
359 std::io::stdout().lock(),
360 "{}#{} → state \"{}\" (status: {})",
361 args.repo,
362 args.number,
363 new_state,
364 new_status
365 )?;
366 Ok(())
367}
368
369pub(crate) fn resolve_repo(cli: &Client, slug: &str) -> Result<(String, String, String)> {
370 let (owner, name) = resolve_repo_slug(slug)?;
372 let data = cli.execute("query { repositories { id ownerSlug name } }", json!({}))?;
373 let repos = data["repositories"].as_array().cloned().unwrap_or_default();
374 let id = repos
375 .iter()
376 .find(|r| r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name))
377 .and_then(|r| r["id"].as_str())
378 .ok_or_else(|| anyhow!("no repository {}/{}", owner, name))?
379 .to_string();
380 Ok((id, owner, name))
381}
382
383fn find_workflow_state_id<'a>(states: &'a [Value], wanted: &str) -> Option<&'a str> {
384 let wanted = wanted.trim().to_lowercase();
385 states
386 .iter()
387 .find(|s| {
388 s["name"]
389 .as_str()
390 .map(|n| n.to_lowercase() == wanted)
391 .unwrap_or(false)
392 })
393 .and_then(|s| s["id"].as_str())
394}
395
396fn workflow_state_names(states: &[Value]) -> Vec<String> {
397 states
398 .iter()
399 .filter_map(|s| s["name"].as_str().map(|n| n.to_string()))
400 .collect()
401}
402
403fn issue_rows(
404 issues: &[Value],
405 state: &str,
406 label_filter: &[String],
407 milestone: Option<&str>,
408) -> Vec<Vec<String>> {
409 let state_filter = state.to_ascii_lowercase();
410 let label_filter: Vec<String> = label_filter.iter().map(|s| s.to_lowercase()).collect();
411 issues
412 .iter()
413 .filter(|i| match state_filter.as_str() {
414 "all" => true,
415 "closed" => matches!(i["status"].as_str(), Some("CLOSED")),
416 _ => matches!(i["status"].as_str(), Some("OPEN")),
417 })
418 .filter(|i| {
419 if label_filter.is_empty() {
420 return true;
421 }
422 let labels: Vec<String> = i["labels"]
423 .as_array()
424 .map(|a| {
425 a.iter()
426 .filter_map(|l| l["name"].as_str().map(|s| s.to_lowercase()))
427 .collect()
428 })
429 .unwrap_or_default();
430 label_filter
431 .iter()
432 .all(|wanted| labels.iter().any(|got| got == wanted))
433 })
434 .filter(|i| match milestone {
435 None => true,
436 Some(m) => i["milestone"]["title"].as_str() == Some(m),
437 })
438 .map(|i| {
439 vec![
440 format!("#{}", i["number"].as_i64().unwrap_or(0)),
441 i["status"].as_str().unwrap_or("").to_string(),
442 i["title"].as_str().unwrap_or("").to_string(),
443 i["authorUsername"].as_str().unwrap_or("").to_string(),
444 ]
445 })
446 .collect()
447}
448
449fn resolve_issue_id(cli: &Client, slug: &str, number: i64) -> Result<String> {
450 let (_id, owner, name) = resolve_repo(cli, slug)?;
451 let data: Value = cli.execute(ISSUE_LIST, json!({}))?;
452 data["repositories"]
453 .as_array()
454 .and_then(|repos| {
455 repos.iter().find(|r| {
456 r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name)
457 })
458 })
459 .and_then(|r| r["issues"].as_array())
460 .and_then(|issues| issues.iter().find(|i| i["number"].as_i64() == Some(number)))
461 .and_then(|i| i["id"].as_str())
462 .map(|s| s.to_string())
463 .ok_or_else(|| anyhow!("no issue #{} in {}/{}", number, owner, name))
464}
465
466const ISSUE_LIST: &str = "{ \
467 repositories { \
468 id ownerSlug name \
469 issues { \
470 id number title status authorUsername updatedAt body \
471 labels { name } \
472 milestone { title } \
473 } \
474 } \
475}";
476
477const CREATE_ISSUE: &str = "mutation($repoId: UUID!, $title: String!, $body: String!) { \
478 createIssue(repositoryId: $repoId, title: $title, body: $body) { id number } \
479}";
480
481const ADD_COMMENT: &str = "mutation($input: AddIssueCommentInput!) { \
482 addIssueComment(input: $input) { id } \
483}";
484
485const CLOSE_ISSUE: &str = "mutation($id: UUID!) { closeIssue(issueId: $id) { id } }";
486const REOPEN_ISSUE: &str = "mutation($id: UUID!) { reopenIssue(issueId: $id) { id } }";
487
488const REPO_WORKFLOW_STATES: &str = "query($id: UUID!) { \
489 repository(id: $id) { workflowStates { id name category } } \
490}";
491
492const SET_ISSUE_WORKFLOW_STATE: &str = "mutation($issueId: UUID!, $stateId: UUID!) { \
493 setIssueWorkflowState(issueId: $issueId, stateId: $stateId) { \
494 id number title status \
495 workflowState { name category } \
496 } \
497}";
498
499const REPO_LABELS: &str = "{ \
500 repositories { ownerSlug name labels { id name } } \
501}";
502const REPO_MILESTONES: &str = "{ \
503 repositories { ownerSlug name milestones { id title } } \
504}";
505const ADD_LABEL: &str = "mutation($issueId: UUID!, $labelId: UUID!) { \
506 addLabelToIssue(issueId: $issueId, labelId: $labelId) \
507}";
508const SET_MILESTONE: &str = "mutation($issueId: UUID!, $milestoneId: UUID) { \
509 setIssueMilestone(issueId: $issueId, milestoneId: $milestoneId) { id } \
510}";
511const ADD_ASSIGNEE: &str = "mutation($issueId: UUID!, $accountId: UUID!) { \
512 addIssueAssignee(issueId: $issueId, accountId: $accountId) \
513}";
514const ACCOUNTS: &str = "{ accounts { id username } }";
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use serde_json::json;
520
521 #[test]
522 fn workflow_state_lookup_matches_trimmed_names_case_insensitively() {
523 let states = vec![
524 json!({ "id": "todo-id", "name": "Todo" }),
525 json!({ "id": "progress-id", "name": "In Progress" }),
526 json!({ "id": "done-id", "name": "Done" }),
527 json!({ "id": "nameless-id" }),
528 ];
529
530 assert_eq!(
531 find_workflow_state_id(&states, " in progress "),
532 Some("progress-id")
533 );
534 assert_eq!(find_workflow_state_id(&states, "DONE"), Some("done-id"));
535 assert_eq!(find_workflow_state_id(&states, "Blocked"), None);
536 }
537
538 #[test]
539 fn workflow_state_names_lists_only_named_states() {
540 let states = vec![
541 json!({ "id": "todo-id", "name": "Todo" }),
542 json!({ "id": "progress-id", "name": "In Progress" }),
543 json!({ "id": "missing-name" }),
544 ];
545
546 assert_eq!(
547 workflow_state_names(&states),
548 vec!["Todo".to_string(), "In Progress".to_string()]
549 );
550 }
551
552 #[test]
553 fn issue_rows_filters_by_state_labels_and_milestone() {
554 let issues = vec![
555 json!({
556 "number": 1,
557 "status": "OPEN",
558 "title": "Wire local runner",
559 "authorUsername": "bri",
560 "labels": [{"name": "ci"}, {"name": "Runtime"}],
561 "milestone": {"title": "single-box"}
562 }),
563 json!({
564 "number": 2,
565 "status": "CLOSED",
566 "title": "Document deploy",
567 "authorUsername": "alex",
568 "labels": [{"name": "docs"}],
569 "milestone": {"title": "single-box"}
570 }),
571 json!({
572 "number": 3,
573 "status": "OPEN",
574 "title": "Polish registry",
575 "authorUsername": "sam",
576 "labels": [{"name": "registry"}],
577 "milestone": {"title": "registry"}
578 }),
579 ];
580
581 assert_eq!(
582 issue_rows(&issues, "open", &[], None),
583 vec![
584 vec!["#1", "OPEN", "Wire local runner", "bri"],
585 vec!["#3", "OPEN", "Polish registry", "sam"],
586 ]
587 );
588 assert_eq!(
589 issue_rows(
590 &issues,
591 "all",
592 &["CI".to_string(), "runtime".to_string()],
593 Some("single-box")
594 ),
595 vec![vec!["#1", "OPEN", "Wire local runner", "bri"]]
596 );
597 assert_eq!(
598 issue_rows(&issues, "closed", &["docs".to_string()], Some("single-box")),
599 vec![vec!["#2", "CLOSED", "Document deploy", "alex"]]
600 );
601 assert!(issue_rows(&issues, "closed", &["ci".to_string()], None).is_empty());
602 }
603
604 #[test]
605 fn issue_rows_defaults_missing_fields_to_empty_values() {
606 let issues = vec![json!({})];
607
608 assert_eq!(
609 issue_rows(&issues, "all", &[], None),
610 vec![vec!["#0", "", "", ""]]
611 );
612 }
613}