1use std::io::{BufRead, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use anyhow::{Context, Result, anyhow, bail};
6use serde_json::{Value, json};
7
8use crate::git::git;
9use crate::pr::{PullRequest, Repo};
10use crate::store::{Link, Note, NoteSide, NoteStore};
11
12const AZURE_DEVOPS: &str = "499b84ac-1321-427f-aa17-267ca6975798";
14
15pub trait Poster {
18 fn post(&self, pr: &PullRequest, notes: &[Note]) -> Vec<Result<Link>>;
19}
20
21pub struct CliPoster {
23 pub dir: PathBuf,
25}
26
27impl Poster for CliPoster {
28 fn post(&self, pr: &PullRequest, notes: &[Note]) -> Vec<Result<Link>> {
29 match &pr.repo {
30 Repo::AzureDevOps { .. } => notes
32 .iter()
33 .map(|note| {
34 let (url, body) = azure_thread(pr, note);
35 let thread = run(
36 "az",
37 &[
38 "rest",
39 "--method",
40 "post",
41 "--uri",
42 &url,
43 "--resource",
44 AZURE_DEVOPS,
45 "--headers",
46 "Content-Type=application/json",
47 "--body",
48 &body.to_string(),
49 ],
50 None,
51 )?;
52 let id = id_of(&thread);
53 Ok(Link {
54 url: azure_link(pr, &id),
55 id,
56 })
57 })
58 .collect(),
59 Repo::GitHub { .. } => {
60 let head = match head_commit(&self.dir, pr) {
61 Ok(head) => head,
62 Err(error) => {
63 return notes.iter().map(|_| Err(anyhow!("{error:#}"))).collect();
64 }
65 };
66 let (args, body) = github_review(pr, notes, &head);
70 if let Ok(review) = run("gh", &args, Some(&body.to_string())) {
71 let link = github_link(&review);
72 return notes.iter().map(|_| Ok(link.clone())).collect();
73 }
74 notes
75 .iter()
76 .map(|note| {
77 let comment = if note.is_general() {
78 run("gh", &github_issue_comment(pr, note), None)?
79 } else {
80 run("gh", &github_comment(pr, note, &head, false), None).or_else(
81 |_| run("gh", &github_comment(pr, note, &head, true), None),
82 )?
83 };
84 Ok(github_link(&comment))
85 })
86 .collect()
87 }
88 }
89 }
90}
91
92fn head_commit(dir: &Path, pr: &PullRequest) -> Result<String> {
93 let out = git(dir, &["rev-parse", &pr.head_ref()])?;
94 Ok(String::from_utf8(out)?.trim().to_string())
95}
96
97fn run<S: AsRef<std::ffi::OsStr>>(program: &str, args: &[S], input: Option<&str>) -> Result<Value> {
99 let mut child = Command::new(program)
100 .args(args)
101 .stdin(if input.is_some() {
102 Stdio::piped()
103 } else {
104 Stdio::null()
105 })
106 .stdout(Stdio::piped())
107 .stderr(Stdio::piped())
108 .spawn()
109 .with_context(|| format!("failed to run `{program}`"))?;
110 if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
111 stdin.write_all(input.as_bytes())?;
112 }
113 let output = child.wait_with_output()?;
114 if !output.status.success() {
115 bail!(
116 "{}",
117 String::from_utf8_lossy(&output.stderr)
118 .lines()
119 .find(|line| !line.trim().is_empty())
120 .unwrap_or("failed")
121 .trim()
122 );
123 }
124 Ok(serde_json::from_slice(&output.stdout).unwrap_or(Value::Null))
125}
126
127fn id_of(created: &Value) -> String {
129 match &created["id"] {
130 Value::Number(id) => id.to_string(),
131 Value::String(id) => id.clone(),
132 _ => String::new(),
133 }
134}
135
136fn github_link(created: &Value) -> Link {
137 Link {
138 id: id_of(created),
139 url: created["html_url"].as_str().unwrap_or_default().to_string(),
140 }
141}
142
143pub fn azure_link(pr: &PullRequest, thread: &str) -> String {
145 let Repo::AzureDevOps { org, project, name } = &pr.repo else {
146 unreachable!("an Azure DevOps link for a GitHub PR");
147 };
148 format!(
149 "https://dev.azure.com/{}/{}/_git/{}/pullrequest/{}?discussionId={thread}",
150 encode(org),
151 encode(project),
152 encode(name),
153 pr.number
154 )
155}
156
157pub fn azure_thread(pr: &PullRequest, note: &Note) -> (String, Value) {
161 let Repo::AzureDevOps { org, project, name } = &pr.repo else {
162 unreachable!("an Azure DevOps thread for a GitHub PR");
163 };
164 let url = format!(
165 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads?api-version=7.1",
166 encode(org),
167 encode(project),
168 encode(name),
169 pr.number
170 );
171 let comments = json!([{ "parentCommentId": 0, "content": note.text.trim(), "commentType": 1 }]);
172 if note.is_general() {
173 return (url, json!({ "comments": comments, "status": "active" }));
174 }
175 let start = json!({ "line": note.line, "offset": 1 });
178 let end = json!({ "line": note.line, "offset": 2 });
179 let mut context = json!({ "filePath": format!("/{}", note.path) });
180 let (start_key, end_key) = match note.side {
181 NoteSide::New => ("rightFileStart", "rightFileEnd"),
182 NoteSide::Old => ("leftFileStart", "leftFileEnd"),
183 };
184 context[start_key] = start;
185 context[end_key] = end;
186 let body = json!({
187 "comments": comments,
188 "status": "active",
189 "threadContext": context,
190 });
191 (url, body)
192}
193
194pub fn github_comment(pr: &PullRequest, note: &Note, head: &str, on_file: bool) -> Vec<String> {
197 let Repo::GitHub { owner, name } = &pr.repo else {
198 unreachable!("a GitHub comment for an Azure DevOps PR");
199 };
200 let mut args = vec![
201 "api".to_string(),
202 "--method".to_string(),
203 "POST".to_string(),
204 format!("repos/{owner}/{name}/pulls/{}/comments", pr.number),
205 "-f".to_string(),
206 format!("commit_id={head}"),
207 "-f".to_string(),
208 format!("path={}", note.path),
209 ];
210 if on_file {
211 let which = match note.side {
212 NoteSide::New => "Line",
213 NoteSide::Old => "Removed line",
214 };
215 args.extend([
216 "-f".to_string(),
217 "subject_type=file".to_string(),
218 "-f".to_string(),
219 format!("body={which} {}: {}", note.line, note.text.trim()),
220 ]);
221 } else {
222 let side = match note.side {
223 NoteSide::New => "RIGHT",
224 NoteSide::Old => "LEFT",
225 };
226 args.extend([
227 "-F".to_string(),
228 format!("line={}", note.line),
229 "-f".to_string(),
230 format!("side={side}"),
231 "-f".to_string(),
232 format!("body={}", note.text.trim()),
233 ]);
234 }
235 args
236}
237
238pub fn github_review(pr: &PullRequest, notes: &[Note], head: &str) -> (Vec<String>, Value) {
241 let Repo::GitHub { owner, name } = &pr.repo else {
242 unreachable!("a GitHub review for an Azure DevOps PR");
243 };
244 let args = [
245 "api",
246 "--method",
247 "POST",
248 &format!("repos/{owner}/{name}/pulls/{}/reviews", pr.number),
249 "--input",
250 "-",
251 ]
252 .map(String::from)
253 .to_vec();
254 let mut body = json!({ "commit_id": head, "event": "COMMENT" });
255 let general: Vec<&str> = notes
256 .iter()
257 .filter(|note| note.is_general())
258 .map(|note| note.text.trim())
259 .collect();
260 body["body"] = json!(general.join("\n\n"));
262 body["comments"] = notes
263 .iter()
264 .filter(|note| !note.is_general())
265 .map(|note| {
266 json!({
267 "path": note.path,
268 "line": note.line,
269 "side": match note.side {
270 NoteSide::New => "RIGHT",
271 NoteSide::Old => "LEFT",
272 },
273 "body": note.text.trim(),
274 })
275 })
276 .collect();
277 (args, body)
278}
279
280fn github_issue_comment(pr: &PullRequest, note: &Note) -> Vec<String> {
282 let Repo::GitHub { owner, name } = &pr.repo else {
283 unreachable!("a GitHub comment for an Azure DevOps PR");
284 };
285 [
286 "api".to_string(),
287 "--method".to_string(),
288 "POST".to_string(),
289 format!("repos/{owner}/{name}/issues/{}/comments", pr.number),
290 "-f".to_string(),
291 format!("body={}", note.text.trim()),
292 ]
293 .to_vec()
294}
295
296fn encode(segment: &str) -> String {
297 segment
298 .bytes()
299 .map(|byte| match byte {
300 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
301 (byte as char).to_string()
302 }
303 _ => format!("%{byte:02X}"),
304 })
305 .collect()
306}
307
308pub fn offer_upload(
313 store: &mut NoteStore,
314 pr: &PullRequest,
315 poster: &dyn Poster,
316 yes: bool,
317 input: &mut impl BufRead,
318 out: &mut impl Write,
319) -> Result<()> {
320 let (drafts, notes): (Vec<Note>, Vec<Note>) = store
321 .notes()
322 .into_iter()
323 .cloned()
324 .partition(|note| note.draft);
325 let held = match drafts.len() {
326 0 => String::new(),
327 1 => "1 draft note not opened — kept".to_string(),
328 n => format!("{n} draft notes not opened — kept"),
329 };
330 if notes.is_empty() {
331 if !drafts.is_empty() {
332 writeln!(
333 out,
334 "\n{held}. Open a draft with m in the viewer (P for the note on the whole \
335 change) to make it yours; then it can be posted."
336 )?;
337 }
338 return Ok(());
339 }
340 let plural = if notes.len() == 1 { "" } else { "s" };
341 writeln!(
342 out,
343 "\nYou left {} note{plural} in this review:",
344 notes.len()
345 )?;
346 for note in ¬es {
347 let mut lines = note.text.trim().lines();
348 let first = lines.next().unwrap_or_default();
349 let more = if lines.next().is_some() { " …" } else { "" };
350 writeln!(out, " {} {first}{more}", note.place())?;
351 }
352 if !drafts.is_empty() {
353 writeln!(out, " ({held})")?;
354 }
355 if yes {
356 writeln!(
357 out,
358 "Posting {} comment{plural} to PR {}.",
359 notes.len(),
360 pr.number
361 )?;
362 } else {
363 write!(
364 out,
365 "Post {} comment{plural} to PR {}? [y/N] ",
366 notes.len(),
367 pr.number
368 )?;
369 out.flush()?;
370 let mut answer = String::new();
371 input.read_line(&mut answer)?;
372 if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
373 writeln!(out, "Not posted. The notes are kept for next time.")?;
374 return Ok(());
375 }
376 }
377
378 let mut posted = Vec::new();
379 for (note, result) in notes.iter().zip(poster.post(pr, ¬es)) {
380 match result {
381 Ok(link) => posted.push((note.clone(), link)),
382 Err(error) => writeln!(out, " could not post {}: {error:#}", note.place())?,
383 }
384 }
385 store.record_posted(&posted)?;
386 let failed = notes.len() - posted.len();
387 match failed {
388 0 => writeln!(
389 out,
390 "Posted {} comment{plural} to PR {}.",
391 posted.len(),
392 pr.number
393 )?,
394 _ => writeln!(
395 out,
396 "Posted {} of {} comments to PR {}; the {failed} that failed are kept.",
397 posted.len(),
398 notes.len(),
399 pr.number
400 )?,
401 }
402 Ok(())
403}