1use async_trait::async_trait;
8use serde::Deserialize;
9
10use crate::error::{CiError, Result};
11use crate::exec::CommandRunner;
12use crate::model::{
13 Annotation, AnnotationLevel, Artifact, Capabilities, CiJob, CiRun, DagSource, JobId, JobStatus,
14 LogChunk, LogMode, LogStepMeta, PullRequest, RunDetail, RunExtras, RunId, ts_sort_key,
15};
16use crate::provider::{CiProvider, ProviderKind};
17
18pub struct GitHubProvider {
22 runner: Box<dyn CommandRunner>,
23 workflows: Vec<String>,
26 branch: Option<String>,
28}
29
30impl GitHubProvider {
31 pub fn new(
32 runner: Box<dyn CommandRunner>,
33 workflows: Vec<String>,
34 branch: Option<String>,
35 ) -> Self {
36 Self {
37 runner,
38 workflows,
39 branch,
40 }
41 }
42
43 async fn api(&self, path: &str) -> Result<String> {
45 self.runner
46 .run("gh", &["api".to_owned(), path.to_owned()])
47 .await
48 }
49
50 async fn artifacts(&self, run: &RunId) -> Result<Vec<Artifact>> {
51 let raw = self
52 .api(&format!(
53 "repos/{{owner}}/{{repo}}/actions/runs/{}/artifacts",
54 run.0
55 ))
56 .await?;
57 let list: ArtifactList = serde_json::from_str(&raw).map_err(|e| CiError::Parse {
58 what: "gh api artifacts".into(),
59 message: e.to_string(),
60 })?;
61 Ok(list.artifacts.into_iter().map(ArtifactItem::into).collect())
62 }
63
64 async fn annotations(&self, run: &RunId) -> Result<Vec<Annotation>> {
65 let raw = self
66 .api(&format!(
67 "repos/{{owner}}/{{repo}}/actions/runs/{}/jobs",
68 run.0
69 ))
70 .await?;
71 let jobs: JobsApi = serde_json::from_str(&raw).map_err(|e| CiError::Parse {
72 what: "gh api jobs".into(),
73 message: e.to_string(),
74 })?;
75 let mut annotations = Vec::new();
76 for job in jobs.jobs {
77 let Ok(raw) = self
80 .api(&format!("{}/annotations", job.check_run_url))
81 .await
82 else {
83 continue;
84 };
85 let Ok(items) = serde_json::from_str::<Vec<AnnotationItem>>(&raw) else {
86 continue;
87 };
88 annotations.extend(items.into_iter().map(AnnotationItem::into));
89 }
90 Ok(annotations)
91 }
92}
93
94#[async_trait]
95impl CiProvider for GitHubProvider {
96 fn kind(&self) -> ProviderKind {
97 ProviderKind::GitHub
98 }
99
100 fn capabilities(&self) -> Capabilities {
101 Capabilities {
102 dag: DagSource::ConfigFile,
103 logs: LogMode::Dump,
104 }
105 }
106
107 async fn list_runs(&self, limit: usize) -> Result<Vec<CiRun>> {
108 let mut args = vec!["run".to_owned(), "list".to_owned()];
109 if let Some(branch) = &self.branch {
110 args.push("--branch".to_owned());
111 args.push(branch.clone());
112 }
113 args.extend(
114 [
115 "-L",
116 &limit.to_string(),
117 "--json",
118 "databaseId,displayTitle,headBranch,headSha,status,conclusion,workflowName,createdAt,url",
119 ]
120 .map(str::to_owned),
121 );
122 let out = self.runner.run("gh", &args).await?;
123 let raw: Vec<RunListItem> = serde_json::from_str(&out).map_err(|e| CiError::Parse {
124 what: "gh run list".into(),
125 message: e.to_string(),
126 })?;
127 Ok(raw.into_iter().map(RunListItem::into_run).collect())
128 }
129
130 async fn run_detail(&self, run: &RunId) -> Result<RunDetail> {
131 let args = [
132 "run",
133 "view",
134 &run.0,
135 "--json",
136 "jobs,displayTitle,headBranch,headSha,status,conclusion,workflowName,createdAt,url",
137 ]
138 .map(str::to_owned);
139 let out = self.runner.run("gh", &args).await?;
140 let view: RunView = serde_json::from_str(&out).map_err(|e| CiError::Parse {
141 what: "gh run view".into(),
142 message: e.to_string(),
143 })?;
144
145 let specs = self
148 .workflows
149 .iter()
150 .find(|yaml| workflow_name(yaml).as_deref() == Some(view.workflow_name.as_str()))
151 .and_then(|yaml| parse_workflow(yaml).ok())
152 .unwrap_or_default();
153 let jobs = if specs.is_empty() {
154 view.jobs
156 .iter()
157 .map(|j| CiJob {
158 id: JobId(j.name.clone()),
159 name: j.name.clone(),
160 status: map_status(&j.status, j.conclusion.as_deref()),
161 needs: Vec::new(),
162 })
163 .collect()
164 } else {
165 specs
166 .iter()
167 .map(|spec| CiJob {
168 id: JobId(spec.id.clone()),
169 name: spec.label.clone(),
170 status: aggregate_status(&spec.id, &spec.label, &view.jobs),
171 needs: spec.needs.iter().cloned().map(JobId).collect(),
172 })
173 .collect()
174 };
175 Ok(RunDetail {
176 run: view.into_run(run.clone()),
177 jobs,
178 })
179 }
180
181 async fn job_log(&self, run: &RunId, job: &JobId, offset: u64) -> Result<LogChunk> {
182 let out = self
185 .api(&format!(
186 "repos/{{owner}}/{{repo}}/actions/runs/{}/jobs",
187 run.0
188 ))
189 .await?;
190 let view: JobList = serde_json::from_str(&out).map_err(|e| CiError::Parse {
191 what: "gh api jobs".into(),
192 message: e.to_string(),
193 })?;
194 let job = view
195 .jobs
196 .iter()
197 .find(|j| j.name == job.0 || job_matches(&j.name, &job.0, &job.0))
198 .ok_or_else(|| CiError::NotFound(format!("job {} in run {}", job.0, run.0)))?;
199 let steps = job.steps.iter().map(RunStep::to_meta).collect();
200 let done = job.status == "completed";
201
202 let log_path = format!(
206 "repos/{{owner}}/{{repo}}/actions/jobs/{}/logs",
207 job.database_id
208 );
209 match self.api(&log_path).await {
210 Ok(full) => {
211 let mut start = usize::try_from(offset)
214 .unwrap_or(usize::MAX)
215 .min(full.len());
216 while start > 0 && !full.is_char_boundary(start) {
217 start -= 1;
218 }
219 let next_offset = full.len() as u64;
220 Ok(LogChunk {
221 text: full.get(start..).unwrap_or_default().to_owned(),
222 steps,
223 next_offset,
224 done,
225 })
226 }
227 Err(_) if !done => Ok(LogChunk {
228 text: String::new(),
229 steps,
230 next_offset: offset,
231 done: false,
232 }),
233 Err(err) => Err(err),
234 }
235 }
236
237 async fn run_extras(&self, run: &RunId) -> Result<RunExtras> {
238 Ok(RunExtras {
242 artifacts: self.artifacts(run).await.unwrap_or_default(),
243 annotations: self.annotations(run).await.unwrap_or_default(),
244 })
245 }
246
247 async fn current_pr(&self) -> Result<Option<PullRequest>> {
248 let Some(branch) = &self.branch else {
249 return Ok(None);
250 };
251 let args = ["pr", "view", branch, "--json", "number,title,url"].map(str::to_owned);
254 let Ok(raw) = self.runner.run("gh", &args).await else {
255 return Ok(None);
256 };
257 let Ok(pr) = serde_json::from_str::<PrView>(&raw) else {
258 return Ok(None);
259 };
260 Ok(Some(PullRequest {
261 number: pr.number,
262 title: pr.title,
263 url: (!pr.url.is_empty()).then_some(pr.url),
264 }))
265 }
266}
267
268struct JobSpec {
270 id: String,
271 label: String,
272 needs: Vec<String>,
273}
274
275fn workflow_name(yaml: &str) -> Option<String> {
278 let value: serde_norway::Value = serde_norway::from_str(yaml).ok()?;
279 value
280 .get("name")
281 .and_then(serde_norway::Value::as_str)
282 .map(str::to_owned)
283}
284
285fn parse_workflow(yaml: &str) -> Result<Vec<JobSpec>> {
288 let value: serde_norway::Value = serde_norway::from_str(yaml).map_err(|e| CiError::Parse {
289 what: "workflow YAML".into(),
290 message: e.to_string(),
291 })?;
292 let jobs = value
293 .get("jobs")
294 .and_then(serde_norway::Value::as_mapping)
295 .ok_or_else(|| CiError::Parse {
296 what: "workflow YAML".into(),
297 message: "no `jobs` mapping".into(),
298 })?;
299
300 let mut specs = Vec::new();
301 for (key, job) in jobs {
302 let Some(id) = key.as_str() else { continue };
303 let label = job
304 .get("name")
305 .and_then(serde_norway::Value::as_str)
306 .unwrap_or(id)
307 .to_owned();
308 specs.push(JobSpec {
309 id: id.to_owned(),
310 label,
311 needs: needs_of(job),
312 });
313 }
314 Ok(specs)
315}
316
317fn needs_of(job: &serde_norway::Value) -> Vec<String> {
318 match job.get("needs") {
319 Some(serde_norway::Value::String(one)) => vec![one.clone()],
320 Some(serde_norway::Value::Sequence(many)) => many
321 .iter()
322 .filter_map(|v| v.as_str().map(str::to_owned))
323 .collect(),
324 _ => Vec::new(),
325 }
326}
327
328fn job_matches(run_job_name: &str, id: &str, label: &str) -> bool {
331 run_job_name == label
332 || run_job_name == id
333 || run_job_name.starts_with(&format!("{label} ("))
334 || run_job_name.starts_with(&format!("{id} ("))
335}
336
337fn aggregate_status(id: &str, label: &str, jobs: &[RunJob]) -> JobStatus {
339 jobs.iter()
340 .filter(|j| job_matches(&j.name, id, label))
341 .map(|j| map_status(&j.status, j.conclusion.as_deref()))
342 .reduce(JobStatus::worse)
343 .unwrap_or(JobStatus::Queued)
344}
345
346fn map_status(status: &str, conclusion: Option<&str>) -> JobStatus {
347 match conclusion {
348 Some("success") => JobStatus::Ok,
349 Some("failure" | "timed_out" | "startup_failure") => JobStatus::Failed,
350 Some("skipped") => JobStatus::Skipped,
351 Some("cancelled") => JobStatus::Neutral,
352 _ => match status {
353 "in_progress" => JobStatus::Running,
354 "completed" => JobStatus::Neutral,
355 _ => JobStatus::Queued,
356 },
357 }
358}
359
360fn parse_created(raw: &str) -> Option<time::OffsetDateTime> {
361 time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339).ok()
362}
363
364#[derive(Deserialize)]
365struct RunListItem {
366 #[serde(rename = "databaseId")]
367 database_id: u64,
368 #[serde(rename = "displayTitle")]
369 display_title: String,
370 #[serde(rename = "headBranch")]
371 head_branch: String,
372 #[serde(rename = "headSha")]
373 head_sha: String,
374 status: String,
375 conclusion: Option<String>,
376 #[serde(rename = "workflowName")]
377 workflow_name: String,
378 #[serde(rename = "createdAt")]
379 created_at: String,
380 url: String,
381}
382
383impl RunListItem {
384 fn into_run(self) -> CiRun {
385 let name = if self.workflow_name.is_empty() {
386 self.display_title.clone()
387 } else {
388 self.workflow_name
389 };
390 CiRun {
391 id: RunId(self.database_id.to_string()),
392 name,
393 title: self.display_title,
394 branch: self.head_branch,
395 commit: self.head_sha,
396 author: String::new(),
397 created: parse_created(&self.created_at),
398 status: map_status(&self.status, self.conclusion.as_deref()),
399 url: Some(self.url),
400 }
401 }
402}
403
404#[derive(Deserialize)]
407struct JobList {
408 jobs: Vec<RunJob>,
409}
410
411#[derive(Deserialize)]
412struct RunView {
413 jobs: Vec<RunJob>,
414 #[serde(rename = "displayTitle")]
415 display_title: String,
416 #[serde(rename = "headBranch")]
417 head_branch: String,
418 #[serde(rename = "headSha")]
419 head_sha: String,
420 status: String,
421 conclusion: Option<String>,
422 #[serde(rename = "workflowName")]
423 workflow_name: String,
424 #[serde(rename = "createdAt")]
425 created_at: String,
426 url: String,
427}
428
429impl RunView {
430 fn into_run(self, id: RunId) -> CiRun {
431 CiRun {
432 id,
433 name: self.workflow_name,
434 title: self.display_title,
435 branch: self.head_branch,
436 commit: self.head_sha,
437 author: String::new(),
438 created: parse_created(&self.created_at),
439 status: map_status(&self.status, self.conclusion.as_deref()),
440 url: Some(self.url),
441 }
442 }
443}
444
445#[derive(Deserialize)]
448struct RunJob {
449 #[serde(rename = "databaseId", alias = "id")]
450 database_id: u64,
451 name: String,
452 status: String,
453 conclusion: Option<String>,
454 #[serde(default)]
455 steps: Vec<RunStep>,
456}
457
458#[derive(Deserialize)]
459struct RunStep {
460 name: String,
461 status: String,
462 conclusion: Option<String>,
463 #[serde(rename = "startedAt", alias = "started_at")]
464 started_at: Option<String>,
465 #[serde(rename = "completedAt", alias = "completed_at")]
466 completed_at: Option<String>,
467}
468
469impl RunStep {
470 fn to_meta(&self) -> LogStepMeta {
471 let started = self.started_at.as_deref().and_then(parse_created);
472 let dur = started
473 .zip(self.completed_at.as_deref().and_then(parse_created))
474 .map(|(start, end)| (end - start).whole_seconds());
475 let ran = started.is_some_and(|t| t.year() >= 2000);
479 LogStepMeta {
480 name: self.name.clone(),
481 status: map_status(&self.status, self.conclusion.as_deref()),
482 start_key: if ran {
483 self.started_at.as_deref().map(ts_sort_key).unwrap_or(0)
484 } else {
485 0
486 },
487 duration_secs: dur,
488 }
489 }
490}
491
492#[derive(Deserialize)]
493struct ArtifactList {
494 artifacts: Vec<ArtifactItem>,
495}
496
497#[derive(Deserialize)]
498struct ArtifactItem {
499 name: String,
500 #[serde(rename = "size_in_bytes")]
501 size_in_bytes: u64,
502 expired: bool,
503}
504
505impl From<ArtifactItem> for Artifact {
506 fn from(item: ArtifactItem) -> Self {
507 Artifact {
508 name: item.name,
509 size_bytes: item.size_in_bytes,
510 expired: item.expired,
511 }
512 }
513}
514
515#[derive(Deserialize)]
519struct JobsApi {
520 jobs: Vec<JobApi>,
521}
522
523#[derive(Deserialize)]
524struct PrView {
525 number: u64,
526 title: String,
527 #[serde(default)]
528 url: String,
529}
530
531#[derive(Deserialize)]
532struct JobApi {
533 check_run_url: String,
534}
535
536#[derive(Deserialize)]
537struct AnnotationItem {
538 annotation_level: Option<String>,
539 title: Option<String>,
540 message: Option<String>,
541 path: Option<String>,
542 start_line: Option<u64>,
543}
544
545impl From<AnnotationItem> for Annotation {
546 fn from(item: AnnotationItem) -> Self {
547 let level = match item.annotation_level.as_deref() {
548 Some("failure") => AnnotationLevel::Failure,
549 Some("warning") => AnnotationLevel::Warning,
550 _ => AnnotationLevel::Notice,
551 };
552 Annotation {
553 level,
554 title: item.title.unwrap_or_default(),
555 message: item.message.unwrap_or_default(),
556 path: item.path.unwrap_or_default(),
557 start_line: item.start_line,
558 }
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use crate::exec::test_support::RecordingRunner;
566
567 const WORKFLOW: &str = r"
568name: CI
569on: push
570jobs:
571 lint:
572 runs-on: ubuntu-latest
573 test:
574 needs: lint
575 runs-on: ubuntu-latest
576 publish:
577 name: Publish
578 needs: [lint, test]
579 runs-on: ubuntu-latest
580";
581
582 const RELEASE_WORKFLOW: &str = r"
583name: Release
584on: push
585jobs:
586 publish:
587 runs-on: ubuntu-latest
588";
589
590 fn provider(responses: &[(&'static str, &str)]) -> GitHubProvider {
591 GitHubProvider::new(
592 Box::new(RecordingRunner::new(responses)),
593 vec![WORKFLOW.to_owned()],
594 None,
595 )
596 }
597
598 #[tokio::test]
599 async fn list_runs_scopes_to_the_branch() {
600 let json = r#"[{"databaseId":1,"displayTitle":"x","headBranch":"feat/x","headSha":"a",
602 "status":"completed","conclusion":"success","workflowName":"CI",
603 "createdAt":"2026-06-18T10:00:00Z","url":"u"}]"#;
604 let runs = GitHubProvider::new(
605 Box::new(RecordingRunner::new(&[("list --branch feat/x", json)])),
606 vec![WORKFLOW.to_owned()],
607 Some("feat/x".to_owned()),
608 )
609 .list_runs(10)
610 .await
611 .expect("runs");
612 assert_eq!(runs.len(), 1);
613 assert_eq!(runs[0].branch, "feat/x");
614 }
615
616 #[tokio::test]
617 async fn run_detail_builds_the_dag_from_the_runs_own_workflow() {
618 let view = r#"{
621 "displayTitle":"cut","headBranch":"main","headSha":"abc","status":"completed",
622 "conclusion":"success","workflowName":"Release",
623 "createdAt":"2026-06-18T10:00:00Z","url":"https://gh/run/9",
624 "jobs":[{"databaseId":1,"name":"publish","status":"completed","conclusion":"success"}]
625 }"#;
626 let detail = GitHubProvider::new(
627 Box::new(RecordingRunner::new(&[("run view", view)])),
628 vec![WORKFLOW.to_owned(), RELEASE_WORKFLOW.to_owned()],
629 None,
630 )
631 .run_detail(&RunId("9".into()))
632 .await
633 .expect("detail");
634 let ids: Vec<&str> = detail.jobs.iter().map(|j| j.id.0.as_str()).collect();
635 assert_eq!(ids, ["publish"], "matched the Release workflow, not CI");
636 }
637
638 #[tokio::test]
639 async fn list_runs_parses_gh_json() {
640 let json = r#"[
641 {"databaseId":42,"displayTitle":"fix things","headBranch":"main","headSha":"abc1234",
642 "status":"completed","conclusion":"success","workflowName":"CI",
643 "createdAt":"2026-06-18T10:00:00Z","url":"https://gh/run/42"}
644 ]"#;
645 let runs = provider(&[("run list", json)])
646 .list_runs(10)
647 .await
648 .expect("runs");
649 assert_eq!(runs.len(), 1);
650 assert_eq!(runs[0].id, RunId("42".into()));
651 assert_eq!(runs[0].name, "CI");
652 assert_eq!(runs[0].branch, "main");
653 assert_eq!(runs[0].status, JobStatus::Ok);
654 assert!(runs[0].created.is_some());
655 }
656
657 #[tokio::test]
658 async fn run_detail_builds_dag_with_matrix_aggregation() {
659 let view = r#"{
660 "displayTitle":"fix things","headBranch":"main","headSha":"abc","status":"in_progress",
661 "conclusion":null,"workflowName":"CI",
662 "createdAt":"2026-06-18T10:00:00Z","url":"https://gh/run/42",
663 "jobs":[
664 {"databaseId":1,"name":"lint","status":"completed","conclusion":"success"},
665 {"databaseId":2,"name":"test (ubuntu-latest)","status":"completed","conclusion":"success"},
666 {"databaseId":3,"name":"test (windows-latest)","status":"in_progress","conclusion":null}
667 ]
668 }"#;
669 let detail = provider(&[("run view", view)])
670 .run_detail(&RunId("42".into()))
671 .await
672 .expect("detail");
673 let ids: Vec<&str> = detail.jobs.iter().map(|j| j.id.0.as_str()).collect();
674 assert_eq!(ids, ["lint", "test", "publish"]);
675 assert_eq!(detail.jobs[2].name, "Publish");
676 assert_eq!(
677 detail.jobs[2].needs,
678 vec![JobId("lint".into()), JobId("test".into())]
679 );
680 assert_eq!(detail.jobs[0].status, JobStatus::Ok, "lint succeeded");
681 assert_eq!(detail.jobs[1].status, JobStatus::Running, "a test leg runs");
682 assert_eq!(
683 detail.jobs[2].status,
684 JobStatus::Queued,
685 "publish not started"
686 );
687 }
688
689 #[tokio::test]
690 async fn job_log_fetches_a_completed_job_from_the_rest_api() {
691 let jobs = r#"{"jobs":[{"id":7,"name":"lint","status":"completed","conclusion":"success",
693 "steps":[{"name":"Run x","status":"completed","conclusion":"success",
694 "started_at":"2026-06-20T00:00:00Z","completed_at":"2026-06-20T00:00:03Z"}]}]}"#;
695 let chunk = provider(&[("runs/42/jobs", jobs), ("/logs", "line one\nline two\n")])
696 .job_log(&RunId("42".into()), &JobId("lint".into()), 0)
697 .await
698 .expect("log");
699 assert!(chunk.text.contains("line one"));
700 assert!(chunk.done);
701 assert_eq!(chunk.steps.len(), 1);
702 assert_eq!(chunk.steps[0].duration_secs, Some(3));
703 assert_eq!(chunk.next_offset, chunk.text.len() as u64);
704 }
705
706 #[tokio::test]
707 async fn job_log_in_progress_returns_live_steps_without_text() {
708 let jobs = r#"{"jobs":[{"id":7,"name":"lint","status":"in_progress","conclusion":null,
711 "steps":[{"name":"Run x","status":"in_progress","conclusion":null,
712 "started_at":"2026-06-20T00:00:00Z","completed_at":null}]}]}"#;
713 let chunk = provider(&[("runs/42/jobs", jobs)])
714 .job_log(&RunId("42".into()), &JobId("lint".into()), 0)
715 .await
716 .expect("log");
717 assert!(chunk.text.is_empty(), "no log archive while running");
718 assert!(!chunk.done, "keep polling until the job completes");
719 assert_eq!(chunk.steps.len(), 1, "live step states are shown");
720 assert_eq!(chunk.steps[0].status, JobStatus::Running);
721 }
722
723 #[tokio::test]
724 async fn run_extras_collects_artifacts_and_annotations() {
725 let artifacts = r#"{"artifacts":[
726 {"name":"coverage","size_in_bytes":2048,"expired":false},
727 {"name":"old-logs","size_in_bytes":10,"expired":true}
728 ]}"#;
729 let jobs = r#"{"jobs":[
730 {"check_run_url":"https://api.github.com/repos/o/r/check-runs/99"}
731 ]}"#;
732 let annotations = r#"[
733 {"annotation_level":"warning","title":"clippy","message":"unused import",
734 "path":"src/lib.rs","start_line":12},
735 {"annotation_level":"failure","title":"test","message":"assert failed",
736 "path":"src/x.rs","start_line":null}
737 ]"#;
738 let extras = provider(&[
739 ("artifacts", artifacts),
740 ("/jobs", jobs),
741 ("annotations", annotations),
742 ])
743 .run_extras(&RunId("42".into()))
744 .await
745 .expect("extras");
746 assert_eq!(extras.artifacts.len(), 2);
747 assert_eq!(extras.artifacts[0].name, "coverage");
748 assert!(extras.artifacts[1].expired);
749 assert_eq!(extras.annotations.len(), 2);
750 assert_eq!(extras.annotations[0].level, AnnotationLevel::Warning);
751 assert_eq!(extras.annotations[0].start_line, Some(12));
752 assert_eq!(extras.annotations[1].level, AnnotationLevel::Failure);
753 }
754
755 #[tokio::test]
756 async fn run_extras_degrades_to_artifacts_when_annotations_fail() {
757 let artifacts =
760 r#"{"artifacts":[{"name":"coverage","size_in_bytes":2048,"expired":false}]}"#;
761 let jobs =
762 r#"{"jobs":[{"check_run_url":"https://api.github.com/repos/o/r/check-runs/99"}]}"#;
763 let extras = provider(&[("artifacts", artifacts), ("/jobs", jobs)])
764 .run_extras(&RunId("42".into()))
765 .await
766 .expect("extras never errors");
767 assert_eq!(extras.artifacts.len(), 1);
768 assert!(extras.annotations.is_empty(), "failed job is skipped");
769 }
770
771 #[tokio::test]
772 async fn current_pr_parses_the_branch_pr() {
773 let json = r#"{"number":28,"title":"Inline CI runs","url":"https://gh/pull/28"}"#;
774 let pr = GitHubProvider::new(
775 Box::new(RecordingRunner::new(&[("pr view feat/x", json)])),
776 vec![],
777 Some("feat/x".to_owned()),
778 )
779 .current_pr()
780 .await
781 .expect("pr call");
782 let pr = pr.expect("a pr");
783 assert_eq!(pr.number, 28);
784 assert_eq!(pr.url.as_deref(), Some("https://gh/pull/28"));
785 }
786
787 #[tokio::test]
788 async fn current_pr_is_none_without_a_branch() {
789 let pr = provider(&[]).current_pr().await.expect("pr call");
790 assert!(pr.is_none(), "no branch → no PR, no gh call");
791 }
792
793 #[tokio::test]
794 async fn capabilities_are_config_dag_and_dump_logs() {
795 let caps = provider(&[]).capabilities();
796 assert_eq!(caps.dag, DagSource::ConfigFile);
797 assert_eq!(caps.logs, LogMode::Dump);
798 }
799}