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.
481    let mut segments = url.trim_end_matches('/').rsplit('/').skip(2);
482    let repo = segments
483        .next()
484        .ok_or_else(|| parse_err("url has no repo"))?;
485    let owner = segments
486        .next()
487        .ok_or_else(|| parse_err("url has no owner"))?;
488    let number = v
489        .get("number")
490        .and_then(Value::as_i64)
491        .ok_or_else(|| parse_err("missing number"))?;
492    Ok(Request {
493        kind: ForgeKind::Github,
494        project: format!("{owner}/{repo}"),
495        id: number.to_string(),
496        base_ref: str_of(v, "baseRefName")?.to_string(),
497        base_tip: str_of(v, "baseRefOid")?.to_string(),
498        head: str_of(v, "headRefOid")?.to_string(),
499        merge_base: None,
500        url: url.to_string(),
501    })
502}
503
504/// One `reviewThreads` page: the threads, and the cursor of the next page.
505fn parse_threads_page(v: &Value) -> Result<(Vec<RemoteThread>, Option<String>), ForgeError> {
506    let conn = v
507        .pointer("/data/repository/pullRequest/reviewThreads")
508        .ok_or_else(|| parse_err("no reviewThreads in the answer"))?;
509    let next = conn
510        .pointer("/pageInfo/hasNextPage")
511        .and_then(Value::as_bool)
512        .unwrap_or(false)
513        .then(|| conn.pointer("/pageInfo/endCursor").and_then(Value::as_str))
514        .flatten()
515        .map(str::to_string);
516    let nodes = conn
517        .get("nodes")
518        .and_then(Value::as_array)
519        .ok_or_else(|| parse_err("reviewThreads has no nodes"))?;
520    let threads = nodes
521        .iter()
522        .map(parse_gh_thread)
523        .collect::<Result<Vec<_>, _>>()?;
524    Ok((threads, next))
525}
526
527fn parse_gh_thread(t: &Value) -> Result<RemoteThread, ForgeError> {
528    let comments = t
529        .pointer("/comments/nodes")
530        .and_then(Value::as_array)
531        .map(|nodes| {
532            nodes
533                .iter()
534                .map(parse_gh_comment)
535                .collect::<Result<Vec<_>, _>>()
536        })
537        .transpose()?
538        .unwrap_or_default();
539    // The text of the last line, from the root's diff hunk: the content key
540    // for a thread whose line has left the diff.
541    let line_text = t
542        .pointer("/comments/nodes/0/diffHunk")
543        .and_then(Value::as_str)
544        .and_then(last_diff_line);
545    let side = match t.get("diffSide").and_then(Value::as_str) {
546        Some("LEFT") => "old",
547        _ => "new",
548    };
549    Ok(RemoteThread {
550        id: str_of(t, "id")?.to_string(),
551        resolved: t
552            .get("isResolved")
553            .and_then(Value::as_bool)
554            .unwrap_or(false),
555        outdated: t
556            .get("isOutdated")
557            .and_then(Value::as_bool)
558            .unwrap_or(false),
559        path: str_of(t, "path")?.to_string(),
560        side: side.to_string(),
561        line: u32_of(t, "line"),
562        start_line: u32_of(t, "startLine"),
563        line_text,
564        anchor: None,
565        comments,
566    })
567}
568
569fn parse_gh_comment(c: &Value) -> Result<RemoteComment, ForgeError> {
570    let (body, finding) = strip_marker(str_of(c, "body")?);
571    Ok(RemoteComment {
572        id: c
573            .get("databaseId")
574            .and_then(Value::as_i64)
575            .ok_or_else(|| parse_err("comment without databaseId"))?
576            .to_string(),
577        author: c
578            .pointer("/author/login")
579            .and_then(Value::as_str)
580            .unwrap_or("(deleted)")
581            .to_string(),
582        created: str_of(c, "createdAt")?.to_string(),
583        body,
584        finding,
585    })
586}
587
588/// The content of the last line of a diff hunk, without its `+`/`-`/space.
589fn last_diff_line(hunk: &str) -> Option<String> {
590    let last = hunk
591        .lines()
592        .rev()
593        .find(|l| !l.is_empty() && !l.starts_with("@@"))?;
594    Some(last.get(1..).unwrap_or("").to_string())
595}
596
597/// The body of `POST /pulls/{n}/reviews`: a pending review submitted at once
598/// as a plain comment (a verdict is later work), against the head this review
599/// was opened on.
600fn review_body(req: &Request, comments: &[NewComment]) -> Value {
601    let side = |s: &str| if s == "old" { "LEFT" } else { "RIGHT" };
602    let items: Vec<Value> = comments
603        .iter()
604        .map(|c| {
605            let mut item = json!({
606                "path": c.path,
607                "body": c.body,
608                "line": c.line,
609                "side": side(&c.side),
610            });
611            if let Some(start) = c.start_line {
612                item["start_line"] = json!(start);
613                item["start_side"] = json!(side(&c.side));
614            }
615            item
616        })
617        .collect();
618    json!({
619        "commit_id": req.head,
620        "event": "COMMENT",
621        "body": "",
622        "comments": items,
623    })
624}
625
626/// Pair each sent comment with the record GitHub made of it, by the marker
627/// its body carries. The review's answer has no ids for its comments; the
628/// list of the review's comments does. The marker rather than path, line
629/// and body: an equality on the stored text matched nothing the first time
630/// this ran against the real forge, and a publish that cannot find what it
631/// sent is a publish that sends it again.
632fn match_published(sent: &[NewComment], posted: &Value) -> Vec<Published> {
633    let Some(posted) = posted.as_array() else {
634        return Vec::new();
635    };
636    sent.iter()
637        .filter_map(|c| {
638            let mark = marker(&c.finding);
639            let hit = posted.iter().find(|p| has_marker(p, &mark))?;
640            Some(Published {
641                finding: c.finding.clone(),
642                thread: String::new(),
643                comment: hit.get("id").and_then(Value::as_i64)?.to_string(),
644                url: hit
645                    .get("html_url")
646                    .and_then(Value::as_str)
647                    .map(str::to_string),
648            })
649        })
650        .collect()
651}
652
653// ===================================================================== GitLab
654
655/// GitLab, through `glab`.
656///
657/// `:id` in an endpoint is the tool's own placeholder for the project of the
658/// current directory, so no path here spells the project out.
659pub struct GlabForge {
660    tool: Tool,
661}
662
663impl GlabForge {
664    pub fn new(root: &Path) -> Self {
665        Self::with_tool("glab", root)
666    }
667
668    /// The same adapter over another executable: a scripted `glab` in a test.
669    fn with_tool(program: &str, root: &Path) -> Self {
670        GlabForge {
671            tool: Tool::new(program, root),
672        }
673    }
674
675    fn mr(req: &Request, tail: &str) -> String {
676        format!("projects/:id/merge_requests/{}{}", req.id, tail)
677    }
678
679    fn discussions(&self, req: &Request) -> Result<Vec<Value>, ForgeError> {
680        let path = Self::mr(req, "/discussions?per_page=100");
681        self.tool.json_stream(&["api", "--paginate", &path])
682    }
683}
684
685impl Forge for GlabForge {
686    fn kind(&self) -> ForgeKind {
687        ForgeKind::Gitlab
688    }
689
690    fn whoami(&self) -> Result<String, ForgeError> {
691        let v = self.tool.json(&["api", "user"], None)?;
692        Ok(str_of(&v, "username")?.to_string())
693    }
694
695    fn request(&self, id: Option<&str>) -> Result<Request, ForgeError> {
696        let mut args = vec!["mr", "view"];
697        if let Some(id) = id {
698            args.push(id);
699        }
700        args.extend(["--output", "json"]);
701        let v = self
702            .tool
703            .json(&args, None)
704            .map_err(|e| no_request(e, id, self.kind().noun()))?;
705        parse_mr(&v)
706    }
707
708    fn threads(&self, req: &Request) -> Result<Vec<RemoteThread>, ForgeError> {
709        let pages = self.discussions(req)?;
710        Ok(parse_discussions(&pages, &req.head))
711    }
712
713    fn publish(&self, req: &Request, batch: &Batch) -> Result<Sent, ForgeError> {
714        if batch.is_empty() {
715            return Ok(Sent::default());
716        }
717        let mut sent = Sent::default();
718        // Nothing is live until the first reply lands or the drafts are
719        // published; an error before that is `Err`. After it, the error
720        // rides in `sent.failed` behind whatever is already recorded.
721        let stop = |mut sent: Sent, e: ForgeError| {
722            if sent.published.is_empty() {
723                return Err(e);
724            }
725            sent.failed = Some(e);
726            Ok(sent)
727        };
728
729        // Replies first, one call each into their discussion: the note
730        // endpoint is the documented way to add to a thread, and it answers
731        // with the note, so each reply is on record the moment it lands and
732        // needs no fetch to be found again. A reply that fails stops the
733        // batch before any new comment has gone up, so nothing live is ever
734        // unrecorded. (A draft note with `in_reply_to_discussion_id` came out
735        // as a new discussion on the first live run.)
736        for r in &batch.replies {
737            let body = json!(r.body);
738            let v = match self.tool.rest_fields(
739                "POST",
740                &Self::mr(req, &format!("/discussions/{}/notes", r.thread)),
741                &[("body", &body)],
742            ) {
743                Ok(v) => v,
744                Err(e) => return stop(sent, e),
745            };
746            sent.published.push(reply_published(r, &v));
747        }
748        if batch.comments.is_empty() {
749            return Ok(sent);
750        }
751
752        // New comments: draft notes, then one publish, so the author is
753        // notified once, as a GitHub review notifies once. A draft is not
754        // live, so a failure before the publish leaves nothing to record —
755        // though the drafts already made stay on the request, unpublished,
756        // which the spec names as a limit.
757        for c in &batch.comments {
758            // The position goes in the query string as `position[key]=…`, not
759            // as a `position` object in the JSON body: this endpoint reads the
760            // hash only from bracket-encoded params, and a body object comes
761            // back "position[base_sha] is missing" for every key. The note,
762            // which carries newlines and the marker, stays a body field.
763            let query = encode_query(&draft_note_position(req, c));
764            let path = Self::mr(req, &format!("/draft_notes?{query}"));
765            let note = json!(c.body);
766            if let Err(e) = self.tool.rest_fields("POST", &path, &[("note", &note)]) {
767                return stop(sent, e);
768            }
769        }
770        let bulk = self.tool.run(
771            &[
772                "api",
773                "--method",
774                "POST",
775                &Self::mr(req, "/draft_notes/bulk_publish"),
776            ],
777            None,
778        );
779        if let Err(e) = bulk {
780            return stop(sent, e);
781        }
782
783        // Live from here. The bulk publish answers with nothing; the
784        // discussions, fetched once, hold every note that landed — and are
785        // the fresh set the caller wants, so they are handed back rather than
786        // fetched twice. If this fetch fails the notes are live and unnamed,
787        // and the markers name them on the next fetch.
788        match self.discussions(req) {
789            Ok(pages) => {
790                sent.published
791                    .extend(match_gitlab_published(&batch.comments, &pages));
792                sent.threads = Some(parse_discussions(&pages, &req.head));
793            }
794            Err(e) => sent.failed = Some(e),
795        }
796        Ok(sent)
797    }
798
799    fn set_resolved(&self, req: &Request, thread: &str, resolved: bool) -> Result<(), ForgeError> {
800        self.tool.rest_fields(
801            "PUT",
802            &Self::mr(req, &format!("/discussions/{thread}")),
803            &[("resolved", &json!(resolved))],
804        )?;
805        Ok(())
806    }
807
808    fn edit_comment(
809        &self,
810        req: &Request,
811        thread: &str,
812        comment: &str,
813        body: &str,
814    ) -> Result<(), ForgeError> {
815        self.tool.rest_fields(
816            "PUT",
817            &Self::mr(req, &format!("/discussions/{thread}/notes/{comment}")),
818            &[("body", &json!(body))],
819        )?;
820        Ok(())
821    }
822
823    fn delete_comment(&self, req: &Request, thread: &str, comment: &str) -> Result<(), ForgeError> {
824        self.tool.delete_at(&Self::mr(
825            req,
826            &format!("/discussions/{thread}/notes/{comment}"),
827        ))
828    }
829}
830
831/// `glab mr view --output json`: the merge request as the API describes it.
832///
833/// `diff_refs` is the three shas a position needs: the target branch tip when
834/// the diff was last computed (`start_sha`), the merge base (`base_sha`), and
835/// the head. The project is the URL's path up to `/-/`.
836fn parse_mr(v: &Value) -> Result<Request, ForgeError> {
837    let url = str_of(v, "web_url")?;
838    let path = url
839        .split_once("://")
840        .map(|(_, rest)| rest)
841        .and_then(|rest| rest.split_once('/'))
842        .map(|(_, path)| path)
843        .ok_or_else(|| parse_err("web_url has no path"))?;
844    let project = path
845        .split_once("/-/")
846        .map(|(p, _)| p)
847        .ok_or_else(|| parse_err("web_url is not a merge request url"))?;
848    let iid = v
849        .get("iid")
850        .and_then(Value::as_i64)
851        .ok_or_else(|| parse_err("missing iid"))?;
852    let refs = v
853        .get("diff_refs")
854        .ok_or_else(|| parse_err("missing diff_refs"))?;
855    Ok(Request {
856        kind: ForgeKind::Gitlab,
857        project: project.to_string(),
858        id: iid.to_string(),
859        base_ref: str_of(v, "target_branch")?.to_string(),
860        base_tip: str_of(refs, "start_sha")?.to_string(),
861        head: str_of(refs, "head_sha")?.to_string(),
862        merge_base: Some(str_of(refs, "base_sha")?.to_string()),
863        url: url.to_string(),
864    })
865}
866
867/// Every page of `GET .../discussions`, as threads.
868///
869/// Only a discussion whose first note is a `DiffNote` with a text position is
870/// a thread here: a comment on the request itself has no line. System notes
871/// are dropped. A position recorded against another head is **outdated**: the
872/// REST answer carries no diff text to place it by, so it is counted, not
873/// drawn.
874fn parse_discussions(pages: &[Value], head: &str) -> Vec<RemoteThread> {
875    pages
876        .iter()
877        .filter_map(Value::as_array)
878        .flatten()
879        .filter_map(|d| parse_discussion(d, head))
880        .collect()
881}
882
883fn parse_discussion(d: &Value, head: &str) -> Option<RemoteThread> {
884    let notes: Vec<&Value> = d
885        .get("notes")?
886        .as_array()?
887        .iter()
888        .filter(|n| !n.get("system").and_then(Value::as_bool).unwrap_or(false))
889        .collect();
890    let first = *notes.first()?;
891    if first.get("type").and_then(Value::as_str) != Some("DiffNote") {
892        return None;
893    }
894    let pos = first.get("position")?;
895    if pos.get("position_type").and_then(Value::as_str) != Some("text") {
896        return None;
897    }
898    let new_line = u32_of(pos, "new_line");
899    let old_line = u32_of(pos, "old_line");
900    let (side, line) = match (new_line, old_line) {
901        (Some(n), _) => ("new", n),
902        (None, Some(o)) => ("old", o),
903        (None, None) => return None,
904    };
905    let start_line = u32_at(pos, &format!("/line_range/start/{side}_line")).filter(|s| *s < line);
906    let outdated = pos.get("head_sha").and_then(Value::as_str) != Some(head);
907    first.get("id").and_then(Value::as_i64)?;
908    let comments = notes
909        .iter()
910        .map(|n| {
911            let (body, finding) =
912                strip_marker(n.get("body").and_then(Value::as_str).unwrap_or_default());
913            RemoteComment {
914                id: n
915                    .get("id")
916                    .and_then(Value::as_i64)
917                    .map(|n| n.to_string())
918                    .unwrap_or_default(),
919                author: n
920                    .pointer("/author/username")
921                    .and_then(Value::as_str)
922                    .unwrap_or("(deleted)")
923                    .to_string(),
924                created: n
925                    .get("created_at")
926                    .and_then(Value::as_str)
927                    .unwrap_or_default()
928                    .to_string(),
929                body,
930                finding,
931            }
932        })
933        .collect();
934    Some(RemoteThread {
935        id: d.get("id")?.as_str()?.to_string(),
936        resolved: first
937            .get("resolved")
938            .and_then(Value::as_bool)
939            .unwrap_or(false),
940        outdated,
941        path: pos
942            .get("new_path")
943            .and_then(Value::as_str)
944            .or_else(|| pos.get("old_path").and_then(Value::as_str))?
945            .to_string(),
946        side: side.to_string(),
947        line: (!outdated).then_some(line),
948        start_line: if outdated { None } else { start_line },
949        line_text: None,
950        anchor: None,
951        comments,
952    })
953}
954
955/// `key=value&…`, each side percent-encoded. Building a query string by hand
956/// is where an unescaped `/` or `+` in a path or sha corrupts a request, so
957/// the encoder is `percent_encoding`, not `format!`. The set encodes every
958/// reserved character — the `[` `]` of a bracket key, the `/` of a path —
959/// and leaves the URL-unreserved `-_.~` alone.
960fn encode_query(fields: &[(String, String)]) -> String {
961    use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
962    const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
963        .remove(b'-')
964        .remove(b'_')
965        .remove(b'.')
966        .remove(b'~');
967    let enc = |s: &str| utf8_percent_encode(s, UNRESERVED).to_string();
968    fields
969        .iter()
970        .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
971        .collect::<Vec<_>>()
972        .join("&")
973}
974
975/// The `position[key]=value` fields of `POST .../draft_notes` for one new
976/// comment, in the order GitLab documents them.
977///
978/// A position names both paths and the three shas. A multi-line finding also
979/// carries a `position[line_range]`; `line_end` in `engine::forge` builds each
980/// end's `line_code` from the path's sha1 and both sides' numbers.
981fn draft_note_position(req: &Request, c: &NewComment) -> Vec<(String, String)> {
982    let mut fields = vec![
983        ("position[position_type]".into(), "text".into()),
984        (
985            "position[base_sha]".into(),
986            req.merge_base.clone().unwrap_or_default(),
987        ),
988        ("position[start_sha]".into(), req.base_tip.clone()),
989        ("position[head_sha]".into(), req.head.clone()),
990        ("position[new_path]".into(), c.path.clone()),
991        (
992            "position[old_path]".into(),
993            c.old_path.clone().unwrap_or_else(|| c.path.clone()),
994        ),
995    ];
996    // GitLab wants one number for a changed line and both for an unchanged
997    // one, which exists on both sides.
998    let (mine, other) = if c.side == "old" {
999        ("old_line", "new_line")
1000    } else {
1001        ("new_line", "old_line")
1002    };
1003    fields.push((format!("position[{mine}]"), c.line.to_string()));
1004    if let Some(o) = c.other_line {
1005        fields.push((format!("position[{other}]"), o.to_string()));
1006    }
1007    // A multi-line comment names each end: a `line_code`, a `type`, and the
1008    // real line number on each side the line exists — the shape GitLab's own
1009    // web UI sends. See `forge::line_end` for how the three kinds differ.
1010    if let Some(span) = &c.span {
1011        for (end, e) in [("start", &span.start), ("end", &span.end)] {
1012            let at = format!("position[line_range][{end}]");
1013            fields.push((format!("{at}[line_code]"), line_code(&c.path, e.old, e.new)));
1014            fields.push((format!("{at}[type]"), e.kind.into()));
1015            if let Some(n) = e.new_line {
1016                fields.push((format!("{at}[new_line]"), n.to_string()));
1017            }
1018            if let Some(o) = e.old_line {
1019                fields.push((format!("{at}[old_line]"), o.to_string()));
1020            }
1021        }
1022    }
1023    fields
1024}
1025
1026/// GitLab's id for a diff line: the sha1 of the file path, then the old and
1027/// new line numbers. `<sha>_<old>_<new>`, as the docs and the diff UI form it.
1028fn line_code(path: &str, old: u32, new: u32) -> String {
1029    let mut h = Sha1::new();
1030    h.update(path.as_bytes());
1031    format!("{}_{old}_{new}", hex::encode(h.finalize()))
1032}
1033
1034/// Pair each sent note with the discussion and note GitLab made of it, from
1035/// the discussions fetched after the publish, by the marker each body
1036/// carries.
1037fn match_gitlab_published(sent: &[NewComment], pages: &[Value]) -> Vec<Published> {
1038    let discussions: Vec<&Value> = pages.iter().filter_map(Value::as_array).flatten().collect();
1039    sent.iter()
1040        .filter_map(|c| {
1041            let mark = marker(&c.finding);
1042            let (thread, comment) = discussions.iter().find_map(|d| {
1043                let note = d
1044                    .get("notes")?
1045                    .as_array()?
1046                    .iter()
1047                    .find(|n| has_marker(n, &mark))?;
1048                Some((
1049                    d.get("id")?.as_str()?.to_string(),
1050                    note.get("id")?.as_i64()?.to_string(),
1051                ))
1052            })?;
1053            Some(Published {
1054                finding: c.finding.clone(),
1055                thread,
1056                comment,
1057                url: None,
1058            })
1059        })
1060        .collect()
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066    use crate::forge::with_marker;
1067
1068    #[test]
1069    fn a_pull_request_is_read_from_gh_pr_view() {
1070        let v = json!({
1071            "number": 84,
1072            "baseRefName": "main",
1073            "baseRefOid": "ecc9400aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1074            "headRefOid": "d5cd4feac46323dae75365481257bd71fb736603",
1075            "url": "https://github.com/owner/repo/pull/84"
1076        });
1077        let req = parse_request(&v).unwrap();
1078        assert_eq!(req.kind, ForgeKind::Github);
1079        assert_eq!(req.project, "owner/repo");
1080        assert_eq!(req.id, "84");
1081        assert_eq!(req.base_ref, "main");
1082        assert_eq!(req.head, "d5cd4feac46323dae75365481257bd71fb736603");
1083        assert_eq!(
1084            req.fetch_hint("origin"),
1085            "git fetch origin main pull/84/head"
1086        );
1087    }
1088
1089    /// The shape GitHub returned for a real thread page, cut to two threads.
1090    fn threads_page(has_next: bool) -> Value {
1091        json!({"data":{"repository":{"pullRequest":{"reviewThreads":{
1092        "pageInfo": {"hasNextPage": has_next, "endCursor": "Y3Vyc29y"},
1093        "nodes": [
1094            {"id":"PRRT_a","isResolved":true,"isOutdated":false,"line":39,"startLine":34,
1095             "diffSide":"RIGHT","path":"assets/record.vhs",
1096             "comments":{"nodes":[
1097                {"databaseId":3928619949_i64,"body":format!("root
1098
1099{}", marker("abc123")),"author":{"login":"alice"},
1100                 "createdAt":"2026-09-03T20:53:12Z","replyTo":null,
1101                 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo\n+Hide\n+Type \"y\""},
1102                {"databaseId":3928660390_i64,"body":"reply","author":{"login":"bob"},
1103                 "createdAt":"2026-09-03T20:58:44Z","replyTo":{"databaseId":3928619949_i64},
1104                 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo"}]}},
1105            {"id":"PRRT_b","isResolved":false,"isOutdated":true,"line":null,"startLine":null,
1106             "diffSide":"LEFT","path":"src/lib.rs",
1107             "comments":{"nodes":[
1108                {"databaseId":1,"body":"gone","author":null,
1109                 "createdAt":"2026-09-03T20:58:48Z","replyTo":null,
1110                 "diffHunk":"@@ -5,3 +5,2 @@\n line_5 = 5\n-line_6 = 6"}]}}
1111        ]}}}}})
1112    }
1113
1114    #[test]
1115    fn a_thread_page_maps_sides_lines_replies_and_the_last_diff_line() {
1116        let (threads, next) = parse_threads_page(&threads_page(true)).unwrap();
1117        assert_eq!(next.as_deref(), Some("Y3Vyc29y"));
1118        assert_eq!(threads.len(), 2);
1119
1120        let a = &threads[0];
1121        assert_eq!(a.id, "PRRT_a");
1122        assert!(a.resolved);
1123        assert_eq!(
1124            (a.side.as_str(), a.line, a.start_line),
1125            ("new", Some(39), Some(34))
1126        );
1127        assert_eq!(a.line_text.as_deref(), Some("Type \"y\""));
1128        assert_eq!(a.comments.len(), 2);
1129        assert_eq!(a.root().unwrap().id, "3928619949");
1130        assert_eq!(a.root().unwrap().body, "root");
1131        assert_eq!(a.root().unwrap().finding.as_deref(), Some("abc123"));
1132        assert_eq!(a.comments[1].finding, None);
1133        assert_eq!(a.comments[1].author, "bob");
1134
1135        let b = &threads[1];
1136        assert!(b.outdated);
1137        assert_eq!((b.side.as_str(), b.line), ("old", None));
1138        assert_eq!(b.line_text.as_deref(), Some("line_6 = 6"));
1139        assert_eq!(b.comments[0].author, "(deleted)");
1140
1141        let (_, none) = parse_threads_page(&threads_page(false)).unwrap();
1142        assert!(none.is_none());
1143    }
1144
1145    fn request() -> Request {
1146        Request {
1147            kind: ForgeKind::Github,
1148            project: "owner/repo".into(),
1149            id: "84".into(),
1150            base_ref: "main".into(),
1151            base_tip: "b".repeat(40),
1152            head: "h".repeat(40),
1153            merge_base: None,
1154            url: "https://github.com/owner/repo/pull/84".into(),
1155        }
1156    }
1157
1158    fn comment(finding: &str, side: &str, line: u32, start: Option<u32>, body: &str) -> NewComment {
1159        NewComment {
1160            finding: finding.into(),
1161            path: "src/lib.rs".into(),
1162            old_path: None,
1163            side: side.into(),
1164            line,
1165            start_line: start,
1166            other_line: None,
1167            span: None,
1168            body: body.into(),
1169        }
1170    }
1171
1172    #[test]
1173    fn a_review_body_is_one_comment_event_against_the_head() {
1174        let mut ranged = comment("f2", "old", 8, Some(6), "a range");
1175        ranged.old_path = Some("src/old.rs".into());
1176        let comments = vec![comment("f1", "new", 3, None, "one line"), ranged];
1177        let body = review_body(&request(), &comments);
1178        assert_eq!(body["commit_id"], json!("h".repeat(40)));
1179        assert_eq!(body["event"], json!("COMMENT"));
1180        let items = body["comments"].as_array().unwrap();
1181        assert_eq!(
1182            items[0],
1183            json!({"path":"src/lib.rs","body":"one line","line":3,"side":"RIGHT"})
1184        );
1185        assert_eq!(
1186            items[1],
1187            json!({"path":"src/lib.rs","body":"a range","line":8,"side":"LEFT",
1188                   "start_line":6,"start_side":"LEFT"})
1189        );
1190        // GitHub takes the new path only; the old path is GitLab's concern.
1191        assert!(items[1].get("old_path").is_none());
1192    }
1193
1194    #[test]
1195    fn posted_comments_are_matched_back_to_their_findings() {
1196        let sent = vec![
1197            {
1198                let mut c = comment("f1", "new", 3, None, "x");
1199                c.path = "a.rs".into();
1200                c
1201            },
1202            {
1203                let mut c = comment("f2", "new", 9, None, "y");
1204                c.path = "a.rs".into();
1205                c
1206            },
1207        ];
1208        // GitHub gives the body back reflowed; only the marker is trusted.
1209        let posted = json!([
1210            {"id": 11, "path": "a.rs", "line": 9, "html_url": "https://x/9",
1211             "body": format!("y\r\n\r\n{}", marker("f2"))},
1212            {"id": 10, "path": "a.rs", "line": 3, "html_url": "https://x/3",
1213             "body": format!("x\r\n\r\n{}", marker("f1"))},
1214            {"id": 12, "path": "a.rs", "line": 3, "html_url": "https://x/3b",
1215             "body": "x"},
1216        ]);
1217        let got = match_published(&sent, &posted);
1218        assert_eq!(got.len(), 2);
1219        assert_eq!(
1220            (got[0].finding.as_str(), got[0].comment.as_str()),
1221            ("f1", "10")
1222        );
1223        assert_eq!(
1224            (got[1].finding.as_str(), got[1].comment.as_str()),
1225            ("f2", "11")
1226        );
1227        assert_eq!(got[1].url.as_deref(), Some("https://x/9"));
1228        assert!(got[0].thread.is_empty(), "REST never names the thread");
1229    }
1230
1231    #[test]
1232    fn a_gitlab_write_goes_as_field_flags_typed_by_shape() {
1233        let position = json!({"new_line": 3, "new_path": "a.rs"});
1234        let args = field_args(
1235            "POST",
1236            "projects/:id/merge_requests/7/draft_notes",
1237            &[
1238                ("note", &json!("why?\n\n<!-- m -->")),
1239                ("position", &position),
1240                ("resolved", &json!(true)),
1241            ],
1242        );
1243        assert_eq!(
1244            args,
1245            vec![
1246                "api",
1247                "--method",
1248                "POST",
1249                "projects/:id/merge_requests/7/draft_notes",
1250                "-f",
1251                "note=why?\n\n<!-- m -->",
1252                "-F",
1253                "position={\"new_line\":3,\"new_path\":\"a.rs\"}",
1254                "-F",
1255                "resolved=true",
1256            ]
1257        );
1258    }
1259
1260    #[test]
1261    fn the_last_diff_line_drops_its_marker() {
1262        assert_eq!(
1263            last_diff_line("@@ -1 +1 @@\n-old\n+new"),
1264            Some("new".into())
1265        );
1266        assert_eq!(
1267            last_diff_line("@@ -1 +1 @@\n context"),
1268            Some("context".into())
1269        );
1270        assert_eq!(last_diff_line("@@ -1 +1 @@"), None);
1271    }
1272
1273    // ------------------------------------------------------------- GitLab
1274
1275    const HEAD: &str = "1111111111111111111111111111111111111111";
1276
1277    fn mr_view() -> Value {
1278        json!({
1279            "iid": 12,
1280            "web_url": "https://gitlab.example.com/group/sub/proj/-/merge_requests/12",
1281            "source_branch": "feature",
1282            "target_branch": "main",
1283            "sha": HEAD,
1284            "diff_refs": {
1285                "base_sha": "2222222222222222222222222222222222222222",
1286                "start_sha": "3333333333333333333333333333333333333333",
1287                "head_sha": HEAD
1288            }
1289        })
1290    }
1291
1292    #[test]
1293    fn a_merge_request_is_read_from_glab_mr_view() {
1294        let req = parse_mr(&mr_view()).unwrap();
1295        assert_eq!(req.kind, ForgeKind::Gitlab);
1296        assert_eq!(req.project, "group/sub/proj");
1297        assert_eq!(req.id, "12");
1298        assert_eq!(req.base_ref, "main");
1299        assert_eq!(req.base_tip, "3".repeat(40));
1300        assert_eq!(req.head, HEAD);
1301        assert_eq!(req.merge_base.as_deref(), Some("2".repeat(40).as_str()));
1302        assert_eq!(
1303            req.fetch_hint("origin"),
1304            "git fetch origin main merge-requests/12/head"
1305        );
1306    }
1307
1308    fn note(id: i64, body: &str, author: &str, ty: Option<&str>, position: Option<Value>) -> Value {
1309        let mut n = json!({
1310            "id": id, "body": body, "system": false,
1311            "author": {"username": author},
1312            "created_at": "2026-09-04T09:00:00Z",
1313            "resolvable": true, "resolved": false,
1314        });
1315        n["type"] = ty.map_or(Value::Null, |t| json!(t));
1316        if let Some(p) = position {
1317            n["position"] = p;
1318        }
1319        n
1320    }
1321
1322    fn position(head: &str, new_line: Option<u32>, old_line: Option<u32>) -> Value {
1323        json!({
1324            "base_sha": "2".repeat(40), "start_sha": "3".repeat(40), "head_sha": head,
1325            "old_path": "src/lib.rs", "new_path": "src/lib.rs", "position_type": "text",
1326            "old_line": old_line, "new_line": new_line,
1327        })
1328    }
1329
1330    /// Two pages, as `--paginate` prints them: one array per page.
1331    fn discussion_pages() -> Vec<Value> {
1332        let mut ranged = position(HEAD, Some(8), None);
1333        ranged["line_range"] = json!({
1334            "start": {"new_line": 6, "old_line": null, "type": "new"},
1335            "end": {"new_line": 8, "old_line": null, "type": "new"},
1336        });
1337        vec![
1338            json!([
1339                {"id": "d1", "individual_note": false, "notes": [
1340                    note(101, &with_marker("why?", "f1"), "alice", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1341                    note(102, &with_marker("because", "f2"), "bob", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1342                ]},
1343                {"id": "d2", "individual_note": false, "notes": [
1344                    note(201, "old side", "carol", Some("DiffNote"), Some(position(HEAD, None, Some(5)))),
1345                ]},
1346                // A comment on the request itself: no line, not a thread.
1347                {"id": "d3", "individual_note": true, "notes": [
1348                    note(301, "looks good", "dave", None, None),
1349                ]},
1350            ]),
1351            json!([
1352                {"id": "d4", "individual_note": false, "notes": [
1353                    {"id": 401, "body": "changed the description", "system": true,
1354                     "author": {"username": "bot"}, "created_at": "2026-09-04T09:00:00Z"},
1355                    note(402, "stale", "erin", Some("DiffNote"), Some(position(&"0".repeat(40), Some(9), None))),
1356                ]},
1357                {"id": "d5", "individual_note": false, "notes": [
1358                    note(501, "range", "frank", Some("DiffNote"), Some(ranged)),
1359                ]},
1360            ]),
1361        ]
1362    }
1363
1364    #[test]
1365    fn discussions_become_threads_and_only_diff_notes_count() {
1366        let threads = parse_discussions(&discussion_pages(), HEAD);
1367        let ids: Vec<&str> = threads.iter().map(|t| t.id.as_str()).collect();
1368        assert_eq!(ids, vec!["d1", "d2", "d4", "d5"], "d3 has no line");
1369
1370        let d1 = &threads[0];
1371        assert_eq!(
1372            (d1.side.as_str(), d1.line, d1.start_line),
1373            ("new", Some(3), None)
1374        );
1375        assert_eq!(d1.comments.len(), 2);
1376        assert_eq!(d1.root().unwrap().id, "101");
1377        assert_eq!(d1.comments[1].author, "bob");
1378        // The marker is read and not shown.
1379        assert_eq!(d1.comments[0].body, "why?");
1380        assert_eq!(d1.comments[0].finding.as_deref(), Some("f1"));
1381        assert_eq!(d1.comments[1].finding.as_deref(), Some("f2"));
1382        assert_eq!(threads[1].comments[0].finding, None);
1383
1384        assert_eq!(
1385            (threads[1].side.as_str(), threads[1].line),
1386            ("old", Some(5))
1387        );
1388
1389        // Recorded against another head: outdated, and the system note is gone.
1390        let d4 = &threads[2];
1391        assert!(d4.outdated);
1392        assert_eq!(d4.line, None);
1393        assert_eq!(d4.comments.len(), 1);
1394        assert_eq!(d4.comments[0].author, "erin");
1395
1396        let d5 = &threads[3];
1397        assert_eq!((d5.line, d5.start_line), (Some(8), Some(6)));
1398    }
1399
1400    #[test]
1401    fn a_draft_note_positions_by_three_shas_and_both_paths() {
1402        let req = parse_mr(&mr_view()).unwrap();
1403        let mut c = comment("f1", "new", 3, None, "one line");
1404        c.old_path = Some("src/old.rs".into());
1405        let pos: std::collections::HashMap<String, String> =
1406            draft_note_position(&req, &c).into_iter().collect();
1407        assert_eq!(pos["position[position_type]"], "text");
1408        assert_eq!(pos["position[base_sha]"], "2".repeat(40));
1409        assert_eq!(pos["position[start_sha]"], "3".repeat(40));
1410        assert_eq!(pos["position[head_sha]"], HEAD);
1411        assert_eq!(pos["position[new_path]"], "src/lib.rs");
1412        assert_eq!(pos["position[old_path]"], "src/old.rs");
1413        assert_eq!(pos["position[new_line]"], "3");
1414        assert!(!pos.contains_key("position[old_line]"));
1415
1416        // An unchanged line carries both numbers.
1417        let mut ctx = comment("f3", "new", 12, None, "context");
1418        ctx.other_line = Some(11);
1419        let both: std::collections::HashMap<String, String> =
1420            draft_note_position(&req, &ctx).into_iter().collect();
1421        assert_eq!(both["position[new_line]"], "12");
1422        assert_eq!(both["position[old_line]"], "11");
1423
1424        // A range is positioned at its last line; the range itself rides in
1425        // line_range, so the note is the body verbatim with no prefix.
1426        let ranged = comment("f2", "old", 8, Some(6), "a range");
1427        let rpos: std::collections::HashMap<String, String> =
1428            draft_note_position(&req, &ranged).into_iter().collect();
1429        assert_eq!(rpos["position[old_line]"], "8");
1430        assert!(!rpos.contains_key("position[new_line]"));
1431
1432        // The position rides in the query string, not a body object; a path
1433        // with a slash is escaped so it cannot break the query.
1434        let query = encode_query(&draft_note_position(&req, &c));
1435        assert!(
1436            query.contains("position%5Bnew_path%5D=src%2Flib.rs"),
1437            "{query}"
1438        );
1439        assert!(!query.contains('/'), "{query}");
1440    }
1441
1442    #[test]
1443    fn a_multi_line_draft_note_carries_a_line_range_at_each_end() {
1444        use crate::forge::LineEnd;
1445        let req = parse_mr(&mr_view()).unwrap();
1446        let mut c = comment("f4", "new", 34, Some(15), "a run of lines");
1447        // Lines 15-34 are all added: `0` on the old side, the real number on
1448        // the new side, `new` type. This is the shape the web UI sends.
1449        c.span = Some(crate::forge::LineSpan {
1450            start: LineEnd {
1451                kind: "new",
1452                old: 0,
1453                new: 15,
1454                old_line: None,
1455                new_line: Some(15),
1456            },
1457            end: LineEnd {
1458                kind: "new",
1459                old: 0,
1460                new: 34,
1461                old_line: None,
1462                new_line: Some(34),
1463            },
1464        });
1465        let pos: std::collections::HashMap<String, String> =
1466            draft_note_position(&req, &c).into_iter().collect();
1467        let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1468        assert_eq!(pos["position[line_range][start][line_code]"], code(0, 15));
1469        assert_eq!(pos["position[line_range][start][type]"], "new");
1470        assert_eq!(pos["position[line_range][start][new_line]"], "15");
1471        assert!(!pos.contains_key("position[line_range][start][old_line]"));
1472        assert_eq!(pos["position[line_range][end][line_code]"], code(0, 34));
1473        assert_eq!(pos["position[line_range][end][new_line]"], "34");
1474        // The last line is still the anchor position.
1475        assert_eq!(pos["position[new_line]"], "34");
1476        // A line_code is the path's sha1, then the two numbers, `0` for the
1477        // side an added line is missing from.
1478        assert!(code(0, 34).ends_with("_0_34"), "{}", code(0, 34));
1479        assert_eq!(code(0, 34).len(), 40 + "_0_34".len());
1480    }
1481
1482    #[test]
1483    fn a_deleted_range_takes_the_new_side_position_not_zero() {
1484        use crate::forge::LineEnd;
1485        let req = parse_mr(&mr_view()).unwrap();
1486        let mut c = comment("f5", "old", 2953, Some(2950), "on a deleted run");
1487        // Deleted old lines 2950-2953 sit at new-side position 2952; the
1488        // `line_code`'s new number is that position, shared by both ends.
1489        c.span = Some(crate::forge::LineSpan {
1490            start: LineEnd {
1491                kind: "old",
1492                old: 2950,
1493                new: 2952,
1494                old_line: Some(2950),
1495                new_line: None,
1496            },
1497            end: LineEnd {
1498                kind: "old",
1499                old: 2953,
1500                new: 2952,
1501                old_line: Some(2953),
1502                new_line: None,
1503            },
1504        });
1505        let pos: std::collections::HashMap<String, String> =
1506            draft_note_position(&req, &c).into_iter().collect();
1507        let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1508        assert_eq!(
1509            pos["position[line_range][start][line_code]"],
1510            code(2950, 2952)
1511        );
1512        assert_eq!(pos["position[line_range][start][type]"], "old");
1513        assert_eq!(pos["position[line_range][start][old_line]"], "2950");
1514        assert!(!pos.contains_key("position[line_range][start][new_line]"));
1515        assert_eq!(
1516            pos["position[line_range][end][line_code]"],
1517            code(2953, 2952)
1518        );
1519    }
1520
1521    #[test]
1522    fn published_notes_are_matched_from_the_refetched_discussions() {
1523        let sent = vec![
1524            comment("f1", "new", 3, None, &with_marker("why?", "f1")),
1525            comment("f2", "new", 3, None, &with_marker("because", "f2")),
1526        ];
1527        let got = match_gitlab_published(&sent, &discussion_pages());
1528        assert_eq!(got.len(), 2);
1529        assert_eq!(
1530            (
1531                got[0].finding.as_str(),
1532                got[0].thread.as_str(),
1533                got[0].comment.as_str()
1534            ),
1535            ("f1", "d1", "101")
1536        );
1537        assert_eq!(
1538            (
1539                got[1].finding.as_str(),
1540                got[1].thread.as_str(),
1541                got[1].comment.as_str()
1542            ),
1543            ("f2", "d1", "102")
1544        );
1545    }
1546
1547    /// A `glab` that is a shell script: it logs each call to `calls`, reads
1548    /// which step fails from `mode`, and answers the discussions fetch with
1549    /// `discussion_pages()`. The adapter's ordering is the thing under test,
1550    /// and only a tool that fails on cue can show it.
1551    #[cfg(unix)]
1552    fn scripted_glab(dir: &Path) -> String {
1553        use std::os::unix::fs::PermissionsExt;
1554        let pages: Vec<String> = discussion_pages()
1555            .iter()
1556            .map(|p| serde_json::to_string(p).unwrap())
1557            .collect();
1558        std::fs::write(dir.join("pages.json"), pages.join("\n")).unwrap();
1559        let script = dir.join("glab");
1560        std::fs::write(
1561            &script,
1562            r#"#!/bin/sh
1563dir=$(dirname "$0")
1564printf '%s\n' "$*" >> "$dir/calls"
1565mode=$(cat "$dir/mode")
1566case "$*" in
1567  *"/discussions/bad/notes"*) echo '{"error":"body is invalid"}'; echo "glab: HTTP 400" >&2; exit 1 ;;
1568  *"/discussions/"*"/notes"*) echo '{"id": 555, "body": "because"}' ;;
1569  *"/draft_notes/bulk_publish"*)
1570    if [ "$mode" = "bulk-fails" ]; then echo "glab: HTTP 500" >&2; exit 1; fi ;;
1571  *"/draft_notes"*) echo '{"id": 9}' ;;
1572  *"--paginate"*) cat "$dir/pages.json" ;;
1573  *) echo "unexpected: $*" >&2; exit 2 ;;
1574esac
1575"#,
1576        )
1577        .unwrap();
1578        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1579        script.to_string_lossy().into_owned()
1580    }
1581
1582    #[cfg(unix)]
1583    fn calls(dir: &Path) -> String {
1584        std::fs::read_to_string(dir.join("calls")).unwrap_or_default()
1585    }
1586
1587    #[cfg(unix)]
1588    fn mixed_batch(reply_thread: &str) -> Batch {
1589        Batch {
1590            comments: vec![comment("f1", "new", 3, None, &with_marker("why?", "f1"))],
1591            replies: vec![NewReply {
1592                finding: "f2".into(),
1593                thread: reply_thread.into(),
1594                root_comment: "101".into(),
1595                body: with_marker("because", "f2"),
1596            }],
1597        }
1598    }
1599
1600    /// Whatever step fails, nothing that went live is left off the record:
1601    /// that record is what keeps the next publish from sending it again.
1602    #[test]
1603    #[cfg(unix)]
1604    fn a_gitlab_publish_records_everything_live_before_it_reports_a_failure() {
1605        let dir = tempfile::TempDir::new().unwrap();
1606        let glab = scripted_glab(dir.path());
1607        let forge = GlabForge::with_tool(&glab, dir.path());
1608        let req = parse_mr(&mr_view()).unwrap();
1609
1610        // The reply fails: nothing has gone up, so nothing is recorded and
1611        // the comments were never sent.
1612        std::fs::write(dir.path().join("mode"), "ok").unwrap();
1613        let err = forge.publish(&req, &mixed_batch("bad")).unwrap_err();
1614        // The status from stderr and the forge's reason from stdout, both.
1615        assert!(err.to_string().contains("HTTP 400"), "{err}");
1616        assert!(err.to_string().contains("body is invalid"), "{err}");
1617        assert!(
1618            !calls(dir.path()).contains("draft_notes"),
1619            "{}",
1620            calls(dir.path())
1621        );
1622
1623        // The bulk publish fails after the reply landed: the reply is on
1624        // record, the failure rides behind it, and no fetch was made for
1625        // notes that never went live.
1626        std::fs::remove_file(dir.path().join("calls")).unwrap();
1627        std::fs::write(dir.path().join("mode"), "bulk-fails").unwrap();
1628        let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1629        assert_eq!(sent.published.len(), 1);
1630        assert_eq!(
1631            (
1632                sent.published[0].finding.as_str(),
1633                sent.published[0].thread.as_str()
1634            ),
1635            ("f2", "d1")
1636        );
1637        assert_eq!(sent.published[0].comment, "555");
1638        assert!(sent.failed.is_some());
1639        assert!(sent.threads.is_none());
1640        let log = calls(dir.path());
1641        assert!(log.contains("draft_notes/bulk_publish"), "{log}");
1642        assert!(!log.contains("--paginate"), "{log}");
1643
1644        // Everything lands: the reply from its answer, the comment from the
1645        // discussions fetched once, which are also handed back.
1646        std::fs::write(dir.path().join("mode"), "ok").unwrap();
1647        let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1648        assert!(sent.failed.is_none());
1649        let mut named: Vec<(&str, &str, &str)> = sent
1650            .published
1651            .iter()
1652            .map(|p| (p.finding.as_str(), p.thread.as_str(), p.comment.as_str()))
1653            .collect();
1654        named.sort();
1655        assert_eq!(named, vec![("f1", "d1", "101"), ("f2", "d1", "555")]);
1656        assert_eq!(sent.threads.as_ref().map(Vec::len), Some(4));
1657    }
1658}