Skip to main content

differential_engine/
forgeio.rs

1//! The forge adapters: `gh` for GitHub, `glab` for GitLab (ADR 0029,
2//! `spec/forge.md`).
3//!
4//! A forge is a tool on the path. Each tool is logged in by its own login
5//! flow, knows the remote's host and project from the working directory, and
6//! prints JSON for any endpoint — so this module holds no token, no hostname
7//! and no HTTP client. It runs the tool through the same runner the model
8//! backend uses, and maps the JSON it gets back onto `engine::forge`'s types.
9//!
10//! Every mapping is a pure function of a `serde_json::Value`, tested against
11//! recorded answers, so the shape of what a forge says is pinned here and a
12//! change to it fails a test rather than a review.
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use serde_json::{Value, json};
18
19use sha1::{Digest, Sha1};
20
21use crate::forge::{
22    Batch, Forge, ForgeError, ForgeKind, NewComment, NewReply, Published, RemoteComment,
23    RemoteThread, Request, Sent, marker, strip_marker,
24};
25use crate::subprocess;
26
27/// One command-line tool, run from the repository root with a deadline.
28///
29/// The root, because both tools resolve the remote from the directory they
30/// run in, exactly as `git` does.
31struct Tool {
32    /// The name on the path, or a path to the executable.
33    program: String,
34    working_dir: PathBuf,
35}
36
37/// Long enough for a paginated read of a large request over a slow link;
38/// short enough that a hung tool gives the reviewer back within a minute.
39const TOOL_TIMEOUT: Duration = Duration::from_secs(60);
40
41impl Tool {
42    fn new(program: &str, root: &Path) -> Self {
43        Tool {
44            program: program.to_string(),
45            working_dir: root.to_path_buf(),
46        }
47    }
48
49    /// Run the tool with these arguments and return its stdout.
50    fn run(&self, args: &[&str], stdin: Option<&[u8]>) -> Result<Vec<u8>, ForgeError> {
51        let argv: Vec<String> = std::iter::once(self.program.as_str())
52            .chain(args.iter().copied())
53            .map(str::to_string)
54            .collect();
55        let command = || argv.join(" ");
56        let out = subprocess::run(&subprocess::Run {
57            argv: &argv,
58            stdin,
59            working_dir: Some(&self.working_dir),
60            timeout: TOOL_TIMEOUT,
61            cancel: None,
62        })
63        .map_err(|f| match f {
64            subprocess::Failure::Spawn(source) => ForgeError::Spawn {
65                command: command(),
66                source,
67            },
68            subprocess::Failure::Io(source) => ForgeError::Io {
69                command: command(),
70                source,
71            },
72            subprocess::Failure::Timeout => ForgeError::Timeout {
73                command: command(),
74                timeout: TOOL_TIMEOUT,
75            },
76            subprocess::Failure::Cancelled => ForgeError::Cancelled { command: command() },
77        })?;
78        if !out.status.success() {
79            let output = [&out.stderr, &out.stdout]
80                .into_iter()
81                .map(|bytes| subprocess::stderr_excerpt(bytes, 600))
82                .filter(|s| !s.is_empty())
83                .collect::<Vec<_>>()
84                .join("\n");
85            return Err(ForgeError::Failed {
86                command: command(),
87                code: out.status.code(),
88                output,
89            });
90        }
91        Ok(out.stdout)
92    }
93
94    fn json(&self, args: &[&str], stdin: Option<&[u8]>) -> Result<Value, ForgeError> {
95        let bytes = self.run(args, stdin)?;
96        serde_json::from_slice(&bytes).map_err(|e| self.parse_err(args, e.to_string()))
97    }
98
99    /// Every JSON document on stdout, in order. A paginated call prints one
100    /// document per page, back to back.
101    fn json_stream(&self, args: &[&str]) -> Result<Vec<Value>, ForgeError> {
102        let bytes = self.run(args, None)?;
103        serde_json::Deserializer::from_slice(&bytes)
104            .into_iter::<Value>()
105            .collect::<Result<Vec<_>, _>>()
106            .map_err(|e| self.parse_err(args, e.to_string()))
107    }
108
109    fn parse_err(&self, args: &[&str], msg: String) -> ForgeError {
110        ForgeError::Parse {
111            command: format!("{} {}", self.program, args.join(" ")),
112            msg,
113        }
114    }
115
116    /// `DELETE` at a path. Not read as JSON: a delete answers with no body.
117    fn delete_at(&self, path: &str) -> Result<(), ForgeError> {
118        self.run(&["api", "--method", "DELETE", path], None)
119            .map(|_| ())
120    }
121
122    /// One REST call through `<tool> api` with the body as the tool's own
123    /// field flags — `-f` for a string, `-F` for a number, a bool or a JSON
124    /// object — which the tool sends as JSON with the content type set. A raw
125    /// body on stdin is sent as-is: `gh` labels it JSON, `glab` does not, and
126    /// GitLab answered `HTTP 415` on the first live write.
127    fn rest_fields(
128        &self,
129        method: &str,
130        path: &str,
131        fields: &[(&str, &Value)],
132    ) -> Result<Value, ForgeError> {
133        let args = field_args(method, path, fields);
134        let refs: Vec<&str> = args.iter().map(String::as_str).collect();
135        self.json(&refs, None)
136    }
137
138    /// One REST call through `<tool> api`, a JSON body on stdin when there is
139    /// one, the JSON answer back. For `gh`, which labels the body as JSON.
140    fn rest(&self, method: &str, path: &str, body: Option<&Value>) -> Result<Value, ForgeError> {
141        let mut args = vec!["api", "--method", method, path];
142        let text;
143        let stdin = match body {
144            Some(b) => {
145                args.extend(["--input", "-"]);
146                text = b.to_string();
147                Some(text.as_bytes())
148            }
149            None => None,
150        };
151        self.json(&args, stdin)
152    }
153}
154
155/// The argv of one `api` call with its body as field flags: `-f name=text`
156/// for a string, `-F name=value` for anything the tool should type — a
157/// number, a bool, a JSON object or array.
158fn field_args(method: &str, path: &str, fields: &[(&str, &Value)]) -> Vec<String> {
159    let mut args: Vec<String> = ["api", "--method", method, path]
160        .into_iter()
161        .map(str::to_string)
162        .collect();
163    for (name, value) in fields {
164        let (flag, text) = match value {
165            Value::String(s) => ("-f", s.clone()),
166            other => ("-F", other.to_string()),
167        };
168        args.push(flag.to_string());
169        args.push(format!("{name}={text}"));
170    }
171    args
172}
173
174/// Without a number the question was "which request is this branch", and
175/// "none" is an answer rather than a broken tool.
176fn no_request(err: ForgeError, id: Option<&str>, noun: &str) -> ForgeError {
177    match err {
178        ForgeError::Failed { output, .. } if id.is_none() => {
179            ForgeError::NoRequest(format!("the current branch has no {noun} ({output})"))
180        }
181        e => e,
182    }
183}
184
185fn parse_err(msg: impl Into<String>) -> ForgeError {
186    ForgeError::Parse {
187        command: "forge".into(),
188        msg: msg.into(),
189    }
190}
191
192fn str_of<'a>(v: &'a Value, key: &str) -> Result<&'a str, ForgeError> {
193    v.get(key)
194        .and_then(Value::as_str)
195        .ok_or_else(|| parse_err(format!("missing {key}")))
196}
197
198fn u32_at(v: &Value, pointer: &str) -> Option<u32> {
199    v.pointer(pointer).and_then(Value::as_u64).map(|n| n as u32)
200}
201
202fn u32_of(v: &Value, key: &str) -> Option<u32> {
203    v.get(key).and_then(Value::as_u64).map(|n| n as u32)
204}
205
206/// Whether a forge's record of a comment carries this marker in its body.
207fn has_marker(v: &Value, mark: &str) -> bool {
208    v.get("body")
209        .and_then(Value::as_str)
210        .is_some_and(|b| b.contains(mark))
211}
212
213/// A reply's record from the forge's answer to posting it, keyed back to its
214/// finding and thread.
215fn reply_published(r: &NewReply, answer: &Value) -> Published {
216    Published {
217        finding: r.finding.clone(),
218        thread: r.thread.clone(),
219        comment: answer
220            .get("id")
221            .and_then(Value::as_i64)
222            .map(|n| n.to_string())
223            .unwrap_or_default(),
224        url: answer
225            .get("html_url")
226            .and_then(Value::as_str)
227            .map(str::to_string),
228    }
229}
230
231// ===================================================================== GitHub
232
233/// GitHub, through `gh`.
234pub struct GhForge {
235    tool: Tool,
236}
237
238/// One page of review threads. GitHub caps a page at 100 and a request can
239/// carry more; the caller walks `pageInfo`.
240const THREADS_QUERY: &str = r#"
241query($owner: String!, $name: String!, $number: Int!, $after: String) {
242  repository(owner: $owner, name: $name) {
243    pullRequest(number: $number) {
244      reviewThreads(first: 100, after: $after) {
245        pageInfo { hasNextPage endCursor }
246        nodes {
247          id isResolved isOutdated path diffSide line startLine
248          comments(first: 100) {
249            nodes {
250              databaseId body createdAt diffHunk
251              author { login }
252              replyTo { databaseId }
253            }
254          }
255        }
256      }
257    }
258  }
259}"#;
260
261const RESOLVE_MUTATION: &str =
262    "mutation($id: ID!) { resolveReviewThread(input: {threadId: $id}) { thread { id } } }";
263const UNRESOLVE_MUTATION: &str =
264    "mutation($id: ID!) { unresolveReviewThread(input: {threadId: $id}) { thread { id } } }";
265
266impl GhForge {
267    pub fn new(root: &Path) -> Self {
268        GhForge {
269            tool: Tool::new("gh", root),
270        }
271    }
272
273    fn graphql(&self, query: &str, vars: &[(&str, Value)]) -> Result<Value, ForgeError> {
274        let body = json!({
275            "query": query,
276            "variables": vars
277                .iter()
278                .map(|(k, v)| ((*k).to_string(), v.clone()))
279                .collect::<serde_json::Map<String, Value>>(),
280        });
281        let v = self.tool.json(
282            &["api", "graphql", "--input", "-"],
283            Some(body.to_string().as_bytes()),
284        )?;
285        if let Some(errors) = v.get("errors").and_then(Value::as_array)
286            && !errors.is_empty()
287        {
288            let msg = errors
289                .iter()
290                .filter_map(|e| e.get("message").and_then(Value::as_str))
291                .collect::<Vec<_>>()
292                .join("; ");
293            return Err(ForgeError::Parse {
294                command: "gh api graphql".into(),
295                msg,
296            });
297        }
298        Ok(v)
299    }
300
301    fn pulls(req: &Request, tail: &str) -> String {
302        format!("repos/{}/pulls/{}{}", req.project, req.id, tail)
303    }
304
305    /// The comments of a review just submitted: the review's own answer names
306    /// none of them.
307    fn review_comments(&self, req: &Request, review: &Value) -> Result<Value, ForgeError> {
308        let review_id = review
309            .get("id")
310            .and_then(Value::as_i64)
311            .ok_or_else(|| parse_err("the review came back without an id"))?;
312        self.tool.rest(
313            "GET",
314            &Self::pulls(req, &format!("/reviews/{review_id}/comments")),
315            None,
316        )
317    }
318}
319
320impl Forge for GhForge {
321    fn kind(&self) -> ForgeKind {
322        ForgeKind::Github
323    }
324
325    fn whoami(&self) -> Result<String, ForgeError> {
326        let v = self.tool.json(&["api", "user"], None)?;
327        Ok(str_of(&v, "login")?.to_string())
328    }
329
330    fn request(&self, id: Option<&str>) -> Result<Request, ForgeError> {
331        let mut args = vec!["pr", "view"];
332        if let Some(id) = id {
333            args.push(id);
334        }
335        args.extend(["--json", "number,baseRefName,baseRefOid,headRefOid,url"]);
336        let v = self
337            .tool
338            .json(&args, None)
339            .map_err(|e| no_request(e, id, self.kind().noun()))?;
340        parse_request(&v)
341    }
342
343    fn threads(&self, req: &Request) -> Result<Vec<RemoteThread>, ForgeError> {
344        let (owner, name) = req
345            .project
346            .split_once('/')
347            .ok_or_else(|| parse_err(format!("project {:?} is not owner/repo", req.project)))?;
348        let number: i64 = req
349            .id
350            .parse()
351            .map_err(|_| parse_err(format!("pull request number {:?} is not a number", req.id)))?;
352        let mut all = Vec::new();
353        let mut after: Option<String> = None;
354        loop {
355            let v = self.graphql(
356                THREADS_QUERY,
357                &[
358                    ("owner", json!(owner)),
359                    ("name", json!(name)),
360                    ("number", json!(number)),
361                    ("after", after.as_deref().map_or(Value::Null, |s| json!(s))),
362                ],
363            )?;
364            let (page, next) = parse_threads_page(&v)?;
365            all.extend(page);
366            match next {
367                Some(cursor) => after = Some(cursor),
368                None => break,
369            }
370        }
371        Ok(all)
372    }
373
374    fn publish(&self, req: &Request, batch: &Batch) -> Result<Sent, ForgeError> {
375        if batch.is_empty() {
376            return Ok(Sent::default());
377        }
378        let mut sent = Sent::default();
379
380        // New comments: one review, so the author gets one notification. Up
381        // to this call nothing has left; from its answer on, a failure is
382        // reported in `sent`, never returned, or the caller would forget
383        // comments that are already live.
384        if !batch.comments.is_empty() {
385            let review = self.tool.rest(
386                "POST",
387                &Self::pulls(req, "/reviews"),
388                Some(&review_body(req, &batch.comments)),
389            )?;
390            match self.review_comments(req, &review) {
391                Ok(posted) => sent
392                    .published
393                    .extend(match_published(&batch.comments, &posted)),
394                Err(e) => {
395                    sent.failed = Some(e);
396                    return Ok(sent);
397                }
398            }
399        }
400
401        // Replies thread under the root comment, one call each.
402        for r in &batch.replies {
403            let v = match self.tool.rest(
404                "POST",
405                &Self::pulls(req, &format!("/comments/{}/replies", r.root_comment)),
406                Some(&json!({ "body": r.body })),
407            ) {
408                Ok(v) => v,
409                Err(e) if sent.published.is_empty() && batch.comments.is_empty() => return Err(e),
410                Err(e) => {
411                    sent.failed = Some(e);
412                    return Ok(sent);
413                }
414            };
415            sent.published.push(reply_published(r, &v));
416        }
417
418        // A new comment's thread id is GraphQL's, which REST never says. One
419        // fetch of the threads names every root — and is the fresh set the
420        // caller wants, so it is handed back rather than fetched twice.
421        match self.threads(req) {
422            Ok(threads) => {
423                for p in sent.published.iter_mut().filter(|p| p.thread.is_empty()) {
424                    if let Some(t) = threads
425                        .iter()
426                        .find(|t| t.root().is_some_and(|c| c.id == p.comment))
427                    {
428                        p.thread = t.id.clone();
429                    }
430                }
431                sent.threads = Some(threads);
432            }
433            Err(e) => sent.failed = Some(e),
434        }
435        Ok(sent)
436    }
437
438    fn set_resolved(&self, _req: &Request, thread: &str, resolved: bool) -> Result<(), ForgeError> {
439        let mutation = if resolved {
440            RESOLVE_MUTATION
441        } else {
442            UNRESOLVE_MUTATION
443        };
444        self.graphql(mutation, &[("id", json!(thread))])?;
445        Ok(())
446    }
447
448    fn edit_comment(
449        &self,
450        req: &Request,
451        _thread: &str,
452        comment: &str,
453        body: &str,
454    ) -> Result<(), ForgeError> {
455        self.tool.rest(
456            "PATCH",
457            &format!("repos/{}/pulls/comments/{comment}", req.project),
458            Some(&json!({ "body": body })),
459        )?;
460        Ok(())
461    }
462
463    fn delete_comment(
464        &self,
465        req: &Request,
466        _thread: &str,
467        comment: &str,
468    ) -> Result<(), ForgeError> {
469        self.tool
470            .delete_at(&format!("repos/{}/pulls/comments/{comment}", req.project))
471    }
472}
473
474/// `gh pr view --json number,baseRefName,baseRefOid,headRefOid,url`.
475///
476/// The project comes from the URL: the request lives in the base repository,
477/// and `gh pr view` names the head repository only.
478fn parse_request(v: &Value) -> Result<Request, ForgeError> {
479    let url = str_of(v, "url")?;
480    // `…/owner/repo/pull/123`, read from the right of the URL's PATH, so a
481    // query or a fragment is not mistaken for a segment.
482    let path = url_path(url)?;
483    let mut segments = path.rsplit('/').skip(2);
484    let repo = segments
485        .next()
486        .ok_or_else(|| parse_err("url has no repo"))?;
487    let owner = segments
488        .next()
489        .ok_or_else(|| parse_err("url has no owner"))?;
490    let number = v
491        .get("number")
492        .and_then(Value::as_i64)
493        .ok_or_else(|| parse_err("missing number"))?;
494    Ok(Request {
495        kind: ForgeKind::Github,
496        project: format!("{owner}/{repo}"),
497        id: number.to_string(),
498        base_ref: str_of(v, "baseRefName")?.to_string(),
499        base_tip: str_of(v, "baseRefOid")?.to_string(),
500        head: str_of(v, "headRefOid")?.to_string(),
501        merge_base: None,
502        url: url.to_string(),
503    })
504}
505
506/// A request URL's path, decoded, without its leading or trailing `/`.
507///
508/// `url` reads the URL, so a query, a fragment or a port cannot be mistaken
509/// for part of the path, and `percent_encoding` decodes it — a GitLab group
510/// may hold characters a URL has to escape.
511fn url_path(text: &str) -> Result<String, ForgeError> {
512    let parsed = url::Url::parse(text).map_err(|e| parse_err(format!("bad url {text:?}: {e}")))?;
513    let path = percent_encoding::percent_decode_str(parsed.path())
514        .decode_utf8()
515        .map_err(|_| parse_err("url path is not UTF-8"))?;
516    Ok(path.trim_matches('/').to_string())
517}
518
519/// One `reviewThreads` page: the threads, and the cursor of the next page.
520fn parse_threads_page(v: &Value) -> Result<(Vec<RemoteThread>, Option<String>), ForgeError> {
521    let conn = v
522        .pointer("/data/repository/pullRequest/reviewThreads")
523        .ok_or_else(|| parse_err("no reviewThreads in the answer"))?;
524    let next = conn
525        .pointer("/pageInfo/hasNextPage")
526        .and_then(Value::as_bool)
527        .unwrap_or(false)
528        .then(|| conn.pointer("/pageInfo/endCursor").and_then(Value::as_str))
529        .flatten()
530        .map(str::to_string);
531    let nodes = conn
532        .get("nodes")
533        .and_then(Value::as_array)
534        .ok_or_else(|| parse_err("reviewThreads has no nodes"))?;
535    let threads = nodes
536        .iter()
537        .map(parse_gh_thread)
538        .collect::<Result<Vec<_>, _>>()?;
539    Ok((threads, next))
540}
541
542fn parse_gh_thread(t: &Value) -> Result<RemoteThread, ForgeError> {
543    let comments = t
544        .pointer("/comments/nodes")
545        .and_then(Value::as_array)
546        .map(|nodes| {
547            nodes
548                .iter()
549                .map(parse_gh_comment)
550                .collect::<Result<Vec<_>, _>>()
551        })
552        .transpose()?
553        .unwrap_or_default();
554    // The text of the last line, from the root's diff hunk: the content key
555    // for a thread whose line has left the diff.
556    let line_text = t
557        .pointer("/comments/nodes/0/diffHunk")
558        .and_then(Value::as_str)
559        .and_then(last_diff_line);
560    let side = match t.get("diffSide").and_then(Value::as_str) {
561        Some("LEFT") => "old",
562        _ => "new",
563    };
564    Ok(RemoteThread {
565        id: str_of(t, "id")?.to_string(),
566        resolved: t
567            .get("isResolved")
568            .and_then(Value::as_bool)
569            .unwrap_or(false),
570        outdated: t
571            .get("isOutdated")
572            .and_then(Value::as_bool)
573            .unwrap_or(false),
574        path: str_of(t, "path")?.to_string(),
575        side: side.to_string(),
576        line: u32_of(t, "line"),
577        start_line: u32_of(t, "startLine"),
578        line_text,
579        anchor: None,
580        comments,
581    })
582}
583
584fn parse_gh_comment(c: &Value) -> Result<RemoteComment, ForgeError> {
585    let (body, finding) = strip_marker(str_of(c, "body")?);
586    Ok(RemoteComment {
587        id: c
588            .get("databaseId")
589            .and_then(Value::as_i64)
590            .ok_or_else(|| parse_err("comment without databaseId"))?
591            .to_string(),
592        author: c
593            .pointer("/author/login")
594            .and_then(Value::as_str)
595            .unwrap_or("(deleted)")
596            .to_string(),
597        created: str_of(c, "createdAt")?.to_string(),
598        body,
599        finding,
600    })
601}
602
603/// The content of the last line of a diff hunk, without its `+`/`-`/space.
604fn last_diff_line(hunk: &str) -> Option<String> {
605    let last = hunk
606        .lines()
607        .rev()
608        .find(|l| !l.is_empty() && !l.starts_with("@@"))?;
609    Some(last.get(1..).unwrap_or("").to_string())
610}
611
612/// The body of `POST /pulls/{n}/reviews`: a pending review submitted at once
613/// as a plain comment (a verdict is later work), against the head this review
614/// was opened on.
615fn review_body(req: &Request, comments: &[NewComment]) -> Value {
616    let side = |s: &str| if s == "old" { "LEFT" } else { "RIGHT" };
617    let items: Vec<Value> = comments
618        .iter()
619        .map(|c| {
620            let mut item = json!({
621                "path": c.path,
622                "body": c.body,
623                "line": c.line,
624                "side": side(&c.side),
625            });
626            if let Some(start) = c.start_line {
627                item["start_line"] = json!(start);
628                item["start_side"] = json!(side(&c.side));
629            }
630            item
631        })
632        .collect();
633    json!({
634        "commit_id": req.head,
635        "event": "COMMENT",
636        "body": "",
637        "comments": items,
638    })
639}
640
641/// Pair each sent comment with the record GitHub made of it, by the marker
642/// its body carries. The review's answer has no ids for its comments; the
643/// list of the review's comments does. The marker rather than path, line
644/// and body: an equality on the stored text matched nothing the first time
645/// this ran against the real forge, and a publish that cannot find what it
646/// sent is a publish that sends it again.
647fn match_published(sent: &[NewComment], posted: &Value) -> Vec<Published> {
648    let Some(posted) = posted.as_array() else {
649        return Vec::new();
650    };
651    sent.iter()
652        .filter_map(|c| {
653            let mark = marker(&c.finding);
654            let hit = posted.iter().find(|p| has_marker(p, &mark))?;
655            Some(Published {
656                finding: c.finding.clone(),
657                thread: String::new(),
658                comment: hit.get("id").and_then(Value::as_i64)?.to_string(),
659                url: hit
660                    .get("html_url")
661                    .and_then(Value::as_str)
662                    .map(str::to_string),
663            })
664        })
665        .collect()
666}
667
668// ===================================================================== GitLab
669
670/// GitLab, through `glab`.
671///
672/// `:id` in an endpoint is the tool's own placeholder for the project of the
673/// current directory, so no path here spells the project out.
674pub struct GlabForge {
675    tool: Tool,
676}
677
678impl GlabForge {
679    pub fn new(root: &Path) -> Self {
680        Self::with_tool("glab", root)
681    }
682
683    /// The same adapter over another executable: a scripted `glab` in a test.
684    fn with_tool(program: &str, root: &Path) -> Self {
685        GlabForge {
686            tool: Tool::new(program, root),
687        }
688    }
689
690    fn mr(req: &Request, tail: &str) -> String {
691        format!("projects/:id/merge_requests/{}{}", req.id, tail)
692    }
693
694    fn discussions(&self, req: &Request) -> Result<Vec<Value>, ForgeError> {
695        let path = Self::mr(req, "/discussions?per_page=100");
696        self.tool.json_stream(&["api", "--paginate", &path])
697    }
698}
699
700impl Forge for GlabForge {
701    fn kind(&self) -> ForgeKind {
702        ForgeKind::Gitlab
703    }
704
705    fn whoami(&self) -> Result<String, ForgeError> {
706        let v = self.tool.json(&["api", "user"], None)?;
707        Ok(str_of(&v, "username")?.to_string())
708    }
709
710    fn request(&self, id: Option<&str>) -> Result<Request, ForgeError> {
711        let mut args = vec!["mr", "view"];
712        if let Some(id) = id {
713            args.push(id);
714        }
715        args.extend(["--output", "json"]);
716        let v = self
717            .tool
718            .json(&args, None)
719            .map_err(|e| no_request(e, id, self.kind().noun()))?;
720        parse_mr(&v)
721    }
722
723    fn threads(&self, req: &Request) -> Result<Vec<RemoteThread>, ForgeError> {
724        let pages = self.discussions(req)?;
725        Ok(parse_discussions(&pages, &req.head))
726    }
727
728    fn publish(&self, req: &Request, batch: &Batch) -> Result<Sent, ForgeError> {
729        if batch.is_empty() {
730            return Ok(Sent::default());
731        }
732        let mut sent = Sent::default();
733        // Nothing is live until the first reply lands or the drafts are
734        // published; an error before that is `Err`. After it, the error
735        // rides in `sent.failed` behind whatever is already recorded.
736        let stop = |mut sent: Sent, e: ForgeError| {
737            if sent.published.is_empty() {
738                return Err(e);
739            }
740            sent.failed = Some(e);
741            Ok(sent)
742        };
743
744        // Replies first, one call each into their discussion: the note
745        // endpoint is the documented way to add to a thread, and it answers
746        // with the note, so each reply is on record the moment it lands and
747        // needs no fetch to be found again. A reply that fails stops the
748        // batch before any new comment has gone up, so nothing live is ever
749        // unrecorded. (A draft note with `in_reply_to_discussion_id` came out
750        // as a new discussion on the first live run.)
751        for r in &batch.replies {
752            let body = json!(r.body);
753            let v = match self.tool.rest_fields(
754                "POST",
755                &Self::mr(req, &format!("/discussions/{}/notes", r.thread)),
756                &[("body", &body)],
757            ) {
758                Ok(v) => v,
759                Err(e) => return stop(sent, e),
760            };
761            sent.published.push(reply_published(r, &v));
762        }
763        if batch.comments.is_empty() {
764            return Ok(sent);
765        }
766
767        // New comments: draft notes, then one publish, so the author is
768        // notified once, as a GitHub review notifies once. A draft is not
769        // live, so a failure before the publish leaves nothing to record —
770        // though the drafts already made stay on the request, unpublished,
771        // which the spec names as a limit.
772        for c in &batch.comments {
773            // The position goes in the query string as `position[key]=…`, not
774            // as a `position` object in the JSON body: this endpoint reads the
775            // hash only from bracket-encoded params, and a body object comes
776            // back "position[base_sha] is missing" for every key. The note,
777            // which carries newlines and the marker, stays a body field.
778            let query = encode_query(&draft_note_position(req, c));
779            let path = Self::mr(req, &format!("/draft_notes?{query}"));
780            let note = json!(c.body);
781            if let Err(e) = self.tool.rest_fields("POST", &path, &[("note", &note)]) {
782                return stop(sent, e);
783            }
784        }
785        let bulk = self.tool.run(
786            &[
787                "api",
788                "--method",
789                "POST",
790                &Self::mr(req, "/draft_notes/bulk_publish"),
791            ],
792            None,
793        );
794        if let Err(e) = bulk {
795            return stop(sent, e);
796        }
797
798        // Live from here. The bulk publish answers with nothing; the
799        // discussions, fetched once, hold every note that landed — and are
800        // the fresh set the caller wants, so they are handed back rather than
801        // fetched twice. If this fetch fails the notes are live and unnamed,
802        // and the markers name them on the next fetch.
803        match self.discussions(req) {
804            Ok(pages) => {
805                sent.published
806                    .extend(match_gitlab_published(&batch.comments, &pages));
807                sent.threads = Some(parse_discussions(&pages, &req.head));
808            }
809            Err(e) => sent.failed = Some(e),
810        }
811        Ok(sent)
812    }
813
814    fn set_resolved(&self, req: &Request, thread: &str, resolved: bool) -> Result<(), ForgeError> {
815        self.tool.rest_fields(
816            "PUT",
817            &Self::mr(req, &format!("/discussions/{thread}")),
818            &[("resolved", &json!(resolved))],
819        )?;
820        Ok(())
821    }
822
823    fn edit_comment(
824        &self,
825        req: &Request,
826        thread: &str,
827        comment: &str,
828        body: &str,
829    ) -> Result<(), ForgeError> {
830        self.tool.rest_fields(
831            "PUT",
832            &Self::mr(req, &format!("/discussions/{thread}/notes/{comment}")),
833            &[("body", &json!(body))],
834        )?;
835        Ok(())
836    }
837
838    fn delete_comment(&self, req: &Request, thread: &str, comment: &str) -> Result<(), ForgeError> {
839        self.tool.delete_at(&Self::mr(
840            req,
841            &format!("/discussions/{thread}/notes/{comment}"),
842        ))
843    }
844}
845
846/// `glab mr view --output json`: the merge request as the API describes it.
847///
848/// `diff_refs` is the three shas a position needs: the target branch tip when
849/// the diff was last computed (`start_sha`), the merge base (`base_sha`), and
850/// the head. The project is the URL's path up to `/-/`.
851fn parse_mr(v: &Value) -> Result<Request, ForgeError> {
852    let url = str_of(v, "web_url")?;
853    let path = url_path(url)?;
854    let project = path
855        .split_once("/-/")
856        .map(|(p, _)| p)
857        .ok_or_else(|| parse_err("web_url is not a merge request url"))?;
858    let iid = v
859        .get("iid")
860        .and_then(Value::as_i64)
861        .ok_or_else(|| parse_err("missing iid"))?;
862    let refs = v
863        .get("diff_refs")
864        .ok_or_else(|| parse_err("missing diff_refs"))?;
865    Ok(Request {
866        kind: ForgeKind::Gitlab,
867        project: project.to_string(),
868        id: iid.to_string(),
869        base_ref: str_of(v, "target_branch")?.to_string(),
870        base_tip: str_of(refs, "start_sha")?.to_string(),
871        head: str_of(refs, "head_sha")?.to_string(),
872        merge_base: Some(str_of(refs, "base_sha")?.to_string()),
873        url: url.to_string(),
874    })
875}
876
877/// Every page of `GET .../discussions`, as threads.
878///
879/// Only a discussion whose first note is a `DiffNote` with a text position is
880/// a thread here: a comment on the request itself has no line. System notes
881/// are dropped. A position recorded against another head is **outdated**: the
882/// REST answer carries no diff text to place it by, so it is counted, not
883/// drawn.
884fn parse_discussions(pages: &[Value], head: &str) -> Vec<RemoteThread> {
885    pages
886        .iter()
887        .filter_map(Value::as_array)
888        .flatten()
889        .filter_map(|d| parse_discussion(d, head))
890        .collect()
891}
892
893fn parse_discussion(d: &Value, head: &str) -> Option<RemoteThread> {
894    let notes: Vec<&Value> = d
895        .get("notes")?
896        .as_array()?
897        .iter()
898        .filter(|n| !n.get("system").and_then(Value::as_bool).unwrap_or(false))
899        .collect();
900    let first = *notes.first()?;
901    if first.get("type").and_then(Value::as_str) != Some("DiffNote") {
902        return None;
903    }
904    let pos = first.get("position")?;
905    if pos.get("position_type").and_then(Value::as_str) != Some("text") {
906        return None;
907    }
908    let new_line = u32_of(pos, "new_line");
909    let old_line = u32_of(pos, "old_line");
910    let (side, line) = match (new_line, old_line) {
911        (Some(n), _) => ("new", n),
912        (None, Some(o)) => ("old", o),
913        (None, None) => return None,
914    };
915    let start_line = u32_at(pos, &format!("/line_range/start/{side}_line")).filter(|s| *s < line);
916    let outdated = pos.get("head_sha").and_then(Value::as_str) != Some(head);
917    first.get("id").and_then(Value::as_i64)?;
918    let comments = notes
919        .iter()
920        .map(|n| {
921            let (body, finding) =
922                strip_marker(n.get("body").and_then(Value::as_str).unwrap_or_default());
923            RemoteComment {
924                id: n
925                    .get("id")
926                    .and_then(Value::as_i64)
927                    .map(|n| n.to_string())
928                    .unwrap_or_default(),
929                author: n
930                    .pointer("/author/username")
931                    .and_then(Value::as_str)
932                    .unwrap_or("(deleted)")
933                    .to_string(),
934                created: n
935                    .get("created_at")
936                    .and_then(Value::as_str)
937                    .unwrap_or_default()
938                    .to_string(),
939                body,
940                finding,
941            }
942        })
943        .collect();
944    Some(RemoteThread {
945        id: d.get("id")?.as_str()?.to_string(),
946        resolved: first
947            .get("resolved")
948            .and_then(Value::as_bool)
949            .unwrap_or(false),
950        outdated,
951        path: pos
952            .get("new_path")
953            .and_then(Value::as_str)
954            .or_else(|| pos.get("old_path").and_then(Value::as_str))?
955            .to_string(),
956        side: side.to_string(),
957        line: (!outdated).then_some(line),
958        start_line: if outdated { None } else { start_line },
959        line_text: None,
960        anchor: None,
961        comments,
962    })
963}
964
965/// `key=value&…`, each side percent-encoded. Building a query string by hand
966/// is where an unescaped `/` or `+` in a path or sha corrupts a request, so
967/// the encoder is `percent_encoding`, not `format!`. The set encodes every
968/// reserved character — the `[` `]` of a bracket key, the `/` of a path —
969/// and leaves the URL-unreserved `-_.~` alone.
970fn encode_query(fields: &[(String, String)]) -> String {
971    use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
972    const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
973        .remove(b'-')
974        .remove(b'_')
975        .remove(b'.')
976        .remove(b'~');
977    let enc = |s: &str| utf8_percent_encode(s, UNRESERVED).to_string();
978    fields
979        .iter()
980        .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
981        .collect::<Vec<_>>()
982        .join("&")
983}
984
985/// The `position[key]=value` fields of `POST .../draft_notes` for one new
986/// comment, in the order GitLab documents them.
987///
988/// A position names both paths and the three shas. A multi-line finding also
989/// carries a `position[line_range]`; `line_end` in `engine::forge` builds each
990/// end's `line_code` from the path's sha1 and both sides' numbers.
991fn draft_note_position(req: &Request, c: &NewComment) -> Vec<(String, String)> {
992    let mut fields = vec![
993        ("position[position_type]".into(), "text".into()),
994        (
995            "position[base_sha]".into(),
996            req.merge_base.clone().unwrap_or_default(),
997        ),
998        ("position[start_sha]".into(), req.base_tip.clone()),
999        ("position[head_sha]".into(), req.head.clone()),
1000        ("position[new_path]".into(), c.path.clone()),
1001        (
1002            "position[old_path]".into(),
1003            c.old_path.clone().unwrap_or_else(|| c.path.clone()),
1004        ),
1005    ];
1006    // GitLab wants one number for a changed line and both for an unchanged
1007    // one, which exists on both sides.
1008    let (mine, other) = if c.side == "old" {
1009        ("old_line", "new_line")
1010    } else {
1011        ("new_line", "old_line")
1012    };
1013    fields.push((format!("position[{mine}]"), c.line.to_string()));
1014    if let Some(o) = c.other_line {
1015        fields.push((format!("position[{other}]"), o.to_string()));
1016    }
1017    // A multi-line comment names each end: a `line_code`, a `type`, and the
1018    // real line number on each side the line exists — the shape GitLab's own
1019    // web UI sends. See `forge::line_end` for how the three kinds differ.
1020    if let Some(span) = &c.span {
1021        for (end, e) in [("start", &span.start), ("end", &span.end)] {
1022            let at = format!("position[line_range][{end}]");
1023            fields.push((format!("{at}[line_code]"), line_code(&c.path, e.old, e.new)));
1024            fields.push((format!("{at}[type]"), e.kind.into()));
1025            if let Some(n) = e.new_line {
1026                fields.push((format!("{at}[new_line]"), n.to_string()));
1027            }
1028            if let Some(o) = e.old_line {
1029                fields.push((format!("{at}[old_line]"), o.to_string()));
1030            }
1031        }
1032    }
1033    fields
1034}
1035
1036/// GitLab's id for a diff line: the sha1 of the file path, then the old and
1037/// new line numbers. `<sha>_<old>_<new>`, as the docs and the diff UI form it.
1038fn line_code(path: &str, old: u32, new: u32) -> String {
1039    let mut h = Sha1::new();
1040    h.update(path.as_bytes());
1041    format!("{}_{old}_{new}", hex::encode(h.finalize()))
1042}
1043
1044/// Pair each sent note with the discussion and note GitLab made of it, from
1045/// the discussions fetched after the publish, by the marker each body
1046/// carries.
1047fn match_gitlab_published(sent: &[NewComment], pages: &[Value]) -> Vec<Published> {
1048    let discussions: Vec<&Value> = pages.iter().filter_map(Value::as_array).flatten().collect();
1049    sent.iter()
1050        .filter_map(|c| {
1051            let mark = marker(&c.finding);
1052            let (thread, comment) = discussions.iter().find_map(|d| {
1053                let note = d
1054                    .get("notes")?
1055                    .as_array()?
1056                    .iter()
1057                    .find(|n| has_marker(n, &mark))?;
1058                Some((
1059                    d.get("id")?.as_str()?.to_string(),
1060                    note.get("id")?.as_i64()?.to_string(),
1061                ))
1062            })?;
1063            Some(Published {
1064                finding: c.finding.clone(),
1065                thread,
1066                comment,
1067                url: None,
1068            })
1069        })
1070        .collect()
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076    use crate::forge::with_marker;
1077
1078    #[test]
1079    fn a_pull_request_is_read_from_gh_pr_view() {
1080        let v = json!({
1081            "number": 84,
1082            "baseRefName": "main",
1083            "baseRefOid": "ecc9400aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1084            "headRefOid": "d5cd4feac46323dae75365481257bd71fb736603",
1085            "url": "https://github.com/owner/repo/pull/84"
1086        });
1087        let req = parse_request(&v).unwrap();
1088        assert_eq!(req.kind, ForgeKind::Github);
1089        assert_eq!(req.project, "owner/repo");
1090        assert_eq!(req.id, "84");
1091        assert_eq!(req.base_ref, "main");
1092        assert_eq!(req.head, "d5cd4feac46323dae75365481257bd71fb736603");
1093        assert_eq!(
1094            req.fetch_hint("origin"),
1095            "git fetch origin main pull/84/head"
1096        );
1097    }
1098
1099    /// The shape GitHub returned for a real thread page, cut to two threads.
1100    fn threads_page(has_next: bool) -> Value {
1101        json!({"data":{"repository":{"pullRequest":{"reviewThreads":{
1102        "pageInfo": {"hasNextPage": has_next, "endCursor": "Y3Vyc29y"},
1103        "nodes": [
1104            {"id":"PRRT_a","isResolved":true,"isOutdated":false,"line":39,"startLine":34,
1105             "diffSide":"RIGHT","path":"assets/record.vhs",
1106             "comments":{"nodes":[
1107                {"databaseId":3928619949_i64,"body":format!("root
1108
1109{}", marker("abc123")),"author":{"login":"alice"},
1110                 "createdAt":"2026-09-03T20:53:12Z","replyTo":null,
1111                 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo\n+Hide\n+Type \"y\""},
1112                {"databaseId":3928660390_i64,"body":"reply","author":{"login":"bob"},
1113                 "createdAt":"2026-09-03T20:58:44Z","replyTo":{"databaseId":3928619949_i64},
1114                 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo"}]}},
1115            {"id":"PRRT_b","isResolved":false,"isOutdated":true,"line":null,"startLine":null,
1116             "diffSide":"LEFT","path":"src/lib.rs",
1117             "comments":{"nodes":[
1118                {"databaseId":1,"body":"gone","author":null,
1119                 "createdAt":"2026-09-03T20:58:48Z","replyTo":null,
1120                 "diffHunk":"@@ -5,3 +5,2 @@\n line_5 = 5\n-line_6 = 6"}]}}
1121        ]}}}}})
1122    }
1123
1124    #[test]
1125    fn a_thread_page_maps_sides_lines_replies_and_the_last_diff_line() {
1126        let (threads, next) = parse_threads_page(&threads_page(true)).unwrap();
1127        assert_eq!(next.as_deref(), Some("Y3Vyc29y"));
1128        assert_eq!(threads.len(), 2);
1129
1130        let a = &threads[0];
1131        assert_eq!(a.id, "PRRT_a");
1132        assert!(a.resolved);
1133        assert_eq!(
1134            (a.side.as_str(), a.line, a.start_line),
1135            ("new", Some(39), Some(34))
1136        );
1137        assert_eq!(a.line_text.as_deref(), Some("Type \"y\""));
1138        assert_eq!(a.comments.len(), 2);
1139        assert_eq!(a.root().unwrap().id, "3928619949");
1140        assert_eq!(a.root().unwrap().body, "root");
1141        assert_eq!(a.root().unwrap().finding.as_deref(), Some("abc123"));
1142        assert_eq!(a.comments[1].finding, None);
1143        assert_eq!(a.comments[1].author, "bob");
1144
1145        let b = &threads[1];
1146        assert!(b.outdated);
1147        assert_eq!((b.side.as_str(), b.line), ("old", None));
1148        assert_eq!(b.line_text.as_deref(), Some("line_6 = 6"));
1149        assert_eq!(b.comments[0].author, "(deleted)");
1150
1151        let (_, none) = parse_threads_page(&threads_page(false)).unwrap();
1152        assert!(none.is_none());
1153    }
1154
1155    fn request() -> Request {
1156        Request {
1157            kind: ForgeKind::Github,
1158            project: "owner/repo".into(),
1159            id: "84".into(),
1160            base_ref: "main".into(),
1161            base_tip: "b".repeat(40),
1162            head: "h".repeat(40),
1163            merge_base: None,
1164            url: "https://github.com/owner/repo/pull/84".into(),
1165        }
1166    }
1167
1168    fn comment(finding: &str, side: &str, line: u32, start: Option<u32>, body: &str) -> NewComment {
1169        NewComment {
1170            finding: finding.into(),
1171            path: "src/lib.rs".into(),
1172            old_path: None,
1173            side: side.into(),
1174            line,
1175            start_line: start,
1176            other_line: None,
1177            span: None,
1178            body: body.into(),
1179        }
1180    }
1181
1182    #[test]
1183    fn a_review_body_is_one_comment_event_against_the_head() {
1184        let mut ranged = comment("f2", "old", 8, Some(6), "a range");
1185        ranged.old_path = Some("src/old.rs".into());
1186        let comments = vec![comment("f1", "new", 3, None, "one line"), ranged];
1187        let body = review_body(&request(), &comments);
1188        assert_eq!(body["commit_id"], json!("h".repeat(40)));
1189        assert_eq!(body["event"], json!("COMMENT"));
1190        let items = body["comments"].as_array().unwrap();
1191        assert_eq!(
1192            items[0],
1193            json!({"path":"src/lib.rs","body":"one line","line":3,"side":"RIGHT"})
1194        );
1195        assert_eq!(
1196            items[1],
1197            json!({"path":"src/lib.rs","body":"a range","line":8,"side":"LEFT",
1198                   "start_line":6,"start_side":"LEFT"})
1199        );
1200        // GitHub takes the new path only; the old path is GitLab's concern.
1201        assert!(items[1].get("old_path").is_none());
1202    }
1203
1204    #[test]
1205    fn posted_comments_are_matched_back_to_their_findings() {
1206        let sent = vec![
1207            {
1208                let mut c = comment("f1", "new", 3, None, "x");
1209                c.path = "a.rs".into();
1210                c
1211            },
1212            {
1213                let mut c = comment("f2", "new", 9, None, "y");
1214                c.path = "a.rs".into();
1215                c
1216            },
1217        ];
1218        // GitHub gives the body back reflowed; only the marker is trusted.
1219        let posted = json!([
1220            {"id": 11, "path": "a.rs", "line": 9, "html_url": "https://x/9",
1221             "body": format!("y\r\n\r\n{}", marker("f2"))},
1222            {"id": 10, "path": "a.rs", "line": 3, "html_url": "https://x/3",
1223             "body": format!("x\r\n\r\n{}", marker("f1"))},
1224            {"id": 12, "path": "a.rs", "line": 3, "html_url": "https://x/3b",
1225             "body": "x"},
1226        ]);
1227        let got = match_published(&sent, &posted);
1228        assert_eq!(got.len(), 2);
1229        assert_eq!(
1230            (got[0].finding.as_str(), got[0].comment.as_str()),
1231            ("f1", "10")
1232        );
1233        assert_eq!(
1234            (got[1].finding.as_str(), got[1].comment.as_str()),
1235            ("f2", "11")
1236        );
1237        assert_eq!(got[1].url.as_deref(), Some("https://x/9"));
1238        assert!(got[0].thread.is_empty(), "REST never names the thread");
1239    }
1240
1241    #[test]
1242    fn a_gitlab_write_goes_as_field_flags_typed_by_shape() {
1243        let position = json!({"new_line": 3, "new_path": "a.rs"});
1244        let args = field_args(
1245            "POST",
1246            "projects/:id/merge_requests/7/draft_notes",
1247            &[
1248                ("note", &json!("why?\n\n<!-- m -->")),
1249                ("position", &position),
1250                ("resolved", &json!(true)),
1251            ],
1252        );
1253        assert_eq!(
1254            args,
1255            vec![
1256                "api",
1257                "--method",
1258                "POST",
1259                "projects/:id/merge_requests/7/draft_notes",
1260                "-f",
1261                "note=why?\n\n<!-- m -->",
1262                "-F",
1263                "position={\"new_line\":3,\"new_path\":\"a.rs\"}",
1264                "-F",
1265                "resolved=true",
1266            ]
1267        );
1268    }
1269
1270    #[test]
1271    fn the_last_diff_line_drops_its_marker() {
1272        assert_eq!(
1273            last_diff_line("@@ -1 +1 @@\n-old\n+new"),
1274            Some("new".into())
1275        );
1276        assert_eq!(
1277            last_diff_line("@@ -1 +1 @@\n context"),
1278            Some("context".into())
1279        );
1280        assert_eq!(last_diff_line("@@ -1 +1 @@"), None);
1281    }
1282
1283    // ------------------------------------------------------------- GitLab
1284
1285    const HEAD: &str = "1111111111111111111111111111111111111111";
1286
1287    fn mr_view() -> Value {
1288        json!({
1289            "iid": 12,
1290            "web_url": "https://gitlab.example.com/group/sub/proj/-/merge_requests/12",
1291            "source_branch": "feature",
1292            "target_branch": "main",
1293            "sha": HEAD,
1294            "diff_refs": {
1295                "base_sha": "2222222222222222222222222222222222222222",
1296                "start_sha": "3333333333333333333333333333333333333333",
1297                "head_sha": HEAD
1298            }
1299        })
1300    }
1301
1302    #[test]
1303    fn a_request_url_is_read_as_a_url() {
1304        let pr = |url: &str| {
1305            let v = json!({
1306                "number": 84, "baseRefName": "main",
1307                "baseRefOid": "e".repeat(40), "headRefOid": "d".repeat(40), "url": url
1308            });
1309            parse_request(&v).map(|r| r.project)
1310        };
1311        // A query, a fragment, a trailing slash: none of them is the path.
1312        assert_eq!(
1313            pr("https://github.com/owner/repo/pull/84?w=1").unwrap(),
1314            "owner/repo"
1315        );
1316        assert_eq!(
1317            pr("https://github.com/owner/repo/pull/84#discussion").unwrap(),
1318            "owner/repo"
1319        );
1320        assert_eq!(
1321            pr("https://github.com/owner/repo/pull/84/").unwrap(),
1322            "owner/repo"
1323        );
1324        assert!(pr("not a url").is_err());
1325
1326        let mr = |url: &str| {
1327            let mut v = mr_view();
1328            v["web_url"] = json!(url);
1329            parse_mr(&v).map(|r| r.project)
1330        };
1331        assert_eq!(
1332            mr("https://gitlab.example.com:8443/group/sub/proj/-/merge_requests/12?tab=diffs")
1333                .unwrap(),
1334            "group/sub/proj"
1335        );
1336        assert_eq!(
1337            mr("https://gitlab.example.com/gr%C3%BCppe/proj/-/merge_requests/12").unwrap(),
1338            "grüppe/proj",
1339            "the path is decoded"
1340        );
1341    }
1342
1343    #[test]
1344    fn a_merge_request_is_read_from_glab_mr_view() {
1345        let req = parse_mr(&mr_view()).unwrap();
1346        assert_eq!(req.kind, ForgeKind::Gitlab);
1347        assert_eq!(req.project, "group/sub/proj");
1348        assert_eq!(req.id, "12");
1349        assert_eq!(req.base_ref, "main");
1350        assert_eq!(req.base_tip, "3".repeat(40));
1351        assert_eq!(req.head, HEAD);
1352        assert_eq!(req.merge_base.as_deref(), Some("2".repeat(40).as_str()));
1353        assert_eq!(
1354            req.fetch_hint("origin"),
1355            "git fetch origin main merge-requests/12/head"
1356        );
1357    }
1358
1359    fn note(id: i64, body: &str, author: &str, ty: Option<&str>, position: Option<Value>) -> Value {
1360        let mut n = json!({
1361            "id": id, "body": body, "system": false,
1362            "author": {"username": author},
1363            "created_at": "2026-09-04T09:00:00Z",
1364            "resolvable": true, "resolved": false,
1365        });
1366        n["type"] = ty.map_or(Value::Null, |t| json!(t));
1367        if let Some(p) = position {
1368            n["position"] = p;
1369        }
1370        n
1371    }
1372
1373    fn position(head: &str, new_line: Option<u32>, old_line: Option<u32>) -> Value {
1374        json!({
1375            "base_sha": "2".repeat(40), "start_sha": "3".repeat(40), "head_sha": head,
1376            "old_path": "src/lib.rs", "new_path": "src/lib.rs", "position_type": "text",
1377            "old_line": old_line, "new_line": new_line,
1378        })
1379    }
1380
1381    /// Two pages, as `--paginate` prints them: one array per page.
1382    fn discussion_pages() -> Vec<Value> {
1383        let mut ranged = position(HEAD, Some(8), None);
1384        ranged["line_range"] = json!({
1385            "start": {"new_line": 6, "old_line": null, "type": "new"},
1386            "end": {"new_line": 8, "old_line": null, "type": "new"},
1387        });
1388        vec![
1389            json!([
1390                {"id": "d1", "individual_note": false, "notes": [
1391                    note(101, &with_marker("why?", "f1"), "alice", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1392                    note(102, &with_marker("because", "f2"), "bob", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1393                ]},
1394                {"id": "d2", "individual_note": false, "notes": [
1395                    note(201, "old side", "carol", Some("DiffNote"), Some(position(HEAD, None, Some(5)))),
1396                ]},
1397                // A comment on the request itself: no line, not a thread.
1398                {"id": "d3", "individual_note": true, "notes": [
1399                    note(301, "looks good", "dave", None, None),
1400                ]},
1401            ]),
1402            json!([
1403                {"id": "d4", "individual_note": false, "notes": [
1404                    {"id": 401, "body": "changed the description", "system": true,
1405                     "author": {"username": "bot"}, "created_at": "2026-09-04T09:00:00Z"},
1406                    note(402, "stale", "erin", Some("DiffNote"), Some(position(&"0".repeat(40), Some(9), None))),
1407                ]},
1408                {"id": "d5", "individual_note": false, "notes": [
1409                    note(501, "range", "frank", Some("DiffNote"), Some(ranged)),
1410                ]},
1411            ]),
1412        ]
1413    }
1414
1415    #[test]
1416    fn discussions_become_threads_and_only_diff_notes_count() {
1417        let threads = parse_discussions(&discussion_pages(), HEAD);
1418        let ids: Vec<&str> = threads.iter().map(|t| t.id.as_str()).collect();
1419        assert_eq!(ids, vec!["d1", "d2", "d4", "d5"], "d3 has no line");
1420
1421        let d1 = &threads[0];
1422        assert_eq!(
1423            (d1.side.as_str(), d1.line, d1.start_line),
1424            ("new", Some(3), None)
1425        );
1426        assert_eq!(d1.comments.len(), 2);
1427        assert_eq!(d1.root().unwrap().id, "101");
1428        assert_eq!(d1.comments[1].author, "bob");
1429        // The marker is read and not shown.
1430        assert_eq!(d1.comments[0].body, "why?");
1431        assert_eq!(d1.comments[0].finding.as_deref(), Some("f1"));
1432        assert_eq!(d1.comments[1].finding.as_deref(), Some("f2"));
1433        assert_eq!(threads[1].comments[0].finding, None);
1434
1435        assert_eq!(
1436            (threads[1].side.as_str(), threads[1].line),
1437            ("old", Some(5))
1438        );
1439
1440        // Recorded against another head: outdated, and the system note is gone.
1441        let d4 = &threads[2];
1442        assert!(d4.outdated);
1443        assert_eq!(d4.line, None);
1444        assert_eq!(d4.comments.len(), 1);
1445        assert_eq!(d4.comments[0].author, "erin");
1446
1447        let d5 = &threads[3];
1448        assert_eq!((d5.line, d5.start_line), (Some(8), Some(6)));
1449    }
1450
1451    #[test]
1452    fn a_draft_note_positions_by_three_shas_and_both_paths() {
1453        let req = parse_mr(&mr_view()).unwrap();
1454        let mut c = comment("f1", "new", 3, None, "one line");
1455        c.old_path = Some("src/old.rs".into());
1456        let pos: std::collections::HashMap<String, String> =
1457            draft_note_position(&req, &c).into_iter().collect();
1458        assert_eq!(pos["position[position_type]"], "text");
1459        assert_eq!(pos["position[base_sha]"], "2".repeat(40));
1460        assert_eq!(pos["position[start_sha]"], "3".repeat(40));
1461        assert_eq!(pos["position[head_sha]"], HEAD);
1462        assert_eq!(pos["position[new_path]"], "src/lib.rs");
1463        assert_eq!(pos["position[old_path]"], "src/old.rs");
1464        assert_eq!(pos["position[new_line]"], "3");
1465        assert!(!pos.contains_key("position[old_line]"));
1466
1467        // An unchanged line carries both numbers.
1468        let mut ctx = comment("f3", "new", 12, None, "context");
1469        ctx.other_line = Some(11);
1470        let both: std::collections::HashMap<String, String> =
1471            draft_note_position(&req, &ctx).into_iter().collect();
1472        assert_eq!(both["position[new_line]"], "12");
1473        assert_eq!(both["position[old_line]"], "11");
1474
1475        // A range is positioned at its last line; the range itself rides in
1476        // line_range, so the note is the body verbatim with no prefix.
1477        let ranged = comment("f2", "old", 8, Some(6), "a range");
1478        let rpos: std::collections::HashMap<String, String> =
1479            draft_note_position(&req, &ranged).into_iter().collect();
1480        assert_eq!(rpos["position[old_line]"], "8");
1481        assert!(!rpos.contains_key("position[new_line]"));
1482
1483        // The position rides in the query string, not a body object; a path
1484        // with a slash is escaped so it cannot break the query.
1485        let query = encode_query(&draft_note_position(&req, &c));
1486        assert!(
1487            query.contains("position%5Bnew_path%5D=src%2Flib.rs"),
1488            "{query}"
1489        );
1490        assert!(!query.contains('/'), "{query}");
1491    }
1492
1493    #[test]
1494    fn a_multi_line_draft_note_carries_a_line_range_at_each_end() {
1495        use crate::forge::LineEnd;
1496        let req = parse_mr(&mr_view()).unwrap();
1497        let mut c = comment("f4", "new", 34, Some(15), "a run of lines");
1498        // Lines 15-34 are all added: `0` on the old side, the real number on
1499        // the new side, `new` type. This is the shape the web UI sends.
1500        c.span = Some(crate::forge::LineSpan {
1501            start: LineEnd {
1502                kind: "new",
1503                old: 0,
1504                new: 15,
1505                old_line: None,
1506                new_line: Some(15),
1507            },
1508            end: LineEnd {
1509                kind: "new",
1510                old: 0,
1511                new: 34,
1512                old_line: None,
1513                new_line: Some(34),
1514            },
1515        });
1516        let pos: std::collections::HashMap<String, String> =
1517            draft_note_position(&req, &c).into_iter().collect();
1518        let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1519        assert_eq!(pos["position[line_range][start][line_code]"], code(0, 15));
1520        assert_eq!(pos["position[line_range][start][type]"], "new");
1521        assert_eq!(pos["position[line_range][start][new_line]"], "15");
1522        assert!(!pos.contains_key("position[line_range][start][old_line]"));
1523        assert_eq!(pos["position[line_range][end][line_code]"], code(0, 34));
1524        assert_eq!(pos["position[line_range][end][new_line]"], "34");
1525        // The last line is still the anchor position.
1526        assert_eq!(pos["position[new_line]"], "34");
1527        // A line_code is the path's sha1, then the two numbers, `0` for the
1528        // side an added line is missing from.
1529        assert!(code(0, 34).ends_with("_0_34"), "{}", code(0, 34));
1530        assert_eq!(code(0, 34).len(), 40 + "_0_34".len());
1531    }
1532
1533    #[test]
1534    fn a_deleted_range_takes_the_new_side_position_not_zero() {
1535        use crate::forge::LineEnd;
1536        let req = parse_mr(&mr_view()).unwrap();
1537        let mut c = comment("f5", "old", 2953, Some(2950), "on a deleted run");
1538        // Deleted old lines 2950-2953 sit at new-side position 2952; the
1539        // `line_code`'s new number is that position, shared by both ends.
1540        c.span = Some(crate::forge::LineSpan {
1541            start: LineEnd {
1542                kind: "old",
1543                old: 2950,
1544                new: 2952,
1545                old_line: Some(2950),
1546                new_line: None,
1547            },
1548            end: LineEnd {
1549                kind: "old",
1550                old: 2953,
1551                new: 2952,
1552                old_line: Some(2953),
1553                new_line: None,
1554            },
1555        });
1556        let pos: std::collections::HashMap<String, String> =
1557            draft_note_position(&req, &c).into_iter().collect();
1558        let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1559        assert_eq!(
1560            pos["position[line_range][start][line_code]"],
1561            code(2950, 2952)
1562        );
1563        assert_eq!(pos["position[line_range][start][type]"], "old");
1564        assert_eq!(pos["position[line_range][start][old_line]"], "2950");
1565        assert!(!pos.contains_key("position[line_range][start][new_line]"));
1566        assert_eq!(
1567            pos["position[line_range][end][line_code]"],
1568            code(2953, 2952)
1569        );
1570    }
1571
1572    #[test]
1573    fn published_notes_are_matched_from_the_refetched_discussions() {
1574        let sent = vec![
1575            comment("f1", "new", 3, None, &with_marker("why?", "f1")),
1576            comment("f2", "new", 3, None, &with_marker("because", "f2")),
1577        ];
1578        let got = match_gitlab_published(&sent, &discussion_pages());
1579        assert_eq!(got.len(), 2);
1580        assert_eq!(
1581            (
1582                got[0].finding.as_str(),
1583                got[0].thread.as_str(),
1584                got[0].comment.as_str()
1585            ),
1586            ("f1", "d1", "101")
1587        );
1588        assert_eq!(
1589            (
1590                got[1].finding.as_str(),
1591                got[1].thread.as_str(),
1592                got[1].comment.as_str()
1593            ),
1594            ("f2", "d1", "102")
1595        );
1596    }
1597
1598    /// A `glab` that is a shell script: it logs each call to `calls`, reads
1599    /// which step fails from `mode`, and answers the discussions fetch with
1600    /// `discussion_pages()`. The adapter's ordering is the thing under test,
1601    /// and only a tool that fails on cue can show it.
1602    #[cfg(unix)]
1603    fn scripted_glab(dir: &Path) -> String {
1604        use std::os::unix::fs::PermissionsExt;
1605        let pages: Vec<String> = discussion_pages()
1606            .iter()
1607            .map(|p| serde_json::to_string(p).unwrap())
1608            .collect();
1609        std::fs::write(dir.join("pages.json"), pages.join("\n")).unwrap();
1610        let script = dir.join("glab");
1611        std::fs::write(
1612            &script,
1613            r#"#!/bin/sh
1614dir=$(dirname "$0")
1615printf '%s\n' "$*" >> "$dir/calls"
1616mode=$(cat "$dir/mode")
1617case "$*" in
1618  *"/discussions/bad/notes"*) echo '{"error":"body is invalid"}'; echo "glab: HTTP 400" >&2; exit 1 ;;
1619  *"/discussions/"*"/notes"*) echo '{"id": 555, "body": "because"}' ;;
1620  *"/draft_notes/bulk_publish"*)
1621    if [ "$mode" = "bulk-fails" ]; then echo "glab: HTTP 500" >&2; exit 1; fi ;;
1622  *"/draft_notes"*) echo '{"id": 9}' ;;
1623  *"--paginate"*) cat "$dir/pages.json" ;;
1624  *) echo "unexpected: $*" >&2; exit 2 ;;
1625esac
1626"#,
1627        )
1628        .unwrap();
1629        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1630        script.to_string_lossy().into_owned()
1631    }
1632
1633    #[cfg(unix)]
1634    fn calls(dir: &Path) -> String {
1635        std::fs::read_to_string(dir.join("calls")).unwrap_or_default()
1636    }
1637
1638    #[cfg(unix)]
1639    fn mixed_batch(reply_thread: &str) -> Batch {
1640        Batch {
1641            comments: vec![comment("f1", "new", 3, None, &with_marker("why?", "f1"))],
1642            replies: vec![NewReply {
1643                finding: "f2".into(),
1644                thread: reply_thread.into(),
1645                root_comment: "101".into(),
1646                body: with_marker("because", "f2"),
1647            }],
1648        }
1649    }
1650
1651    /// Whatever step fails, nothing that went live is left off the record:
1652    /// that record is what keeps the next publish from sending it again.
1653    #[test]
1654    #[cfg(unix)]
1655    fn a_gitlab_publish_records_everything_live_before_it_reports_a_failure() {
1656        let dir = tempfile::TempDir::new().unwrap();
1657        let glab = scripted_glab(dir.path());
1658        let forge = GlabForge::with_tool(&glab, dir.path());
1659        let req = parse_mr(&mr_view()).unwrap();
1660
1661        // The reply fails: nothing has gone up, so nothing is recorded and
1662        // the comments were never sent.
1663        std::fs::write(dir.path().join("mode"), "ok").unwrap();
1664        let err = forge.publish(&req, &mixed_batch("bad")).unwrap_err();
1665        // The status from stderr and the forge's reason from stdout, both.
1666        assert!(err.to_string().contains("HTTP 400"), "{err}");
1667        assert!(err.to_string().contains("body is invalid"), "{err}");
1668        assert!(
1669            !calls(dir.path()).contains("draft_notes"),
1670            "{}",
1671            calls(dir.path())
1672        );
1673
1674        // The bulk publish fails after the reply landed: the reply is on
1675        // record, the failure rides behind it, and no fetch was made for
1676        // notes that never went live.
1677        std::fs::remove_file(dir.path().join("calls")).unwrap();
1678        std::fs::write(dir.path().join("mode"), "bulk-fails").unwrap();
1679        let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1680        assert_eq!(sent.published.len(), 1);
1681        assert_eq!(
1682            (
1683                sent.published[0].finding.as_str(),
1684                sent.published[0].thread.as_str()
1685            ),
1686            ("f2", "d1")
1687        );
1688        assert_eq!(sent.published[0].comment, "555");
1689        assert!(sent.failed.is_some());
1690        assert!(sent.threads.is_none());
1691        let log = calls(dir.path());
1692        assert!(log.contains("draft_notes/bulk_publish"), "{log}");
1693        assert!(!log.contains("--paginate"), "{log}");
1694
1695        // Everything lands: the reply from its answer, the comment from the
1696        // discussions fetched once, which are also handed back.
1697        std::fs::write(dir.path().join("mode"), "ok").unwrap();
1698        let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1699        assert!(sent.failed.is_none());
1700        let mut named: Vec<(&str, &str, &str)> = sent
1701            .published
1702            .iter()
1703            .map(|p| (p.finding.as_str(), p.thread.as_str(), p.comment.as_str()))
1704            .collect();
1705        named.sort();
1706        assert_eq!(named, vec![("f1", "d1", "101"), ("f2", "d1", "555")]);
1707        assert_eq!(sent.threads.as_ref().map(Vec::len), Some(4));
1708    }
1709}