1use 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
27struct Tool {
32 program: String,
34 working_dir: PathBuf,
35}
36
37const 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 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 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 fn delete_at(&self, path: &str) -> Result<(), ForgeError> {
118 self.run(&["api", "--method", "DELETE", path], None)
119 .map(|_| ())
120 }
121
122 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 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
155fn 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
174fn 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
206fn 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
213fn 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
231pub struct GhForge {
235 tool: Tool,
236}
237
238const 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 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 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 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 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
474fn parse_request(v: &Value) -> Result<Request, ForgeError> {
479 let url = str_of(v, "url")?;
480 let path = url_path(url)?;
483 let mut segments = path.rsplit('/').skip(2);
484 let repo = segments
485 .next()
486 .ok_or_else(|| parse_err("url has no repo"))?;
487 let owner = segments
488 .next()
489 .ok_or_else(|| parse_err("url has no owner"))?;
490 let number = v
491 .get("number")
492 .and_then(Value::as_i64)
493 .ok_or_else(|| parse_err("missing number"))?;
494 Ok(Request {
495 kind: ForgeKind::Github,
496 project: format!("{owner}/{repo}"),
497 id: number.to_string(),
498 base_ref: str_of(v, "baseRefName")?.to_string(),
499 base_tip: str_of(v, "baseRefOid")?.to_string(),
500 head: str_of(v, "headRefOid")?.to_string(),
501 merge_base: None,
502 url: url.to_string(),
503 })
504}
505
506fn url_path(text: &str) -> Result<String, ForgeError> {
512 let parsed = url::Url::parse(text).map_err(|e| parse_err(format!("bad url {text:?}: {e}")))?;
513 let path = percent_encoding::percent_decode_str(parsed.path())
514 .decode_utf8()
515 .map_err(|_| parse_err("url path is not UTF-8"))?;
516 Ok(path.trim_matches('/').to_string())
517}
518
519fn parse_threads_page(v: &Value) -> Result<(Vec<RemoteThread>, Option<String>), ForgeError> {
521 let conn = v
522 .pointer("/data/repository/pullRequest/reviewThreads")
523 .ok_or_else(|| parse_err("no reviewThreads in the answer"))?;
524 let next = conn
525 .pointer("/pageInfo/hasNextPage")
526 .and_then(Value::as_bool)
527 .unwrap_or(false)
528 .then(|| conn.pointer("/pageInfo/endCursor").and_then(Value::as_str))
529 .flatten()
530 .map(str::to_string);
531 let nodes = conn
532 .get("nodes")
533 .and_then(Value::as_array)
534 .ok_or_else(|| parse_err("reviewThreads has no nodes"))?;
535 let threads = nodes
536 .iter()
537 .map(parse_gh_thread)
538 .collect::<Result<Vec<_>, _>>()?;
539 Ok((threads, next))
540}
541
542fn parse_gh_thread(t: &Value) -> Result<RemoteThread, ForgeError> {
543 let comments = t
544 .pointer("/comments/nodes")
545 .and_then(Value::as_array)
546 .map(|nodes| {
547 nodes
548 .iter()
549 .map(parse_gh_comment)
550 .collect::<Result<Vec<_>, _>>()
551 })
552 .transpose()?
553 .unwrap_or_default();
554 let line_text = t
557 .pointer("/comments/nodes/0/diffHunk")
558 .and_then(Value::as_str)
559 .and_then(last_diff_line);
560 let side = match t.get("diffSide").and_then(Value::as_str) {
561 Some("LEFT") => "old",
562 _ => "new",
563 };
564 Ok(RemoteThread {
565 id: str_of(t, "id")?.to_string(),
566 resolved: t
567 .get("isResolved")
568 .and_then(Value::as_bool)
569 .unwrap_or(false),
570 outdated: t
571 .get("isOutdated")
572 .and_then(Value::as_bool)
573 .unwrap_or(false),
574 path: str_of(t, "path")?.to_string(),
575 side: side.to_string(),
576 line: u32_of(t, "line"),
577 start_line: u32_of(t, "startLine"),
578 line_text,
579 anchor: None,
580 comments,
581 })
582}
583
584fn parse_gh_comment(c: &Value) -> Result<RemoteComment, ForgeError> {
585 let (body, finding) = strip_marker(str_of(c, "body")?);
586 Ok(RemoteComment {
587 id: c
588 .get("databaseId")
589 .and_then(Value::as_i64)
590 .ok_or_else(|| parse_err("comment without databaseId"))?
591 .to_string(),
592 author: c
593 .pointer("/author/login")
594 .and_then(Value::as_str)
595 .unwrap_or("(deleted)")
596 .to_string(),
597 created: str_of(c, "createdAt")?.to_string(),
598 body,
599 finding,
600 })
601}
602
603fn last_diff_line(hunk: &str) -> Option<String> {
605 let last = hunk
606 .lines()
607 .rev()
608 .find(|l| !l.is_empty() && !l.starts_with("@@"))?;
609 Some(last.get(1..).unwrap_or("").to_string())
610}
611
612fn review_body(req: &Request, comments: &[NewComment]) -> Value {
616 let side = |s: &str| if s == "old" { "LEFT" } else { "RIGHT" };
617 let items: Vec<Value> = comments
618 .iter()
619 .map(|c| {
620 let mut item = json!({
621 "path": c.path,
622 "body": c.body,
623 "line": c.line,
624 "side": side(&c.side),
625 });
626 if let Some(start) = c.start_line {
627 item["start_line"] = json!(start);
628 item["start_side"] = json!(side(&c.side));
629 }
630 item
631 })
632 .collect();
633 json!({
634 "commit_id": req.head,
635 "event": "COMMENT",
636 "body": "",
637 "comments": items,
638 })
639}
640
641fn match_published(sent: &[NewComment], posted: &Value) -> Vec<Published> {
648 let Some(posted) = posted.as_array() else {
649 return Vec::new();
650 };
651 sent.iter()
652 .filter_map(|c| {
653 let mark = marker(&c.finding);
654 let hit = posted.iter().find(|p| has_marker(p, &mark))?;
655 Some(Published {
656 finding: c.finding.clone(),
657 thread: String::new(),
658 comment: hit.get("id").and_then(Value::as_i64)?.to_string(),
659 url: hit
660 .get("html_url")
661 .and_then(Value::as_str)
662 .map(str::to_string),
663 })
664 })
665 .collect()
666}
667
668pub struct GlabForge {
675 tool: Tool,
676}
677
678impl GlabForge {
679 pub fn new(root: &Path) -> Self {
680 Self::with_tool("glab", root)
681 }
682
683 fn with_tool(program: &str, root: &Path) -> Self {
685 GlabForge {
686 tool: Tool::new(program, root),
687 }
688 }
689
690 fn mr(req: &Request, tail: &str) -> String {
691 format!("projects/:id/merge_requests/{}{}", req.id, tail)
692 }
693
694 fn discussions(&self, req: &Request) -> Result<Vec<Value>, ForgeError> {
695 let path = Self::mr(req, "/discussions?per_page=100");
696 self.tool.json_stream(&["api", "--paginate", &path])
697 }
698}
699
700impl Forge for GlabForge {
701 fn kind(&self) -> ForgeKind {
702 ForgeKind::Gitlab
703 }
704
705 fn whoami(&self) -> Result<String, ForgeError> {
706 let v = self.tool.json(&["api", "user"], None)?;
707 Ok(str_of(&v, "username")?.to_string())
708 }
709
710 fn request(&self, id: Option<&str>) -> Result<Request, ForgeError> {
711 let mut args = vec!["mr", "view"];
712 if let Some(id) = id {
713 args.push(id);
714 }
715 args.extend(["--output", "json"]);
716 let v = self
717 .tool
718 .json(&args, None)
719 .map_err(|e| no_request(e, id, self.kind().noun()))?;
720 parse_mr(&v)
721 }
722
723 fn threads(&self, req: &Request) -> Result<Vec<RemoteThread>, ForgeError> {
724 let pages = self.discussions(req)?;
725 Ok(parse_discussions(&pages, &req.head))
726 }
727
728 fn publish(&self, req: &Request, batch: &Batch) -> Result<Sent, ForgeError> {
729 if batch.is_empty() {
730 return Ok(Sent::default());
731 }
732 let mut sent = Sent::default();
733 let stop = |mut sent: Sent, e: ForgeError| {
737 if sent.published.is_empty() {
738 return Err(e);
739 }
740 sent.failed = Some(e);
741 Ok(sent)
742 };
743
744 for r in &batch.replies {
752 let body = json!(r.body);
753 let v = match self.tool.rest_fields(
754 "POST",
755 &Self::mr(req, &format!("/discussions/{}/notes", r.thread)),
756 &[("body", &body)],
757 ) {
758 Ok(v) => v,
759 Err(e) => return stop(sent, e),
760 };
761 sent.published.push(reply_published(r, &v));
762 }
763 if batch.comments.is_empty() {
764 return Ok(sent);
765 }
766
767 for c in &batch.comments {
773 let query = encode_query(&draft_note_position(req, c));
779 let path = Self::mr(req, &format!("/draft_notes?{query}"));
780 let note = json!(c.body);
781 if let Err(e) = self.tool.rest_fields("POST", &path, &[("note", ¬e)]) {
782 return stop(sent, e);
783 }
784 }
785 let bulk = self.tool.run(
786 &[
787 "api",
788 "--method",
789 "POST",
790 &Self::mr(req, "/draft_notes/bulk_publish"),
791 ],
792 None,
793 );
794 if let Err(e) = bulk {
795 return stop(sent, e);
796 }
797
798 match self.discussions(req) {
804 Ok(pages) => {
805 sent.published
806 .extend(match_gitlab_published(&batch.comments, &pages));
807 sent.threads = Some(parse_discussions(&pages, &req.head));
808 }
809 Err(e) => sent.failed = Some(e),
810 }
811 Ok(sent)
812 }
813
814 fn set_resolved(&self, req: &Request, thread: &str, resolved: bool) -> Result<(), ForgeError> {
815 self.tool.rest_fields(
816 "PUT",
817 &Self::mr(req, &format!("/discussions/{thread}")),
818 &[("resolved", &json!(resolved))],
819 )?;
820 Ok(())
821 }
822
823 fn edit_comment(
824 &self,
825 req: &Request,
826 thread: &str,
827 comment: &str,
828 body: &str,
829 ) -> Result<(), ForgeError> {
830 self.tool.rest_fields(
831 "PUT",
832 &Self::mr(req, &format!("/discussions/{thread}/notes/{comment}")),
833 &[("body", &json!(body))],
834 )?;
835 Ok(())
836 }
837
838 fn delete_comment(&self, req: &Request, thread: &str, comment: &str) -> Result<(), ForgeError> {
839 self.tool.delete_at(&Self::mr(
840 req,
841 &format!("/discussions/{thread}/notes/{comment}"),
842 ))
843 }
844}
845
846fn parse_mr(v: &Value) -> Result<Request, ForgeError> {
852 let url = str_of(v, "web_url")?;
853 let path = url_path(url)?;
854 let project = path
855 .split_once("/-/")
856 .map(|(p, _)| p)
857 .ok_or_else(|| parse_err("web_url is not a merge request url"))?;
858 let iid = v
859 .get("iid")
860 .and_then(Value::as_i64)
861 .ok_or_else(|| parse_err("missing iid"))?;
862 let refs = v
863 .get("diff_refs")
864 .ok_or_else(|| parse_err("missing diff_refs"))?;
865 Ok(Request {
866 kind: ForgeKind::Gitlab,
867 project: project.to_string(),
868 id: iid.to_string(),
869 base_ref: str_of(v, "target_branch")?.to_string(),
870 base_tip: str_of(refs, "start_sha")?.to_string(),
871 head: str_of(refs, "head_sha")?.to_string(),
872 merge_base: Some(str_of(refs, "base_sha")?.to_string()),
873 url: url.to_string(),
874 })
875}
876
877fn parse_discussions(pages: &[Value], head: &str) -> Vec<RemoteThread> {
885 pages
886 .iter()
887 .filter_map(Value::as_array)
888 .flatten()
889 .filter_map(|d| parse_discussion(d, head))
890 .collect()
891}
892
893fn parse_discussion(d: &Value, head: &str) -> Option<RemoteThread> {
894 let notes: Vec<&Value> = d
895 .get("notes")?
896 .as_array()?
897 .iter()
898 .filter(|n| !n.get("system").and_then(Value::as_bool).unwrap_or(false))
899 .collect();
900 let first = *notes.first()?;
901 if first.get("type").and_then(Value::as_str) != Some("DiffNote") {
902 return None;
903 }
904 let pos = first.get("position")?;
905 if pos.get("position_type").and_then(Value::as_str) != Some("text") {
906 return None;
907 }
908 let new_line = u32_of(pos, "new_line");
909 let old_line = u32_of(pos, "old_line");
910 let (side, line) = match (new_line, old_line) {
911 (Some(n), _) => ("new", n),
912 (None, Some(o)) => ("old", o),
913 (None, None) => return None,
914 };
915 let start_line = u32_at(pos, &format!("/line_range/start/{side}_line")).filter(|s| *s < line);
916 let outdated = pos.get("head_sha").and_then(Value::as_str) != Some(head);
917 first.get("id").and_then(Value::as_i64)?;
918 let comments = notes
919 .iter()
920 .map(|n| {
921 let (body, finding) =
922 strip_marker(n.get("body").and_then(Value::as_str).unwrap_or_default());
923 RemoteComment {
924 id: n
925 .get("id")
926 .and_then(Value::as_i64)
927 .map(|n| n.to_string())
928 .unwrap_or_default(),
929 author: n
930 .pointer("/author/username")
931 .and_then(Value::as_str)
932 .unwrap_or("(deleted)")
933 .to_string(),
934 created: n
935 .get("created_at")
936 .and_then(Value::as_str)
937 .unwrap_or_default()
938 .to_string(),
939 body,
940 finding,
941 }
942 })
943 .collect();
944 Some(RemoteThread {
945 id: d.get("id")?.as_str()?.to_string(),
946 resolved: first
947 .get("resolved")
948 .and_then(Value::as_bool)
949 .unwrap_or(false),
950 outdated,
951 path: pos
952 .get("new_path")
953 .and_then(Value::as_str)
954 .or_else(|| pos.get("old_path").and_then(Value::as_str))?
955 .to_string(),
956 side: side.to_string(),
957 line: (!outdated).then_some(line),
958 start_line: if outdated { None } else { start_line },
959 line_text: None,
960 anchor: None,
961 comments,
962 })
963}
964
965fn encode_query(fields: &[(String, String)]) -> String {
971 use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
972 const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
973 .remove(b'-')
974 .remove(b'_')
975 .remove(b'.')
976 .remove(b'~');
977 let enc = |s: &str| utf8_percent_encode(s, UNRESERVED).to_string();
978 fields
979 .iter()
980 .map(|(k, v)| format!("{}={}", enc(k), enc(v)))
981 .collect::<Vec<_>>()
982 .join("&")
983}
984
985fn draft_note_position(req: &Request, c: &NewComment) -> Vec<(String, String)> {
992 let mut fields = vec![
993 ("position[position_type]".into(), "text".into()),
994 (
995 "position[base_sha]".into(),
996 req.merge_base.clone().unwrap_or_default(),
997 ),
998 ("position[start_sha]".into(), req.base_tip.clone()),
999 ("position[head_sha]".into(), req.head.clone()),
1000 ("position[new_path]".into(), c.path.clone()),
1001 (
1002 "position[old_path]".into(),
1003 c.old_path.clone().unwrap_or_else(|| c.path.clone()),
1004 ),
1005 ];
1006 let (mine, other) = if c.side == "old" {
1009 ("old_line", "new_line")
1010 } else {
1011 ("new_line", "old_line")
1012 };
1013 fields.push((format!("position[{mine}]"), c.line.to_string()));
1014 if let Some(o) = c.other_line {
1015 fields.push((format!("position[{other}]"), o.to_string()));
1016 }
1017 if let Some(span) = &c.span {
1021 for (end, e) in [("start", &span.start), ("end", &span.end)] {
1022 let at = format!("position[line_range][{end}]");
1023 fields.push((format!("{at}[line_code]"), line_code(&c.path, e.old, e.new)));
1024 fields.push((format!("{at}[type]"), e.kind.into()));
1025 if let Some(n) = e.new_line {
1026 fields.push((format!("{at}[new_line]"), n.to_string()));
1027 }
1028 if let Some(o) = e.old_line {
1029 fields.push((format!("{at}[old_line]"), o.to_string()));
1030 }
1031 }
1032 }
1033 fields
1034}
1035
1036fn line_code(path: &str, old: u32, new: u32) -> String {
1039 let mut h = Sha1::new();
1040 h.update(path.as_bytes());
1041 format!("{}_{old}_{new}", hex::encode(h.finalize()))
1042}
1043
1044fn match_gitlab_published(sent: &[NewComment], pages: &[Value]) -> Vec<Published> {
1048 let discussions: Vec<&Value> = pages.iter().filter_map(Value::as_array).flatten().collect();
1049 sent.iter()
1050 .filter_map(|c| {
1051 let mark = marker(&c.finding);
1052 let (thread, comment) = discussions.iter().find_map(|d| {
1053 let note = d
1054 .get("notes")?
1055 .as_array()?
1056 .iter()
1057 .find(|n| has_marker(n, &mark))?;
1058 Some((
1059 d.get("id")?.as_str()?.to_string(),
1060 note.get("id")?.as_i64()?.to_string(),
1061 ))
1062 })?;
1063 Some(Published {
1064 finding: c.finding.clone(),
1065 thread,
1066 comment,
1067 url: None,
1068 })
1069 })
1070 .collect()
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use crate::forge::with_marker;
1077
1078 #[test]
1079 fn a_pull_request_is_read_from_gh_pr_view() {
1080 let v = json!({
1081 "number": 84,
1082 "baseRefName": "main",
1083 "baseRefOid": "ecc9400aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1084 "headRefOid": "d5cd4feac46323dae75365481257bd71fb736603",
1085 "url": "https://github.com/owner/repo/pull/84"
1086 });
1087 let req = parse_request(&v).unwrap();
1088 assert_eq!(req.kind, ForgeKind::Github);
1089 assert_eq!(req.project, "owner/repo");
1090 assert_eq!(req.id, "84");
1091 assert_eq!(req.base_ref, "main");
1092 assert_eq!(req.head, "d5cd4feac46323dae75365481257bd71fb736603");
1093 assert_eq!(
1094 req.fetch_hint("origin"),
1095 "git fetch origin main pull/84/head"
1096 );
1097 }
1098
1099 fn threads_page(has_next: bool) -> Value {
1101 json!({"data":{"repository":{"pullRequest":{"reviewThreads":{
1102 "pageInfo": {"hasNextPage": has_next, "endCursor": "Y3Vyc29y"},
1103 "nodes": [
1104 {"id":"PRRT_a","isResolved":true,"isOutdated":false,"line":39,"startLine":34,
1105 "diffSide":"RIGHT","path":"assets/record.vhs",
1106 "comments":{"nodes":[
1107 {"databaseId":3928619949_i64,"body":format!("root
1108
1109{}", marker("abc123")),"author":{"login":"alice"},
1110 "createdAt":"2026-09-03T20:53:12Z","replyTo":null,
1111 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo\n+Hide\n+Type \"y\""},
1112 {"databaseId":3928660390_i64,"body":"reply","author":{"login":"bob"},
1113 "createdAt":"2026-09-03T20:58:44Z","replyTo":{"databaseId":3928619949_i64},
1114 "diffHunk":"@@ -1,100 +1,227 @@\n+# The demo"}]}},
1115 {"id":"PRRT_b","isResolved":false,"isOutdated":true,"line":null,"startLine":null,
1116 "diffSide":"LEFT","path":"src/lib.rs",
1117 "comments":{"nodes":[
1118 {"databaseId":1,"body":"gone","author":null,
1119 "createdAt":"2026-09-03T20:58:48Z","replyTo":null,
1120 "diffHunk":"@@ -5,3 +5,2 @@\n line_5 = 5\n-line_6 = 6"}]}}
1121 ]}}}}})
1122 }
1123
1124 #[test]
1125 fn a_thread_page_maps_sides_lines_replies_and_the_last_diff_line() {
1126 let (threads, next) = parse_threads_page(&threads_page(true)).unwrap();
1127 assert_eq!(next.as_deref(), Some("Y3Vyc29y"));
1128 assert_eq!(threads.len(), 2);
1129
1130 let a = &threads[0];
1131 assert_eq!(a.id, "PRRT_a");
1132 assert!(a.resolved);
1133 assert_eq!(
1134 (a.side.as_str(), a.line, a.start_line),
1135 ("new", Some(39), Some(34))
1136 );
1137 assert_eq!(a.line_text.as_deref(), Some("Type \"y\""));
1138 assert_eq!(a.comments.len(), 2);
1139 assert_eq!(a.root().unwrap().id, "3928619949");
1140 assert_eq!(a.root().unwrap().body, "root");
1141 assert_eq!(a.root().unwrap().finding.as_deref(), Some("abc123"));
1142 assert_eq!(a.comments[1].finding, None);
1143 assert_eq!(a.comments[1].author, "bob");
1144
1145 let b = &threads[1];
1146 assert!(b.outdated);
1147 assert_eq!((b.side.as_str(), b.line), ("old", None));
1148 assert_eq!(b.line_text.as_deref(), Some("line_6 = 6"));
1149 assert_eq!(b.comments[0].author, "(deleted)");
1150
1151 let (_, none) = parse_threads_page(&threads_page(false)).unwrap();
1152 assert!(none.is_none());
1153 }
1154
1155 fn request() -> Request {
1156 Request {
1157 kind: ForgeKind::Github,
1158 project: "owner/repo".into(),
1159 id: "84".into(),
1160 base_ref: "main".into(),
1161 base_tip: "b".repeat(40),
1162 head: "h".repeat(40),
1163 merge_base: None,
1164 url: "https://github.com/owner/repo/pull/84".into(),
1165 }
1166 }
1167
1168 fn comment(finding: &str, side: &str, line: u32, start: Option<u32>, body: &str) -> NewComment {
1169 NewComment {
1170 finding: finding.into(),
1171 path: "src/lib.rs".into(),
1172 old_path: None,
1173 side: side.into(),
1174 line,
1175 start_line: start,
1176 other_line: None,
1177 span: None,
1178 body: body.into(),
1179 }
1180 }
1181
1182 #[test]
1183 fn a_review_body_is_one_comment_event_against_the_head() {
1184 let mut ranged = comment("f2", "old", 8, Some(6), "a range");
1185 ranged.old_path = Some("src/old.rs".into());
1186 let comments = vec![comment("f1", "new", 3, None, "one line"), ranged];
1187 let body = review_body(&request(), &comments);
1188 assert_eq!(body["commit_id"], json!("h".repeat(40)));
1189 assert_eq!(body["event"], json!("COMMENT"));
1190 let items = body["comments"].as_array().unwrap();
1191 assert_eq!(
1192 items[0],
1193 json!({"path":"src/lib.rs","body":"one line","line":3,"side":"RIGHT"})
1194 );
1195 assert_eq!(
1196 items[1],
1197 json!({"path":"src/lib.rs","body":"a range","line":8,"side":"LEFT",
1198 "start_line":6,"start_side":"LEFT"})
1199 );
1200 assert!(items[1].get("old_path").is_none());
1202 }
1203
1204 #[test]
1205 fn posted_comments_are_matched_back_to_their_findings() {
1206 let sent = vec![
1207 {
1208 let mut c = comment("f1", "new", 3, None, "x");
1209 c.path = "a.rs".into();
1210 c
1211 },
1212 {
1213 let mut c = comment("f2", "new", 9, None, "y");
1214 c.path = "a.rs".into();
1215 c
1216 },
1217 ];
1218 let posted = json!([
1220 {"id": 11, "path": "a.rs", "line": 9, "html_url": "https://x/9",
1221 "body": format!("y\r\n\r\n{}", marker("f2"))},
1222 {"id": 10, "path": "a.rs", "line": 3, "html_url": "https://x/3",
1223 "body": format!("x\r\n\r\n{}", marker("f1"))},
1224 {"id": 12, "path": "a.rs", "line": 3, "html_url": "https://x/3b",
1225 "body": "x"},
1226 ]);
1227 let got = match_published(&sent, &posted);
1228 assert_eq!(got.len(), 2);
1229 assert_eq!(
1230 (got[0].finding.as_str(), got[0].comment.as_str()),
1231 ("f1", "10")
1232 );
1233 assert_eq!(
1234 (got[1].finding.as_str(), got[1].comment.as_str()),
1235 ("f2", "11")
1236 );
1237 assert_eq!(got[1].url.as_deref(), Some("https://x/9"));
1238 assert!(got[0].thread.is_empty(), "REST never names the thread");
1239 }
1240
1241 #[test]
1242 fn a_gitlab_write_goes_as_field_flags_typed_by_shape() {
1243 let position = json!({"new_line": 3, "new_path": "a.rs"});
1244 let args = field_args(
1245 "POST",
1246 "projects/:id/merge_requests/7/draft_notes",
1247 &[
1248 ("note", &json!("why?\n\n<!-- m -->")),
1249 ("position", &position),
1250 ("resolved", &json!(true)),
1251 ],
1252 );
1253 assert_eq!(
1254 args,
1255 vec![
1256 "api",
1257 "--method",
1258 "POST",
1259 "projects/:id/merge_requests/7/draft_notes",
1260 "-f",
1261 "note=why?\n\n<!-- m -->",
1262 "-F",
1263 "position={\"new_line\":3,\"new_path\":\"a.rs\"}",
1264 "-F",
1265 "resolved=true",
1266 ]
1267 );
1268 }
1269
1270 #[test]
1271 fn the_last_diff_line_drops_its_marker() {
1272 assert_eq!(
1273 last_diff_line("@@ -1 +1 @@\n-old\n+new"),
1274 Some("new".into())
1275 );
1276 assert_eq!(
1277 last_diff_line("@@ -1 +1 @@\n context"),
1278 Some("context".into())
1279 );
1280 assert_eq!(last_diff_line("@@ -1 +1 @@"), None);
1281 }
1282
1283 const HEAD: &str = "1111111111111111111111111111111111111111";
1286
1287 fn mr_view() -> Value {
1288 json!({
1289 "iid": 12,
1290 "web_url": "https://gitlab.example.com/group/sub/proj/-/merge_requests/12",
1291 "source_branch": "feature",
1292 "target_branch": "main",
1293 "sha": HEAD,
1294 "diff_refs": {
1295 "base_sha": "2222222222222222222222222222222222222222",
1296 "start_sha": "3333333333333333333333333333333333333333",
1297 "head_sha": HEAD
1298 }
1299 })
1300 }
1301
1302 #[test]
1303 fn a_request_url_is_read_as_a_url() {
1304 let pr = |url: &str| {
1305 let v = json!({
1306 "number": 84, "baseRefName": "main",
1307 "baseRefOid": "e".repeat(40), "headRefOid": "d".repeat(40), "url": url
1308 });
1309 parse_request(&v).map(|r| r.project)
1310 };
1311 assert_eq!(
1313 pr("https://github.com/owner/repo/pull/84?w=1").unwrap(),
1314 "owner/repo"
1315 );
1316 assert_eq!(
1317 pr("https://github.com/owner/repo/pull/84#discussion").unwrap(),
1318 "owner/repo"
1319 );
1320 assert_eq!(
1321 pr("https://github.com/owner/repo/pull/84/").unwrap(),
1322 "owner/repo"
1323 );
1324 assert!(pr("not a url").is_err());
1325
1326 let mr = |url: &str| {
1327 let mut v = mr_view();
1328 v["web_url"] = json!(url);
1329 parse_mr(&v).map(|r| r.project)
1330 };
1331 assert_eq!(
1332 mr("https://gitlab.example.com:8443/group/sub/proj/-/merge_requests/12?tab=diffs")
1333 .unwrap(),
1334 "group/sub/proj"
1335 );
1336 assert_eq!(
1337 mr("https://gitlab.example.com/gr%C3%BCppe/proj/-/merge_requests/12").unwrap(),
1338 "grüppe/proj",
1339 "the path is decoded"
1340 );
1341 }
1342
1343 #[test]
1344 fn a_merge_request_is_read_from_glab_mr_view() {
1345 let req = parse_mr(&mr_view()).unwrap();
1346 assert_eq!(req.kind, ForgeKind::Gitlab);
1347 assert_eq!(req.project, "group/sub/proj");
1348 assert_eq!(req.id, "12");
1349 assert_eq!(req.base_ref, "main");
1350 assert_eq!(req.base_tip, "3".repeat(40));
1351 assert_eq!(req.head, HEAD);
1352 assert_eq!(req.merge_base.as_deref(), Some("2".repeat(40).as_str()));
1353 assert_eq!(
1354 req.fetch_hint("origin"),
1355 "git fetch origin main merge-requests/12/head"
1356 );
1357 }
1358
1359 fn note(id: i64, body: &str, author: &str, ty: Option<&str>, position: Option<Value>) -> Value {
1360 let mut n = json!({
1361 "id": id, "body": body, "system": false,
1362 "author": {"username": author},
1363 "created_at": "2026-09-04T09:00:00Z",
1364 "resolvable": true, "resolved": false,
1365 });
1366 n["type"] = ty.map_or(Value::Null, |t| json!(t));
1367 if let Some(p) = position {
1368 n["position"] = p;
1369 }
1370 n
1371 }
1372
1373 fn position(head: &str, new_line: Option<u32>, old_line: Option<u32>) -> Value {
1374 json!({
1375 "base_sha": "2".repeat(40), "start_sha": "3".repeat(40), "head_sha": head,
1376 "old_path": "src/lib.rs", "new_path": "src/lib.rs", "position_type": "text",
1377 "old_line": old_line, "new_line": new_line,
1378 })
1379 }
1380
1381 fn discussion_pages() -> Vec<Value> {
1383 let mut ranged = position(HEAD, Some(8), None);
1384 ranged["line_range"] = json!({
1385 "start": {"new_line": 6, "old_line": null, "type": "new"},
1386 "end": {"new_line": 8, "old_line": null, "type": "new"},
1387 });
1388 vec![
1389 json!([
1390 {"id": "d1", "individual_note": false, "notes": [
1391 note(101, &with_marker("why?", "f1"), "alice", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1392 note(102, &with_marker("because", "f2"), "bob", Some("DiffNote"), Some(position(HEAD, Some(3), None))),
1393 ]},
1394 {"id": "d2", "individual_note": false, "notes": [
1395 note(201, "old side", "carol", Some("DiffNote"), Some(position(HEAD, None, Some(5)))),
1396 ]},
1397 {"id": "d3", "individual_note": true, "notes": [
1399 note(301, "looks good", "dave", None, None),
1400 ]},
1401 ]),
1402 json!([
1403 {"id": "d4", "individual_note": false, "notes": [
1404 {"id": 401, "body": "changed the description", "system": true,
1405 "author": {"username": "bot"}, "created_at": "2026-09-04T09:00:00Z"},
1406 note(402, "stale", "erin", Some("DiffNote"), Some(position(&"0".repeat(40), Some(9), None))),
1407 ]},
1408 {"id": "d5", "individual_note": false, "notes": [
1409 note(501, "range", "frank", Some("DiffNote"), Some(ranged)),
1410 ]},
1411 ]),
1412 ]
1413 }
1414
1415 #[test]
1416 fn discussions_become_threads_and_only_diff_notes_count() {
1417 let threads = parse_discussions(&discussion_pages(), HEAD);
1418 let ids: Vec<&str> = threads.iter().map(|t| t.id.as_str()).collect();
1419 assert_eq!(ids, vec!["d1", "d2", "d4", "d5"], "d3 has no line");
1420
1421 let d1 = &threads[0];
1422 assert_eq!(
1423 (d1.side.as_str(), d1.line, d1.start_line),
1424 ("new", Some(3), None)
1425 );
1426 assert_eq!(d1.comments.len(), 2);
1427 assert_eq!(d1.root().unwrap().id, "101");
1428 assert_eq!(d1.comments[1].author, "bob");
1429 assert_eq!(d1.comments[0].body, "why?");
1431 assert_eq!(d1.comments[0].finding.as_deref(), Some("f1"));
1432 assert_eq!(d1.comments[1].finding.as_deref(), Some("f2"));
1433 assert_eq!(threads[1].comments[0].finding, None);
1434
1435 assert_eq!(
1436 (threads[1].side.as_str(), threads[1].line),
1437 ("old", Some(5))
1438 );
1439
1440 let d4 = &threads[2];
1442 assert!(d4.outdated);
1443 assert_eq!(d4.line, None);
1444 assert_eq!(d4.comments.len(), 1);
1445 assert_eq!(d4.comments[0].author, "erin");
1446
1447 let d5 = &threads[3];
1448 assert_eq!((d5.line, d5.start_line), (Some(8), Some(6)));
1449 }
1450
1451 #[test]
1452 fn a_draft_note_positions_by_three_shas_and_both_paths() {
1453 let req = parse_mr(&mr_view()).unwrap();
1454 let mut c = comment("f1", "new", 3, None, "one line");
1455 c.old_path = Some("src/old.rs".into());
1456 let pos: std::collections::HashMap<String, String> =
1457 draft_note_position(&req, &c).into_iter().collect();
1458 assert_eq!(pos["position[position_type]"], "text");
1459 assert_eq!(pos["position[base_sha]"], "2".repeat(40));
1460 assert_eq!(pos["position[start_sha]"], "3".repeat(40));
1461 assert_eq!(pos["position[head_sha]"], HEAD);
1462 assert_eq!(pos["position[new_path]"], "src/lib.rs");
1463 assert_eq!(pos["position[old_path]"], "src/old.rs");
1464 assert_eq!(pos["position[new_line]"], "3");
1465 assert!(!pos.contains_key("position[old_line]"));
1466
1467 let mut ctx = comment("f3", "new", 12, None, "context");
1469 ctx.other_line = Some(11);
1470 let both: std::collections::HashMap<String, String> =
1471 draft_note_position(&req, &ctx).into_iter().collect();
1472 assert_eq!(both["position[new_line]"], "12");
1473 assert_eq!(both["position[old_line]"], "11");
1474
1475 let ranged = comment("f2", "old", 8, Some(6), "a range");
1478 let rpos: std::collections::HashMap<String, String> =
1479 draft_note_position(&req, &ranged).into_iter().collect();
1480 assert_eq!(rpos["position[old_line]"], "8");
1481 assert!(!rpos.contains_key("position[new_line]"));
1482
1483 let query = encode_query(&draft_note_position(&req, &c));
1486 assert!(
1487 query.contains("position%5Bnew_path%5D=src%2Flib.rs"),
1488 "{query}"
1489 );
1490 assert!(!query.contains('/'), "{query}");
1491 }
1492
1493 #[test]
1494 fn a_multi_line_draft_note_carries_a_line_range_at_each_end() {
1495 use crate::forge::LineEnd;
1496 let req = parse_mr(&mr_view()).unwrap();
1497 let mut c = comment("f4", "new", 34, Some(15), "a run of lines");
1498 c.span = Some(crate::forge::LineSpan {
1501 start: LineEnd {
1502 kind: "new",
1503 old: 0,
1504 new: 15,
1505 old_line: None,
1506 new_line: Some(15),
1507 },
1508 end: LineEnd {
1509 kind: "new",
1510 old: 0,
1511 new: 34,
1512 old_line: None,
1513 new_line: Some(34),
1514 },
1515 });
1516 let pos: std::collections::HashMap<String, String> =
1517 draft_note_position(&req, &c).into_iter().collect();
1518 let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1519 assert_eq!(pos["position[line_range][start][line_code]"], code(0, 15));
1520 assert_eq!(pos["position[line_range][start][type]"], "new");
1521 assert_eq!(pos["position[line_range][start][new_line]"], "15");
1522 assert!(!pos.contains_key("position[line_range][start][old_line]"));
1523 assert_eq!(pos["position[line_range][end][line_code]"], code(0, 34));
1524 assert_eq!(pos["position[line_range][end][new_line]"], "34");
1525 assert_eq!(pos["position[new_line]"], "34");
1527 assert!(code(0, 34).ends_with("_0_34"), "{}", code(0, 34));
1530 assert_eq!(code(0, 34).len(), 40 + "_0_34".len());
1531 }
1532
1533 #[test]
1534 fn a_deleted_range_takes_the_new_side_position_not_zero() {
1535 use crate::forge::LineEnd;
1536 let req = parse_mr(&mr_view()).unwrap();
1537 let mut c = comment("f5", "old", 2953, Some(2950), "on a deleted run");
1538 c.span = Some(crate::forge::LineSpan {
1541 start: LineEnd {
1542 kind: "old",
1543 old: 2950,
1544 new: 2952,
1545 old_line: Some(2950),
1546 new_line: None,
1547 },
1548 end: LineEnd {
1549 kind: "old",
1550 old: 2953,
1551 new: 2952,
1552 old_line: Some(2953),
1553 new_line: None,
1554 },
1555 });
1556 let pos: std::collections::HashMap<String, String> =
1557 draft_note_position(&req, &c).into_iter().collect();
1558 let code = |old: u32, new: u32| line_code("src/lib.rs", old, new);
1559 assert_eq!(
1560 pos["position[line_range][start][line_code]"],
1561 code(2950, 2952)
1562 );
1563 assert_eq!(pos["position[line_range][start][type]"], "old");
1564 assert_eq!(pos["position[line_range][start][old_line]"], "2950");
1565 assert!(!pos.contains_key("position[line_range][start][new_line]"));
1566 assert_eq!(
1567 pos["position[line_range][end][line_code]"],
1568 code(2953, 2952)
1569 );
1570 }
1571
1572 #[test]
1573 fn published_notes_are_matched_from_the_refetched_discussions() {
1574 let sent = vec![
1575 comment("f1", "new", 3, None, &with_marker("why?", "f1")),
1576 comment("f2", "new", 3, None, &with_marker("because", "f2")),
1577 ];
1578 let got = match_gitlab_published(&sent, &discussion_pages());
1579 assert_eq!(got.len(), 2);
1580 assert_eq!(
1581 (
1582 got[0].finding.as_str(),
1583 got[0].thread.as_str(),
1584 got[0].comment.as_str()
1585 ),
1586 ("f1", "d1", "101")
1587 );
1588 assert_eq!(
1589 (
1590 got[1].finding.as_str(),
1591 got[1].thread.as_str(),
1592 got[1].comment.as_str()
1593 ),
1594 ("f2", "d1", "102")
1595 );
1596 }
1597
1598 #[cfg(unix)]
1603 fn scripted_glab(dir: &Path) -> String {
1604 use std::os::unix::fs::PermissionsExt;
1605 let pages: Vec<String> = discussion_pages()
1606 .iter()
1607 .map(|p| serde_json::to_string(p).unwrap())
1608 .collect();
1609 std::fs::write(dir.join("pages.json"), pages.join("\n")).unwrap();
1610 let script = dir.join("glab");
1611 std::fs::write(
1612 &script,
1613 r#"#!/bin/sh
1614dir=$(dirname "$0")
1615printf '%s\n' "$*" >> "$dir/calls"
1616mode=$(cat "$dir/mode")
1617case "$*" in
1618 *"/discussions/bad/notes"*) echo '{"error":"body is invalid"}'; echo "glab: HTTP 400" >&2; exit 1 ;;
1619 *"/discussions/"*"/notes"*) echo '{"id": 555, "body": "because"}' ;;
1620 *"/draft_notes/bulk_publish"*)
1621 if [ "$mode" = "bulk-fails" ]; then echo "glab: HTTP 500" >&2; exit 1; fi ;;
1622 *"/draft_notes"*) echo '{"id": 9}' ;;
1623 *"--paginate"*) cat "$dir/pages.json" ;;
1624 *) echo "unexpected: $*" >&2; exit 2 ;;
1625esac
1626"#,
1627 )
1628 .unwrap();
1629 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1630 script.to_string_lossy().into_owned()
1631 }
1632
1633 #[cfg(unix)]
1634 fn calls(dir: &Path) -> String {
1635 std::fs::read_to_string(dir.join("calls")).unwrap_or_default()
1636 }
1637
1638 #[cfg(unix)]
1639 fn mixed_batch(reply_thread: &str) -> Batch {
1640 Batch {
1641 comments: vec![comment("f1", "new", 3, None, &with_marker("why?", "f1"))],
1642 replies: vec![NewReply {
1643 finding: "f2".into(),
1644 thread: reply_thread.into(),
1645 root_comment: "101".into(),
1646 body: with_marker("because", "f2"),
1647 }],
1648 }
1649 }
1650
1651 #[test]
1654 #[cfg(unix)]
1655 fn a_gitlab_publish_records_everything_live_before_it_reports_a_failure() {
1656 let dir = tempfile::TempDir::new().unwrap();
1657 let glab = scripted_glab(dir.path());
1658 let forge = GlabForge::with_tool(&glab, dir.path());
1659 let req = parse_mr(&mr_view()).unwrap();
1660
1661 std::fs::write(dir.path().join("mode"), "ok").unwrap();
1664 let err = forge.publish(&req, &mixed_batch("bad")).unwrap_err();
1665 assert!(err.to_string().contains("HTTP 400"), "{err}");
1667 assert!(err.to_string().contains("body is invalid"), "{err}");
1668 assert!(
1669 !calls(dir.path()).contains("draft_notes"),
1670 "{}",
1671 calls(dir.path())
1672 );
1673
1674 std::fs::remove_file(dir.path().join("calls")).unwrap();
1678 std::fs::write(dir.path().join("mode"), "bulk-fails").unwrap();
1679 let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1680 assert_eq!(sent.published.len(), 1);
1681 assert_eq!(
1682 (
1683 sent.published[0].finding.as_str(),
1684 sent.published[0].thread.as_str()
1685 ),
1686 ("f2", "d1")
1687 );
1688 assert_eq!(sent.published[0].comment, "555");
1689 assert!(sent.failed.is_some());
1690 assert!(sent.threads.is_none());
1691 let log = calls(dir.path());
1692 assert!(log.contains("draft_notes/bulk_publish"), "{log}");
1693 assert!(!log.contains("--paginate"), "{log}");
1694
1695 std::fs::write(dir.path().join("mode"), "ok").unwrap();
1698 let sent = forge.publish(&req, &mixed_batch("d1")).unwrap();
1699 assert!(sent.failed.is_none());
1700 let mut named: Vec<(&str, &str, &str)> = sent
1701 .published
1702 .iter()
1703 .map(|p| (p.finding.as_str(), p.thread.as_str(), p.comment.as_str()))
1704 .collect();
1705 named.sort();
1706 assert_eq!(named, vec![("f1", "d1", "101"), ("f2", "d1", "555")]);
1707 assert_eq!(sent.threads.as_ref().map(Vec::len), Some(4));
1708 }
1709}