Skip to content

Commit c94aaa4

Browse files
Merge pull request #403 from nazarli-shabnam/fix/cache-clear-activity-feed
Fix/cache clear activity feed
2 parents 7af9018 + c1ffe77 commit c94aaa4

16 files changed

Lines changed: 623 additions & 101 deletions

File tree

apps/api/src/repositories/job_repo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ def list_jobs(db: Session, limit: int = 50) -> list[dict]:
3030
]
3131

3232

33+
def get_job(db: Session, job_id: int) -> Job | None:
34+
return db.query(Job).filter(Job.id == job_id).one_or_none()
35+
36+
3337
def list_recent_by_type(db: Session, job_type: str, limit: int = 20) -> list[dict]:
3438
rows = (
3539
db.query(Job)

apps/api/src/routers/github.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,14 @@ def org_events(
217217
# call site instead of baking the check into the shared repository function.
218218
installation = installation_repo.get_for_org(db, org_id=ctx.org.id, account_login=org_login)
219219
if installation is not None and installation.installation_id is not None:
220-
return _fetch_events_from_repo_events(db, org_login, ctx.org.tenant_id, payload.per_page)
220+
from_db = _fetch_events_from_repo_events(db, org_login, ctx.org.tenant_id, payload.per_page)
221+
if from_db.events:
222+
return from_db
223+
# repo_events is empty for this tenant. That normally means the GitHub App isn't
224+
# subscribed to the push/pull_request/issues/release/create webhooks yet (see
225+
# docs/self-hosting.md), or the one-time install backfill has aged out. Rather
226+
# than show a permanently blank feed, fall through to the live-GitHub read below
227+
# -- resolve_org_token already prefers the installation token.
221228

222229
client_token = payload.token.get_secret_value() if payload.token else None
223230
try:

apps/api/src/routers/jobs.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
1-
from fastapi import APIRouter, Depends
1+
import json
2+
3+
from fastapi import APIRouter, Depends, HTTPException
24
from sqlalchemy.orm import Session
35

4-
from src.core.auth import UserOut, require_workspace_admin
6+
from src.core.auth import UserOut, require_auth, require_workspace_admin
57
from src.core.db import get_db
68
from src.repositories import job_repo
79
from src.schemas.job import JobOut
810

911
router = APIRouter()
1012

13+
# Job types a non-admin caller is allowed to poll by id, and which record the enqueuing
14+
# user's email in their payload (`actor`) so we can scope the read to its owner.
15+
_SELF_READABLE_JOB_TYPES = {"github.clear_actions_cache"}
16+
1117

1218
@router.get("", response_model=list[JobOut])
1319
def jobs(db: Session = Depends(get_db), _user: UserOut = Depends(require_workspace_admin)):
1420
return job_repo.list_jobs(db)
21+
22+
23+
@router.get("/{job_id}", response_model=JobOut)
24+
def job(job_id: int, db: Session = Depends(get_db), user: UserOut = Depends(require_auth)):
25+
# The cache-clear panel polls this to show a queued job's real terminal status
26+
# (done/failed) instead of claiming success the moment it's enqueued. The `jobs` table
27+
# has no owner/tenant column, so to avoid cross-tenant id enumeration this is scoped to
28+
# the job's own `actor` (the email of the user who enqueued it) and to job types that
29+
# record one. Anything else 404s -- workspace admins use the list endpoint above.
30+
row = job_repo.get_job(db, job_id)
31+
if row is None or row.job_type not in _SELF_READABLE_JOB_TYPES:
32+
raise HTTPException(status_code=404, detail="Unknown job")
33+
try:
34+
actor = json.loads(row.payload or "{}").get("actor")
35+
except (ValueError, TypeError):
36+
actor = None
37+
if actor != user.email:
38+
raise HTTPException(status_code=404, detail="Unknown job")
39+
return row

apps/api/src/routers/webhooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
POST /webhooks/github verifies X-Hub-Signature-256, then handles installation
44
lifecycle events to keep github_installations in sync, and
55
durably queues a bounded set of event types (issue #191/S3)
6-
for a future event-processor fleet (S4, not built yet).
6+
for the event-processor fleet (S4, apps/worker/src/event_consumer.py).
77
"""
88

99
import hashlib

apps/api/tests/test_github_events.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,23 @@ def test_events_falls_back_to_live_github_when_the_installation_has_no_installat
401401
assert resp.json()["events"][0]["actor"] == "alice"
402402

403403

404+
def test_events_fall_back_to_live_github_when_installation_connected_but_repo_events_empty(
405+
events_client, db, acme_org_with_installation
406+
):
407+
# An App-connected org whose repo_events table is still empty (webhooks not yet
408+
# subscribed, or the install-time backfill aged out). Instead of a permanently blank
409+
# feed, org_events must fall through to the live-GitHub read.
410+
with patch("src.routers.github.GitHubClient") as mock_client:
411+
mock_client.return_value.request.return_value = [_PUSH_EVENT]
412+
resp = events_client.post(
413+
"/github/orgs/acme/events", json={"token": "ghp_testtoken123456789012345678901234"}
414+
)
415+
416+
assert resp.status_code == 200
417+
mock_client.assert_called_once()
418+
assert resp.json()["events"][0]["actor"] == "alice"
419+
420+
404421
def test_events_falls_back_to_live_github_when_no_installation_is_connected(events_client, acme_org):
405422
# acme_org (no installation fixture) -- confirms the hybrid still uses the unchanged
406423
# live-GitHub path for a legacy PAT-only org, not an empty feed.

apps/api/tests/test_owner_only_routes.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,48 @@ def test_jobs_non_owner_forbidden(db):
4343
assert resp.status_code == 403
4444

4545

46+
def test_single_clear_job_readable_by_the_user_who_enqueued_it(db):
47+
# GET /jobs/{id} is only require_auth (the cache-clear panel polls it), but scoped to
48+
# the job's own `actor` -- a non-workspace-admin who enqueued the clear can read it.
49+
db.execute(
50+
text(
51+
"INSERT INTO jobs (id, job_type, payload, status, result) VALUES "
52+
"(9991, 'github.clear_actions_cache', '{\"actor\": \"member@example.com\"}', "
53+
"'done', '{\"ok\": true, \"deleted\": 2}')"
54+
)
55+
)
56+
resp = _client(jobs_router, db, _NON_OWNER, prefix="/jobs").get("/jobs/9991")
57+
assert resp.status_code == 200
58+
assert resp.json()["status"] == "done"
59+
60+
61+
def test_single_job_hidden_from_a_different_user(db):
62+
db.execute(
63+
text(
64+
"INSERT INTO jobs (id, job_type, payload, status) VALUES "
65+
"(9992, 'github.clear_actions_cache', '{\"actor\": \"someone-else@example.com\"}', 'done')"
66+
)
67+
)
68+
resp = _client(jobs_router, db, _NON_OWNER, prefix="/jobs").get("/jobs/9992")
69+
assert resp.status_code == 404
70+
71+
72+
def test_single_job_of_a_non_self_readable_type_is_404(db):
73+
db.execute(
74+
text(
75+
"INSERT INTO jobs (id, job_type, payload, status) VALUES "
76+
"(9993, 'github.backfill_repo_events', '{\"actor\": \"member@example.com\"}', 'done')"
77+
)
78+
)
79+
resp = _client(jobs_router, db, _NON_OWNER, prefix="/jobs").get("/jobs/9993")
80+
assert resp.status_code == 404
81+
82+
83+
def test_single_job_unknown_id_is_404(db):
84+
resp = _client(jobs_router, db, _NON_OWNER, prefix="/jobs").get("/jobs/424242")
85+
assert resp.status_code == 404
86+
87+
4688
# ── audit ─────────────────────────────────────────────────────────────────────
4789

4890
def test_audit_owner_ok(db):

apps/ui/app/activity/page.tsx

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import { useEffect, useState } from "react"
3+
import { useEffect } from "react"
44
import Link from "next/link"
55
import { useQuery } from "@tanstack/react-query"
66
import { PageHeader } from "@/components/page-header"
@@ -18,25 +18,6 @@ import { useActiveScope } from "@/lib/active-scope"
1818
const EVENTS_REFRESH_SECONDS = 30
1919
const HEATMAP_COLOR_SCALE = [CHART_COLORS.grid, "#1d4ed8", "#3b82f6", "#60a5fa", "#93c5fd"]
2020

21-
// Isolated into its own component so the 1s tick only re-renders this small chip,
22-
// not the whole page (and the feed/job lists below it).
23-
function RefreshCountdown({ resetKey, seconds }: { resetKey: number; seconds: number }) {
24-
const [remaining, setRemaining] = useState(seconds)
25-
26-
useEffect(() => {
27-
setRemaining(seconds)
28-
}, [resetKey, seconds])
29-
30-
useEffect(() => {
31-
const interval = setInterval(() => {
32-
setRemaining((r) => (r > 0 ? r - 1 : 0))
33-
}, 1000)
34-
return () => clearInterval(interval)
35-
}, [])
36-
37-
return <span className="stat-chip">refreshes in {remaining}s</span>
38-
}
39-
4021
export default function ActivityPage() {
4122
// Marks all cockpit-sourced events as read so the sidebar's unread badge
4223
// clears once the user has actually looked at this page.
@@ -78,10 +59,6 @@ export default function ActivityPage() {
7859
refetchInterval: EVENTS_REFRESH_SECONDS * 1000,
7960
})
8061

81-
// Reset the countdown on either a successful fetch OR a failed one, so it always
82-
// tracks the real refetchInterval cadence instead of sticking at 0 after an error.
83-
const lastAttemptAt = Math.max(eventsQuery.dataUpdatedAt, eventsQuery.errorUpdatedAt)
84-
8562
// Heatmap data rides on the personal cockpit endpoint (commit_heatmap_52w) --
8663
// that endpoint is personal-scoped (no OrgMembership needed), unlike the
8764
// org-scoped failed-runs/release-timeline calls below, but the same resolved
@@ -127,7 +104,7 @@ export default function ActivityPage() {
127104
<div className="lg:col-span-2 card">
128105
<div className="px-4 py-3 border-b border-border flex items-center justify-between gap-3">
129106
<span className="section-label">Activity Feed</span>
130-
{hasOrg && <RefreshCountdown resetKey={lastAttemptAt} seconds={EVENTS_REFRESH_SECONDS} />}
107+
{hasOrg && <span className="stat-chip">auto-refreshes every {EVENTS_REFRESH_SECONDS}s</span>}
131108
</div>
132109
{!hasOrg ? (
133110
<EmptyStateNoAccount bare />

0 commit comments

Comments
 (0)