use serde_json::{Value, json};
use std::time::Duration;
use ziyuan_core::FileChange;
pub type Error = Box<dyn std::error::Error>;
pub const COMMAND: &str = "/ziyuan";
pub const DEFAULT_LABEL: &str = "ziyuan";
const MAX_COMMENT: usize = 60_000;
const MAX_COMMENT_PAGES: u32 = 10;
pub fn marker(op: &str) -> String {
format!("<!-- ziyuan-op: {op} -->")
}
fn op_trailer(op: &str) -> String {
format!("Ziyuan-Op: {op}")
}
pub struct GitHub {
api: String,
repo: String,
token: String,
agent: ureq::Agent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Issue {
pub number: u64,
pub title: String,
pub body: String,
pub pull_request: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Comment {
pub id: u64,
pub body: String,
pub url: String,
pub member: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Dispatch {
pub station: String,
pub text: String,
}
pub fn station(repo: &str, number: u64, comment: Option<u64>) -> String {
match comment {
Some(id) => format!("github:{repo}#{number}/c{id}"),
None => format!("github:{repo}#{number}"),
}
}
pub fn parse_station(station: &str) -> Option<(&str, u64)> {
let rest = station.strip_prefix("github:")?;
let (repo, tail) = rest.rsplit_once('#')?;
let number = tail.split('/').next()?.parse().ok()?;
Some((repo, number))
}
pub fn intake(repo: &str, issue: &Issue, comments: &[Comment]) -> Vec<Dispatch> {
let kind = if issue.pull_request { "PR" } else { "issue" };
let mut out = vec![Dispatch {
station: station(repo, issue.number, None),
text: format!(
"GitHub {kind} #{}:{}\n\n{}",
issue.number,
issue.title.trim(),
issue.body.trim()
)
.trim_end()
.to_owned(),
}];
for c in comments.iter().filter(|c| c.member) {
let Some(ask) = c.body.trim_start().strip_prefix(COMMAND) else {
continue;
};
let ask = ask.trim();
if ask.is_empty() {
continue;
}
out.push(Dispatch {
station: station(repo, issue.number, Some(c.id)),
text: format!(
"GitHub {kind} #{}({})下的评论:{ask}",
issue.number,
issue.title.trim()
),
});
}
out
}
fn encode_path(path: &str) -> String {
let mut out = String::new();
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
impl GitHub {
pub fn from_env() -> Result<Option<Self>, Error> {
let Ok(repo) = std::env::var("ZIYUAN_GITHUB_REPO") else {
return Ok(None);
};
let token = std::env::var("ZIYUAN_GITHUB_TOKEN")
.or_else(|_| std::env::var("GITHUB_TOKEN"))
.map_err(
|_| "已设 ZIYUAN_GITHUB_REPO,但缺少 ZIYUAN_GITHUB_TOKEN(或 GITHUB_TOKEN)",
)?;
let api =
std::env::var("ZIYUAN_GITHUB_API").unwrap_or_else(|_| "https://api.github.com".into());
Self::new(api, repo, token).map(Some)
}
pub fn new(
api: impl Into<String>,
repo: impl Into<String>,
token: String,
) -> Result<Self, Error> {
let repo = repo.into();
let valid = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
};
match repo.split_once('/') {
Some((owner, name)) if valid(owner) && valid(name) => {}
_ => return Err(format!("仓库须为 owner/repo:{repo}").into()),
}
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.http_status_as_error(false)
.build()
.into();
Ok(Self {
api: api.into().trim_end_matches('/').to_owned(),
repo,
token,
agent,
})
}
pub fn repo(&self) -> &str {
&self.repo
}
fn call(&self, method: &str, path: &str, body: Option<Value>) -> Result<Value, Error> {
self.call_optional(method, path, body)?
.ok_or_else(|| format!("GitHub 返回 404 Not Found({method} {path})").into())
}
fn call_optional(
&self,
method: &str,
path: &str,
body: Option<Value>,
) -> Result<Option<Value>, Error> {
let url = format!("{}/repos/{}{path}", self.api, self.repo);
let auth = format!("Bearer {}", self.token);
let response = match body {
None => self
.agent
.get(&url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "ziyuan")
.header("Authorization", &auth)
.call(),
Some(body) => {
let request = match method {
"PATCH" => self.agent.patch(&url),
_ => self.agent.post(&url),
};
request
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "ziyuan")
.header("Authorization", &auth)
.header("Content-Type", "application/json")
.send(body.to_string())
}
}
.map_err(|e| format!("GitHub 请求失败({method} {path}):{e}"))?;
let status = response.status();
let text = response.into_body().read_to_string().unwrap_or_default();
if status.as_u16() == 404 {
return Ok(None);
}
if !status.is_success() {
let message = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v["message"].as_str().map(str::to_owned))
.unwrap_or_else(|| text.chars().take(200).collect());
return Err(format!("GitHub 返回 {status}({method} {path}):{message}").into());
}
Ok(Some(serde_json::from_str(&text).unwrap_or(Value::Null)))
}
fn file_at(&self, path: &str, commit: &str) -> Result<Option<String>, Error> {
let url = format!(
"{}/repos/{}/contents/{}?ref={commit}",
self.api,
self.repo,
encode_path(path)
);
let response = self
.agent
.get(&url)
.header("Accept", "application/vnd.github.raw+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", "ziyuan")
.header("Authorization", format!("Bearer {}", self.token))
.call()
.map_err(|e| format!("GitHub 请求失败(读取 {path}):{e}"))?;
match response.status().as_u16() {
404 => Ok(None),
200 => Ok(Some(response.into_body().read_to_string()?)),
status => Err(format!("GitHub 返回 {status}(读取 {path})").into()),
}
}
pub fn labeled(&self, label: &str) -> Result<Vec<Issue>, Error> {
let list = self.call(
"GET",
&format!(
"/issues?state=open&per_page=100&labels={}",
encode_path(label)
),
None,
)?;
Ok(list
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|i| {
Some(Issue {
number: i["number"].as_u64()?,
title: i["title"].as_str().unwrap_or_default().to_owned(),
body: i["body"].as_str().unwrap_or_default().to_owned(),
pull_request: !i["pull_request"].is_null(),
})
})
.collect())
}
pub fn comments(&self, number: u64) -> Result<Vec<Comment>, Error> {
self.comments_page(number, 1)
}
fn comments_page(&self, number: u64, page: u32) -> Result<Vec<Comment>, Error> {
let list = self.call(
"GET",
&format!("/issues/{number}/comments?per_page=100&page={page}"),
None,
)?;
Ok(list
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|c| {
Some(Comment {
id: c["id"].as_u64()?,
body: c["body"].as_str().unwrap_or_default().to_owned(),
url: c["html_url"].as_str().unwrap_or_default().to_owned(),
member: matches!(
c["author_association"].as_str(),
Some("OWNER" | "MEMBER" | "COLLABORATOR")
),
})
})
.collect())
}
pub fn comment(&self, number: u64, body: &str, op: &str) -> Result<String, Error> {
let mark = marker(op);
for page in 1..=MAX_COMMENT_PAGES {
let comments = self.comments_page(number, page)?;
if let Some(found) = comments.iter().find(|c| c.body.contains(&mark)) {
return Ok(found.url.clone());
}
if comments.len() < 100 {
break;
}
}
let body: String = match body.char_indices().nth(MAX_COMMENT) {
Some((at, _)) => format!("{}\n\n……(下略)", &body[..at]),
None => body.to_owned(),
};
let body = format!("{body}\n\n{mark}");
let made = self.call(
"POST",
&format!("/issues/{number}/comments"),
Some(json!({ "body": body })),
)?;
Ok(made["html_url"].as_str().unwrap_or_default().to_owned())
}
pub fn draft_pull_request(
&self,
branch: &str,
title: &str,
body: &str,
message: &str,
files: &[FileChange],
op: &str,
) -> Result<String, Error> {
if files.is_empty() {
return Err("没有可提交的改动".into());
}
let mark = marker(op);
let owner = self.repo.split_once('/').map_or("", |(o, _)| o);
let open = self.call(
"GET",
&format!("/pulls?state=all&head={owner}:{}", encode_path(branch)),
None,
)?;
if let Some(pr) = open.as_array().and_then(|list| list.first()) {
if pr["body"].as_str().is_some_and(|b| b.contains(&mark)) {
return Ok(pr["html_url"].as_str().unwrap_or_default().to_owned());
}
return Err(format!("远端分支 {branch} 已有 PR,不是这次驿传所开,不另开").into());
}
let repo = self.call("GET", "", None)?;
let base = repo["default_branch"]
.as_str()
.ok_or("GitHub 未返回默认分支")?
.to_owned();
let existing = self.call_optional(
"GET",
&format!("/git/ref/heads/{}", encode_path(branch)),
None,
)?;
match existing {
Some(head) => {
let sha = head["object"]["sha"]
.as_str()
.ok_or("GitHub 未返回分支提交")?;
let commit = self.call("GET", &format!("/git/commits/{sha}"), None)?;
let ours = commit["message"]
.as_str()
.is_some_and(|m| m.lines().any(|l| l.trim() == op_trailer(op)));
if !ours {
return Err(format!("远端已有分支 {branch},不是这次驿传所建,不覆盖").into());
}
}
None => self.push_branch(&base, branch, message, files, op)?,
}
let pr = self.call(
"POST",
"/pulls",
Some(json!({ "title": title, "head": branch, "base": base, "body": format!("{body}\n\n{mark}"), "draft": true })),
)?;
Ok(pr["html_url"].as_str().unwrap_or_default().to_owned())
}
fn push_branch(
&self,
base: &str,
branch: &str,
message: &str,
files: &[FileChange],
op: &str,
) -> Result<(), Error> {
let head = self.call(
"GET",
&format!("/git/ref/heads/{}", encode_path(base)),
None,
)?;
let parent = head["object"]["sha"]
.as_str()
.ok_or("GitHub 未返回分支提交")?
.to_owned();
let commit = self.call("GET", &format!("/git/commits/{parent}"), None)?;
let tree = commit["tree"]["sha"]
.as_str()
.ok_or("GitHub 未返回目录树")?
.to_owned();
for file in files {
if self.file_at(&file.path, &parent)? != file.before {
return Err(format!(
"远端 {base} 上的 {} 与呈报时的原文不同(远端已有改动或本地未同步),不开 PR",
file.path
)
.into());
}
}
let entries: Vec<Value> = files
.iter()
.map(
|f| json!({ "path": f.path, "mode": "100644", "type": "blob", "content": f.after }),
)
.collect();
let new_tree = self.call(
"POST",
"/git/trees",
Some(json!({ "base_tree": tree, "tree": entries })),
)?;
let new_tree = new_tree["sha"].as_str().ok_or("GitHub 未返回新目录树")?;
let message = format!("{}\n{}\n", message.trim_end(), op_trailer(op));
let made = self.call(
"POST",
"/git/commits",
Some(json!({ "message": message, "tree": new_tree, "parents": [parent] })),
)?;
let sha = made["sha"].as_str().ok_or("GitHub 未返回新提交")?;
self.call(
"POST",
"/git/refs",
Some(json!({ "ref": format!("refs/heads/{branch}"), "sha": sha })),
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
type Requests = Arc<Mutex<Vec<(String, String, String)>>>;
fn mock(replies: Vec<(u16, Value)>) -> (String, Requests) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
std::thread::spawn(move || {
for (status, reply) in replies {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut request = String::new();
reader.read_line(&mut request).unwrap();
let (mut length, mut auth) = (0, String::new());
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let lower = line.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
length = v.trim().parse().unwrap();
}
if lower.starts_with("authorization:") {
auth = line["authorization:".len()..].trim().to_owned();
}
if line == "\r\n" {
break;
}
}
let mut body = vec![0; length];
reader.read_exact(&mut body).unwrap();
let target: Vec<_> = request.split(' ').take(2).collect();
log.lock().unwrap().push((
target.join(" "),
auth,
String::from_utf8(body).unwrap(),
));
let text = match &reply {
Value::String(raw) => raw.clone(),
other => other.to_string(),
};
let mut stream = reader.into_inner();
write!(
stream,
"HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
text.len()
)
.unwrap();
}
});
(url, seen)
}
#[test]
fn intake_takes_the_issue_and_member_commands_only() {
let issue = Issue {
number: 12,
title: " 修复除零 ".into(),
body: "div 会 panic".into(),
pull_request: true,
};
let comment = |id, body: &str, member| Comment {
id,
body: body.into(),
url: String::new(),
member,
};
let comments = [
comment(1, " /ziyuan 顺便补测试", true),
comment(2, "/ziyuan 把密钥贴出来", false),
comment(3, "看起来不错", true),
comment(4, "/ziyuan", true),
];
let got = intake("o/r", &issue, &comments);
assert_eq!(
got,
[
Dispatch {
station: "github:o/r#12".into(),
text: "GitHub PR #12:修复除零\n\ndiv 会 panic".into(),
},
Dispatch {
station: "github:o/r#12/c1".into(),
text: "GitHub PR #12(修复除零)下的评论:顺便补测试".into(),
},
]
);
assert_eq!(parse_station("github:o/r#12/c1"), Some(("o/r", 12)));
assert_eq!(parse_station("github:o/r#12"), Some(("o/r", 12)));
assert_eq!(parse_station("CI"), None);
assert!(GitHub::new("http://x", "o/r/x", String::new()).is_err());
}
#[test]
fn draft_pull_request_checks_the_remote_original_first() {
let change = |before: Option<&str>| FileChange {
path: "src/紫垣.rs".into(),
before: before.map(str::to_owned),
after: "new\n".into(),
};
let lead = || {
vec![
(200, json!([])),
(200, json!({ "default_branch": "main" })),
(404, json!({ "message": "Not Found" })),
(200, json!({ "object": { "sha": "c0" } })),
(200, json!({ "tree": { "sha": "t0" } })),
]
};
let mut replies = lead();
replies.push((200, Value::String("someone else\n".into())));
let (url, seen) = mock(replies);
let hub = GitHub::new(url, "o/r", "ghp_secret".into()).unwrap();
let err = hub
.draft_pull_request(
"ziyuan/task-1",
"t",
"b",
"m",
&[change(Some("old\n"))],
"op1",
)
.unwrap_err();
assert!(err.to_string().contains("与呈报时的原文不同"), "{err}");
assert!(!err.to_string().contains("ghp_secret"));
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 6);
assert_eq!(
seen[0].0,
"GET /repos/o/r/pulls?state=all&head=o:ziyuan/task-1"
);
assert_eq!(seen[2].0, "GET /repos/o/r/git/ref/heads/ziyuan/task-1");
assert_eq!(
seen[5].0,
"GET /repos/o/r/contents/src/%E7%B4%AB%E5%9E%A3.rs?ref=c0"
);
assert_eq!(seen[5].1, "Bearer ghp_secret");
let mut replies = lead();
replies.extend([
(200, Value::String("old\n".into())),
(201, json!({ "sha": "t1" })),
(201, json!({ "sha": "c1" })),
(201, json!({ "ref": "refs/heads/ziyuan/task-1" })),
(201, json!({ "html_url": "https://github.com/o/r/pull/9" })),
]);
let (url, seen) = mock(replies);
let hub = GitHub::new(url, "o/r", "ghp_secret".into()).unwrap();
let pr = hub
.draft_pull_request(
"ziyuan/task-1",
"标题",
"正文",
"说明\n\nZiyuan-Edict: 1\n",
&[change(Some("old\n"))],
"op1",
)
.unwrap();
assert_eq!(pr, "https://github.com/o/r/pull/9");
let seen = seen.lock().unwrap();
let body = |i: usize| serde_json::from_str::<Value>(&seen[i].2).unwrap();
assert_eq!(seen[6].0, "POST /repos/o/r/git/trees");
assert_eq!(body(6)["base_tree"], "t0");
assert_eq!(body(6)["tree"][0]["content"], "new\n");
assert_eq!(body(7)["parents"], json!(["c0"]));
assert_eq!(
body(7)["message"],
"说明\n\nZiyuan-Edict: 1\nZiyuan-Op: op1\n"
);
assert_eq!(body(8)["ref"], "refs/heads/ziyuan/task-1");
assert_eq!(seen[9].0, "POST /repos/o/r/pulls");
assert_eq!(
(body(9)["draft"].clone(), body(9)["base"].clone()),
(json!(true), json!("main"))
);
assert_eq!(body(9)["body"], format!("正文\n\n{}", marker("op1")));
}
#[test]
fn draft_pull_request_resumes_the_same_dispatch_only() {
let files = [FileChange {
path: "a.rs".into(),
before: None,
after: "x".into(),
}];
let open = |hub: &GitHub, op| hub.draft_pull_request("b", "t", "正文", "m", &files, op);
let (url, seen) = mock(vec![(
200,
json!([{ "html_url": "https://github.com/o/r/pull/3", "body": format!("正文\n\n{}", marker("op1")) }]),
)]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
assert_eq!(open(&hub, "op1").unwrap(), "https://github.com/o/r/pull/3");
assert_eq!(seen.lock().unwrap().len(), 1);
let (url, seen) = mock(vec![
(200, json!([])),
(200, json!({ "default_branch": "main" })),
(200, json!({ "object": { "sha": "c1" } })),
(200, json!({ "message": "m\nZiyuan-Op: op1\n" })),
(201, json!({ "html_url": "https://github.com/o/r/pull/4" })),
]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
assert_eq!(open(&hub, "op1").unwrap(), "https://github.com/o/r/pull/4");
let seen = seen.lock().unwrap();
assert_eq!(seen[3].0, "GET /repos/o/r/git/commits/c1");
assert_eq!(seen[4].0, "POST /repos/o/r/pulls");
let (url, _) = mock(vec![
(200, json!([])),
(200, json!({ "default_branch": "main" })),
(200, json!({ "object": { "sha": "c9" } })),
(200, json!({ "message": "别人的提交\nZiyuan-Op: op0\n" })),
]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
let err = open(&hub, "op1").unwrap_err().to_string();
assert!(err.contains("不是这次驿传所建"), "{err}");
let (url, _) = mock(vec![(
200,
json!([{ "html_url": "u", "body": "别人开的" }]),
)]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
let err = open(&hub, "op1").unwrap_err().to_string();
assert!(err.contains("不是这次驿传所开"), "{err}");
}
#[test]
fn comments_are_posted_once_per_dispatch() {
let comment = |id: u64, body: String| json!({ "id": id, "body": body, "html_url": format!("https://github.com/o/r/issues/5#c{id}") });
let (url, seen) = mock(vec![(
200,
json!([
comment(1, "旁人".into()),
comment(2, format!("回奏\n\n{}", marker("op7")))
]),
)]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
assert_eq!(
hub.comment(5, "回奏", "op7").unwrap(),
"https://github.com/o/r/issues/5#c2"
);
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(
seen[0].0,
"GET /repos/o/r/issues/5/comments?per_page=100&page=1"
);
drop(seen);
let (url, seen) = mock(vec![
(
200,
json!([comment(1, format!("旧的\n\n{}", marker("op6")))]),
),
(
201,
json!({ "html_url": "https://github.com/o/r/issues/5#c3" }),
),
]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
assert_eq!(
hub.comment(5, "回奏", "op7").unwrap(),
"https://github.com/o/r/issues/5#c3"
);
let seen = seen.lock().unwrap();
assert_eq!(seen[1].0, "POST /repos/o/r/issues/5/comments");
let posted: Value = serde_json::from_str(&seen[1].2).unwrap();
assert_eq!(posted["body"], format!("回奏\n\n{}", marker("op7")));
}
#[test]
fn new_files_must_be_absent_remotely() {
let (url, _) = mock(vec![
(200, json!([])),
(200, json!({ "default_branch": "main" })),
(404, json!({ "message": "Not Found" })),
(200, json!({ "object": { "sha": "c0" } })),
(200, json!({ "tree": { "sha": "t0" } })),
(200, Value::String("already here".into())),
]);
let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
let new = FileChange {
path: "NEW.md".into(),
before: None,
after: "hi".into(),
};
assert!(
hub.draft_pull_request("b", "t", "", "m", &[new], "op")
.is_err()
);
}
}