From cbf08a1bdf275bab2bc4daa7144e285a6f866980 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 05:24:18 +0000 Subject: [PATCH 1/8] fix(engineering-analytics): span run-activity chart over full window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow run-activity chart reused the runs-list endpoint, which is capped at 200 rows newest-first. On busy workflows those 200 runs all fall within the last day, so the chart's focus-lens brush — gated on the loaded runs spanning more than 24h — never appeared, and the chart stopped well short of the selected date window. Add a dedicated `workflow_run_activity` endpoint that returns a compact per-run projection (start / duration / conclusion / branch / PR) over the full date_from/date_to window at a higher cap (2000), and point the chart at it. The runs table keeps its own 200-row endpoint, so the two reads stay disjoint rather than the chart re-fetching the table's full-detail rows. `truncated` now reflects the chart's own cap. Generated frontend types (api.ts / api.schemas.ts / api.zod.schemas.ts) mirror the Orval output for the new endpoint; run `hogli build:openapi` on a full env to canonicalize. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Yp2qw9YU7y78eAeEpKmFP --- .../backend/facade/api.py | 20 +++++ .../backend/facade/contracts.py | 34 +++++++++ .../backend/logic/__init__.py | 25 +++++++ .../logic/queries/workflow_run_activity.py | 73 +++++++++++++++++++ .../backend/presentation/serializers.py | 41 +++++++++++ .../backend/presentation/views.py | 54 ++++++++++++++ .../backend/tests/test_logic.py | 36 +++++++++ .../frontend/generated/api.schemas.ts | 56 ++++++++++++++ .../frontend/generated/api.ts | 35 +++++++++ .../frontend/generated/api.zod.schemas.ts | 35 +++++++++ .../frontend/scenes/WorkflowRunsScene.tsx | 4 +- .../frontend/scenes/workflowRunsLogic.ts | 49 ++++++++++++- products/engineering_analytics/mcp/tools.yaml | 3 + 13 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 products/engineering_analytics/backend/logic/queries/workflow_run_activity.py diff --git a/products/engineering_analytics/backend/facade/api.py b/products/engineering_analytics/backend/facade/api.py index b69e2b02c75a..146447c5c504 100644 --- a/products/engineering_analytics/backend/facade/api.py +++ b/products/engineering_analytics/backend/facade/api.py @@ -33,6 +33,7 @@ QuarantineRequestResult, WorkflowHealthItem, WorkflowJob, + WorkflowRunActivity, WorkflowRunDetail, WorkflowRunnerCost, ) @@ -135,6 +136,25 @@ def list_workflow_runs( ) +def get_workflow_run_activity( + *, + team: Team, + repo: str, + workflow_name: str, + date_from: str | None = None, + date_to: str | None = None, + source_id: str | None = None, + user_access_control: "UserAccessControl | None" = None, +) -> WorkflowRunActivity: + return logic.build_workflow_run_activity( + curated=_authorized_source(team, source_id, user_access_control), + repo=repo, + workflow_name=workflow_name, + date_from=date_from, + date_to=date_to, + ) + + def get_workflow_runner_costs( *, team: Team, diff --git a/products/engineering_analytics/backend/facade/contracts.py b/products/engineering_analytics/backend/facade/contracts.py index e64813c06356..718a65ec3086 100644 --- a/products/engineering_analytics/backend/facade/contracts.py +++ b/products/engineering_analytics/backend/facade/contracts.py @@ -216,6 +216,40 @@ class WorkflowRunDetail: pr_number: int +@dataclass(frozen=True) +class WorkflowRunActivityPoint: + """A single workflow run reduced to the fields the run-activity chart plots: start time, duration, + conclusion, branch, and attributed PR. Deliberately leaner than ``WorkflowRunDetail`` so the chart can + load far more runs across the full window (for the scatter, the in-flight band, and the focus-lens + brush) than the capped run-detail table, without the per-row wire cost of the full detail shape. + """ + + run_id: int + # Raw conclusion passthrough ('success' / 'failure' / 'timed_out' / ...), or None while still running. + conclusion: str | None + # None for a queued/barely-started run whose timestamp the warehouse hasn't landed yet. + run_started_at: datetime | None + # None until the run completes — duration is only computed for completed runs. + duration_seconds: int | None + head_branch: str + # Attributed pull request number, or 0 when unattributed. + pr_number: int + + +@dataclass(frozen=True) +class WorkflowRunActivity: + """The run-activity chart's data for one workflow over a window: compact per-run points plus an + explicit truncation signal. ``points`` is capped at ``limit`` (newest first); ``truncated`` is True + when more runs matched than the cap, so the chart can label itself as covering only the most recent + runs rather than the full window. Higher-capped than the run-detail table, so the chart still spans + multiple days on busy workflows where the smaller table cap would collapse to a sliver. + """ + + points: list[WorkflowRunActivityPoint] + truncated: bool + limit: int + + @dataclass(frozen=True) class WorkflowJob: """One job within a workflow run, for the run's expandable job breakdown. ``estimated_cost_usd`` diff --git a/products/engineering_analytics/backend/logic/__init__.py b/products/engineering_analytics/backend/logic/__init__.py index ed0f2b0c4b77..3efff24285dd 100644 --- a/products/engineering_analytics/backend/logic/__init__.py +++ b/products/engineering_analytics/backend/logic/__init__.py @@ -22,6 +22,7 @@ PullRequestList, WorkflowHealthItem, WorkflowJob, + WorkflowRunActivity, WorkflowRunDetail, WorkflowRunnerCost, ) @@ -39,6 +40,7 @@ from products.engineering_analytics.backend.logic.queries.workflow_health import query_workflow_health from products.engineering_analytics.backend.logic.queries.workflow_jobs import query_workflow_jobs from products.engineering_analytics.backend.logic.queries.workflow_run import query_workflow_run +from products.engineering_analytics.backend.logic.queries.workflow_run_activity import query_workflow_run_activity from products.engineering_analytics.backend.logic.queries.workflow_run_list import query_workflow_run_list from products.engineering_analytics.backend.logic.sources import list_github_sources @@ -122,6 +124,29 @@ def build_workflow_run_list( ) +def build_workflow_run_activity( + *, + curated: CuratedGitHubSource, + repo: str | None, + workflow_name: str, + date_from: str | None = None, + date_to: str | None = None, +) -> WorkflowRunActivity: + owner, name = _split_repo(repo) + if not (owner and name): + raise ValueError("repo must be in 'owner/name' format") + parsed_from = _parse_date(curated.team, date_from or _DEFAULT_WINDOW) + parsed_to = _parse_date(curated.team, date_to) if date_to else None + return query_workflow_run_activity( + curated=curated, + repo_owner=owner, + repo_name=name, + workflow_name=workflow_name, + date_from=parsed_from, + date_to=parsed_to, + ) + + def build_workflow_runner_costs( *, curated: CuratedGitHubSource, diff --git a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py new file mode 100644 index 000000000000..4e6228bb4cfe --- /dev/null +++ b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py @@ -0,0 +1,73 @@ +"""Curated query: compact per-run points for the run-activity chart. + +Lists one workflow's runs over a window as the minimal shape the chart plots (start / duration / +conclusion / branch / attributed PR), newest first, at a higher cap than the run-detail table. Kept +separate from ``workflow_run_list`` so the chart can span the full window — enough runs for the +scatter, the in-flight band, and the focus-lens brush — while the detail table stays capped at its +smaller list size. Fetches ``_LIMIT + 1`` rows so an overflow is reported as ``truncated`` rather than +silently dropped. +""" + +from datetime import datetime + +from posthog.hogql import ast + +from products.engineering_analytics.backend.facade.contracts import WorkflowRunActivity, WorkflowRunActivityPoint +from products.engineering_analytics.backend.logic.queries._curated import CuratedGitHubSource + +# The chart plots a point per run and needs enough span to cover the window; far higher than the +# run-detail table's cap so the scatter and focus-lens brush see multiple days on busy workflows. +_LIMIT = 2000 + +_SELECT = f""" + SELECT + id, conclusion, run_started_at, duration_seconds, head_branch, pr_number + FROM __RUNS_SOURCE__ AS r + WHERE repo_owner = {{repo_owner}} AND repo_name = {{repo_name}} AND workflow_name = {{workflow_name}} + AND run_started_at >= {{date_from}} __DATE_TO__ + ORDER BY run_started_at DESC, run_attempt DESC + LIMIT {_LIMIT + 1} +""" + + +def query_workflow_run_activity( + *, + curated: CuratedGitHubSource, + repo_owner: str, + repo_name: str, + workflow_name: str, + date_from: datetime, + date_to: datetime | None, +) -> WorkflowRunActivity: + placeholders: dict[str, ast.Expr] = { + "repo_owner": ast.Constant(value=repo_owner), + "repo_name": ast.Constant(value=repo_name), + "workflow_name": ast.Constant(value=workflow_name), + "date_from": ast.Constant(value=date_from), + } + date_to_clause = "" + if date_to is not None: + date_to_clause = "AND run_started_at <= {date_to}" + placeholders["date_to"] = ast.Constant(value=date_to) + response = curated.run( + _SELECT.replace("__RUNS_SOURCE__", curated.run_source()).replace("__DATE_TO__", date_to_clause), + query_type="engineering_analytics.workflow_run_activity", + placeholders=placeholders, + ) + rows = response.results or [] + truncated = len(rows) > _LIMIT + points = [_to_point(row) for row in rows[:_LIMIT]] + return WorkflowRunActivity(points=points, truncated=truncated, limit=_LIMIT) + + +def _to_point(row: tuple) -> WorkflowRunActivityPoint: + run_id, conclusion, run_started_at, duration_seconds, head_branch, pr_number = row + return WorkflowRunActivityPoint( + run_id=int(run_id), + # Empty string means "no conclusion yet" (still running) — normalize to None for the contract. + conclusion=conclusion or None, + run_started_at=run_started_at, + duration_seconds=int(duration_seconds) if duration_seconds is not None else None, + head_branch=head_branch or "", + pr_number=int(pr_number) if pr_number is not None else 0, + ) diff --git a/products/engineering_analytics/backend/presentation/serializers.py b/products/engineering_analytics/backend/presentation/serializers.py index 800781071d28..507865508ab9 100644 --- a/products/engineering_analytics/backend/presentation/serializers.py +++ b/products/engineering_analytics/backend/presentation/serializers.py @@ -33,6 +33,8 @@ WorkflowHealthBucket, WorkflowHealthItem, WorkflowJob, + WorkflowRunActivity, + WorkflowRunActivityPoint, WorkflowRunDetail, WorkflowRunnerCost, ) @@ -207,6 +209,45 @@ class Meta: } +class WorkflowRunActivityPointSerializer(DataclassSerializer): + class Meta: + dataclass = WorkflowRunActivityPoint + extra_kwargs = { + "run_id": {"help_text": "GitHub Actions run id."}, + "conclusion": { + "help_text": "Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), " + "or null while still in progress.", + "allow_null": True, + }, + "run_started_at": { + "help_text": "When the run started, or null for a queued/barely-started run.", + "allow_null": True, + }, + "duration_seconds": { + "help_text": "Wall-clock duration in seconds; null until the run completes.", + "allow_null": True, + }, + "head_branch": {"help_text": "Git branch the run was triggered on, or '' when unknown."}, + "pr_number": {"help_text": "Attributed pull request number, or 0 when unattributed."}, + } + + +class WorkflowRunActivitySerializer(DataclassSerializer): + points = WorkflowRunActivityPointSerializer( + many=True, help_text="Per-run chart points, newest first, capped at `limit`." + ) + + class Meta: + dataclass = WorkflowRunActivity + extra_kwargs = { + "truncated": { + "help_text": "True when more runs matched than the cap; `points` is the newest `limit` runs, so the " + "chart covers only the most recent activity, not the full window.", + }, + "limit": {"help_text": "Maximum number of run points returned in `points`."}, + } + + class WorkflowJobSerializer(DataclassSerializer): class Meta: dataclass = WorkflowJob diff --git a/products/engineering_analytics/backend/presentation/views.py b/products/engineering_analytics/backend/presentation/views.py index ef5029c8081b..22c2efb9549b 100644 --- a/products/engineering_analytics/backend/presentation/views.py +++ b/products/engineering_analytics/backend/presentation/views.py @@ -39,6 +39,7 @@ QuarantineRequestSerializer, WorkflowHealthItemSerializer, WorkflowJobSerializer, + WorkflowRunActivitySerializer, WorkflowRunDetailSerializer, WorkflowRunnerCostSerializer, ) @@ -133,6 +134,7 @@ class EngineeringAnalyticsViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSe "pr_cost", "workflow_run", "workflow_runs", + "workflow_run_activity", "workflow_runner_costs", "workflow_jobs", ] @@ -542,6 +544,58 @@ def workflow_runs(self, request: Request, **kwargs) -> Response: return _bad_request(exc, fallback="Invalid date, repo, or source_id") return Response(WorkflowRunDetailSerializer(instance=runs, many=True).data) + @extend_schema( + operation_id="engineering_analytics_workflow_run_activity", + parameters=[ + OpenApiParameter( + name="workflow_name", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=True, + description="Workflow name to load run activity for.", + ), + OpenApiParameter( + name="repo", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=True, + description="'owner/name' repository the workflow belongs to.", + ), + _DATE_FROM, + _DATE_TO, + _SOURCE_ID, + ], + responses={ + 200: WorkflowRunActivitySerializer, + 400: OpenApiResponse(description="Missing workflow_name/repo, or invalid date or source_id."), + }, + description=( + "Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for " + "the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner " + "and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; " + "`truncated` is true when the cap is hit, so the chart covers only the most recent runs." + ), + ) + @action(detail=False, methods=["get"], pagination_class=None) + def workflow_run_activity(self, request: Request, **kwargs) -> Response: + workflow_name = request.query_params.get("workflow_name") + repo = request.query_params.get("repo") + if not workflow_name or not repo: + return Response({"detail": "workflow_name and repo are required"}, status=status.HTTP_400_BAD_REQUEST) + try: + result = api.get_workflow_run_activity( + team=self.team, + repo=repo, + workflow_name=workflow_name, + date_from=request.query_params.get("date_from") or None, + date_to=request.query_params.get("date_to") or None, + source_id=request.query_params.get("source_id") or None, + user_access_control=self.user_access_control, + ) + except ValueError as exc: + return _bad_request(exc, fallback="Invalid date, repo, or source_id") + return Response(WorkflowRunActivitySerializer(instance=result).data) + @extend_schema( operation_id="engineering_analytics_workflow_runner_costs", parameters=[ diff --git a/products/engineering_analytics/backend/tests/test_logic.py b/products/engineering_analytics/backend/tests/test_logic.py index fa2007c0ebe6..52525633cd3d 100644 --- a/products/engineering_analytics/backend/tests/test_logic.py +++ b/products/engineering_analytics/backend/tests/test_logic.py @@ -874,6 +874,42 @@ def test_workflow_run_list_scoped_to_workflow(self) -> None: # A repo with no such workflow yields an empty list (not an error). assert api.list_workflow_runs(team=self.team, repo="PostHog/posthog", workflow_name="Nope") == [] + def test_workflow_run_activity_projects_and_windows(self) -> None: + # The chart endpoint returns compact per-run points over the window, newest first, with the + # projection mapped in the right column order and an explicit (untruncated) cap signal. + self._create_table( + "github_pull_requests", + _PULL_REQUESTS_COLUMNS, + [_pr_row(80, "alice", "open", 0, _ago(1), head_sha="sha80")], + ) + self._create_table( + "github_workflow_runs", + _WORKFLOW_RUNS_COLUMNS, + [ + _run_row( + 8101, "CI", "sha-a", "completed", "failure", _ago(2), _ago(2), pr_number=80, head_branch="feat" + ), + _run_row(8102, "CI", "sha-b", "completed", "success", _ago(1), _ago(1)), + _run_row(8103, "Deploy", "sha-c", "completed", "success", _ago(1), _ago(1)), + # Older than the default -30d window — excluded unless the caller widens it. + _run_row(8104, "CI", "sha-d", "completed", "success", _ago(60), _ago(60)), + ], + ) + activity = api.get_workflow_run_activity(team=self.team, repo="PostHog/posthog", workflow_name="CI") + assert [p.run_id for p in activity.points] == [8102, 8101] # only CI runs in window, newest first + assert activity.truncated is False + assert activity.limit == 2000 + # Each field maps to the right column — guards a wrong unpack order in _to_point. + newest, failed = activity.points + assert (newest.run_id, newest.conclusion, newest.pr_number) == (8102, "success", 0) + assert (failed.conclusion, failed.head_branch, failed.pr_number) == ("failure", "feat", 80) + + # Widening the window pulls in the older run. + wide = api.get_workflow_run_activity( + team=self.team, repo="PostHog/posthog", workflow_name="CI", date_from="-90d" + ) + assert [p.run_id for p in wide.points] == [8102, 8101, 8104] + def test_pr_runs_span_all_commits(self) -> None: # The PR detail lists runs across all of the PR's commits (by association), not just head SHA. self._create_table( diff --git a/products/engineering_analytics/frontend/generated/api.schemas.ts b/products/engineering_analytics/frontend/generated/api.schemas.ts index 4aec8240f596..f3fe080bcc5a 100644 --- a/products/engineering_analytics/frontend/generated/api.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.schemas.ts @@ -249,6 +249,39 @@ export interface PRLifecycleApi { metric_quality?: MetricQualityEnumApi } +export interface WorkflowRunActivityPointApi { + /** GitHub Actions run id. */ + run_id: number + /** + * Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), or null while still in progress. + * @nullable + */ + conclusion: string | null + /** + * When the run started, or null for a queued/barely-started run. + * @nullable + */ + run_started_at: string | null + /** + * Wall-clock duration in seconds; null until the run completes. + * @nullable + */ + duration_seconds: number | null + /** Git branch the run was triggered on, or '' when unknown. */ + head_branch: string + /** Attributed pull request number, or 0 when unattributed. */ + pr_number: number +} + +export interface WorkflowRunActivityApi { + /** Per-run chart points, newest first, capped at `limit`. */ + points: WorkflowRunActivityPointApi[] + /** True when more runs matched than the cap; `points` is the newest `limit` runs, so the chart covers only the most recent activity, not the full window. */ + truncated: boolean + /** Maximum number of run points returned in `points`. */ + limit: number +} + export interface WorkflowRunDetailApi { /** Repository the run belongs to. */ repo: RepoRefApi @@ -799,6 +832,29 @@ export type EngineeringAnalyticsWorkflowRunnerCostsParams = { workflow_name: string } +export type EngineeringAnalyticsWorkflowRunActivityParams = { + /** + * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. + */ + date_from?: string + /** + * Window end: relative or ISO8601. Defaults to now. + */ + date_to?: string + /** + * 'owner/name' repository the workflow belongs to. + */ + repo: string + /** + * Connected GitHub data warehouse source to read from. Defaults to the oldest connected GitHub source when the team has more than one. + */ + source_id?: string + /** + * Workflow name to load run activity for. + */ + workflow_name: string +} + export type EngineeringAnalyticsWorkflowRunsParams = { /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. diff --git a/products/engineering_analytics/frontend/generated/api.ts b/products/engineering_analytics/frontend/generated/api.ts index b9ff79e9d683..444354cff2b9 100644 --- a/products/engineering_analytics/frontend/generated/api.ts +++ b/products/engineering_analytics/frontend/generated/api.ts @@ -20,6 +20,7 @@ import type { EngineeringAnalyticsQuarantineParams, EngineeringAnalyticsWorkflowHealthParams, EngineeringAnalyticsWorkflowJobsParams, + EngineeringAnalyticsWorkflowRunActivityParams, EngineeringAnalyticsWorkflowRunParams, EngineeringAnalyticsWorkflowRunnerCostsParams, EngineeringAnalyticsWorkflowRunsParams, @@ -32,6 +33,7 @@ import type { QuarantineRequestResultApi, WorkflowHealthItemApi, WorkflowJobApi, + WorkflowRunActivityApi, WorkflowRunDetailApi, WorkflowRunnerCostApi, } from './api.schemas' @@ -429,6 +431,39 @@ export const engineeringAnalyticsWorkflowRunnerCosts = async ( }) } +export const getEngineeringAnalyticsWorkflowRunActivityUrl = ( + projectId: string, + params: EngineeringAnalyticsWorkflowRunActivityParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/?${stringifiedParams}` + : `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/` +} + +/** + * Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when the cap is hit, so the chart covers only the most recent runs. + */ +export const engineeringAnalyticsWorkflowRunActivity = async ( + projectId: string, + params: EngineeringAnalyticsWorkflowRunActivityParams, + options?: RequestInit +): Promise => { + return apiMutator(getEngineeringAnalyticsWorkflowRunActivityUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + export const getEngineeringAnalyticsWorkflowRunsUrl = ( projectId: string, params: EngineeringAnalyticsWorkflowRunsParams diff --git a/products/engineering_analytics/frontend/generated/api.zod.schemas.ts b/products/engineering_analytics/frontend/generated/api.zod.schemas.ts index 1e2b9f6cb6a5..f9403ec9e34c 100644 --- a/products/engineering_analytics/frontend/generated/api.zod.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.zod.schemas.ts @@ -284,6 +284,41 @@ export const PRLifecycleApi = zod.object({ export type PRLifecycleApi = zod.input export type PRLifecycleApiOutput = zod.output +export const WorkflowRunActivityApi = zod.object({ + points: zod + .array( + zod.object({ + run_id: zod.number().describe('GitHub Actions run id.'), + conclusion: zod + .string() + .nullable() + .describe( + "Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), or null while still in progress." + ), + run_started_at: zod.iso + .datetime({ offset: true }) + .nullable() + .describe('When the run started, or null for a queued\/barely-started run.'), + duration_seconds: zod + .number() + .nullable() + .describe('Wall-clock duration in seconds; null until the run completes.'), + head_branch: zod.string().describe("Git branch the run was triggered on, or '' when unknown."), + pr_number: zod.number().describe('Attributed pull request number, or 0 when unattributed.'), + }) + ) + .describe('Per-run chart points, newest first, capped at `limit`.'), + truncated: zod + .boolean() + .describe( + 'True when more runs matched than the cap; `points` is the newest `limit` runs, so the chart covers only the most recent activity, not the full window.' + ), + limit: zod.number().describe('Maximum number of run points returned in `points`.'), +}) + +export type WorkflowRunActivityApi = zod.input +export type WorkflowRunActivityApiOutput = zod.output + export const WorkflowRunDetailApi = zod.object({ repo: zod .object({ diff --git a/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx b/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx index 8e2dfbcae905..e5e18a038c17 100644 --- a/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx +++ b/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx @@ -116,6 +116,8 @@ export function WorkflowRunsScene(): JSX.Element { healthSummary, costSummary, runsTruncated, + activityRuns, + activityTruncated, } = useValues(workflowRunsLogic) const { loadRuns, setRunExpanded } = useActions(workflowRunsLogic) const { dateFrom, dateTo } = useValues(engineeringAnalyticsFiltersLogic) @@ -219,7 +221,7 @@ export function WorkflowRunsScene(): JSX.Element { /> - + {runnerCosts.length > 0 && }

