github.rs 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779
  1. use anyhow::Context;
  2. use async_trait::async_trait;
  3. use chrono::{DateTime, FixedOffset, Utc};
  4. use futures::{future::BoxFuture, FutureExt};
  5. use hyper::header::HeaderValue;
  6. use once_cell::sync::OnceCell;
  7. use reqwest::header::{AUTHORIZATION, USER_AGENT};
  8. use reqwest::{Client, Request, RequestBuilder, Response, StatusCode};
  9. use std::collections::HashMap;
  10. use std::convert::TryInto;
  11. use std::{
  12. fmt,
  13. time::{Duration, SystemTime},
  14. };
  15. use tracing as log;
  16. #[derive(Debug, PartialEq, Eq, serde::Deserialize)]
  17. pub struct User {
  18. pub login: String,
  19. pub id: Option<i64>,
  20. }
  21. impl GithubClient {
  22. async fn _send_req(&self, req: RequestBuilder) -> anyhow::Result<(Response, String)> {
  23. const MAX_ATTEMPTS: usize = 2;
  24. log::debug!("_send_req with {:?}", req);
  25. let req_dbg = format!("{:?}", req);
  26. let req = req
  27. .build()
  28. .with_context(|| format!("building reqwest {}", req_dbg))?;
  29. let mut resp = self.client.execute(req.try_clone().unwrap()).await?;
  30. if let Some(sleep) = Self::needs_retry(&resp).await {
  31. resp = self.retry(req, sleep, MAX_ATTEMPTS).await?;
  32. }
  33. resp.error_for_status_ref()?;
  34. Ok((resp, req_dbg))
  35. }
  36. async fn needs_retry(resp: &Response) -> Option<Duration> {
  37. const REMAINING: &str = "X-RateLimit-Remaining";
  38. const RESET: &str = "X-RateLimit-Reset";
  39. if resp.status().is_success() {
  40. return None;
  41. }
  42. let headers = resp.headers();
  43. if !(headers.contains_key(REMAINING) && headers.contains_key(RESET)) {
  44. return None;
  45. }
  46. // Weird github api behavior. It asks us to retry but also has a remaining count above 1
  47. // Try again immediately and hope for the best...
  48. if headers[REMAINING] != "0" {
  49. return Some(Duration::from_secs(0));
  50. }
  51. let reset_time = headers[RESET].to_str().unwrap().parse::<u64>().unwrap();
  52. Some(Duration::from_secs(Self::calc_sleep(reset_time) + 10))
  53. }
  54. fn calc_sleep(reset_time: u64) -> u64 {
  55. let epoch_time = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_secs();
  56. reset_time.saturating_sub(epoch_time)
  57. }
  58. fn retry(
  59. &self,
  60. req: Request,
  61. sleep: Duration,
  62. remaining_attempts: usize,
  63. ) -> BoxFuture<Result<Response, reqwest::Error>> {
  64. #[derive(Debug, serde::Deserialize)]
  65. struct RateLimit {
  66. #[allow(unused)]
  67. pub limit: u64,
  68. pub remaining: u64,
  69. pub reset: u64,
  70. }
  71. #[derive(Debug, serde::Deserialize)]
  72. struct RateLimitResponse {
  73. pub resources: Resources,
  74. }
  75. #[derive(Debug, serde::Deserialize)]
  76. struct Resources {
  77. pub core: RateLimit,
  78. pub search: RateLimit,
  79. #[allow(unused)]
  80. pub graphql: RateLimit,
  81. #[allow(unused)]
  82. pub source_import: RateLimit,
  83. }
  84. log::warn!(
  85. "Retrying after {} seconds, remaining attepts {}",
  86. sleep.as_secs(),
  87. remaining_attempts,
  88. );
  89. async move {
  90. tokio::time::sleep(sleep).await;
  91. // check rate limit
  92. let rate_resp = self
  93. .client
  94. .execute(
  95. self.client
  96. .get("https://api.github.com/rate_limit")
  97. .configure(self)
  98. .build()
  99. .unwrap(),
  100. )
  101. .await?;
  102. let rate_limit_response = rate_resp.json::<RateLimitResponse>().await?;
  103. // Check url for search path because github has different rate limits for the search api
  104. let rate_limit = if req
  105. .url()
  106. .path_segments()
  107. .map(|mut segments| matches!(segments.next(), Some("search")))
  108. .unwrap_or(false)
  109. {
  110. rate_limit_response.resources.search
  111. } else {
  112. rate_limit_response.resources.core
  113. };
  114. // If we still don't have any more remaining attempts, try sleeping for the remaining
  115. // period of time
  116. if rate_limit.remaining == 0 {
  117. let sleep = Self::calc_sleep(rate_limit.reset);
  118. if sleep > 0 {
  119. tokio::time::sleep(Duration::from_secs(sleep)).await;
  120. }
  121. }
  122. let resp = self.client.execute(req.try_clone().unwrap()).await?;
  123. if let Some(sleep) = Self::needs_retry(&resp).await {
  124. if remaining_attempts > 0 {
  125. return self.retry(req, sleep, remaining_attempts - 1).await;
  126. }
  127. }
  128. Ok(resp)
  129. }
  130. .boxed()
  131. }
  132. async fn send_req(&self, req: RequestBuilder) -> anyhow::Result<Vec<u8>> {
  133. let (mut resp, req_dbg) = self._send_req(req).await?;
  134. let mut body = Vec::new();
  135. while let Some(chunk) = resp.chunk().await.transpose() {
  136. let chunk = chunk
  137. .context("reading stream failed")
  138. .map_err(anyhow::Error::from)
  139. .context(req_dbg.clone())?;
  140. body.extend_from_slice(&chunk);
  141. }
  142. Ok(body)
  143. }
  144. pub async fn json<T>(&self, req: RequestBuilder) -> anyhow::Result<T>
  145. where
  146. T: serde::de::DeserializeOwned,
  147. {
  148. let (resp, req_dbg) = self._send_req(req).await?;
  149. Ok(resp.json().await.context(req_dbg)?)
  150. }
  151. }
  152. impl User {
  153. pub async fn current(client: &GithubClient) -> anyhow::Result<Self> {
  154. client.json(client.get("https://api.github.com/user")).await
  155. }
  156. pub async fn is_team_member<'a>(&'a self, client: &'a GithubClient) -> anyhow::Result<bool> {
  157. log::trace!("Getting team membership for {:?}", self.login);
  158. let permission = crate::team_data::teams(client).await?;
  159. let map = permission.teams;
  160. let is_triager = map
  161. .get("wg-triage")
  162. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  163. let is_pri_member = map
  164. .get("wg-prioritization")
  165. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  166. let is_async_member = map
  167. .get("wg-async")
  168. .map_or(false, |w| w.members.iter().any(|g| g.github == self.login));
  169. let in_all = map["all"].members.iter().any(|g| g.github == self.login);
  170. log::trace!(
  171. "{:?} is all?={:?}, triager?={:?}, prioritizer?={:?}, async?={:?}",
  172. self.login,
  173. in_all,
  174. is_triager,
  175. is_pri_member,
  176. is_async_member,
  177. );
  178. Ok(in_all || is_triager || is_pri_member || is_async_member)
  179. }
  180. // Returns the ID of the given user, if the user is in the `all` team.
  181. pub async fn get_id<'a>(&'a self, client: &'a GithubClient) -> anyhow::Result<Option<usize>> {
  182. let permission = crate::team_data::teams(client).await?;
  183. let map = permission.teams;
  184. Ok(map["all"]
  185. .members
  186. .iter()
  187. .find(|g| g.github == self.login)
  188. .map(|u| u.github_id))
  189. }
  190. }
  191. pub async fn get_team(
  192. client: &GithubClient,
  193. team: &str,
  194. ) -> anyhow::Result<Option<rust_team_data::v1::Team>> {
  195. let permission = crate::team_data::teams(client).await?;
  196. let mut map = permission.teams;
  197. Ok(map.swap_remove(team))
  198. }
  199. #[derive(PartialEq, Eq, Debug, Clone, serde::Deserialize)]
  200. pub struct Label {
  201. pub name: String,
  202. }
  203. /// An issue or pull request.
  204. ///
  205. /// For convenience, since issues and pull requests share most of their
  206. /// fields, this struct is used for both. The `pull_request` field can be used
  207. /// to determine which it is. Some fields are only available on pull requests
  208. /// (but not always, check the GitHub API for details).
  209. #[derive(Debug, serde::Deserialize)]
  210. pub struct Issue {
  211. pub number: u64,
  212. #[serde(deserialize_with = "opt_string")]
  213. pub body: String,
  214. created_at: chrono::DateTime<Utc>,
  215. pub updated_at: chrono::DateTime<Utc>,
  216. /// The SHA for a merge commit.
  217. ///
  218. /// This field is complicated, see the [Pull Request
  219. /// docs](https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request)
  220. /// for details.
  221. #[serde(default)]
  222. pub merge_commit_sha: Option<String>,
  223. pub title: String,
  224. /// The common URL for viewing this issue or PR.
  225. ///
  226. /// Example: `https://github.com/octocat/Hello-World/pull/1347`
  227. pub html_url: String,
  228. pub user: User,
  229. pub labels: Vec<Label>,
  230. pub assignees: Vec<User>,
  231. /// This is true if this is a pull request.
  232. ///
  233. /// Note that this field does not come from GitHub. This is manually added
  234. /// when the webhook arrives to help differentiate between an event
  235. /// related to an issue versus a pull request.
  236. #[serde(default)]
  237. pub pull_request: bool,
  238. /// Whether or not the pull request was merged.
  239. #[serde(default)]
  240. pub merged: bool,
  241. #[serde(default)]
  242. pub draft: bool,
  243. /// The API URL for discussion comments.
  244. ///
  245. /// Example: `https://api.github.com/repos/octocat/Hello-World/issues/1347/comments`
  246. comments_url: String,
  247. /// The repository for this issue.
  248. ///
  249. /// Note that this is constructed via the [`Issue::repository`] method.
  250. /// It is not deserialized from the GitHub API.
  251. #[serde(skip)]
  252. repository: OnceCell<IssueRepository>,
  253. /// The base commit for a PR (the branch of the destination repo).
  254. #[serde(default)]
  255. pub base: Option<CommitBase>,
  256. /// The head commit for a PR (the branch from the source repo).
  257. #[serde(default)]
  258. pub head: Option<CommitBase>,
  259. }
  260. /// Contains only the parts of `Issue` that are needed for turning the issue title into a Zulip
  261. /// topic.
  262. #[derive(Clone, Debug, PartialEq, Eq)]
  263. pub struct ZulipGitHubReference {
  264. pub number: u64,
  265. pub title: String,
  266. pub repository: IssueRepository,
  267. }
  268. impl ZulipGitHubReference {
  269. pub fn zulip_topic_reference(&self) -> String {
  270. let repo = &self.repository;
  271. if repo.organization == "rust-lang" {
  272. if repo.repository == "rust" {
  273. format!("#{}", self.number)
  274. } else {
  275. format!("{}#{}", repo.repository, self.number)
  276. }
  277. } else {
  278. format!("{}/{}#{}", repo.organization, repo.repository, self.number)
  279. }
  280. }
  281. }
  282. #[derive(Debug, serde::Deserialize)]
  283. pub struct Comment {
  284. #[serde(deserialize_with = "opt_string")]
  285. pub body: String,
  286. pub html_url: String,
  287. pub user: User,
  288. #[serde(alias = "submitted_at")] // for pull request reviews
  289. pub updated_at: chrono::DateTime<Utc>,
  290. #[serde(default, rename = "state")]
  291. pub pr_review_state: Option<PullRequestReviewState>,
  292. }
  293. #[derive(Debug, serde::Deserialize, Eq, PartialEq)]
  294. #[serde(rename_all = "snake_case")]
  295. pub enum PullRequestReviewState {
  296. Approved,
  297. ChangesRequested,
  298. Commented,
  299. Dismissed,
  300. Pending,
  301. }
  302. fn opt_string<'de, D>(deserializer: D) -> Result<String, D::Error>
  303. where
  304. D: serde::de::Deserializer<'de>,
  305. {
  306. use serde::de::Deserialize;
  307. match <Option<String>>::deserialize(deserializer) {
  308. Ok(v) => Ok(v.unwrap_or_default()),
  309. Err(e) => Err(e),
  310. }
  311. }
  312. #[derive(Debug)]
  313. pub enum AssignmentError {
  314. InvalidAssignee,
  315. Http(anyhow::Error),
  316. }
  317. #[derive(Debug)]
  318. pub enum Selection<'a, T: ?Sized> {
  319. All,
  320. One(&'a T),
  321. Except(&'a T),
  322. }
  323. impl fmt::Display for AssignmentError {
  324. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  325. match self {
  326. AssignmentError::InvalidAssignee => write!(f, "invalid assignee"),
  327. AssignmentError::Http(e) => write!(f, "cannot assign: {}", e),
  328. }
  329. }
  330. }
  331. impl std::error::Error for AssignmentError {}
  332. #[derive(Debug, Clone, PartialEq, Eq)]
  333. pub struct IssueRepository {
  334. pub organization: String,
  335. pub repository: String,
  336. }
  337. impl fmt::Display for IssueRepository {
  338. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  339. write!(f, "{}/{}", self.organization, self.repository)
  340. }
  341. }
  342. impl IssueRepository {
  343. fn url(&self) -> String {
  344. format!(
  345. "https://api.github.com/repos/{}/{}",
  346. self.organization, self.repository
  347. )
  348. }
  349. async fn has_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<bool> {
  350. #[allow(clippy::redundant_pattern_matching)]
  351. let url = format!("{}/labels/{}", self.url(), label);
  352. match client._send_req(client.get(&url)).await {
  353. Ok((_, _)) => Ok(true),
  354. Err(e) => {
  355. if e.downcast_ref::<reqwest::Error>()
  356. .map_or(false, |e| e.status() == Some(StatusCode::NOT_FOUND))
  357. {
  358. Ok(false)
  359. } else {
  360. Err(e)
  361. }
  362. }
  363. }
  364. }
  365. }
  366. #[derive(Debug)]
  367. pub(crate) struct UnknownLabels {
  368. labels: Vec<String>,
  369. }
  370. // NOTE: This is used to post the Github comment; make sure it's valid markdown.
  371. impl fmt::Display for UnknownLabels {
  372. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  373. write!(f, "Unknown labels: {}", &self.labels.join(", "))
  374. }
  375. }
  376. impl std::error::Error for UnknownLabels {}
  377. impl Issue {
  378. pub fn to_zulip_github_reference(&self) -> ZulipGitHubReference {
  379. ZulipGitHubReference {
  380. number: self.number,
  381. title: self.title.clone(),
  382. repository: self.repository().clone(),
  383. }
  384. }
  385. pub fn repository(&self) -> &IssueRepository {
  386. self.repository.get_or_init(|| {
  387. // https://api.github.com/repos/rust-lang/rust/issues/69257/comments
  388. log::trace!("get repository for {}", self.comments_url);
  389. let url = url::Url::parse(&self.comments_url).unwrap();
  390. let mut segments = url.path_segments().unwrap();
  391. let _comments = segments.next_back().unwrap();
  392. let _number = segments.next_back().unwrap();
  393. let _issues_or_prs = segments.next_back().unwrap();
  394. let repository = segments.next_back().unwrap();
  395. let organization = segments.next_back().unwrap();
  396. IssueRepository {
  397. organization: organization.into(),
  398. repository: repository.into(),
  399. }
  400. })
  401. }
  402. pub fn global_id(&self) -> String {
  403. format!("{}#{}", self.repository(), self.number)
  404. }
  405. pub fn is_pr(&self) -> bool {
  406. self.pull_request
  407. }
  408. pub async fn get_comment(&self, client: &GithubClient, id: usize) -> anyhow::Result<Comment> {
  409. let comment_url = format!("{}/issues/comments/{}", self.repository().url(), id);
  410. let comment = client.json(client.get(&comment_url)).await?;
  411. Ok(comment)
  412. }
  413. pub async fn edit_body(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  414. let edit_url = format!("{}/issues/{}", self.repository().url(), self.number);
  415. #[derive(serde::Serialize)]
  416. struct ChangedIssue<'a> {
  417. body: &'a str,
  418. }
  419. client
  420. ._send_req(client.patch(&edit_url).json(&ChangedIssue { body }))
  421. .await
  422. .context("failed to edit issue body")?;
  423. Ok(())
  424. }
  425. pub async fn edit_comment(
  426. &self,
  427. client: &GithubClient,
  428. id: usize,
  429. new_body: &str,
  430. ) -> anyhow::Result<()> {
  431. let comment_url = format!("{}/issues/comments/{}", self.repository().url(), id);
  432. #[derive(serde::Serialize)]
  433. struct NewComment<'a> {
  434. body: &'a str,
  435. }
  436. client
  437. ._send_req(
  438. client
  439. .patch(&comment_url)
  440. .json(&NewComment { body: new_body }),
  441. )
  442. .await
  443. .context("failed to edit comment")?;
  444. Ok(())
  445. }
  446. pub async fn post_comment(&self, client: &GithubClient, body: &str) -> anyhow::Result<()> {
  447. #[derive(serde::Serialize)]
  448. struct PostComment<'a> {
  449. body: &'a str,
  450. }
  451. client
  452. ._send_req(client.post(&self.comments_url).json(&PostComment { body }))
  453. .await
  454. .context("failed to post comment")?;
  455. Ok(())
  456. }
  457. pub async fn remove_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<()> {
  458. log::info!("remove_label from {}: {:?}", self.global_id(), label);
  459. // DELETE /repos/:owner/:repo/issues/:number/labels/{name}
  460. let url = format!(
  461. "{repo_url}/issues/{number}/labels/{name}",
  462. repo_url = self.repository().url(),
  463. number = self.number,
  464. name = label,
  465. );
  466. if !self.labels().iter().any(|l| l.name == label) {
  467. log::info!(
  468. "remove_label from {}: {:?} already not present, skipping",
  469. self.global_id(),
  470. label
  471. );
  472. return Ok(());
  473. }
  474. client
  475. ._send_req(client.delete(&url))
  476. .await
  477. .context("failed to delete label")?;
  478. Ok(())
  479. }
  480. pub async fn add_labels(
  481. &self,
  482. client: &GithubClient,
  483. labels: Vec<Label>,
  484. ) -> anyhow::Result<()> {
  485. log::info!("add_labels: {} +{:?}", self.global_id(), labels);
  486. // POST /repos/:owner/:repo/issues/:number/labels
  487. // repo_url = https://api.github.com/repos/Codertocat/Hello-World
  488. let url = format!(
  489. "{repo_url}/issues/{number}/labels",
  490. repo_url = self.repository().url(),
  491. number = self.number
  492. );
  493. // Don't try to add labels already present on this issue.
  494. let labels = labels
  495. .into_iter()
  496. .filter(|l| !self.labels().contains(&l))
  497. .map(|l| l.name)
  498. .collect::<Vec<_>>();
  499. log::info!("add_labels: {} filtered to {:?}", self.global_id(), labels);
  500. if labels.is_empty() {
  501. return Ok(());
  502. }
  503. let mut unknown_labels = vec![];
  504. let mut known_labels = vec![];
  505. for label in labels {
  506. if !self.repository().has_label(client, &label).await? {
  507. unknown_labels.push(label);
  508. } else {
  509. known_labels.push(label);
  510. }
  511. }
  512. if !unknown_labels.is_empty() {
  513. return Err(UnknownLabels {
  514. labels: unknown_labels,
  515. }
  516. .into());
  517. }
  518. #[derive(serde::Serialize)]
  519. struct LabelsReq {
  520. labels: Vec<String>,
  521. }
  522. client
  523. ._send_req(client.post(&url).json(&LabelsReq {
  524. labels: known_labels,
  525. }))
  526. .await
  527. .context("failed to add labels")?;
  528. Ok(())
  529. }
  530. pub fn labels(&self) -> &[Label] {
  531. &self.labels
  532. }
  533. pub fn contain_assignee(&self, user: &str) -> bool {
  534. self.assignees.iter().any(|a| a.login == user)
  535. }
  536. pub async fn remove_assignees(
  537. &self,
  538. client: &GithubClient,
  539. selection: Selection<'_, str>,
  540. ) -> Result<(), AssignmentError> {
  541. log::info!("remove {:?} assignees for {}", selection, self.global_id());
  542. let url = format!(
  543. "{repo_url}/issues/{number}/assignees",
  544. repo_url = self.repository().url(),
  545. number = self.number
  546. );
  547. let assignees = match selection {
  548. Selection::All => self
  549. .assignees
  550. .iter()
  551. .map(|u| u.login.as_str())
  552. .collect::<Vec<_>>(),
  553. Selection::One(user) => vec![user],
  554. Selection::Except(user) => self
  555. .assignees
  556. .iter()
  557. .map(|u| u.login.as_str())
  558. .filter(|&u| u != user)
  559. .collect::<Vec<_>>(),
  560. };
  561. #[derive(serde::Serialize)]
  562. struct AssigneeReq<'a> {
  563. assignees: &'a [&'a str],
  564. }
  565. client
  566. ._send_req(client.delete(&url).json(&AssigneeReq {
  567. assignees: &assignees[..],
  568. }))
  569. .await
  570. .map_err(AssignmentError::Http)?;
  571. Ok(())
  572. }
  573. pub async fn add_assignee(
  574. &self,
  575. client: &GithubClient,
  576. user: &str,
  577. ) -> Result<(), AssignmentError> {
  578. log::info!("add_assignee {} for {}", user, self.global_id());
  579. let url = format!(
  580. "{repo_url}/issues/{number}/assignees",
  581. repo_url = self.repository().url(),
  582. number = self.number
  583. );
  584. #[derive(serde::Serialize)]
  585. struct AssigneeReq<'a> {
  586. assignees: &'a [&'a str],
  587. }
  588. let result: Issue = client
  589. .json(client.post(&url).json(&AssigneeReq { assignees: &[user] }))
  590. .await
  591. .map_err(AssignmentError::Http)?;
  592. // Invalid assignees are silently ignored. We can just check if the user is now
  593. // contained in the assignees list.
  594. let success = result.assignees.iter().any(|u| u.login.as_str() == user);
  595. if success {
  596. Ok(())
  597. } else {
  598. Err(AssignmentError::InvalidAssignee)
  599. }
  600. }
  601. pub async fn set_assignee(
  602. &self,
  603. client: &GithubClient,
  604. user: &str,
  605. ) -> Result<(), AssignmentError> {
  606. log::info!("set_assignee for {} to {}", self.global_id(), user);
  607. self.add_assignee(client, user).await?;
  608. self.remove_assignees(client, Selection::Except(user))
  609. .await?;
  610. Ok(())
  611. }
  612. pub async fn set_milestone(&self, client: &GithubClient, title: &str) -> anyhow::Result<()> {
  613. log::trace!(
  614. "Setting milestone for rust-lang/rust#{} to {}",
  615. self.number,
  616. title
  617. );
  618. let create_url = format!("{}/milestones", self.repository().url());
  619. let resp = client
  620. .send_req(
  621. client
  622. .post(&create_url)
  623. .body(serde_json::to_vec(&MilestoneCreateBody { title }).unwrap()),
  624. )
  625. .await;
  626. // Explicitly do *not* try to return Err(...) if this fails -- that's
  627. // fine, it just means the milestone was already created.
  628. log::trace!("Created milestone: {:?}", resp);
  629. let list_url = format!("{}/milestones", self.repository().url());
  630. let milestone_list: Vec<Milestone> = client.json(client.get(&list_url)).await?;
  631. let milestone_no = if let Some(milestone) = milestone_list.iter().find(|v| v.title == title)
  632. {
  633. milestone.number
  634. } else {
  635. anyhow::bail!(
  636. "Despite just creating milestone {} on {}, it does not exist?",
  637. title,
  638. self.repository()
  639. )
  640. };
  641. #[derive(serde::Serialize)]
  642. struct SetMilestone {
  643. milestone: u64,
  644. }
  645. let url = format!("{}/issues/{}", self.repository().url(), self.number);
  646. client
  647. ._send_req(client.patch(&url).json(&SetMilestone {
  648. milestone: milestone_no,
  649. }))
  650. .await
  651. .context("failed to set milestone")?;
  652. Ok(())
  653. }
  654. pub async fn close(&self, client: &GithubClient) -> anyhow::Result<()> {
  655. let edit_url = format!("{}/issues/{}", self.repository().url(), self.number);
  656. #[derive(serde::Serialize)]
  657. struct CloseIssue<'a> {
  658. state: &'a str,
  659. }
  660. client
  661. ._send_req(
  662. client
  663. .patch(&edit_url)
  664. .json(&CloseIssue { state: "closed" }),
  665. )
  666. .await
  667. .context("failed to close issue")?;
  668. Ok(())
  669. }
  670. /// Returns the diff in this event, for Open and Synchronize events for now.
  671. pub async fn diff(&self, client: &GithubClient) -> anyhow::Result<Option<String>> {
  672. let (before, after) = if let (Some(base), Some(head)) = (&self.base, &self.head) {
  673. (base.sha.clone(), head.sha.clone())
  674. } else {
  675. return Ok(None);
  676. };
  677. let mut req = client.get(&format!(
  678. "{}/compare/{}...{}",
  679. self.repository().url(),
  680. before,
  681. after
  682. ));
  683. req = req.header("Accept", "application/vnd.github.v3.diff");
  684. let diff = client.send_req(req).await?;
  685. Ok(Some(String::from(String::from_utf8_lossy(&diff))))
  686. }
  687. pub async fn files(&self, client: &GithubClient) -> anyhow::Result<Vec<PullRequestFile>> {
  688. if !self.is_pr() {
  689. return Ok(vec![]);
  690. }
  691. let req = client.get(&format!(
  692. "{}/pulls/{}/files",
  693. self.repository().url(),
  694. self.number
  695. ));
  696. Ok(client.json(req).await?)
  697. }
  698. }
  699. #[derive(Debug, serde::Deserialize)]
  700. pub struct PullRequestFile {
  701. pub sha: String,
  702. pub filename: String,
  703. pub blob_url: String,
  704. }
  705. #[derive(serde::Serialize)]
  706. struct MilestoneCreateBody<'a> {
  707. title: &'a str,
  708. }
  709. #[derive(Debug, serde::Deserialize)]
  710. pub struct Milestone {
  711. number: u64,
  712. title: String,
  713. }
  714. #[derive(Debug, serde::Deserialize)]
  715. pub struct ChangeInner {
  716. pub from: String,
  717. }
  718. #[derive(Debug, serde::Deserialize)]
  719. pub struct Changes {
  720. pub title: Option<ChangeInner>,
  721. pub body: Option<ChangeInner>,
  722. }
  723. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  724. #[serde(rename_all = "lowercase")]
  725. pub enum PullRequestReviewAction {
  726. Submitted,
  727. Edited,
  728. Dismissed,
  729. }
  730. /// A pull request review event.
  731. ///
  732. /// <https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request_review>
  733. #[derive(Debug, serde::Deserialize)]
  734. pub struct PullRequestReviewEvent {
  735. pub action: PullRequestReviewAction,
  736. pub pull_request: Issue,
  737. pub review: Comment,
  738. pub changes: Option<Changes>,
  739. pub repository: Repository,
  740. }
  741. #[derive(Debug, serde::Deserialize)]
  742. pub struct PullRequestReviewComment {
  743. pub action: IssueCommentAction,
  744. pub changes: Option<Changes>,
  745. #[serde(rename = "pull_request")]
  746. pub issue: Issue,
  747. pub comment: Comment,
  748. pub repository: Repository,
  749. }
  750. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  751. #[serde(rename_all = "lowercase")]
  752. pub enum IssueCommentAction {
  753. Created,
  754. Edited,
  755. Deleted,
  756. }
  757. #[derive(Debug, serde::Deserialize)]
  758. pub struct IssueCommentEvent {
  759. pub action: IssueCommentAction,
  760. pub changes: Option<Changes>,
  761. pub issue: Issue,
  762. pub comment: Comment,
  763. pub repository: Repository,
  764. }
  765. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  766. #[serde(rename_all = "snake_case")]
  767. pub enum IssuesAction {
  768. Opened,
  769. Edited,
  770. Deleted,
  771. Transferred,
  772. Pinned,
  773. Unpinned,
  774. Closed,
  775. Reopened,
  776. Assigned,
  777. Unassigned,
  778. Labeled,
  779. Unlabeled,
  780. Locked,
  781. Unlocked,
  782. Milestoned,
  783. Demilestoned,
  784. ReviewRequested,
  785. ReviewRequestRemoved,
  786. ReadyForReview,
  787. Synchronize,
  788. ConvertedToDraft,
  789. }
  790. #[derive(Debug, serde::Deserialize)]
  791. pub struct IssuesEvent {
  792. pub action: IssuesAction,
  793. #[serde(alias = "pull_request")]
  794. pub issue: Issue,
  795. pub changes: Option<Changes>,
  796. pub repository: Repository,
  797. /// Some if action is IssuesAction::Labeled, for example
  798. pub label: Option<Label>,
  799. }
  800. #[derive(Debug, serde::Deserialize)]
  801. struct PullRequestEventFields {}
  802. #[derive(Clone, Debug, serde::Deserialize)]
  803. pub struct CommitBase {
  804. sha: String,
  805. #[serde(rename = "ref")]
  806. pub git_ref: String,
  807. pub repo: Repository,
  808. }
  809. pub fn files_changed(diff: &str) -> Vec<&str> {
  810. let mut files = Vec::new();
  811. for line in diff.lines() {
  812. // mostly copied from highfive
  813. if line.starts_with("diff --git ") {
  814. files.push(
  815. line[line.find(" b/").unwrap()..]
  816. .strip_prefix(" b/")
  817. .unwrap(),
  818. );
  819. }
  820. }
  821. files
  822. }
  823. #[derive(Debug, serde::Deserialize)]
  824. pub struct IssueSearchResult {
  825. pub total_count: usize,
  826. pub incomplete_results: bool,
  827. pub items: Vec<Issue>,
  828. }
  829. #[derive(Clone, Debug, serde::Deserialize)]
  830. pub struct Repository {
  831. pub full_name: String,
  832. pub default_branch: String,
  833. }
  834. #[derive(Copy, Clone)]
  835. struct Ordering<'a> {
  836. pub sort: &'a str,
  837. pub direction: &'a str,
  838. pub per_page: &'a str,
  839. pub page: usize,
  840. }
  841. impl Repository {
  842. const GITHUB_API_URL: &'static str = "https://api.github.com";
  843. const GITHUB_GRAPHQL_API_URL: &'static str = "https://api.github.com/graphql";
  844. pub fn owner(&self) -> &str {
  845. self.full_name.split_once('/').unwrap().0
  846. }
  847. pub fn name(&self) -> &str {
  848. self.full_name.split_once('/').unwrap().1
  849. }
  850. pub async fn get_issues<'a>(
  851. &self,
  852. client: &GithubClient,
  853. query: &Query<'a>,
  854. ) -> anyhow::Result<Vec<Issue>> {
  855. let Query {
  856. filters,
  857. include_labels,
  858. exclude_labels,
  859. } = query;
  860. let mut ordering = Ordering {
  861. sort: "created",
  862. direction: "asc",
  863. per_page: "100",
  864. page: 1,
  865. };
  866. let filters: Vec<_> = filters
  867. .clone()
  868. .into_iter()
  869. .filter(|(key, val)| {
  870. match *key {
  871. "sort" => ordering.sort = val,
  872. "direction" => ordering.direction = val,
  873. "per_page" => ordering.per_page = val,
  874. _ => return true,
  875. };
  876. false
  877. })
  878. .collect();
  879. // `is: pull-request` indicates the query to retrieve PRs only
  880. let is_pr = filters
  881. .iter()
  882. .any(|&(key, value)| key == "is" && value == "pull-request");
  883. // There are some cases that can only be handled by the search API:
  884. // 1. When using negating label filters (exclude_labels)
  885. // 2. When there's a key parameter key=no
  886. // 3. When the query is to retrieve PRs only and there are label filters
  887. //
  888. // Check https://docs.github.com/en/rest/reference/search#search-issues-and-pull-requests
  889. // for more information
  890. let use_search_api = !exclude_labels.is_empty()
  891. || filters.iter().any(|&(key, _)| key == "no")
  892. || is_pr && !include_labels.is_empty();
  893. // If there are more than `per_page` of issues, we need to paginate
  894. let mut issues = vec![];
  895. loop {
  896. let url = if use_search_api {
  897. self.build_search_issues_url(&filters, include_labels, exclude_labels, ordering)
  898. } else if is_pr {
  899. self.build_pulls_url(&filters, include_labels, ordering)
  900. } else {
  901. self.build_issues_url(&filters, include_labels, ordering)
  902. };
  903. let result = client.get(&url);
  904. if use_search_api {
  905. let result = client
  906. .json::<IssueSearchResult>(result)
  907. .await
  908. .with_context(|| format!("failed to list issues from {}", url))?;
  909. issues.extend(result.items);
  910. if issues.len() < result.total_count {
  911. ordering.page += 1;
  912. continue;
  913. }
  914. } else {
  915. // FIXME: paginate with non-search
  916. issues = client
  917. .json(result)
  918. .await
  919. .with_context(|| format!("failed to list issues from {}", url))?
  920. }
  921. break;
  922. }
  923. Ok(issues)
  924. }
  925. fn build_issues_url(
  926. &self,
  927. filters: &Vec<(&str, &str)>,
  928. include_labels: &Vec<&str>,
  929. ordering: Ordering<'_>,
  930. ) -> String {
  931. self.build_endpoint_url("issues", filters, include_labels, ordering)
  932. }
  933. fn build_pulls_url(
  934. &self,
  935. filters: &Vec<(&str, &str)>,
  936. include_labels: &Vec<&str>,
  937. ordering: Ordering<'_>,
  938. ) -> String {
  939. self.build_endpoint_url("pulls", filters, include_labels, ordering)
  940. }
  941. fn build_endpoint_url(
  942. &self,
  943. endpoint: &str,
  944. filters: &Vec<(&str, &str)>,
  945. include_labels: &Vec<&str>,
  946. ordering: Ordering<'_>,
  947. ) -> String {
  948. let filters = filters
  949. .iter()
  950. .map(|(key, val)| format!("{}={}", key, val))
  951. .chain(std::iter::once(format!(
  952. "labels={}",
  953. include_labels.join(",")
  954. )))
  955. .chain(std::iter::once("filter=all".to_owned()))
  956. .chain(std::iter::once(format!("sort={}", ordering.sort,)))
  957. .chain(std::iter::once(
  958. format!("direction={}", ordering.direction,),
  959. ))
  960. .chain(std::iter::once(format!("per_page={}", ordering.per_page,)))
  961. .collect::<Vec<_>>()
  962. .join("&");
  963. format!(
  964. "{}/repos/{}/{}?{}",
  965. Repository::GITHUB_API_URL,
  966. self.full_name,
  967. endpoint,
  968. filters
  969. )
  970. }
  971. fn build_search_issues_url(
  972. &self,
  973. filters: &Vec<(&str, &str)>,
  974. include_labels: &Vec<&str>,
  975. exclude_labels: &Vec<&str>,
  976. ordering: Ordering<'_>,
  977. ) -> String {
  978. let filters = filters
  979. .iter()
  980. .filter(|&&(key, val)| !(key == "state" && val == "all"))
  981. .map(|(key, val)| format!("{}:{}", key, val))
  982. .chain(
  983. include_labels
  984. .iter()
  985. .map(|label| format!("label:{}", label)),
  986. )
  987. .chain(
  988. exclude_labels
  989. .iter()
  990. .map(|label| format!("-label:{}", label)),
  991. )
  992. .chain(std::iter::once(format!("repo:{}", self.full_name)))
  993. .collect::<Vec<_>>()
  994. .join("+");
  995. format!(
  996. "{}/search/issues?q={}&sort={}&order={}&per_page={}&page={}",
  997. Repository::GITHUB_API_URL,
  998. filters,
  999. ordering.sort,
  1000. ordering.direction,
  1001. ordering.per_page,
  1002. ordering.page,
  1003. )
  1004. }
  1005. }
  1006. pub struct Query<'a> {
  1007. // key/value filter
  1008. pub filters: Vec<(&'a str, &'a str)>,
  1009. pub include_labels: Vec<&'a str>,
  1010. pub exclude_labels: Vec<&'a str>,
  1011. }
  1012. fn quote_reply(markdown: &str) -> String {
  1013. if markdown.is_empty() {
  1014. String::from("*No content*")
  1015. } else {
  1016. format!("\n\t> {}", markdown.replace("\n", "\n\t> "))
  1017. }
  1018. }
  1019. #[async_trait]
  1020. impl<'q> IssuesQuery for Query<'q> {
  1021. async fn query<'a>(
  1022. &'a self,
  1023. repo: &'a Repository,
  1024. include_fcp_details: bool,
  1025. client: &'a GithubClient,
  1026. ) -> anyhow::Result<Vec<crate::actions::IssueDecorator>> {
  1027. let issues = repo
  1028. .get_issues(&client, self)
  1029. .await
  1030. .with_context(|| "Unable to get issues.")?;
  1031. let fcp_map = if include_fcp_details {
  1032. crate::rfcbot::get_all_fcps().await?
  1033. } else {
  1034. HashMap::new()
  1035. };
  1036. let mut issues_decorator = Vec::new();
  1037. for issue in issues {
  1038. let fcp_details = if include_fcp_details {
  1039. let repository_name = if let Some(repo) = issue.repository.get() {
  1040. repo.repository.clone()
  1041. } else {
  1042. let re = regex::Regex::new("https://github.com/rust-lang/|/").unwrap();
  1043. let split = re.split(&issue.html_url).collect::<Vec<&str>>();
  1044. split[1].to_string()
  1045. };
  1046. let key = format!(
  1047. "rust-lang/{}:{}:{}",
  1048. repository_name, issue.number, issue.title,
  1049. );
  1050. if let Some(fcp) = fcp_map.get(&key) {
  1051. let bot_tracking_comment_html_url = format!(
  1052. "{}#issuecomment-{}",
  1053. issue.html_url, fcp.fcp.fk_bot_tracking_comment
  1054. );
  1055. let bot_tracking_comment_content = quote_reply(&fcp.status_comment.body);
  1056. let fk_initiating_comment = fcp.fcp.fk_initiating_comment;
  1057. let init_comment = issue
  1058. .get_comment(&client, fk_initiating_comment.try_into()?)
  1059. .await?;
  1060. Some(crate::actions::FCPDetails {
  1061. bot_tracking_comment_html_url,
  1062. bot_tracking_comment_content,
  1063. initiating_comment_html_url: init_comment.html_url.clone(),
  1064. initiating_comment_content: quote_reply(&init_comment.body),
  1065. })
  1066. } else {
  1067. None
  1068. }
  1069. } else {
  1070. None
  1071. };
  1072. issues_decorator.push(crate::actions::IssueDecorator {
  1073. title: issue.title.clone(),
  1074. number: issue.number,
  1075. html_url: issue.html_url.clone(),
  1076. repo_name: repo.name().to_owned(),
  1077. labels: issue
  1078. .labels
  1079. .iter()
  1080. .map(|l| l.name.as_ref())
  1081. .collect::<Vec<_>>()
  1082. .join(", "),
  1083. assignees: issue
  1084. .assignees
  1085. .iter()
  1086. .map(|u| u.login.as_ref())
  1087. .collect::<Vec<_>>()
  1088. .join(", "),
  1089. updated_at_hts: crate::actions::to_human(issue.updated_at),
  1090. fcp_details,
  1091. });
  1092. }
  1093. Ok(issues_decorator)
  1094. }
  1095. }
  1096. #[derive(Debug, serde::Deserialize)]
  1097. #[serde(rename_all = "snake_case")]
  1098. pub enum CreateKind {
  1099. Branch,
  1100. Tag,
  1101. }
  1102. #[derive(Debug, serde::Deserialize)]
  1103. pub struct CreateEvent {
  1104. pub ref_type: CreateKind,
  1105. repository: Repository,
  1106. sender: User,
  1107. }
  1108. #[derive(Debug, serde::Deserialize)]
  1109. pub struct PushEvent {
  1110. #[serde(rename = "ref")]
  1111. pub git_ref: String,
  1112. repository: Repository,
  1113. sender: User,
  1114. }
  1115. /// An event triggered by a webhook.
  1116. #[derive(Debug)]
  1117. pub enum Event {
  1118. /// A Git branch or tag is created.
  1119. Create(CreateEvent),
  1120. /// A comment on an issue or PR.
  1121. ///
  1122. /// Can be:
  1123. /// - Regular comment on an issue or PR.
  1124. /// - A PR review.
  1125. /// - A comment on a PR review.
  1126. ///
  1127. /// These different scenarios are unified into the `IssueComment` variant
  1128. /// when triagebot receives the corresponding webhook event.
  1129. IssueComment(IssueCommentEvent),
  1130. /// Activity on an issue or PR.
  1131. Issue(IssuesEvent),
  1132. /// One or more commits are pushed to a repository branch or tag.
  1133. Push(PushEvent),
  1134. }
  1135. impl Event {
  1136. pub fn repo(&self) -> &Repository {
  1137. match self {
  1138. Event::Create(event) => &event.repository,
  1139. Event::IssueComment(event) => &event.repository,
  1140. Event::Issue(event) => &event.repository,
  1141. Event::Push(event) => &event.repository,
  1142. }
  1143. }
  1144. pub fn issue(&self) -> Option<&Issue> {
  1145. match self {
  1146. Event::Create(_) => None,
  1147. Event::IssueComment(event) => Some(&event.issue),
  1148. Event::Issue(event) => Some(&event.issue),
  1149. Event::Push(_) => None,
  1150. }
  1151. }
  1152. /// This will both extract from IssueComment events but also Issue events
  1153. pub fn comment_body(&self) -> Option<&str> {
  1154. match self {
  1155. Event::Create(_) => None,
  1156. Event::Issue(e) => Some(&e.issue.body),
  1157. Event::IssueComment(e) => Some(&e.comment.body),
  1158. Event::Push(_) => None,
  1159. }
  1160. }
  1161. /// This will both extract from IssueComment events but also Issue events
  1162. pub fn comment_from(&self) -> Option<&str> {
  1163. match self {
  1164. Event::Create(_) => None,
  1165. Event::Issue(e) => Some(&e.changes.as_ref()?.body.as_ref()?.from),
  1166. Event::IssueComment(e) => Some(&e.changes.as_ref()?.body.as_ref()?.from),
  1167. Event::Push(_) => None,
  1168. }
  1169. }
  1170. pub fn html_url(&self) -> Option<&str> {
  1171. match self {
  1172. Event::Create(_) => None,
  1173. Event::Issue(e) => Some(&e.issue.html_url),
  1174. Event::IssueComment(e) => Some(&e.comment.html_url),
  1175. Event::Push(_) => None,
  1176. }
  1177. }
  1178. pub fn user(&self) -> &User {
  1179. match self {
  1180. Event::Create(e) => &e.sender,
  1181. Event::Issue(e) => &e.issue.user,
  1182. Event::IssueComment(e) => &e.comment.user,
  1183. Event::Push(e) => &e.sender,
  1184. }
  1185. }
  1186. pub fn time(&self) -> Option<chrono::DateTime<FixedOffset>> {
  1187. match self {
  1188. Event::Create(_) => None,
  1189. Event::Issue(e) => Some(e.issue.created_at.into()),
  1190. Event::IssueComment(e) => Some(e.comment.updated_at.into()),
  1191. Event::Push(_) => None,
  1192. }
  1193. }
  1194. }
  1195. trait RequestSend: Sized {
  1196. fn configure(self, g: &GithubClient) -> Self;
  1197. }
  1198. impl RequestSend for RequestBuilder {
  1199. fn configure(self, g: &GithubClient) -> RequestBuilder {
  1200. let mut auth = HeaderValue::from_maybe_shared(format!("token {}", g.token)).unwrap();
  1201. auth.set_sensitive(true);
  1202. self.header(USER_AGENT, "rust-lang-triagebot")
  1203. .header(AUTHORIZATION, &auth)
  1204. }
  1205. }
  1206. /// Finds the token in the user's environment, panicking if no suitable token
  1207. /// can be found.
  1208. pub fn default_token_from_env() -> String {
  1209. match std::env::var("GITHUB_API_TOKEN") {
  1210. Ok(v) => return v,
  1211. Err(_) => (),
  1212. }
  1213. match get_token_from_git_config() {
  1214. Ok(v) => return v,
  1215. Err(_) => (),
  1216. }
  1217. panic!("could not find token in GITHUB_API_TOKEN or .gitconfig/github.oath-token")
  1218. }
  1219. fn get_token_from_git_config() -> anyhow::Result<String> {
  1220. let output = std::process::Command::new("git")
  1221. .arg("config")
  1222. .arg("--get")
  1223. .arg("github.oauth-token")
  1224. .output()?;
  1225. if !output.status.success() {
  1226. anyhow::bail!("error received executing `git`: {:?}", output.status);
  1227. }
  1228. let git_token = String::from_utf8(output.stdout)?.trim().to_string();
  1229. Ok(git_token)
  1230. }
  1231. #[derive(Clone)]
  1232. pub struct GithubClient {
  1233. token: String,
  1234. client: Client,
  1235. }
  1236. impl GithubClient {
  1237. pub fn new(client: Client, token: String) -> Self {
  1238. GithubClient { client, token }
  1239. }
  1240. pub fn new_with_default_token(client: Client) -> Self {
  1241. Self::new(client, default_token_from_env())
  1242. }
  1243. pub fn raw(&self) -> &Client {
  1244. &self.client
  1245. }
  1246. pub async fn raw_file(
  1247. &self,
  1248. repo: &str,
  1249. branch: &str,
  1250. path: &str,
  1251. ) -> anyhow::Result<Option<Vec<u8>>> {
  1252. let url = format!(
  1253. "https://raw.githubusercontent.com/{}/{}/{}",
  1254. repo, branch, path
  1255. );
  1256. let req = self.get(&url);
  1257. let req_dbg = format!("{:?}", req);
  1258. let req = req
  1259. .build()
  1260. .with_context(|| format!("failed to build request {:?}", req_dbg))?;
  1261. let mut resp = self.client.execute(req).await.context(req_dbg.clone())?;
  1262. let status = resp.status();
  1263. match status {
  1264. StatusCode::OK => {
  1265. let mut buf = Vec::with_capacity(resp.content_length().unwrap_or(4) as usize);
  1266. while let Some(chunk) = resp.chunk().await.transpose() {
  1267. let chunk = chunk
  1268. .context("reading stream failed")
  1269. .map_err(anyhow::Error::from)
  1270. .context(req_dbg.clone())?;
  1271. buf.extend_from_slice(&chunk);
  1272. }
  1273. Ok(Some(buf))
  1274. }
  1275. StatusCode::NOT_FOUND => Ok(None),
  1276. status => anyhow::bail!("failed to GET {}: {}", url, status),
  1277. }
  1278. }
  1279. /// Get the raw gist content from the URL of the HTML version of the gist:
  1280. ///
  1281. /// `html_url` looks like `https://gist.github.com/rust-play/7e80ca3b1ec7abe08f60c41aff91f060`.
  1282. ///
  1283. /// `filename` is the name of the file you want the content of.
  1284. pub async fn raw_gist_from_url(
  1285. &self,
  1286. html_url: &str,
  1287. filename: &str,
  1288. ) -> anyhow::Result<String> {
  1289. let url = html_url.replace("github.com", "githubusercontent.com") + "/raw/" + filename;
  1290. let response = self.raw().get(&url).send().await?;
  1291. response.text().await.context("raw gist from url")
  1292. }
  1293. fn get(&self, url: &str) -> RequestBuilder {
  1294. log::trace!("get {:?}", url);
  1295. self.client.get(url).configure(self)
  1296. }
  1297. fn patch(&self, url: &str) -> RequestBuilder {
  1298. log::trace!("patch {:?}", url);
  1299. self.client.patch(url).configure(self)
  1300. }
  1301. fn delete(&self, url: &str) -> RequestBuilder {
  1302. log::trace!("delete {:?}", url);
  1303. self.client.delete(url).configure(self)
  1304. }
  1305. fn post(&self, url: &str) -> RequestBuilder {
  1306. log::trace!("post {:?}", url);
  1307. self.client.post(url).configure(self)
  1308. }
  1309. #[allow(unused)]
  1310. fn put(&self, url: &str) -> RequestBuilder {
  1311. log::trace!("put {:?}", url);
  1312. self.client.put(url).configure(self)
  1313. }
  1314. pub async fn rust_commit(&self, sha: &str) -> Option<GithubCommit> {
  1315. let req = self.get(&format!(
  1316. "https://api.github.com/repos/rust-lang/rust/commits/{}",
  1317. sha
  1318. ));
  1319. match self.json(req).await {
  1320. Ok(r) => Some(r),
  1321. Err(e) => {
  1322. log::error!("Failed to query commit {:?}: {:?}", sha, e);
  1323. None
  1324. }
  1325. }
  1326. }
  1327. /// This does not retrieve all of them, only the last several.
  1328. pub async fn bors_commits(&self) -> Vec<GithubCommit> {
  1329. let req = self.get("https://api.github.com/repos/rust-lang/rust/commits?author=bors");
  1330. match self.json(req).await {
  1331. Ok(r) => r,
  1332. Err(e) => {
  1333. log::error!("Failed to query commit list: {:?}", e);
  1334. Vec::new()
  1335. }
  1336. }
  1337. }
  1338. }
  1339. #[derive(Debug, serde::Deserialize)]
  1340. pub struct GithubCommit {
  1341. pub sha: String,
  1342. pub commit: GitCommit,
  1343. pub parents: Vec<Parent>,
  1344. }
  1345. #[derive(Debug, serde::Deserialize)]
  1346. pub struct GitCommit {
  1347. pub author: GitUser,
  1348. pub message: String,
  1349. }
  1350. #[derive(Debug, serde::Deserialize)]
  1351. pub struct GitUser {
  1352. pub date: DateTime<FixedOffset>,
  1353. }
  1354. #[derive(Debug, serde::Deserialize)]
  1355. pub struct Parent {
  1356. pub sha: String,
  1357. }
  1358. #[async_trait]
  1359. pub trait IssuesQuery {
  1360. async fn query<'a>(
  1361. &'a self,
  1362. repo: &'a Repository,
  1363. include_fcp_details: bool,
  1364. client: &'a GithubClient,
  1365. ) -> anyhow::Result<Vec<crate::actions::IssueDecorator>>;
  1366. }
  1367. pub struct LeastRecentlyReviewedPullRequests;
  1368. #[async_trait]
  1369. impl IssuesQuery for LeastRecentlyReviewedPullRequests {
  1370. async fn query<'a>(
  1371. &'a self,
  1372. repo: &'a Repository,
  1373. _include_fcp_details: bool,
  1374. client: &'a GithubClient,
  1375. ) -> anyhow::Result<Vec<crate::actions::IssueDecorator>> {
  1376. use cynic::QueryBuilder;
  1377. use github_graphql::queries;
  1378. let repository_owner = repo.owner().to_owned();
  1379. let repository_name = repo.name().to_owned();
  1380. let mut prs: Vec<Option<queries::PullRequest>> = vec![];
  1381. let mut args = queries::LeastRecentlyReviewedPullRequestsArguments {
  1382. repository_owner,
  1383. repository_name: repository_name.clone(),
  1384. after: None,
  1385. };
  1386. loop {
  1387. let query = queries::LeastRecentlyReviewedPullRequests::build(&args);
  1388. let req = client.post(Repository::GITHUB_GRAPHQL_API_URL);
  1389. let req = req.json(&query);
  1390. let (resp, req_dbg) = client._send_req(req).await?;
  1391. let response = resp.json().await.context(req_dbg)?;
  1392. let data: cynic::GraphQlResponse<queries::LeastRecentlyReviewedPullRequests> =
  1393. query.decode_response(response).with_context(|| {
  1394. format!("failed to parse response for `LeastRecentlyReviewedPullRequests`")
  1395. })?;
  1396. if let Some(errors) = data.errors {
  1397. anyhow::bail!("There were graphql errors. {:?}", errors);
  1398. }
  1399. let repository = data
  1400. .data
  1401. .ok_or_else(|| anyhow::anyhow!("No data returned."))?
  1402. .repository
  1403. .ok_or_else(|| anyhow::anyhow!("No repository."))?;
  1404. prs.extend(
  1405. repository
  1406. .pull_requests
  1407. .nodes
  1408. .unwrap_or_default()
  1409. .into_iter(),
  1410. );
  1411. let page_info = repository.pull_requests.page_info;
  1412. if !page_info.has_next_page || page_info.end_cursor.is_none() {
  1413. break;
  1414. }
  1415. args.after = page_info.end_cursor;
  1416. }
  1417. let mut prs: Vec<_> = prs
  1418. .into_iter()
  1419. .filter_map(|pr| pr)
  1420. .filter_map(|pr| {
  1421. if pr.is_draft {
  1422. return None;
  1423. }
  1424. let labels = pr.labels;
  1425. let labels = (|| -> Option<_> {
  1426. let labels = labels?;
  1427. let nodes = labels.nodes?;
  1428. let labels = nodes
  1429. .into_iter()
  1430. .filter_map(|node| node)
  1431. .map(|node| node.name)
  1432. .collect::<Vec<_>>();
  1433. Some(labels)
  1434. })()
  1435. .unwrap_or_default();
  1436. if !labels.iter().any(|label| label == "T-compiler") {
  1437. return None;
  1438. }
  1439. let labels = labels.join(", ");
  1440. let assignees: Vec<_> = pr
  1441. .assignees
  1442. .nodes
  1443. .unwrap_or_default()
  1444. .into_iter()
  1445. .filter_map(|user| user)
  1446. .map(|user| user.login)
  1447. .collect();
  1448. let latest_reviews = pr.latest_reviews;
  1449. let mut reviews = (|| -> Option<_> {
  1450. let reviews = latest_reviews?;
  1451. let nodes = reviews.nodes?;
  1452. let reviews = nodes
  1453. .into_iter()
  1454. .filter_map(|node| node)
  1455. .filter_map(|node| {
  1456. let created_at = node.created_at;
  1457. node.author.map(|author| (author, created_at))
  1458. })
  1459. .map(|(author, created_at)| (author.login, created_at))
  1460. .collect::<Vec<_>>();
  1461. Some(reviews)
  1462. })()
  1463. .unwrap_or_default();
  1464. reviews.sort_by_key(|r| r.1);
  1465. let comments = pr.comments;
  1466. let comments = (|| -> Option<_> {
  1467. let nodes = comments.nodes?;
  1468. let comments = nodes
  1469. .into_iter()
  1470. .filter_map(|node| node)
  1471. .filter_map(|node| {
  1472. let created_at = node.created_at;
  1473. node.author.map(|author| (author, created_at))
  1474. })
  1475. .map(|(author, created_at)| (author.login, created_at))
  1476. .collect::<Vec<_>>();
  1477. Some(comments)
  1478. })()
  1479. .unwrap_or_default();
  1480. let mut comments: Vec<_> = comments
  1481. .into_iter()
  1482. .filter(|comment| assignees.contains(&comment.0))
  1483. .collect();
  1484. comments.sort_by_key(|c| c.1);
  1485. let updated_at = std::cmp::max(
  1486. reviews.last().map(|t| t.1).unwrap_or(pr.created_at),
  1487. comments.last().map(|t| t.1).unwrap_or(pr.created_at),
  1488. );
  1489. let assignees = assignees.join(", ");
  1490. Some((
  1491. updated_at,
  1492. pr.number as u64,
  1493. pr.title,
  1494. pr.url.0,
  1495. repository_name.clone(),
  1496. labels,
  1497. assignees,
  1498. ))
  1499. })
  1500. .collect();
  1501. prs.sort_by_key(|pr| pr.0);
  1502. let prs: Vec<_> = prs
  1503. .into_iter()
  1504. .take(50)
  1505. .map(
  1506. |(updated_at, number, title, html_url, repo_name, labels, assignees)| {
  1507. let updated_at_hts = crate::actions::to_human(updated_at);
  1508. crate::actions::IssueDecorator {
  1509. number,
  1510. title,
  1511. html_url,
  1512. repo_name,
  1513. labels,
  1514. assignees,
  1515. updated_at_hts,
  1516. fcp_details: None,
  1517. }
  1518. },
  1519. )
  1520. .collect();
  1521. Ok(prs)
  1522. }
  1523. }
  1524. #[cfg(test)]
  1525. mod tests {
  1526. use super::*;
  1527. #[test]
  1528. fn display_labels() {
  1529. let x = UnknownLabels {
  1530. labels: vec!["A-bootstrap".into(), "xxx".into()],
  1531. };
  1532. assert_eq!(x.to_string(), "Unknown labels: A-bootstrap, xxx");
  1533. }
  1534. #[test]
  1535. fn extract_one_file() {
  1536. let input = r##"\
  1537. diff --git a/triagebot.toml b/triagebot.toml
  1538. index fb9cee43b2d..b484c25ea51 100644
  1539. --- a/triagebot.toml
  1540. +++ b/triagebot.toml
  1541. @@ -114,6 +114,15 @@ trigger_files = [
  1542. "src/tools/rustdoc-themes",
  1543. ]
  1544. +[autolabel."T-compiler"]
  1545. +trigger_files = [
  1546. + # Source code
  1547. + "compiler",
  1548. +
  1549. + # Tests
  1550. + "src/test/ui",
  1551. +]
  1552. +
  1553. [notify-zulip."I-prioritize"]
  1554. zulip_stream = 245100 # #t-compiler/wg-prioritization/alerts
  1555. topic = "#{number} {title}"
  1556. "##;
  1557. assert_eq!(files_changed(input), vec!["triagebot.toml".to_string()]);
  1558. }
  1559. #[test]
  1560. fn extract_several_files() {
  1561. let input = r##"\
  1562. diff --git a/library/stdarch b/library/stdarch
  1563. index b70ae88ef2a..cfba59fccd9 160000
  1564. --- a/library/stdarch
  1565. +++ b/library/stdarch
  1566. @@ -1 +1 @@
  1567. -Subproject commit b70ae88ef2a6c83acad0a1e83d5bd78f9655fd05
  1568. +Subproject commit cfba59fccd90b3b52a614120834320f764ab08d1
  1569. diff --git a/src/librustdoc/clean/types.rs b/src/librustdoc/clean/types.rs
  1570. index 1fe4aa9023e..f0330f1e424 100644
  1571. --- a/src/librustdoc/clean/types.rs
  1572. +++ b/src/librustdoc/clean/types.rs
  1573. @@ -2322,3 +2322,4 @@ impl SubstParam {
  1574. if let Self::Lifetime(lt) = self { Some(lt) } else { None }
  1575. }
  1576. }
  1577. +
  1578. diff --git a/src/librustdoc/core.rs b/src/librustdoc/core.rs
  1579. index c58310947d2..3b0854d4a9b 100644
  1580. --- a/src/librustdoc/core.rs
  1581. +++ b/src/librustdoc/core.rs
  1582. @@ -591,3 +591,4 @@ fn from(idx: u32) -> Self {
  1583. ImplTraitParam::ParamIndex(idx)
  1584. }
  1585. }
  1586. +
  1587. "##;
  1588. assert_eq!(
  1589. files_changed(input),
  1590. vec![
  1591. "library/stdarch".to_string(),
  1592. "src/librustdoc/clean/types.rs".to_string(),
  1593. "src/librustdoc/core.rs".to_string(),
  1594. ]
  1595. )
  1596. }
  1597. }