github.rs 70 KB

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