Skip to main content

declutter/
upload.rs

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
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 one note to a pull request as a comment on its line.
16pub trait Poster {
17    fn post(&self, pr: &PullRequest, note: &Note) -> Result<()>;
18}
19
20/// Posts with the `az` or `gh` CLI, as whoever they are signed in as.
21pub struct CliPoster {
22    /// The repository the PR was fetched into, for its head commit.
23    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                // GitHub only takes line comments inside its own hunks; elsewhere the
52                // note goes on the file, naming its line.
53                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
82/// The Azure DevOps request that opens a thread on the note's line: its URL and body.
83/// A removed line is anchored on the left (target) side of the diff.
84pub 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    // A line-only anchor is rejected: the offsets are required, and 1 → 2 marks the
96    // start of the line.
97    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
114/// The `gh api` arguments that comment on the note's line at the PR's head commit, or,
115/// with `on_file`, on the file as a whole with the line named in the text.
116pub 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
170/// After a review of a pull request, offers to post its notes there. Lists what would
171/// be posted, asks, and posts only on an explicit yes. Posted notes leave the store;
172/// notes that fail stay, so nothing is posted twice and nothing is lost.
173pub 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 &notes {
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 &notes {
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}