Android: fork run approval, Run workflow, and the newer CI statuses

30f666b · Gabriel Morell · 2026-10-17 19:10

38 files +824 -32
Message
{commit_body(@commit)}

Files changed

modified android/app/build.gradle.kts
+4 −0
@@ -114,4 +114,8 @@ dependencies {
114 114 implementation(libs.okhttp.logging)
115 115
116 116 implementation(libs.zxing.android.embedded)
117 +
118 + // Plain JVM tests of the wire shapes and status vocabulary — no
119 + // device, no Robolectric: `./gradlew testDebugUnitTest`.
120 + testImplementation(libs.junit)
117 121 }
modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+2 −0
@@ -229,6 +229,7 @@ fun GitGudApp(
229 229 popUpTo("repo/$owner/$name/run/$number") { inclusive = true }
230 230 }
231 231 },
232 + onOpenPull = { pr -> nav.navigate("repo/$owner/$name/pull/$pr") },
232 233 )
233 234 }
234 235
@@ -274,6 +275,7 @@ fun GitGudApp(
274 275 number = number,
275 276 tokens = tokens,
276 277 onBack = { nav.popBackStack() },
278 + onOpenRun = { run -> nav.navigate("repo/$owner/$name/run/$run") },
277 279 )
278 280 }
279 281
modified android/app/src/main/java/org/neiam/gitgud/app/data/GitGudClient.kt
+44 −7
@@ -82,6 +82,20 @@ interface GitGudApi {
82 82 @Path("number") number: Int,
83 83 ): RunResponse
84 84
85 + /** The default branch's `on: workflow_dispatch` workflows, with their inputs. */
86 + @GET("api/mobile/v1/repos/{owner}/{name}/workflows")
87 + suspend fun workflows(
88 + @Path("owner") owner: String,
89 + @Path("name") name: String,
90 + ): WorkflowsResponse
91 +
92 + @POST("api/mobile/v1/repos/{owner}/{name}/workflows/dispatch")
93 + suspend fun dispatch(
94 + @Path("owner") owner: String,
95 + @Path("name") name: String,
96 + @Body body: DispatchBody,
97 + ): RunResponse
98 +
85 99 // --- issues ---
86 100
87 101 @GET("api/mobile/v1/repos/{owner}/{name}/issues")
@@ -156,6 +170,22 @@ interface GitGudApi {
156 170 @Body body: MergeBody,
157 171 ): PullResponse
158 172
173 + /** Let a fork's waiting runs on the PR's current head go ahead. */
174 + @POST("api/mobile/v1/repos/{owner}/{name}/pulls/{number}/runs/approve")
175 + suspend fun approveForkRuns(
176 + @Path("owner") owner: String,
177 + @Path("name") name: String,
178 + @Path("number") number: Int,
179 + ): RunsOnlyResponse
180 +
181 + /** Cancel a fork's runs that are waiting for approval, on any head. */
182 + @POST("api/mobile/v1/repos/{owner}/{name}/pulls/{number}/runs/reject")
183 + suspend fun rejectForkRuns(
184 + @Path("owner") owner: String,
185 + @Path("name") name: String,
186 + @Path("number") number: Int,
187 + ): RunsOnlyResponse
188 +
159 189 // --- wiki ---
160 190
161 191 @GET("api/mobile/v1/repos/{owner}/{name}/wiki")
@@ -247,13 +277,20 @@ object GitGudClient {
247 277 * "HTTP 403 Forbidden", so unwrap the body first.
248 278 */
249 279 fun describe(e: Throwable): String = when (e) {
250 − is HttpException -> {
251 − val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
252 − val parsed = raw?.let {
253 − runCatching { errorJson.decodeFromString<ApiError>(it).message }.getOrNull()
254 − }
255 − parsed ?: "HTTP ${e.code()}"
256 − }
280 + is HttpException -> apiError(e)?.message ?: "HTTP ${e.code()}"
257 281 else -> e.message ?: e.javaClass.simpleName
258 282 }
283 +
284 + /**
285 + * The parsed error body, or null when there isn't one. Reads the
286 + * body, which OkHttp lets you do once — call this *instead of*
287 + * [describe] when the per-field `errors` matter.
288 + */
289 + fun apiError(e: HttpException): ApiError? {
290 + val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() ?: return null
291 + return runCatching { errorJson.decodeFromString<ApiError>(raw) }.getOrNull()
292 + }
293 +
294 + /** The HTTP status of a failed call, or null when it never got an answer. */
295 + fun httpCode(e: Throwable): Int? = (e as? HttpException)?.code()
259 296 }
modified android/app/src/main/java/org/neiam/gitgud/app/data/Models.kt
+91 −2
@@ -109,7 +109,24 @@ data class Run(
109 109 val completed_at: String? = null,
110 110 val inserted_at: String? = null,
111 111 val triggered_by: ApiUser? = null,
112 + /** The pull request a `pull_request` / `pull_request_target` run is for. */
113 + val pull_request_number: Int? = null,
114 + /** A fork pull request's run: no secrets, a read-only token. */
115 + val fork: Boolean = false,
116 + /** A fork run nobody with write access has approved yet. */
117 + val awaiting_approval: Boolean = false,
112 118 ) {
119 + /**
120 + * What the run's chip says. A run waiting for approval is `queued`
121 + * on the server, but "queued" would promise a runner is coming —
122 + * nothing happens until someone approves it.
123 + */
124 + val displayStatus: String?
125 + get() =
126 + if (awaiting_approval && status in setOf("queued", "running")) "awaiting_approval"
127 + else if (status == "completed" || status == "finished") conclusion ?: status
128 + else status
129 +
113 130 /** Short ref for display: `refs/heads/main` reads as `main`. */
114 131 val shortRef: String?
115 132 get() = head_ref?.removePrefix("refs/heads/")?.removePrefix("refs/tags/")
@@ -136,11 +153,30 @@ data class Job(
136 153 val status: String = "queued",
137 154 val conclusion: String? = null,
138 155 val needs: List<String> = emptyList(),
156 + /** Why it's waiting, skipped or cancelled, worded for display. */
157 + val status_reason: String? = null,
158 + /** `github.run_attempt`: 1, plus one per re-run of the job. */
159 + val attempt: Int = 1,
160 + /** Cancelled because a newer run or job in its concurrency group replaced it. */
161 + val superseded: Boolean = false,
162 + /**
163 + * A dynamic matrix that hasn't expanded yet. When it does, this row
164 + * is deleted and its legs appear with new ids — so nothing should
165 + * hold on to this one's id.
166 + */
167 + val placeholder: Boolean = false,
139 168 val started_at: String? = null,
140 169 val completed_at: String? = null,
141 170 val steps: List<Step> = emptyList(),
142 171 ) {
143 172 val label: String get() = name ?: job_id ?: "job $id"
173 +
174 + /** `superseded` for a job a newer one replaced, else its effective status. */
175 + val displayStatus: String?
176 + get() =
177 + if (superseded) "superseded"
178 + else if (status == "completed" || status == "finished") conclusion ?: status
179 + else status
144 180 }
145 181
146 182 @Serializable
@@ -162,6 +198,38 @@ data class RunDetailResponse(
162 198 val can_manage: Boolean = false,
163 199 )
164 200
201 +/**
202 + * One input of a `workflow_dispatch`, as the workflow declares it.
203 + * `type` is GitHub's: `string`, `boolean`, `choice`, `number` or
204 + * `environment` (a plain string here — the forge has no environments).
205 + */
206 +@Serializable
207 +data class DispatchInput(
208 + val name: String,
209 + val type: String = "string",
210 + val description: String? = null,
211 + val required: Boolean = false,
212 + val default: String? = null,
213 + val options: List<String> = emptyList(),
214 +)
215 +
216 +@Serializable
217 +data class DispatchWorkflow(
218 + val path: String,
219 + val name: String? = null,
220 + val inputs: List<DispatchInput> = emptyList(),
221 +) {
222 + val label: String get() = name ?: path.substringAfterLast('/')
223 +}
224 +
225 +@Serializable
226 +data class WorkflowsResponse(
227 + val workflows: List<DispatchWorkflow> = emptyList(),
228 + val default_branch: String? = null,
229 + val branches: List<String> = emptyList(),
230 + val can_dispatch: Boolean = false,
231 +)
232 +
165 233 @Serializable
166 234 data class LogLine(val seq: Long, val body: String = "")
167 235
@@ -272,6 +340,10 @@ data class PullDetailResponse(
272 340 val can_review: Boolean = false,
273 341 val can_merge: Boolean = false,
274 342 val can_override: Boolean = false,
343 + /** A fork's runs on the current head, waiting for someone to approve them. */
344 + val awaiting_approval_runs: List<Run> = emptyList(),
345 + /** Whether the viewer may approve (or reject) them: write access. */
346 + val can_approve_runs: Boolean = false,
275 347 )
276 348
277 349 // ── wiki ─────────────────────────────────────────────────────────────
@@ -312,6 +384,17 @@ data class ReviewBody(val state: String, val body: String? = null)
312 384 @Serializable
313 385 data class MergeBody(val message: String? = null, val override: Boolean? = null)
314 386
387 +/** Inputs go as strings — `"true"`/`"false"` for a boolean — as GitHub's API takes them. */
388 +@Serializable
389 +data class DispatchBody(
390 + val workflow: String,
391 + val ref: String? = null,
392 + val inputs: Map<String, String> = emptyMap(),
393 +)
394 +
395 +@Serializable
396 +data class RunsOnlyResponse(val runs: List<Run> = emptyList())
397 +
315 398 @Serializable
316 399 data class CommentResponse(val comment: Comment)
317 400
@@ -434,6 +517,12 @@ data class UsageStep(
434 517 val code: String,
435 518 )
436 519
437 −/** The server's error shape — `{"message": "..."}` on every 4xx. */
520 +/**
521 + * The server's error shape — `{"message": "..."}` on every 4xx, plus
522 + * `errors` (input name → reason) when a dispatch's inputs don't fit.
523 + */
438 524 @Serializable
439 −data class ApiError(val message: String? = null)
525 +data class ApiError(
526 + val message: String? = null,
527 + val errors: Map<String, String> = emptyMap(),
528 +)
modified android/app/src/main/java/org/neiam/gitgud/app/data/RepoSort.kt
+2 −2
@@ -67,8 +67,8 @@ private val byCi = compareBy<Repository> { ciRank(it.ci_status) }.then(byPushed)
67 67 private fun ciRank(status: String?): Int = when (status) {
68 68 "failure", "timed_out" -> 0
69 69 "running" -> 1
70 − "queued", "blocked", "waiting", "pending" -> 2
71 − "cancelled" -> 3
70 + "queued", "blocked", "waiting", "awaiting_approval", "pending" -> 2
71 + "cancelled", "superseded" -> 3
72 72 "success" -> 4
73 73 else -> 5
74 74 }
modified android/app/src/main/java/org/neiam/gitgud/app/ui/CiTab.kt
+46 −2

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/ui/DispatchSheet.kt
+280 −0

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/LogScreen.kt
+21 −3

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/PullScreen.kt
+98 −0

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/RunScreen.kt
+64 −4

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/RunTimes.kt
+2 −2

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/StatusChip.kt
+37 −4

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/watch/RunWatch.kt
+7 −1

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetModel.kt
+5 −4

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetRenderer.kt
+2 −1

Click to load diff…

added android/app/src/test/java/org/neiam/gitgud/app/data/CiModelsTest.kt
+116 −0

Click to load diff…

modified android/gradle/libs.versions.toml
+3 −0

Click to load diff…

Parents: 1d7ef31