Skip to content

Commit 4b08957

Browse files
authored
Merge pull request #135 from nikomatsakis/gh-json
generate json files with status of tracking issues
2 parents daa642d + 54dee0c commit 4b08957

File tree

11 files changed

+543
-298
lines changed

11 files changed

+543
-298
lines changed

.github/workflows/mdbook.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ jobs:
4242
uses: actions/configure-pages@v5
4343
- name: Build with mdBook
4444
run: mdbook build
45+
- name: Generate JSON data
46+
run: cargo run -- json 2024h2 --json-path book/html/api/2024h2.json
4547
- name: Upload artifact
4648
uses: actions/upload-pages-artifact@v3
4749
with:

mdbook-goals/src/gh.rs

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,7 @@
1-
use std::fmt::Display;
1+
//! Code for querying and interacting with github.
2+
//!
3+
//! We do most everything through the `gh` command-line tool.
24
3-
use crate::re::TRACKING_ISSUE;
4-
5-
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)]
6-
pub struct IssueId {
7-
/// Something like `rust-lang/rust-project-goals`
8-
pub repository: String,
9-
10-
/// Something like `22`
11-
pub number: u64,
12-
}
13-
14-
impl IssueId {
15-
pub fn new(repository: &(impl Display + ?Sized), number: u64) -> Self {
16-
Self {
17-
repository: repository.to_string(),
18-
number,
19-
}
20-
}
21-
}
22-
23-
impl std::fmt::Debug for IssueId {
24-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25-
write!(f, "{self}")
26-
}
27-
}
28-
29-
impl std::fmt::Display for IssueId {
30-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31-
write!(
32-
f,
33-
"[{repository}#{number}]",
34-
repository = self.repository,
35-
number = self.number,
36-
)
37-
}
38-
}
39-
40-
impl std::str::FromStr for IssueId {
41-
type Err = anyhow::Error;
42-
43-
fn from_str(s: &str) -> Result<Self, Self::Err> {
44-
let Some(c) = TRACKING_ISSUE.captures(s) else {
45-
anyhow::bail!("invalid issue-id")
46-
};
47-
48-
Ok(IssueId::new(&c[1], c[2].parse()?))
49-
}
50-
}
5+
pub mod issue_id;
6+
pub mod issues;
7+
pub mod labels;

mdbook-goals/src/gh/issue_id.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use crate::re::TRACKING_ISSUE;
2+
use std::fmt::Display;
3+
4+
#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)]
5+
pub struct IssueId {
6+
/// Something like `rust-lang/rust-project-goals`
7+
pub repository: String,
8+
9+
/// Something like `22`
10+
pub number: u64,
11+
}
12+
13+
impl IssueId {
14+
pub fn new(repository: &(impl Display + ?Sized), number: u64) -> Self {
15+
Self {
16+
repository: repository.to_string(),
17+
number,
18+
}
19+
}
20+
}
21+
22+
impl std::fmt::Debug for IssueId {
23+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24+
write!(f, "{self}")
25+
}
26+
}
27+
28+
impl std::fmt::Display for IssueId {
29+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30+
write!(
31+
f,
32+
"[{repository}#{number}]",
33+
repository = self.repository,
34+
number = self.number,
35+
)
36+
}
37+
}
38+
39+
impl std::str::FromStr for IssueId {
40+
type Err = anyhow::Error;
41+
42+
fn from_str(s: &str) -> Result<Self, Self::Err> {
43+
let Some(c) = TRACKING_ISSUE.captures(s) else {
44+
anyhow::bail!("invalid issue-id")
45+
};
46+
47+
Ok(IssueId::new(&c[1], c[2].parse()?))
48+
}
49+
}

