OXYGENHUB
# e833eb0a9e7f oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / Models.kt
12.4 KiB · last changed Project boards on the phone: every board you can see, columns to swipe, cards that open and move 10 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 project board's summary (`GET /projects/{owner}`). */
81
@Serializable
82
data class Project(
83
    val owner: String,
84
    val number: Long,
85
    val title: String = "",
86
    val description: String = "",
87
    val public: Boolean = true,
88
    val closed: Boolean = false,
89
    @SerialName("updated_ms") val updatedMs: Long = 0,
90
    val url: String = "",
91
)
92
93
/** A card on a board: an issue, a proposal or a note. */
94
@Serializable
95
data class ProjectCard(
96
    val id: Long,
97
    /** `issue`, `proposal` or `note`. */
98
    val kind: String = "note",
99
    /** `owner/name#N`; empty for a note. */
100
    val reference: String = "",
101
    val title: String = "",
102
    val state: String = "",
103
    val labels: List<String> = emptyList(),
104
    val assignees: List<String> = emptyList(),
105
) {
106
    /** The repository and number a card refers to, or null for a note. */
107
    val target: Triple<String, String, Long>? get() {
108
        val slug = reference.substringBefore('#', "")
109
        val n = reference.substringAfter('#', "").toLongOrNull() ?: return null
110
        val (owner, name) = slug.split('/').takeIf { it.size == 2 } ?: return null
111
        return Triple(owner, name, n)
112
    }
113
}
114
115
@Serializable
116
data class ProjectColumn(val id: Long, val name: String, val cards: List<ProjectCard> = emptyList())
117
118
/** A board, its columns in order (`GET /projects/{owner}/{n}`). */
119
@Serializable
120
data class Board(val project: Project, val columns: List<ProjectColumn> = emptyList())
121
122
/** A comment on an issue or proposal. */
123
@Serializable
124
data class Comment(
125
    val id: Long = 0,
126
    val author: String = "",
127
    val body: String = "",
128
    @SerialName("created_ms") val createdMs: Long = 0,
129
)
130
131
/** Which issues to list: the hub's `?state=`. */
132
enum class IssueState(val query: String) { Open("open"), Closed("closed"), All("all") }
133
134
/**
135
 * A proposal (`GET /api/v1/repos/{owner}/{name}/proposals`, `…/proposals/{n}`):
136
 * changes asking to join a target bookmark. The hub decides whether it may
137
 * land, and says why not in [blockers], in its own words.
138
 */
