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
16pub struct GitHubCommitPullAssociation {
18 pub commit_sha: String,
19 pub pull_requests: Vec<PullRequestSummary>,
20}
21
22pub 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
45pub 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
68pub 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
137pub 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
176pub 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 if let Some(status_resp) = combined_status {
192 for s in &status_resp.statuses {
193 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
261fn 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
282fn is_bot_approval_command(body: Option<&str>) -> bool {
285 let Some(body) = body else { return false };
286 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
296fn classify_ci_platform(slug: &str) -> (bool, bool, bool, bool) {
299 match slug {
300 "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 "netlify" => (true, false, false, false),
311 "vercel" => (true, false, false, false),
312 "render" => (true, false, false, false),
313
314 "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 "github-advanced-security" => (true, true, true, false),
323
324 "pkg-pr-new" => (true, false, false, false),
326
327 "dco" => (true, false, false, false),
329
330 "readthedocs" => (true, false, false, false),
332
333 "vs-code-engineering" | "microsoft-github-policy-service" => (true, false, false, false),
335
336 _ => (true, false, false, false),
339 }
340}
341
342pub 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 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
383fn infer_platform_from_name(name: &str) -> &'static str {
387 let lower = name.to_ascii_lowercase();
388 if lower.starts_with("pull-") || lower.starts_with("ci-") || lower == "tide" {
390 return "prow";
391 }
392 if lower.starts_with("bors") {
394 return "github-actions"; }
396 if lower.contains("easycla") || lower.contains("cla") {
398 return "github-actions";
399 }
400 if lower.contains("codecov") || lower.contains("coverage") {
402 return "codecov";
403 }
404 if lower.contains("netlify") {
406 return "netlify";
407 }
408 if lower.contains("cirrus") {
410 return "cirrus-ci";
411 }
412 if lower.starts_with("buildkite/") || lower.contains("buildkite") {
414 return "buildkite";
415 }
416 if lower.contains("readthedocs") {
418 return "readthedocs";
419 }
420 if lower.starts_with("pull-requests-") || lower.starts_with("pr-") {
423 return "github-actions"; }
425 if lower.contains("preview deploy") || lower.contains("vercel") {
427 return "vercel";
428 }
429 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}