mdbook-goals/src/gh/issues.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
use std::{
2+
collections::{BTreeMap, BTreeSet},
3+
process::Command,
4+
};
5+
6+
use serde::{Deserialize, Serialize};
7+
8+
use crate::util::comma;
9+
10+
use super::labels::GhLabel;
11+
12+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
13+
pub struct ExistingGithubIssue {
14+
pub number: u64,
15+
/// Just github username, no `@`
16+
pub assignees: BTreeSet<String>,
17+
pub comments: Vec<ExistingGithubComment>,
18+
pub body: String,
19+
pub state: ExistingIssueState,
20+
pub labels: Vec<GhLabel>,
21+
}
22+
23+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
24+
pub struct ExistingGithubComment {
25+
/// Just github username, no `@`
26+
pub author: String,
27+
pub body: String,
28+
pub created_at: String,
29+
pub url: String,
30+
}
31+
32+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
33+
struct ExistingGithubIssueJson {
34+
title: String,
35+
number: u64,
36+
assignees: Vec<ExistingGithubAssigneeJson>,
37+
comments: Vec<ExistingGithubCommentJson>,
38+
body: String,
39+
state: ExistingIssueState,
40+
labels: Vec<GhLabel>,
41+
}
42+
43+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
44+
struct ExistingGithubAssigneeJson {
45+
login: String,
46+
name: String,
47+
}
48+
49+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
50+
struct ExistingGithubCommentJson {
51+
body: String,
52+
author: ExistingGithubAuthorJson,
53+
#[serde(rename = "createdAt")]
54+
created_at: String,
55+
url: String,
56+
}
57+
58+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
59+
struct ExistingGithubAuthorJson {
60+
login: String,
61+
}
62+
63+
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
64+
#[serde(rename_all = "UPPERCASE")]
65+
pub enum ExistingIssueState {
66+
Open,
67+
Closed,
68+
}
69+
70+
pub fn list_issue_titles_in_milestone(
71+
repository: &str,
72+
timeframe: &str,
73+
) -> anyhow::Result<BTreeMap<String, ExistingGithubIssue>> {
74+
let output = Command::new("gh")
75+
.arg("-R")
76+
.arg(repository)
77+
.arg("issue")
78+
.arg("list")
79+
.arg("-m")
80+
.arg(timeframe)
81+
.arg("-s")
82+
.arg("all")
83+
.arg("--json")
84+
.arg("title,assignees,number,comments,body,state,labels")
85+
.output()?;
86+
87+
let existing_issues: Vec<ExistingGithubIssueJson> = serde_json::from_slice(&output.stdout)?;
88+
89+
Ok(existing_issues
90+
.into_iter()
91+
.map(|e_i| {
92+
(
93+
e_i.title,
94+
ExistingGithubIssue {
95+
number: e_i.number,
96+
assignees: e_i.assignees.into_iter().map(|a| a.login).collect(),
97+
comments: e_i
98+
.comments
99+
.into_iter()
100+
.map(|c| ExistingGithubComment {
101+
author: format!("@{}", c.author.login),
102+
body: c.body,
103+
url: c.url,
104+
created_at: c.created_at,
105+
})
106+
.collect(),
107+
body: e_i.body,
108+
state: e_i.state,
109+
labels: e_i.labels,
110+
},
111+
)
112+
})
113+
.collect())
114+
}
115+
116+
pub fn create_issue(
117+
repository: &str,
118+
body: &str,
119+
title: &str,
120+
labels: &[String],
121+
assignees: &BTreeSet<String>,
122+
milestone: &str,
123+
) -> anyhow::Result<()> {
124+
let output = Command::new("gh")
125+
.arg("-R")
126+
.arg(&repository)
127+
.arg("issue")
128+
.arg("create")
129+
.arg("-b")
130+
.arg(&body)
131+
.arg("-t")
132+
.arg(&title)
133+
.arg("-l")
134+
.arg(labels.join(","))
135+
.arg("-a")
136+
.arg(comma(&assignees))
137+
.arg("-m")
138+
.arg(&milestone)
139+
.output()?;
140+
141+
if !output.status.success() {
142+
Err(anyhow::anyhow!(
143+
"failed to create issue `{}`: {}",
144+
title,
145+
String::from_utf8_lossy(&output.stderr)
146+
))
147+
} else {
148+
Ok(())
149+
}
150+
}
151+
152+
pub fn sync_assignees(
153+
repository: &str,
154+
number: u64,
155+
remove_owners: &BTreeSet<String>,
156+
add_owners: &BTreeSet<String>,
157+
) -> anyhow::Result<()> {
158+
let mut command = Command::new("gh");
159+
command
160+
.arg("-R")
161+
.arg(&repository)
162+
.arg("issue")
163+
.arg("edit")
164+
.arg(number.to_string());
165+
166+
if !remove_owners.is_empty() {
167+
command.arg("--remove-assignee").arg(comma(&remove_owners));
168+
}
169+
170+
if !add_owners.is_empty() {
171+
command.arg("--add-assignee").arg(comma(&add_owners));
172+
}
173+
174+
let output = command.output()?;
175+
if !output.status.success() {
176+
Err(anyhow::anyhow!(
177+
"failed to sync issue `{}`: {}",
178+
number,
179+
String::from_utf8_lossy(&output.stderr)
180+
))
181+
} else {
182+
Ok(())
183+
}
184+
}
185+
186+
const LOCK_TEXT: &str = "This issue is intended for status updates only.\n\nFor general questions or comments, please contact the owner(s) directly.";
187+
188+
impl ExistingGithubIssue {
189+
/// We use the presence of a "lock comment" as a signal that we successfully locked the issue.
190+
/// The github CLI doesn't let you query that directly.
191+
pub fn was_locked(&self) -> bool {
192+
self.comments.iter().any(|c| c.body.trim() == LOCK_TEXT)
193+
}
194+
}
195+
196+
pub fn lock_issue(repository: &str, number: u64) -> anyhow::Result<()> {
197+
let output = Command::new("gh")
198+
.arg("-R")
199+
.arg(repository)
200+
.arg("issue")
201+
.arg("lock")
202+
.arg(number.to_string())
203+
.output()?;
204+
205+
if !output.status.success() {
206+
if !output.stderr.starts_with(b"already locked") {
207+
return Err(anyhow::anyhow!(
208+
"failed to lock issue `{}`: {}",
209+
number,
210+
String::from_utf8_lossy(&output.stderr)
211+
));
212+
}
213+
}
214+
215+
// Leave a comment explaining what is going on.
216+
let output = Command::new("gh")
217+
.arg("-R")
218+
.arg(repository)
219+
.arg("issue")
220+
.arg("comment")
221+
.arg(number.to_string())
222+
.arg("-b")
223+
.arg(LOCK_TEXT)
224+
.output()?;
225+
226+
if !output.status.success() {
227+
return Err(anyhow::anyhow!(
228+
"failed to leave lock comment `{}`: {}",
229+
number,
230+
String::from_utf8_lossy(&output.stderr)
231+
));
232+
}
233+
234+
Ok(())
235+
}
236+
237+
impl ExistingGithubComment {
238+
/// True if this is one of the special comments that we put on issues.
239+
pub fn is_automated_comment(&self) -> bool {
240+
self.body.trim() == LOCK_TEXT
241+
}
242+
}

0 commit comments

Comments
 (0)