Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,8 @@ describe("dashboard routing and API query helpers", () => {

it("limits retry actions to manually recoverable job states", () => {
expect(isRetryableStatus("blocked")).toBe(true);
expect(isRetryableStatus("denied")).toBe(true);
expect(isRetryableStatus("waiting_approval")).toBe(true);
expect(isRetryableStatus("denied")).toBe(false);
expect(isRetryableStatus("waiting_approval")).toBe(false);
expect(isRetryableStatus("pending")).toBe(false);
expect(isRetryableStatus("running")).toBe(false);
expect(isRetryableStatus("done")).toBe(false);
Expand Down Expand Up @@ -812,7 +812,7 @@ describe("status badges", () => {
thread: 58,
status: "blocked",
action: "reply_comment",
decision: "allowed",
decision: "auto_trusted",
intent: "work_allowed",
subject: "Needs a guarded retry from the list",
trigger_actor: "ecarreras",
Expand Down
17 changes: 11 additions & 6 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,10 @@ function shouldRefreshJobForSessionEvent(eventType: string) {
}

function isRetryableStatus(status: string) {
return status === "blocked";
}

function isDismissableStatus(status: string) {
return ["blocked", "denied", "waiting_approval"].includes(status);
}

Expand Down Expand Up @@ -2081,7 +2085,8 @@ function JobDetailPage({
const [retrying, setRetrying] = React.useState(false);
const [dismissing, setDismissing] = React.useState(false);
const [cancelling, setCancelling] = React.useState(false);
const canRetry = Boolean(user?.is_admin && selectedJob && isRetryableStatus(selectedJob.status));
const canRetry = Boolean(user?.is_admin && selectedJob && isRetryableStatus(selectedJob.status) && selectedJob.decision === "auto_trusted");
const canDismiss = Boolean(user?.is_admin && selectedJob && isDismissableStatus(selectedJob.status));
const canCancel = Boolean(selectedJob && canCancelJob(selectedJob, user));
const retryLabel = retrying ? "Retrying..." : "Retry";
const dismissLabel = dismissing ? "Dismissing..." : "Dismiss";
Expand Down Expand Up @@ -2121,7 +2126,7 @@ function JobDetailPage({
{retryLabel}
</button>
) : null}
{canRetry ? (
{canDismiss ? (
<button
className="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border bg-white px-3 text-sm font-semibold text-foreground hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
type="button"
Expand Down Expand Up @@ -3403,10 +3408,10 @@ function JobsList({
job={job}
onViewJob={onViewJob}
now={now}
canRetry={canRetryFromList && isRetryableStatus(job.status)}
canRetry={canRetryFromList && isRetryableStatus(job.status) && job.decision === "auto_trusted"}
retrying={retryingJobId === job.id}
onRetry={retryJobFromList}
canDismiss={canDismissFromList && isRetryableStatus(job.status)}
canDismiss={canDismissFromList && isDismissableStatus(job.status)}
dismissing={dismissingJobId === job.id}
onDismiss={dismissJobFromList}
canCancel={Boolean(onCancel && canCancelJob(job, user))}
Expand Down Expand Up @@ -3436,10 +3441,10 @@ function JobsList({
job={job}
now={now}
onViewJob={onViewJob}
canRetry={canRetryFromList && isRetryableStatus(job.status)}
canRetry={canRetryFromList && isRetryableStatus(job.status) && job.decision === "auto_trusted"}
retrying={retryingJobId === job.id}
onRetry={retryJobFromList}
canDismiss={canDismissFromList && isRetryableStatus(job.status)}
canDismiss={canDismissFromList && isDismissableStatus(job.status)}
dismissing={dismissingJobId === job.id}
onDismiss={dismissJobFromList}
canCancel={Boolean(onCancel && canCancelJob(job, user))}
Expand Down
7 changes: 4 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -732,9 +732,10 @@ gab --db ~/.local/state/github-agent-bridge/bridge.sqlite3 retry <job-id>
```

Dashboard users listed in `GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS` or
`GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS` can also sign in and retry blocked,
denied, or waiting-approval jobs from the job detail page. The dashboard records
the admin login in the job worklog.
`GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS` can also sign in and retry blocked
jobs from the job detail page. Jobs denied by policy or awaiting approval cannot
be retried; a new event must pass policy. The dashboard records the admin login
in the job worklog.

For `reply_comment` jobs, the executor checks GitHub before dispatching. If the
authenticated bot has already commented after the triggering issue comment, the
Expand Down
187 changes: 187 additions & 0 deletions src/github_agent_bridge/dashboard_static/assets/index-BUQ_5OwA.js

Large diffs are not rendered by default.

187 changes: 0 additions & 187 deletions src/github_agent_bridge/dashboard_static/assets/index-DBHrKV9X.js

This file was deleted.

2 changes: 1 addition & 1 deletion src/github_agent_bridge/dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Agent Bridge Dashboard</title>
<script type="module" crossorigin src="/assets/index-DBHrKV9X.js"></script>
<script type="module" crossorigin src="/assets/index-BUQ_5OwA.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-BRAUfDnL.css">
</head>
Expand Down
2 changes: 1 addition & 1 deletion src/github_agent_bridge/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,7 @@ def retry(self, job_id: int, *, actor: str | None = None) -> bool:
now = utc_now()
summary = f"job requeued by @{actor}" if actor else "job requeued"
with self.connect() as con:
cur = con.execute("UPDATE jobs SET status='pending', locked_by=NULL, last_error=NULL, updated_at=? WHERE id=? AND status IN ('blocked','denied','waiting_approval')", (now, job_id))
cur = con.execute("UPDATE jobs SET status='pending', locked_by=NULL, last_error=NULL, updated_at=? WHERE id=? AND status='blocked' AND decision='auto_trusted'", (now, job_id))
if cur.rowcount:
row = con.execute("SELECT work_key FROM jobs WHERE id=?", (job_id,)).fetchone()
self._log(con, job_id, row["work_key"] if row else None, "retry", summary, None)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,24 @@ def test_dashboard_retry_rejects_non_retryable_jobs(tmp_path):
assert response.json()["detail"] == "job_not_retryable"


@pytest.mark.parametrize("decision,status", [("deny", "denied"), ("ask", "waiting_approval")])
def test_dashboard_retry_cannot_override_policy_decision(tmp_path, decision, status):
db = tmp_path / "bridge.sqlite3"
q = JobQueue(db)
job, _ = q.enqueue(notif(), Policy(trusted_orgs=["gisce"]))
with q.connect() as con:
con.execute("UPDATE jobs SET decision=?, status=? WHERE id=?", (decision, status, job.id))
app = create_app(DashboardConfig(db=db, secret_key="secret", allowed_users={"alice"}, admin_users={"alice"}))
client = TestClient(app)
client.cookies.set("gab_dashboard_session", _sign(app.state.dashboard_config, _encode_session({"login": "Alice"}, is_admin=True)))

response = client.post(f"/api/jobs/{job.id}/retry")

assert response.status_code == 409
assert response.json()["detail"] == "job_not_retryable"
assert q.get(job.id).status == status


def test_dashboard_dismiss_requires_admin_and_marks_recoverable_job_done(tmp_path):
db = tmp_path / "bridge.sqlite3"
q = JobQueue(db)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ def intent_policy(**kwargs):
)


@pytest.mark.parametrize("decision,status", [("deny", "denied"), ("ask", "waiting_approval")])
def test_retry_cannot_override_policy_decision(tmp_path, decision, status):
q = JobQueue(tmp_path / "q.sqlite3")
job, _ = q.enqueue(notif(1, "<policy@github.com>", BODY1), policy())
with q.connect() as con:
con.execute("UPDATE jobs SET decision=?, status=? WHERE id=?", (decision, status, job.id))

assert q.retry(job.id, actor="admin") is False
assert q.get(job.id).status == status
with q.connect() as con:
assert con.execute(
"SELECT count(*) FROM worklog WHERE job_id=? AND phase='retry'", (job.id,)
).fetchone()[0] == 0


def test_retry_blocked_job_requires_executable_decision(tmp_path):
q = JobQueue(tmp_path / "q.sqlite3")
job, _ = q.enqueue(notif(1, "<blocked@github.com>", BODY1), policy())
q.finish(job.id, "blocked", "failed", "boom")

assert q.retry(job.id, actor="admin") is True
assert q.get(job.id).status == "pending"

q.finish(job.id, "blocked", "failed", "boom")
with q.connect() as con:
con.execute("UPDATE jobs SET decision='deny' WHERE id=?", (job.id,))
assert q.retry(job.id, actor="admin") is False
assert q.get(job.id).status == "blocked"


def test_queue_expands_user_in_db_path(tmp_path, monkeypatch):
home = tmp_path / "home"
monkeypatch.setenv("HOME", str(home))
Expand Down
Loading