Skip to main content

diffler_ci/providers/
github.rs

1//! GitHub Actions adapter (via `gh`). The dependency DAG comes from a run's
2//! workflow YAML `jobs.<id>.needs` (the run API omits it); status overlays from
3//! `gh run view`. Logs, steps, artifacts, and annotations all come from the REST
4//! API via `gh api` — the job-log archive 404s until the job finishes, so an
5//! in-progress job returns its live step states with the content still empty.
6
7use 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
18/// Talks to GitHub Actions through `gh`. The runs list is scoped to the current
19/// `branch` (across all of its workflows); each run's DAG comes from whichever
20/// of the repo's `workflows` YAMLs matches that run's workflow name.
21pub struct GitHubProvider {
22    runner: Box<dyn CommandRunner>,
23    /// Every `.github/workflows/*.yml` body, so a run's DAG is built from its own
24    /// workflow (matched by the YAML `name:`), not a single guessed file.
25    workflows: Vec<String>,
26    /// The checked-out branch, scoping the runs list; `None` on detached HEAD.
27    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    /// `gh api <path>`; `{owner}`/`{repo}` in `path` resolve to the current repo.
44    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            // one job's annotations 404ing (a GC'd check run) or rate-limiting
78            // must not drop every other job's — skip it and keep going
79            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        // build the DAG from the run's own workflow, matched by the YAML `name:`
146        // against the run's `workflowName`; an unmatched run falls back to flat
147        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            // no workflow file: a flat, edgeless node per run job
155            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        // resolve the run-job (matrix jobs expand into several legs; the first
183        // matching leg is shown) and its live step states straight from the API
184        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        // the log archive (`jobs/{id}/logs`) only exists once the job finishes —
203        // it 404s while running. so for an in-progress job, return the live step
204        // states with no text and keep polling; the content fills in on completion
205        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                // honor `offset` so a re-poll racing `done` yields the tail
212                // (empty), never a duplicated transcript
213                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        // the extras panel is auxiliary: a forge hiccup degrades a section to
239        // empty rather than failing the graph page (and, since the host re-polls
240        // extras only while they're absent, rather than re-fetching forever)
241        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        // `gh pr view` exits non-zero when the branch has no PR; that's a normal
252        // state, not an error, so a failed call resolves to "no PR"
253        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
268/// A workflow job's structure from the YAML.
269struct JobSpec {
270    id: String,
271    label: String,
272    needs: Vec<String>,
273}
274
275/// The workflow's display `name:` (what `gh run list` reports as `workflowName`),
276/// used to match a run to the YAML that defines its DAG.
277fn 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
285/// Parse `jobs.<id>` into specs, preserving declaration order; `needs` is a
286/// scalar or a sequence of upstream job ids.
287fn 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
328/// A matrix job (`test`) expands into several run jobs (`test (ubuntu-latest)`).
329/// Match by exact name/id or the `name (` matrix prefix.
330fn 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
337/// Worst status across a job's matching run jobs, so one red leg shows red.
338fn 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/// The jobs array alone (from the REST `actions/runs/{id}/jobs` response, whose
405/// `total_count` is ignored) — the run meta in [`RunView`] isn't needed for logs.
406#[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// Parses both `gh run view --json jobs` (camelCase) and the REST jobs API
446// (snake_case `id`/`started_at`/…), so the same shape serves the DAG and logs.
447#[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        // a skipped/not-started step gets key 0 so it claims no log lines: GitHub
476        // gives those a null or zero (`0001-…`) start that would otherwise sort
477        // below real steps and, mid-list, swallow an earlier step's output
478        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/// The REST jobs response (`actions/runs/{id}/jobs`), which — unlike
516/// `gh run view --json jobs` — carries each job's `check_run_url`, the handle
517/// the annotations endpoint hangs off.
518#[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        // the response only matches if `--branch feat/x` was sent
601        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        // the run is a `Release` run; its DAG must come from the Release YAML
619        // (one `publish` job), not the CI YAML (lint/test/publish)
620        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        // the REST jobs response uses `id` (not `databaseId`) — the alias covers it
692        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        // the log archive 404s mid-run (here: no `/logs` response, so empty); the
709        // job stays in_progress → live steps but no text, and polling continues
710        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        // the jobs list is fetchable but its one job's annotations call has no
758        // recorded response (the mock errors) — artifacts must survive
759        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}