use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use crate::git::git;
use crate::pr::{PullRequest, Repo};
use crate::store::{Note, NoteSide, NoteStore};
const AZURE_DEVOPS: &str = "499b84ac-1321-427f-aa17-267ca6975798";
pub trait Poster {
fn post(&self, pr: &PullRequest, note: &Note) -> Result<()>;
}
pub struct CliPoster {
pub dir: PathBuf,
}
impl Poster for CliPoster {
fn post(&self, pr: &PullRequest, note: &Note) -> Result<()> {
match &pr.repo {
Repo::AzureDevOps { .. } => {
let (url, body) = azure_thread(pr, note);
run(
"az",
&[
"rest",
"--method",
"post",
"--uri",
&url,
"--resource",
AZURE_DEVOPS,
"--headers",
"Content-Type=application/json",
"--body",
&body.to_string(),
],
)
}
Repo::GitHub { .. } => {
let head = head_commit(&self.dir, pr)?;
let on_line = github_comment(pr, note, &head, false);
run("gh", &on_line).or_else(|_| run("gh", &github_comment(pr, note, &head, true)))
}
}
}
}
fn head_commit(dir: &Path, pr: &PullRequest) -> Result<String> {
let out = git(dir, &["rev-parse", &pr.head_ref()])?;
Ok(String::from_utf8(out)?.trim().to_string())
}
fn run<S: AsRef<std::ffi::OsStr>>(program: &str, args: &[S]) -> Result<()> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("failed to run `{program}`"))?;
if !output.status.success() {
bail!(
"{}",
String::from_utf8_lossy(&output.stderr)
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("failed")
.trim()
);
}
Ok(())
}
pub fn azure_thread(pr: &PullRequest, note: &Note) -> (String, Value) {
let Repo::AzureDevOps { org, project, name } = &pr.repo else {
unreachable!("an Azure DevOps thread for a GitHub PR");
};
let url = format!(
"https://dev.azure.com/{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads?api-version=7.1",
encode(org),
encode(project),
encode(name),
pr.number
);
let start = json!({ "line": note.line, "offset": 1 });
let end = json!({ "line": note.line, "offset": 2 });
let mut context = json!({ "filePath": format!("/{}", note.path) });
let (start_key, end_key) = match note.side {
NoteSide::New => ("rightFileStart", "rightFileEnd"),
NoteSide::Old => ("leftFileStart", "leftFileEnd"),
};
context[start_key] = start;
context[end_key] = end;
let body = json!({
"comments": [{ "parentCommentId": 0, "content": note.text.trim(), "commentType": 1 }],
"status": "active",
"threadContext": context,
});
(url, body)
}
pub fn github_comment(pr: &PullRequest, note: &Note, head: &str, on_file: bool) -> Vec<String> {
let Repo::GitHub { owner, name } = &pr.repo else {
unreachable!("a GitHub comment for an Azure DevOps PR");
};
let mut args = vec![
"api".to_string(),
"--method".to_string(),
"POST".to_string(),
format!("repos/{owner}/{name}/pulls/{}/comments", pr.number),
"-f".to_string(),
format!("commit_id={head}"),
"-f".to_string(),
format!("path={}", note.path),
];
if on_file {
let which = match note.side {
NoteSide::New => "Line",
NoteSide::Old => "Removed line",
};
args.extend([
"-f".to_string(),
"subject_type=file".to_string(),
"-f".to_string(),
format!("body={which} {}: {}", note.line, note.text.trim()),
]);
} else {
let side = match note.side {
NoteSide::New => "RIGHT",
NoteSide::Old => "LEFT",
};
args.extend([
"-F".to_string(),
format!("line={}", note.line),
"-f".to_string(),
format!("side={side}"),
"-f".to_string(),
format!("body={}", note.text.trim()),
]);
}
args
}
fn encode(segment: &str) -> String {
segment
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
pub fn offer_upload(
store: &mut NoteStore,
pr: &PullRequest,
poster: &dyn Poster,
input: &mut impl BufRead,
out: &mut impl Write,
) -> Result<()> {
let notes: Vec<Note> = store.notes().into_iter().cloned().collect();
if notes.is_empty() {
return Ok(());
}
let plural = if notes.len() == 1 { "" } else { "s" };
writeln!(
out,
"\nYou left {} note{plural} in this review:",
notes.len()
)?;
for note in ¬es {
writeln!(out, " {} {}", note.place(), note.text.trim())?;
}
write!(
out,
"Post {} comment{plural} to PR {}? [y/N] ",
notes.len(),
pr.number
)?;
out.flush()?;
let mut answer = String::new();
input.read_line(&mut answer)?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
writeln!(out, "Not posted. The notes are kept for next time.")?;
return Ok(());
}
let mut posted = Vec::new();
for note in ¬es {
match poster.post(pr, note) {
Ok(()) => posted.push(note.clone()),
Err(error) => writeln!(out, " could not post {}: {error:#}", note.place())?,
}
}
store.remove(&posted)?;
let failed = notes.len() - posted.len();
match failed {
0 => writeln!(
out,
"Posted {} comment{plural} to PR {}.",
posted.len(),
pr.number
)?,
_ => writeln!(
out,
"Posted {} of {} comments to PR {}; the {failed} that failed are kept.",
posted.len(),
notes.len(),
pr.number
)?,
}
Ok(())
}