Skip to main content

libverify_github/
adapter.rs

1use std::collections::HashSet;
2
3use libverify_core::evidence::{
4    ApprovalDecision, ApprovalDisposition, ArtifactAttestation, AuthenticityEvidence,
5    ChangeRequestId, ChangedAsset, EvidenceBundle, EvidenceGap, EvidenceState, GovernedChange,
6    PromotionBatch, SourceRevision, WorkItemRef,
7};
8
9use libverify_core::evidence::{BuildPlatformEvidence, CheckConclusion, CheckRunEvidence};
10
11use crate::types::{
12    CheckRunItem, CombinedStatusResponse, CompareCommit, PrCommit, PrFile, PrMetadata,
13    PullRequestSummary, Review,
14};
15
16/// Associates a commit SHA with the pull requests that introduced it.
17pub struct GitHubCommitPullAssociation {
18    pub commit_sha: String,
19    pub pull_requests: Vec<PullRequestSummary>,
20}
21
22/// Builds an evidence bundle from a single pull request's metadata and reviews.
23pub fn build_pull_request_bundle(
24    repo: &str,
25    pr_number: u32,
26    pr_metadata: &PrMetadata,
27    pr_files: &[PrFile],
28    pr_reviews: &[Review],
29    pr_commits: &[PrCommit],
30) -> EvidenceBundle {
31    EvidenceBundle {
32        change_requests: vec![map_pull_request_evidence(
33            repo,
34            pr_number,
35            pr_metadata,
36            pr_files,
37            pr_reviews,
38            pr_commits,
39        )],
40        promotion_batches: Vec::new(),
41        ..Default::default()
42    }
43}
44
45/// Builds an evidence bundle from a release tag comparison and associated commits.
46pub fn build_release_bundle(
47    repo: &str,
48    base_tag: &str,
49    head_tag: &str,
50    commits: &[CompareCommit],
51    commit_pulls: &[GitHubCommitPullAssociation],
52    artifact_attestations: EvidenceState<Vec<ArtifactAttestation>>,
53) -> EvidenceBundle {
54    EvidenceBundle {
55        change_requests: Vec::new(),
56        promotion_batches: vec![map_promotion_batch_evidence(
57            repo,
58            base_tag,
59            head_tag,
60            commits,
61            commit_pulls,
62        )],
63        artifact_attestations,
64        ..Default::default()
65    }
66}
67
68/// Converts GitHub PR data into a platform-neutral `GovernedChange`.
69pub fn map_pull_request_evidence(
70    repo: &str,
71    pr_number: u32,
72    pr_metadata: &PrMetadata,
73    pr_files: &[PrFile],
74    pr_reviews: &[Review],
75    pr_commits: &[PrCommit],
76) -> GovernedChange {
77    let changed_assets = map_changed_assets(pr_files);
78    let approval_decisions = EvidenceState::complete(
79        pr_reviews
80            .iter()
81            .map(|review| ApprovalDecision {
82                actor: review.user.login.clone(),
83                disposition: map_review_disposition(&review.state, review.body.as_deref()),
84                submitted_at: review.submitted_at.clone(),
85            })
86            .collect(),
87    );
88
89    let source_revisions = EvidenceState::complete(
90        pr_commits
91            .iter()
92            .map(|commit| SourceRevision {
93                id: commit.sha.clone(),
94                authored_by: commit.author.as_ref().map(|a| a.login.clone()),
95                committed_at: commit
96                    .commit
97                    .committer
98                    .as_ref()
99                    .and_then(|committer| committer.date.clone()),
100                merge: false,
101                authenticity: match &commit.commit.verification {
102                    Some(v) => EvidenceState::complete(AuthenticityEvidence::new(
103                        v.verified,
104                        Some(v.reason.clone()),
105                    )),
106                    None => EvidenceState::not_applicable(),
107                },
108            })
109            .collect(),
110    );
111
112    let work_item_refs = EvidenceState::complete(
113        libverify_core::linkage::extract_issue_references(
114            pr_metadata.body.as_deref().unwrap_or(""),
115            &[],
116        )
117        .into_iter()
118        .map(|reference| WorkItemRef {
119            system: map_issue_ref_kind(&reference.kind).to_string(),
120            value: reference.value,
121        })
122        .collect(),
123    );
124
125    GovernedChange {
126        id: ChangeRequestId::new("github_pr", format!("{repo}#{pr_number}")),
127        title: pr_metadata.title.clone(),
128        summary: pr_metadata.body.clone(),
129        submitted_by: pr_metadata.user.as_ref().map(|u| u.login.clone()),
130        changed_assets,
131        approval_decisions,
132        source_revisions,
133        work_item_refs,
134    }
135}
136
137/// Converts a GitHub tag comparison into a platform-neutral `PromotionBatch`.
138pub fn map_promotion_batch_evidence(
139    repo: &str,
140    base_tag: &str,
141    head_tag: &str,
142    commits: &[CompareCommit],
143    commit_pulls: &[GitHubCommitPullAssociation],
144) -> PromotionBatch {
145    let commit_shas: HashSet<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
146    let mut seen_prs = HashSet::new();
147    let linked_change_requests: Vec<ChangeRequestId> = commit_pulls
148        .iter()
149        .filter(|assoc| commit_shas.contains(assoc.commit_sha.as_str()))
150        .flat_map(|assoc| assoc.pull_requests.iter())
151        .filter(|pr| seen_prs.insert(pr.number))
152        .map(|pr| ChangeRequestId::new("github_pr", format!("{repo}#{}", pr.number)))
153        .collect();
154
155    PromotionBatch {
156        id: format!("github_release:{repo}:{base_tag}..{head_tag}"),
157        source_revisions: EvidenceState::complete(
158            commits
159                .iter()
160                .map(|commit| SourceRevision {
161                    id: commit.sha.clone(),
162                    authored_by: commit.author.as_ref().map(|author| author.login.clone()),
163                    committed_at: None,
164                    merge: commit.parents.len() >= 2,
165                    authenticity: EvidenceState::complete(AuthenticityEvidence::new(
166                        commit.commit.verification.verified,
167                        Some(commit.commit.verification.reason.clone()),
168                    )),
169                })
170                .collect(),
171        ),
172        linked_change_requests: EvidenceState::complete(linked_change_requests),
173    }
174}
175
176/// Maps GitHub check run items and combined commit statuses into platform-neutral evidence.
177pub fn map_check_runs_evidence(
178    check_runs: &[CheckRunItem],
179    combined_status: Option<&CombinedStatusResponse>,
180) -> Vec<CheckRunEvidence> {
181    let mut evidence: Vec<CheckRunEvidence> = check_runs
182        .iter()
183        .map(|cr| CheckRunEvidence {
184            name: cr.name.clone(),
185            conclusion: map_check_run_conclusion(cr.status.as_str(), cr.conclusion.as_deref()),
186            app_slug: cr.app.as_ref().map(|a| a.slug.clone()),
187        })
188        .collect();
189
190    // Merge legacy commit statuses (reported via the Status API, not Check Runs API)
191    if let Some(status_resp) = combined_status {
192        for s in &status_resp.statuses {
193            // Avoid duplicates if a check run already covers this context
194            if evidence.iter().any(|e| e.name == s.context) {
195                continue;
196            }
197            evidence.push(CheckRunEvidence {
198                name: s.context.clone(),
199                conclusion: map_commit_status_state(&s.state),
200                app_slug: None,
201            });
202        }
203    }
204
205    evidence
206}
207
208fn map_check_run_conclusion(status: &str, conclusion: Option<&str>) -> CheckConclusion {
209    if status != "completed" {
210        return CheckConclusion::Pending;
211    }
212    match conclusion {
213        Some("success") => CheckConclusion::Success,
214        Some("failure") => CheckConclusion::Failure,
215        Some("neutral") => CheckConclusion::Neutral,
216        Some("cancelled") => CheckConclusion::Cancelled,
217        Some("skipped") => CheckConclusion::Skipped,
218        Some("timed_out") => CheckConclusion::TimedOut,
219        Some("action_required") => CheckConclusion::ActionRequired,
220        _ => CheckConclusion::Unknown,
221    }
222}
223
224fn map_commit_status_state(state: &str) -> CheckConclusion {
225    match state {
226        "success" => CheckConclusion::Success,
227        "failure" | "error" => CheckConclusion::Failure,
228        "pending" => CheckConclusion::Pending,
229        _ => CheckConclusion::Unknown,
230    }
231}
232
233fn map_changed_assets(pr_files: &[PrFile]) -> EvidenceState<Vec<ChangedAsset>> {
234    let assets: Vec<ChangedAsset> = pr_files
235        .iter()
236        .map(|file| ChangedAsset {
237            path: file.filename.clone(),
238            diff_available: file.patch.is_some(),
239            additions: file.additions,
240            deletions: file.deletions,
241            status: file.status.clone(),
242            diff: file.patch.clone(),
243        })
244        .collect();
245
246    let gaps: Vec<EvidenceGap> = pr_files
247        .iter()
248        .filter(|file| file.patch.is_none())
249        .map(|file| EvidenceGap::DiffUnavailable {
250            subject: file.filename.clone(),
251        })
252        .collect();
253
254    if gaps.is_empty() {
255        EvidenceState::complete(assets)
256    } else {
257        EvidenceState::partial(assets, gaps)
258    }
259}
260
261/// Maps a GitHub review state + body to an approval disposition.
262///
263/// Handles bot-mediated approvals (Prow, GitLab-style bots) where the review
264/// state is `COMMENTED` but the body contains approval commands like `/lgtm`
265/// or `/approve`. This is critical for Kubernetes, Istio, and other CNCF projects.
266fn map_review_disposition(state: &str, body: Option<&str>) -> ApprovalDisposition {
267    match state {
268        "APPROVED" => ApprovalDisposition::Approved,
269        "CHANGES_REQUESTED" => ApprovalDisposition::Rejected,
270        "COMMENTED" => {
271            if is_bot_approval_command(body) {
272                ApprovalDisposition::Approved
273            } else {
274                ApprovalDisposition::Commented
275            }
276        }
277        "DISMISSED" => ApprovalDisposition::Dismissed,
278        _ => ApprovalDisposition::Unknown,
279    }
280}
281
282/// Detects bot-mediated approval commands in review body text.
283/// Recognizes Prow (`/lgtm`, `/approve`), GitLab (`/merge`), and similar patterns.
284fn is_bot_approval_command(body: Option<&str>) -> bool {
285    let Some(body) = body else { return false };
286    // Check each line for approval commands (commands are line-start anchored)
287    body.lines().any(|line| {
288        let trimmed = line.trim();
289        trimmed == "/lgtm"
290            || trimmed == "/approve"
291            || trimmed.starts_with("/lgtm ")
292            || trimmed.starts_with("/approve ")
293    })
294}
295
296/// Known hosted CI platforms and their isolation characteristics.
297/// (hosted, ephemeral, isolated, signing_key_isolated)
298fn classify_ci_platform(slug: &str) -> (bool, bool, bool, bool) {
299    match slug {
300        // Fully hosted + isolated platforms
301        "github-actions" => (true, true, true, true),
302        "cirrus-ci" => (true, true, true, false),
303        "travis-ci" => (true, true, true, false),
304        "azure-pipelines" => (true, true, true, false),
305        "google-cloud-build" => (true, true, true, true),
306        "aws-codebuild" => (true, true, true, false),
307        "buildkite" => (true, true, true, false),
308
309        // Hosted but not fully isolated (preview deploys, shared runners)
310        "netlify" => (true, false, false, false),
311        "vercel" => (true, false, false, false),
312        "render" => (true, false, false, false),
313
314        // Bot/meta platforms (not build platforms — treat as hosted to avoid FP)
315        "prow" | "tide" => (true, true, true, false),
316        "codecov" | "codspeed-hq" | "codecov-commenter" => (true, false, false, false),
317        "sonarcloud" | "snyk" => (true, false, false, false),
318        "dependabot" | "renovate" => (true, false, false, false),
319        "buildomat" => (true, true, true, false),
320
321        // Code scanning / security analysis (hosted SaaS)
322        "github-advanced-security" => (true, true, true, false),
323
324        // Package preview / deploy bots (hosted)
325        "pkg-pr-new" => (true, false, false, false),
326
327        // DCO (Developer Certificate of Origin) check
328        "dco" => (true, false, false, false),
329
330        // ReadTheDocs (documentation build)
331        "readthedocs" => (true, false, false, false),
332
333        // Enterprise GitHub Apps (Microsoft, etc.)
334        "vs-code-engineering" | "microsoft-github-policy-service" => (true, false, false, false),
335
336        // Unknown — if a check run was reported to GitHub, *something* hosted
337        // ran it. Mark as hosted but not isolated (we cannot verify isolation).
338        _ => (true, false, false, false),
339    }
340}
341
342/// Maps check run evidence into build platform evidence.
343///
344/// Recognizes a wide range of hosted CI platforms beyond GitHub Actions,
345/// including Cirrus CI, Buildkite, Netlify, Prow, Codecov, etc.
346pub fn map_build_platform_evidence(check_runs: &[CheckRunEvidence]) -> Vec<BuildPlatformEvidence> {
347    check_runs
348        .iter()
349        .filter(|cr| cr.conclusion != CheckConclusion::Pending)
350        .map(|cr| {
351            let slug = cr.app_slug.as_deref().unwrap_or("unknown");
352            let (hosted, ephemeral, isolated, signing_key_isolated) = classify_ci_platform(slug);
353
354            // Fallback: if app_slug is unknown, try to infer from check run name.
355            // Prow check runs (pull-kubernetes-*, tide) have no app_slug.
356            let (platform, hosted, ephemeral, isolated, signing_key_isolated) = if slug == "unknown"
357            {
358                let inferred = infer_platform_from_name(&cr.name);
359                let (h, e, i, s) = classify_ci_platform(inferred);
360                (inferred.to_string(), h, e, i, s)
361            } else {
362                (
363                    slug.to_string(),
364                    hosted,
365                    ephemeral,
366                    isolated,
367                    signing_key_isolated,
368                )
369            };
370
371            BuildPlatformEvidence {
372                platform,
373                hosted,
374                ephemeral,
375                isolated,
376                runner_labels: vec![cr.app_slug.as_deref().unwrap_or("unknown").to_string()],
377                signing_key_isolated,
378            }
379        })
380        .collect()
381}
382
383/// Infer CI platform from check run name when app_slug is missing.
384/// Common patterns: Prow jobs start with "pull-" or "ci-", Tide is k8s merge bot,
385/// EasyCLA is a compliance check.
386fn infer_platform_from_name(name: &str) -> &'static str {
387    let lower = name.to_ascii_lowercase();
388    // Prow (Kubernetes, CNCF): "pull-kubernetes-*", "ci-*", "tide"
389    if lower.starts_with("pull-") || lower.starts_with("ci-") || lower == "tide" {
390        return "prow";
391    }
392    // Bors (Rust, Servo): "Bors auto build", "bors try"
393    if lower.starts_with("bors") {
394        return "github-actions"; // bors runs via GitHub, treat as hosted
395    }
396    // CLA checks
397    if lower.contains("easycla") || lower.contains("cla") {
398        return "github-actions";
399    }
400    // Codecov/coverage reporting
401    if lower.contains("codecov") || lower.contains("coverage") {
402        return "codecov";
403    }
404    // Netlify (deploy previews reported as status checks without app_slug)
405    if lower.contains("netlify") {
406        return "netlify";
407    }
408    // Cirrus CI
409    if lower.contains("cirrus") {
410        return "cirrus-ci";
411    }
412    // Buildkite
413    if lower.starts_with("buildkite/") || lower.contains("buildkite") {
414        return "buildkite";
415    }
416    // ReadTheDocs
417    if lower.contains("readthedocs") {
418        return "readthedocs";
419    }
420    // External CI reported via GitHub Status API (Jenkins, Buildbot, etc.)
421    // Pattern: "pull-requests-*", "pr-*" are common Jenkins job names
422    if lower.starts_with("pull-requests-") || lower.starts_with("pr-") {
423        return "github-actions"; // external CI, treat as hosted
424    }
425    // Vercel/preview deployments
426    if lower.contains("preview deploy") || lower.contains("vercel") {
427        return "vercel";
428    }
429    // Ecosystem CI (cross-project compatibility testing)
430    if lower.contains("ecosystem-ci") {
431        return "github-actions";
432    }
433    "unknown"
434}
435
436fn map_issue_ref_kind(kind: &libverify_core::linkage::IssueRefKind) -> &'static str {
437    match kind {
438        libverify_core::linkage::IssueRefKind::NumericIssue => "numeric_issue",
439        libverify_core::linkage::IssueRefKind::ProjectTicket => "project_ticket",
440        libverify_core::linkage::IssueRefKind::Url => "url",
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::types::{
448        CommitParent, CommitVerification, CompareCommitInner, PrBase, PrCommitAuthor,
449        PrCommitInner, PrHead, PrUser,
450    };
451
452    #[test]
453    fn pull_request_mapping_marks_missing_patch_as_partial() {
454        let evidence = map_pull_request_evidence(
455            "owner/repo",
456            42,
457            &PrMetadata {
458                number: 42,
459                title: "feat: add abstraction layer".to_string(),
460                body: Some("fixes #10".to_string()),
461                user: Some(PrUser {
462                    login: "author".to_string(),
463                }),
464                head: PrHead {
465                    sha: "abc123".to_string(),
466                },
467                base: PrBase {
468                    ref_name: "main".to_string(),
469                },
470            },
471            &[PrFile {
472                filename: "src/lib.rs".to_string(),
473                patch: None,
474                additions: 0,
475                deletions: 0,
476                status: "modified".to_string(),
477            }],
478            &[Review {
479                user: PrUser {
480                    login: "reviewer".to_string(),
481                },
482                state: "APPROVED".to_string(),
483                submitted_at: Some("2026-03-15T00:00:00Z".to_string()),
484                body: None,
485            }],
486            &[PrCommit {
487                sha: "abc123".to_string(),
488                commit: PrCommitInner {
489                    committer: Some(PrCommitAuthor {
490                        date: Some("2026-03-15T00:00:00Z".to_string()),
491                    }),
492                    verification: None,
493                },
494                author: Some(PrUser {
495                    login: "author".to_string(),
496                }),
497            }],
498        );
499
500        assert!(matches!(
501            evidence.changed_assets,
502            EvidenceState::Partial { .. }
503        ));
504        assert!(matches!(
505            evidence.source_revisions,
506            EvidenceState::Complete { .. }
507        ));
508    }
509
510    #[test]
511    fn promotion_batch_mapping_preserves_signature_state() {
512        let batch = map_promotion_batch_evidence(
513            "owner/repo",
514            "v0.1.0",
515            "v0.2.0",
516            &[CompareCommit {
517                sha: "deadbeef".to_string(),
518                commit: CompareCommitInner {
519                    message: "feat: ship control layer".to_string(),
520                    verification: CommitVerification {
521                        verified: false,
522                        reason: "unsigned".to_string(),
523                    },
524                },
525                author: None,
526                parents: vec![CommitParent {
527                    sha: "parent".to_string(),
528                }],
529            }],
530            &[GitHubCommitPullAssociation {
531                commit_sha: "deadbeef".to_string(),
532                pull_requests: vec![],
533            }],
534        );
535
536        let revisions = match &batch.source_revisions {
537            EvidenceState::Complete { value } => value,
538            _ => panic!("source revisions should be complete"),
539        };
540        assert_eq!(revisions.len(), 1);
541        assert!(matches!(
542            revisions[0].authenticity,
543            EvidenceState::Complete { .. }
544        ));
545    }
546
547    #[test]
548    fn promotion_batch_filters_unrelated_commits_and_deduplicates_prs() {
549        let commits = vec![CompareCommit {
550            sha: "aaa111".to_string(),
551            commit: CompareCommitInner {
552                message: "feat: in-range commit".to_string(),
553                verification: CommitVerification {
554                    verified: true,
555                    reason: "valid".to_string(),
556                },
557            },
558            author: None,
559            parents: vec![],
560        }];
561
562        let commit_pulls = vec![
563            GitHubCommitPullAssociation {
564                commit_sha: "aaa111".to_string(),
565                pull_requests: vec![PullRequestSummary {
566                    number: 1,
567                    merged_at: Some("2026-03-15T00:00:00Z".to_string()),
568                    user: PrUser {
569                        login: "dev".to_string(),
570                    },
571                }],
572            },
573            GitHubCommitPullAssociation {
574                commit_sha: "bbb222".to_string(),
575                pull_requests: vec![PullRequestSummary {
576                    number: 99,
577                    merged_at: Some("2026-03-15T00:00:00Z".to_string()),
578                    user: PrUser {
579                        login: "other".to_string(),
580                    },
581                }],
582            },
583            GitHubCommitPullAssociation {
584                commit_sha: "aaa111".to_string(),
585                pull_requests: vec![PullRequestSummary {
586                    number: 1,
587                    merged_at: Some("2026-03-15T00:00:00Z".to_string()),
588                    user: PrUser {
589                        login: "dev".to_string(),
590                    },
591                }],
592            },
593        ];
594
595        let batch =
596            map_promotion_batch_evidence("owner/repo", "v0.1.0", "v0.2.0", &commits, &commit_pulls);
597
598        let crs = match &batch.linked_change_requests {
599            EvidenceState::Complete { value } => value,
600            _ => panic!("linked_change_requests should be complete"),
601        };
602        assert_eq!(crs.len(), 1, "expected exactly 1 CR after filter+dedup");
603        assert_eq!(crs[0].value, "owner/repo#1");
604    }
605
606    #[test]
607    fn pull_request_bundle_uses_new_evidence_entrypoint() {
608        let bundle = build_pull_request_bundle(
609            "owner/repo",
610            42,
611            &PrMetadata {
612                number: 42,
613                title: "feat: add abstraction layer".to_string(),
614                body: Some("fixes #10".to_string()),
615                user: Some(PrUser {
616                    login: "author".to_string(),
617                }),
618                head: PrHead {
619                    sha: "abc123".to_string(),
620                },
621                base: PrBase {
622                    ref_name: "main".to_string(),
623                },
624            },
625            &[],
626            &[],
627            &[],
628        );
629
630        assert_eq!(bundle.change_requests.len(), 1);
631        assert!(bundle.promotion_batches.is_empty());
632    }
633
634    #[test]
635    fn submitted_by_populated_from_pr_user() {
636        let evidence = map_pull_request_evidence(
637            "owner/repo",
638            1,
639            &PrMetadata {
640                number: 1,
641                title: "feat: wire user".to_string(),
642                body: None,
643                user: Some(PrUser {
644                    login: "octocat".to_string(),
645                }),
646                head: PrHead {
647                    sha: "def456".to_string(),
648                },
649                base: PrBase {
650                    ref_name: "main".to_string(),
651                },
652            },
653            &[],
654            &[],
655            &[],
656        );
657
658        assert_eq!(evidence.submitted_by, Some("octocat".to_string()));
659    }
660
661    #[test]
662    fn submitted_by_none_when_user_absent() {
663        let evidence = map_pull_request_evidence(
664            "owner/repo",
665            1,
666            &PrMetadata {
667                number: 1,
668                title: "feat: anonymous".to_string(),
669                body: None,
670                user: None,
671                head: PrHead {
672                    sha: "ghi789".to_string(),
673                },
674                base: PrBase {
675                    ref_name: "main".to_string(),
676                },
677            },
678            &[],
679            &[],
680            &[],
681        );
682
683        assert_eq!(evidence.submitted_by, None);
684    }
685
686    #[test]
687    fn release_bundle_includes_artifact_attestations() {
688        let attestations =
689            EvidenceState::complete(vec![libverify_core::evidence::ArtifactAttestation {
690                subject: "binary-linux-amd64".to_string(),
691                subject_digest: None,
692                predicate_type: "https://slsa.dev/provenance/v1".to_string(),
693                signer_workflow: Some(".github/workflows/release.yml".to_string()),
694                source_repo: Some("owner/repo".to_string()),
695                verification: libverify_core::evidence::VerificationOutcome::Verified,
696            }]);
697
698        let bundle = build_release_bundle(
699            "owner/repo",
700            "v0.1.0",
701            "v0.2.0",
702            &[CompareCommit {
703                sha: "abc123".to_string(),
704                commit: CompareCommitInner {
705                    message: "feat: ship".to_string(),
706                    verification: CommitVerification {
707                        verified: true,
708                        reason: "valid".to_string(),
709                    },
710                },
711                author: None,
712                parents: vec![],
713            }],
714            &[],
715            attestations,
716        );
717
718        match &bundle.artifact_attestations {
719            EvidenceState::Complete { value } => {
720                assert_eq!(value.len(), 1);
721                assert!(value[0].verification.is_verified());
722                assert_eq!(value[0].subject, "binary-linux-amd64");
723            }
724            other => panic!("expected Complete, got {other:?}"),
725        }
726    }
727
728    #[test]
729    fn release_bundle_not_applicable_without_attestations() {
730        let bundle = build_release_bundle(
731            "owner/repo",
732            "v0.1.0",
733            "v0.2.0",
734            &[CompareCommit {
735                sha: "abc123".to_string(),
736                commit: CompareCommitInner {
737                    message: "feat: ship".to_string(),
738                    verification: CommitVerification {
739                        verified: true,
740                        reason: "valid".to_string(),
741                    },
742                },
743                author: None,
744                parents: vec![],
745            }],
746            &[],
747            EvidenceState::not_applicable(),
748        );
749
750        assert!(matches!(
751            bundle.artifact_attestations,
752            EvidenceState::NotApplicable
753        ));
754    }
755
756    #[test]
757    fn prow_lgtm_comment_treated_as_approval() {
758        let disposition = map_review_disposition("COMMENTED", Some("/lgtm\n/approve"));
759        assert_eq!(disposition, ApprovalDisposition::Approved);
760    }
761
762    #[test]
763    fn prow_lgtm_with_text_treated_as_approval() {
764        let disposition = map_review_disposition("COMMENTED", Some("/lgtm looks good"));
765        assert_eq!(disposition, ApprovalDisposition::Approved);
766    }
767
768    #[test]
769    fn plain_comment_stays_commented() {
770        let disposition =
771            map_review_disposition("COMMENTED", Some("this looks good, but needs a fix"));
772        assert_eq!(disposition, ApprovalDisposition::Commented);
773    }
774
775    #[test]
776    fn approved_state_unchanged() {
777        let disposition = map_review_disposition("APPROVED", None);
778        assert_eq!(disposition, ApprovalDisposition::Approved);
779    }
780
781    #[test]
782    fn empty_body_stays_commented() {
783        let disposition = map_review_disposition("COMMENTED", None);
784        assert_eq!(disposition, ApprovalDisposition::Commented);
785    }
786}