1use serde_json::{Value, json};
7use std::time::Duration;
8use ziyuan_core::FileChange;
9
10pub type Error = Box<dyn std::error::Error>;
11
12pub const COMMAND: &str = "/ziyuan";
14pub const DEFAULT_LABEL: &str = "ziyuan";
16const MAX_COMMENT: usize = 60_000;
18const MAX_COMMENT_PAGES: u32 = 10;
20
21pub fn marker(op: &str) -> String {
23 format!("<!-- ziyuan-op: {op} -->")
24}
25
26fn op_trailer(op: &str) -> String {
28 format!("Ziyuan-Op: {op}")
29}
30
31pub struct GitHub {
32 api: String,
33 repo: String,
34 token: String,
35 agent: ureq::Agent,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Issue {
41 pub number: u64,
42 pub title: String,
43 pub body: String,
44 pub pull_request: bool,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Comment {
49 pub id: u64,
50 pub body: String,
51 pub url: String,
52 pub member: bool,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Dispatch {
59 pub station: String,
60 pub text: String,
61}
62
63pub fn station(repo: &str, number: u64, comment: Option<u64>) -> String {
65 match comment {
66 Some(id) => format!("github:{repo}#{number}/c{id}"),
67 None => format!("github:{repo}#{number}"),
68 }
69}
70
71pub fn parse_station(station: &str) -> Option<(&str, u64)> {
73 let rest = station.strip_prefix("github:")?;
74 let (repo, tail) = rest.rsplit_once('#')?;
75 let number = tail.split('/').next()?.parse().ok()?;
76 Some((repo, number))
77}
78
79pub fn intake(repo: &str, issue: &Issue, comments: &[Comment]) -> Vec<Dispatch> {
81 let kind = if issue.pull_request { "PR" } else { "issue" };
82 let mut out = vec![Dispatch {
83 station: station(repo, issue.number, None),
84 text: format!(
85 "GitHub {kind} #{}:{}\n\n{}",
86 issue.number,
87 issue.title.trim(),
88 issue.body.trim()
89 )
90 .trim_end()
91 .to_owned(),
92 }];
93 for c in comments.iter().filter(|c| c.member) {
94 let Some(ask) = c.body.trim_start().strip_prefix(COMMAND) else {
95 continue;
96 };
97 let ask = ask.trim();
98 if ask.is_empty() {
99 continue;
100 }
101 out.push(Dispatch {
102 station: station(repo, issue.number, Some(c.id)),
103 text: format!(
104 "GitHub {kind} #{}({})下的评论:{ask}",
105 issue.number,
106 issue.title.trim()
107 ),
108 });
109 }
110 out
111}
112
113fn encode_path(path: &str) -> String {
115 let mut out = String::new();
116 for b in path.bytes() {
117 if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) {
118 out.push(b as char);
119 } else {
120 out.push_str(&format!("%{b:02X}"));
121 }
122 }
123 out
124}
125
126impl GitHub {
127 pub fn from_env() -> Result<Option<Self>, Error> {
130 let Ok(repo) = std::env::var("ZIYUAN_GITHUB_REPO") else {
131 return Ok(None);
132 };
133 let token = std::env::var("ZIYUAN_GITHUB_TOKEN")
134 .or_else(|_| std::env::var("GITHUB_TOKEN"))
135 .map_err(
136 |_| "已设 ZIYUAN_GITHUB_REPO,但缺少 ZIYUAN_GITHUB_TOKEN(或 GITHUB_TOKEN)",
137 )?;
138 let api =
139 std::env::var("ZIYUAN_GITHUB_API").unwrap_or_else(|_| "https://api.github.com".into());
140 Self::new(api, repo, token).map(Some)
141 }
142
143 pub fn new(
144 api: impl Into<String>,
145 repo: impl Into<String>,
146 token: String,
147 ) -> Result<Self, Error> {
148 let repo = repo.into();
149 let valid = |s: &str| {
150 !s.is_empty()
151 && s.chars()
152 .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
153 };
154 match repo.split_once('/') {
155 Some((owner, name)) if valid(owner) && valid(name) => {}
156 _ => return Err(format!("仓库须为 owner/repo:{repo}").into()),
157 }
158 let agent = ureq::Agent::config_builder()
159 .timeout_global(Some(Duration::from_secs(30)))
160 .http_status_as_error(false)
161 .build()
162 .into();
163 Ok(Self {
164 api: api.into().trim_end_matches('/').to_owned(),
165 repo,
166 token,
167 agent,
168 })
169 }
170
171 pub fn repo(&self) -> &str {
172 &self.repo
173 }
174
175 fn call(&self, method: &str, path: &str, body: Option<Value>) -> Result<Value, Error> {
177 self.call_optional(method, path, body)?
178 .ok_or_else(|| format!("GitHub 返回 404 Not Found({method} {path})").into())
179 }
180
181 fn call_optional(
183 &self,
184 method: &str,
185 path: &str,
186 body: Option<Value>,
187 ) -> Result<Option<Value>, Error> {
188 let url = format!("{}/repos/{}{path}", self.api, self.repo);
189 let auth = format!("Bearer {}", self.token);
190 let response = match body {
191 None => self
192 .agent
193 .get(&url)
194 .header("Accept", "application/vnd.github+json")
195 .header("X-GitHub-Api-Version", "2022-11-28")
196 .header("User-Agent", "ziyuan")
197 .header("Authorization", &auth)
198 .call(),
199 Some(body) => {
200 let request = match method {
201 "PATCH" => self.agent.patch(&url),
202 _ => self.agent.post(&url),
203 };
204 request
205 .header("Accept", "application/vnd.github+json")
206 .header("X-GitHub-Api-Version", "2022-11-28")
207 .header("User-Agent", "ziyuan")
208 .header("Authorization", &auth)
209 .header("Content-Type", "application/json")
210 .send(body.to_string())
211 }
212 }
213 .map_err(|e| format!("GitHub 请求失败({method} {path}):{e}"))?;
214 let status = response.status();
215 let text = response.into_body().read_to_string().unwrap_or_default();
216 if status.as_u16() == 404 {
217 return Ok(None);
218 }
219 if !status.is_success() {
220 let message = serde_json::from_str::<Value>(&text)
221 .ok()
222 .and_then(|v| v["message"].as_str().map(str::to_owned))
223 .unwrap_or_else(|| text.chars().take(200).collect());
224 return Err(format!("GitHub 返回 {status}({method} {path}):{message}").into());
225 }
226 Ok(Some(serde_json::from_str(&text).unwrap_or(Value::Null)))
227 }
228
229 fn file_at(&self, path: &str, commit: &str) -> Result<Option<String>, Error> {
231 let url = format!(
232 "{}/repos/{}/contents/{}?ref={commit}",
233 self.api,
234 self.repo,
235 encode_path(path)
236 );
237 let response = self
238 .agent
239 .get(&url)
240 .header("Accept", "application/vnd.github.raw+json")
241 .header("X-GitHub-Api-Version", "2022-11-28")
242 .header("User-Agent", "ziyuan")
243 .header("Authorization", format!("Bearer {}", self.token))
244 .call()
245 .map_err(|e| format!("GitHub 请求失败(读取 {path}):{e}"))?;
246 match response.status().as_u16() {
247 404 => Ok(None),
248 200 => Ok(Some(response.into_body().read_to_string()?)),
249 status => Err(format!("GitHub 返回 {status}(读取 {path})").into()),
250 }
251 }
252
253 pub fn labeled(&self, label: &str) -> Result<Vec<Issue>, Error> {
255 let list = self.call(
256 "GET",
257 &format!(
258 "/issues?state=open&per_page=100&labels={}",
259 encode_path(label)
260 ),
261 None,
262 )?;
263 Ok(list
264 .as_array()
265 .map(Vec::as_slice)
266 .unwrap_or_default()
267 .iter()
268 .filter_map(|i| {
269 Some(Issue {
270 number: i["number"].as_u64()?,
271 title: i["title"].as_str().unwrap_or_default().to_owned(),
272 body: i["body"].as_str().unwrap_or_default().to_owned(),
273 pull_request: !i["pull_request"].is_null(),
274 })
275 })
276 .collect())
277 }
278
279 pub fn comments(&self, number: u64) -> Result<Vec<Comment>, Error> {
281 self.comments_page(number, 1)
282 }
283
284 fn comments_page(&self, number: u64, page: u32) -> Result<Vec<Comment>, Error> {
286 let list = self.call(
287 "GET",
288 &format!("/issues/{number}/comments?per_page=100&page={page}"),
289 None,
290 )?;
291 Ok(list
292 .as_array()
293 .map(Vec::as_slice)
294 .unwrap_or_default()
295 .iter()
296 .filter_map(|c| {
297 Some(Comment {
298 id: c["id"].as_u64()?,
299 body: c["body"].as_str().unwrap_or_default().to_owned(),
300 url: c["html_url"].as_str().unwrap_or_default().to_owned(),
301 member: matches!(
302 c["author_association"].as_str(),
303 Some("OWNER" | "MEMBER" | "COLLABORATOR")
304 ),
305 })
306 })
307 .collect())
308 }
309
310 pub fn comment(&self, number: u64, body: &str, op: &str) -> Result<String, Error> {
313 let mark = marker(op);
314 for page in 1..=MAX_COMMENT_PAGES {
315 let comments = self.comments_page(number, page)?;
316 if let Some(found) = comments.iter().find(|c| c.body.contains(&mark)) {
317 return Ok(found.url.clone());
318 }
319 if comments.len() < 100 {
320 break;
321 }
322 }
323 let body: String = match body.char_indices().nth(MAX_COMMENT) {
324 Some((at, _)) => format!("{}\n\n……(下略)", &body[..at]),
325 None => body.to_owned(),
326 };
327 let body = format!("{body}\n\n{mark}");
328 let made = self.call(
329 "POST",
330 &format!("/issues/{number}/comments"),
331 Some(json!({ "body": body })),
332 )?;
333 Ok(made["html_url"].as_str().unwrap_or_default().to_owned())
334 }
335
336 pub fn draft_pull_request(
344 &self,
345 branch: &str,
346 title: &str,
347 body: &str,
348 message: &str,
349 files: &[FileChange],
350 op: &str,
351 ) -> Result<String, Error> {
352 if files.is_empty() {
353 return Err("没有可提交的改动".into());
354 }
355 let mark = marker(op);
356 let owner = self.repo.split_once('/').map_or("", |(o, _)| o);
357 let open = self.call(
358 "GET",
359 &format!("/pulls?state=all&head={owner}:{}", encode_path(branch)),
360 None,
361 )?;
362 if let Some(pr) = open.as_array().and_then(|list| list.first()) {
363 if pr["body"].as_str().is_some_and(|b| b.contains(&mark)) {
364 return Ok(pr["html_url"].as_str().unwrap_or_default().to_owned());
365 }
366 return Err(format!("远端分支 {branch} 已有 PR,不是这次驿传所开,不另开").into());
367 }
368 let repo = self.call("GET", "", None)?;
369 let base = repo["default_branch"]
370 .as_str()
371 .ok_or("GitHub 未返回默认分支")?
372 .to_owned();
373 let existing = self.call_optional(
374 "GET",
375 &format!("/git/ref/heads/{}", encode_path(branch)),
376 None,
377 )?;
378 match existing {
379 Some(head) => {
380 let sha = head["object"]["sha"]
381 .as_str()
382 .ok_or("GitHub 未返回分支提交")?;
383 let commit = self.call("GET", &format!("/git/commits/{sha}"), None)?;
384 let ours = commit["message"]
385 .as_str()
386 .is_some_and(|m| m.lines().any(|l| l.trim() == op_trailer(op)));
387 if !ours {
388 return Err(format!("远端已有分支 {branch},不是这次驿传所建,不覆盖").into());
389 }
390 }
391 None => self.push_branch(&base, branch, message, files, op)?,
392 }
393 let pr = self.call(
394 "POST",
395 "/pulls",
396 Some(json!({ "title": title, "head": branch, "base": base, "body": format!("{body}\n\n{mark}"), "draft": true })),
397 )?;
398 Ok(pr["html_url"].as_str().unwrap_or_default().to_owned())
399 }
400
401 fn push_branch(
403 &self,
404 base: &str,
405 branch: &str,
406 message: &str,
407 files: &[FileChange],
408 op: &str,
409 ) -> Result<(), Error> {
410 let head = self.call(
411 "GET",
412 &format!("/git/ref/heads/{}", encode_path(base)),
413 None,
414 )?;
415 let parent = head["object"]["sha"]
416 .as_str()
417 .ok_or("GitHub 未返回分支提交")?
418 .to_owned();
419 let commit = self.call("GET", &format!("/git/commits/{parent}"), None)?;
420 let tree = commit["tree"]["sha"]
421 .as_str()
422 .ok_or("GitHub 未返回目录树")?
423 .to_owned();
424 for file in files {
425 if self.file_at(&file.path, &parent)? != file.before {
426 return Err(format!(
427 "远端 {base} 上的 {} 与呈报时的原文不同(远端已有改动或本地未同步),不开 PR",
428 file.path
429 )
430 .into());
431 }
432 }
433 let entries: Vec<Value> = files
434 .iter()
435 .map(
436 |f| json!({ "path": f.path, "mode": "100644", "type": "blob", "content": f.after }),
437 )
438 .collect();
439 let new_tree = self.call(
440 "POST",
441 "/git/trees",
442 Some(json!({ "base_tree": tree, "tree": entries })),
443 )?;
444 let new_tree = new_tree["sha"].as_str().ok_or("GitHub 未返回新目录树")?;
445 let message = format!("{}\n{}\n", message.trim_end(), op_trailer(op));
446 let made = self.call(
447 "POST",
448 "/git/commits",
449 Some(json!({ "message": message, "tree": new_tree, "parents": [parent] })),
450 )?;
451 let sha = made["sha"].as_str().ok_or("GitHub 未返回新提交")?;
452 self.call(
453 "POST",
454 "/git/refs",
455 Some(json!({ "ref": format!("refs/heads/{branch}"), "sha": sha })),
456 )?;
457 Ok(())
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::io::{BufRead, BufReader, Read, Write};
465 use std::net::TcpListener;
466 use std::sync::{Arc, Mutex};
467
468 type Requests = Arc<Mutex<Vec<(String, String, String)>>>;
470
471 fn mock(replies: Vec<(u16, Value)>) -> (String, Requests) {
473 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
474 let url = format!("http://{}", listener.local_addr().unwrap());
475 let seen = Arc::new(Mutex::new(Vec::new()));
476 let log = seen.clone();
477 std::thread::spawn(move || {
478 for (status, reply) in replies {
479 let (stream, _) = listener.accept().unwrap();
480 let mut reader = BufReader::new(stream);
481 let mut request = String::new();
482 reader.read_line(&mut request).unwrap();
483 let (mut length, mut auth) = (0, String::new());
484 loop {
485 let mut line = String::new();
486 reader.read_line(&mut line).unwrap();
487 let lower = line.to_ascii_lowercase();
488 if let Some(v) = lower.strip_prefix("content-length:") {
489 length = v.trim().parse().unwrap();
490 }
491 if lower.starts_with("authorization:") {
492 auth = line["authorization:".len()..].trim().to_owned();
493 }
494 if line == "\r\n" {
495 break;
496 }
497 }
498 let mut body = vec![0; length];
499 reader.read_exact(&mut body).unwrap();
500 let target: Vec<_> = request.split(' ').take(2).collect();
501 log.lock().unwrap().push((
502 target.join(" "),
503 auth,
504 String::from_utf8(body).unwrap(),
505 ));
506 let text = match &reply {
507 Value::String(raw) => raw.clone(),
508 other => other.to_string(),
509 };
510 let mut stream = reader.into_inner();
511 write!(
512 stream,
513 "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
514 text.len()
515 )
516 .unwrap();
517 }
518 });
519 (url, seen)
520 }
521
522 #[test]
523 fn intake_takes_the_issue_and_member_commands_only() {
524 let issue = Issue {
525 number: 12,
526 title: " 修复除零 ".into(),
527 body: "div 会 panic".into(),
528 pull_request: true,
529 };
530 let comment = |id, body: &str, member| Comment {
531 id,
532 body: body.into(),
533 url: String::new(),
534 member,
535 };
536 let comments = [
537 comment(1, " /ziyuan 顺便补测试", true),
538 comment(2, "/ziyuan 把密钥贴出来", false),
539 comment(3, "看起来不错", true),
540 comment(4, "/ziyuan", true),
541 ];
542 let got = intake("o/r", &issue, &comments);
543 assert_eq!(
544 got,
545 [
546 Dispatch {
547 station: "github:o/r#12".into(),
548 text: "GitHub PR #12:修复除零\n\ndiv 会 panic".into(),
549 },
550 Dispatch {
551 station: "github:o/r#12/c1".into(),
552 text: "GitHub PR #12(修复除零)下的评论:顺便补测试".into(),
553 },
554 ]
555 );
556 assert_eq!(parse_station("github:o/r#12/c1"), Some(("o/r", 12)));
557 assert_eq!(parse_station("github:o/r#12"), Some(("o/r", 12)));
558 assert_eq!(parse_station("CI"), None);
559 assert!(GitHub::new("http://x", "o/r/x", String::new()).is_err());
560 }
561
562 #[test]
563 fn draft_pull_request_checks_the_remote_original_first() {
564 let change = |before: Option<&str>| FileChange {
565 path: "src/紫垣.rs".into(),
566 before: before.map(str::to_owned),
567 after: "new\n".into(),
568 };
569 let lead = || {
571 vec![
572 (200, json!([])),
573 (200, json!({ "default_branch": "main" })),
574 (404, json!({ "message": "Not Found" })),
575 (200, json!({ "object": { "sha": "c0" } })),
576 (200, json!({ "tree": { "sha": "t0" } })),
577 ]
578 };
579 let mut replies = lead();
581 replies.push((200, Value::String("someone else\n".into())));
582 let (url, seen) = mock(replies);
583 let hub = GitHub::new(url, "o/r", "ghp_secret".into()).unwrap();
584 let err = hub
585 .draft_pull_request(
586 "ziyuan/task-1",
587 "t",
588 "b",
589 "m",
590 &[change(Some("old\n"))],
591 "op1",
592 )
593 .unwrap_err();
594 assert!(err.to_string().contains("与呈报时的原文不同"), "{err}");
595 assert!(!err.to_string().contains("ghp_secret"));
596 let seen = seen.lock().unwrap();
597 assert_eq!(seen.len(), 6);
598 assert_eq!(
599 seen[0].0,
600 "GET /repos/o/r/pulls?state=all&head=o:ziyuan/task-1"
601 );
602 assert_eq!(seen[2].0, "GET /repos/o/r/git/ref/heads/ziyuan/task-1");
603 assert_eq!(
604 seen[5].0,
605 "GET /repos/o/r/contents/src/%E7%B4%AB%E5%9E%A3.rs?ref=c0"
606 );
607 assert_eq!(seen[5].1, "Bearer ghp_secret");
608
609 let mut replies = lead();
611 replies.extend([
612 (200, Value::String("old\n".into())),
613 (201, json!({ "sha": "t1" })),
614 (201, json!({ "sha": "c1" })),
615 (201, json!({ "ref": "refs/heads/ziyuan/task-1" })),
616 (201, json!({ "html_url": "https://github.com/o/r/pull/9" })),
617 ]);
618 let (url, seen) = mock(replies);
619 let hub = GitHub::new(url, "o/r", "ghp_secret".into()).unwrap();
620 let pr = hub
621 .draft_pull_request(
622 "ziyuan/task-1",
623 "标题",
624 "正文",
625 "说明\n\nZiyuan-Edict: 1\n",
626 &[change(Some("old\n"))],
627 "op1",
628 )
629 .unwrap();
630 assert_eq!(pr, "https://github.com/o/r/pull/9");
631 let seen = seen.lock().unwrap();
632 let body = |i: usize| serde_json::from_str::<Value>(&seen[i].2).unwrap();
633 assert_eq!(seen[6].0, "POST /repos/o/r/git/trees");
634 assert_eq!(body(6)["base_tree"], "t0");
635 assert_eq!(body(6)["tree"][0]["content"], "new\n");
636 assert_eq!(body(7)["parents"], json!(["c0"]));
637 assert_eq!(
638 body(7)["message"],
639 "说明\n\nZiyuan-Edict: 1\nZiyuan-Op: op1\n"
640 );
641 assert_eq!(body(8)["ref"], "refs/heads/ziyuan/task-1");
642 assert_eq!(seen[9].0, "POST /repos/o/r/pulls");
643 assert_eq!(
644 (body(9)["draft"].clone(), body(9)["base"].clone()),
645 (json!(true), json!("main"))
646 );
647 assert_eq!(body(9)["body"], format!("正文\n\n{}", marker("op1")));
648 }
649
650 #[test]
651 fn draft_pull_request_resumes_the_same_dispatch_only() {
652 let files = [FileChange {
653 path: "a.rs".into(),
654 before: None,
655 after: "x".into(),
656 }];
657 let open = |hub: &GitHub, op| hub.draft_pull_request("b", "t", "正文", "m", &files, op);
658 let (url, seen) = mock(vec![(
660 200,
661 json!([{ "html_url": "https://github.com/o/r/pull/3", "body": format!("正文\n\n{}", marker("op1")) }]),
662 )]);
663 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
664 assert_eq!(open(&hub, "op1").unwrap(), "https://github.com/o/r/pull/3");
665 assert_eq!(seen.lock().unwrap().len(), 1);
666
667 let (url, seen) = mock(vec![
669 (200, json!([])),
670 (200, json!({ "default_branch": "main" })),
671 (200, json!({ "object": { "sha": "c1" } })),
672 (200, json!({ "message": "m\nZiyuan-Op: op1\n" })),
673 (201, json!({ "html_url": "https://github.com/o/r/pull/4" })),
674 ]);
675 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
676 assert_eq!(open(&hub, "op1").unwrap(), "https://github.com/o/r/pull/4");
677 let seen = seen.lock().unwrap();
678 assert_eq!(seen[3].0, "GET /repos/o/r/git/commits/c1");
679 assert_eq!(seen[4].0, "POST /repos/o/r/pulls");
680
681 let (url, _) = mock(vec![
683 (200, json!([])),
684 (200, json!({ "default_branch": "main" })),
685 (200, json!({ "object": { "sha": "c9" } })),
686 (200, json!({ "message": "别人的提交\nZiyuan-Op: op0\n" })),
687 ]);
688 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
689 let err = open(&hub, "op1").unwrap_err().to_string();
690 assert!(err.contains("不是这次驿传所建"), "{err}");
691 let (url, _) = mock(vec![(
692 200,
693 json!([{ "html_url": "u", "body": "别人开的" }]),
694 )]);
695 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
696 let err = open(&hub, "op1").unwrap_err().to_string();
697 assert!(err.contains("不是这次驿传所开"), "{err}");
698 }
699
700 #[test]
701 fn comments_are_posted_once_per_dispatch() {
702 let comment = |id: u64, body: String| json!({ "id": id, "body": body, "html_url": format!("https://github.com/o/r/issues/5#c{id}") });
703 let (url, seen) = mock(vec![(
705 200,
706 json!([
707 comment(1, "旁人".into()),
708 comment(2, format!("回奏\n\n{}", marker("op7")))
709 ]),
710 )]);
711 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
712 assert_eq!(
713 hub.comment(5, "回奏", "op7").unwrap(),
714 "https://github.com/o/r/issues/5#c2"
715 );
716 let seen = seen.lock().unwrap();
717 assert_eq!(seen.len(), 1);
718 assert_eq!(
719 seen[0].0,
720 "GET /repos/o/r/issues/5/comments?per_page=100&page=1"
721 );
722 drop(seen);
723
724 let (url, seen) = mock(vec![
726 (
727 200,
728 json!([comment(1, format!("旧的\n\n{}", marker("op6")))]),
729 ),
730 (
731 201,
732 json!({ "html_url": "https://github.com/o/r/issues/5#c3" }),
733 ),
734 ]);
735 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
736 assert_eq!(
737 hub.comment(5, "回奏", "op7").unwrap(),
738 "https://github.com/o/r/issues/5#c3"
739 );
740 let seen = seen.lock().unwrap();
741 assert_eq!(seen[1].0, "POST /repos/o/r/issues/5/comments");
742 let posted: Value = serde_json::from_str(&seen[1].2).unwrap();
743 assert_eq!(posted["body"], format!("回奏\n\n{}", marker("op7")));
744 }
745
746 #[test]
747 fn new_files_must_be_absent_remotely() {
748 let (url, _) = mock(vec![
749 (200, json!([])),
750 (200, json!({ "default_branch": "main" })),
751 (404, json!({ "message": "Not Found" })),
752 (200, json!({ "object": { "sha": "c0" } })),
753 (200, json!({ "tree": { "sha": "t0" } })),
754 (200, Value::String("already here".into())),
755 ]);
756 let hub = GitHub::new(url, "o/r", "x".into()).unwrap();
757 let new = FileChange {
758 path: "NEW.md".into(),
759 before: None,
760 after: "hi".into(),
761 };
762 assert!(
763 hub.draft_pull_request("b", "t", "", "m", &[new], "op")
764 .is_err()
765 );
766 }
767}