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