|
| 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