1use std::io::{BufRead, Write};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6use serde_json::{Value, json};
7
8use crate::git::git;
9use crate::pr::{PullRequest, Repo};
10use crate::store::{Note, NoteSide, NoteStore};
11
12const AZURE_DEVOPS: &str = "499b84ac-1321-427f-aa17-267ca6975798";
14
15pub trait Poster {
17 fn post(&self, pr: &PullRequest, note: &Note) -> Result<()>;
18}
19
20pub struct CliPoster {
22 pub dir: PathBuf,
24}
25
26impl Poster for CliPoster {
27 fn post(&self, pr: &PullRequest, note: &Note) -> Result<()> {
28 match &pr.repo {
29 Repo::AzureDevOps { .. } => {
30 let (url, body) = azure_thread(pr, note);
31 run(
32 "az",
33 &[
34 "rest",
35 "--method",
36 "post",
37 "--uri",
38 &url,
39 "--resource",
40 AZURE_DEVOPS,
41 "--headers",
42 "Content-Type=application/json",
43 "--body",
44 &body.to_string(),
45 ],
46 )
47 }
48 Repo::GitHub { .. } => {
49 let head = head_commit(&self.dir, pr)?;
50 let on_line = github_comment(pr, note, &head, false);
51 run("gh", &on_line).or_else(|_| run("gh", &github_comment(pr, note, &head, true)))
54 }
55 }
56 }
57}
58
59fn head_commit(dir: &Path, pr: &PullRequest) -> Result<String> {
60 let out = git(dir, &["rev-parse", &pr.head_ref()])?;
61 Ok(String::from_utf8(out)?.trim().to_string())
62}
63
64fn run<S: AsRef<std::ffi::OsStr>>(program: &str, args: &[S]) -> Result<()> {
65 let output = Command::new(program)
66 .args(args)
67 .output()
68 .with_context(|| format!("failed to run `{program}`"))?;
69 if !output.status.success() {
70 bail!(
71 "{}",
72 String::from_utf8_lossy(&output.stderr)
73 .lines()
74 .find(|line| !line.trim().is_empty())
75 .unwrap_or("failed")
76 .trim()
77 );
78 }
79 Ok(())
80}
81
82pub fn azure_thread(pr: &PullRequest, note: &Note) -> (String, Value) {
85 let Repo::AzureDevOps { org, project, name } = &pr.repo else {
86 unreachable!("an Azure DevOps thread for a GitHub PR");
87 };
88 let url = format!(
89 "https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads?api-version=7.1",
90 encode(org),
91 encode(project),
92 encode(name),
93 pr.number
94 );
95 let start = json!({ "line": note.line, "offset": 1 });
98 let end = json!({ "line": note.line, "offset": 2 });
99 let mut context = json!({ "filePath": format!("/{}", note.path) });
100 let (start_key, end_key) = match note.side {
101 NoteSide::New => ("rightFileStart", "rightFileEnd"),
102 NoteSide::Old => ("leftFileStart", "leftFileEnd"),
103 };
104 context[start_key] = start;
105 context[end_key] = end;
106 let body = json!({
107 "comments": [{ "parentCommentId": 0, "content": note.text.trim(), "commentType": 1 }],
108 "status": "active",
109 "threadContext": context,
110 });
111 (url, body)
112}
113
114pub fn github_comment(pr: &PullRequest, note: &Note, head: &str, on_file: bool) -> Vec<String> {
117 let Repo::GitHub { owner, name } = &pr.repo else {
118 unreachable!("a GitHub comment for an Azure DevOps PR");
119 };
120 let mut args = vec![
121 "api".to_string(),
122 "--method".to_string(),
123 "POST".to_string(),
124 format!("repos/{owner}/{name}/pulls/{}/comments", pr.number),
125 "-f".to_string(),
126 format!("commit_id={head}"),
127 "-f".to_string(),
128 format!("path={}", note.path),
129 ];
130 if on_file {
131 let which = match note.side {
132 NoteSide::New => "Line",
133 NoteSide::Old => "Removed line",
134 };
135 args.extend([
136 "-f".to_string(),
137 "subject_type=file".to_string(),
138 "-f".to_string(),
139 format!("body={which} {}: {}", note.line, note.text.trim()),
140 ]);
141 } else {
142 let side = match note.side {
143 NoteSide::New => "RIGHT",
144 NoteSide::Old => "LEFT",
145 };
146 args.extend([
147 "-F".to_string(),
148 format!("line={}", note.line),
149 "-f".to_string(),
150 format!("side={side}"),
151 "-f".to_string(),
152 format!("body={}", note.text.trim()),
153 ]);
154 }
155 args
156}
157
158fn encode(segment: &str) -> String {
159 segment
160 .bytes()
161 .map(|byte| match byte {
162 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
163 (byte as char).to_string()
164 }
165 _ => format!("%{byte:02X}"),
166 })
167 .collect()
168}
169
170pub fn offer_upload(
174 store: &mut NoteStore,
175 pr: &PullRequest,
176 poster: &dyn Poster,
177 input: &mut impl BufRead,
178 out: &mut impl Write,
179) -> Result<()> {
180 let notes: Vec<Note> = store.notes().into_iter().cloned().collect();
181 if notes.is_empty() {
182 return Ok(());
183 }
184 let plural = if notes.len() == 1 { "" } else { "s" };
185 writeln!(
186 out,
187 "\nYou left {} note{plural} in this review:",
188 notes.len()
189 )?;
190 for note in ¬es {
191 writeln!(out, " {} {}", note.place(), note.text.trim())?;
192 }
193 write!(
194 out,
195 "Post {} comment{plural} to PR {}? [y/N] ",
196 notes.len(),
197 pr.number
198 )?;
199 out.flush()?;
200 let mut answer = String::new();
201 input.read_line(&mut answer)?;
202 if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
203 writeln!(out, "Not posted. The notes are kept for next time.")?;
204 return Ok(());
205 }
206
207 let mut posted = Vec::new();
208 for note in ¬es {
209 match poster.post(pr, note) {
210 Ok(()) => posted.push(note.clone()),
211 Err(error) => writeln!(out, " could not post {}: {error:#}", note.place())?,
212 }
213 }
214 store.remove(&posted)?;
215 let failed = notes.len() - posted.len();
216 match failed {
217 0 => writeln!(
218 out,
219 "Posted {} comment{plural} to PR {}.",
220 posted.len(),
221 pr.number
222 )?,
223 _ => writeln!(
224 out,
225 "Posted {} of {} comments to PR {}; the {failed} that failed are kept.",
226 posted.len(),
227 notes.len(),
228 pr.number
229 )?,
230 }
231 Ok(())
232}