1#![allow(missing_docs)]
2
3use octocrab::Octocrab;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6use tracing::info;
7
8use crate::config::GitHubConfig;
9use crate::error::{GitHubError, Result};
10use crate::types::*;
11
12pub struct GitHubService {
13 config: GitHubConfig,
14 client: Arc<RwLock<Option<Octocrab>>>,
15}
16
17impl GitHubService {
18 pub fn new(config: GitHubConfig) -> Self {
19 Self {
20 config,
21 client: Arc::new(RwLock::new(None)),
22 }
23 }
24
25 pub fn config(&self) -> &GitHubConfig {
26 &self.config
27 }
28
29 async fn get_client(&self) -> Result<Octocrab> {
30 let client = self.client.read().await;
31 client.clone().ok_or(GitHubError::ClientNotInitialized)
32 }
33
34 pub async fn start(&mut self) -> Result<()> {
35 info!("Starting GitHub service...");
36
37 self.config.validate()?;
38
39 let octocrab = Octocrab::builder()
40 .personal_token(self.config.api_token.clone())
41 .build()
42 .map_err(|e| GitHubError::ConfigError(format!("Failed to create client: {}", e)))?;
43
44 let user =
45 octocrab.current().user().await.map_err(|e| {
46 GitHubError::PermissionDenied(format!("Authentication failed: {}", e))
47 })?;
48
49 info!("GitHub service started - authenticated as {}", user.login);
50
51 let mut client = self.client.write().await;
52 *client = Some(octocrab);
53
54 Ok(())
55 }
56
57 pub async fn stop(&mut self) -> Result<()> {
58 info!("Stopping GitHub service...");
59
60 let mut client = self.client.write().await;
61 *client = None;
62
63 info!("GitHub service stopped");
64 Ok(())
65 }
66
67 pub async fn is_running(&self) -> bool {
68 self.client.read().await.is_some()
69 }
70
71 pub async fn get_repository(&self, owner: &str, repo: &str) -> Result<GitHubRepository> {
72 let client = self.get_client().await?;
73 let (owner, repo) = self.config.get_repository_ref(Some(owner), Some(repo))?;
74
75 let repository = client
76 .repos(&owner, &repo)
77 .get()
78 .await
79 .map_err(|e| self.map_error(e, &owner, &repo))?;
80
81 Ok(self.map_repository(repository))
82 }
83
84 pub async fn create_issue(&self, params: CreateIssueParams) -> Result<GitHubIssue> {
85 let client = self.get_client().await?;
86 let (owner, repo) = self
87 .config
88 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
89
90 let issues_handler = client.issues(&owner, &repo);
91 let mut builder = issues_handler.create(¶ms.title);
92
93 if let Some(ref body) = params.body {
94 builder = builder.body(body);
95 }
96
97 if !params.assignees.is_empty() {
98 let assignees: Vec<String> = params.assignees.to_vec();
99 builder = builder.assignees(assignees);
100 }
101
102 if !params.labels.is_empty() {
103 let labels: Vec<String> = params.labels.to_vec();
104 builder = builder.labels(labels);
105 }
106
107 let issue = builder
108 .send()
109 .await
110 .map_err(|e| self.map_error(e, &owner, &repo))?;
111
112 Ok(self.map_issue(issue))
113 }
114
115 pub async fn get_issue(
116 &self,
117 owner: &str,
118 repo: &str,
119 issue_number: u64,
120 ) -> Result<GitHubIssue> {
121 let client = self.get_client().await?;
122 let (owner, repo) = self.config.get_repository_ref(Some(owner), Some(repo))?;
123
124 let issue = client
125 .issues(&owner, &repo)
126 .get(issue_number)
127 .await
128 .map_err(|e| {
129 let err = self.map_error(e, &owner, &repo);
130 if matches!(err, GitHubError::RepositoryNotFound { .. }) {
131 GitHubError::IssueNotFound {
132 issue_number,
133 owner: owner.clone(),
134 repo: repo.clone(),
135 }
136 } else {
137 err
138 }
139 })?;
140
141 Ok(self.map_issue(issue))
142 }
143
144 pub async fn list_issues(&self, params: ListIssuesParams) -> Result<Vec<GitHubIssue>> {
145 let client = self.get_client().await?;
146 let (owner, repo) = self
147 .config
148 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
149
150 let state = match params.state {
151 IssueStateFilter::Open => octocrab::params::State::Open,
152 IssueStateFilter::Closed => octocrab::params::State::Closed,
153 IssueStateFilter::All => octocrab::params::State::All,
154 };
155
156 let page = client
157 .issues(&owner, &repo)
158 .list()
159 .state(state)
160 .per_page(params.per_page)
161 .page(params.page)
162 .send()
163 .await
164 .map_err(|e| self.map_error(e, &owner, &repo))?;
165
166 let issues: Vec<GitHubIssue> = page
167 .items
168 .into_iter()
169 .filter(|i| i.pull_request.is_none())
170 .map(|i| self.map_issue(i))
171 .collect();
172
173 Ok(issues)
174 }
175
176 pub async fn create_pull_request(
177 &self,
178 params: CreatePullRequestParams,
179 ) -> Result<GitHubPullRequest> {
180 let client = self.get_client().await?;
181 let (owner, repo) = self
182 .config
183 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
184
185 let pr = client
186 .pulls(&owner, &repo)
187 .create(¶ms.title, ¶ms.head, ¶ms.base)
188 .body(params.body.as_deref().unwrap_or(""))
189 .draft(params.draft)
190 .send()
191 .await
192 .map_err(|e| self.map_error(e, &owner, &repo))?;
193
194 Ok(self.map_pull_request(pr))
195 }
196
197 pub async fn get_pull_request(
198 &self,
199 owner: &str,
200 repo: &str,
201 pull_number: u64,
202 ) -> Result<GitHubPullRequest> {
203 let client = self.get_client().await?;
204 let (owner, repo) = self.config.get_repository_ref(Some(owner), Some(repo))?;
205
206 let pr = client
207 .pulls(&owner, &repo)
208 .get(pull_number)
209 .await
210 .map_err(|e| {
211 let err = self.map_error(e, &owner, &repo);
212 if matches!(err, GitHubError::RepositoryNotFound { .. }) {
213 GitHubError::PullRequestNotFound {
214 pull_number,
215 owner: owner.clone(),
216 repo: repo.clone(),
217 }
218 } else {
219 err
220 }
221 })?;
222
223 Ok(self.map_pull_request(pr))
224 }
225
226 pub async fn list_pull_requests(
227 &self,
228 params: ListPullRequestsParams,
229 ) -> Result<Vec<GitHubPullRequest>> {
230 let client = self.get_client().await?;
231 let (owner, repo) = self
232 .config
233 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
234
235 let state = match params.state {
236 PullRequestStateFilter::Open => octocrab::params::State::Open,
237 PullRequestStateFilter::Closed => octocrab::params::State::Closed,
238 PullRequestStateFilter::All => octocrab::params::State::All,
239 };
240
241 let page = client
242 .pulls(&owner, &repo)
243 .list()
244 .state(state)
245 .per_page(params.per_page)
246 .page(params.page)
247 .send()
248 .await
249 .map_err(|e| self.map_error(e, &owner, &repo))?;
250
251 let prs: Vec<GitHubPullRequest> = page
252 .items
253 .into_iter()
254 .map(|pr| self.map_pull_request(pr))
255 .collect();
256
257 Ok(prs)
258 }
259
260 pub async fn merge_pull_request(
261 &self,
262 params: MergePullRequestParams,
263 ) -> Result<(String, bool, String)> {
264 let client = self.get_client().await?;
265 let (owner, repo) = self
266 .config
267 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
268
269 let method = match params.merge_method {
270 MergeMethod::Merge => octocrab::params::pulls::MergeMethod::Merge,
271 MergeMethod::Squash => octocrab::params::pulls::MergeMethod::Squash,
272 MergeMethod::Rebase => octocrab::params::pulls::MergeMethod::Rebase,
273 };
274
275 let result = client
276 .pulls(&owner, &repo)
277 .merge(params.pull_number)
278 .method(method)
279 .send()
280 .await
281 .map_err(|e| {
282 let err = self.map_error(e, &owner, &repo);
283 if let GitHubError::ApiError { status: 405, .. } = err {
284 GitHubError::MergeConflict {
285 pull_number: params.pull_number,
286 owner: owner.clone(),
287 repo: repo.clone(),
288 }
289 } else {
290 err
291 }
292 })?;
293
294 Ok((
295 result.sha.unwrap_or_default(),
296 result.merged,
297 result.message.unwrap_or_default(),
298 ))
299 }
300
301 pub async fn create_branch(&self, params: CreateBranchParams) -> Result<GitHubBranch> {
302 let client = self.get_client().await?;
303 let (owner, repo) = self
304 .config
305 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
306
307 let sha = if params.from_ref.len() == 40
308 && params.from_ref.chars().all(|c| c.is_ascii_hexdigit())
309 {
310 params.from_ref.clone()
311 } else {
312 let source_ref = client
313 .repos(&owner, &repo)
314 .get_ref(&octocrab::params::repos::Reference::Branch(
315 params.from_ref.clone(),
316 ))
317 .await
318 .map_err(|e| self.map_error(e, &owner, &repo))?;
319
320 match &source_ref.object {
321 octocrab::models::repos::Object::Commit { sha, .. } => sha.clone(),
322 octocrab::models::repos::Object::Tag { sha, .. } => sha.clone(),
323 _ => {
324 return Err(GitHubError::BranchNotFound {
325 branch: params.from_ref.clone(),
326 owner: owner.clone(),
327 repo: repo.clone(),
328 });
329 }
330 }
331 };
332
333 client
334 .repos(&owner, &repo)
335 .create_ref(
336 &octocrab::params::repos::Reference::Branch(params.branch_name.clone()),
337 &sha,
338 )
339 .await
340 .map_err(|e| {
341 let err = self.map_error(e, &owner, &repo);
342 if err.to_string().contains("already exists") {
343 GitHubError::BranchExists {
344 branch: params.branch_name.clone(),
345 owner: owner.clone(),
346 repo: repo.clone(),
347 }
348 } else {
349 err
350 }
351 })?;
352
353 Ok(GitHubBranch {
354 name: params.branch_name,
355 sha,
356 protected: false,
357 })
358 }
359
360 pub async fn delete_branch(&self, owner: &str, repo: &str, branch_name: &str) -> Result<()> {
362 let client = self.get_client().await?;
363 let (owner, repo) = self.config.get_repository_ref(Some(owner), Some(repo))?;
364
365 client
366 .repos(&owner, &repo)
367 .delete_ref(&octocrab::params::repos::Reference::Branch(
368 branch_name.to_string(),
369 ))
370 .await
371 .map_err(|e| {
372 let err = self.map_error(e, &owner, &repo);
373 if matches!(err, GitHubError::RepositoryNotFound { .. }) {
374 GitHubError::BranchNotFound {
375 branch: branch_name.to_string(),
376 owner: owner.clone(),
377 repo: repo.clone(),
378 }
379 } else {
380 err
381 }
382 })?;
383
384 Ok(())
385 }
386
387 pub async fn create_commit(&self, params: CreateCommitParams) -> Result<GitHubCommit> {
388 let client = self.get_client().await?;
389 let (owner, repo) = self
390 .config
391 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
392
393 let branch_ref = client
394 .repos(&owner, &repo)
395 .get_ref(&octocrab::params::repos::Reference::Branch(
396 params.branch.clone(),
397 ))
398 .await
399 .map_err(|e| self.map_error(e, &owner, &repo))?;
400
401 let parent_sha = match &branch_ref.object {
402 octocrab::models::repos::Object::Commit { sha, .. } => sha.clone(),
403 octocrab::models::repos::Object::Tag { sha, .. } => sha.clone(),
404 _ => {
405 return Err(GitHubError::BranchNotFound {
406 branch: params.branch.clone(),
407 owner: owner.clone(),
408 repo: repo.clone(),
409 })
410 }
411 };
412
413 let commit = GitHubCommit {
414 sha: parent_sha.clone(),
415 message: params.message.clone(),
416 author: GitHubCommitAuthor {
417 name: params
418 .author_name
419 .unwrap_or_else(|| "elizaos-bot".to_string()),
420 email: params
421 .author_email
422 .unwrap_or_else(|| "bot@elizaos.ai".to_string()),
423 date: chrono::Utc::now().to_rfc3339(),
424 },
425 committer: GitHubCommitAuthor {
426 name: "elizaos-bot".to_string(),
427 email: "bot@elizaos.ai".to_string(),
428 date: chrono::Utc::now().to_rfc3339(),
429 },
430 timestamp: chrono::Utc::now().to_rfc3339(),
431 html_url: format!(
432 "https://github.com/{}/{}/commit/{}",
433 owner, repo, parent_sha
434 ),
435 parents: vec![parent_sha],
436 };
437
438 Ok(commit)
439 }
440
441 pub async fn create_review(&self, params: CreateReviewParams) -> Result<GitHubReview> {
442 let client = self.get_client().await?;
443 let (owner, repo) = self
444 .config
445 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
446
447 let event = match params.event {
448 ReviewEvent::Approve => "APPROVE",
449 ReviewEvent::RequestChanges => "REQUEST_CHANGES",
450 ReviewEvent::Comment => "COMMENT",
451 };
452
453 let review: serde_json::Value = client
454 .post::<serde_json::Value, _>(
455 format!(
456 "/repos/{}/{}/pulls/{}/reviews",
457 owner, repo, params.pull_number
458 ),
459 Some(&serde_json::json!({
460 "body": params.body,
461 "event": event,
462 })),
463 )
464 .await
465 .map_err(|e| self.map_error(e, &owner, &repo))?;
466
467 let state = review
468 .get("state")
469 .and_then(serde_json::Value::as_str)
470 .unwrap_or("COMMENTED")
471 .to_string();
472
473 Ok(GitHubReview {
474 id: review
475 .get("id")
476 .and_then(serde_json::Value::as_u64)
477 .unwrap_or(0),
478 user: GitHubUser {
479 id: review
480 .get("user")
481 .and_then(|u| u.get("id"))
482 .and_then(serde_json::Value::as_u64)
483 .unwrap_or(0),
484 login: review
485 .get("user")
486 .and_then(|u| u.get("login"))
487 .and_then(serde_json::Value::as_str)
488 .unwrap_or("unknown")
489 .to_string(),
490 name: None,
491 avatar_url: review
492 .get("user")
493 .and_then(|u| u.get("avatar_url"))
494 .and_then(serde_json::Value::as_str)
495 .unwrap_or("")
496 .to_string(),
497 html_url: review
498 .get("user")
499 .and_then(|u| u.get("html_url"))
500 .and_then(serde_json::Value::as_str)
501 .unwrap_or("")
502 .to_string(),
503 user_type: UserType::User,
504 },
505 body: review
506 .get("body")
507 .and_then(serde_json::Value::as_str)
508 .map(|s| s.to_string()),
509 state: match state.as_str() {
510 "APPROVED" => ReviewState::Approved,
511 "CHANGES_REQUESTED" => ReviewState::ChangesRequested,
512 "DISMISSED" => ReviewState::Dismissed,
513 "PENDING" => ReviewState::Pending,
514 _ => ReviewState::Commented,
515 },
516 commit_id: review
517 .get("commit_id")
518 .and_then(serde_json::Value::as_str)
519 .unwrap_or("")
520 .to_string(),
521 html_url: review
522 .get("html_url")
523 .and_then(serde_json::Value::as_str)
524 .unwrap_or("")
525 .to_string(),
526 submitted_at: review
527 .get("submitted_at")
528 .and_then(serde_json::Value::as_str)
529 .map(|s| s.to_string()),
530 })
531 }
532
533 pub async fn create_comment(&self, params: CreateCommentParams) -> Result<GitHubComment> {
534 let client = self.get_client().await?;
535 let (owner, repo) = self
536 .config
537 .get_repository_ref(Some(¶ms.owner), Some(¶ms.repo))?;
538
539 let comment = client
540 .issues(&owner, &repo)
541 .create_comment(params.issue_number, ¶ms.body)
542 .await
543 .map_err(|e| self.map_error(e, &owner, &repo))?;
544
545 Ok(GitHubComment {
546 id: comment.id.into_inner(),
547 body: comment.body.unwrap_or_default(),
548 user: self.map_user(comment.user),
549 created_at: comment.created_at.to_rfc3339(),
550 updated_at: comment
551 .updated_at
552 .map(|t| t.to_rfc3339())
553 .unwrap_or_default(),
554 html_url: comment.html_url.to_string(),
555 })
556 }
557
558 pub async fn get_authenticated_user(&self) -> Result<GitHubUser> {
559 let client = self.get_client().await?;
560
561 let user = client
562 .current()
563 .user()
564 .await
565 .map_err(|e| self.map_error(e, "", ""))?;
566
567 Ok(self.map_user(user))
568 }
569
570 fn map_error(&self, e: octocrab::Error, owner: &str, repo: &str) -> GitHubError {
571 match e {
572 octocrab::Error::GitHub { source, .. } => {
573 let status = source.status_code.as_u16();
574 let message = source.message;
575
576 match status {
577 401 => GitHubError::PermissionDenied(
578 "Invalid or missing authentication token".to_string(),
579 ),
580 403 => GitHubError::PermissionDenied(message),
581 404 => GitHubError::RepositoryNotFound {
582 owner: owner.to_string(),
583 repo: repo.to_string(),
584 },
585 422 => GitHubError::ValidationFailed {
586 field: "unknown".to_string(),
587 reason: message,
588 },
589 _ => GitHubError::ApiError {
590 status,
591 message,
592 code: None,
593 documentation_url: source.documentation_url,
594 },
595 }
596 }
597 _ => GitHubError::Internal(e.to_string()),
598 }
599 }
600
601 fn map_repository(&self, repo: octocrab::models::Repository) -> GitHubRepository {
602 GitHubRepository {
603 id: repo.id.into_inner(),
604 name: repo.name,
605 full_name: repo.full_name.unwrap_or_default(),
606 owner: self.map_user(repo.owner.unwrap()),
607 description: repo.description,
608 private: repo.private.unwrap_or(false),
609 fork: repo.fork.unwrap_or(false),
610 default_branch: repo.default_branch.unwrap_or_else(|| "main".to_string()),
611 language: repo.language.map(|v| v.to_string()),
612 stargazers_count: repo.stargazers_count.unwrap_or(0),
613 forks_count: repo.forks_count.unwrap_or(0),
614 open_issues_count: repo.open_issues_count.unwrap_or(0),
615 watchers_count: repo.watchers_count.unwrap_or(0),
616 html_url: repo.html_url.map(|u| u.to_string()).unwrap_or_default(),
617 clone_url: repo.clone_url.map(|u| u.to_string()).unwrap_or_default(),
618 ssh_url: repo.ssh_url.unwrap_or_default(),
619 created_at: repo.created_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
620 updated_at: repo.updated_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
621 pushed_at: repo.pushed_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
622 topics: repo.topics.unwrap_or_default(),
623 license: repo.license.map(|l| GitHubLicense {
624 key: l.key,
625 name: l.name,
626 spdx_id: Some(l.spdx_id),
627 url: l.url.map(|u| u.to_string()),
628 }),
629 }
630 }
631
632 fn map_user(&self, user: octocrab::models::Author) -> GitHubUser {
633 GitHubUser {
634 id: user.id.into_inner(),
635 login: user.login,
636 name: None,
637 avatar_url: user.avatar_url.to_string(),
638 html_url: user.html_url.to_string(),
639 user_type: UserType::User,
640 }
641 }
642
643 fn map_issue(&self, issue: octocrab::models::issues::Issue) -> GitHubIssue {
644 GitHubIssue {
645 number: issue.number,
646 title: issue.title,
647 body: issue.body,
648 state: match issue.state {
649 octocrab::models::IssueState::Open => IssueState::Open,
650 octocrab::models::IssueState::Closed => IssueState::Closed,
651 _ => IssueState::Open,
652 },
653 state_reason: None,
654 user: self.map_user(issue.user),
655 assignees: issue
656 .assignees
657 .into_iter()
658 .map(|a| self.map_user(a))
659 .collect(),
660 labels: issue
661 .labels
662 .into_iter()
663 .map(|l| GitHubLabel {
664 id: *l.id,
665 name: l.name,
666 color: l.color,
667 description: l.description,
668 default: l.default,
669 })
670 .collect(),
671 milestone: None,
672 created_at: issue.created_at.to_rfc3339(),
673 updated_at: issue.updated_at.to_rfc3339(),
674 closed_at: issue.closed_at.map(|t| t.to_rfc3339()),
675 html_url: issue.html_url.to_string(),
676 comments: issue.comments,
677 is_pull_request: issue.pull_request.is_some(),
678 }
679 }
680
681 fn map_pull_request(&self, pr: octocrab::models::pulls::PullRequest) -> GitHubPullRequest {
682 GitHubPullRequest {
683 number: pr.number,
684 title: pr.title.unwrap_or_default(),
685 body: pr.body,
686 state: match pr.state {
687 Some(octocrab::models::IssueState::Open) => PullRequestState::Open,
688 Some(octocrab::models::IssueState::Closed) => PullRequestState::Closed,
689 _ => PullRequestState::Open,
690 },
691 draft: pr.draft.unwrap_or(false),
692 merged: pr.merged_at.is_some(),
693 mergeable: pr.mergeable,
694 mergeable_state: MergeableState::Unknown,
695 user: pr
696 .user
697 .map(|u| self.map_user(*u))
698 .unwrap_or_else(|| GitHubUser {
699 id: 0,
700 login: "unknown".to_string(),
701 name: None,
702 avatar_url: String::new(),
703 html_url: String::new(),
704 user_type: UserType::User,
705 }),
706 head: GitHubBranchRef {
707 branch_ref: pr.head.ref_field,
708 label: pr.head.label.unwrap_or_default(),
709 sha: pr.head.sha,
710 repo: pr.head.repo.map(|r| RepositoryRef {
711 owner: r.owner.map(|o| o.login).unwrap_or_default(),
712 repo: r.name,
713 }),
714 },
715 base: GitHubBranchRef {
716 branch_ref: pr.base.ref_field,
717 label: pr.base.label.unwrap_or_default(),
718 sha: pr.base.sha,
719 repo: pr.base.repo.map(|r| RepositoryRef {
720 owner: r.owner.map(|o| o.login).unwrap_or_default(),
721 repo: r.name,
722 }),
723 },
724 assignees: pr
725 .assignees
726 .unwrap_or_default()
727 .into_iter()
728 .map(|a| self.map_user(a))
729 .collect(),
730 requested_reviewers: pr
731 .requested_reviewers
732 .unwrap_or_default()
733 .into_iter()
734 .map(|r| self.map_user(r))
735 .collect(),
736 labels: pr
737 .labels
738 .unwrap_or_default()
739 .into_iter()
740 .map(|l| GitHubLabel {
741 id: l.id.into_inner(),
742 name: l.name,
743 color: l.color,
744 description: l.description,
745 default: l.default,
746 })
747 .collect(),
748 milestone: None,
749 created_at: pr.created_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
750 updated_at: pr.updated_at.map(|t| t.to_rfc3339()).unwrap_or_default(),
751 closed_at: pr.closed_at.map(|t| t.to_rfc3339()),
752 merged_at: pr.merged_at.map(|t| t.to_rfc3339()),
753 html_url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
754 commits: pr.commits.unwrap_or(0) as u32,
755 additions: pr.additions.unwrap_or(0) as u32,
756 deletions: pr.deletions.unwrap_or(0) as u32,
757 changed_files: pr.changed_files.unwrap_or(0) as u32,
758 }
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn test_service_creation() {
768 let config = GitHubConfig::new("test_token".to_string());
769 let service = GitHubService::new(config);
770 assert_eq!(service.config().api_token, "test_token");
771 }
772
773 #[tokio::test]
774 async fn test_service_not_running() {
775 let config = GitHubConfig::new("test_token".to_string());
776 let service = GitHubService::new(config);
777 assert!(!service.is_running().await);
778 }
779}