Skip to main content

declutter/
upload.rs

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
12/// Azure DevOps' resource id, for asking `az` for a token that reaches it.
13const AZURE_DEVOPS: &str = "499b84ac-1321-427f-aa17-267ca6975798";
14
15/// Posts notes to a pull request, each as a comment on its line (or on the change as a
16/// whole), and says where each one landed — or why it didn't.
17pub trait Poster {
18    fn post(&self, pr: &PullRequest, notes: &[Note]) -> Vec<Result<Link>>;
19}
20
21/// Posts with the `az` or `gh` CLI, as whoever they are signed in as.
22pub struct CliPoster {
23    /// The repository the PR was fetched into, for its head commit.
24    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            // Azure DevOps has no batch: one thread per note.
31            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                // One review, so the author hears about it once. GitHub refuses the
67                // whole review if any line is outside its diff; then each note goes on
68                // its own, on the file when its line is refused.
69                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
97/// Runs a CLI, optionally feeding it `input`, and reads its JSON answer.
98fn 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
127/// The `id` of a created thread, comment or review, as text.
128fn 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
143/// The page of an Azure DevOps pull request, opened at one of its threads.
144pub 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
157/// The Azure DevOps request that opens a thread on the note's line: its URL and body.
158/// A removed line is anchored on the left (target) side of the diff; a note on the
159/// change as a whole has no anchor.
160pub 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    // A line-only anchor is rejected: the offsets are required, and 1 → 2 marks the
176    // start of the line.
177    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
194/// The `gh api` arguments that comment on the note's line at the PR's head commit, or,
195/// with `on_file`, on the file as a whole with the line named in the text.
196pub 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
238/// The `gh api` arguments and JSON body for one review holding every note: the note on
239/// the change as a whole as its text, the others as comments on their lines.
240pub 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    // GitHub wants a body with a COMMENT review, even an empty one.
261    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
280/// The `gh api` arguments for a comment on the pull request itself.
281fn 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
308/// After a review of a pull request, offers to post its notes there. Lists what would
309/// be posted, asks — unless `yes` already said so — and posts only on an explicit yes.
310/// Drafts nobody has opened are never posted. Posted notes leave the store for the
311/// posted log; notes that fail stay, so nothing is posted twice and nothing is lost.
312pub 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 &notes {
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, &notes)) {
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}