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 contents_read_token(&self, repo: &Resource) -> Result<Secret> {
444 Ok(self.repo_token_for(repo, PERMS_READ).await?.0)
445 }
446
447 pub fn clone_url(&self, repo: &Resource) -> Result<Url> {
449 repo.require_owner_repo()?;
450 let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
451 resource: repo.to_string(),
452 expected: "a repository".into(),
453 })?;
454 Ok(self.api().web_url(&[repo.owner(), &format!("{name}.git")]))
455 }
456
457 pub async fn start_check_run(
459 &self,
460 repo: &Resource,
461 head_sha: &str,
462 name: &str,
463 external_id: &str,
464 ) -> Result<u64> {
465 check_sha(head_sha)?;
466 let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
467 #[derive(Deserialize)]
468 struct Created {
469 id: u64,
470 }
471 let body = json!({
472 "name": name,
473 "head_sha": head_sha,
474 "status": "in_progress",
475 "external_id": external_id,
476 "output": {
477 "title": "Checking commit signatures",
478 "summary": "The community's bridge is verifying every commit against the Trust Registry.",
479 },
480 });
481 let created: Created = self
482 .api()
483 .json(
484 Method::POST,
485 self.api().url(&["repos", &owner, &repo_name, "check-runs"]),
486 Auth::Bearer(&token),
487 Some(&body),
488 "check run",
489 )
490 .await?;
491 Ok(created.id)
492 }
493
494 pub async fn finish_check_run(
497 &self,
498 repo: &Resource,
499 id: u64,
500 conclusion: CheckConclusion,
501 title: &str,
502 summary: &str,
503 ) -> Result<()> {
504 let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
505 let body = json!({
506 "status": "completed",
507 "conclusion": conclusion.as_str(),
508 "output": { "title": title, "summary": truncate(summary, MAX_SUMMARY) },
509 });
510 self.api()
511 .send(
512 Method::PATCH,
513 self.api()
514 .url(&["repos", &owner, &repo_name, "check-runs", &id.to_string()]),
515 Auth::Bearer(&token),
516 Some(&body),
517 "check run",
518 )
519 .await?;
520 Ok(())
521 }
522}
523
524pub fn check_sha(sha: &str) -> Result<()> {
527 let ok = (sha.len() == 40 || sha.len() == 64)
528 && sha
529 .bytes()
530 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
531 if ok {
532 Ok(())
533 } else {
534 Err(ForgeError::Protocol(format!("`{sha}` is not a commit id")))
535 }
536}
537
538fn sha_at(v: &Value, path: &[&str]) -> Result<String> {
539 let s = path
540 .iter()
541 .try_fold(v, |v, k| v.get(k))
542 .and_then(Value::as_str)
543 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))?;
544 check_sha(s)
545 .map_err(|_| ForgeError::Webhook(format!("`{}` is not a commit id", path.join("."))))?;
546 Ok(s.to_string())
547}
548
549fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
550 path.iter()
551 .try_fold(v, |v, k| v.get(k))
552 .and_then(Value::as_str)
553 .filter(|s| !s.is_empty())
554 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
555}
556
557fn truncate(s: &str, max: usize) -> String {
558 if s.len() <= max {
559 return s.to_string();
560 }
561 let mut end = max;
562 while !s.is_char_boundary(end) {
563 end -= 1;
564 }
565 format!("{}\n\n… (truncated)", &s[..end])
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn shas_are_hex_of_the_right_length() {
574 assert!(check_sha(&"a".repeat(40)).is_ok());
575 assert!(check_sha(&"0".repeat(64)).is_ok());
576 for bad in [
577 "",
578 "abc",
579 &"A".repeat(40),
580 &"g".repeat(40),
581 "--upload-pack=x",
582 ] {
583 assert!(check_sha(bad).is_err(), "{bad}");
584 }
585 }
586
587 #[test]
588 fn long_summaries_are_cut_on_a_char_boundary() {
589 let s = "é".repeat(40_000);
590 let t = truncate(&s, MAX_SUMMARY);
591 assert!(t.len() < s.len());
592 assert!(t.ends_with("(truncated)"));
593 }
594}