1use http::HeaderMap;
22use reqwest::Method;
23use serde::Deserialize;
24use serde_json::{Value, json};
25use url::Url;
26use vgi_forge::{ForgeError, Resource, Result};
27
28use crate::api::Auth;
29use crate::forge::GitHubForge;
30use crate::secret::Secret;
31use crate::webhook;
32
33const PERMS_CHECKS: &[(&str, &str)] = &[("checks", "write"), ("metadata", "read")];
35const PERMS_READ: &[(&str, &str)] = &[("contents", "read"), ("metadata", "read")];
38const PERMS_PULLS: &[(&str, &str)] = &[("metadata", "read"), ("pull_requests", "read")];
40const PERMS_METADATA: &[(&str, &str)] = &[("metadata", "read")];
42const COMPARE_PER_PAGE: usize = 100;
44const COMPARE_PAGES: usize = 3;
45
46const MAX_SUMMARY: usize = 65_000;
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum CheckTriggerKind {
53 PullRequest {
56 number: u64,
58 },
59 MergeGroup,
61 Rerequested {
64 number: u64,
66 },
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct CheckTrigger {
80 pub repo: Resource,
82 pub repo_id: u64,
84 pub head_sha: String,
86 pub base_sha: String,
89 pub base_ref: String,
91 pub kind: CheckTriggerKind,
93 pub delivery_id: Option<String>,
95 pub head_ref: Option<String>,
98 pub author_login: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct PullRequestInfo {
107 pub head_sha: String,
109 pub base_ref: String,
111 pub base_sha: String,
113 pub open: bool,
115 pub head_ref: String,
117 pub head_repo_id: Option<u64>,
120 pub base_repo_id: Option<u64>,
122 pub author_login: Option<String>,
124 pub author_id: Option<u64>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum CheckConclusion {
132 Success,
134 Failure,
137}
138
139impl CheckConclusion {
140 fn as_str(self) -> &'static str {
141 match self {
142 CheckConclusion::Success => "success",
143 CheckConclusion::Failure => "failure",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150#[non_exhaustive]
151pub struct Comparison {
152 pub commits: Vec<String>,
155 pub total: u64,
158 pub merge_base: String,
160}
161
162fn branch_of(r: &str) -> &str {
165 r.strip_prefix("refs/heads/").unwrap_or(r)
166}
167
168fn rerequests(
171 repo: &Resource,
172 repo_id: u64,
173 head_sha: &str,
174 prs: Option<&Value>,
175 delivery_id: &Option<String>,
176) -> Vec<CheckTrigger> {
177 let mut out = Vec::new();
178 for pr in prs.and_then(Value::as_array).into_iter().flatten() {
179 let Some(number) = pr.get("number").and_then(Value::as_u64) else {
180 continue;
181 };
182 let base_ref = pr
183 .pointer("/base/ref")
184 .and_then(Value::as_str)
185 .unwrap_or_default();
186 let base_sha = pr
187 .pointer("/base/sha")
188 .and_then(Value::as_str)
189 .unwrap_or_default();
190 if base_ref.is_empty() || check_sha(base_sha).is_err() {
191 continue;
192 }
193 out.push(CheckTrigger {
194 repo: repo.clone(),
195 repo_id,
196 head_sha: head_sha.to_string(),
197 base_sha: base_sha.to_string(),
198 base_ref: branch_of(base_ref).to_string(),
199 kind: CheckTriggerKind::Rerequested { number },
200 delivery_id: delivery_id.clone(),
201 head_ref: None,
202 author_login: None,
203 });
204 }
205 out
206}
207
208impl GitHubForge {
209 pub fn parse_check_trigger(
221 &self,
222 headers: &HeaderMap,
223 body: &[u8],
224 ) -> Result<Vec<CheckTrigger>> {
225 webhook::verify_signature(self.webhook_secret(), headers, body)?;
226 let event = headers
227 .get("x-github-event")
228 .and_then(|v| v.to_str().ok())
229 .ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
230 let delivery_id = headers
231 .get("x-github-delivery")
232 .and_then(|v| v.to_str().ok())
233 .map(str::to_string);
234 if !matches!(
235 event,
236 "pull_request" | "merge_group" | "check_run" | "check_suite"
237 ) {
238 return Ok(Vec::new());
239 }
240 let payload: Value = serde_json::from_slice(body)
241 .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
242 let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
243 let repo_json = &payload["repository"];
244 let full_name = repo_json
245 .get("full_name")
246 .and_then(Value::as_str)
247 .ok_or_else(|| {
248 ForgeError::Webhook("payload is missing `repository.full_name`".into())
249 })?;
250 let repo = Resource::parse_owner_repo(&format!("{}/{full_name}", self.config().host))?;
251 let repo_id = repo_json
252 .get("id")
253 .and_then(Value::as_u64)
254 .ok_or_else(|| ForgeError::Webhook("payload is missing `repository.id`".into()))?;
255
256 let one = |head_sha: String, base_sha: String, base_ref: &str, kind| {
257 vec![CheckTrigger {
258 repo: repo.clone(),
259 repo_id,
260 head_sha,
261 base_sha,
262 base_ref: branch_of(base_ref).to_string(),
263 kind,
264 delivery_id: delivery_id.clone(),
265 head_ref: None,
266 author_login: None,
267 }]
268 };
269 let app_id = self.config().app_id;
270 Ok(match (event, action) {
271 ("pull_request", "opened" | "synchronize" | "reopened" | "edited") => {
272 if action == "edited" && payload.pointer("/changes/base").is_none() {
275 return Ok(Vec::new());
276 }
277 let pr = &payload["pull_request"];
278 let number = pr
279 .get("number")
280 .and_then(Value::as_u64)
281 .ok_or_else(|| ForgeError::Webhook("pull request has no number".into()))?;
282 let mut t = one(
283 sha_at(pr, &["head", "sha"])?,
284 sha_at(pr, &["base", "sha"])?,
285 str_at(pr, &["base", "ref"])?,
286 CheckTriggerKind::PullRequest { number },
287 );
288 t[0].head_ref = str_at(pr, &["head", "ref"])
289 .ok()
290 .map(|r| branch_of(r).to_string());
291 t[0].author_login = str_at(pr, &["user", "login"]).ok().map(str::to_string);
292 t
293 }
294 ("merge_group", "checks_requested") => {
295 let group = &payload["merge_group"];
296 one(
297 sha_at(group, &["head_sha"])?,
298 sha_at(group, &["base_sha"])?,
299 str_at(group, &["base_ref"])?,
300 CheckTriggerKind::MergeGroup,
301 )
302 }
303 ("check_run", "rerequested") | ("check_suite", "rerequested") => {
304 let obj = &payload[event];
305 if obj.pointer("/app/id").and_then(Value::as_u64) != Some(app_id) {
308 return Ok(Vec::new());
309 }
310 let head = sha_at(obj, &["head_sha"])?;
311 rerequests(
312 &repo,
313 repo_id,
314 &head,
315 obj.get("pull_requests"),
316 &delivery_id,
317 )
318 }
319 _ => Vec::new(),
320 })
321 }
322
323 pub async fn default_branch(&self, repo: &Resource) -> Result<String> {
327 let (token, owner, name) = self.repo_token_for(repo, PERMS_METADATA).await?;
328 #[derive(Deserialize)]
329 struct R {
330 default_branch: Option<String>,
331 }
332 let r: R = self
333 .api()
334 .json(
335 Method::GET,
336 self.api().url(&["repos", &owner, &name]),
337 Auth::Bearer(&token),
338 None,
339 repo.as_str(),
340 )
341 .await?;
342 r.default_branch
343 .ok_or_else(|| ForgeError::Protocol(format!("`{repo}` has no default branch")))
344 }
345
346 pub async fn pull_request(&self, repo: &Resource, number: u64) -> Result<PullRequestInfo> {
348 let (token, owner, name) = self.repo_token_for(repo, PERMS_PULLS).await?;
349 let pr: Value = self
350 .api()
351 .json(
352 Method::GET,
353 self.api()
354 .url(&["repos", &owner, &name, "pulls", &number.to_string()]),
355 Auth::Bearer(&token),
356 None,
357 "pull request",
358 )
359 .await?;
360 Ok(PullRequestInfo {
361 head_sha: sha_at(&pr, &["head", "sha"])?,
362 base_ref: branch_of(str_at(&pr, &["base", "ref"])?).to_string(),
363 base_sha: sha_at(&pr, &["base", "sha"])?,
364 open: pr.get("state").and_then(Value::as_str) == Some("open"),
365 head_ref: pr
366 .pointer("/head/ref")
367 .and_then(Value::as_str)
368 .map(|r| branch_of(r).to_string())
369 .unwrap_or_default(),
370 head_repo_id: pr.pointer("/head/repo/id").and_then(Value::as_u64),
371 base_repo_id: pr.pointer("/base/repo/id").and_then(Value::as_u64),
372 author_login: pr
373 .pointer("/user/login")
374 .and_then(Value::as_str)
375 .map(str::to_string),
376 author_id: pr.pointer("/user/id").and_then(Value::as_u64),
377 })
378 }
379
380 pub async fn compare_commits(
384 &self,
385 repo: &Resource,
386 base: &str,
387 head: &str,
388 ) -> Result<Comparison> {
389 check_sha(base)?;
390 check_sha(head)?;
391 let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
392 #[derive(Deserialize)]
393 struct Sha {
394 sha: String,
395 }
396 #[derive(Deserialize)]
397 struct Compare {
398 total_commits: u64,
399 merge_base_commit: Sha,
400 #[serde(default)]
401 commits: Vec<Sha>,
402 }
403 let range = format!("{base}...{head}");
404 let mut commits = Vec::new();
405 let mut total = 0;
406 let mut merge_base = String::new();
407 for page in 1..=COMPARE_PAGES {
408 let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
409 url.query_pairs_mut()
410 .append_pair("per_page", &COMPARE_PER_PAGE.to_string())
411 .append_pair("page", &page.to_string());
412 let c: Compare = self
413 .api()
414 .json(
415 Method::GET,
416 url,
417 Auth::Bearer(&token),
418 None,
419 "commit comparison",
420 )
421 .await?;
422 total = c.total_commits;
423 merge_base = c.merge_base_commit.sha;
424 let n = c.commits.len();
425 commits.extend(c.commits.into_iter().map(|s| s.sha));
426 if n < COMPARE_PER_PAGE || commits.len() as u64 >= total {
427 break;
428 }
429 }
430 for sha in &commits {
431 check_sha(sha)?;
432 }
433 Ok(Comparison {
434 commits,
435 total,
436 merge_base,
437 })
438 }
439
440 pub async fn merge_base(&self, repo: &Resource, a: &str, b: &str) -> Result<String> {
444 check_sha(a)?;
445 check_sha(b)?;
446 let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
447 #[derive(Deserialize)]
448 struct Sha {
449 sha: String,
450 }
451 #[derive(Deserialize)]
452 struct Compare {
453 merge_base_commit: Sha,
454 }
455 let range = format!("{a}...{b}");
456 let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
457 url.query_pairs_mut().append_pair("per_page", "1");
458 let c: Compare = self
459 .api()
460 .json(Method::GET, url, Auth::Bearer(&token), None, "merge base")
461 .await?;
462 check_sha(&c.merge_base_commit.sha)?;
463 Ok(c.merge_base_commit.sha)
464 }
465
466 pub async fn contents_read_token(&self, repo: &Resource) -> Result<Secret> {
470 Ok(self.repo_token_for(repo, PERMS_READ).await?.0)
471 }
472
473 pub fn clone_url(&self, repo: &Resource) -> Result<Url> {
475 repo.require_owner_repo()?;
476 let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
477 resource: repo.to_string(),
478 expected: "a repository".into(),
479 })?;
480 Ok(self.api().web_url(&[repo.owner(), &format!("{name}.git")]))
481 }
482
483 pub async fn start_check_run(
485 &self,
486 repo: &Resource,
487 head_sha: &str,
488 name: &str,
489 external_id: &str,
490 ) -> Result<u64> {
491 check_sha(head_sha)?;
492 let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
493 #[derive(Deserialize)]
494 struct Created {
495 id: u64,
496 }
497 let body = json!({
498 "name": name,
499 "head_sha": head_sha,
500 "status": "in_progress",
501 "external_id": external_id,
502 "output": {
503 "title": "Checking commit signatures",
504 "summary": "The community's bridge is verifying every commit against the Trust Registry.",
505 },
506 });
507 let created: Created = self
508 .api()
509 .json(
510 Method::POST,
511 self.api().url(&["repos", &owner, &repo_name, "check-runs"]),
512 Auth::Bearer(&token),
513 Some(&body),
514 "check run",
515 )
516 .await?;
517 Ok(created.id)
518 }
519
520 pub async fn finish_check_run(
523 &self,
524 repo: &Resource,
525 id: u64,
526 conclusion: CheckConclusion,
527 title: &str,
528 summary: &str,
529 ) -> Result<()> {
530 let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
531 let body = json!({
532 "status": "completed",
533 "conclusion": conclusion.as_str(),
534 "output": { "title": title, "summary": truncate(summary, MAX_SUMMARY) },
535 });
536 self.api()
537 .send(
538 Method::PATCH,
539 self.api()
540 .url(&["repos", &owner, &repo_name, "check-runs", &id.to_string()]),
541 Auth::Bearer(&token),
542 Some(&body),
543 "check run",
544 )
545 .await?;
546 Ok(())
547 }
548}
549
550pub fn check_sha(sha: &str) -> Result<()> {
553 let ok = (sha.len() == 40 || sha.len() == 64)
554 && sha
555 .bytes()
556 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
557 if ok {
558 Ok(())
559 } else {
560 Err(ForgeError::Protocol(format!("`{sha}` is not a commit id")))
561 }
562}
563
564fn sha_at(v: &Value, path: &[&str]) -> Result<String> {
565 let s = path
566 .iter()
567 .try_fold(v, |v, k| v.get(k))
568 .and_then(Value::as_str)
569 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))?;
570 check_sha(s)
571 .map_err(|_| ForgeError::Webhook(format!("`{}` is not a commit id", path.join("."))))?;
572 Ok(s.to_string())
573}
574
575fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
576 path.iter()
577 .try_fold(v, |v, k| v.get(k))
578 .and_then(Value::as_str)
579 .filter(|s| !s.is_empty())
580 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
581}
582
583fn truncate(s: &str, max: usize) -> String {
584 if s.len() <= max {
585 return s.to_string();
586 }
587 let mut end = max;
588 while !s.is_char_boundary(end) {
589 end -= 1;
590 }
591 format!("{}\n\n… (truncated)", &s[..end])
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn shas_are_hex_of_the_right_length() {
600 assert!(check_sha(&"a".repeat(40)).is_ok());
601 assert!(check_sha(&"0".repeat(64)).is_ok());
602 for bad in [
603 "",
604 "abc",
605 &"A".repeat(40),
606 &"g".repeat(40),
607 "--upload-pack=x",
608 ] {
609 assert!(check_sha(bad).is_err(), "{bad}");
610 }
611 }
612
613 #[test]
614 fn long_summaries_are_cut_on_a_char_boundary() {
615 let s = "é".repeat(40_000);
616 let t = truncate(&s, MAX_SUMMARY);
617 assert!(t.len() < s.len());
618 assert!(t.ends_with("(truncated)"));
619 }
620}