1use anyhow::{Context, Result};
2
3use super::{GhClient, Issue};
4use crate::process::CommandRunner;
5
6#[derive(Debug, Clone)]
11pub struct ParsedIssue {
12 pub issue: Issue,
13 pub target_repo: Option<String>,
14 pub body_without_frontmatter: String,
15}
16
17pub fn parse_issue_frontmatter(issue: &Issue, target_field: &str) -> ParsedIssue {
23 let body = issue.body.trim_start();
24
25 if !body.starts_with("---") {
26 return ParsedIssue {
27 issue: issue.clone(),
28 target_repo: None,
29 body_without_frontmatter: issue.body.clone(),
30 };
31 }
32
33 let after_open = &body[3..];
35 let closing = after_open.find("\n---");
36
37 let Some(close_idx) = closing else {
38 return ParsedIssue {
40 issue: issue.clone(),
41 target_repo: None,
42 body_without_frontmatter: issue.body.clone(),
43 };
44 };
45
46 let frontmatter = &after_open[..close_idx];
47 let rest = &after_open[close_idx + 4..]; let body_without = rest.trim_start_matches('\n').to_string();
49
50 let needle = format!("{target_field}:");
52 let target_repo = frontmatter.lines().find_map(|line| {
53 let trimmed = line.trim();
54 if trimmed.starts_with(&needle) {
55 Some(trimmed[needle.len()..].trim().to_string())
56 } else {
57 None
58 }
59 });
60
61 ParsedIssue { issue: issue.clone(), target_repo, body_without_frontmatter: body_without }
62}
63
64impl<R: CommandRunner> GhClient<R> {
65 pub async fn get_issues_by_label(&self, label: &str) -> Result<Vec<Issue>> {
67 let output = self
68 .runner
69 .run_gh(
70 &Self::s(&[
71 "issue",
72 "list",
73 "--label",
74 label,
75 "--author",
76 "@me",
77 "--json",
78 "number,title,body,labels,author",
79 "--state",
80 "open",
81 "--limit",
82 "100",
83 ]),
84 &self.repo_dir,
85 )
86 .await
87 .context("fetching issues by label")?;
88 Self::check_output(&output, "fetch issues")?;
89
90 let mut issues: Vec<Issue> =
91 serde_json::from_str(&output.stdout).context("parsing issue list JSON")?;
92 issues.sort_by_key(|i| i.number);
94 Ok(issues)
95 }
96
97 pub async fn get_issue(&self, issue_number: u32) -> Result<Issue> {
99 let output = self
100 .runner
101 .run_gh(
102 &Self::s(&[
103 "issue",
104 "view",
105 &issue_number.to_string(),
106 "--json",
107 "number,title,body,labels,author",
108 ]),
109 &self.repo_dir,
110 )
111 .await
112 .context("fetching issue")?;
113 Self::check_output(&output, "fetch issue")?;
114
115 let issue: Issue = serde_json::from_str(&output.stdout).context("parsing issue JSON")?;
116 Ok(issue)
117 }
118
119 pub async fn get_current_user(&self) -> Result<String> {
121 let output = self
122 .runner
123 .run_gh(&Self::s(&["api", "user", "--jq", ".login"]), &self.repo_dir)
124 .await
125 .context("fetching current user")?;
126 Self::check_output(&output, "fetch current user")?;
127 Ok(output.stdout.trim().to_string())
128 }
129
130 pub async fn comment_on_issue(&self, issue_number: u32, body: &str) -> Result<()> {
132 let output = self
133 .runner
134 .run_gh(
135 &Self::s(&["issue", "comment", &issue_number.to_string(), "--body", body]),
136 &self.repo_dir,
137 )
138 .await
139 .context("commenting on issue")?;
140 Self::check_output(&output, "comment on issue")?;
141 Ok(())
142 }
143
144 pub async fn close_issue(&self, issue_number: u32, comment: Option<&str>) -> Result<()> {
146 let num_str = issue_number.to_string();
147 let mut args = vec!["issue", "close", &num_str];
148 if let Some(body) = comment {
149 args.extend(["--comment", body]);
150 }
151 let output =
152 self.runner.run_gh(&Self::s(&args), &self.repo_dir).await.context("closing issue")?;
153 Self::check_output(&output, "close issue")?;
154 Ok(())
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use std::path::Path;
161
162 use super::*;
163 use crate::{
164 github::GhClient,
165 process::{CommandOutput, MockCommandRunner},
166 };
167
168 #[tokio::test]
169 async fn get_issues_by_label_parses_json() {
170 let mut mock = MockCommandRunner::new();
171 mock.expect_run_gh().returning(|_, _| {
172 Box::pin(async {
173 Ok(CommandOutput {
174 stdout: r#"[{"number":3,"title":"Third","body":"c","labels":[{"name":"o-ready"}]},{"number":1,"title":"First","body":"a","labels":[{"name":"o-ready"}]},{"number":2,"title":"Second","body":"b","labels":[{"name":"o-ready"}]}]"#.to_string(),
175 stderr: String::new(),
176 success: true,
177 })
178 })
179 });
180
181 let client = GhClient::new(mock, Path::new("/tmp"));
182 let issues = client.get_issues_by_label("o-ready").await.unwrap();
183
184 assert_eq!(issues.len(), 3);
185 assert_eq!(issues[0].number, 1);
187 assert_eq!(issues[1].number, 2);
188 assert_eq!(issues[2].number, 3);
189 }
190
191 #[tokio::test]
192 async fn get_issues_by_label_filters_by_current_user() {
193 let mut mock = MockCommandRunner::new();
194 mock.expect_run_gh().returning(|args, _| {
195 assert!(args.contains(&"--author".to_string()));
196 assert!(args.contains(&"@me".to_string()));
197 Box::pin(async {
198 Ok(CommandOutput { stdout: "[]".to_string(), stderr: String::new(), success: true })
199 })
200 });
201
202 let client = GhClient::new(mock, Path::new("/tmp"));
203 let issues = client.get_issues_by_label("o-ready").await.unwrap();
204 assert!(issues.is_empty());
205 }
206
207 #[tokio::test]
208 async fn get_issue_parses_single() {
209 let mut mock = MockCommandRunner::new();
210 mock.expect_run_gh().returning(|_, _| {
211 Box::pin(async {
212 Ok(CommandOutput {
213 stdout: r#"{"number":42,"title":"Fix bug","body":"details","labels":[]}"#
214 .to_string(),
215 stderr: String::new(),
216 success: true,
217 })
218 })
219 });
220
221 let client = GhClient::new(mock, Path::new("/tmp"));
222 let issue = client.get_issue(42).await.unwrap();
223
224 assert_eq!(issue.number, 42);
225 assert_eq!(issue.title, "Fix bug");
226 assert_eq!(issue.body, "details");
227 }
228
229 #[tokio::test]
230 async fn get_issue_parses_author_login() {
231 let mut mock = MockCommandRunner::new();
232 mock.expect_run_gh().returning(|_, _| {
233 Box::pin(async {
234 Ok(CommandOutput {
235 stdout: r#"{"number":10,"title":"Auth","body":"b","labels":[],"author":{"login":"alice"}}"#
236 .to_string(),
237 stderr: String::new(),
238 success: true,
239 })
240 })
241 });
242
243 let client = GhClient::new(mock, Path::new("/tmp"));
244 let issue = client.get_issue(10).await.unwrap();
245
246 assert_eq!(issue.author.as_ref().unwrap().login, "alice");
247 }
248
249 #[tokio::test]
250 async fn get_issue_handles_missing_author() {
251 let mut mock = MockCommandRunner::new();
252 mock.expect_run_gh().returning(|_, _| {
253 Box::pin(async {
254 Ok(CommandOutput {
255 stdout: r#"{"number":11,"title":"No author","body":"b","labels":[]}"#
256 .to_string(),
257 stderr: String::new(),
258 success: true,
259 })
260 })
261 });
262
263 let client = GhClient::new(mock, Path::new("/tmp"));
264 let issue = client.get_issue(11).await.unwrap();
265
266 assert!(issue.author.is_none());
267 }
268
269 #[tokio::test]
270 async fn get_current_user_returns_login() {
271 let mut mock = MockCommandRunner::new();
272 mock.expect_run_gh().returning(|args, _| {
273 assert!(args.contains(&"api".to_string()));
274 assert!(args.contains(&"user".to_string()));
275 Box::pin(async {
276 Ok(CommandOutput {
277 stdout: "octocat\n".to_string(),
278 stderr: String::new(),
279 success: true,
280 })
281 })
282 });
283
284 let client = GhClient::new(mock, Path::new("/tmp"));
285 let user = client.get_current_user().await.unwrap();
286
287 assert_eq!(user, "octocat");
288 }
289
290 #[tokio::test]
291 async fn comment_on_issue_succeeds() {
292 let mut mock = MockCommandRunner::new();
293 mock.expect_run_gh().returning(|_, _| {
294 Box::pin(async {
295 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
296 })
297 });
298
299 let client = GhClient::new(mock, Path::new("/tmp"));
300 let result = client.comment_on_issue(42, "hello").await;
301 assert!(result.is_ok());
302 }
303
304 #[tokio::test]
305 async fn close_issue_with_comment() {
306 let mut mock = MockCommandRunner::new();
307 mock.expect_run_gh().returning(|args, _| {
308 assert!(args.contains(&"issue".to_string()));
309 assert!(args.contains(&"close".to_string()));
310 assert!(args.contains(&"--comment".to_string()));
311 Box::pin(async {
312 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
313 })
314 });
315
316 let client = GhClient::new(mock, Path::new("/tmp"));
317 let result = client.close_issue(42, Some("Done")).await;
318 assert!(result.is_ok());
319 }
320
321 #[tokio::test]
322 async fn close_issue_without_comment() {
323 let mut mock = MockCommandRunner::new();
324 mock.expect_run_gh().returning(|args, _| {
325 assert!(!args.contains(&"--comment".to_string()));
326 Box::pin(async {
327 Ok(CommandOutput { stdout: String::new(), stderr: String::new(), success: true })
328 })
329 });
330
331 let client = GhClient::new(mock, Path::new("/tmp"));
332 let result = client.close_issue(42, None).await;
333 assert!(result.is_ok());
334 }
335
336 fn make_issue(body: &str) -> Issue {
337 Issue {
338 number: 1,
339 title: "Test".to_string(),
340 body: body.to_string(),
341 labels: vec![],
342 author: None,
343 }
344 }
345
346 #[test]
347 fn parse_frontmatter_extracts_target_repo() {
348 let issue = make_issue("---\ntarget_repo: my-service\n---\n\nFix the bug");
349 let parsed = parse_issue_frontmatter(&issue, "target_repo");
350 assert_eq!(parsed.target_repo.as_deref(), Some("my-service"));
351 assert_eq!(parsed.body_without_frontmatter, "Fix the bug");
352 }
353
354 #[test]
355 fn parse_frontmatter_custom_field_name() {
356 let issue = make_issue("---\nrepo: other-thing\n---\n\nDo stuff");
357 let parsed = parse_issue_frontmatter(&issue, "repo");
358 assert_eq!(parsed.target_repo.as_deref(), Some("other-thing"));
359 }
360
361 #[test]
362 fn parse_frontmatter_no_frontmatter() {
363 let issue = make_issue("Just a regular issue body");
364 let parsed = parse_issue_frontmatter(&issue, "target_repo");
365 assert!(parsed.target_repo.is_none());
366 assert_eq!(parsed.body_without_frontmatter, "Just a regular issue body");
367 }
368
369 #[test]
370 fn parse_frontmatter_unclosed_delimiters() {
371 let issue = make_issue("---\ntarget_repo: oops\nno closing delimiter");
372 let parsed = parse_issue_frontmatter(&issue, "target_repo");
373 assert!(parsed.target_repo.is_none());
374 assert_eq!(parsed.body_without_frontmatter, issue.body);
375 }
376
377 #[test]
378 fn parse_frontmatter_missing_field() {
379 let issue = make_issue("---\nother_key: value\n---\n\nBody here");
380 let parsed = parse_issue_frontmatter(&issue, "target_repo");
381 assert!(parsed.target_repo.is_none());
382 assert_eq!(parsed.body_without_frontmatter, "Body here");
383 }
384
385 #[test]
386 fn parse_frontmatter_strips_leading_newlines() {
387 let issue = make_issue("---\ntarget_repo: svc\n---\n\n\nBody");
388 let parsed = parse_issue_frontmatter(&issue, "target_repo");
389 assert_eq!(parsed.body_without_frontmatter, "Body");
390 }
391
392 #[test]
393 fn parse_frontmatter_preserves_issue() {
394 let issue = make_issue("---\ntarget_repo: api\n---\nContent");
395 let parsed = parse_issue_frontmatter(&issue, "target_repo");
396 assert_eq!(parsed.issue.number, 1);
397 assert_eq!(parsed.issue.title, "Test");
398 }
399
400 #[test]
401 fn parse_frontmatter_with_extra_fields() {
402 let issue =
403 make_issue("---\npriority: high\ntarget_repo: backend\nlabel: bug\n---\n\nDetails");
404 let parsed = parse_issue_frontmatter(&issue, "target_repo");
405 assert_eq!(parsed.target_repo.as_deref(), Some("backend"));
406 assert_eq!(parsed.body_without_frontmatter, "Details");
407 }
408
409 #[test]
410 fn parse_frontmatter_empty_body() {
411 let issue = make_issue("");
412 let parsed = parse_issue_frontmatter(&issue, "target_repo");
413 assert!(parsed.target_repo.is_none());
414 assert_eq!(parsed.body_without_frontmatter, "");
415 }
416}