github.rs 42 KB

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