1use eyre::{Context, Result, bail, ensure};
2use futures_util::future::try_join_all;
3use http::StatusCode;
4use octocrab::{
5 GitHubError, Octocrab,
6 issues::IssueHandler,
7 models::{
8 IssueState, Label,
9 issues::Issue,
10 pulls::{self, Comment, PullRequest},
11 repos::Branch,
12 },
13 pulls::PullRequestHandler,
14 repos::RepoHandler,
15};
16use parking_lot::{MappedMutexGuard, Mutex, MutexGuard};
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use specta::Type;
21use std::{env, fs, path::Path, sync::Arc, time::Duration};
22use tokio::{time::timeout, try_join};
23use tracing::warn;
24
25use crate::{
26 command::command,
27 git::{GitRepo, MergeType},
28 package::QuestPackage,
29 utils,
30};
31
32pub struct GithubRepo {
33 user: String,
34 name: String,
35 gh: Arc<Octocrab>,
36 prs: Mutex<Option<Vec<PullRequest>>>,
37 issues: Mutex<Option<Vec<Issue>>>,
38}
39
40#[derive(Debug)]
41pub enum PullSelector {
42 Branch(String),
43 Label(String),
44}
45
46pub fn find_pr<'a>(
47 selector: &PullSelector,
48 prs: impl IntoIterator<Item = &'a PullRequest> + 'a,
49) -> Option<usize> {
50 prs.into_iter().position(|pr| match selector {
51 PullSelector::Branch(branch) => &pr.head.ref_field == branch,
52 PullSelector::Label(label) => pr
53 .labels
54 .as_ref()
55 .map(|labels| labels.iter().any(|l| &l.name == label))
56 .unwrap_or(false),
57 })
58}
59
60pub fn find_issue<'a>(
61 label_name: &str,
62 issues: impl IntoIterator<Item = &'a Issue> + 'a,
63) -> Option<usize> {
64 issues
65 .into_iter()
66 .position(|issue| issue.labels.iter().any(|label| label.name == label_name))
67}
68
69const RESET_LABEL: &str = "reset";
70
71pub async fn load_user() -> Result<String> {
72 let user = octocrab::instance()
73 .current()
74 .user()
75 .await
76 .context("Failed to query Github connector for current user")?;
77 Ok(user.login)
78}
79
80pub fn check_ssh() -> Result<()> {
82 let output = command("ssh -T git@github.com", Path::new("/")).output()?;
83 match output.status.code() {
84 Some(1) => Ok(()),
86 _ => {
87 let stderr = String::from_utf8(output.stderr)?;
88 if stderr.contains("git@github.com: Permission denied (publickey).") {
89 bail!(
90 "Your machine is not setup for a secure connection to Github. Please follow the instructions here: https://docs.github.com/en/authentication/troubleshooting-ssh/error-permission-denied-publickey"
91 );
92 } else {
93 bail!("Failed to establish a secure connection to Github with error:\n{stderr}")
94 }
95 }
96 }
97}
98
99pub enum GitProtocol {
100 Ssh,
101 Https,
102}
103
104#[derive(PartialEq, Eq, Debug)]
105pub enum TestRepoResult {
106 HasContent,
107 NoContent,
108 NotFound,
109}
110
111impl GithubRepo {
112 pub fn new(user: &str, name: &str) -> Self {
113 GithubRepo {
114 user: user.to_string(),
115 name: name.to_string(),
116 gh: octocrab::instance(),
117 prs: Mutex::new(None),
118 issues: Mutex::new(None),
119 }
120 }
121
122 pub async fn load(user: &str, name: &str) -> Result<Self> {
123 let repo = GithubRepo::new(user, name);
124 ensure!(repo.fetch().await?, "Not found");
125 Ok(repo)
126 }
127
128 pub async fn fetch(&self) -> Result<bool> {
130 let (pr_handler, issue_handler) = (self.pr_handler(), self.issue_handler());
131 let res = try_join!(
132 pr_handler.list().state(octocrab::params::State::All).send(),
133 issue_handler
134 .list()
135 .state(octocrab::params::State::All)
136 .send()
137 );
138 let (mut pr_page, mut issue_page) = match res {
139 Ok(pages) => pages,
140 Err(octocrab::Error::GitHub { source, .. })
141 if matches!(
142 &*source,
143 GitHubError {
144 status_code: StatusCode::NOT_FOUND,
145 ..
146 },
147 ) =>
148 {
149 return Ok(false);
150 }
151 Err(e) => return Err(e.into()),
152 };
153 let (prs, mut issues) = (pr_page.take_items(), issue_page.take_items());
154
155 issues.retain(|issue| issue.pull_request.is_none());
157
158 *self.prs.lock() = Some(prs);
159 *self.issues.lock() = Some(issues);
160
161 Ok(true)
162 }
163
164 pub fn remote(&self, protocol: GitProtocol) -> String {
165 match protocol {
166 GitProtocol::Https => format!("https://github.com/{}/{}", self.user, self.name),
167 GitProtocol::Ssh => format!("git@github.com:{}/{}.git", self.user, self.name),
168 }
169 }
170
171 pub async fn test_repo(&self) -> Result<TestRepoResult> {
172 let result = self.repo_handler().list_commits().send().await;
173 match result {
174 Err(octocrab::Error::GitHub { source, .. })
175 if matches!(
176 &*source,
177 GitHubError {
178 status_code: StatusCode::NO_CONTENT | StatusCode::CONFLICT,
179 ..
180 }
181 ) =>
182 {
183 Ok(TestRepoResult::NoContent)
184 }
185 Err(octocrab::Error::GitHub { source, .. })
186 if matches!(
187 &*source,
188 GitHubError {
189 status_code: StatusCode::NOT_FOUND,
190 ..
191 }
192 ) =>
193 {
194 Ok(TestRepoResult::NotFound)
195 }
196 Ok(_) => Ok(TestRepoResult::HasContent),
197 Err(e) => {
198 if let octocrab::Error::GitHub { source, .. } = &e {
199 tracing::debug!("Error: {:?}", source.status_code);
200 }
201
202 Err(e.into())
203 }
204 }
205 }
206
207 pub fn clone(&self, path: &Path) -> Result<GitRepo> {
208 let remote = self.remote(GitProtocol::Ssh);
209 GitRepo::clone(&path.join(&self.name), &remote)
210 }
211
212 async fn wait_for_content(&self, expected: TestRepoResult) -> Result<()> {
215 const RETRY_INTERVAL: u64 = 500;
216 const RETRY_TIMEOUT: u64 = 5000;
217
218 let strategy = tokio_retry::strategy::FixedInterval::from_millis(RETRY_INTERVAL);
219 let has_content = tokio_retry::Retry::spawn(strategy, || async {
220 match self.test_repo().await {
221 Ok(actual) if expected == actual => Ok(()),
222 result => {
223 tracing::debug!("wait status: {result:?}");
224 Err(result)
225 }
226 }
227 });
228 let _ = timeout(Duration::from_millis(RETRY_TIMEOUT), has_content)
229 .await
230 .context("Repo is still empty after timeout")?;
231
232 Ok(())
233 }
234
235 async fn create_labels(&self, labels: &[Label]) -> Result<()> {
236 let issues = self.issue_handler();
237 try_join_all(labels.iter().filter(|label| !label.default).map(|label| {
238 issues.create_label(
239 &label.name,
240 &label.color,
241 label.description.as_deref().unwrap_or(""),
242 )
243 }))
244 .await
245 .context("Failed to create labels")?;
246 Ok(())
247 }
248
249 async fn unsubscribe(&self) -> Result<()> {
250 let route = format!("/repos/{}/{}/subscription", self.user, self.name);
251 self
252 .gh
253 .put::<serde_json::Value, _, _>(
254 route,
255 Some(&json!({
256 "subscribed": false,
257 "ignored": true
258 })),
259 )
260 .await
261 .context("Failed to unsubscribe from repo")?;
262 Ok(())
263 }
264
265 pub async fn instantiate_from_package(package: &QuestPackage) -> Result<GithubRepo> {
266 let user = load_user().await.context("Failed to load user")?;
267 let params = json!({
268 "name": &package.config.repo,
269 "private": true,
270 });
271 octocrab::instance()
272 .post::<_, serde_json::Value>("/user/repos", Some(¶ms))
273 .await
274 .context("Failed to create repo")?;
275 let repo = GithubRepo::new(&user, &package.config.repo);
276 repo
277 .wait_for_content(TestRepoResult::NoContent)
278 .await
279 .context("Github repo was not properly initialized")?;
280 repo
281 .unsubscribe()
282 .await
283 .context("Failed to unsubscribe from repo")?;
284 repo
285 .create_labels(&package.labels)
286 .await
287 .context("Failed to transfer package labels to repo")?;
288 Ok(repo)
289 }
290
291 pub async fn instantiate_from_repo(base: &GithubRepo) -> Result<GithubRepo> {
292 let user = load_user().await?;
293 let name = &base.name;
294 base
295 .repo_handler()
296 .generate(name)
297 .owner(&user)
298 .send()
301 .await
302 .with_context(|| format!("Failed to clone template repo {}/{}", base.user, base.name))?;
303
304 let repo = GithubRepo::new(&user, name);
305 repo
306 .wait_for_content(TestRepoResult::HasContent)
307 .await
308 .context("Github repo was not properly initialized")?;
309
310 repo
312 .unsubscribe()
313 .await
314 .context("Failed to unsubscribe from repo")?;
315
316 let mut page = base
318 .issue_handler()
319 .list_labels_for_repo()
320 .send()
321 .await
322 .context("Failed to fetch labels from upstream repo")?;
323 let labels = page.take_items();
324 repo
325 .create_labels(&labels)
326 .await
327 .context("Failed to transfer upstream labels to repo")?;
328
329 Ok(repo)
330 }
331
332 pub fn repo_handler(&self) -> RepoHandler {
333 self.gh.repos(&self.user, &self.name)
334 }
335
336 pub async fn branches(&self) -> Result<Vec<Branch>> {
337 let pages = self
338 .repo_handler()
339 .list_branches()
340 .send()
341 .await
342 .context("Failed to fetch branches")?;
343 let branches = pages.into_iter().collect::<Vec<_>>();
344 Ok(branches)
345 }
346
347 pub fn pr_handler(&self) -> PullRequestHandler {
348 self.gh.pulls(&self.user, &self.name)
349 }
350
351 pub fn prs(&self) -> MappedMutexGuard<'_, Vec<PullRequest>> {
352 MutexGuard::map(self.prs.lock(), |opt| {
353 opt.as_mut().expect("PRs not populated")
354 })
355 }
356
357 pub fn pr(&self, selector: &PullSelector) -> Option<MappedMutexGuard<'_, PullRequest>> {
358 let prs = self.prs();
359 let idx = find_pr(selector, prs.iter())?;
360 Some(MappedMutexGuard::map(prs, |prs| &mut prs[idx]))
361 }
362
363 pub async fn pr_comments(&self, pr: &PullRequest) -> Result<Vec<pulls::Comment>> {
364 let comment_pages = self
365 .pr_handler()
366 .list_comments(Some(pr.number))
367 .send()
368 .await
369 .with_context(|| format!("Failed to fetch comments for PR {}", pr.number))?;
370 let comments = comment_pages.into_iter().collect::<Vec<_>>();
371 Ok(comments)
372 }
373
374 pub fn issue_handler(&self) -> IssueHandler {
375 self.gh.issues(&self.user, &self.name)
376 }
377
378 pub fn issues(&self) -> MappedMutexGuard<'_, Vec<Issue>> {
379 MutexGuard::map(self.issues.lock(), |opt| {
380 opt.as_mut().expect("Issues not populated")
381 })
382 }
383
384 pub fn issue(&self, label_name: &str) -> Option<MappedMutexGuard<'_, Issue>> {
385 let issues = self.issues();
386 let idx = find_issue(label_name, issues.iter())?;
387 Some(MappedMutexGuard::map(issues, |issues| &mut issues[idx]))
388 }
389
390 pub async fn copy_pr(
391 &self,
392 pr: &PullRequest,
393 comments: &[Comment],
394 head: &str,
395 merge_type: MergeType,
396 ) -> Result<PullRequest> {
397 let pulls = self.pr_handler();
398 let mut body = pr
399 .body
400 .as_ref()
401 .expect("Author error: PR missing body")
402 .clone();
403
404 let is_reset = match merge_type {
405 MergeType::SolutionReset => {
406 body.push_str(r#"
407
408Note: due to a merge conflict, this PR is a hard reset to the reference solution, and may have overwritten your previous changes."#);
409 true
410 }
411
412 MergeType::StarterReset => {
413 body.push_str(r#"
414
415Note: due to a merge conflict, this PR is a hard reset to the starter code, and may have overwritten your previous changes."#);
416 true
417 }
418
419 MergeType::Success => false,
420 };
421
422 let request = pulls
423 .create(
424 pr.title.as_ref().expect("Author error: PR missing title"),
425 &pr.head.ref_field,
426 "main", )
428 .body(body);
429 let self_pr = request.send().await.context("Failed to create new PR")?;
430
431 let mut labels = match &pr.labels {
434 Some(labels) => labels
435 .iter()
436 .map(|label| label.name.clone())
437 .collect::<Vec<_>>(),
438 None => Vec::new(),
439 };
440 if is_reset {
441 labels.push(RESET_LABEL.into());
442 }
443 self
444 .issue_handler()
445 .add_labels(self_pr.number, &labels)
446 .await
447 .context("Failed to add labels to PR")?;
448
449 for comment in comments {
450 self
451 .copy_pr_comment(self_pr.number, comment, head)
452 .await
453 .context("Failed to add comment to PR")?;
454 }
455
456 Ok(self_pr)
457 }
458
459 pub async fn copy_pr_comment(
460 &self,
461 pr: u64,
462 comment: &pulls::Comment,
463 commit: &str,
464 ) -> Result<()> {
465 let route = format!("/repos/{}/{}/pulls/{pr}/comments", self.user, self.name);
466 let comment_json = json!({
467 "path": comment.path,
468 "commit_id": commit,
469 "body": comment.body,
470 "line": comment.line
471 });
472 let _response = self
473 .gh
474 .post::<_, serde_json::Value>(route, Some(&comment_json))
475 .await
476 .with_context(|| format!("Failed to copy PR comment: {comment_json:#?}"))?;
477 Ok(())
478 }
479
480 fn process_issue_body(&self, body: &str) -> String {
481 let re = Regex::new(r"\{\{ (\S+) (\S+) \}\}").unwrap();
482 let mut new_body = body.to_string();
483 let substitutions = re.captures_iter(body).filter_map(|cap| {
484 let full_match = cap.get(0).unwrap();
485 let label = &cap[1];
486 let kind = &cap[2];
487 let number = match kind {
488 "pr" => {
489 let Some(pr) = self.pr(&PullSelector::Label(label.to_string())) else {
490 warn!("No PR with label {label}");
491 return None;
492 };
493 pr.number
494 }
495 "issue" => {
496 let Some(issue) = self.issue(label) else {
497 warn!("No issue with label {label}");
498 return None;
499 };
500 issue.number
501 }
502 _ => unimplemented!(),
503 };
504
505 Some((full_match.range(), format!("#{number}")))
506 });
507 utils::replace_many_ranges(&mut new_body, substitutions);
508
509 new_body
510 }
511
512 pub async fn copy_issue(&self, issue: &Issue) -> Result<Issue> {
513 let body = issue.body.as_ref().unwrap();
514 let body_processed = self.process_issue_body(body);
515 let issue = self
516 .issue_handler()
517 .create(&issue.title)
518 .body(body_processed)
519 .labels(
520 issue
521 .labels
522 .iter()
523 .map(|label| label.name.clone())
524 .collect::<Vec<_>>(),
525 )
526 .send()
527 .await
528 .with_context(|| format!("Failed to create issue: {}", issue.title))?;
529 Ok(issue)
530 }
531
532 pub async fn close_issue(&self, issue: &Issue) -> Result<()> {
533 self
534 .issue_handler()
535 .update(issue.number)
536 .state(IssueState::Closed)
537 .send()
538 .await
539 .with_context(|| format!("Failed to close issue: {}", issue.number))?;
540 Ok(())
541 }
542
543 pub async fn merge_pr(&self, pr: &PullRequest) -> Result<()> {
544 self
545 .pr_handler()
546 .merge(pr.number)
547 .send()
548 .await
549 .with_context(|| format!("Failed to merge PR: {}", pr.number))?;
550 Ok(())
551 }
552
553 pub async fn delete(&self) -> Result<()> {
554 self
555 .repo_handler()
556 .delete()
557 .await
558 .context("Failed to delete repo")?;
559 Ok(())
560 }
561}
562
563#[derive(Serialize, Deserialize, Type, Debug, Clone)]
564#[serde(tag = "type", content = "value")]
565pub enum GithubToken {
566 Found(String),
567 NotFound,
568 Error(String),
569}
570
571macro_rules! token_try {
572 ($e:expr) => {{
573 match $e {
574 Ok(x) => x,
575 Err(e) => return GithubToken::Error(format!("{e:?}")),
576 }
577 }};
578}
579
580fn read_github_token_from_fs() -> GithubToken {
581 let home = match home::home_dir() {
582 Some(dir) => dir,
583 None => return GithubToken::NotFound,
584 };
585 let path = home.join(".rqst-token");
586 if path.exists() {
587 let token = token_try!(fs::read_to_string(path));
588 GithubToken::Found(token.trim_end().to_string())
589 } else {
590 GithubToken::NotFound
591 }
592}
593
594fn generate_github_token_from_cli() -> GithubToken {
595 let res = command("gh auth token", &env::current_dir().unwrap()).output();
596 match res {
597 Ok(token_output) if token_output.status.success() => {
598 let token = token_try!(String::from_utf8(token_output.stdout));
599 let token_clean = token.trim_end().to_string();
600 GithubToken::Found(token_clean)
601 }
602 _ => GithubToken::NotFound,
603 }
604}
605
606pub fn get_github_token() -> GithubToken {
607 match read_github_token_from_fs() {
608 GithubToken::NotFound => generate_github_token_from_cli(),
609 result => result,
610 }
611}
612
613pub fn init_octocrab(token: &str) -> Result<()> {
614 let crab_inst = Octocrab::builder()
615 .personal_token(token.to_string())
616 .build()
617 .context("Failed to build Github connector")?;
618 octocrab::initialise(crab_inst);
619 Ok(())
620}