Skip to main content

ssot_sync_github/
lib.rs

1#![forbid(unsafe_code)]
2//! # ssot-sync-github
3//!
4//! GitHub Issues adapter for the SSOT sync ecosystem.
5//!
6//! Maps SSOT tasks to GitHub Issues and vice versa via the REST API v3.
7//! Implements [`ssot_sync::SyncAdapter`] so [`ssot_sync::SyncEngine`] can
8//! register it alongside other provider adapters.
9
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use ssot_client::http::parse_http_error;
13use ssot_protocol::models::{
14    ExternalEvent, Resolution, SsotEvent, SyncRecord, SyncResult, SyncStatus,
15};
16use ssot_sync::error::SdkError;
17use ssot_sync::SyncAdapter;
18
19/// Provider identifier used as the `sync_state.provider` primary key.
20///
21/// Downstream callers should prefer this constant over the `"github"`
22/// magic string when interacting with the sync engine registry.
23pub const PROVIDER: &str = "github";
24
25/// GitHub Issues sync adapter.
26///
27/// Connects SSOT tasks to GitHub Issues in a specific repository.
28pub struct GitHubAdapter {
29    client: reqwest::Client,
30    token: String,
31    owner: String,
32    repo: String,
33}
34
35/// A GitHub issue as returned by the REST API.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GitHubIssue {
38    /// Issue number.
39    pub number: u64,
40    /// Issue title.
41    pub title: String,
42    /// Issue body (nullable).
43    pub body: Option<String>,
44    /// Issue state ("open" or "closed").
45    pub state: String,
46    /// Labels applied to the issue.
47    #[serde(default)]
48    pub labels: Vec<GitHubLabel>,
49}
50
51/// A GitHub label.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct GitHubLabel {
54    /// Label name.
55    pub name: String,
56}
57
58/// Input for updating a GitHub issue.
59#[derive(Debug, Default, Serialize)]
60pub struct GitHubIssueUpdate {
61    /// New title.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub title: Option<String>,
64    /// New body.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub body: Option<String>,
67    /// New state ("open" or "closed").
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub state: Option<String>,
70    /// New labels.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub labels: Option<Vec<String>>,
73}
74
75impl GitHubAdapter {
76    /// Create a new GitHub adapter for a specific repository.
77    pub fn new(token: &str, owner: &str, repo: &str) -> Self {
78        let client = reqwest::Client::builder()
79            .user_agent("nott-ssot-sdk/0.1")
80            .build()
81            .unwrap_or_else(|_| reqwest::Client::new());
82
83        Self {
84            client,
85            token: token.to_string(),
86            owner: owner.to_string(),
87            repo: repo.to_string(),
88        }
89    }
90
91    /// Base URL for the GitHub API.
92    fn api_base(&self) -> String {
93        format!("https://api.github.com/repos/{}/{}", self.owner, self.repo)
94    }
95
96    /// Build headers for GitHub API requests.
97    fn auth_headers(&self) -> reqwest::header::HeaderMap {
98        let mut headers = reqwest::header::HeaderMap::new();
99        if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token)) {
100            headers.insert(reqwest::header::AUTHORIZATION, val);
101        }
102        if let Ok(val) = reqwest::header::HeaderValue::from_str("application/vnd.github+json") {
103            headers.insert(reqwest::header::ACCEPT, val);
104        }
105        headers
106    }
107
108    /// Create an issue in GitHub.
109    pub async fn create_issue(
110        &self,
111        title: &str,
112        body: Option<&str>,
113        labels: &[String],
114    ) -> Result<GitHubIssue, SdkError> {
115        let mut payload = json!({ "title": title });
116        if let Some(b) = body {
117            payload["body"] = json!(b);
118        }
119        if !labels.is_empty() {
120            payload["labels"] = json!(labels);
121        }
122
123        let resp = self
124            .client
125            .post(format!("{}/issues", self.api_base()))
126            .headers(self.auth_headers())
127            .json(&payload)
128            .send()
129            .await?;
130
131        self.parse_issue_response(resp).await
132    }
133
134    /// Update an existing issue.
135    pub async fn update_issue(
136        &self,
137        number: u64,
138        update: GitHubIssueUpdate,
139    ) -> Result<GitHubIssue, SdkError> {
140        let resp = self
141            .client
142            .patch(format!("{}/issues/{}", self.api_base(), number))
143            .headers(self.auth_headers())
144            .json(&update)
145            .send()
146            .await?;
147
148        self.parse_issue_response(resp).await
149    }
150
151    /// List issues from the repository.
152    pub async fn list_issues(&self, state: &str) -> Result<Vec<GitHubIssue>, SdkError> {
153        let resp = self
154            .client
155            .get(format!("{}/issues", self.api_base()))
156            .headers(self.auth_headers())
157            .query(&[("state", state), ("per_page", "100")])
158            .send()
159            .await?;
160
161        if !resp.status().is_success() {
162            return Err(parse_http_error(resp).await.into());
163        }
164
165        let issues: Vec<GitHubIssue> = resp.json().await?;
166        Ok(issues)
167    }
168
169    /// Get a specific issue by number.
170    pub async fn get_issue(&self, number: u64) -> Result<GitHubIssue, SdkError> {
171        let resp = self
172            .client
173            .get(format!("{}/issues/{}", self.api_base(), number))
174            .headers(self.auth_headers())
175            .send()
176            .await?;
177
178        self.parse_issue_response(resp).await
179    }
180
181    /// Parse a single-issue API response.
182    async fn parse_issue_response(&self, resp: reqwest::Response) -> Result<GitHubIssue, SdkError> {
183        if !resp.status().is_success() {
184            return Err(parse_http_error(resp).await.into());
185        }
186        let issue: GitHubIssue = resp.json().await?;
187        Ok(issue)
188    }
189}
190
191/// Map SSOT task status to GitHub issue state.
192fn task_status_to_gh_state(status: &str) -> &str {
193    match status {
194        "done" => "closed",
195        _ => "open", // open, doing -> open
196    }
197}
198
199/// Map GitHub issue state to SSOT task status.
200fn gh_state_to_task_status(state: &str) -> &str {
201    match state {
202        "closed" => "done",
203        _ => "open",
204    }
205}
206
207#[async_trait::async_trait]
208impl SyncAdapter for GitHubAdapter {
209    fn provider(&self) -> &str {
210        PROVIDER
211    }
212
213    async fn push(&self, events: &[SsotEvent]) -> Result<Vec<SyncResult>, SdkError> {
214        let mut results = Vec::new();
215
216        for event in events {
217            if event.entity_type != "task" {
218                continue;
219            }
220
221            let title = event.data["title"].as_str().unwrap_or("Untitled");
222            let body = event.data["body"].as_str();
223            let tags: Vec<String> = event.data["tags"]
224                .as_array()
225                .map(|arr| {
226                    arr.iter()
227                        .filter_map(|v| v.as_str().map(String::from))
228                        .collect()
229                })
230                .unwrap_or_default();
231            let status = event.data["status"].as_str().unwrap_or("open");
232
233            match event.action.as_str() {
234                "create" => {
235                    let issue = self.create_issue(title, body, &tags).await?;
236                    results.push(SyncResult {
237                        local_id: event.entity_id.clone(),
238                        remote_id: issue.number.to_string(),
239                        status: SyncStatus::Synced,
240                    });
241                }
242                "update" => {
243                    // Remote ID should be in the event data for updates
244                    if let Some(remote_id) = event.data["remote_id"].as_str() {
245                        if let Ok(number) = remote_id.parse::<u64>() {
246                            let update = GitHubIssueUpdate {
247                                title: Some(title.to_string()),
248                                body: body.map(String::from),
249                                state: Some(task_status_to_gh_state(status).to_string()),
250                                labels: if tags.is_empty() { None } else { Some(tags) },
251                            };
252                            let issue = self.update_issue(number, update).await?;
253                            results.push(SyncResult {
254                                local_id: event.entity_id.clone(),
255                                remote_id: issue.number.to_string(),
256                                status: SyncStatus::Synced,
257                            });
258                        }
259                    }
260                }
261                _ => {}
262            }
263        }
264
265        Ok(results)
266    }
267
268    async fn pull(&self) -> Result<Vec<ExternalEvent>, SdkError> {
269        let issues = self.list_issues("all").await?;
270        let events: Vec<ExternalEvent> = issues
271            .into_iter()
272            .map(|issue| {
273                let labels: Vec<String> = issue.labels.iter().map(|l| l.name.clone()).collect();
274                ExternalEvent {
275                    provider: PROVIDER.into(),
276                    remote_id: issue.number.to_string(),
277                    entity_type: "task".into(),
278                    action: "sync".into(),
279                    data: json!({
280                        "title": issue.title,
281                        "body": issue.body,
282                        "status": gh_state_to_task_status(&issue.state),
283                        "tags": labels,
284                    }),
285                }
286            })
287            .collect();
288
289        Ok(events)
290    }
291
292    async fn resolve_conflict(
293        &self,
294        _local: &SyncRecord,
295        _remote: &SyncRecord,
296    ) -> Result<Resolution, SdkError> {
297        // Default: remote (GitHub) wins
298        Ok(Resolution::KeepRemote)
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn task_status_mapping() {
308        assert_eq!(task_status_to_gh_state("open"), "open");
309        assert_eq!(task_status_to_gh_state("doing"), "open");
310        assert_eq!(task_status_to_gh_state("done"), "closed");
311    }
312
313    #[test]
314    fn gh_state_mapping() {
315        assert_eq!(gh_state_to_task_status("open"), "open");
316        assert_eq!(gh_state_to_task_status("closed"), "done");
317    }
318
319    #[test]
320    fn github_issue_deserialization() {
321        let json = serde_json::json!({
322            "number": 42,
323            "title": "Fix the thing",
324            "body": "It is broken",
325            "state": "open",
326            "labels": [{"name": "bug"}],
327        });
328
329        let issue: Result<GitHubIssue, _> = serde_json::from_value(json);
330        assert!(issue.is_ok());
331        let i = issue.as_ref().ok();
332        assert_eq!(i.map(|i| i.number), Some(42));
333        assert_eq!(i.map(|i| i.title.as_str()), Some("Fix the thing"));
334        assert_eq!(
335            i.and_then(|i| i.labels.first()).map(|l| l.name.as_str()),
336            Some("bug")
337        );
338    }
339
340    #[test]
341    fn github_issue_update_serialization() {
342        let update = GitHubIssueUpdate {
343            title: Some("New title".into()),
344            body: None,
345            state: Some("closed".into()),
346            labels: None,
347        };
348
349        let json = serde_json::to_value(&update).ok();
350        assert!(json.is_some());
351        let v = json.as_ref();
352        assert_eq!(
353            v.and_then(|j| j.get("title")).and_then(|t| t.as_str()),
354            Some("New title")
355        );
356        // body is None and should be skipped
357        assert!(v.and_then(|j| j.get("body")).is_none());
358    }
359
360    #[test]
361    fn adapter_provider_name() {
362        let adapter = GitHubAdapter::new("tok", "owner", "repo");
363        assert_eq!(adapter.provider(), PROVIDER);
364        assert_eq!(PROVIDER, "github");
365    }
366}