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}