github.rs 45 KB

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