Skip to main content

ralph/git/
issue.rs

1//! GitHub Issue helpers using the `gh` CLI.
2//!
3//! Responsibilities:
4//! - Create and edit GitHub issues for Ralph tasks via `gh issue`.
5//! - Parse issue URLs/numbers from `gh` output for persistence.
6//!
7//! Not handled here:
8//! - Queue mutation or task persistence.
9//! - Rendering issue bodies from tasks (see `cli::queue::export`).
10//!
11//! Invariants/assumptions:
12//! - `gh` is installed and authenticated.
13//! - Commands run with `GH_NO_UPDATE_NOTIFIER=1` to avoid noisy prompts.
14
15use anyhow::{Context, Result, bail};
16use serde::Serialize;
17use sha2::{Digest, Sha256};
18use std::path::Path;
19use std::process::Command;
20
21use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
22
23pub(crate) const GITHUB_ISSUE_SYNC_HASH_KEY: &str = "github_issue_sync_hash";
24
25pub(crate) struct IssueInfo {
26    pub url: String,
27    pub number: Option<u32>,
28}
29
30#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
31struct IssueSyncPayload<'a> {
32    title: &'a str,
33    body: &'a str,
34    labels: Vec<String>,
35    assignees: Vec<String>,
36    repo: Option<&'a str>,
37}
38
39pub(crate) fn normalize_issue_metadata_list(values: &[String]) -> Vec<String> {
40    let mut values = values
41        .iter()
42        .map(|value| value.trim())
43        .filter(|value| !value.is_empty())
44        .map(ToString::to_string)
45        .collect::<Vec<_>>();
46    values.sort_unstable();
47    values.dedup();
48    values
49}
50
51pub(crate) fn compute_issue_sync_hash(
52    title: &str,
53    body: &str,
54    labels: &[String],
55    assignees: &[String],
56    repo: Option<&str>,
57) -> Result<String> {
58    let payload = IssueSyncPayload {
59        title: title.trim(),
60        body: body.trim(),
61        labels: normalize_issue_metadata_list(labels),
62        assignees: normalize_issue_metadata_list(assignees),
63        repo: repo.map(str::trim).filter(|r| !r.is_empty()),
64    };
65
66    let encoded = serde_json::to_string(&payload)
67        .context("failed to serialize issue sync fingerprint payload")?;
68    let mut hasher = Sha256::new();
69    hasher.update(encoded.as_bytes());
70    Ok(hex::encode(hasher.finalize()))
71}
72
73fn extract_first_url(output: &str) -> Option<String> {
74    output
75        .lines()
76        .map(str::trim)
77        .find(|line| line.starts_with("http://") || line.starts_with("https://"))
78        .map(|line| line.to_string())
79}
80
81pub(crate) fn parse_issue_number(url: &str) -> Option<u32> {
82    let marker = "/issues/";
83    let idx = url.find(marker)?;
84    let rest = &url[idx + marker.len()..];
85    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
86    digits.parse().ok()
87}
88
89pub(crate) fn create_issue(
90    repo_root: &Path,
91    selector_repo: Option<&str>,
92    title: &str,
93    body_file: &Path,
94    labels: &[String],
95    assignees: &[String],
96) -> Result<IssueInfo> {
97    let safe_title = title.trim();
98    if safe_title.is_empty() {
99        bail!("Issue title must be non-empty");
100    }
101
102    let mut cmd = Command::new("gh");
103    cmd.current_dir(repo_root)
104        .env("GH_NO_UPDATE_NOTIFIER", "1")
105        .arg("issue")
106        .arg("create")
107        .arg("--title")
108        .arg(safe_title)
109        .arg("--body-file")
110        .arg(body_file);
111
112    if let Some(repo) = selector_repo {
113        cmd.arg("-R").arg(repo);
114    }
115
116    for label in labels {
117        cmd.arg("--label").arg(label);
118    }
119    for assignee in assignees {
120        cmd.arg("--assignee").arg(assignee);
121    }
122
123    let output = run_gh_issue_command(cmd, "gh issue create")
124        .with_context(|| format!("run gh issue create in {}", repo_root.display()))?;
125
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        bail!("gh issue create failed: {}", stderr.trim());
129    }
130
131    let stdout = String::from_utf8_lossy(&output.stdout);
132    let url = extract_first_url(&stdout).ok_or_else(|| {
133        anyhow::anyhow!(
134            "Unable to parse issue URL from gh output. Output: {}",
135            stdout.trim()
136        )
137    })?;
138
139    Ok(IssueInfo {
140        number: parse_issue_number(&url),
141        url,
142    })
143}
144
145pub(crate) fn edit_issue(
146    repo_root: &Path,
147    selector_repo: Option<&str>,
148    issue_selector: &str, // number or URL
149    title: &str,
150    body_file: &Path,
151    add_labels: &[String],
152    add_assignees: &[String],
153) -> Result<()> {
154    let safe_title = title.trim();
155    if safe_title.is_empty() {
156        bail!("Issue title must be non-empty");
157    }
158
159    let mut cmd = Command::new("gh");
160    cmd.current_dir(repo_root)
161        .env("GH_NO_UPDATE_NOTIFIER", "1")
162        .arg("issue")
163        .arg("edit")
164        .arg(issue_selector)
165        .arg("--title")
166        .arg(safe_title)
167        .arg("--body-file")
168        .arg(body_file);
169
170    if let Some(repo) = selector_repo {
171        cmd.arg("-R").arg(repo);
172    }
173
174    for label in add_labels {
175        cmd.arg("--add-label").arg(label);
176    }
177    for assignee in add_assignees {
178        cmd.arg("--add-assignee").arg(assignee);
179    }
180
181    let output = run_gh_issue_command(cmd, "gh issue edit")
182        .with_context(|| format!("run gh issue edit in {}", repo_root.display()))?;
183
184    if !output.status.success() {
185        let stderr = String::from_utf8_lossy(&output.stderr);
186        bail!("gh issue edit failed: {}", stderr.trim());
187    }
188
189    Ok(())
190}
191
192fn run_gh_issue_command(
193    command: Command,
194    description: impl Into<String>,
195) -> Result<std::process::Output> {
196    execute_managed_command(ManagedCommand::new(
197        command,
198        description,
199        TimeoutClass::GitHubCli,
200    ))
201    .map(|output| {
202        let truncated = output.stdout_truncated || output.stderr_truncated;
203        if truncated {
204            log::debug!("managed gh issue capture truncated command output");
205        }
206        output.into_output()
207    })
208    .map_err(Into::into)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::{extract_first_url, parse_issue_number};
214
215    #[test]
216    fn extract_first_url_picks_first_url_line() {
217        let output = "Creating issue for task...\nhttps://github.com/org/repo/issues/5\n";
218        let url = extract_first_url(output).expect("url");
219        assert_eq!(url, "https://github.com/org/repo/issues/5");
220    }
221
222    #[test]
223    fn extract_first_url_returns_none_when_no_url() {
224        let output = "Some output without a URL\n";
225        assert!(extract_first_url(output).is_none());
226    }
227
228    #[test]
229    fn parse_issue_number_extracts_number() {
230        assert_eq!(
231            parse_issue_number("https://github.com/org/repo/issues/123"),
232            Some(123)
233        );
234        assert_eq!(
235            parse_issue_number("https://github.com/org/repo/issues/42?foo=bar"),
236            Some(42)
237        );
238    }
239
240    #[test]
241    fn parse_issue_number_returns_none_for_invalid() {
242        assert!(parse_issue_number("https://github.com/org/repo/pull/123").is_none());
243        assert!(parse_issue_number("not a url").is_none());
244        assert!(parse_issue_number("").is_none());
245    }
246}