Skip to content

Commit bd055d4

Browse files
Merge pull request #404 from nazarli-shabnam/fix/workflow-dispatch-403-and-bulk
feat(automation): surface real GitHub dispatch error + add bulk workflow dispatch
2 parents c94aaa4 + cfefc9a commit bd055d4

14 files changed

Lines changed: 690 additions & 15 deletions

File tree

apps/api/src/routers/automation.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from src.core.rbac import OrgContext, assert_owner_matches_org, require_org_role
2525
from src.repositories import audit_repo, tenant_repo
2626
from src.schemas.automation import (
27+
DispatchAllInput,
28+
DispatchAllResponse,
29+
DispatchAllResult,
2730
DispatchInput,
2831
DispatchResponse,
2932
RunSummary,
@@ -143,6 +146,118 @@ def _dispatch(
143146
return DispatchResponse(dispatched=True, message="Workflow dispatched.")
144147

145148

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+
146261
# ── org-scoped ───────────────────────────────────────────────────────────────
147262

148263
@router.get("/orgs/{org_login}/repos/{owner}/{repo}/workflows", response_model=WorkflowsResponse)
@@ -200,6 +315,25 @@ def org_dispatch_workflow(
200315
return _dispatch(db, owner, repo, workflow_id, payload, token, actor=user.email, tenant_id=ctx.org.tenant_id)
201316

202317

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+
203337
# ── personal-scoped ──────────────────────────────────────────────────────────
204338

205339
@router.get("/me/repos/{owner}/{repo}/workflows", response_model=WorkflowsResponse)
@@ -251,3 +385,22 @@ def personal_dispatch_workflow(
251385
raise HTTPException(status_code=400, detail=str(exc))
252386
personal_tenant = tenant_repo.ensure_personal_tenant(db, user.id)
253387
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)

apps/api/src/schemas/automation.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import Annotated
2+
from typing import Annotated, Literal
33

44
from pydantic import BaseModel, Field, SecretStr
55

@@ -54,3 +54,27 @@ class DispatchInput(BaseModel):
5454
class DispatchResponse(BaseModel):
5555
dispatched: bool
5656
message: str | None = None
57+
58+
59+
class DispatchAllInput(BaseModel):
60+
# Same token fallback as DispatchInput. No per-workflow `inputs` -- a single
61+
# inputs dict can't sensibly apply across every workflow in the repo.
62+
token: SecretStr | None = None
63+
ref: str = Field(max_length=255)
64+
65+
66+
class DispatchAllResult(BaseModel):
67+
workflow_id: int
68+
name: str
69+
# "skipped" = the workflow has no `workflow_dispatch` trigger (GitHub 422); this
70+
# is an expected outcome for a bulk fire, not a failure.
71+
status: Literal["dispatched", "skipped", "failed"]
72+
message: str | None = None
73+
74+
75+
class DispatchAllResponse(BaseModel):
76+
ref: str
77+
results: list[DispatchAllResult]
78+
dispatched_count: int
79+
skipped_count: int
80+
failed_count: int

apps/api/src/services/app_permissions.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
The permission keys and levels are GitHub's own (see the REST "Get an installation"
1212
response ``permissions`` object): ``administration``/``issues``/``pull_requests``/
1313
``contents``/``vulnerability_alerts`` (Dependabot alerts)/``security_events`` (code
14-
scanning)/``secret_scanning_alerts``/``members`` accept ``read``|``write``; ``workflows``
15-
is ``write``-only.
14+
scanning)/``secret_scanning_alerts``/``members``/``actions`` accept ``read``|``write``;
15+
``workflows`` is ``write``-only.
1616
"""
1717

1818
from __future__ import annotations
@@ -73,6 +73,10 @@ class FeatureSpec:
7373
"Dependabot auto-triage",
7474
{"pull_requests": "write", "contents": "write"},
7575
),
76+
"workflow_dispatch": FeatureSpec(
77+
"Workflow dispatch (Automation page)",
78+
{"actions": "write"},
79+
),
7680
}
7781

7882

apps/api/src/services/github_client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ def github_error(exc: Exception) -> HTTPException:
2121
"""Map an httpx exception raised by GitHubClient into the HTTPException every
2222
GitHub-proxying router returns to its caller."""
2323
if isinstance(exc, httpx.HTTPStatusError):
24-
return HTTPException(status_code=400, detail=f"GitHub API error: {exc.response.status_code}")
24+
detail = f"GitHub API error: {exc.response.status_code}"
25+
try:
26+
message = exc.response.json().get("message")
27+
except (ValueError, AttributeError):
28+
message = None
29+
if message:
30+
detail = f"{detail}: {message}"
31+
return HTTPException(status_code=400, detail=detail)
2532
if isinstance(exc, httpx.RequestError):
2633
return HTTPException(status_code=503, detail="GitHub API unreachable")
2734
raise exc

apps/api/tests/test_app_permissions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,20 @@ def test_blocked_features_full_write_grant_unblocks_all():
4242
"pull_requests": "write",
4343
"contents": "write",
4444
"workflows": "write",
45+
"actions": "write",
4546
}
4647
assert app_permissions.blocked_features(granted) == []
4748

4849

50+
def test_workflow_dispatch_needs_actions_write():
51+
# Actions: read (enough to list workflows) does not satisfy the dispatch write need.
52+
blocked = {b.feature: b.missing for b in app_permissions.blocked_features({"actions": "read"})}
53+
assert blocked["workflow_dispatch"] == {"actions": "write"}
54+
assert "workflow_dispatch" not in {
55+
b.feature for b in app_permissions.blocked_features({"actions": "write"})
56+
}
57+
58+
4959
def test_blocked_features_order_is_stable():
5060
blocked = app_permissions.blocked_features({})
5161
assert [b.feature for b in blocked] == list(app_permissions.FEATURE_PERMISSIONS)

0 commit comments

Comments
 (0)