diff --git a/products/engineering_analytics/backend/facade/api.py b/products/engineering_analytics/backend/facade/api.py index e64eb4b30601..977eb556c852 100644 --- a/products/engineering_analytics/backend/facade/api.py +++ b/products/engineering_analytics/backend/facade/api.py @@ -34,6 +34,7 @@ QuarantineRequestResult, WorkflowHealthItem, WorkflowJob, + WorkflowRunActivity, WorkflowRunDetail, WorkflowRunnerCost, ) @@ -138,6 +139,27 @@ 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, + branch: 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, + branch=branch, + ) + + 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..4547720f5bd4 100644 --- a/products/engineering_analytics/backend/facade/contracts.py +++ b/products/engineering_analytics/backend/facade/contracts.py @@ -216,6 +216,42 @@ 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 + # 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 + # 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 352721ba969f..2ca6f3f9eeaf 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 @@ -124,6 +126,31 @@ 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, + branch: 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, + branch=branch, + ) + + 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..62a7043c86d9 --- /dev/null +++ b/products/engineering_analytics/backend/logic/queries/workflow_run_activity.py @@ -0,0 +1,88 @@ +"""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. + +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 + +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: 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""" + 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__ __BRANCH__ + 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, + branch: str | None = 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) + # 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) + .replace("__BRANCH__", branch_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..8bc1923eeaae 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. 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.", + "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 be62b7f5723d..bbe05d815c50 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", ] @@ -545,6 +547,61 @@ 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, + _BRANCH, + _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. " + "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) + 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, + 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, + ) + 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 ab866de0645c..bef66a6072d4 100644 --- a/products/engineering_analytics/backend/tests/test_logic.py +++ b/products/engineering_analytics/backend/tests/test_logic.py @@ -874,6 +874,44 @@ 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) + # 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( + 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_workflow_detail_branch_filter(self) -> None: # The workflow detail page's runs list and runner-cost breakdown must honor the same branch scope # as the Workflows tab — without it, drilling in from a branch-scoped tab widened back to every @@ -922,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 2db779016e67..71e40d732082 100644 --- a/products/engineering_analytics/frontend/generated/api.schemas.ts +++ b/products/engineering_analytics/frontend/generated/api.schemas.ts @@ -622,6 +622,36 @@ 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. 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 + */ + 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 @@ -776,6 +806,33 @@ export type EngineeringAnalyticsWorkflowRunParams = { source_id?: string } +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. + */ + 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 = { /** * Optional exact git branch (head_branch) to scope results to, e.g. 'main'. Omit or leave blank to aggregate across all branches. diff --git a/products/engineering_analytics/frontend/generated/api.ts b/products/engineering_analytics/frontend/generated/api.ts index c020db274f1e..9143cd527b48 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' @@ -396,6 +398,39 @@ export const engineeringAnalyticsWorkflowRun = 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. 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, + params: EngineeringAnalyticsWorkflowRunActivityParams, + options?: RequestInit +): Promise => { + return apiMutator(getEngineeringAnalyticsWorkflowRunActivityUrl(projectId, params), { + ...options, + method: 'GET', + }) +} + export const getEngineeringAnalyticsWorkflowRunnerCostsUrl = ( projectId: string, params: EngineeringAnalyticsWorkflowRunnerCostsParams 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 2a6cff579fc4..c6565fe91a01 100644 --- a/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx +++ b/products/engineering_analytics/frontend/scenes/WorkflowRunsScene.tsx @@ -117,6 +117,8 @@ export function WorkflowRunsScene(): JSX.Element { healthSummary, costSummary, runsTruncated, + activityRuns, + activityTruncated, } = useValues(workflowRunsLogic) const { loadRuns, setRunExpanded } = useActions(workflowRunsLogic) const { dateFrom, dateTo } = useValues(engineeringAnalyticsFiltersLogic) @@ -223,7 +225,7 @@ export function WorkflowRunsScene(): JSX.Element { - + {runnerCosts.length > 0 && }

Runs

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' })) }) }) diff --git a/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts b/products/engineering_analytics/frontend/scenes/workflowRunsLogic.ts index 13a096d5640a..a37e621d9d94 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' @@ -83,6 +90,23 @@ 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, + branch: values.appliedBranch || 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[], @@ -166,6 +190,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 @@ -223,19 +267,22 @@ export const workflowRunsLogic = kea([ actions.loadJobs({ runId, runAttempt }) } }, - // The shared window and branch scope both lists — reload them together when either changes. + // The shared window scopes all three reads — reload them together when it changes. [engineeringAnalyticsFiltersLogic.actionTypes.setDateRange]: () => { actions.loadRuns() + actions.loadRunActivity() actions.loadRunnerCosts() }, [engineeringAnalyticsFiltersLogic.actionTypes.setAppliedBranch]: () => { 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 diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 50dc9a5060e4..c581c80b8846 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53486,6 +53486,36 @@ 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. 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 + */ + 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; @@ -61793,6 +61823,33 @@ export namespace Schemas { source_id?: string; }; + 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. + */ + 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 = { /** * Optional exact git branch (head_branch) to scope results to, e.g. 'main'. Omit or leave blank to aggregate across all branches.