OXYGENHUB
# 645469f5227b oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / Models.kt
19.6 KiB · last changed This phone as the second factor: a Keystore key opened by fingerprint, face or PIN approves sign-ins and sensitive changes on the web by the number the browser shows (closes #32) 3 days ago source blame history raw
1
package io.oxygenhub.android.api
2
3
import kotlinx.serialization.SerialName
4
import kotlinx.serialization.Serializable
5
6
// The hub's JSON, by hand from oxygenhub's crates/hub-api/src/lib.rs (and
7
// the device flow's answers in crates/hub/src/api/device.rs). Field names
8
// are the hub's own, snake case; times are milliseconds since the epoch.
9
// Every field has a default, so an added or missing field never breaks the
10
// app: the hub's types may still change before 1.0.
11
12
/** `GET /api/v1/user`. */
13
@Serializable
14
data class User(
15
    val username: String,
16
    @SerialName("display_name") val displayName: String = "",
17
    @SerialName("is_admin") val isAdmin: Boolean = false,
18
    val keys: List<String> = emptyList(),
19
    @SerialName("created_ms") val createdMs: Long = 0,
20
) {
21
    /** The name to greet: the display name, or the username without one. */
22
    val shownName: String get() = displayName.ifBlank { username }
23
}
24
25
@Serializable
26
enum class Visibility {
27
    @SerialName("public") Public,
28
    @SerialName("internal") Internal,
29
    @SerialName("private") Private,
30
}
31
32
/** A repository as `GET /api/v1/repos` lists it. */
33
@Serializable
34
data class Repository(
35
    val owner: String,
36
    val name: String,
37
    val description: String = "",
38
    @SerialName("default_bookmark") val defaultBookmark: String = "main",
39
    val visibility: Visibility = Visibility.Public,
40
    @SerialName("created_ms") val createdMs: Long = 0,
41
    @SerialName("updated_ms") val updatedMs: Long = 0,
42
    @SerialName("clone_url") val cloneUrl: String = "",
43
) {
44
    val slug: String get() = "$owner/$name"
45
}
46
47
@Serializable
48
data class Label(
49
    val name: String,
50
    /** `#rrggbb`. */
51
    val colour: String = "",
52
    val description: String = "",
53
)
54
55
/** `GET /api/v1/repos/{owner}/{name}/issues`. */
56
@Serializable
57
data class Issue(
58
    val number: Long,
59
    val title: String,
60
    val body: String = "",
61
    val author: String = "",
62
    /** `open` or `closed`. */
63
    val state: String = "open",
64
    /** Why it closed: `completed`, `not_planned`, … (empty while open). */
65
    val reason: String = "",
66
    val labels: List<Label> = emptyList(),
67
    val assignees: List<String> = emptyList(),
68
    val milestone: String? = null,
69
    val comments: Long = 0,
70
    val locked: Boolean = false,
71
    val pinned: Boolean = false,
72
    @SerialName("created_ms") val createdMs: Long = 0,
73
    @SerialName("updated_ms") val updatedMs: Long = 0,
74
    @SerialName("closed_ms") val closedMs: Long? = null,
75
    val url: String = "",
76
) {
77
    val isOpen: Boolean get() = state == "open"
78
}
79
80
/** A release of a repository: its tag, notes and files. */
81
@Serializable
82
data class Release(
83
    val tag: String,
84
    val title: String = "",
85
    val notes: String = "",
86
    val prerelease: Boolean = false,
87
    val latest: Boolean = false,
88
    val author: String = "",
89
    val signer: String? = null,
90
    val assets: List<ReleaseAsset> = emptyList(),
91
    @SerialName("created_ms") val createdMs: Long = 0,
92
    val url: String = "",
93
)
94
95
@Serializable
96
data class ReleaseAsset(val name: String, val size: Long = 0, val hash: String = "", val downloads: Long = 0, val url: String = "")
97
98
/** An issue or proposal search found (`GET /search?q=`). */
99
@Serializable
100
data class SearchHit(
101
    /** `issue` or `proposal`. */
102
    val kind: String = "issue",
103
    val repository: String = "",
104
    val number: Long = 0,
105
    val title: String = "",
106
    val snippet: String = "",
107
) {
108
    val owner: String get() = repository.substringBefore('/')
109
    val name: String get() = repository.substringAfter('/')
110
}
111
112
/** A Flow run of a repository's pipeline (#5). */
113
@Serializable
114
data class FlowRun(
115
    val number: Long,
116
    val bookmark: String = "",
117
    /** `queued`, `running`, `passed`, `failed`, `error` or `cancelled`. */
118
    val status: String = "queued",
119
    /** `push`, `manual`, `schedule`, `proposal` or `rerun:N`. */
120
    val trigger: String = "",
121
    val actor: String = "",
122
    val error: String = "",
123
    @SerialName("created_ms") val createdMs: Long = 0,
124
    @SerialName("finished_ms") val finishedMs: Long? = null,
125
    val url: String = "",
126
    @SerialName("queue_position") val queuePosition: Long? = null,
127
    val jobs: List<FlowJob> = emptyList(),
128
) {
129
    val live: Boolean get() = status == "queued" || status == "running"
130
}
131
132
/** A job of a run. */
133
@Serializable
134
data class FlowJob(
135
    val name: String,
136
    val status: String = "queued",
137
    @SerialName("depends_on") val dependsOn: List<String> = emptyList(),
138
    val runner: String = "",
139
    @SerialName("exit_code") val exitCode: Long? = null,
140
    val error: String = "",
141
    @SerialName("started_ms") val startedMs: Long? = null,
142
    @SerialName("finished_ms") val finishedMs: Long? = null,
143
    @SerialName("log_lines") val logLines: Long = 0,
144
    val waiting: String = "",
145
    /** Its approval gate: `waiting` while it waits for a person, `approved` or `rejected` after (OxygenHub 0.9.11). */
146
    val approval: String = "",
147
) {
148
    val live: Boolean get() = status == "queued" || status == "running"
149
150
    /** Queued and waiting for someone to approve or reject it (#5). */
151
    val waitsForPerson: Boolean get() = status == "queued" && approval == "waiting"
152
153
    /** How long it ran, or has been running, in seconds. */
154
    fun seconds(now: Long = System.currentTimeMillis()): Long? = startedMs?.let { ((finishedMs ?: now) - it) / 1000 }
155
}
156
157
/** A line of a job's log: `stdout`, `stderr` or `flow` (the runner's own). */
158
@Serializable
159
data class LogLine(val seq: Long, val t: Long = 0, val stream: String = "stdout", val text: String = "")
160
161
/** A project board's summary (`GET /projects/{owner}`). */
162
@Serializable
163
data class Project(
164
    val owner: String,
165
    val number: Long,
166
    val title: String = "",
167
    val description: String = "",
168
    val public: Boolean = true,
169
    val closed: Boolean = false,
170
    @SerialName("updated_ms") val updatedMs: Long = 0,
171
    val url: String = "",
172
)
173
174
/** A card on a board: an issue, a proposal or a note. */
175
@Serializable
176
data class ProjectCard(
177
    val id: Long,
178
    /** `issue`, `proposal` or `note`. */
179
    val kind: String = "note",
180
    /** `owner/name#N`; empty for a note. */
181
    val reference: String = "",
182
    val title: String = "",
183
    val state: String = "",
184
    val labels: List<String> = emptyList(),
185
    val assignees: List<String> = emptyList(),
186
) {
187
    /** The repository and number a card refers to, or null for a note. */
188
    val target: Triple<String, String, Long>? get() {
189
        val slug = reference.substringBefore('#', "")
190
        val n = reference.substringAfter('#', "").toLongOrNull() ?: return null
191
        val (owner, name) = slug.split('/').takeIf { it.size == 2 } ?: return null
192
        return Triple(owner, name, n)
193
    }
194
}
195
196
@Serializable
197
data class ProjectColumn(val id: Long, val name: String, val cards: List<ProjectCard> = emptyList())
198
199
/** A board, its columns in order (`GET /projects/{owner}/{n}`). */
200
@Serializable
201
data class Board(val project: Project, val columns: List<ProjectColumn> = emptyList())
202
203
/** A comment on an issue or proposal. */
204
@Serializable
205
data class Comment(
206
    val id: Long = 0,
207
    val author: String = "",
208
    val body: String = "",
209
    @SerialName("created_ms") val createdMs: Long = 0,
210
)
211
212
/** Which issues to list: the hub's `?state=`. */
213
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
214
215
/**
216
 * A proposal (`GET /api/v1/repos/{owner}/{name}/proposals`, `…/proposals/{n}`):
217
 * changes asking to join a target bookmark. The hub decides whether it may
218
 * land, and says why not in [blockers], in its own words.
219
 */
220
@Serializable
221
data class Proposal(
222
    val number: Long,
223
    val title: String,
224
    val body: String = "",
225
    val author: String = "",
226
    /** `draft`, `open`, `landed` or `closed`. */
227
    val state: String = "open",
228
    /** The bookmark it lands on. */
229
    val target: String = "main",
230
    /** The changes it adds, in dependency order, newest last. */
231
    val changes: List<ProposalChange> = emptyList(),
232
    val conflicted: Boolean = false,
233
    val approvals: List<Approval> = emptyList(),
234
    val landable: Boolean = false,
235
    val blockers: List<String> = emptyList(),
236
    @SerialName("created_ms") val createdMs: Long = 0,
237
    @SerialName("updated_ms") val updatedMs: Long = 0,
238
    val url: String = "",
239
    val landed: Landed? = null,
240
    /** Who set it to land by itself when its checks pass, while set. */
241
    @SerialName("auto_land") val autoLand: String? = null,
242
    @SerialName("requested_reviewers") val requestedReviewers: List<String> = emptyList(),
243
    val labels: List<Label> = emptyList(),
244
) {
245
    val isOpen: Boolean get() = state == "open" || state == "draft"
246
    val isDraft: Boolean get() = state == "draft"
247
}
248
249
/** `GET /api/v1/user/proposals`: the proposals to act on, as the hub's dashboard lists them. */
250
@Serializable
251
data class UserProposals(
252
    /** Open ones by others in the caller's repositories, not yet reviewed by them. */
253
    val review: List<ProposalBrief> = emptyList(),
254
    /** The caller's own open ones and drafts. */
255
    val authored: List<ProposalBrief> = emptyList(),
256
    /** Asked for with `landable=true`: open ones the caller can land now. */
257
    val landable: List<ProposalBrief> = emptyList(),
258
)
259
260
/**
261
 * `GET /api/v1/user/sky` (#23): what the hub's dashboard backdrop draws, the
262
 * caller's repositories and their events of the last 30 days.
263
 */
264
@Serializable
265
data class UserSky(
266
    /** The hub's clock (ms), to age the events by. */
267
    val now: Long = 0,
268
    /** `owner/name` of each. */
269
    val repos: List<String> = emptyList(),
270
    /** `[repo, kind, at_ms, n]`: an index into [repos]; kind 0 a push of n changes, 1 a proposal event, 2 an issue event, 3 a release, 4 a Flow run. */
271
    val events: List<List<Long>> = emptyList(),
272
)
273
274
/** `GET /api/v1/user/orgs`: an organisation the caller belongs to, and their role in it. */
275
@Serializable
276
data class UserOrg(
277
    val name: String,
278
    @SerialName("display_name") val displayName: String = "",
279
    val role: String = "",
280
)
281
282
/** `GET /api/v1/user/starter?owner=X` (#27, oxygenhub#347): an assistant a new repository may ask. */
283
@Serializable
284
data class StarterAssistant(val name: String, val model: String = "", val from: String = "")
285
286
@Serializable
287
data class StarterAssistants(val assistants: List<StarterAssistant> = emptyList())
288
289
/** Where a repository's starter stands: `running`, `done`, `failed` or `none`. */
290
@Serializable
291
data class StarterStatus(
292
    val status: String = "none",
293
    val assistant: String = "",
294
    val change: String = "",
295
    val files: List<String> = emptyList(),
296
    val error: String = "",
297
)
298
299
/** `GET /api/v1/user/flow/runs` (#26): a run of one of your repositories. */
300
data class UserFlowRun(val repository: String, val run: FlowRun) {
301
    val owner: String get() = repository.substringBefore('/')
302
    val name: String get() = repository.substringAfter('/')
303
}
304
305
/** An organisation (`GET /orgs/{name}`). */
306
@Serializable
307
data class Organisation(
308
    val name: String,
309
    @SerialName("display_name") val displayName: String = "",
310
    val description: String = "",
311
    val members: List<String> = emptyList(),
312
)
313
314
/** An account or an organisation, as its page shows it. */
315
sealed interface Owner {
316
    val name: String
317
    val shownName: String
318
319
    data class Person(val user: User) : Owner {
320
        override val name get() = user.username
321
        override val shownName get() = user.shownName
322
    }
323
324
    data class Org(val org: Organisation) : Owner {
325
        override val name get() = org.name
326
        override val shownName get() = org.displayName.ifBlank { org.name }
327
    }
328
}
329
330
/** A phone that approves for its account (oxygenhub#356, #32). */
331
@Serializable
332
data class Authenticator(
333
    val id: Long,
334
    val name: String = "",
335
    @SerialName("created_ms") val createdMs: Long = 0,
336
    @SerialName("last_used_ms") val lastUsedMs: Long = 0,
337
    /** This phone. */
338
    @SerialName("this") val isThis: Boolean = false,
339
)
340
341
/** An approval waiting for this phone: a sign-in or a sensitive change on the web. */
342
@Serializable
343
data class PendingApproval(
344
    val id: String,
345
    /** `signin` or `stepup`. */
346
    val kind: String = "",
347
    val what: String = "",
348
    /** The three numbers to offer; the browser shows one of them. */
349
    val choices: List<Long> = emptyList(),
350
    @SerialName("created_ms") val createdMs: Long = 0,
351
    @SerialName("expires_ms") val expiresMs: Long = 0,
352
)
353
354
@Serializable
355
data class ApprovalState(val state: String = "")
356
357
/** An issue in a list across repositories (`GET /user/issues`, oxygenhub#359). */
358
@Serializable
359
data class IssueBrief(
360
    /** `owner/name`. */
361
    val repository: String,
362
    val number: Long,
363
    val title: String = "",
364
    @SerialName("updated_ms") val updatedMs: Long = 0,
365
    val url: String = "",
366
)
367
368
/** A proposal in a list across repositories. */
369
@Serializable
370
data class ProposalBrief(
371
    /** `owner/name`. */
372
    val repository: String,
373
    val number: Long,
374
    val title: String = "",
375
    val author: String = "",
376
    val state: String = "open",
377
    val conflicted: Boolean = false,
378
    @SerialName("updated_ms") val updatedMs: Long = 0,
379
    val url: String = "",
380
) {
381
    val owner: String get() = repository.substringBefore('/')
382
    val name: String get() = repository.substringAfter('/')
383
}
384
385
/** `POST /api/v1/user/runner-token`: a one-time token that registers a runner for [owner]'s repositories. */
386
@Serializable
387
data class RunnerToken(val token: String, val owner: String = "") {
388
    // The token is a secret until it is used.
389
    override fun toString(): String = "RunnerToken(owner=$owner, token=<redacted>)"
390
}
391
392
/** A change a proposal adds. */
393
@Serializable
394
data class ProposalChange(
395
    val hash: String,
396
    val message: String = "",
397
    /** One that nothing else in the proposal depends on. */
398
    val head: Boolean = false,
399
) {
400
    /** The message's first line. */
401
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
402
}
403
404
/** A file or directory of a repository's tree at some reference (the code browser, #3). */
405
@Serializable
406
data class TreeEntry(
407
    val name: String,
408
    val path: String,
409
    /** `file`, `dir`, `symlink` or `subrepo`. */
410
    val kind: String = "file",
411
    val exec: Boolean = false,
412
    val size: Long? = null,
413
    val conflicted: Boolean = false,
414
) {
415
    val isDir: Boolean get() = kind == "dir"
416
}
417
418
/** Who made a change, as the hub knows them. */
419
@Serializable
420
data class ChangeAuthor(val name: String = "", val email: String = "", val account: String? = null)
421
422
/** A change in a history or a proposal. */
423
@Serializable
424
data class ChangeSummary(
425
    val hash: String,
426
    val message: String = "",
427
    val author: ChangeAuthor = ChangeAuthor(),
428
    @SerialName("when_ms") val whenMs: Long = 0,
429
) {
430
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
431
    val short: String get() = hash.take(12)
432
}
433
434
/** A page of a reference's history (`GET …/history/{r}`), newest first. */
435
@Serializable
436
data class History(
437
    val reference: String = "",
438
    val total: Int = 0,
439
    val offset: Int = 0,
440
    val changes: List<ChangeSummary> = emptyList(),
441
)
442
443
/** A file a change touched, and how. */
444
@Serializable
445
data class FileChange(
446
    val path: String,
447
    /** `added`, `modified`, `removed` or `renamed`. */
448
    val kind: String = "modified",
449
    val from: String? = null,
450
    val added: Int = 0,
451
    val removed: Int = 0,
452
    /** The diff with three lines of context (hub #320), cut at 2000 lines. */
453
    val lines: List<DiffLine> = emptyList(),
454
    val truncated: Boolean = false,
455
)
456
457
/** A line of a file's diff: `context`, `add`, `del`, or `gap` for lines left out between hunks. */
458
@Serializable
459
data class DiffLine(
460
    val kind: String = "context",
461
    val old: Int? = null,
462
    val new: Int? = null,
463
    val text: String = "",
464
)
465
466
/** One change, whole (`GET …/changes/{hash}`). */
467
@Serializable
468
data class ChangeDetail(
469
    val change: ChangeSummary,
470
    val body: String = "",
471
    @SerialName("in_bookmarks") val inBookmarks: List<String> = emptyList(),
472
    @SerialName("in_tags") val inTags: List<String> = emptyList(),
473
    val files: List<FileChange> = emptyList(),
474
)
475
476
/** A bookmark or tag to browse at. */
477
@Serializable
478
data class NamedRef(val name: String)
479
480
/** A reviewer's approval; it counts only when [sealed] (a seal over every change, by a key linked to them). */
481
@Serializable
482
data class Approval(val account: String, val sealed: Boolean = false)
483
484
/** Who landed a proposal, how (`web`, or `api (token NAME)`), and when. */
485
@Serializable
486
data class Landed(val by: String, val via: String = "", @SerialName("when_ms") val whenMs: Long = 0)
487
488
/** Which proposals to list. `Open` includes drafts, as the hub's own list does. */
489
enum class ProposalState(val query: String) { Open("open"), Landed("landed"), Closed("closed"), All("all") }
490
491
/** A check on a state (`GET …/proposals/{n}/checks`): a Flow job, or another reporter's. */
492
@Serializable
493
data class Check(
494
    val name: String,
495
    /** `queued`, `running`, `passed`, `failed`, `cancelled` or `skipped`. */
496
    val status: String = "queued",
497
    val summary: String = "",
498
    val url: String = "",
499
    @SerialName("started_ms") val startedMs: Long = 0,
500
    @SerialName("finished_ms") val finishedMs: Long? = null,
501
)
502
503
/** One entry in the caller's inbox. */
504
@Serializable
505
data class Notification(
506
    val id: Long,
507
    @SerialName("at_ms") val atMs: Long = 0,
508
    /** `issue.opened`, `proposal.comment`, `mention`, `review`, … */
509
    val kind: String = "",
510
    val title: String = "",
511
    /** The page on the hub, absolute. */
512
    val url: String = "",
513
    val actor: String = "",
514
    val read: Boolean = false,
515
)
516
517
/** `GET /api/v1/notifications`: the unread count and the newest entries, unread first. */
518
@Serializable
519
data class Inbox(
520
    val unread: Long = 0,
521
    val items: List<Notification> = emptyList(),
522
)
523
524
/** An RFC 7807 problem: what the API answers with on an error. */
525
@Serializable
526
data class Problem(
527
    val status: Int = 0,
528
    val title: String = "",
529
    val detail: String = "",
530
)
531
532
/** The device flow's errors, RFC 8628's shape: `{"error": …, "error_description": …}`. */
533
@Serializable
534
data class OAuthError(
535
    val error: String = "",
536
    @SerialName("error_description") val description: String = "",
537
)
538
539
/** `POST /api/v1/device/code`'s answer. */
540
@Serializable
541
data class DeviceCode(
542
    @SerialName("device_code") val deviceCode: String,
543
    @SerialName("user_code") val userCode: String,
544
    @SerialName("verification_uri") val verificationUri: String,
545
    @SerialName("verification_uri_complete") val verificationUriComplete: String = "",
546
    @SerialName("expires_in") val expiresIn: Long = 900,
547
    val interval: Long = 5,
548
) {
549
    /** The page to open: with the code filled in when the hub offers it. */
550
    val pageToOpen: String get() = verificationUriComplete.ifBlank { verificationUri }
551
552
    // The device code is a secret until it is used: never in logs.
553
    override fun toString(): String = "DeviceCode(userCode=$userCode, verificationUri=$verificationUri)"
554
}
555
556
/** `POST /api/v1/device/token`'s answer once the user approved. */
557
@Serializable
558
data class TokenGrant(
559
    @SerialName("access_token") val accessToken: String,
560
    @SerialName("token_type") val tokenType: String = "bearer",
561
    /** The scopes the user allowed, space separated (they may untick some). */
562
    val scope: String = "",
563
    val username: String = "",
564
    @SerialName("expires_in") val expiresIn: Long = 0,
565
) {
566
    val scopes: List<String> get() = scope.split(' ').filter { it.isNotBlank() }
567
568
    override fun toString(): String = "TokenGrant(username=$username, scope=$scope, accessToken=<redacted>)"
569
}
570
571
/** What `PUT /user/push` answers (#16): whether the hub sends alerts at all. */
572
@Serializable
573
data class PushRegistered(val alerts: Boolean = false)
574
575
/** What `POST …/reviews` answers; the app only needs that it was taken. */
576
@Serializable
577
data class ReviewPosted(val verdict: String = "")
578
579
/** What `POST …/flow/runs/N/expedite` answers. */
580
@Serializable
581
data class Expedited(val expedited: Boolean = false)