Skip to main content

ziyuan_github/
lib.rs

1//! 兵部驿站 · GitHub:把带标签的 issue / PR(及其中成员以 `/ziyuan` 开头的评论)转为诏令,
2//! 把回奏写回评论,把准奏的改动开为草稿 PR。
3//!
4//! GitHub 凭据只在本 crate 中读取与使用:不交给朝廷(`Session`)、执行后端或任何模型提示,
5//! 错误信息也不含令牌。草稿 PR 经 Git Data API 在远端直接建分支与提交,不碰本地版本库。
6use serde_json::{Value, json};
7use std::time::Duration;
8use ziyuan_core::FileChange;
9
10pub type Error = Box<dyn std::error::Error>;
11
12/// 评论以此开头(去掉前导空白)才入队。
13pub const COMMAND: &str = "/ziyuan";
14/// 默认的入队标签。
15pub const DEFAULT_LABEL: &str = "ziyuan";
16/// GitHub 评论正文上限为 65536 字符,留些余量。
17const MAX_COMMENT: usize = 60_000;
18/// 查找已发评论时至多翻的页数(每页 100 条)。
19const MAX_COMMENT_PAGES: u32 = 10;
20
21/// 外发的评论与 PR 正文末尾的隐藏标记:带上驿传的幂等键,重试时据此认出已发出的那件。
22pub fn marker(op: &str) -> String {
23    format!("<!-- ziyuan-op: {op} -->")
24}
25
26/// 草稿 PR 那个提交的说明末尾的尾注,据此认出远端分支是否为同一次驿传所建。
27fn 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/// 一件 issue 或 PR(GitHub 的 issues 接口两者都返回)。
39#[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    /// 作者是仓库所有者、组织成员或协作者;只有他们的 `/ziyuan` 评论入队。
53    pub member: bool,
54}
55
56/// 待入队的一件:驿站名(含来处,用于去重)与诏令内容。
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Dispatch {
59    pub station: String,
60    pub text: String,
61}
62
63/// 驿站名:`github:<owner/repo>#<编号>`,评论再加 `/c<评论ID>`。
64pub 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
71/// 从驿站名取出仓库与 issue / PR 编号;不是 GitHub 驿站则为 `None`。
72pub 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
79/// 一件带标签的 issue / PR 应入队的诏令:本身一件,另加成员以 `/ziyuan` 开头的每条评论各一件。
80pub 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
113/// 路径按段百分号编码(保留 `/`)。
114fn 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    /// `ZIYUAN_GITHUB_REPO`(`owner/repo`)未设时为 `Ok(None)`。令牌取 `ZIYUAN_GITHUB_TOKEN`,
128    /// 否则 `GITHUB_TOKEN`;`ZIYUAN_GITHUB_API` 可改 API 地址(GitHub Enterprise)。
129    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    /// 调用 `/repos/<repo><path>`;非 2xx 返回错误(含状态与 GitHub 的说明,不含令牌)。
176    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    /// 同 [`Self::call`],但 404 为 `Ok(None)`(如查找尚不存在的分支)。
182    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    /// 文件在 `commit` 时的原文;不存在为 `None`。
230    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    /// 带 `label` 的未关闭 issue 与 PR(至多 100 件)。
254    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    /// issue / PR 下的评论(至多 100 条)。
280    pub fn comments(&self, number: u64) -> Result<Vec<Comment>, Error> {
281        self.comments_page(number, 1)
282    }
283
284    /// 评论的第 `page` 页(每页 100 条,从 1 起,旧者在前)。
285    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    /// 在 issue / PR 下发评论,返回评论网址。过长的正文截断。正文末尾带幂等键 `op` 的
311    /// 隐藏标记;已有带同一标记的评论(上次发出而未及记账)则不再发,返回其网址。
312    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    /// 以默认分支最新提交为底,在远端新建分支 `branch`、一次提交写入 `files` 的 `after`,
337    /// 再开草稿 PR,返回 PR 网址。先核对每个文件的远端原文与 `before` 一致(新建的须不存在),
338    /// 不一致则不开,以免 PR 悄悄冲掉远端的改动。
339    /// `message` 为 PR 里那个提交的说明(与本地提交的相同,便于对应),末尾另加 `Ziyuan-Op` 尾注。
340    ///
341    /// 按幂等键 `op` 续作:`branch` 上已有正文带同一标记的 PR 就返回它;分支已由同一 `op` 建好
342    /// (提交带同一尾注)而 PR 未开,就只开 PR。分支或 PR 已存在却不是这次驿传所建,则报错、不覆盖。
343    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    /// 核对远端原文后,以 `base` 最新提交为底建一个提交,并建分支 `branch` 指向它。
402    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    /// 收到的请求:「方法 路径」、Authorization 与正文。
469    type Requests = Arc<Mutex<Vec<(String, String, String)>>>;
470
471    /// 依次以 `replies`(状态、正文)应答,并记下收到的请求。
472    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        // 分支上没有 PR、分支尚不存在:取默认分支的最新提交与目录树。
570        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        // 远端原文已变:不开 PR,不建分支。
580        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        // 原文一致:建树、提交(带 Ziyuan-Op 尾注)、分支,再开草稿 PR(正文带标记)。
610        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        // 上次已开出 PR 而未及记账:认出标记,直接返回,不再开。
659        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        // 分支已由同一驿传建好而 PR 未开:不再建树与提交,只开 PR。
668        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        // 分支或 PR 不是这次驿传所建:不覆盖、不另开。
682        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        // 已有带同一标记的评论:不再发。
704        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        // 没有:发出,正文末尾带标记。
725        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}