|
24 | 24 | from src.core.rbac import OrgContext, assert_owner_matches_org, require_org_role |
25 | 25 | from src.repositories import audit_repo, tenant_repo |
26 | 26 | from src.schemas.automation import ( |
| 27 | + DispatchAllInput, |
| 28 | + DispatchAllResponse, |
| 29 | + DispatchAllResult, |
27 | 30 | DispatchInput, |
28 | 31 | DispatchResponse, |
29 | 32 | RunSummary, |
@@ -143,6 +146,118 @@ def _dispatch( |
143 | 146 | return DispatchResponse(dispatched=True, message="Workflow dispatched.") |
144 | 147 |
|
145 | 148 |
|
| 149 | +# Bounds one bulk-dispatch request: each active workflow is one sequential GitHub POST, |
| 150 | +# and the request shouldn't fan out unboundedly. Repos with more workflows than this |
| 151 | +# should dispatch individually. |
| 152 | +_BULK_DISPATCH_MAX = 40 |
| 153 | + |
| 154 | + |
| 155 | +def _github_message(exc: httpx.HTTPStatusError) -> str: |
| 156 | + try: |
| 157 | + return exc.response.json().get("message") or "" |
| 158 | + except (ValueError, AttributeError): |
| 159 | + return "" |
| 160 | + |
| 161 | + |
| 162 | +def _list_all_workflows(client: GitHubClient, owner: str, repo: str) -> list[dict]: |
| 163 | + """Every workflow across every page. GitHub's `/actions/workflows` returns a |
| 164 | + ``{total_count, workflows}`` object (not a bare array), so request_paginated's |
| 165 | + Link-following can't be reused -- page through it explicitly instead.""" |
| 166 | + workflows: list[dict] = [] |
| 167 | + page = 1 |
| 168 | + while True: |
| 169 | + data = client.request( |
| 170 | + "GET", |
| 171 | + f"/repos/{owner}/{repo}/actions/workflows", |
| 172 | + params={"per_page": 100, "page": page}, |
| 173 | + ) |
| 174 | + batch = data.get("workflows", []) |
| 175 | + workflows.extend(batch) |
| 176 | + total = data.get("total_count") |
| 177 | + # Stop on an empty page (guards against a misbehaving API and infinite loops), |
| 178 | + # a short page, or once total_count says we've seen everything. |
| 179 | + if not batch or len(batch) < 100 or (total is not None and len(workflows) >= total): |
| 180 | + break |
| 181 | + page += 1 |
| 182 | + return workflows |
| 183 | + |
| 184 | + |
| 185 | +def _dispatch_all( |
| 186 | + db: Session, |
| 187 | + owner: str, |
| 188 | + repo: str, |
| 189 | + payload: DispatchAllInput, |
| 190 | + token: str, |
| 191 | + actor: str, |
| 192 | + tenant_id: int | None = None, |
| 193 | +) -> DispatchAllResponse: |
| 194 | + client = GitHubClient(token) |
| 195 | + try: |
| 196 | + all_workflows = _list_all_workflows(client, owner, repo) |
| 197 | + except (httpx.HTTPStatusError, httpx.RequestError) as exc: |
| 198 | + raise _github_error(exc) from exc |
| 199 | + |
| 200 | + active = [w for w in all_workflows if w.get("state") == "active"] |
| 201 | + if len(active) > _BULK_DISPATCH_MAX: |
| 202 | + raise HTTPException( |
| 203 | + status_code=422, |
| 204 | + detail=f"Too many workflows for bulk dispatch ({_BULK_DISPATCH_MAX} max); dispatch individually.", |
| 205 | + ) |
| 206 | + |
| 207 | + results: list[DispatchAllResult] = [] |
| 208 | + for w in active: |
| 209 | + wf_id, name = w["id"], w["name"] |
| 210 | + # One audit row per attempted workflow, written before the call -- same |
| 211 | + # convention as _dispatch, so a rejected bulk dispatch still leaves a record. |
| 212 | + audit_repo.write( |
| 213 | + db, |
| 214 | + actor, |
| 215 | + "automation.workflow.dispatch", |
| 216 | + f"{owner}/{repo}#{wf_id}", |
| 217 | + {"ref": payload.ref, "inputs": {}, "bulk": True}, |
| 218 | + tenant_id=tenant_id, |
| 219 | + ) |
| 220 | + try: |
| 221 | + client.request( |
| 222 | + "POST", |
| 223 | + f"/repos/{owner}/{repo}/actions/workflows/{wf_id}/dispatches", |
| 224 | + json={"ref": payload.ref, "inputs": {}}, |
| 225 | + ) |
| 226 | + except httpx.HTTPStatusError as exc: |
| 227 | + message = _github_message(exc) |
| 228 | + if exc.response.status_code == 422 and "workflow_dispatch" in message: |
| 229 | + results.append( |
| 230 | + DispatchAllResult( |
| 231 | + workflow_id=wf_id, name=name, status="skipped", |
| 232 | + message="No workflow_dispatch trigger", |
| 233 | + ) |
| 234 | + ) |
| 235 | + else: |
| 236 | + results.append( |
| 237 | + DispatchAllResult( |
| 238 | + workflow_id=wf_id, name=name, status="failed", |
| 239 | + message=message or f"GitHub API error: {exc.response.status_code}", |
| 240 | + ) |
| 241 | + ) |
| 242 | + except httpx.RequestError: |
| 243 | + results.append( |
| 244 | + DispatchAllResult( |
| 245 | + workflow_id=wf_id, name=name, status="failed", |
| 246 | + message="GitHub API unreachable", |
| 247 | + ) |
| 248 | + ) |
| 249 | + else: |
| 250 | + results.append(DispatchAllResult(workflow_id=wf_id, name=name, status="dispatched")) |
| 251 | + |
| 252 | + return DispatchAllResponse( |
| 253 | + ref=payload.ref, |
| 254 | + results=results, |
| 255 | + dispatched_count=sum(r.status == "dispatched" for r in results), |
| 256 | + skipped_count=sum(r.status == "skipped" for r in results), |
| 257 | + failed_count=sum(r.status == "failed" for r in results), |
| 258 | + ) |
| 259 | + |
| 260 | + |
146 | 261 | # ── org-scoped ─────────────────────────────────────────────────────────────── |
147 | 262 |
|
148 | 263 | @router.get("/orgs/{org_login}/repos/{owner}/{repo}/workflows", response_model=WorkflowsResponse) |
@@ -200,6 +315,25 @@ def org_dispatch_workflow( |
200 | 315 | return _dispatch(db, owner, repo, workflow_id, payload, token, actor=user.email, tenant_id=ctx.org.tenant_id) |
201 | 316 |
|
202 | 317 |
|
| 318 | +@router.post("/orgs/{org_login}/repos/{owner}/{repo}/workflows/dispatch-all", response_model=DispatchAllResponse) |
| 319 | +def org_dispatch_all_workflows( |
| 320 | + org_login: str, |
| 321 | + owner: str, |
| 322 | + repo: str, |
| 323 | + payload: DispatchAllInput, |
| 324 | + ctx: OrgContext = Depends(require_org_role(min_role="admin")), |
| 325 | + user: UserOut = Depends(require_auth), |
| 326 | + db: Session = Depends(get_db), |
| 327 | +): |
| 328 | + assert_owner_matches_org(owner, ctx) |
| 329 | + client_token = payload.token.get_secret_value() if payload.token else None |
| 330 | + try: |
| 331 | + token = resolve_org_token(db, org_id=ctx.org.id, account_login=owner, client_token=client_token) |
| 332 | + except NoGitHubTokenAvailable as exc: |
| 333 | + raise HTTPException(status_code=400, detail=str(exc)) |
| 334 | + return _dispatch_all(db, owner, repo, payload, token, actor=user.email, tenant_id=ctx.org.tenant_id) |
| 335 | + |
| 336 | + |
203 | 337 | # ── personal-scoped ────────────────────────────────────────────────────────── |
204 | 338 |
|
205 | 339 | @router.get("/me/repos/{owner}/{repo}/workflows", response_model=WorkflowsResponse) |
@@ -251,3 +385,22 @@ def personal_dispatch_workflow( |
251 | 385 | raise HTTPException(status_code=400, detail=str(exc)) |
252 | 386 | personal_tenant = tenant_repo.ensure_personal_tenant(db, user.id) |
253 | 387 | return _dispatch(db, owner, repo, workflow_id, payload, token, actor=user.email, tenant_id=personal_tenant.id) |
| 388 | + |
| 389 | + |
| 390 | +@router.post("/me/repos/{owner}/{repo}/workflows/dispatch-all", response_model=DispatchAllResponse) |
| 391 | +def personal_dispatch_all_workflows( |
| 392 | + owner: str, |
| 393 | + repo: str, |
| 394 | + payload: DispatchAllInput, |
| 395 | + user: UserOut = Depends(require_auth), |
| 396 | + db: Session = Depends(get_db), |
| 397 | +): |
| 398 | + client_token = payload.token.get_secret_value() if payload.token else None |
| 399 | + try: |
| 400 | + token = resolve_owner_token(db, user_id=user.id, owner=owner, client_token=client_token, min_role="admin") |
| 401 | + except InsufficientOrgRole as exc: |
| 402 | + raise HTTPException(status_code=403, detail=str(exc)) |
| 403 | + except NoGitHubTokenAvailable as exc: |
| 404 | + raise HTTPException(status_code=400, detail=str(exc)) |
| 405 | + personal_tenant = tenant_repo.ensure_personal_tenant(db, user.id) |
| 406 | + return _dispatch_all(db, owner, repo, payload, token, actor=user.email, tenant_id=personal_tenant.id) |
0 commit comments