Skip to main content

elizaos_plugin_github/
service.rs

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(&params.owner), Some(&params.repo))?;
89
90        let issues_handler = client.issues(&owner, &repo);
91        let mut builder = issues_handler.create(&params.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(&params.owner), Some(&params.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(&params.owner), Some(&params.repo))?;
184
185        let pr = client
186            .pulls(&owner, &repo)
187            .create(&params.title, &params.head, &params.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(&params.owner), Some(&params.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(&params.owner), Some(&params.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(&params.owner), Some(&params.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    /// Delete a branch.
361    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(&params.owner), Some(&params.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(&params.owner), Some(&params.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(&params.owner), Some(&params.repo))?;
538
539        let comment = client
540            .issues(&owner, &repo)
541            .create_comment(params.issue_number, &params.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}