Runs

diff --git a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts index 76b50d9bc938..2670f816a5d6 100644 --- a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts +++ b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts @@ -6,12 +6,19 @@ import { urls } from 'scenes/urls' import { Breadcrumb } from '~/types' +import type { ActivityRun } from '../components/RunActivityChart' import { engineeringAnalyticsWorkflowJobs, + engineeringAnalyticsWorkflowRunActivity, engineeringAnalyticsWorkflowRunnerCosts, engineeringAnalyticsWorkflowRuns, } from '../generated/api' -import type { WorkflowJobApi, WorkflowRunDetailApi, WorkflowRunnerCostApi } from '../generated/api.schemas' +import type { + WorkflowJobApi, + WorkflowRunActivityApi, + WorkflowRunDetailApi, + WorkflowRunnerCostApi, +} from '../generated/api.schemas' import { jobCacheKey } from '../lib/jobs' import { type CostSummary, type HealthSummary, computeHealthSummary } from '../lib/runHealth' import { engineeringAnalyticsFiltersLogic } from './engineeringAnalyticsFiltersLogic' @@ -81,6 +88,22 @@ export const workflowRunsLogic = kea([ }), }, ], + // Compact per-run points for the activity chart, over the full window at a higher cap than the runs + // table — so the chart spans multiple days (and its focus-lens brush appears) on busy workflows where + // the 200-run table would collapse to a sub-day slice. + runActivity: [ + { points: [], truncated: false, limit: 0 } as WorkflowRunActivityApi, + { + loadRunActivity: async (): Promise => + await engineeringAnalyticsWorkflowRunActivity(projectId(), { + workflow_name: props.workflowName, + repo: `${props.repoOwner}/${props.repoName}`, + date_from: values.dateFrom ?? undefined, + date_to: values.dateTo ?? undefined, + source_id: props.sourceId ?? undefined, + }), + }, + ], // Cost split by runner tier over the window — "where this workflow's spend goes"; [] when jobs aren't synced. runnerCosts: [ [] as WorkflowRunnerCostApi[], @@ -163,6 +186,26 @@ export const workflowRunsLogic = kea([ repoName: run.repo.name, })), ], + // The activity chart's points, mapped to the shape it plots. Sourced from the higher-capped activity + // endpoint (not the 200-run table) so the chart covers the full window and its brush shows. + activityRuns: [ + (s) => [s.runActivity], + (runActivity: WorkflowRunActivityApi): ActivityRun[] => + runActivity.points.map((point) => ({ + runId: point.run_id, + conclusion: point.conclusion, + startedAt: point.run_started_at, + durationSeconds: point.duration_seconds, + headBranch: point.head_branch, + prNumber: point.pr_number, + })), + ], + // The chart's own cap was hit — it covers only the most recent runs, not the full window. Distinct + // from `runsTruncated` (the table's smaller cap), so the chart labels itself honestly. + activityTruncated: [ + (s) => [s.runActivity], + (runActivity: WorkflowRunActivityApi): boolean => runActivity.truncated, + ], // Verdict + headline stats for the health strip above the chart. healthSummary: [(s) => [s.runRows], (runRows): HealthSummary => computeHealthSummary(runRows)], // Runs list is capped server-side; when hit, run rollups cover only the most recent runs (cost @@ -220,15 +263,17 @@ export const workflowRunsLogic = kea([ actions.loadJobs({ runId, runAttempt }) } }, - // The shared window scopes both lists — reload them together when it changes. + // The shared window scopes all three reads — reload them together when it changes. [engineeringAnalyticsFiltersLogic.actionTypes.setDateRange]: () => { actions.loadRuns() + actions.loadRunActivity() actions.loadRunnerCosts() }, })), afterMount(({ actions }) => { actions.loadRuns() + actions.loadRunActivity() actions.loadRunnerCosts() }), ]) diff --git a/products/engineering_analytics/mcp/tools.yaml b/products/engineering_analytics/mcp/tools.yaml index 93a1305a789b..a4f79e498693 100644 --- a/products/engineering_analytics/mcp/tools.yaml +++ b/products/engineering_analytics/mcp/tools.yaml @@ -95,6 +95,9 @@ tools: engineering-analytics-workflow-run: operation: engineering_analytics_workflow_run enabled: false + engineering-analytics-workflow-run-activity: + operation: engineering_analytics_workflow_run_activity + enabled: false engineering-analytics-workflow-runner-costs: operation: engineering_analytics_workflow_runner_costs enabled: true From 6b654718d9ba3ff647a6ce3d841c33f2cb8dcdf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 06:15:03 +0000 Subject: [PATCH 2/8] chore(engineering-analytics): document run-activity chart cap ratio Note the run-activity cap's relationship to the run-detail table cap so it doesn't bitrot if the table cap changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Yp2qw9YU7y78eAeEpKmFP --- .../backend/logic/queries/workflow_run_activity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py index 4e6228bb4cfe..c067e0cb7190 100644 --- a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py +++ b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py @@ -15,8 +15,10 @@ from products.engineering_analytics.backend.facade.contracts import WorkflowRunActivity, WorkflowRunActivityPoint from products.engineering_analytics.backend.logic.queries._curated import CuratedGitHubSource -# The chart plots a point per run and needs enough span to cover the window; far higher than the -# run-detail table's cap so the scatter and focus-lens brush see multiple days on busy workflows. +# The chart plots a point per run and needs enough span to cover the window: an order of magnitude +# above the run-detail table's cap (workflow_run_list._LIMIT = 200) so the scatter and focus-lens +# brush still see multiple days on busy workflows where the smaller cap collapses to a sub-day slice. +# Revisit this alongside that table cap if it changes materially, so the chart keeps spanning the window. _LIMIT = 2000 _SELECT = f""" From 361fccbf240326533086aaa202c0a3ca27e91ffc Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:20:59 +0000 Subject: [PATCH 3/8] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 74 +++++++++---------- .../frontend/generated/api.ts | 36 ++++----- services/mcp/src/api/generated.ts | 56 ++++++++++++++ 3 files changed, 111 insertions(+), 55 deletions(-) diff --git a/products/engineering_analytics/frontend/generated/api.schemas.ts b/products/engineering_analytics/frontend/generated/api.schemas.ts index f3fe080bcc5a..889acc1eb1af 100644 --- a/products/engineering_analytics/frontend/generated/api.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.schemas.ts @@ -249,39 +249,6 @@ export interface PRLifecycleApi { metric_quality?: MetricQualityEnumApi } -export interface WorkflowRunActivityPointApi { - /** GitHub Actions run id. */ - run_id: number - /** - * Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), or null while still in progress. - * @nullable - */ - conclusion: string | null - /** - * When the run started, or null for a queued/barely-started run. - * @nullable - */ - run_started_at: string | null - /** - * Wall-clock duration in seconds; null until the run completes. - * @nullable - */ - duration_seconds: number | null - /** Git branch the run was triggered on, or '' when unknown. */ - head_branch: string - /** Attributed pull request number, or 0 when unattributed. */ - pr_number: number -} - -export interface WorkflowRunActivityApi { - /** Per-run chart points, newest first, capped at `limit`. */ - points: WorkflowRunActivityPointApi[] - /** True when more runs matched than the cap; `points` is the newest `limit` runs, so the chart covers only the most recent activity, not the full window. */ - truncated: boolean - /** Maximum number of run points returned in `points`. */ - limit: number -} - export interface WorkflowRunDetailApi { /** Repository the run belongs to. */ repo: RepoRefApi @@ -655,6 +622,39 @@ export interface WorkflowJobApi { estimated_cost_usd: number | null } +export interface WorkflowRunActivityPointApi { + /** GitHub Actions run id. */ + run_id: number + /** + * Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), or null while still in progress. + * @nullable + */ + conclusion: string | null + /** + * When the run started, or null for a queued/barely-started run. + * @nullable + */ + run_started_at: string | null + /** + * Wall-clock duration in seconds; null until the run completes. + * @nullable + */ + duration_seconds: number | null + /** Git branch the run was triggered on, or '' when unknown. */ + head_branch: string + /** Attributed pull request number, or 0 when unattributed. */ + pr_number: number +} + +export interface WorkflowRunActivityApi { + /** Per-run chart points, newest first, capped at `limit`. */ + points: WorkflowRunActivityPointApi[] + /** True when more runs matched than the cap; `points` is the newest `limit` runs, so the chart covers only the most recent activity, not the full window. */ + truncated: boolean + /** Maximum number of run points returned in `points`. */ + limit: number +} + export interface WorkflowRunnerCostApi { /** 'self_hosted' (billable), 'github_hosted' (free), or 'unknown'. */ provider: string @@ -809,7 +809,7 @@ export type EngineeringAnalyticsWorkflowRunParams = { source_id?: string } -export type EngineeringAnalyticsWorkflowRunnerCostsParams = { +export type EngineeringAnalyticsWorkflowRunActivityParams = { /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. */ @@ -827,12 +827,12 @@ export type EngineeringAnalyticsWorkflowRunnerCostsParams = { */ source_id?: string /** - * Workflow name to break down cost for. + * Workflow name to load run activity for. */ workflow_name: string } -export type EngineeringAnalyticsWorkflowRunActivityParams = { +export type EngineeringAnalyticsWorkflowRunnerCostsParams = { /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. */ @@ -850,7 +850,7 @@ export type EngineeringAnalyticsWorkflowRunActivityParams = { */ source_id?: string /** - * Workflow name to load run activity for. + * Workflow name to break down cost for. */ workflow_name: string } diff --git a/products/engineering_analytics/frontend/generated/api.ts b/products/engineering_analytics/frontend/generated/api.ts index 444354cff2b9..45a4a9256672 100644 --- a/products/engineering_analytics/frontend/generated/api.ts +++ b/products/engineering_analytics/frontend/generated/api.ts @@ -398,9 +398,9 @@ export const engineeringAnalyticsWorkflowRun = async ( }) } -export const getEngineeringAnalyticsWorkflowRunnerCostsUrl = ( +export const getEngineeringAnalyticsWorkflowRunActivityUrl = ( projectId: string, - params: EngineeringAnalyticsWorkflowRunnerCostsParams + params: EngineeringAnalyticsWorkflowRunActivityParams ) => { const normalizedParams = new URLSearchParams() @@ -413,27 +413,27 @@ export const getEngineeringAnalyticsWorkflowRunnerCostsUrl = ( const stringifiedParams = normalizedParams.toString() return stringifiedParams.length > 0 - ? `/api/projects/${projectId}/engineering_analytics/workflow_runner_costs/?${stringifiedParams}` - : `/api/projects/${projectId}/engineering_analytics/workflow_runner_costs/` + ? `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/?${stringifiedParams}` + : `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/` } /** - * A workflow's estimated CI cost broken down by runner tier over a window (date_from default -30d), highest spend first. Returns an empty list when the job-level source isn't synced. + * Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when the cap is hit, so the chart covers only the most recent runs. */ -export const engineeringAnalyticsWorkflowRunnerCosts = async ( +export const engineeringAnalyticsWorkflowRunActivity = async ( projectId: string, - params: EngineeringAnalyticsWorkflowRunnerCostsParams, + params: EngineeringAnalyticsWorkflowRunActivityParams, options?: RequestInit -): Promise => { - return apiMutator(getEngineeringAnalyticsWorkflowRunnerCostsUrl(projectId, params), { +): Promise => { + return apiMutator(getEngineeringAnalyticsWorkflowRunActivityUrl(projectId, params), { ...options, method: 'GET', }) } -export const getEngineeringAnalyticsWorkflowRunActivityUrl = ( +export const getEngineeringAnalyticsWorkflowRunnerCostsUrl = ( projectId: string, - params: EngineeringAnalyticsWorkflowRunActivityParams + params: EngineeringAnalyticsWorkflowRunnerCostsParams ) => { const normalizedParams = new URLSearchParams() @@ -446,19 +446,19 @@ export const getEngineeringAnalyticsWorkflowRunActivityUrl = ( const stringifiedParams = normalizedParams.toString() return stringifiedParams.length > 0 - ? `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/?${stringifiedParams}` - : `/api/projects/${projectId}/engineering_analytics/workflow_run_activity/` + ? `/api/projects/${projectId}/engineering_analytics/workflow_runner_costs/?${stringifiedParams}` + : `/api/projects/${projectId}/engineering_analytics/workflow_runner_costs/` } /** - * Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when the cap is hit, so the chart covers only the most recent runs. + * A workflow's estimated CI cost broken down by runner tier over a window (date_from default -30d), highest spend first. Returns an empty list when the job-level source isn't synced. */ -export const engineeringAnalyticsWorkflowRunActivity = async ( +export const engineeringAnalyticsWorkflowRunnerCosts = async ( projectId: string, - params: EngineeringAnalyticsWorkflowRunActivityParams, + params: EngineeringAnalyticsWorkflowRunnerCostsParams, options?: RequestInit -): Promise => { - return apiMutator(getEngineeringAnalyticsWorkflowRunActivityUrl(projectId, params), { +): Promise => { + return apiMutator(getEngineeringAnalyticsWorkflowRunnerCostsUrl(projectId, params), { ...options, method: 'GET', }) diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index cb42c9afeae5..35083f280cd2 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53318,6 +53318,39 @@ export namespace Schemas { estimated_cost_usd: number | null; } + export interface WorkflowRunActivityPoint { + /** GitHub Actions run id. */ + run_id: number; + /** + * Run conclusion ('success', 'failure', 'timed_out', 'cancelled', 'skipped', ...), or null while still in progress. + * @nullable + */ + conclusion: string | null; + /** + * When the run started, or null for a queued/barely-started run. + * @nullable + */ + run_started_at: string | null; + /** + * Wall-clock duration in seconds; null until the run completes. + * @nullable + */ + duration_seconds: number | null; + /** Git branch the run was triggered on, or '' when unknown. */ + head_branch: string; + /** Attributed pull request number, or 0 when unattributed. */ + pr_number: number; + } + + export interface WorkflowRunActivity { + /** Per-run chart points, newest first, capped at `limit`. */ + points: WorkflowRunActivityPoint[]; + /** True when more runs matched than the cap; `points` is the newest `limit` runs, so the chart covers only the most recent activity, not the full window. */ + truncated: boolean; + /** Maximum number of run points returned in `points`. */ + limit: number; + } + export interface WorkflowRunDetail { /** Repository the run belongs to. */ repo: RepoRef; @@ -61611,6 +61644,29 @@ export namespace Schemas { source_id?: string; }; + export type EngineeringAnalyticsWorkflowRunActivityParams = { + /** + * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. + */ + date_from?: string; + /** + * Window end: relative or ISO8601. Defaults to now. + */ + date_to?: string; + /** + * 'owner/name' repository the workflow belongs to. + */ + repo: string; + /** + * Connected GitHub data warehouse source to read from. Defaults to the oldest connected GitHub source when the team has more than one. + */ + source_id?: string; + /** + * Workflow name to load run activity for. + */ + workflow_name: string; + }; + export type EngineeringAnalyticsWorkflowRunnerCostsParams = { /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. From af976582789d576bd2ffc7273e8a89f18706eadf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 07:01:22 +0000 Subject: [PATCH 4/8] fix(engineering-analytics): make run-activity run_started_at non-null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow_run_activity window filter (`run_started_at >= date_from`) excludes runs whose start timestamp didn't parse, so the endpoint never returns a null start — but the contract/serializer declared it nullable, a misleading API shape flagged in review. Tighten the contract to non-null and document that unparseable-start runs are excluded, since they can't be placed on the chart's time axis. This diverges intentionally from the shared WorkflowRunDetail shape, which lists all runs including queued ones. Generated types will be regenerated by CI's OpenAPI job to match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Yp2qw9YU7y78eAeEpKmFP --- products/engineering_analytics/backend/facade/contracts.py | 6 ++++-- .../backend/logic/queries/workflow_run_activity.py | 4 ++++ .../backend/presentation/serializers.py | 4 ++-- products/engineering_analytics/backend/tests/test_logic.py | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/products/engineering_analytics/backend/facade/contracts.py b/products/engineering_analytics/backend/facade/contracts.py index 718a65ec3086..4547720f5bd4 100644 --- a/products/engineering_analytics/backend/facade/contracts.py +++ b/products/engineering_analytics/backend/facade/contracts.py @@ -227,8 +227,10 @@ class WorkflowRunActivityPoint: run_id: int # Raw conclusion passthrough ('success' / 'failure' / 'timed_out' / ...), or None while still running. conclusion: str | None - # None for a queued/barely-started run whose timestamp the warehouse hasn't landed yet. - run_started_at: datetime | None + # Always set here (unlike the shared WorkflowRunDetail shape): the windowed query filters on + # run_started_at, so a run with no parseable start timestamp is excluded — it can't be placed on the + # chart's time axis anyway. Non-null keeps the contract honest for this chart-only endpoint. + run_started_at: datetime # None until the run completes — duration is only computed for completed runs. duration_seconds: int | None head_branch: str diff --git a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py index c067e0cb7190..7288406f54c2 100644 --- a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py +++ b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py @@ -6,6 +6,10 @@ scatter, the in-flight band, and the focus-lens brush — while the detail table stays capped at its smaller list size. Fetches ``_LIMIT + 1`` rows so an overflow is reported as ``truncated`` rather than silently dropped. + +The ``run_started_at >= date_from`` filter also excludes runs whose start timestamp didn't parse +(``NULL``), which is intended: a run with no start time can't be placed on the chart's time axis. That +is why ``WorkflowRunActivityPoint.run_started_at`` is non-null while the shared run-detail shape is not. """ from datetime import datetime diff --git a/products/engineering_analytics/backend/presentation/serializers.py b/products/engineering_analytics/backend/presentation/serializers.py index 507865508ab9..8bc1923eeaae 100644 --- a/products/engineering_analytics/backend/presentation/serializers.py +++ b/products/engineering_analytics/backend/presentation/serializers.py @@ -220,8 +220,8 @@ class Meta: "allow_null": True, }, "run_started_at": { - "help_text": "When the run started, or null for a queued/barely-started run.", - "allow_null": True, + "help_text": "When the run started. Never null on this endpoint: runs without a parseable " + "start timestamp are excluded from the window (they can't be plotted on the chart's time axis).", }, "duration_seconds": { "help_text": "Wall-clock duration in seconds; null until the run completes.", diff --git a/products/engineering_analytics/backend/tests/test_logic.py b/products/engineering_analytics/backend/tests/test_logic.py index 52525633cd3d..1d3b7c7d70ce 100644 --- a/products/engineering_analytics/backend/tests/test_logic.py +++ b/products/engineering_analytics/backend/tests/test_logic.py @@ -903,6 +903,8 @@ def test_workflow_run_activity_projects_and_windows(self) -> None: newest, failed = activity.points assert (newest.run_id, newest.conclusion, newest.pr_number) == (8102, "success", 0) assert (failed.conclusion, failed.head_branch, failed.pr_number) == ("failure", "feat", 80) + # run_started_at is non-null on this endpoint — the window filter excludes unparseable-start runs. + assert all(p.run_started_at is not None for p in activity.points) # Widening the window pulls in the older run. wide = api.get_workflow_run_activity( From 8884fd8d6ed85045fc559d4ff92f705cfebc3492 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:25:54 +0000 Subject: [PATCH 5/8] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 7 ++----- services/mcp/src/api/generated.ts | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/products/engineering_analytics/frontend/generated/api.schemas.ts b/products/engineering_analytics/frontend/generated/api.schemas.ts index 889acc1eb1af..1b1c460991d0 100644 --- a/products/engineering_analytics/frontend/generated/api.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.schemas.ts @@ -630,11 +630,8 @@ export interface WorkflowRunActivityPointApi { * @nullable */ conclusion: string | null - /** - * When the run started, or null for a queued/barely-started run. - * @nullable - */ - run_started_at: string | null + /** When the run started. Never null on this endpoint: runs without a parseable start timestamp are excluded from the window (they can't be plotted on the chart's time axis). */ + run_started_at: string /** * Wall-clock duration in seconds; null until the run completes. * @nullable diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 35083f280cd2..7f446312ce3f 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53326,11 +53326,8 @@ export namespace Schemas { * @nullable */ conclusion: string | null; - /** - * When the run started, or null for a queued/barely-started run. - * @nullable - */ - run_started_at: string | null; + /** When the run started. Never null on this endpoint: runs without a parseable start timestamp are excluded from the window (they can't be plotted on the chart's time axis). */ + run_started_at: string; /** * Wall-clock duration in seconds; null until the run completes. * @nullable From 27b80d34fd12e6ee481270694e1a180fc0611da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:19:37 +0000 Subject: [PATCH 6/8] fix(engineering-analytics): scope run-activity chart to the selected branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master's branch filter scopes the runs table and runner-cost calls via head_branch, but the new activity-chart endpoint didn't take a branch param and wasn't reloaded on setAppliedBranch — so a branch-scoped view plotted every branch's runs under the selected-branch filter. Thread `branch` through workflow_run_activity (query WHERE head_branch, facade, logic, view param) mirroring workflow_runs/workflow_runner_costs, and reload the chart on setAppliedBranch. Extends the branch-filter test to cover the activity endpoint. Generated types canonicalize via CI's OpenAPI job. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Yp2qw9YU7y78eAeEpKmFP --- .../engineering_analytics/backend/facade/api.py | 2 ++ .../engineering_analytics/backend/logic/__init__.py | 2 ++ .../backend/logic/queries/workflow_run_activity.py | 13 +++++++++++-- .../backend/presentation/views.py | 9 ++++++--- .../backend/tests/test_logic.py | 7 +++++++ .../frontend/generated/api.schemas.ts | 4 ++++ .../frontend/scenes/workflowRunsLogic.ts | 2 ++ 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/products/engineering_analytics/backend/facade/api.py b/products/engineering_analytics/backend/facade/api.py index f78e45b88818..977eb556c852 100644 --- a/products/engineering_analytics/backend/facade/api.py +++ b/products/engineering_analytics/backend/facade/api.py @@ -146,6 +146,7 @@ def get_workflow_run_activity( workflow_name: str, date_from: str | None = None, date_to: str | None = None, + branch: str | None = None, source_id: str | None = None, user_access_control: "UserAccessControl | None" = None, ) -> WorkflowRunActivity: @@ -155,6 +156,7 @@ def get_workflow_run_activity( workflow_name=workflow_name, date_from=date_from, date_to=date_to, + branch=branch, ) diff --git a/products/engineering_analytics/backend/logic/__init__.py b/products/engineering_analytics/backend/logic/__init__.py index b8823659405a..2ca6f3f9eeaf 100644 --- a/products/engineering_analytics/backend/logic/__init__.py +++ b/products/engineering_analytics/backend/logic/__init__.py @@ -133,6 +133,7 @@ def build_workflow_run_activity( workflow_name: str, date_from: str | None = None, date_to: str | None = None, + branch: str | None = None, ) -> WorkflowRunActivity: owner, name = _split_repo(repo) if not (owner and name): @@ -146,6 +147,7 @@ def build_workflow_run_activity( workflow_name=workflow_name, date_from=parsed_from, date_to=parsed_to, + branch=branch, ) diff --git a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py index 7288406f54c2..62a7043c86d9 100644 --- a/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py +++ b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py @@ -30,7 +30,7 @@ id, conclusion, run_started_at, duration_seconds, head_branch, pr_number FROM __RUNS_SOURCE__ AS r WHERE repo_owner = {{repo_owner}} AND repo_name = {{repo_name}} AND workflow_name = {{workflow_name}} - AND run_started_at >= {{date_from}} __DATE_TO__ + AND run_started_at >= {{date_from}} __DATE_TO__ __BRANCH__ ORDER BY run_started_at DESC, run_attempt DESC LIMIT {_LIMIT + 1} """ @@ -44,6 +44,7 @@ def query_workflow_run_activity( workflow_name: str, date_from: datetime, date_to: datetime | None, + branch: str | None = None, ) -> WorkflowRunActivity: placeholders: dict[str, ast.Expr] = { "repo_owner": ast.Constant(value=repo_owner), @@ -55,8 +56,16 @@ def query_workflow_run_activity( if date_to is not None: date_to_clause = "AND run_started_at <= {date_to}" placeholders["date_to"] = ast.Constant(value=date_to) + # An empty/whitespace branch is "no filter", not a literal match on '' — mirrors workflow_run_list. + branch = branch.strip() if branch else None + branch_clause = "" + if branch: + branch_clause = "AND head_branch = {branch}" + placeholders["branch"] = ast.Constant(value=branch) response = curated.run( - _SELECT.replace("__RUNS_SOURCE__", curated.run_source()).replace("__DATE_TO__", date_to_clause), + _SELECT.replace("__RUNS_SOURCE__", curated.run_source()) + .replace("__DATE_TO__", date_to_clause) + .replace("__BRANCH__", branch_clause), query_type="engineering_analytics.workflow_run_activity", placeholders=placeholders, ) diff --git a/products/engineering_analytics/backend/presentation/views.py b/products/engineering_analytics/backend/presentation/views.py index 9fe32976426c..bbe05d815c50 100644 --- a/products/engineering_analytics/backend/presentation/views.py +++ b/products/engineering_analytics/backend/presentation/views.py @@ -566,6 +566,7 @@ def workflow_runs(self, request: Request, **kwargs) -> Response: ), _DATE_FROM, _DATE_TO, + _BRANCH, _SOURCE_ID, ], responses={ @@ -574,9 +575,10 @@ def workflow_runs(self, request: Request, **kwargs) -> Response: }, description=( "Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for " - "the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner " - "and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; " - "`truncated` is true when the cap is hit, so the chart covers only the most recent runs." + "the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. " + "Optionally scope to a single git branch via `branch`, matching workflow_runs. Leaner and higher-capped " + "than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when " + "the cap is hit, so the chart covers only the most recent runs." ), ) @action(detail=False, methods=["get"], pagination_class=None) @@ -592,6 +594,7 @@ def workflow_run_activity(self, request: Request, **kwargs) -> Response: workflow_name=workflow_name, date_from=request.query_params.get("date_from") or None, date_to=request.query_params.get("date_to") or None, + branch=request.query_params.get("branch") or None, source_id=request.query_params.get("source_id") or None, user_access_control=self.user_access_control, ) diff --git a/products/engineering_analytics/backend/tests/test_logic.py b/products/engineering_analytics/backend/tests/test_logic.py index 06ae9602d879..bef66a6072d4 100644 --- a/products/engineering_analytics/backend/tests/test_logic.py +++ b/products/engineering_analytics/backend/tests/test_logic.py @@ -960,6 +960,13 @@ def test_workflow_detail_branch_filter(self) -> None: ) assert (all_jobs, main_jobs) == (3, 2) + # The activity chart honors the same branch scope as the runs list, so it can't plot other + # branches' runs under an applied branch filter. + all_activity = api.get_workflow_run_activity(team=self.team, repo=repo, workflow_name=workflow) + assert {p.run_id for p in all_activity.points} == {8501, 8502, 8503} + main_activity = api.get_workflow_run_activity(team=self.team, repo=repo, workflow_name=workflow, branch="main") + assert {p.run_id for p in main_activity.points} == {8501, 8502} + def test_pr_runs_span_all_commits(self) -> None: # The PR detail lists runs across all of the PR's commits (by association), not just head SHA. self._create_table( diff --git a/products/engineering_analytics/frontend/generated/api.schemas.ts b/products/engineering_analytics/frontend/generated/api.schemas.ts index cc4808c4dc0e..71e40d732082 100644 --- a/products/engineering_analytics/frontend/generated/api.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.schemas.ts @@ -807,6 +807,10 @@ export type EngineeringAnalyticsWorkflowRunParams = { } export type EngineeringAnalyticsWorkflowRunActivityParams = { + /** + * Optional exact git branch (head_branch) to scope results to, e.g. 'main'. Omit or leave blank to aggregate across all branches. + */ + branch?: string /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. */ diff --git a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts index e33c1401dbd2..a37e621d9d94 100644 --- a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts +++ b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts @@ -102,6 +102,7 @@ export const workflowRunsLogic = kea([ repo: `${props.repoOwner}/${props.repoName}`, date_from: values.dateFrom ?? undefined, date_to: values.dateTo ?? undefined, + branch: values.appliedBranch || undefined, source_id: props.sourceId ?? undefined, }), }, @@ -274,6 +275,7 @@ export const workflowRunsLogic = kea([ }, [engineeringAnalyticsFiltersLogic.actionTypes.setAppliedBranch]: () => { actions.loadRuns() + actions.loadRunActivity() actions.loadRunnerCosts() }, })), From 40a9e878152450d58a2fad221b0df92022e77285 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 15:26:44 +0000 Subject: [PATCH 7/8] chore(engineering-analytics): cover activity loader in workflowRunsLogic test Master's new workflowRunsLogic test mocks ../generated/api with only the runs/runner-cost/jobs functions; the activity loader this PR added is now called on mount and on branch change, so the mock returned undefined and the loader threw. Add the activity mock + resolved value, include it in the reload expectations, and assert the activity request is branch-scoped too. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Yp2qw9YU7y78eAeEpKmFP --- .../frontend/scenes/workflowRunsLogic.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.test.ts b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.test.ts index 5c659e06903f..74d7593feb5c 100644 --- a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.test.ts +++ b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.test.ts @@ -6,6 +6,7 @@ import { initKeaTests } from '~/test/init' import { engineeringAnalyticsWorkflowJobs, + engineeringAnalyticsWorkflowRunActivity, engineeringAnalyticsWorkflowRunnerCosts, engineeringAnalyticsWorkflowRuns, } from '../generated/api' @@ -14,11 +15,15 @@ import { workflowRunsLogic } from './workflowRunsLogic' jest.mock('../generated/api', () => ({ engineeringAnalyticsWorkflowJobs: jest.fn(), + engineeringAnalyticsWorkflowRunActivity: jest.fn(), engineeringAnalyticsWorkflowRunnerCosts: jest.fn(), engineeringAnalyticsWorkflowRuns: jest.fn(), })) const mockRuns = engineeringAnalyticsWorkflowRuns as jest.MockedFunction +const mockRunActivity = engineeringAnalyticsWorkflowRunActivity as jest.MockedFunction< + typeof engineeringAnalyticsWorkflowRunActivity +> const mockRunnerCosts = engineeringAnalyticsWorkflowRunnerCosts as jest.MockedFunction< typeof engineeringAnalyticsWorkflowRunnerCosts > @@ -32,6 +37,7 @@ describe('workflowRunsLogic', () => { ApiConfig.setCurrentProjectId(1) jest.clearAllMocks() mockRuns.mockResolvedValue([]) + mockRunActivity.mockResolvedValue({ points: [], truncated: false, limit: 0 }) mockRunnerCosts.mockResolvedValue([]) mockJobs.mockResolvedValue([]) }) @@ -40,29 +46,38 @@ describe('workflowRunsLogic', () => { logic?.unmount() }) - it('scopes the runs list and cost breakdown to the shared branch, reloading both on a change', async () => { + it('scopes the runs list, activity chart, and cost breakdown to the shared branch, reloading all on a change', async () => { logic = workflowRunsLogic({ repoOwner: 'PostHog', repoName: 'posthog', workflowName: 'CI', sourceId: null }) logic.mount() const filters = engineeringAnalyticsFiltersLogic() filters.mount() - await expectLogic(logic).toDispatchActions(['loadRunsSuccess', 'loadRunnerCostsSuccess']) + await expectLogic(logic).toDispatchActions([ + 'loadRunsSuccess', + 'loadRunActivitySuccess', + 'loadRunnerCostsSuccess', + ]) // No branch applied → the endpoints see every branch (the pre-fix behavior for the whole page). const runsArgs = { workflow_name: 'CI', repo: 'PostHog/posthog', date_from: '-7d', branch: undefined } expect(mockRuns).toHaveBeenLastCalledWith('1', expect.objectContaining(runsArgs)) + expect(mockRunActivity).toHaveBeenLastCalledWith('1', expect.objectContaining(runsArgs)) expect(mockRunnerCosts).toHaveBeenLastCalledWith('1', expect.objectContaining(runsArgs)) - // Applying a branch on the shared filters logic reloads both lists scoped to it — so the detail - // page's numbers match the branch-scoped Workflows tab instead of widening back to all branches. + // Applying a branch on the shared filters logic reloads all three reads scoped to it — so the detail + // page's numbers (and the chart's runs) match the branch-scoped Workflows tab instead of widening + // back to all branches. filters.actions.setBranchFilter('master') filters.actions.applyBranchFilter() await expectLogic(logic).toDispatchActions([ 'loadRuns', + 'loadRunActivity', 'loadRunnerCosts', 'loadRunsSuccess', + 'loadRunActivitySuccess', 'loadRunnerCostsSuccess', ]) expect(mockRuns).toHaveBeenLastCalledWith('1', expect.objectContaining({ branch: 'master' })) + expect(mockRunActivity).toHaveBeenLastCalledWith('1', expect.objectContaining({ branch: 'master' })) expect(mockRunnerCosts).toHaveBeenLastCalledWith('1', expect.objectContaining({ branch: 'master' })) }) }) From 04d3eae4826295598437d516198621f8a153dbc2 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:44:06 +0000 Subject: [PATCH 8/8] chore: update OpenAPI generated types --- products/engineering_analytics/frontend/generated/api.ts | 2 +- services/mcp/src/api/generated.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/products/engineering_analytics/frontend/generated/api.ts b/products/engineering_analytics/frontend/generated/api.ts index 66e08aa15584..9143cd527b48 100644 --- a/products/engineering_analytics/frontend/generated/api.ts +++ b/products/engineering_analytics/frontend/generated/api.ts @@ -418,7 +418,7 @@ export const getEngineeringAnalyticsWorkflowRunActivityUrl = ( } /** - * Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Leaner and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when the cap is hit, so the chart covers only the most recent runs. + * Compact per-run points for a single workflow over a window (date_from default -30d), newest first, for the run-activity chart: each run's start time, duration, conclusion, branch, and attributed PR. Optionally scope to a single git branch via `branch`, matching workflow_runs. Leaner and higher-capped than workflow_runs so the chart spans the full window even on busy workflows; `truncated` is true when the cap is hit, so the chart covers only the most recent runs. */ export const engineeringAnalyticsWorkflowRunActivity = async ( projectId: string, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index f6ee65b63c68..664e127309c4 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -61707,6 +61707,10 @@ export namespace Schemas { }; export type EngineeringAnalyticsWorkflowRunActivityParams = { + /** + * Optional exact git branch (head_branch) to scope results to, e.g. 'main'. Omit or leave blank to aggregate across all branches. + */ + branch?: string; /** * Window start: relative ('-30d', '-8w') or ISO8601. Defaults to -30d. */