github.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. use anyhow::Context;
  2. use chrono::{DateTime, FixedOffset, Utc};
  3. use futures::stream::{FuturesUnordered, StreamExt};
  4. use once_cell::sync::OnceCell;
  5. use reqwest::header::{AUTHORIZATION, USER_AGENT};
  6. use reqwest::{Client, RequestBuilder, Response, StatusCode};
  7. use std::fmt;
  8. #[derive(Debug, PartialEq, Eq, serde::Deserialize)]
  9. pub struct User {
  10. pub login: String,
  11. pub id: Option<i64>,
  12. }
  13. impl GithubClient {
  14. async fn _send_req(&self, req: RequestBuilder) -> Result<(Response, String), reqwest::Error> {
  15. log::debug!("_send_req with {:?}", req);
  16. let req = req.build()?;
  17. let req_dbg = format!("{:?}", req);
  18. let resp = self.client.execute(req).await?;
  19. resp.error_for_status_ref()?;
  20. Ok((resp, req_dbg))
  21. }
  22. async fn send_req(&self, req: RequestBuilder) -> anyhow::Result<Vec<u8>> {
  23. let (mut resp, req_dbg) = self._send_req(req).await?;
  24. let mut body = Vec::new();
  25. while let Some(chunk) = resp.chunk().await.transpose() {
  26. let chunk = chunk
  27. .context("reading stream failed")
  28. .map_err(anyhow::Error::from)
  29. .context(req_dbg.clone())?;
  30. body.extend_from_slice(&chunk);
  31. }
  32. Ok(body)
  33. }
  34. pub async fn json<T>(&self, req: RequestBuilder) -> anyhow::Result<T>
  35. where
  36. T: serde::de::DeserializeOwned,
  37. {
  38. let (resp, req_dbg) = self._send_req(req).await?;
  39. Ok(resp.json().await.context(req_dbg)?)
  40. }
  41. }
  42. impl User {
  43. pub async fn current(client: &GithubClient) -> anyhow::Result<Self> {
  44. client.json(client.get("https://api.github.com/user")).await
  45. }
  46. pub async fn is_team_member<'a>(&'a self, client: &'a GithubClient) -> anyhow::Result<bool> {
  47. let permission = crate::team_data::teams(client).await?;
  48. let map = permission.teams;
  49. let is_triager = map
  50. .get("wg-triage")
  51. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  52. let is_pri_member = map
  53. .get("wg-prioritization")
  54. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  55. Ok(
  56. map["all"].members.iter().any(|g| g.github == self.login)
  57. || is_triager
  58. || is_pri_member,
  59. )
  60. }
  61. // Returns the ID of the given user, if the user is in the `all` team.
  62. pub async fn get_id<'a>(&'a self, client: &'a GithubClient) -> anyhow::Result<Option<usize>> {
  63. let permission = crate::team_data::teams(client).await?;
  64. let map = permission.teams;
  65. Ok(map["all"]
  66. .members
  67. .iter()
  68. .find(|g| g.github == self.login)
  69. .map(|u| u.github_id))
  70. }
  71. }
  72. pub async fn get_team(
  73. client: &GithubClient,
  74. team: &str,
  75. ) -> anyhow::Result<Option<rust_team_data::v1::Team>> {
  76. let permission = crate::team_data::teams(client).await?;
  77. let mut map = permission.teams;
  78. Ok(map.swap_remove(team))
  79. }
  80. #[derive(PartialEq, Eq, Debug, Clone, serde::Deserialize)]
  81. pub struct Label {
  82. pub name: String,
  83. }
  84. impl Label {
  85. async fn exists<'a>(&'a self, repo_api_prefix: &'a str, client: &'a GithubClient) -> bool {
  86. #[allow(clippy::redundant_pattern_matching)]
  87. let url = format!("{}/labels/{}", repo_api_prefix, self.name);
  88. match client.send_req(client.get(&url)).await {
  89. Ok(_) => true,
  90. // XXX: Error handling if the request failed for reasons beyond 'label didn't exist'
  91. Err(_) => false,
  92. }
  93. }
  94. }
  95. #[derive(Debug, serde::Deserialize)]
  96. pub struct PullRequestDetails {
  97. // none for now
  98. }
  99. #[derive(Debug, serde::Deserialize)]
  100. pub struct Issue {
  101. pub number: u64,
  102. pub body: String,
  103. created_at: chrono::DateTime<Utc>,
  104. pub title: String,
  105. html_url: String,
  106. pub user: User,
  107. labels: Vec<Label>,
  108. assignees: Vec<User>,
  109. pull_request: Option<PullRequestDetails>,
  110. // API URL
  111. comments_url: String,
  112. #[serde(skip)]
  113. repository: OnceCell<IssueRepository>,
  114. }
  115. #[derive(Debug, serde::Deserialize)]
  116. pub struct Comment {
  117. #[serde(deserialize_with = "opt_string")]
  118. pub body: String,
  119. pub html_url: String,
  120. pub user: User,
  121. #[serde(alias = "submitted_at")] // for pull request reviews
  122. pub updated_at: chrono::DateTime<Utc>,
  123. }
  124. fn opt_string<'de, D>(deserializer: D) -> Result<String, D::Error>
  125. where
  126. D: serde::de::Deserializer<'de>,
  127. {
  128. use serde::de::Deserialize;
  129. match <Option<String>>::deserialize(deserializer) {
  130. Ok(v) => Ok(v.unwrap_or_default()),
  131. Err(e) => Err(e),
  132. }
  133. }
  134. #[derive(Debug)]
  135. pub enum AssignmentError {
  136. InvalidAssignee,
  137. Http(reqwest::Error),
  138. }
  139. #[derive(Debug)]
  140. pub enum Selection<'a, T> {
  141. All,
  142. One(&'a T),
  143. }
  144. impl fmt::Display for AssignmentError {
  145. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  146. match self {
  147. AssignmentError::InvalidAssignee => write!(f, "invalid assignee"),
  148. AssignmentError::Http(e) => write!(f, "cannot assign: {}", e),
  149. }
  150. }
  151. }
  152. impl std::error::Error for AssignmentError {}
  153. #[derive(Debug)]
  154. pub struct IssueRepository {
  155. pub organization: String,
  156. pub repository: String,
  157. }
  158. impl fmt::Display for IssueRepository {
  159. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  160. write!(f, "{}/{}", self.organization, self.repository)
  161. }
  162. }
  163. impl IssueRepository {
  164. fn url(&self) -> String {
  165. format!(
  166. "https://api.github.com/repos/{}/{}",
  167. self.organization, self.repository
  168. )
  169. }
  170. }
  171. impl Issue {
  172. pub fn zulip_topic_reference(&self) -> String {
  173. let repo = self.repository();
  174. if repo.organization == "rust-lang" {
  175. if repo.repository == "rust" {
  176. format!("#{}", self.number)
  177. } else {
  178. format!("{}#{}", repo.repository, self.number)
  179. }
  180. } else {
  181. format!("{}/{}#{}", repo.organization, repo.repository, self.number)
  182. }
  183. }
  184. pub fn repository(&self) -> &IssueRepository {
  185. self.repository.get_or_init(|| {
  186. // https://api.github.com/repos/rust-lang/rust/issues/69257/comments
  187. log::trace!("get repository for {}", self.comments_url);
  188. let url = url::Url::parse(&self.comments_url).unwrap();
  189. let mut segments = url.path_segments().unwrap();
  190. let _comments = segments.next_back().unwrap();
  191. let _number = segments.next_back().unwrap();
  192. let _issues_or_prs = segments.next_back().unwrap();
  193. let repository = segments.next_back().unwrap();
  194. let organization = segments.next_back().unwrap();
  195. IssueRepository {
  196. organization: organization.into(),
  197. repository: repository.into(),
  198. }
  199. })
  200. }
  201. pub fn global_id(&self) -> String {
  202. format!("{}#{}", self.repository(), self.number)
  203. }
  204. pub fn is_pr(&self) -> bool {
  205. self.pull_request.is_some()
  206. }
  207. pub async fn get_comment(&self, client: &GithubClient, id: usize) -> anyhow::Result<Comment> {
  208. let comment_url = format!("{}/issues/comments/{}", self.repository().url(), id);
  209. let comment = client.json(client.get(&comment_url)).await?;
  210. Ok(comment)
  211. }
  212. pub async fn edit_body(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  213. let edit_url = format!("{}/issues/{}", self.repository().url(), self.number);
  214. #[derive(serde::Serialize)]
  215. struct ChangedIssue<'a> {
  216. body: &'a str,
  217. }
  218. client
  219. ._send_req(client.patch(&edit_url).json(&ChangedIssue { body }))
  220. .await
  221. .context("failed to edit issue body")?;
  222. Ok(())
  223. }
  224. pub async fn edit_comment(
  225. &self,
  226. client: &GithubClient,
  227. id: usize,
  228. new_body: &str,
  229. ) -> anyhow::Result<()> {
  230. let comment_url = format!("{}/issues/comments/{}", self.repository().url(), id);
  231. #[derive(serde::Serialize)]
  232. struct NewComment<'a> {
  233. body: &'a str,
  234. }
  235. client
  236. ._send_req(
  237. client
  238. .patch(&comment_url)
  239. .json(&NewComment { body: new_body }),
  240. )
  241. .await
  242. .context("failed to edit comment")?;
  243. Ok(())
  244. }
  245. pub async fn post_comment(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  246. #[derive(serde::Serialize)]
  247. struct PostComment<'a> {
  248. body: &'a str,
  249. }
  250. client
  251. ._send_req(client.post(&self.comments_url).json(&PostComment { body }))
  252. .await
  253. .context("failed to post comment")?;
  254. Ok(())
  255. }
  256. pub async fn set_labels(
  257. &self,
  258. client: &GithubClient,
  259. labels: Vec<Label>,
  260. ) -> anyhow::Result<()> {
  261. log::info!("set_labels {} to {:?}", self.global_id(), labels);
  262. // PUT /repos/:owner/:repo/issues/:number/labels
  263. // repo_url = https://api.github.com/repos/Codertocat/Hello-World
  264. let url = format!(
  265. "{repo_url}/issues/{number}/labels",
  266. repo_url = self.repository().url(),
  267. number = self.number
  268. );
  269. let mut stream = labels
  270. .into_iter()
  271. .map(|label| async { (label.exists(&self.repository().url(), &client).await, label) })
  272. .collect::<FuturesUnordered<_>>();
  273. let mut labels = Vec::new();
  274. while let Some((true, label)) = stream.next().await {
  275. labels.push(label);
  276. }
  277. #[derive(serde::Serialize)]
  278. struct LabelsReq {
  279. labels: Vec<String>,
  280. }
  281. client
  282. ._send_req(client.put(&url).json(&LabelsReq {
  283. labels: labels.iter().map(|l| l.name.clone()).collect(),
  284. }))
  285. .await
  286. .context("failed to set labels")?;
  287. Ok(())
  288. }
  289. pub fn labels(&self) -> &[Label] {
  290. &self.labels
  291. }
  292. pub fn contain_assignee(&self, user: &User) -> bool {
  293. self.assignees.contains(user)
  294. }
  295. pub async fn remove_assignees(
  296. &self,
  297. client: &GithubClient,
  298. selection: Selection<'_, User>,
  299. ) -> Result<(), AssignmentError> {
  300. log::info!("remove {:?} assignees for {}", selection, self.global_id());
  301. let url = format!(
  302. "{repo_url}/issues/{number}/assignees",
  303. repo_url = self.repository().url(),
  304. number = self.number
  305. );
  306. let assignees = match selection {
  307. Selection::All => self
  308. .assignees
  309. .iter()
  310. .map(|u| u.login.as_str())
  311. .collect::<Vec<_>>(),
  312. Selection::One(user) => vec![user.login.as_str()],
  313. };
  314. #[derive(serde::Serialize)]
  315. struct AssigneeReq<'a> {
  316. assignees: &'a [&'a str],
  317. }
  318. client
  319. ._send_req(client.delete(&url).json(&AssigneeReq {
  320. assignees: &assignees[..],
  321. }))
  322. .await
  323. .map_err(AssignmentError::Http)?;
  324. Ok(())
  325. }
  326. pub async fn set_assignee(
  327. &self,
  328. client: &GithubClient,
  329. user: &str,
  330. ) -> Result<(), AssignmentError> {
  331. log::info!("set_assignee for {} to {}", self.global_id(), user);
  332. let url = format!(
  333. "{repo_url}/issues/{number}/assignees",
  334. repo_url = self.repository().url(),
  335. number = self.number
  336. );
  337. let check_url = format!(
  338. "{repo_url}/assignees/{name}",
  339. repo_url = self.repository().url(),
  340. name = user,
  341. );
  342. match client._send_req(client.get(&check_url)).await {
  343. Ok((resp, _)) => {
  344. if resp.status() == reqwest::StatusCode::NO_CONTENT {
  345. // all okay
  346. log::debug!("set_assignee: assignee is valid");
  347. } else {
  348. log::error!(
  349. "unknown status for assignee check, assuming all okay: {:?}",
  350. resp
  351. );
  352. }
  353. }
  354. Err(e) => {
  355. if e.status() == Some(reqwest::StatusCode::NOT_FOUND) {
  356. log::debug!("set_assignee: assignee is invalid, returning");
  357. return Err(AssignmentError::InvalidAssignee);
  358. }
  359. log::debug!("set_assignee: get {} failed, {:?}", check_url, e);
  360. return Err(AssignmentError::Http(e));
  361. }
  362. }
  363. self.remove_assignees(client, Selection::All).await?;
  364. #[derive(serde::Serialize)]
  365. struct AssigneeReq<'a> {
  366. assignees: &'a [&'a str],
  367. }
  368. client
  369. ._send_req(client.post(&url).json(&AssigneeReq { assignees: &[user] }))
  370. .await
  371. .map_err(AssignmentError::Http)?;
  372. Ok(())
  373. }
  374. }
  375. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  376. #[serde(rename_all = "lowercase")]
  377. pub enum PullRequestReviewAction {
  378. Submitted,
  379. Edited,
  380. Dismissed,
  381. }
  382. #[derive(Debug, serde::Deserialize)]
  383. pub struct PullRequestReviewEvent {
  384. pub action: PullRequestReviewAction,
  385. pub pull_request: Issue,
  386. pub review: Comment,
  387. pub repository: Repository,
  388. }
  389. #[derive(Debug, serde::Deserialize)]
  390. pub struct PullRequestReviewComment {
  391. pub action: IssueCommentAction,
  392. #[serde(rename = "pull_request")]
  393. pub issue: Issue,
  394. pub comment: Comment,
  395. pub repository: Repository,
  396. }
  397. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  398. #[serde(rename_all = "lowercase")]
  399. pub enum IssueCommentAction {
  400. Created,
  401. Edited,
  402. Deleted,
  403. }
  404. #[derive(Debug, serde::Deserialize)]
  405. pub struct IssueCommentEvent {
  406. pub action: IssueCommentAction,
  407. pub issue: Issue,
  408. pub comment: Comment,
  409. pub repository: Repository,
  410. }
  411. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  412. #[serde(rename_all = "lowercase")]
  413. pub enum IssuesAction {
  414. Opened,
  415. Edited,
  416. Deleted,
  417. Transferred,
  418. Pinned,
  419. Unpinned,
  420. Closed,
  421. Reopened,
  422. Assigned,
  423. Unassigned,
  424. Labeled,
  425. Unlabeled,
  426. Locked,
  427. Unlocked,
  428. Milestoned,
  429. Demilestoned,
  430. ReviewRequested,
  431. ReviewRequestRemoved,
  432. ReadyForReview,
  433. Synchronize,
  434. }
  435. #[derive(Debug, serde::Deserialize)]
  436. pub struct IssuesEvent {
  437. pub action: IssuesAction,
  438. #[serde(alias = "pull_request")]
  439. pub issue: Issue,
  440. pub repository: Repository,
  441. /// Some if action is IssuesAction::Labeled, for example
  442. pub label: Option<Label>,
  443. }
  444. #[derive(Debug, serde::Deserialize)]
  445. pub struct Repository {
  446. pub full_name: String,
  447. }
  448. #[derive(Debug)]
  449. pub enum Event {
  450. IssueComment(IssueCommentEvent),
  451. Issue(IssuesEvent),
  452. }
  453. impl Event {
  454. pub fn repo_name(&self) -> &str {
  455. match self {
  456. Event::IssueComment(event) => &event.repository.full_name,
  457. Event::Issue(event) => &event.repository.full_name,
  458. }
  459. }
  460. pub fn issue(&self) -> Option<&Issue> {
  461. match self {
  462. Event::IssueComment(event) => Some(&event.issue),
  463. Event::Issue(event) => Some(&event.issue),
  464. }
  465. }
  466. /// This will both extract from IssueComment events but also Issue events
  467. pub fn comment_body(&self) -> Option<&str> {
  468. match self {
  469. Event::Issue(e) => Some(&e.issue.body),
  470. Event::IssueComment(e) => Some(&e.comment.body),
  471. }
  472. }
  473. pub fn html_url(&self) -> Option<&str> {
  474. match self {
  475. Event::Issue(e) => Some(&e.issue.html_url),
  476. Event::IssueComment(e) => Some(&e.comment.html_url),
  477. }
  478. }
  479. pub fn user(&self) -> &User {
  480. match self {
  481. Event::Issue(e) => &e.issue.user,
  482. Event::IssueComment(e) => &e.comment.user,
  483. }
  484. }
  485. pub fn time(&self) -> chrono::DateTime<FixedOffset> {
  486. match self {
  487. Event::Issue(e) => e.issue.created_at.into(),
  488. Event::IssueComment(e) => e.comment.updated_at.into(),
  489. }
  490. }
  491. }
  492. trait RequestSend: Sized {
  493. fn configure(self, g: &GithubClient) -> Self;
  494. }
  495. impl RequestSend for RequestBuilder {
  496. fn configure(self, g: &GithubClient) -> RequestBuilder {
  497. self.header(USER_AGENT, "rust-lang-triagebot")
  498. .header(AUTHORIZATION, format!("token {}", g.token))
  499. }
  500. }
  501. #[derive(Clone)]
  502. pub struct GithubClient {
  503. token: String,
  504. client: Client,
  505. }
  506. impl GithubClient {
  507. pub fn new(client: Client, token: String) -> Self {
  508. GithubClient { client, token }
  509. }
  510. pub fn raw(&self) -> &Client {
  511. &self.client
  512. }
  513. pub async fn raw_file(
  514. &self,
  515. repo: &str,
  516. branch: &str,
  517. path: &str,
  518. ) -> anyhow::Result<Option<Vec<u8>>> {
  519. let url = format!(
  520. "https://raw.githubusercontent.com/{}/{}/{}",
  521. repo, branch, path
  522. );
  523. let req = self.get(&url);
  524. let req_dbg = format!("{:?}", req);
  525. let req = req
  526. .build()
  527. .with_context(|| format!("failed to build request {:?}", req_dbg))?;
  528. let mut resp = self.client.execute(req).await.context(req_dbg.clone())?;
  529. let status = resp.status();
  530. match status {
  531. StatusCode::OK => {
  532. let mut buf = Vec::with_capacity(resp.content_length().unwrap_or(4) as usize);
  533. while let Some(chunk) = resp.chunk().await.transpose() {
  534. let chunk = chunk
  535. .context("reading stream failed")
  536. .map_err(anyhow::Error::from)
  537. .context(req_dbg.clone())?;
  538. buf.extend_from_slice(&chunk);
  539. }
  540. Ok(Some(buf))
  541. }
  542. StatusCode::NOT_FOUND => Ok(None),
  543. status => anyhow::bail!("failed to GET {}: {}", url, status),
  544. }
  545. }
  546. fn get(&self, url: &str) -> RequestBuilder {
  547. log::trace!("get {:?}", url);
  548. self.client.get(url).configure(self)
  549. }
  550. fn patch(&self, url: &str) -> RequestBuilder {
  551. log::trace!("patch {:?}", url);
  552. self.client.patch(url).configure(self)
  553. }
  554. fn delete(&self, url: &str) -> RequestBuilder {
  555. log::trace!("delete {:?}", url);
  556. self.client.delete(url).configure(self)
  557. }
  558. fn post(&self, url: &str) -> RequestBuilder {
  559. log::trace!("post {:?}", url);
  560. self.client.post(url).configure(self)
  561. }
  562. fn put(&self, url: &str) -> RequestBuilder {
  563. log::trace!("put {:?}", url);
  564. self.client.put(url).configure(self)
  565. }
  566. pub async fn rust_commit(&self, sha: &str) -> Option<GithubCommit> {
  567. let req = self.get(&format!(
  568. "https://api.github.com/repos/rust-lang/rust/commits/{}",
  569. sha
  570. ));
  571. match self.json(req).await {
  572. Ok(r) => Some(r),
  573. Err(e) => {
  574. log::error!("Failed to query commit {:?}: {:?}", sha, e);
  575. None
  576. }
  577. }
  578. }
  579. /// This does not retrieve all of them, only the last several.
  580. pub async fn bors_commits(&self) -> Vec<GithubCommit> {
  581. let req = self.get("https://api.github.com/repos/rust-lang/rust/commits?author=bors");
  582. match self.json(req).await {
  583. Ok(r) => r,
  584. Err(e) => {
  585. log::error!("Failed to query commit list: {:?}", e);
  586. Vec::new()
  587. }
  588. }
  589. }
  590. }
  591. #[derive(Debug, serde::Deserialize)]
  592. pub struct GithubCommit {
  593. pub sha: String,
  594. pub commit: GitCommit,
  595. pub parents: Vec<Parent>,
  596. }
  597. #[derive(Debug, serde::Deserialize)]
  598. pub struct GitCommit {
  599. pub author: GitUser,
  600. }
  601. #[derive(Debug, serde::Deserialize)]
  602. pub struct GitUser {
  603. pub date: DateTime<FixedOffset>,
  604. }
  605. #[derive(Debug, serde::Deserialize)]
  606. pub struct Parent {
  607. pub sha: String,
  608. }