xtask/release/
changelog.rs

1use std::fmt::Write;
2use std::{env, iter};
3
4use anyhow::bail;
5use xshell::{Shell, cmd};
6
7pub(crate) fn get_changelog(
8    sh: &Shell,
9    changelog_n: usize,
10    commit: &str,
11    prev_tag: &str,
12    today: &str,
13) -> anyhow::Result<String> {
14    let token = match env::var("GITHUB_TOKEN") {
15        Ok(token) => token,
16        Err(_) => bail!(
17            "Please obtain a personal access token from https://github.com/settings/tokens and set the `GITHUB_TOKEN` environment variable."
18        ),
19    };
20
21    let git_log = cmd!(sh, "git log {prev_tag}..HEAD --reverse").read()?;
22    let mut features = String::new();
23    let mut fixes = String::new();
24    let mut internal = String::new();
25    let mut others = String::new();
26    for line in git_log.lines() {
27        let line = line.trim_start();
28        if let Some(pr_num) = parse_pr_number(line) {
29            let accept = "Accept: application/vnd.github.v3+json";
30            let authorization = format!("Authorization: token {token}");
31            let pr_url = "https://api.github.com/repos/rust-lang/rust-analyzer/issues";
32
33            // we don't use an HTTPS client or JSON parser to keep the build times low
34            let pr = pr_num.to_string();
35            let cmd = &cmd!(sh, "curl --fail -s -H {accept} -H {authorization} {pr_url}/{pr}");
36            let pr_json = match cmd.read() {
37                Ok(pr_json) => pr_json,
38                Err(e) => {
39                    // most likely a rust-lang/rust PR
40                    eprintln!("Cannot get info for #{pr}: {e}");
41                    continue;
42                }
43            };
44
45            let pr_title = cmd!(sh, "jq .title").stdin(&pr_json).read()?;
46            let pr_title = unescape(&pr_title[1..pr_title.len() - 1]);
47            let pr_comment = cmd!(sh, "jq .body").stdin(pr_json).read()?;
48
49            let cmd =
50                &cmd!(sh, "curl --fail -s -H {accept} -H {authorization} {pr_url}/{pr}/comments");
51            let pr_info = match cmd.read() {
52                Ok(comments_json) => {
53                    let pr_comments = cmd!(sh, "jq .[].body").stdin(comments_json).read()?;
54
55                    iter::once(pr_comment.as_str())
56                        .chain(pr_comments.lines())
57                        .rev()
58                        .find_map(|it| {
59                            let it = unescape(&it[1..it.len() - 1]);
60                            it.lines().find_map(parse_changelog_line)
61                        })
62                        .into_iter()
63                        .next()
64                }
65                Err(e) => {
66                    eprintln!("Cannot get comments for #{pr}: {e}");
67                    None
68                }
69            };
70
71            let pr_info = pr_info.unwrap_or_else(|| parse_title_line(&pr_title));
72            let s = match pr_info.kind {
73                PrKind::Feature => &mut features,
74                PrKind::Fix => &mut fixes,
75                PrKind::Internal => &mut internal,
76                PrKind::Other => &mut others,
77                PrKind::Skip => continue,
78            };
79            writeln!(s, "* pr:{pr_num}[] {}", pr_info.message.as_deref().unwrap_or(&pr_title))
80                .unwrap();
81        }
82    }
83
84    let contents = format!(
85        "\
86= Changelog #{changelog_n}
87:sectanchors:
88:experimental:
89:page-layout: post
90
91Commit: commit:{commit}[] +
92Release: release:{today}[] (`TBD`)
93
94== New Features
95
96{features}
97
98== Fixes
99
100{fixes}
101
102== Internal Improvements
103
104{internal}
105
106== Others
107
108{others}
109"
110    );
111    Ok(contents)
112}
113
114#[derive(Clone, Copy)]
115enum PrKind {
116    Feature,
117    Fix,
118    Internal,
119    Other,
120    Skip,
121}
122
123struct PrInfo {
124    message: Option<String>,
125    kind: PrKind,
126}
127
128fn unescape(s: &str) -> String {
129    s.replace(r#"\""#, "").replace(r#"\n"#, "\n").replace(r#"\r"#, "")
130}
131
132fn parse_pr_number(s: &str) -> Option<u32> {
133    const GITHUB_PREFIX: &str = "Merge pull request #";
134    const HOMU_PREFIX: &str = "Auto merge of #";
135    if let Some(s) = s.strip_prefix(GITHUB_PREFIX) {
136        let s = if let Some(space) = s.find(' ') { &s[..space] } else { s };
137        s.parse().ok()
138    } else if let Some(s) = s.strip_prefix(HOMU_PREFIX) {
139        if let Some(space) = s.find(' ') { s[..space].parse().ok() } else { None }
140    } else {
141        None
142    }
143}
144
145fn parse_changelog_line(s: &str) -> Option<PrInfo> {
146    let parts = s.splitn(3, ' ').collect::<Vec<_>>();
147    if parts.len() < 2 || parts[0] != "changelog" {
148        return None;
149    }
150    let message = parts.get(2).map(|it| it.to_string());
151    let kind = match parts[1].trim_end_matches(':') {
152        "feature" => PrKind::Feature,
153        "fix" => PrKind::Fix,
154        "internal" => PrKind::Internal,
155        "skip" => PrKind::Skip,
156        _ => {
157            let kind = PrKind::Other;
158            let message = format!("{} {}", parts[1], message.unwrap_or_default());
159            return Some(PrInfo { kind, message: Some(message) });
160        }
161    };
162    let res = PrInfo { message, kind };
163    Some(res)
164}
165
166fn parse_title_line(s: &str) -> PrInfo {
167    let lower = s.to_ascii_lowercase();
168    const PREFIXES: [(&str, PrKind); 5] = [
169        ("feat: ", PrKind::Feature),
170        ("feature: ", PrKind::Feature),
171        ("fix: ", PrKind::Fix),
172        ("internal: ", PrKind::Internal),
173        ("minor: ", PrKind::Skip),
174    ];
175
176    for (prefix, kind) in PREFIXES {
177        if lower.starts_with(prefix) {
178            let message = match &kind {
179                PrKind::Skip => None,
180                _ => Some(s[prefix.len()..].to_string()),
181            };
182            return PrInfo { message, kind };
183        }
184    }
185    PrInfo { kind: PrKind::Other, message: Some(s.to_owned()) }
186}