github.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. use anyhow::Context;
  2. use futures::stream::{FuturesUnordered, StreamExt};
  3. use once_cell::sync::OnceCell;
  4. use reqwest::header::{AUTHORIZATION, USER_AGENT};
  5. use reqwest::{Client, RequestBuilder, Response, StatusCode};
  6. use std::fmt;
  7. #[derive(Debug, PartialEq, Eq, serde::Deserialize)]
  8. pub struct User {
  9. pub login: String,
  10. }
  11. impl GithubClient {
  12. async fn _send_req(&self, req: RequestBuilder) -> Result<(Response, String), reqwest::Error> {
  13. log::debug!("_send_req with {:?}", req);
  14. let req = req.build()?;
  15. let req_dbg = format!("{:?}", req);
  16. let resp = self.client.execute(req).await?;
  17. resp.error_for_status_ref()?;
  18. Ok((resp, req_dbg))
  19. }
  20. async fn send_req(&self, req: RequestBuilder) -> anyhow::Result<Vec<u8>> {
  21. let (mut resp, req_dbg) = self._send_req(req).await?;
  22. let mut body = Vec::new();
  23. while let Some(chunk) = resp.chunk().await.transpose() {
  24. let chunk = chunk
  25. .context("reading stream failed")
  26. .map_err(anyhow::Error::from)
  27. .context(req_dbg.clone())?;
  28. body.extend_from_slice(&chunk);
  29. }
  30. Ok(body)
  31. }
  32. async fn json<T>(&self, req: RequestBuilder) -> anyhow::Result<T>
  33. where
  34. T: serde::de::DeserializeOwned,
  35. {
  36. let (resp, req_dbg) = self._send_req(req).await?;
  37. Ok(resp.json().await.context(req_dbg)?)
  38. }
  39. }
  40. impl User {
  41. pub async fn current(client: &GithubClient) -> anyhow::Result<Self> {
  42. client.json(client.get("https://api.github.com/user")).await
  43. }
  44. pub async fn is_team_member<'a>(&'a self, client: &'a GithubClient) -> anyhow::Result<bool> {
  45. let url = format!("{}/teams.json", rust_team_data::v1::BASE_URL);
  46. let permission: rust_team_data::v1::Teams = client
  47. .json(client.raw().get(&url))
  48. .await
  49. .context("could not get team data")?;
  50. let map = permission.teams;
  51. let is_triager = map
  52. .get("wg-triage")
  53. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  54. Ok(map["all"].members.iter().any(|g| g.github == self.login) || is_triager)
  55. }
  56. }
  57. pub async fn get_team(
  58. client: &GithubClient,
  59. team: &str,
  60. ) -> anyhow::Result<Option<rust_team_data::v1::Team>> {
  61. let url = format!("{}/teams.json", rust_team_data::v1::BASE_URL);
  62. let permission: rust_team_data::v1::Teams = client
  63. .json(client.raw().get(&url))
  64. .await
  65. .context("could not get team data")?;
  66. let mut map = permission.teams;
  67. Ok(map.swap_remove(team))
  68. }
  69. #[derive(PartialEq, Eq, Debug, Clone, serde::Deserialize)]
  70. pub struct Label {
  71. pub name: String,
  72. }
  73. impl Label {
  74. async fn exists<'a>(&'a self, repo_api_prefix: &'a str, client: &'a GithubClient) -> bool {
  75. #[allow(clippy::redundant_pattern_matching)]
  76. let url = format!("{}/labels/{}", repo_api_prefix, self.name);
  77. match client.send_req(client.get(&url)).await {
  78. Ok(_) => true,
  79. // XXX: Error handling if the request failed for reasons beyond 'label didn't exist'
  80. Err(_) => false,
  81. }
  82. }
  83. }
  84. #[derive(Debug, serde::Deserialize)]
  85. pub struct PullRequestDetails {
  86. // none for now
  87. }
  88. #[derive(Debug, serde::Deserialize)]
  89. pub struct Issue {
  90. pub number: u64,
  91. pub body: String,
  92. title: String,
  93. html_url: String,
  94. user: User,
  95. labels: Vec<Label>,
  96. assignees: Vec<User>,
  97. pull_request: Option<PullRequestDetails>,
  98. // API URL
  99. repository_url: String,
  100. comments_url: String,
  101. #[serde(skip)]
  102. repository: OnceCell<IssueRepository>,
  103. }
  104. #[derive(Debug, serde::Deserialize)]
  105. pub struct Comment {
  106. pub body: String,
  107. pub html_url: String,
  108. pub user: User,
  109. }
  110. #[derive(Debug)]
  111. pub enum AssignmentError {
  112. InvalidAssignee,
  113. Http(reqwest::Error),
  114. }
  115. #[derive(Debug)]
  116. pub enum Selection<'a, T> {
  117. All,
  118. One(&'a T),
  119. }
  120. impl fmt::Display for AssignmentError {
  121. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  122. match self {
  123. AssignmentError::InvalidAssignee => write!(f, "invalid assignee"),
  124. AssignmentError::Http(e) => write!(f, "cannot assign: {}", e),
  125. }
  126. }
  127. }
  128. impl std::error::Error for AssignmentError {}
  129. #[derive(Debug)]
  130. pub struct IssueRepository {
  131. pub organization: String,
  132. pub repository: String,
  133. }
  134. impl fmt::Display for IssueRepository {
  135. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  136. write!(f, "{}/{}", self.organization, self.repository)
  137. }
  138. }
  139. impl Issue {
  140. pub fn repository(&self) -> &IssueRepository {
  141. self.repository.get_or_init(|| {
  142. log::trace!("get repository for {}", self.repository_url);
  143. let url = url::Url::parse(&self.repository_url).unwrap();
  144. let mut segments = url.path_segments().unwrap();
  145. let repository = segments.nth_back(0).unwrap();
  146. let organization = segments.nth_back(1).unwrap();
  147. IssueRepository {
  148. organization: organization.into(),
  149. repository: repository.into(),
  150. }
  151. })
  152. }
  153. pub fn global_id(&self) -> String {
  154. format!("{}#{}", self.repository(), self.number)
  155. }
  156. pub fn is_pr(&self) -> bool {
  157. self.pull_request.is_some()
  158. }
  159. pub async fn get_comment(&self, client: &GithubClient, id: usize) -> anyhow::Result<Comment> {
  160. let comment_url = format!("{}/issues/comments/{}", self.repository_url, id);
  161. let comment = client.json(client.get(&comment_url)).await?;
  162. Ok(comment)
  163. }
  164. pub async fn edit_body(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  165. let edit_url = format!("{}/issues/{}", self.repository_url, self.number);
  166. #[derive(serde::Serialize)]
  167. struct ChangedIssue<'a> {
  168. body: &'a str,
  169. }
  170. client
  171. ._send_req(client.patch(&edit_url).json(&ChangedIssue { body }))
  172. .await
  173. .context("failed to edit issue body")?;
  174. Ok(())
  175. }
  176. pub async fn edit_comment(
  177. &self,
  178. client: &GithubClient,
  179. id: usize,
  180. new_body: &str,
  181. ) -> anyhow::Result<()> {
  182. let comment_url = format!("{}/issues/comments/{}", self.repository_url, id);
  183. #[derive(serde::Serialize)]
  184. struct NewComment<'a> {
  185. body: &'a str,
  186. }
  187. client
  188. ._send_req(
  189. client
  190. .patch(&comment_url)
  191. .json(&NewComment { body: new_body }),
  192. )
  193. .await
  194. .context("failed to edit comment")?;
  195. Ok(())
  196. }
  197. pub async fn post_comment(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  198. #[derive(serde::Serialize)]
  199. struct PostComment<'a> {
  200. body: &'a str,
  201. }
  202. client
  203. ._send_req(client.post(&self.comments_url).json(&PostComment { body }))
  204. .await
  205. .context("failed to post comment")?;
  206. Ok(())
  207. }
  208. pub async fn set_labels(
  209. &self,
  210. client: &GithubClient,
  211. labels: Vec<Label>,
  212. ) -> anyhow::Result<()> {
  213. log::info!("set_labels {} to {:?}", self.global_id(), labels);
  214. // PUT /repos/:owner/:repo/issues/:number/labels
  215. // repo_url = https://api.github.com/repos/Codertocat/Hello-World
  216. let url = format!(
  217. "{repo_url}/issues/{number}/labels",
  218. repo_url = self.repository_url,
  219. number = self.number
  220. );
  221. let mut stream = labels
  222. .into_iter()
  223. .map(|label| async { (label.exists(&self.repository_url, &client).await, label) })
  224. .collect::<FuturesUnordered<_>>();
  225. let mut labels = Vec::new();
  226. while let Some((true, label)) = stream.next().await {
  227. labels.push(label);
  228. }
  229. #[derive(serde::Serialize)]
  230. struct LabelsReq {
  231. labels: Vec<String>,
  232. }
  233. client
  234. ._send_req(client.put(&url).json(&LabelsReq {
  235. labels: labels.iter().map(|l| l.name.clone()).collect(),
  236. }))
  237. .await
  238. .context("failed to set labels")?;
  239. Ok(())
  240. }
  241. pub fn labels(&self) -> &[Label] {
  242. &self.labels
  243. }
  244. pub fn contain_assignee(&self, user: &User) -> bool {
  245. self.assignees.contains(user)
  246. }
  247. pub async fn remove_assignees(
  248. &self,
  249. client: &GithubClient,
  250. selection: Selection<'_, User>,
  251. ) -> Result<(), AssignmentError> {
  252. log::info!("remove {:?} assignees for {}", selection, self.global_id());
  253. let url = format!(
  254. "{repo_url}/issues/{number}/assignees",
  255. repo_url = self.repository_url,
  256. number = self.number
  257. );
  258. let assignees = match selection {
  259. Selection::All => self
  260. .assignees
  261. .iter()
  262. .map(|u| u.login.as_str())
  263. .collect::<Vec<_>>(),
  264. Selection::One(user) => vec![user.login.as_str()],
  265. };
  266. #[derive(serde::Serialize)]
  267. struct AssigneeReq<'a> {
  268. assignees: &'a [&'a str],
  269. }
  270. client
  271. ._send_req(client.delete(&url).json(&AssigneeReq {
  272. assignees: &assignees[..],
  273. }))
  274. .await
  275. .map_err(AssignmentError::Http)?;
  276. Ok(())
  277. }
  278. pub async fn set_assignee(
  279. &self,
  280. client: &GithubClient,
  281. user: &str,
  282. ) -> Result<(), AssignmentError> {
  283. log::info!("set_assignee for {} to {}", self.global_id(), user);
  284. let url = format!(
  285. "{repo_url}/issues/{number}/assignees",
  286. repo_url = self.repository_url,
  287. number = self.number
  288. );
  289. let check_url = format!(
  290. "{repo_url}/assignees/{name}",
  291. repo_url = self.repository_url,
  292. name = user,
  293. );
  294. match client._send_req(client.get(&check_url)).await {
  295. Ok((resp, _)) => {
  296. if resp.status() == reqwest::StatusCode::NO_CONTENT {
  297. // all okay
  298. log::debug!("set_assignee: assignee is valid");
  299. } else {
  300. log::error!(
  301. "unknown status for assignee check, assuming all okay: {:?}",
  302. resp
  303. );
  304. }
  305. }
  306. Err(e) => {
  307. if e.status() == Some(reqwest::StatusCode::NOT_FOUND) {
  308. log::debug!("set_assignee: assignee is invalid, returning");
  309. return Err(AssignmentError::InvalidAssignee);
  310. }
  311. log::debug!("set_assignee: get {} failed, {:?}", check_url, e);
  312. return Err(AssignmentError::Http(e));
  313. }
  314. }
  315. self.remove_assignees(client, Selection::All).await?;
  316. #[derive(serde::Serialize)]
  317. struct AssigneeReq<'a> {
  318. assignees: &'a [&'a str],
  319. }
  320. client
  321. ._send_req(client.post(&url).json(&AssigneeReq { assignees: &[user] }))
  322. .await
  323. .map_err(AssignmentError::Http)?;
  324. Ok(())
  325. }
  326. }
  327. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  328. #[serde(rename_all = "lowercase")]
  329. pub enum IssueCommentAction {
  330. Created,
  331. Edited,
  332. Deleted,
  333. }
  334. #[derive(Debug, serde::Deserialize)]
  335. pub struct IssueCommentEvent {
  336. pub action: IssueCommentAction,
  337. pub issue: Issue,
  338. pub comment: Comment,
  339. pub repository: Repository,
  340. }
  341. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  342. #[serde(rename_all = "lowercase")]
  343. pub enum IssuesAction {
  344. Opened,
  345. Edited,
  346. Deleted,
  347. Transferred,
  348. Pinned,
  349. Unpinned,
  350. Closed,
  351. Reopened,
  352. Assigned,
  353. Unassigned,
  354. Labeled,
  355. Unlabeled,
  356. Locked,
  357. Unlocked,
  358. Milestoned,
  359. Demilestoned,
  360. }
  361. #[derive(Debug, serde::Deserialize)]
  362. pub struct IssuesEvent {
  363. pub action: IssuesAction,
  364. pub issue: Issue,
  365. pub repository: Repository,
  366. }
  367. #[derive(Debug, serde::Deserialize)]
  368. pub struct Repository {
  369. pub full_name: String,
  370. }
  371. #[derive(Debug)]
  372. pub enum Event {
  373. IssueComment(IssueCommentEvent),
  374. Issue(IssuesEvent),
  375. }
  376. impl Event {
  377. pub fn repo_name(&self) -> &str {
  378. match self {
  379. Event::IssueComment(event) => &event.repository.full_name,
  380. Event::Issue(event) => &event.repository.full_name,
  381. }
  382. }
  383. pub fn issue(&self) -> Option<&Issue> {
  384. match self {
  385. Event::IssueComment(event) => Some(&event.issue),
  386. Event::Issue(event) => Some(&event.issue),
  387. }
  388. }
  389. /// This will both extract from IssueComment events but also Issue events
  390. pub fn comment_body(&self) -> Option<&str> {
  391. match self {
  392. Event::Issue(e) => Some(&e.issue.body),
  393. Event::IssueComment(e) => Some(&e.comment.body),
  394. }
  395. }
  396. pub fn html_url(&self) -> Option<&str> {
  397. match self {
  398. Event::Issue(e) => Some(&e.issue.html_url),
  399. Event::IssueComment(e) => Some(&e.comment.html_url),
  400. }
  401. }
  402. pub fn user(&self) -> &User {
  403. match self {
  404. Event::Issue(e) => &e.issue.user,
  405. Event::IssueComment(e) => &e.comment.user,
  406. }
  407. }
  408. }
  409. trait RequestSend: Sized {
  410. fn configure(self, g: &GithubClient) -> Self;
  411. }
  412. impl RequestSend for RequestBuilder {
  413. fn configure(self, g: &GithubClient) -> RequestBuilder {
  414. self.header(USER_AGENT, "rust-lang-triagebot")
  415. .header(AUTHORIZATION, format!("token {}", g.token))
  416. }
  417. }
  418. #[derive(Clone)]
  419. pub struct GithubClient {
  420. token: String,
  421. client: Client,
  422. }
  423. impl GithubClient {
  424. pub fn new(client: Client, token: String) -> Self {
  425. GithubClient { client, token }
  426. }
  427. pub fn raw(&self) -> &Client {
  428. &self.client
  429. }
  430. pub async fn raw_file(
  431. &self,
  432. repo: &str,
  433. branch: &str,
  434. path: &str,
  435. ) -> anyhow::Result<Option<Vec<u8>>> {
  436. let url = format!(
  437. "https://raw.githubusercontent.com/{}/{}/{}",
  438. repo, branch, path
  439. );
  440. let req = self.get(&url);
  441. let req_dbg = format!("{:?}", req);
  442. let req = req
  443. .build()
  444. .with_context(|| format!("failed to build request {:?}", req_dbg))?;
  445. let mut resp = self.client.execute(req).await.context(req_dbg.clone())?;
  446. let status = resp.status();
  447. match status {
  448. StatusCode::OK => {
  449. let mut buf = Vec::with_capacity(resp.content_length().unwrap_or(4) as usize);
  450. while let Some(chunk) = resp.chunk().await.transpose() {
  451. let chunk = chunk
  452. .context("reading stream failed")
  453. .map_err(anyhow::Error::from)
  454. .context(req_dbg.clone())?;
  455. buf.extend_from_slice(&chunk);
  456. }
  457. Ok(Some(buf))
  458. }
  459. StatusCode::NOT_FOUND => Ok(None),
  460. status => anyhow::bail!("failed to GET {}: {}", url, status),
  461. }
  462. }
  463. fn get(&self, url: &str) -> RequestBuilder {
  464. log::trace!("get {:?}", url);
  465. self.client.get(url).configure(self)
  466. }
  467. fn patch(&self, url: &str) -> RequestBuilder {
  468. log::trace!("patch {:?}", url);
  469. self.client.patch(url).configure(self)
  470. }
  471. fn delete(&self, url: &str) -> RequestBuilder {
  472. log::trace!("delete {:?}", url);
  473. self.client.delete(url).configure(self)
  474. }
  475. fn post(&self, url: &str) -> RequestBuilder {
  476. log::trace!("post {:?}", url);
  477. self.client.post(url).configure(self)
  478. }
  479. fn put(&self, url: &str) -> RequestBuilder {
  480. log::trace!("put {:?}", url);
  481. self.client.put(url).configure(self)
  482. }
  483. }