main
rs 303 lines 9.4 KB
Raw
1 //! Wire types for the sigit.si API.
2 //!
3 //! Field names and nullability mirror the Rails serializers in
4 //! `app/controllers/api/v1/` and the MCP tool results in
5 //! `app/services/mcp/tools.rb`. Every struct is `deny_unknown_fields`-free on
6 //! purpose: the server may add fields, and an older `si` should keep working.
7
8 use {
9 chrono::{DateTime, Utc},
10 serde::{Deserialize, Serialize},
11 };
12
13 // ── Account ─────────────────────────────────────────────────────────────────
14
15 /// `GET /api/v1/user`
16 #[derive(Debug, Clone, Deserialize, Serialize)]
17 pub struct User {
18 pub id: i64,
19 pub username: String,
20 pub display_name: Option<String>,
21 pub avatar_url: Option<String>,
22 pub email: String,
23 pub created_at: DateTime<Utc>,
24 }
25
26 /// The result of `POST /api/v1/auth/sign_in`, matching the desktop app's
27 /// `AccountStatus` enum: the bare string `"NotFound"`, `{"Ready":{…}}`, or
28 /// `{"Incomplete":{…}}`.
29 #[derive(Debug, Clone, Deserialize)]
30 pub enum AccountStatus {
31 NotFound,
32 Ready { access_token: String },
33 Incomplete { status: u32 },
34 }
35
36 // ── Repositories ────────────────────────────────────────────────────────────
37
38 /// `GET /api/v1/repos`, `POST /api/v1/repos`
39 #[derive(Debug, Clone, Deserialize, Serialize)]
40 pub struct Repository {
41 pub id: i64,
42 pub name: String,
43 pub full_name: String,
44 pub description: Option<String>,
45 pub default_branch: String,
46 pub kind: Option<String>,
47 pub is_private: bool,
48 pub stars_count: i64,
49 pub initialized: bool,
50 pub updated_at: DateTime<Utc>,
51 pub web_url: String,
52 pub clone_url: String,
53 }
54
55 impl Repository {
56 pub fn visibility(&self) -> &'static str {
57 if self.is_private {
58 "private"
59 } else {
60 "public"
61 }
62 }
63 }
64
65 // ── Webhooks ────────────────────────────────────────────────────────────────
66
67 /// `GET/POST /api/v1/repos/:owner/:repo/hooks`
68 #[derive(Debug, Clone, Deserialize, Serialize)]
69 pub struct Hook {
70 pub id: i64,
71 pub url: String,
72 pub events: Vec<String>,
73 pub active: bool,
74 pub last_status_code: Option<i32>,
75 pub last_delivered_at: Option<DateTime<Utc>>,
76 pub created_at: DateTime<Utc>,
77 /// Returned exactly once, by `POST`. It is not retrievable afterwards.
78 #[serde(default)]
79 pub secret: Option<String>,
80 }
81
82 // ── Cloud Sessions ──────────────────────────────────────────────────────────
83
84 /// `GET /api/v1/sessions`, `GET /api/v1/sessions/:id`
85 #[derive(Debug, Clone, Deserialize, Serialize)]
86 pub struct CloudSession {
87 pub id: i64,
88 pub title: Option<String>,
89 pub model: Option<String>,
90 pub last_message_at: Option<DateTime<Utc>>,
91 pub created_at: DateTime<Utc>,
92 pub updated_at: DateTime<Utc>,
93 /// Only present on the single-session `show` response.
94 #[serde(default)]
95 pub messages: Option<Vec<CloudMessage>>,
96 }
97
98 #[derive(Debug, Clone, Deserialize, Serialize)]
99 pub struct CloudMessage {
100 pub id: i64,
101 pub role: String,
102 pub content: String,
103 pub created_at: DateTime<Utc>,
104 }
105
106 /// `POST /api/v1/sessions/:id/messages`
107 #[derive(Debug, Clone, Deserialize, Serialize)]
108 pub struct AppendedMessage {
109 pub session: CloudSession,
110 pub message: CloudMessage,
111 }
112
113 // ── Billing ─────────────────────────────────────────────────────────────────
114
115 /// `GET /api/v1/billing`
116 #[derive(Debug, Clone, Deserialize, Serialize)]
117 pub struct Billing {
118 pub plan: String,
119 pub status: String,
120 pub entitled_to_cloud: bool,
121 pub cloud_requests_used: i64,
122 pub cloud_allowance: i64,
123 pub current_period_end: Option<DateTime<Utc>>,
124 }
125
126 /// `POST /api/v1/billing/checkout`, `POST /api/v1/billing/portal`
127 #[derive(Debug, Clone, Deserialize)]
128 pub struct BillingUrl {
129 pub url: String,
130 }
131
132 // ── Git credentials ─────────────────────────────────────────────────────────
133
134 /// `POST /api/v1/git_credentials` — a short-lived, read-scoped git password.
135 #[derive(Debug, Clone, Deserialize)]
136 pub struct GitCredential {
137 pub git_token: String,
138 pub username: String,
139 pub expires_at: DateTime<Utc>,
140 }
141
142 // ── MCP-backed resources ────────────────────────────────────────────────────
143 //
144 // Issues, pull requests, and code search have no REST surface; they are served
145 // by the MCP tools at `POST /api/v1/mcp`. These are the tool result shapes.
146
147 #[derive(Debug, Clone, Deserialize, Serialize)]
148 pub struct RepositorySummary {
149 pub full_name: String,
150 pub description: Option<String>,
151 pub default_branch: String,
152 pub private: bool,
153 pub url: String,
154 }
155
156 #[derive(Debug, Clone, Deserialize, Serialize)]
157 pub struct FileContents {
158 pub repo: String,
159 pub path: String,
160 #[serde(rename = "ref")]
161 pub git_ref: String,
162 pub size: i64,
163 pub content: String,
164 }
165
166 #[derive(Debug, Clone, Deserialize, Serialize)]
167 pub struct CodeHit {
168 pub path: String,
169 pub line: i64,
170 pub snippet: String,
171 }
172
173 #[derive(Debug, Clone, Deserialize, Serialize)]
174 pub struct Comment {
175 pub author: String,
176 pub body: String,
177 pub created_at: DateTime<Utc>,
178 }
179
180 #[derive(Debug, Clone, Deserialize, Serialize)]
181 pub struct IssueSummary {
182 pub number: i64,
183 pub title: String,
184 pub state: String,
185 pub author: String,
186 pub url: String,
187 }
188
189 #[derive(Debug, Clone, Deserialize, Serialize)]
190 pub struct Issue {
191 pub number: i64,
192 pub title: String,
193 pub state: String,
194 pub author: String,
195 pub body: Option<String>,
196 pub url: String,
197 #[serde(default)]
198 pub comments: Vec<Comment>,
199 }
200
201 #[derive(Debug, Clone, Deserialize, Serialize)]
202 pub struct PullRequestSummary {
203 pub number: i64,
204 pub title: String,
205 pub state: String,
206 pub head: String,
207 pub base: String,
208 pub url: String,
209 }
210
211 #[derive(Debug, Clone, Deserialize, Serialize)]
212 pub struct PullRequest {
213 pub number: i64,
214 pub title: String,
215 pub state: String,
216 pub author: String,
217 pub head: String,
218 pub base: String,
219 pub body: Option<String>,
220 pub url: String,
221 #[serde(default)]
222 pub comments: Vec<Comment>,
223 /// `None` when a branch no longer exists; see `diff_note`.
224 #[serde(default)]
225 pub diff: Option<String>,
226 #[serde(default)]
227 pub diff_truncated: bool,
228 #[serde(default)]
229 pub diff_note: Option<String>,
230 }
231
232 /// What `create_issue` / `create_pull_request` hand back.
233 #[derive(Debug, Clone, Deserialize, Serialize)]
234 pub struct CreatedRef {
235 pub number: i64,
236 pub state: String,
237 pub url: String,
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 #[test]
245 fn account_status_reads_the_bare_not_found_string() {
246 let status: AccountStatus = serde_json::from_str(r#""NotFound""#).unwrap();
247 assert!(matches!(status, AccountStatus::NotFound));
248 }
249
250 #[test]
251 fn account_status_reads_the_ready_envelope() {
252 let status: AccountStatus =
253 serde_json::from_str(r#"{"Ready":{"access_token":"abc123"}}"#).unwrap();
254 match status {
255 AccountStatus::Ready { access_token } => assert_eq!(access_token, "abc123"),
256 other => panic!("expected Ready, got {other:?}"),
257 }
258 }
259
260 #[test]
261 fn account_status_reads_the_incomplete_envelope() {
262 let status: AccountStatus =
263 serde_json::from_str(r#"{"Incomplete":{"status":1001}}"#).unwrap();
264 match status {
265 AccountStatus::Incomplete { status } => assert_eq!(status, 1001),
266 other => panic!("expected Incomplete, got {other:?}"),
267 }
268 }
269
270 #[test]
271 fn a_hook_without_a_secret_still_parses() {
272 // Only the create response carries `secret`; index responses omit it.
273 let hook: Hook = serde_json::from_str(
274 r#"{"id":1,"url":"https://x.test","events":["push"],"active":true,
275 "last_status_code":null,"last_delivered_at":null,
276 "created_at":"2026-08-16T10:00:00.000Z"}"#,
277 )
278 .unwrap();
279 assert!(hook.secret.is_none());
280 assert!(hook.last_delivered_at.is_none());
281 }
282
283 #[test]
284 fn file_contents_maps_the_ref_keyword_field() {
285 let file: FileContents = serde_json::from_str(
286 r#"{"repo":"a/b","path":"README.md","ref":"main","size":3,"content":"hi\n"}"#,
287 )
288 .unwrap();
289 assert_eq!(file.git_ref, "main");
290 }
291
292 #[test]
293 fn a_pull_request_without_diff_fields_parses() {
294 // `get_pull_request` omits diff_truncated unless it truncated.
295 let pr: PullRequest = serde_json::from_str(
296 r#"{"number":7,"title":"t","state":"open","author":"seto","head":"f","base":"main",
297 "body":null,"url":"https://sigit.si/a/b/pull/7","comments":[],"diff":"@@"}"#,
298 )
299 .unwrap();
300 assert!(!pr.diff_truncated);
301 assert_eq!(pr.diff.as_deref(), Some("@@"));
302 }
303 }