| 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 | } |