neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android: fork run approval, Run workflow, and the newer CI statuses
30f666b · Gabriel Morell · 2026-10-17 19:10
Message
{commit_body(@commit)}
Files changed
modified
android/app/build.gradle.kts
+4
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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