1#![forbid(unsafe_code)]
2use 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
19pub const PROVIDER: &str = "github";
24
25pub struct GitHubAdapter {
29 client: reqwest::Client,
30 token: String,
31 owner: String,
32 repo: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GitHubIssue {
38 pub number: u64,
40 pub title: String,
42 pub body: Option<String>,
44 pub state: String,
46 #[serde(default)]
48 pub labels: Vec<GitHubLabel>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct GitHubLabel {
54 pub name: String,
56}
57
58#[derive(Debug, Default, Serialize)]
60pub struct GitHubIssueUpdate {
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub title: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub body: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub state: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub labels: Option<Vec<String>>,
73}
74
75impl GitHubAdapter {
76 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 fn api_base(&self) -> String {
93 format!("https://api.github.com/repos/{}/{}", self.owner, self.repo)
94 }
95
96 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 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 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 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 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 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
191fn task_status_to_gh_state(status: &str) -> &str {
193 match status {
194 "done" => "closed",
195 _ => "open", }
197}
198
199fn 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 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 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 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}