Skip to main content

isb_apps/app/
webhook.rs

1//! Push webhooks: who sent one, whether its signature holds, and what it
2//! asks for.
3//!
4//! `POST /api/v1/webhooks/<org>/<app>` is reachable without a session; the
5//! app's webhook secret (an org secret) is its only credential:
6//! - GitHub: `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body>`;
7//! - Gitea / Forgejo: `X-Gitea-Signature` / `X-Forgejo-Signature`: the hex
8//!   HMAC-SHA256 of the body;
9//! - GitLab: `X-Gitlab-Token`: the secret itself;
10//! - anything else: `?token=<secret>`.
11//!
12//! Every comparison is constant-time.
13
14use serde_json::Value;
15
16/// A request's headers by lowercase name.
17pub type Headers<'a> = dyn Fn(&str) -> Option<String> + 'a;
18
19/// Which kind of sender, by the header that authenticated it.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Provider {
22    GitHub,
23    Gitea,
24    GitLab,
25    Generic,
26}
27
28/// What a verified request asks for.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Event {
31    /// A configuration check (GitHub's `ping`): answer 200, do nothing.
32    Ping,
33    /// A push to `reference` (`refs/heads/main`), now at `after`.
34    Push {
35        reference: String,
36        after: Option<String>,
37        message: Option<String>,
38        deleted: bool,
39    },
40    /// A generic `?token=` call with no push payload: deploy.
41    Trigger,
42    /// A pull (merge) request opened, updated or closed: previews.
43    PullRequest(PullRequest),
44    /// Anything else (issues, comments, ...): ignored.
45    Other(String),
46}
47
48/// What happened to a pull request, as far as previews care.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PrAction {
51    /// Opened or reopened: build and deploy a preview.
52    Open,
53    /// New commits on its head: rebuild the preview.
54    Sync,
55    /// Closed, merged or not: remove the preview.
56    Close { merged: bool },
57    /// Edited, labelled, reviewed, ...: nothing to do.
58    Other,
59}
60
61/// A pull request event (GitHub and Gitea/Forgejo `pull_request`, GitLab
62/// `Merge Request Hook`).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PullRequest {
65    pub action: PrAction,
66    /// The action as the sender named it (`synchronize`, `update`, ...).
67    pub raw_action: String,
68    /// GitHub/Gitea `number`, GitLab `iid`.
69    pub number: u64,
70    pub head_sha: Option<String>,
71    /// The branch the changes are on (in the head repository).
72    pub head_ref: String,
73    /// The branch it would merge into.
74    pub base_ref: String,
75    /// The head is in another repository than the base: a fork, whose code
76    /// nobody with push access wrote.
77    pub fork: bool,
78    pub title: String,
79}
80
81impl PullRequest {
82    /// The ref the forge keeps the request's head under, in the base
83    /// repository (so a fork's commits are fetched from the base).
84    pub fn head_ref_in_base(provider: Provider, number: u64) -> String {
85        match provider {
86            Provider::GitLab => format!("refs/merge-requests/{number}/head"),
87            _ => format!("refs/pull/{number}/head"),
88        }
89    }
90}
91
92/// Why a request was refused.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Refusal {
95    /// No credential this endpoint knows.
96    Missing,
97    /// A credential that does not match.
98    Invalid,
99}
100
101/// Verify a request against the app's secret: the provider it came from.
102pub fn verify(
103    secret: &[u8],
104    header: &Headers,
105    query_token: Option<&str>,
106    body: &[u8],
107) -> Result<Provider, Refusal> {
108    // Gitea and Forgejo also send GitHub's header: theirs decides, so the
109    // sender is known for what follows (pull request refs, status API).
110    if let Some(sig) = header("x-gitea-signature").or_else(|| header("x-forgejo-signature")) {
111        return hmac_ok(secret, body, sig.trim())
112            .then_some(Provider::Gitea)
113            .ok_or(Refusal::Invalid);
114    }
115    if let Some(sig) = header("x-hub-signature-256") {
116        let hex = sig.trim().strip_prefix("sha256=").ok_or(Refusal::Invalid)?;
117        return hmac_ok(secret, body, hex)
118            .then_some(Provider::GitHub)
119            .ok_or(Refusal::Invalid);
120    }
121    if let Some(tok) = header("x-gitlab-token") {
122        return ct_eq(tok.trim().as_bytes(), secret)
123            .then_some(Provider::GitLab)
124            .ok_or(Refusal::Invalid);
125    }
126    if let Some(tok) = query_token {
127        return ct_eq(tok.as_bytes(), secret)
128            .then_some(Provider::Generic)
129            .ok_or(Refusal::Invalid);
130    }
131    Err(Refusal::Missing)
132}
133
134fn hmac_ok(secret: &[u8], body: &[u8], hex: &str) -> bool {
135    let Some(sig) = from_hex(hex) else {
136        return false;
137    };
138    let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
139    ring::hmac::verify(&key, body, &sig).is_ok()
140}
141
142/// The hex HMAC-SHA256 a sender computes, for tests and for `isb app
143/// webhook --test`.
144pub fn sign(secret: &[u8], body: &[u8]) -> String {
145    let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
146    to_hex(ring::hmac::sign(&key, body).as_ref())
147}
148
149fn ct_eq(a: &[u8], b: &[u8]) -> bool {
150    // Compare digests, so the time spent depends on neither length.
151    let da = ring::digest::digest(&ring::digest::SHA256, a);
152    let db = ring::digest::digest(&ring::digest::SHA256, b);
153    let mut d = 0u8;
154    for (x, y) in da.as_ref().iter().zip(db.as_ref()) {
155        d |= x ^ y;
156    }
157    d == 0
158}
159
160fn from_hex(s: &str) -> Option<Vec<u8>> {
161    if s.len() % 2 != 0 {
162        return None;
163    }
164    (0..s.len())
165        .step_by(2)
166        .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
167        .collect()
168}
169
170fn to_hex(b: &[u8]) -> String {
171    b.iter().map(|x| format!("{x:02x}")).collect()
172}
173
174/// What a verified request asks for, from its event header and body.
175pub fn event(provider: Provider, header: &Headers, body: &[u8]) -> Event {
176    let kind = match provider {
177        Provider::GitHub => header("x-github-event"),
178        Provider::Gitea => header("x-gitea-event").or_else(|| header("x-forgejo-event")),
179        Provider::GitLab => header("x-gitlab-event"),
180        Provider::Generic => header("x-github-event")
181            .or_else(|| header("x-gitea-event"))
182            .or_else(|| header("x-gitlab-event")),
183    };
184    let json: Option<Value> = serde_json::from_slice(body).ok();
185    match kind.as_deref().map(str::to_ascii_lowercase).as_deref() {
186        Some("ping") => return Event::Ping,
187        Some("push" | "push hook" | "tag push hook") => {}
188        Some("pull_request" | "merge request hook") => {
189            return match json.as_ref().and_then(|j| pull_request(provider, j)) {
190                Some(pr) => Event::PullRequest(pr),
191                None => Event::Other("pull request without a number".into()),
192            };
193        }
194        Some(other) => return Event::Other(other.to_string()),
195        None if provider == Provider::Generic => {}
196        None => return Event::Other("unknown".into()),
197    }
198    let Some(j) = json.filter(|j| j.get("ref").is_some()) else {
199        return if provider == Provider::Generic {
200            Event::Trigger
201        } else {
202            Event::Other("push without a ref".into())
203        };
204    };
205    let s = |v: &Value| v.as_str().map(String::from);
206    let after = j
207        .get("after")
208        .and_then(s)
209        .or_else(|| j.get("checkout_sha").and_then(s));
210    let zero = after
211        .as_deref()
212        .is_some_and(|a| a.bytes().all(|b| b == b'0'));
213    let message = j
214        .pointer("/head_commit/message")
215        .and_then(s)
216        .or_else(|| {
217            j.get("commits")
218                .and_then(Value::as_array)
219                .and_then(|c| c.last())
220                .and_then(|c| c.get("message"))
221                .and_then(s)
222        })
223        .map(|m| m.lines().next().unwrap_or("").to_string());
224    Event::Push {
225        reference: j.get("ref").and_then(s).unwrap_or_default(),
226        after: after.filter(|_| !zero),
227        message,
228        deleted: zero || j.get("deleted").and_then(Value::as_bool) == Some(true),
229    }
230}
231
232/// A pull request from its payload: GitHub and Gitea/Forgejo share a shape
233/// (`action`, `number`, `pull_request.{head,base}`), GitLab has its own
234/// (`object_attributes`).
235fn pull_request(provider: Provider, j: &Value) -> Option<PullRequest> {
236    let s = |p: &str| j.pointer(p).and_then(Value::as_str).map(String::from);
237    if provider == Provider::GitLab
238        || j.get("object_kind").and_then(Value::as_str) == Some("merge_request")
239    {
240        let a = j.get("object_attributes")?;
241        let number = a.get("iid").and_then(Value::as_u64)?;
242        let raw = s("/object_attributes/action").unwrap_or_default();
243        // An `update` with `oldrev` moved the head; without it only the
244        // title, labels or the like changed.
245        let action = match raw.as_str() {
246            "open" | "reopen" => PrAction::Open,
247            "update" if a.get("oldrev").is_some_and(|v| v.is_string()) => PrAction::Sync,
248            "close" => PrAction::Close { merged: false },
249            "merge" => PrAction::Close { merged: true },
250            _ => PrAction::Other,
251        };
252        let src = a.get("source_project_id").and_then(Value::as_u64);
253        let dst = a.get("target_project_id").and_then(Value::as_u64);
254        return Some(PullRequest {
255            action,
256            raw_action: raw,
257            number,
258            head_sha: s("/object_attributes/last_commit/id"),
259            head_ref: s("/object_attributes/source_branch").unwrap_or_default(),
260            base_ref: s("/object_attributes/target_branch").unwrap_or_default(),
261            // Unknown counts as a fork: the safe side.
262            fork: src.is_none() || src != dst,
263            title: s("/object_attributes/title").unwrap_or_default(),
264        });
265    }
266    let pr = j.get("pull_request")?;
267    let number = j
268        .get("number")
269        .and_then(Value::as_u64)
270        .or_else(|| pr.get("number").and_then(Value::as_u64))?;
271    let raw = s("/action").unwrap_or_default();
272    let merged = pr.get("merged").and_then(Value::as_bool) == Some(true);
273    let action = match raw.as_str() {
274        "opened" | "reopened" => PrAction::Open,
275        // GitHub `synchronize`, Gitea/Forgejo `synchronized`.
276        "synchronize" | "synchronized" => PrAction::Sync,
277        "closed" => PrAction::Close { merged },
278        _ => PrAction::Other,
279    };
280    // A head repository that is gone (a deleted fork) or is not the base
281    // repository is a fork.
282    let head_repo = s("/pull_request/head/repo/full_name");
283    let base_repo = s("/pull_request/base/repo/full_name").or_else(|| s("/repository/full_name"));
284    let fork = match (&head_repo, &base_repo) {
285        (Some(h), Some(b)) => !h.eq_ignore_ascii_case(b),
286        _ => true,
287    };
288    Some(PullRequest {
289        action,
290        raw_action: raw,
291        number,
292        head_sha: s("/pull_request/head/sha"),
293        head_ref: s("/pull_request/head/ref").unwrap_or_default(),
294        base_ref: s("/pull_request/base/ref").unwrap_or_default(),
295        fork,
296        title: s("/pull_request/title").unwrap_or_default(),
297    })
298}
299
300/// A delivery id, for refusing a replayed delivery.
301pub fn delivery_id(header: &Headers) -> Option<String> {
302    header("x-github-delivery")
303        .or_else(|| header("x-gitea-delivery"))
304        .or_else(|| header("x-forgejo-delivery"))
305        .or_else(|| header("x-gitlab-event-uuid"))
306        .filter(|d| !d.is_empty() && d.len() <= 200)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn headers(h: &[(&str, &str)]) -> Box<Headers<'static>> {
314        let h: Vec<(String, String)> = h
315            .iter()
316            .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
317            .collect();
318        Box::new(move |k: &str| {
319            h.iter()
320                .find(|(n, _)| n == &k.to_ascii_lowercase())
321                .map(|(_, v)| v.clone())
322        })
323    }
324
325    const SECRET: &[u8] = b"It's a Secret to Everybody";
326
327    #[test]
328    fn github_signature() {
329        // GitHub's documented example.
330        let body = b"Hello, World!";
331        let want = "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
332        assert_eq!(sign(SECRET, body), want);
333        let ok = headers(&[("X-Hub-Signature-256", &format!("sha256={want}"))]);
334        assert_eq!(verify(SECRET, &ok, None, body), Ok(Provider::GitHub));
335        // Another body, another secret, a mangled header: refused.
336        assert_eq!(
337            verify(SECRET, &ok, None, b"Hello, World?"),
338            Err(Refusal::Invalid)
339        );
340        assert_eq!(verify(b"other", &ok, None, body), Err(Refusal::Invalid));
341        let bare = headers(&[("X-Hub-Signature-256", want)]);
342        assert_eq!(verify(SECRET, &bare, None, body), Err(Refusal::Invalid));
343        let odd = headers(&[("X-Hub-Signature-256", "sha256=zz")]);
344        assert_eq!(verify(SECRET, &odd, None, body), Err(Refusal::Invalid));
345        // A signature header is decisive: a right ?token= does not rescue a
346        // wrong signature.
347        let wrong = headers(&[("X-Hub-Signature-256", "sha256=00")]);
348        assert_eq!(
349            verify(SECRET, &wrong, Some("It's a Secret to Everybody"), body),
350            Err(Refusal::Invalid)
351        );
352    }
353
354    #[test]
355    fn gitea_gitlab_and_token() {
356        let body = br#"{"ref":"refs/heads/main"}"#;
357        let sig = sign(SECRET, body);
358        let gitea = headers(&[("X-Gitea-Signature", &sig)]);
359        assert_eq!(verify(SECRET, &gitea, None, body), Ok(Provider::Gitea));
360        let forgejo = headers(&[("X-Forgejo-Signature", &sig)]);
361        assert_eq!(verify(SECRET, &forgejo, None, body), Ok(Provider::Gitea));
362        // Gitea sends GitHub's header too; it is still Gitea.
363        let both = headers(&[
364            ("X-Gitea-Signature", &sig),
365            ("X-Hub-Signature-256", &format!("sha256={sig}")),
366        ]);
367        assert_eq!(verify(SECRET, &both, None, body), Ok(Provider::Gitea));
368        let gl = headers(&[("X-Gitlab-Token", "It's a Secret to Everybody")]);
369        assert_eq!(verify(SECRET, &gl, None, body), Ok(Provider::GitLab));
370        let gl_bad = headers(&[("X-Gitlab-Token", "It's a Secret to Everybod")]);
371        assert_eq!(verify(SECRET, &gl_bad, None, body), Err(Refusal::Invalid));
372        let none = headers(&[]);
373        assert_eq!(
374            verify(SECRET, &none, Some("It's a Secret to Everybody"), body),
375            Ok(Provider::Generic)
376        );
377        assert_eq!(
378            verify(SECRET, &none, Some("nope"), body),
379            Err(Refusal::Invalid)
380        );
381        assert_eq!(verify(SECRET, &none, None, body), Err(Refusal::Missing));
382    }
383
384    #[test]
385    fn events() {
386        let push = br#"{"ref":"refs/heads/main","after":"abc123","head_commit":{"message":"fix: it\n\nbody"}}"#;
387        let gh = headers(&[("X-GitHub-Event", "push")]);
388        assert_eq!(
389            event(Provider::GitHub, &gh, push),
390            Event::Push {
391                reference: "refs/heads/main".into(),
392                after: Some("abc123".into()),
393                message: Some("fix: it".into()),
394                deleted: false,
395            }
396        );
397        let ping = headers(&[("X-GitHub-Event", "ping")]);
398        assert_eq!(event(Provider::GitHub, &ping, b"{}"), Event::Ping);
399        // A signed non-push event (an issue, a replayed pull request) is not
400        // a deploy.
401        let issues = headers(&[("X-GitHub-Event", "issues")]);
402        assert_eq!(
403            event(Provider::GitHub, &issues, push),
404            Event::Other("issues".into())
405        );
406        let gl = headers(&[("X-Gitlab-Event", "Push Hook")]);
407        let gl_body = br#"{"ref":"refs/heads/dev","checkout_sha":"def","commits":[{"message":"one"},{"message":"two"}]}"#;
408        assert_eq!(
409            event(Provider::GitLab, &gl, gl_body),
410            Event::Push {
411                reference: "refs/heads/dev".into(),
412                after: Some("def".into()),
413                message: Some("two".into()),
414                deleted: false,
415            }
416        );
417        let del = br#"{"ref":"refs/heads/main","after":"0000000000000000000000000000000000000000","deleted":true}"#;
418        assert!(matches!(
419            event(Provider::Gitea, &headers(&[("X-Gitea-Event", "push")]), del),
420            Event::Push {
421                deleted: true,
422                after: None,
423                ..
424            }
425        ));
426        assert_eq!(event(Provider::Generic, &headers(&[]), b""), Event::Trigger);
427        // A pull request event is not a push.
428        let pr = br#"{"action":"opened","number":3,"pull_request":{"head":{"sha":"abc","ref":"f","repo":{"full_name":"o/r"}},"base":{"ref":"main","repo":{"full_name":"o/r"}}}}"#;
429        assert!(matches!(
430            event(
431                Provider::GitHub,
432                &headers(&[("X-GitHub-Event", "pull_request")]),
433                pr
434            ),
435            Event::PullRequest(_)
436        ));
437        assert!(matches!(
438            event(Provider::Generic, &headers(&[]), push),
439            Event::Push { .. }
440        ));
441    }
442
443    fn pr_of(provider: Provider, h: &[(&str, &str)], body: &str) -> PullRequest {
444        match event(provider, &headers(h), body.as_bytes()) {
445            Event::PullRequest(p) => p,
446            e => panic!("not a pull request: {e:?}"),
447        }
448    }
449
450    /// GitHub's `pull_request` payload, trimmed to what is read.
451    fn github(action: &str, head_repo: &str, merged: bool) -> String {
452        format!(
453            r#"{{"action":"{action}","number":42,"pull_request":{{"number":42,"title":"Add a thing","merged":{merged},
454            "head":{{"ref":"feature","sha":"0123456789abcdef0123456789abcdef01234567","repo":{{"full_name":"{head_repo}","fork":{}}}}},
455            "base":{{"ref":"main","sha":"fedcba","repo":{{"full_name":"acme/web"}}}}}},
456            "repository":{{"full_name":"acme/web"}}}}"#,
457            head_repo != "acme/web"
458        )
459    }
460
461    #[test]
462    fn github_pull_requests() {
463        let h = [("X-GitHub-Event", "pull_request")];
464        let p = pr_of(Provider::GitHub, &h, &github("opened", "acme/web", false));
465        assert_eq!(p.action, PrAction::Open);
466        assert_eq!(p.number, 42);
467        assert_eq!(p.head_ref, "feature");
468        assert_eq!(p.base_ref, "main");
469        assert_eq!(p.title, "Add a thing");
470        assert_eq!(
471            p.head_sha.as_deref(),
472            Some("0123456789abcdef0123456789abcdef01234567")
473        );
474        assert!(!p.fork);
475        let p = pr_of(Provider::GitHub, &h, &github("reopened", "acme/web", false));
476        assert_eq!(p.action, PrAction::Open);
477        let p = pr_of(
478            Provider::GitHub,
479            &h,
480            &github("synchronize", "acme/web", false),
481        );
482        assert_eq!(p.action, PrAction::Sync);
483        let p = pr_of(Provider::GitHub, &h, &github("closed", "acme/web", false));
484        assert_eq!(p.action, PrAction::Close { merged: false });
485        let p = pr_of(Provider::GitHub, &h, &github("closed", "acme/web", true));
486        assert_eq!(p.action, PrAction::Close { merged: true });
487        let p = pr_of(Provider::GitHub, &h, &github("labeled", "acme/web", false));
488        assert_eq!(p.action, PrAction::Other);
489        // From a fork; from a fork since deleted (head.repo null).
490        let p = pr_of(
491            Provider::GitHub,
492            &h,
493            &github("opened", "mallory/web", false),
494        );
495        assert!(p.fork);
496        let gone = r#"{"action":"synchronize","number":5,"pull_request":{"head":{"ref":"x","sha":"ab","repo":null},"base":{"ref":"main","repo":{"full_name":"acme/web"}}}}"#;
497        assert!(pr_of(Provider::GitHub, &h, gone).fork);
498        // Case differences in the name are the same repository.
499        let p = pr_of(Provider::GitHub, &h, &github("opened", "ACME/Web", false));
500        assert!(!p.fork);
501        // A pull request without a number is nothing to act on.
502        assert!(matches!(
503            event(Provider::GitHub, &headers(&h), br#"{"action":"opened"}"#),
504            Event::Other(_)
505        ));
506    }
507
508    #[test]
509    fn gitea_pull_requests() {
510        // Gitea/Forgejo send `pull_request` for open, sync and close, with
511        // `synchronized` (not GitHub's `synchronize`).
512        let body = |action: &str, head: &str, merged: bool| {
513            format!(
514                r#"{{"action":"{action}","number":7,"pull_request":{{"id":99,"number":7,"title":"t","merged":{merged},
515                "head":{{"label":"f","ref":"f","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repo_id":2,"repo":{{"id":2,"full_name":"{head}"}}}},
516                "base":{{"label":"main","ref":"main","sha":"b","repo_id":1,"repo":{{"id":1,"full_name":"isb/app"}}}}}},
517                "repository":{{"id":1,"full_name":"isb/app"}}}}"#
518            )
519        };
520        let h = [
521            ("X-Gitea-Event", "pull_request"),
522            ("X-Gitea-Event-Type", "pull_request"),
523        ];
524        let p = pr_of(Provider::Gitea, &h, &body("opened", "isb/app", false));
525        assert_eq!((p.action, p.number, p.fork), (PrAction::Open, 7, false));
526        let hs = [
527            ("X-Gitea-Event", "pull_request"),
528            ("X-Gitea-Event-Type", "pull_request_sync"),
529        ];
530        let p = pr_of(
531            Provider::Gitea,
532            &hs,
533            &body("synchronized", "isb/app", false),
534        );
535        assert_eq!(p.action, PrAction::Sync);
536        let p = pr_of(Provider::Gitea, &h, &body("closed", "isb/app", true));
537        assert_eq!(p.action, PrAction::Close { merged: true });
538        let p = pr_of(Provider::Gitea, &h, &body("opened", "someone/app", false));
539        assert!(p.fork);
540        let f = [("X-Forgejo-Event", "pull_request")];
541        assert_eq!(
542            pr_of(Provider::Gitea, &f, &body("reopened", "isb/app", false)).action,
543            PrAction::Open
544        );
545        // Reviews and comments on a pull request are other events.
546        let rv = [("X-Gitea-Event", "pull_request_approved")];
547        assert!(matches!(
548            event(
549                Provider::Gitea,
550                &headers(&rv),
551                body("reviewed", "isb/app", false).as_bytes()
552            ),
553            Event::Other(_)
554        ));
555    }
556
557    #[test]
558    fn gitlab_merge_requests() {
559        let body = |action: &str, oldrev: bool, src: u64| {
560            format!(
561                r#"{{"object_kind":"merge_request","project":{{"id":1,"path_with_namespace":"acme/web"}},
562                "object_attributes":{{"iid":12,"title":"MR","action":"{action}","state":"opened",
563                "source_branch":"feat","target_branch":"main","source_project_id":{src},"target_project_id":1,
564                {}"last_commit":{{"id":"cccccccccccccccccccccccccccccccccccccccc","message":"m"}}}}}}"#,
565                if oldrev {
566                    r#""oldrev":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","#
567                } else {
568                    ""
569                }
570            )
571        };
572        let h = [("X-Gitlab-Event", "Merge Request Hook")];
573        let p = pr_of(Provider::GitLab, &h, &body("open", false, 1));
574        assert_eq!((p.action, p.number, p.fork), (PrAction::Open, 12, false));
575        assert_eq!(p.head_ref, "feat");
576        assert_eq!(p.base_ref, "main");
577        assert_eq!(
578            p.head_sha.as_deref(),
579            Some("cccccccccccccccccccccccccccccccccccccccc")
580        );
581        assert_eq!(
582            pr_of(Provider::GitLab, &h, &body("reopen", false, 1)).action,
583            PrAction::Open
584        );
585        // An update with new commits carries oldrev; one without is a
586        // title or label change.
587        assert_eq!(
588            pr_of(Provider::GitLab, &h, &body("update", true, 1)).action,
589            PrAction::Sync
590        );
591        assert_eq!(
592            pr_of(Provider::GitLab, &h, &body("update", false, 1)).action,
593            PrAction::Other
594        );
595        assert_eq!(
596            pr_of(Provider::GitLab, &h, &body("close", false, 1)).action,
597            PrAction::Close { merged: false }
598        );
599        assert_eq!(
600            pr_of(Provider::GitLab, &h, &body("merge", false, 1)).action,
601            PrAction::Close { merged: true }
602        );
603        assert!(pr_of(Provider::GitLab, &h, &body("open", false, 2)).fork);
604    }
605}