OXYGENHUB
# ca672a3ada1f oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / Models.kt
15.9 KiB · last changed Approve, reject and expedite from the phone: a job waiting for a person shows Approve and Reject, a live run has Expedite with its reason (refs #5) 8 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 Flow run of a repository's pipeline (#5). */
81
@Serializable
82
data class FlowRun(
83
    val number: Long,
84
    val bookmark: String = "",
85
    /** `queued`, `running`, `passed`, `failed`, `error` or `cancelled`. */
86
    val status: String = "queued",
87
    /** `push`, `manual`, `schedule`, `proposal` or `rerun:N`. */
88
    val trigger: String = "",
89
    val actor: String = "",
90
    val error: String = "",
91
    @SerialName("created_ms") val createdMs: Long = 0,
92
    @SerialName("finished_ms") val finishedMs: Long? = null,
93
    val url: String = "",
94
    @SerialName("queue_position") val queuePosition: Long? = null,
95
    val jobs: List<FlowJob> = emptyList(),
96
) {
97
    val live: Boolean get() = status == "queued" || status == "running"
98
}
99
100
/** A job of a run. */
101
@Serializable
102
data class FlowJob(
103
    val name: String,
104
    val status: String = "queued",
105
    @SerialName("depends_on") val dependsOn: List<String> = emptyList(),
106
    val runner: String = "",
107
    @SerialName("exit_code") val exitCode: Long? = null,
108
    val error: String = "",
109
    @SerialName("started_ms") val startedMs: Long? = null,
110
    @SerialName("finished_ms") val finishedMs: Long? = null,
111
    @SerialName("log_lines") val logLines: Long = 0,
112
    val waiting: String = "",
113
    /** Its approval gate: `waiting` while it waits for a person, `approved` or `rejected` after (OxygenHub 0.9.11). */
114
    val approval: String = "",
115
) {
116
    val live: Boolean get() = status == "queued" || status == "running"
117
118
    /** Queued and waiting for someone to approve or reject it (#5). */
119
    val waitsForPerson: Boolean get() = status == "queued" && approval == "waiting"
120
121
    /** How long it ran, or has been running, in seconds. */
122
    fun seconds(now: Long = System.currentTimeMillis()): Long? = startedMs?.let { ((finishedMs ?: now) - it) / 1000 }
123
}
124
125
/** A line of a job's log: `stdout`, `stderr` or `flow` (the runner's own). */
126
@Serializable
127
data class LogLine(val seq: Long, val t: Long = 0, val stream: String = "stdout", val text: String = "")
128
129
/** A project board's summary (`GET /projects/{owner}`). */
130
@Serializable
131
data class Project(
132
    val owner: String,
133
    val number: Long,
134
    val title: String = "",
135
    val description: String = "",
136
    val public: Boolean = true,
137
    val closed: Boolean = false,
138
    @SerialName("updated_ms") val updatedMs: Long = 0,
139
    val url: String = "",
140
)
141
142
/** A card on a board: an issue, a proposal or a note. */
143
@Serializable
144
data class ProjectCard(
145
    val id: Long,
146
    /** `issue`, `proposal` or `note`. */
147
    val kind: String = "note",
148
    /** `owner/name#N`; empty for a note. */
149
    val reference: String = "",
150
    val title: String = "",
151
    val state: String = "",
152
    val labels: List<String> = emptyList(),
153
    val assignees: List<String> = emptyList(),
154
) {
155
    /** The repository and number a card refers to, or null for a note. */
156
    val target: Triple<String, String, Long>? get() {
157
        val slug = reference.substringBefore('#', "")
158
        val n = reference.substringAfter('#', "").toLongOrNull() ?: return null
159
        val (owner, name) = slug.split('/').takeIf { it.size == 2 } ?: return null
160
        return Triple(owner, name, n)
161
    }
162
}
163
164
@Serializable
165
data class ProjectColumn(val id: Long, val name: String, val cards: List<ProjectCard> = emptyList())
166
167
/** A board, its columns in order (`GET /projects/{owner}/{n}`). */
168
@Serializable
169
data class Board(val project: Project, val columns: List<ProjectColumn> = emptyList())
170
171
/** A comment on an issue or proposal. */
172
@Serializable
173
data class Comment(
174
    val id: Long = 0,
175
    val author: String = "",
176
    val body: String = "",
177
    @SerialName("created_ms") val createdMs: Long = 0,
178
)
179
180
/** Which issues to list: the hub's `?state=`. */
181
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
182
183
/**
184
 * A proposal (`GET /api/v1/repos/{owner}/{name}/proposals`, `…/proposals/{n}`):
185
 * changes asking to join a target bookmark. The hub decides whether it may
186
 * land, and says why not in [blockers], in its own words.
187
 */
188
@Serializable
189
data class Proposal(
190
    val number: Long,
191
    val title: String,
192
    val body: String = "",
193
    val author: String = "",
194
    /** `draft`, `open`, `landed` or `closed`. */
195
    val state: String = "open",
196
    /** The bookmark it lands on. */
197
    val target: String = "main",
198
    /** The changes it adds, in dependency order, newest last. */
199
    val changes: List<ProposalChange> = emptyList(),
200
    val conflicted: Boolean = false,
201
    val approvals: List<Approval> = emptyList(),
202
    val landable: Boolean = false,
203
    val blockers: List<String> = emptyList(),
204
    @SerialName("created_ms") val createdMs: Long = 0,
205
    @SerialName("updated_ms") val updatedMs: Long = 0,
206
    val url: String = "",
207
    val landed: Landed? = null,
208
    /** Who set it to land by itself when its checks pass, while set. */
209
    @SerialName("auto_land") val autoLand: String? = null,
210
    @SerialName("requested_reviewers") val requestedReviewers: List<String> = emptyList(),
211
    val labels: List<Label> = emptyList(),
212
) {
213
    val isOpen: Boolean get() = state == "open" || state == "draft"
214
    val isDraft: Boolean get() = state == "draft"
215
}
216
217
/** `GET /api/v1/user/proposals`: the proposals to act on, as the hub's dashboard lists them. */
218
@Serializable
219
data class UserProposals(
220
    /** Open ones by others in the caller's repositories, not yet reviewed by them. */
221
    val review: List<ProposalBrief> = emptyList(),
222
    /** The caller's own open ones and drafts. */
223
    val authored: List<ProposalBrief> = emptyList(),
224
    /** Asked for with `landable=true`: open ones the caller can land now. */
225
    val landable: List<ProposalBrief> = emptyList(),
226
)
227
228
/** An organisation (`GET /orgs/{name}`). */
229
@Serializable
230
data class Organisation(
231
    val name: String,
232
    @SerialName("display_name") val displayName: String = "",
233
    val description: String = "",
234
    val members: List<String> = emptyList(),
235
)
236
237
/** An account or an organisation, as its page shows it. */
238
sealed interface Owner {
239
    val name: String
240
    val shownName: String
241
242
    data class Person(val user: User) : Owner {
243
        override val name get() = user.username
244
        override val shownName get() = user.shownName
245
    }
246
247
    data class Org(val org: Organisation) : Owner {
248
        override val name get() = org.name
249
        override val shownName get() = org.displayName.ifBlank { org.name }
250
    }
251
}
252
253
/** A proposal in a list across repositories. */
254
@Serializable
255
data class ProposalBrief(
256
    /** `owner/name`. */
257
    val repository: String,
258
    val number: Long,
259
    val title: String = "",
260
    val author: String = "",
261
    val state: String = "open",
262
    val conflicted: Boolean = false,
263
    @SerialName("updated_ms") val updatedMs: Long = 0,
264
    val url: String = "",
265
) {
266
    val owner: String get() = repository.substringBefore('/')
267
    val name: String get() = repository.substringAfter('/')
268
}
269
270
/** `POST /api/v1/user/runner-token`: a one-time token that registers a runner for [owner]'s repositories. */
271
@Serializable
272
data class RunnerToken(val token: String, val owner: String = "") {
273
    // The token is a secret until it is used.
274
    override fun toString(): String = "RunnerToken(owner=$owner, token=<redacted>)"
275
}
276
277
/** A change a proposal adds. */
278
@Serializable
279
data class ProposalChange(
280
    val hash: String,
281
    val message: String = "",
282
    /** One that nothing else in the proposal depends on. */
283
    val head: Boolean = false,
284
) {
285
    /** The message's first line. */
286
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
287
}
288
289
/** A file or directory of a repository's tree at some reference (the code browser, #3). */
290
@Serializable
291
data class TreeEntry(
292
    val name: String,
293
    val path: String,
294
    /** `file`, `dir`, `symlink` or `subrepo`. */
295
    val kind: String = "file",
296
    val exec: Boolean = false,
297
    val size: Long? = null,
298
    val conflicted: Boolean = false,
299
) {
300
    val isDir: Boolean get() = kind == "dir"
301
}
302
303
/** Who made a change, as the hub knows them. */
304
@Serializable
305
data class ChangeAuthor(val name: String = "", val email: String = "", val account: String? = null)
306
307
/** A change in a history or a proposal. */
308
@Serializable
309
data class ChangeSummary(
310
    val hash: String,
311
    val message: String = "",
312
    val author: ChangeAuthor = ChangeAuthor(),
313
    @SerialName("when_ms") val whenMs: Long = 0,
314
) {
315
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
316
    val short: String get() = hash.take(12)
317
}
318
319
/** A page of a reference's history (`GET …/history/{r}`), newest first. */
320
@Serializable
321
data class History(
322
    val reference: String = "",
323
    val total: Int = 0,
324
    val offset: Int = 0,
325
    val changes: List<ChangeSummary> = emptyList(),
326
)
327
328
/** A file a change touched, and how. */
329
@Serializable
330
data class FileChange(
331
    val path: String,
332
    /** `added`, `modified`, `removed` or `renamed`. */
333
    val kind: String = "modified",
334
    val from: String? = null,
335
    val added: Int = 0,
336
    val removed: Int = 0,
337
    /** The diff with three lines of context (hub #320), cut at 2000 lines. */
338
    val lines: List<DiffLine> = emptyList(),
339
    val truncated: Boolean = false,
340
)
341
342
/** A line of a file's diff: `context`, `add`, `del`, or `gap` for lines left out between hunks. */
343
@Serializable
344
data class DiffLine(
345
    val kind: String = "context",
346
    val old: Int? = null,
347
    val new: Int? = null,
348
    val text: String = "",
349
)
350
351
/** One change, whole (`GET …/changes/{hash}`). */
352
@Serializable
353
data class ChangeDetail(
354
    val change: ChangeSummary,
355
    val body: String = "",
356
    @SerialName("in_bookmarks") val inBookmarks: List<String> = emptyList(),
357
    @SerialName("in_tags") val inTags: List<String> = emptyList(),
358
    val files: List<FileChange> = emptyList(),
359
)
360
361
/** A bookmark or tag to browse at. */
362
@Serializable
363
data class NamedRef(val name: String)
364
365
/** A reviewer's approval; it counts only when [sealed] (a seal over every change, by a key linked to them). */
366
@Serializable
367
data class Approval(val account: String, val sealed: Boolean = false)
368
369
/** Who landed a proposal, how (`web`, or `api (token NAME)`), and when. */
370
@Serializable
371
data class Landed(val by: String, val via: String = "", @SerialName("when_ms") val whenMs: Long = 0)
372
373
/** Which proposals to list. `Open` includes drafts, as the hub's own list does. */
374
enum class ProposalState(val query: String) { Open("open"), Landed("landed"), Closed("closed"), All("all") }
375
376
/** A check on a state (`GET …/proposals/{n}/checks`): a Flow job, or another reporter's. */
377
@Serializable
378
data class Check(
379
    val name: String,
380
    /** `queued`, `running`, `passed`, `failed`, `cancelled` or `skipped`. */
381
    val status: String = "queued",
382
    val summary: String = "",
383
    val url: String = "",
384
    @SerialName("started_ms") val startedMs: Long = 0,
385
    @SerialName("finished_ms") val finishedMs: Long? = null,
386
)
387
388
/** One entry in the caller's inbox. */
389
@Serializable
390
data class Notification(
391
    val id: Long,
392
    @SerialName("at_ms") val atMs: Long = 0,
393
    /** `issue.opened`, `proposal.comment`, `mention`, `review`, … */
394
    val kind: String = "",
395
    val title: String = "",
396
    /** The page on the hub, absolute. */
397
    val url: String = "",
398
    val actor: String = "",
399
    val read: Boolean = false,
400
)
401
402
/** `GET /api/v1/notifications`: the unread count and the newest entries, unread first. */
403
@Serializable
404
data class Inbox(
405
    val unread: Long = 0,
406
    val items: List<Notification> = emptyList(),
407
)
408
409
/** An RFC 7807 problem: what the API answers with on an error. */
410
@Serializable
411
data class Problem(
412
    val status: Int = 0,
413
    val title: String = "",
414
    val detail: String = "",
415
)
416
417
/** The device flow's errors, RFC 8628's shape: `{"error": …, "error_description": …}`. */
418
@Serializable
419
data class OAuthError(
420
    val error: String = "",
421
    @SerialName("error_description") val description: String = "",
422
)
423
424
/** `POST /api/v1/device/code`'s answer. */
425
@Serializable
426
data class DeviceCode(
427
    @SerialName("device_code") val deviceCode: String,
428
    @SerialName("user_code") val userCode: String,
429
    @SerialName("verification_uri") val verificationUri: String,
430
    @SerialName("verification_uri_complete") val verificationUriComplete: String = "",
431
    @SerialName("expires_in") val expiresIn: Long = 900,
432
    val interval: Long = 5,
433
) {
434
    /** The page to open: with the code filled in when the hub offers it. */
435
    val pageToOpen: String get() = verificationUriComplete.ifBlank { verificationUri }
436
437
    // The device code is a secret until it is used: never in logs.
438
    override fun toString(): String = "DeviceCode(userCode=$userCode, verificationUri=$verificationUri)"
439
}
440
441
/** `POST /api/v1/device/token`'s answer once the user approved. */
442
@Serializable
443
data class TokenGrant(
444
    @SerialName("access_token") val accessToken: String,
445
    @SerialName("token_type") val tokenType: String = "bearer",
446
    /** The scopes the user allowed, space separated (they may untick some). */
447
    val scope: String = "",
448
    val username: String = "",
449
    @SerialName("expires_in") val expiresIn: Long = 0,
450
) {
451
    val scopes: List<String> get() = scope.split(' ').filter { it.isNotBlank() }
452
453
    override fun toString(): String = "TokenGrant(username=$username, scope=$scope, accessToken=<redacted>)"
454
}
455
456
/** What `PUT /user/push` answers (#16): whether the hub sends alerts at all. */
457
@Serializable
458
data class PushRegistered(val alerts: Boolean = false)
459
460
/** What `POST …/reviews` answers; the app only needs that it was taken. */
461
@Serializable
462
data class ReviewPosted(val verdict: String = "")
463
464
/** What `POST …/flow/runs/N/expedite` answers. */
465
@Serializable
466
data class Expedited(val expedited: Boolean = false)