Skip to main content

runtime_cli/commands/
issue.rs

1//! `runtime issue` — list / view / create / comment / close / reopen.
2//!
3//! All commands take a `owner/name` slug. The CLI resolves it to a
4//! repository id via `repositories { ownerSlug name id }` (one
5//! round-trip), then issues the mutation/query with that id.
6//!
7//! `create` opens `$EDITOR` for the body when `--body` is not given.
8//! Labels / milestones / assignees are applied as separate calls
9//! after the issue exists (the API has no "create with labels"
10//! convenience input).
11
12use 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 issues in a repository.
26    List(ListArgs),
27    /// Show a single issue (with comments).
28    View(ViewArgs),
29    /// Open a new issue.
30    Create(CreateArgs),
31    /// Add a comment to an issue.
32    Comment(CommentArgs),
33    /// Close an issue.
34    Close(NumberArgs),
35    /// Reopen an issue.
36    Reopen(NumberArgs),
37    /// Set an issue's workflow state by name (e.g. "In progress").
38    /// The state name is matched case-insensitively against the repo's
39    /// declared workflow states. The legacy `status` field auto-flips
40    /// based on the new state's category — moving to a "Closed"-category
41    /// state closes the issue, "Open"/"InProgress" reopens it.
42    SetState(SetStateArgs),
43}
44
45#[derive(Debug, Args)]
46pub struct ListArgs {
47    pub repo: String,
48    /// Filter by state: `open` / `closed` / `all` (default `open`).
49    #[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    /// Workflow state name (case-insensitive). See `runtime api 'query {
105    /// repositoryByPath(path: "owner/name") { workflowStates { name } } }'`
106    /// for the available states in a repo.
107    #[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    // Apply labels by name.
209    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    // Look up workflow states on this repo; case-insensitive name match.
331    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    // Issue #31 — empty slug → infer from cwd's `origin` URL.
371    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}