Skip to main content

differential_engine/
forge.rs

1//! The forge consumer's domain (ADR 0029, `spec/forge.md`).
2//!
3//! What a pull request or merge request is to a review, the forge's review
4//! threads as the review sees them, and the two decisions that sit between a
5//! forge and the reader's findings: where a fetched thread lands in the diff,
6//! and which findings a publish may send.
7//!
8//! Nothing here runs a program. The adapters that speak to `gh` and `glab`
9//! implement [`Forge`] and live in `forgeio`; this module is the trait, the
10//! types it speaks in, and pure policy over a plan document.
11
12use serde::{Deserialize, Serialize};
13
14use crate::model::DiffView;
15use crate::plan::ReviewSource;
16use crate::ports::{Ancestry, Fetcher, RangeResolver, ReviewIdentity};
17use crate::review_state::{Anchor, Finding, FindingStatus};
18use crate::schema;
19
20/// Which forge a request lives on. The flag that names the request names
21/// this too: `--pr` is GitHub and `--mr` is GitLab, and nothing infers it.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum ForgeKind {
25    Github,
26    Gitlab,
27}
28
29impl ForgeKind {
30    /// The wire name, identical to `source.remote.forge` in the document.
31    pub fn name(self) -> &'static str {
32        match self {
33            ForgeKind::Github => "github",
34            ForgeKind::Gitlab => "gitlab",
35        }
36    }
37
38    /// What the forge calls the thing: for messages.
39    pub fn noun(self) -> &'static str {
40        match self {
41            ForgeKind::Github => "pull request",
42            ForgeKind::Gitlab => "merge request",
43        }
44    }
45
46    /// The ref a clone fetches to get a request's head without the branch.
47    pub fn head_ref(self, id: &str) -> String {
48        match self {
49            ForgeKind::Github => format!("pull/{id}/head"),
50            ForgeKind::Gitlab => format!("merge-requests/{id}/head"),
51        }
52    }
53
54    pub fn source_kind(self) -> schema::SourceKind {
55        match self {
56            ForgeKind::Github => schema::SourceKind::Pr,
57            ForgeKind::Gitlab => schema::SourceKind::Mr,
58        }
59    }
60
61    /// How far from a change a line comment may sit, or no limit.
62    ///
63    /// GitHub's public API resolves a line against the request's diff with
64    /// exactly `REQUEST_CONTEXT` lines around each hunk — measured on a live
65    /// request: three after a change lands, four is refused. GitLab positions
66    /// a note by its own line numbers against the diff refs and is not held to
67    /// that rule here; if it refuses a line, its refusal is what the reader
68    /// sees.
69    pub fn line_rule(self) -> Option<u32> {
70        match self {
71            ForgeKind::Github => Some(REQUEST_CONTEXT),
72            ForgeKind::Gitlab => None,
73        }
74    }
75}
76
77/// A request as the forge describes it: the object a review is of.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Request {
80    pub kind: ForgeKind,
81    /// `owner/repo` on GitHub, the full namespaced path on GitLab.
82    pub project: String,
83    /// The number, as a string: GitLab's iid is a number too, and neither is
84    /// ever arithmetic here.
85    pub id: String,
86    /// The branch the request targets, for the fetch hint.
87    pub base_ref: String,
88    /// The tip of that branch, as the forge sees it now.
89    pub base_tip: String,
90    /// The request's head commit, as the forge sees it now.
91    pub head: String,
92    /// The merge base, when the forge says it (GitLab's `diff_refs.base_sha`).
93    /// A GitLab position names it; the review's range is computed from git
94    /// either way.
95    pub merge_base: Option<String>,
96    pub url: String,
97}
98
99impl Request {
100    /// The document's `source.remote`.
101    pub fn remote(&self) -> schema::Remote {
102        schema::Remote {
103            forge: self.kind.name().to_string(),
104            project: self.project.clone(),
105            id: self.id.clone(),
106        }
107    }
108
109    /// The review this request opens. Keyed like a name: the request is an
110    /// object, and its endpoints are attributes that are allowed to move.
111    pub fn identity(&self) -> ReviewIdentity {
112        ReviewIdentity::Remote(self.remote())
113    }
114
115    /// The command a reader runs when the request's commits are not local.
116    pub fn fetch_hint(&self, remote_name: &str) -> String {
117        format!(
118            "git fetch {remote_name} {} {}",
119            self.base_ref,
120            self.kind.head_ref(&self.id)
121        )
122    }
123}
124
125/// One comment in a thread. `reply_to` is `None` on the root.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct RemoteComment {
128    pub id: String,
129    pub author: String,
130    /// As the forge gives it, ISO 8601. Shown as its date, never parsed
131    /// for anything else.
132    pub created: String,
133    pub body: String,
134    /// The finding this comment was published from, when its body carried
135    /// the marker `with_marker` writes. What makes a publish idempotent: a
136    /// comment that says which finding it is can be matched without trusting
137    /// the forge's answer to the publish itself.
138    #[serde(default)]
139    pub finding: Option<String>,
140}
141
142/// The marker a published body ends with: an HTML comment, which neither
143/// forge renders, carrying the finding's id.
144pub(crate) fn marker(finding: &str) -> String {
145    format!("<!-- differential:finding {finding} -->")
146}
147
148/// A finding's body as it is sent: the text, a blank line, the marker.
149pub fn with_marker(body: &str, finding: &str) -> String {
150    format!("{}\n\n{}", body.trim_end(), marker(finding))
151}
152
153/// A fetched body, split into what is shown and which finding wrote it.
154pub fn strip_marker(body: &str) -> (String, Option<String>) {
155    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
156    let re = RE.get_or_init(|| {
157        regex::Regex::new(r"\s*<!-- differential:finding ([0-9a-f]+) -->\s*").expect("a literal")
158    });
159    match re.captures(body) {
160        Some(c) => {
161            let id = c[1].to_string();
162            (re.replace(body, "").trim_end().to_string(), Some(id))
163        }
164        None => (body.to_string(), None),
165    }
166}
167
168/// One review thread: where the forge put it, and where this review did.
169///
170/// The forge's coordinates are kept beside the anchor so a thread loaded from
171/// a stale cache can be placed again against a newer plan — `place` is a pure
172/// function of them and the document, and it runs on every open.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct RemoteThread {
175    /// The forge's thread id, opaque. GraphQL node id on GitHub, discussion
176    /// id on GitLab.
177    pub id: String,
178    pub resolved: bool,
179    /// The forge says the line has left the request's diff. Such a thread
180    /// has no `line`, and is placed by content or not at all.
181    pub outdated: bool,
182    /// The file's path in the request, which is the new path.
183    pub path: String,
184    /// `"old"` | `"new"`, the review's words for LEFT and RIGHT.
185    pub side: String,
186    /// The last (or only) line, in the side's numbering. `None` when outdated.
187    #[serde(default)]
188    pub line: Option<u32>,
189    /// The first line of a multi-line thread.
190    #[serde(default)]
191    pub start_line: Option<u32>,
192    /// The text of `line`, when the forge recorded the diff around it. The
193    /// content key for a thread whose line is gone.
194    #[serde(default)]
195    pub line_text: Option<String>,
196    /// Where this review shows the thread. `None` until placed, and `None`
197    /// after placing when nothing in the plan holds it.
198    #[serde(default)]
199    pub anchor: Option<Anchor>,
200    pub comments: Vec<RemoteComment>,
201}
202
203impl RemoteThread {
204    /// The comment a reply is threaded under. GitHub replies to the root;
205    /// GitLab replies to the discussion, whose id this thread already is.
206    pub fn root(&self) -> Option<&RemoteComment> {
207        self.comments.first()
208    }
209
210    /// Whether this thread holds `finding`, published from here: by the
211    /// address the publish recorded, or by the marker in a comment's body,
212    /// which survives a publish whose answer was lost.
213    pub fn is_twin_of(&self, finding: &Finding) -> bool {
214        if let Some(up) = &finding.upstream
215            && (up.thread == self.id || self.comments.iter().any(|c| c.id == up.comment))
216        {
217            return true;
218        }
219        self.published_here(&finding.id).is_some()
220    }
221
222    /// The comment in this thread that a finding's publish made, if any.
223    pub fn published_here(&self, finding: &str) -> Option<&RemoteComment> {
224        self.comments
225            .iter()
226            .find(|c| c.finding.as_deref() == Some(finding))
227    }
228}
229
230/// A comment on the forge that is the reader's: by author, by marker, or by
231/// the address a publish recorded. What the reviewer's `c` edits and `dd`
232/// deletes; `ReviewSession::own_comment` is the one place that decides it.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct OwnComment {
235    pub thread: String,
236    pub comment: String,
237    /// The local record, when one is linked.
238    pub finding: Option<String>,
239    pub body: String,
240    /// `file:lines`, for a prompt.
241    pub at: String,
242}
243
244/// A new review comment to publish: a finding that is not a reply.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct NewComment {
247    pub finding: String,
248    /// The path in the request: the new path.
249    pub path: String,
250    /// The old path when the file was renamed. GitLab wants both; GitHub
251    /// wants only `path`.
252    pub old_path: Option<String>,
253    /// `"old"` | `"new"`.
254    pub side: String,
255    /// The last (or only) line.
256    pub line: u32,
257    /// The first line of a multi-line comment; `None` for one line.
258    pub start_line: Option<u32>,
259    /// The same line's number on the other side, when the line is unchanged
260    /// and so exists on both. GitLab positions an unchanged line by both
261    /// numbers; a changed line has one.
262    pub other_line: Option<u32>,
263    /// The two ends of a multi-line comment, each as its `(old, new)` pair,
264    /// which GitLab needs to build a `line_code`. `None` for one line.
265    pub span: Option<LineSpan>,
266    pub body: String,
267}
268
269/// One end of a multi-line comment, as GitLab's `line_range` names it.
270///
271/// `kind` is `"new"` for an added line, `"old"` for a deleted one, and
272/// `"expanded"` for an unchanged line, which exists on both sides. `old` and
273/// `new` are the numbers the `line_code` carries — `<sha>_<old>_<new>` — and
274/// they are not always the line's own numbers: a line missing from one side
275/// takes the position it sits at there — the paired hunk's start, which is
276/// `0` only for a block at the file's top.
277/// `old_line` and `new_line` are the real numbers, present only on the side
278/// the line exists, exactly as the forge's web UI sends them.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub struct LineEnd {
281    pub kind: &'static str,
282    pub old: u32,
283    pub new: u32,
284    pub old_line: Option<u32>,
285    pub new_line: Option<u32>,
286}
287
288/// The two ends of a multi-line comment's `line_range`.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct LineSpan {
291    pub start: LineEnd,
292    pub end: LineEnd,
293}
294
295/// A reply to publish into an existing thread.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct NewReply {
298    pub finding: String,
299    pub thread: String,
300    /// The thread's root comment id, for a forge that threads under a comment.
301    pub root_comment: String,
302    pub body: String,
303}
304
305/// What one publish sends: everything in one submission where the forge
306/// allows it.
307#[derive(Debug, Clone, PartialEq, Eq, Default)]
308pub struct Batch {
309    pub comments: Vec<NewComment>,
310    pub replies: Vec<NewReply>,
311}
312
313impl Batch {
314    pub fn is_empty(&self) -> bool {
315        self.comments.is_empty() && self.replies.is_empty()
316    }
317
318    pub fn len(&self) -> usize {
319        self.comments.len() + self.replies.len()
320    }
321
322    /// The findings this batch sends, comments then replies: the ids a
323    /// publish is afterwards held to account for.
324    pub fn finding_ids(&self) -> Vec<String> {
325        self.comments
326            .iter()
327            .map(|c| c.finding.clone())
328            .chain(self.replies.iter().map(|r| r.finding.clone()))
329            .collect()
330    }
331}
332
333/// A finding a publish left out, and why, in words for the status line.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct Excluded {
336    pub finding: String,
337    pub file: String,
338    pub lines: String,
339    pub reason: String,
340}
341
342/// A publish, decided: what goes and what stays.
343#[derive(Debug, Clone, PartialEq, Eq, Default)]
344pub struct PublishPlan {
345    pub batch: Batch,
346    pub excluded: Vec<Excluded>,
347}
348
349/// What an adapter's publish brought back.
350///
351/// `Err` from `Forge::publish` means nothing left the machine. Once anything
352/// has, the adapter returns `Ok` with what it knows: the comments it can name,
353/// the threads if it fetched them on the way, and the error that stopped it,
354/// so the caller records what landed before it says what failed. A publish
355/// that lost track of its own comments would send them again.
356#[derive(Debug, Default)]
357pub struct Sent {
358    pub published: Vec<Published>,
359    /// The threads, when the adapter had to fetch them anyway.
360    pub threads: Option<Vec<RemoteThread>>,
361    /// What stopped the adapter after something had already gone up.
362    pub failed: Option<ForgeError>,
363}
364
365/// One comment the forge accepted, keyed back to its finding.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct Published {
368    pub finding: String,
369    pub thread: String,
370    pub comment: String,
371    pub url: Option<String>,
372}
373
374#[derive(Debug, thiserror::Error)]
375pub enum ForgeError {
376    /// The request's commits are not in this clone, and a fetch of its refs
377    /// did not bring them. The message carries the command a reader could
378    /// try by hand.
379    #[error(
380        "{noun} {id} needs commits this clone does not have, and fetching did not bring them; try\n    {hint}\nby hand"
381    )]
382    NotFetched {
383        noun: &'static str,
384        id: String,
385        hint: String,
386    },
387    #[error("failed to spawn {command}: {source}")]
388    Spawn {
389        command: String,
390        #[source]
391        source: std::io::Error,
392    },
393    #[error("{command} exited with {code:?}: {output}")]
394    Failed {
395        command: String,
396        code: Option<i32>,
397        /// What the tool said as it failed: stderr, then stdout. `glab` and
398        /// `gh` print only the status on stderr and the forge's own answer —
399        /// `{"error": "position[new_line] is invalid"}` — on stdout, and the
400        /// answer is the part that says why.
401        output: String,
402    },
403    #[error("{command} did not finish within {timeout:?}")]
404    Timeout {
405        command: String,
406        timeout: std::time::Duration,
407    },
408    #[error("{command} was cancelled")]
409    Cancelled { command: String },
410    #[error("{command}: {source}")]
411    Io {
412        command: String,
413        #[source]
414        source: std::io::Error,
415    },
416    #[error("could not read {command}'s answer: {msg}")]
417    Parse { command: String, msg: String },
418    #[error("{0}")]
419    NoRequest(String),
420    /// The request's head is not the one this review was built on: someone
421    /// pushed. Both forges refuse a comment against another commit, so
422    /// nothing was sent.
423    #[error("the {noun} moved to {at} since this review was built; open it again")]
424    HeadMoved { noun: &'static str, at: String },
425}
426
427/// The forge, as the domain needs it. `dyn`: which forge a repository is on
428/// is a run-time answer, like which model groups (ADR 0020, 0029).
429pub trait Forge: Send + Sync {
430    fn kind(&self) -> ForgeKind;
431
432    /// The login the tool is signed in as. A comment by this author is the
433    /// reader's, marker or not.
434    fn whoami(&self) -> Result<String, ForgeError>;
435
436    /// The request with this id, or the current branch's when `None`.
437    fn request(&self, id: Option<&str>) -> Result<Request, ForgeError>;
438
439    /// Every review thread on the request, unplaced (`anchor: None`).
440    fn threads(&self, req: &Request) -> Result<Vec<RemoteThread>, ForgeError>;
441
442    /// Send one batch. Comments against `req.head`; the caller has already
443    /// checked that is the review's head. `Err` only while nothing has left
444    /// the machine; after that, `Ok(Sent)` with a `failed`.
445    fn publish(&self, req: &Request, batch: &Batch) -> Result<Sent, ForgeError>;
446
447    fn set_resolved(&self, req: &Request, thread: &str, resolved: bool) -> Result<(), ForgeError>;
448
449    /// Rewrite a comment this reader published. The body arrives with its
450    /// marker, as it was sent.
451    fn edit_comment(
452        &self,
453        req: &Request,
454        thread: &str,
455        comment: &str,
456        body: &str,
457    ) -> Result<(), ForgeError>;
458
459    /// Remove a comment this reader published.
460    fn delete_comment(&self, req: &Request, thread: &str, comment: &str) -> Result<(), ForgeError>;
461}
462
463// ------------------------------------------------------------------ placing
464
465/// One side of a hunk as `(first line, lines)`, in that side's numbering. The
466/// three places that need it used to spell it out, one `if` each.
467fn side_range(h: &schema::HunkEntry, old: bool) -> (u32, u32) {
468    if old {
469        (h.old_start.max(1), h.old_count)
470    } else {
471        (h.new_start.max(1), h.new_count)
472    }
473}
474
475/// Where a fetched thread lands in this plan.
476///
477/// Three tries, cheapest and most certain first. A line the forge gave that a
478/// hunk on that side holds is exact: the digest and the offset are the hunk's.
479/// A line no hunk holds is context, and lands on the nearest hunk in the file
480/// at a signed offset, which is how a finding on a context line is recorded
481/// too. A thread with no line — outdated, the forge says — is looked for by
482/// its recorded text in the file's hunks, on its own side first. Nothing
483/// found is `anchor: None`, and the thread is counted, not drawn.
484pub fn place(doc: &schema::PlanDocument, view: &DiffView, thread: &mut RemoteThread) {
485    thread.anchor = None;
486    let old = thread.side == "old";
487    let in_file = |h: &&schema::HunkEntry| h.file == thread.path;
488
489    if let Some(line) = thread.line {
490        let start = thread.start_line.unwrap_or(line).min(line);
491        // Exact: a hunk whose changed lines on this side hold the last line.
492        let holds = |h: &&schema::HunkEntry| {
493            let (s, n) = side_range(h, old);
494            n > 0 && line >= s && line < s.saturating_add(n)
495        };
496        // Otherwise the nearest hunk in the file on this side.
497        let distance = |h: &schema::HunkEntry| -> u32 {
498            let (s, n) = side_range(h, old);
499            let end = s.saturating_add(n.max(1)) - 1;
500            if line < s {
501                s - line
502            } else {
503                line.saturating_sub(end)
504            }
505        };
506        let hit = doc
507            .hunks
508            .iter()
509            .enumerate()
510            .filter(|(_, h)| in_file(h))
511            .find(|(_, h)| holds(h))
512            .or_else(|| {
513                doc.hunks
514                    .iter()
515                    .enumerate()
516                    .filter(|(_, h)| in_file(h))
517                    .min_by_key(|(_, h)| distance(h))
518            });
519        let Some((hi, h)) = hit else {
520            return;
521        };
522        let (s, n) = side_range(h, old);
523        let vh = &view.hunks[hi];
524        let side_lines = if old { &vh.removed } else { &vh.added };
525        let text_at = |l: u32| -> Option<String> {
526            (l >= s && l < s.saturating_add(n))
527                .then(|| side_lines.get((l - s) as usize))
528                .flatten()
529                .map(|b| String::from_utf8_lossy(b).into_owned())
530        };
531        let end_line_text = text_at(line)
532            .or_else(|| thread.line_text.clone())
533            .unwrap_or_default();
534        let line_text = if start == line {
535            end_line_text.clone()
536        } else {
537            text_at(start).unwrap_or_default()
538        };
539        thread.anchor = Some(Anchor {
540            file: thread.path.clone(),
541            side: thread.side.clone(),
542            line: start,
543            end_line: line,
544            offset: (i64::from(start) - i64::from(s)) as i32,
545            span: line - start,
546            hunk_digest: h.digest.clone(),
547            line_text,
548            end_line_text,
549        });
550        return;
551    }
552
553    // No line: find the text.
554    let Some(text) = thread.line_text.as_deref().filter(|t| !t.is_empty()) else {
555        return;
556    };
557    let at = |lines: &[Vec<u8>]| lines.iter().position(|l| l == text.as_bytes());
558    for (hi, h) in doc.hunks.iter().enumerate().filter(|(_, h)| in_file(h)) {
559        let vh = &view.hunks[hi];
560        let found = if old {
561            at(&vh.removed)
562                .map(|p| ("old", p))
563                .or_else(|| at(&vh.added).map(|p| ("new", p)))
564        } else {
565            at(&vh.added)
566                .map(|p| ("new", p))
567                .or_else(|| at(&vh.removed).map(|p| ("old", p)))
568        };
569        if let Some((side, offset)) = found {
570            let (s, _) = side_range(h, side == "old");
571            let line = s + offset as u32;
572            thread.anchor = Some(Anchor {
573                file: thread.path.clone(),
574                side: side.to_string(),
575                line,
576                end_line: line,
577                offset: offset as i32,
578                span: 0,
579                hunk_digest: h.digest.clone(),
580                line_text: text.to_string(),
581                end_line_text: text.to_string(),
582            });
583            return;
584        }
585    }
586}
587
588// --------------------------------------------------------------- publishing
589
590/// Lines of context a request diff shows around each hunk, on both forges'
591/// web diffs. A comment further out than this is refused by the forge, and on
592/// GitHub it fails the whole review.
593const REQUEST_CONTEXT: u32 = 3;
594
595/// Which open findings a publish may send, and which it must leave.
596///
597/// A finding already published is not a candidate. A reply needs its thread
598/// to still exist and nothing else. A new comment needs its lines inside the
599/// request's diff: within `REQUEST_CONTEXT` of a hunk in its file on its
600/// side, both ends. The old path rides along for a renamed file, because
601/// GitLab positions a comment by both paths.
602pub fn publish_plan(
603    doc: &schema::PlanDocument,
604    findings: &[Finding],
605    threads: &[RemoteThread],
606    line_rule: Option<u32>,
607) -> PublishPlan {
608    let mut out = PublishPlan::default();
609    // A finding a thread already carries is on the request, whatever its
610    // record says: a publish whose answer was lost must not send it twice.
611    for f in findings.iter().filter(|f| {
612        f.status == FindingStatus::Open
613            && f.upstream.is_none()
614            && !threads.iter().any(|t| t.published_here(&f.id).is_some())
615    }) {
616        if let Some(thread_id) = &f.reply_to {
617            match threads.iter().find(|t| &t.id == thread_id) {
618                Some(t) => out.batch.replies.push(NewReply {
619                    finding: f.id.clone(),
620                    thread: t.id.clone(),
621                    root_comment: t.root().map(|c| c.id.clone()).unwrap_or_default(),
622                    body: with_marker(&f.body, &f.id),
623                }),
624                None => out
625                    .excluded
626                    .push(excluded(f, "its thread is no longer on the request")),
627            }
628            continue;
629        }
630        if let Some(context) = line_rule
631            && !in_request_diff(doc, &f.anchor, context)
632        {
633            out.excluded.push(excluded(
634                f,
635                &format!("outside the request's diff: more than {context} lines from a change"),
636            ));
637            continue;
638        }
639        let old_path = doc
640            .files
641            .iter()
642            .find(|e| e.path == f.anchor.file)
643            .and_then(|e| e.old_path.clone());
644        out.batch.comments.push(NewComment {
645            finding: f.id.clone(),
646            path: f.anchor.file.clone(),
647            old_path,
648            side: f.anchor.side.clone(),
649            line: f.anchor.end_line.max(f.anchor.line),
650            start_line: (f.anchor.end_line > f.anchor.line).then_some(f.anchor.line),
651            other_line: other_side_line(
652                doc,
653                &f.anchor.file,
654                &f.anchor.side,
655                f.anchor.end_line.max(f.anchor.line),
656            ),
657            span: (f.anchor.end_line > f.anchor.line).then(|| LineSpan {
658                start: line_end(doc, &f.anchor.file, &f.anchor.side, f.anchor.line),
659                end: line_end(
660                    doc,
661                    &f.anchor.file,
662                    &f.anchor.side,
663                    f.anchor.end_line.max(f.anchor.line),
664                ),
665            }),
666            body: with_marker(&f.body, &f.id),
667        });
668    }
669    out
670}
671
672fn excluded(f: &Finding, reason: &str) -> Excluded {
673    Excluded {
674        finding: f.id.clone(),
675        file: f.anchor.file.clone(),
676        lines: f.anchor.line_span(),
677        reason: reason.to_string(),
678    }
679}
680
681/// The number an unchanged line has on the other side, or `None` for a
682/// changed line, which exists on one side only.
683///
684/// Every hunk that ends before the line shifts the other side's numbering by
685/// its own imbalance; the nearest such hunk carries the whole shift, since
686/// its end already accounts for every hunk before it.
687pub fn other_side_line(
688    doc: &schema::PlanDocument,
689    file: &str,
690    side: &str,
691    line: u32,
692) -> Option<u32> {
693    let old = side == "old";
694    let in_file = || doc.hunks.iter().filter(|h| h.file == file);
695    if in_file().any(|h| {
696        let (s, n) = side_range(h, old);
697        n > 0 && line >= s && line < s.saturating_add(n)
698    }) {
699        return None;
700    }
701    let shift = in_file()
702        .map(|h| (side_range(h, old), side_range(h, !old)))
703        .filter(|((s, n), _)| s.saturating_add(*n) <= line)
704        .max_by_key(|((s, _), _)| *s)
705        .map(|((s, n), (os, on))| i64::from(os + on) - i64::from(s + n))
706        .unwrap_or(0);
707    u32::try_from(i64::from(line) + shift).ok()
708}
709
710/// One `line_range` end, as GitLab forms it. Three cases, matching the shapes
711/// the web UI sends:
712///
713/// - an **unchanged** line exists on both sides (`other_side_line` is `Some`):
714///   `kind` `"expanded"`, both numbers real.
715/// - an **added** line (new side, no old): `kind` `"new"`, `old` is `0` and
716///   `old_line` absent; `new`/`new_line` are the line.
717/// - a **deleted** line (old side, no new): `kind` `"old"`, `new` is the
718///   new-side position it sits at — not `0` — and `new_line` is absent;
719///   `old`/`old_line` are the line.
720fn line_end(doc: &schema::PlanDocument, file: &str, side: &str, line: u32) -> LineEnd {
721    match other_side_line(doc, file, side, line) {
722        Some(other) => {
723            let (old, new) = if side == "old" {
724                (line, other)
725            } else {
726                (other, line)
727            };
728            LineEnd {
729                kind: "expanded",
730                old,
731                new,
732                old_line: Some(old),
733                new_line: Some(new),
734            }
735        }
736        None if side == "old" => LineEnd {
737            kind: "old",
738            old: line,
739            new: other_position(doc, file, "old", line),
740            old_line: Some(line),
741            new_line: None,
742        },
743        None => LineEnd {
744            kind: "new",
745            old: other_position(doc, file, "new", line),
746            new: line,
747            old_line: None,
748            new_line: Some(line),
749        },
750    }
751}
752
753/// Where `line` on `side` sits on the other side: the paired hunk's start
754/// when nothing sits there (a pure insertion's old side, a pure deletion's
755/// new side), else the matching offset into it. Every added or deleted line
756/// in one hunk shares the other side's start, which is the number GitLab's
757/// `line_code` carries for it — `0` only when the block is at the file's top.
758fn other_position(doc: &schema::PlanDocument, file: &str, side: &str, line: u32) -> u32 {
759    let own_old = side == "old";
760    doc.hunks
761        .iter()
762        .filter(|h| h.file == file)
763        .find_map(|h| {
764            let (s, n) = side_range(h, own_old);
765            (n > 0 && line >= s && line < s.saturating_add(n)).then(|| {
766                // The paired side's RAW start: a block at the file's top sits
767                // at `0`, which `side_range` would clamp to 1. That `0` is
768                // exactly what GitLab's `line_code` carries there.
769                let (os, on) = if own_old {
770                    (h.new_start, h.new_count)
771                } else {
772                    (h.old_start, h.old_count)
773                };
774                if on == 0 {
775                    os
776                } else {
777                    os.saturating_add((line - s).min(on - 1))
778                }
779            })
780        })
781        .unwrap_or(line)
782}
783
784/// Whether both ends of `a` sit inside the request's diff of its file, with
785/// `context` lines around each hunk.
786fn in_request_diff(doc: &schema::PlanDocument, a: &Anchor, context: u32) -> bool {
787    let old = a.side == "old";
788    let first = a.line;
789    let last = a.end_line.max(a.line);
790    doc.hunks.iter().filter(|h| h.file == a.file).any(|h| {
791        let (s, n) = side_range(h, old);
792        let lo = s.saturating_sub(context);
793        let hi = s
794            .saturating_add(n)
795            .saturating_sub(1)
796            .saturating_add(context);
797        first >= lo && last <= hi
798    })
799}
800
801/// The range a request reviews: the merge base of its target branch's tip
802/// and its head, to its head. That is the diff the request page shows.
803///
804/// When either commit is not local, the request's refs are fetched from
805/// `origin` — the target branch and the forge's own ref for the head — and
806/// the check is made again. Only a commit still missing after that is
807/// `NotFetched`, with the line a reader could run by hand (ADR 0029, decision
808/// 4 as reversed by the author).
809pub fn source_for<G: Ancestry + RangeResolver + Fetcher>(
810    git: &G,
811    req: &Request,
812) -> Result<ReviewSource, crate::EngineError> {
813    let have = |sha: &str| git.commit_of(sha).map(|c| c.is_some());
814    if !have(&req.head)? || !have(&req.base_tip)? {
815        git.fetch("origin", &[&req.base_ref, &req.kind.head_ref(&req.id)])?;
816    }
817    if !have(&req.head)? || !have(&req.base_tip)? {
818        return Err(ForgeError::NotFetched {
819            noun: req.kind.noun(),
820            id: req.id.clone(),
821            hint: req.fetch_hint("origin"),
822        }
823        .into());
824    }
825    let base = git.merge_base(&req.base_tip, &req.head)?;
826    Ok(ReviewSource::request(
827        base,
828        req.head.clone(),
829        req.kind.source_kind(),
830        req.remote(),
831    ))
832}
833
834/// Whether the forge still has the head this review was opened on. Both
835/// forges reject a comment against any other commit, so this is the first
836/// thing a publish checks and the batch is not built when it fails.
837pub fn head_matches(req: &Request, review_head: &str) -> bool {
838    req.head == review_head
839}
840
841/// What one publish brings back: the forge's record of each finding it
842/// took, and the threads fetched afterwards so the twins can be shown.
843///
844/// The refetch is its own result. The comments are on the request the moment
845/// the publish returned; a refetch that then fails must not read as nothing
846/// having been sent, or the next publish sends it all again.
847#[derive(Debug)]
848pub struct PublishOutcome {
849    pub published: Vec<Published>,
850    pub threads: Result<Vec<RemoteThread>, ForgeError>,
851    /// What stopped the adapter after part of the batch had gone up.
852    pub failed: Option<ForgeError>,
853}
854
855/// The whole publish, forge side: ask the forge where the request is now,
856/// refuse if it moved, send the batch, fetch the threads again.
857///
858/// One function so the reviewer and `dfr findings --post` cannot order these
859/// differently. It runs on whichever thread the caller chooses; it touches
860/// nothing of the review's.
861pub fn publish(
862    forge: &dyn Forge,
863    req: &Request,
864    review_head: &str,
865    batch: &Batch,
866) -> Result<PublishOutcome, ForgeError> {
867    let fresh = forge.request(Some(&req.id))?;
868    if !head_matches(&fresh, review_head) {
869        return Err(ForgeError::HeadMoved {
870            noun: req.kind.noun(),
871            at: fresh.head.get(..12).unwrap_or(&fresh.head).to_string(),
872        });
873    }
874    let sent = forge.publish(req, batch)?;
875    // The adapter may have fetched the threads on its way; one round of
876    // pages, not two.
877    let threads = match sent.threads {
878        Some(threads) => Ok(threads),
879        None => forge.threads(req),
880    };
881    Ok(PublishOutcome {
882        published: sent.published,
883        threads,
884        failed: sent.failed,
885    })
886}