139
@Serializable
140
data class Proposal(
141
    val number: Long,
142
    val title: String,
143
    val body: String = "",
144
    val author: String = "",
145
    /** `draft`, `open`, `landed` or `closed`. */
146
    val state: String = "open",
147
    /** The bookmark it lands on. */
148
    val target: String = "main",
149
    /** The changes it adds, in dependency order, newest last. */
150
    val changes: List<ProposalChange> = emptyList(),
151
    val conflicted: Boolean = false,
152
    val approvals: List<Approval> = emptyList(),
153
    val landable: Boolean = false,
154
    val blockers: List<String> = emptyList(),
155
    @SerialName("created_ms") val createdMs: Long = 0,
156
    @SerialName("updated_ms") val updatedMs: Long = 0,
157
    val url: String = "",
158
    val landed: Landed? = null,
159
    /** Who set it to land by itself when its checks pass, while set. */
160
    @SerialName("auto_land") val autoLand: String? = null,
161
    @SerialName("requested_reviewers") val requestedReviewers: List<String> = emptyList(),
162
    val labels: List<Label> = emptyList(),
163
) {
164
    val isOpen: Boolean get() = state == "open" || state == "draft"
165
    val isDraft: Boolean get() = state == "draft"
166
}
167
168
/** `GET /api/v1/user/proposals`: the proposals to act on, as the hub's dashboard lists them. */
169
@Serializable
170
data class UserProposals(
171
    /** Open ones by others in the caller's repositories, not yet reviewed by them. */
172
    val review: List<ProposalBrief> = emptyList(),
173
    /** The caller's own open ones and drafts. */
174
    val authored: List<ProposalBrief> = emptyList(),
175
    /** Asked for with `landable=true`: open ones the caller can land now. */
176
    val landable: List<ProposalBrief> = emptyList(),
177
)
178
179
/** A proposal in a list across repositories. */
180
@Serializable
181
data class ProposalBrief(
182
    /** `owner/name`. */
183
    val repository: String,
184
    val number: Long,
185
    val title: String = "",
186
    val author: String = "",
187
    val state: String = "open",
188
    val conflicted: Boolean = false,
189
    @SerialName("updated_ms") val updatedMs: Long = 0,
190
    val url: String = "",
191
) {
192
    val owner: String get() = repository.substringBefore('/')
193
    val name: String get() = repository.substringAfter('/')
194
}
195
196
/** `POST /api/v1/user/runner-token`: a one-time token that registers a runner for [owner]'s repositories. */
197
@Serializable
198
data class RunnerToken(val token: String, val owner: String = "") {
199
    // The token is a secret until it is used.
200
    override fun toString(): String = "RunnerToken(owner=$owner, token=<redacted>)"
201
}
202
203
/** A change a proposal adds. */
204
@Serializable
205
data class ProposalChange(
206
    val hash: String,
207
    val message: String = "",
208
    /** One that nothing else in the proposal depends on. */
209
    val head: Boolean = false,
210
) {
211
    /** The message's first line. */
212
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
213
}
214
215
/** A file or directory of a repository's tree at some reference (the code browser, #3). */
216
@Serializable
217
data class TreeEntry(
218
    val name: String,
219
    val path: String,
220
    /** `file`, `dir`, `symlink` or `subrepo`. */
221
    val kind: String = "file",
222
    val exec: Boolean = false,
223
    val size: Long? = null,
224
    val conflicted: Boolean = false,
225
) {
226
    val isDir: Boolean get() = kind == "dir"
227
}
228
229
/** Who made a change, as the hub knows them. */
230
@Serializable
231
data class ChangeAuthor(val name: String = "", val email: String = "", val account: String? = null)
232
233
/** A change in a history or a proposal. */
234
@Serializable
235
data class ChangeSummary(
236
    val hash: String,
237
    val message: String = "",
238
    val author: ChangeAuthor = ChangeAuthor(),
239
    @SerialName("when_ms") val whenMs: Long = 0,
240
) {
241
    val subject: String get() = message.lineSequence().firstOrNull().orEmpty()
242
    val short: String get() = hash.take(12)
243
}
244
245
/** A page of a reference's history (`GET …/history/{r}`), newest first. */
246
@Serializable
247
data class History(
248
    val reference: String = "",
249
    val total: Int = 0,
250
    val offset: Int = 0,
251
    val changes: List<ChangeSummary> = emptyList(),
252
)
253
254
/** A file a change touched, and how. */
255
@Serializable
256
data class FileChange(
257
    val path: String,
258
    /** `added`, `modified`, `removed` or `renamed`. */
259
    val kind: String = "modified",
260
    val from: String? = null,
261
    val added: Int = 0,
262
    val removed: Int = 0,
263
)
264
265
/** One change, whole (`GET …/changes/{hash}`). */
266
@Serializable
267
data class ChangeDetail(
268
    val change: ChangeSummary,
269
    val body: String = "",
270
    @SerialName("in_bookmarks") val inBookmarks: List<String> = emptyList(),
271
    @SerialName("in_tags") val inTags: List<String> = emptyList(),
272
    val files: List<FileChange> = emptyList(),
273
)
274
275
/** A bookmark or tag to browse at. */
276
@Serializable
277
data class NamedRef(val name: String)
278
279
/** A reviewer's approval; it counts only when [sealed] (a seal over every change, by a key linked to them). */
280
@Serializable
281
data class Approval(val account: String, val sealed: Boolean = false)
282
283
/** Who landed a proposal, how (`web`, or `api (token NAME)`), and when. */
284
@Serializable
285
data class Landed(val by: String, val via: String = "", @SerialName("when_ms") val whenMs: Long = 0)
286
287
/** Which proposals to list. `Open` includes drafts, as the hub's own list does. */
288
enum class ProposalState(val query: String) { Open("open"), Landed("landed"), Closed("closed"), All("all") }
289
290
/** A check on a state (`GET …/proposals/{n}/checks`): a Flow job, or another reporter's. */
291
@Serializable
292
data class Check(
293
    val name: String,
294
    /** `queued`, `running`, `passed`, `failed`, `cancelled` or `skipped`. */
295
    val status: String = "queued",
296
    val summary: String = "",
297
    val url: String = "",
298
    @SerialName("started_ms") val startedMs: Long = 0,
299
    @SerialName("finished_ms") val finishedMs: Long? = null,
300
)
301
302
/** One entry in the caller's inbox. */
303
@Serializable
304
data class Notification(
305
    val id: Long,
306
    @SerialName("at_ms") val atMs: Long = 0,
307
    /** `issue.opened`, `proposal.comment`, `mention`, `review`, … */
308
    val kind: String = "",
309
    val title: String = "",
310
    /** The page on the hub, absolute. */
311
    val url: String = "",
312
    val actor: String = "",
313
    val read: Boolean = false,
314
)
315
316
/** `GET /api/v1/notifications`: the unread count and the newest entries, unread first. */
317
@Serializable
318
data class Inbox(
319
    val unread: Long = 0,
320
    val items: List<Notification> = emptyList(),
321
)
322
323
/** An RFC 7807 problem: what the API answers with on an error. */
324
@Serializable
325
data class Problem(
326
    val status: Int = 0,
327
    val title: String = "",
328
    val detail: String = "",
329
)
330
331
/** The device flow's errors, RFC 8628's shape: `{"error": …, "error_description": …}`. */
332
@Serializable
333
data class OAuthError(
334
    val error: String = "",
335
    @SerialName("error_description") val description: String = "",
336
)
337
338
/** `POST /api/v1/device/code`'s answer. */
339
@Serializable
340
data class DeviceCode(
341
    @SerialName("device_code") val deviceCode: String,
342
    @SerialName("user_code") val userCode: String,
343
    @SerialName("verification_uri") val verificationUri: String,
344
    @SerialName("verification_uri_complete") val verificationUriComplete: String = "",
345
    @SerialName("expires_in") val expiresIn: Long = 900,
346
    val interval: Long = 5,
347
) {
348
    /** The page to open: with the code filled in when the hub offers it. */
349
    val pageToOpen: String get() = verificationUriComplete.ifBlank { verificationUri }
350
351
    // The device code is a secret until it is used: never in logs.
352
    override fun toString(): String = "DeviceCode(userCode=$userCode, verificationUri=$verificationUri)"
353
}
354
355
/** `POST /api/v1/device/token`'s answer once the user approved. */
356
@Serializable
357
data class TokenGrant(
358
    @SerialName("access_token") val accessToken: String,
359
    @SerialName("token_type") val tokenType: String = "bearer",
360
    /** The scopes the user allowed, space separated (they may untick some). */
361
    val scope: String = "",
362
    val username: String = "",
363
    @SerialName("expires_in") val expiresIn: Long = 0,
364
) {
365
    val scopes: List<String> get() = scope.split(' ').filter { it.isNotBlank() }
366
367
    override fun toString(): String = "TokenGrant(username=$username, scope=$scope, accessToken=<redacted>)"
368
}