# BuildPulse Platform API — Full Reference This is the prose reference intended for LLM consumption. For the machine-readable contract, fetch `https://platform.buildpulse.io/openapi.yaml`. For an interactive renderer, open `https://platform.buildpulse.io/docs`. ## Overview BuildPulse is a CI analytics platform. The Platform API exposes read-only test analytics for any repository onboarded to BuildPulse. It is the drop-in replacement for the legacy Rails API at `https://buildpulse.io/api` — endpoint paths, query parameters, and JSON response shapes are identical so existing integrations migrate by changing only the base URL. ### Base URLs | Environment | URL | |-------------|-----| | Production | `https://platform.buildpulse.io` | | Development | `https://platform.dev.buildpulse.io` | ### Authentication All endpoints except `/health` require a 40-character hexadecimal API token. Tokens are created in the BuildPulse web app under Organization Settings → API Tokens (shown once at creation; SHA256- hashed at rest). Send the token in the `Authorization` header in one of two forms: ``` Authorization: token <40-hex-token> Authorization: Bearer <40-hex-token> ``` `token` is the legacy Rails form; `Bearer` is accepted as well so modern HTTP clients work without a custom header builder. Both behave identically. ### Errors | Status | Body | |--------|------| | 401 | empty | | 404 | `{"message":"Not Found"}` | | 400 | `{"errors":{"after":[""]}}` | | 500 | empty | The empty-body 401/500 contract matches the legacy Rails behavior (`render body: nil`). ### Pagination The next page cursor is returned as `metadata.after`. Pass it back verbatim as `?after=` to fetch the next page. When there is no next page, `metadata.after` is `null`. Two cursor encodings are in use (both base64; treat them as opaque): - **Keyset** (submissions only) — `base64("id:<24-hex>")`. - **Offset** (tests, flaky tests) — `base64("offset:")`. ### Reporting window All time-bounded endpoints use a fixed 14-day rolling window. A per-organization override is on the roadmap; not yet exposed. ### Time format All `*_at` fields are ISO 8601 with second precision, in UTC, e.g. `2026-05-17T12:34:56Z`. List fields are always JSON arrays (`[]` when empty, never `null`). --- ## Endpoints ### `GET /health` Liveness probe. Unauthenticated. Returns `{"status":"ok"}`. Used by the load balancer. **Example** ```bash curl https://platform.buildpulse.io/health # {"status":"ok"} ``` --- ### `GET /api` Validates the supplied token and that the organization has an active plan. Returns `204 No Content` on success, `401` (empty body) on failure. Useful as a smoke test before issuing other calls. **Example** ```bash curl -i -H "Authorization: token $BUILDPULSE_TOKEN" \ https://platform.buildpulse.io/api # HTTP/1.1 204 No Content ``` --- ### `GET /api/repos/{owner}/{name}/submissions` List recent test result sets ("submissions") for a single repository, newest first. Each submission represents one CI run that uploaded results. Repository lookup is case-insensitive. **Path parameters** - `owner` — Repository owner. - `name` — Repository name. **Query parameters** - `limit` (integer, default 10, max 100) — Page size. - `after` (string) — Opaque keyset cursor from a prior response's `metadata.after`. **Response shape** ```json { "count": 412, "submissions": [ { "key": "1234/5678/test-results.xml.gz", "build_url": "https://github.com/acme/widgets/actions/runs/9001", "commit_oid": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", "recorded_at": "2026-05-17T12:34:56Z", "status": "processed", "test_result_count": 412 } ], "metadata": { "after": "aWQ6NjVkNGYxYThiM2MyZDFlMGY5YThiN2M2", "limit": 10 } } ``` - `count` is the total matching the filter (not just this page). - `status` is the processing status (typically `processed` or `processing`). - `key` is the S3 object key for the raw archive — stable per submission. **Pagination loop (Python)** ```python import os, requests url = "https://platform.buildpulse.io/api/repos/acme/widgets/submissions" headers = {"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"} params = {"limit": 100} while True: r = requests.get(url, headers=headers, params=params) r.raise_for_status() data = r.json() for sub in data["submissions"]: print(sub["recorded_at"], sub["build_url"]) after = data["metadata"]["after"] if not after: break params["after"] = after ``` --- ### `GET /api/repos/{owner}/{name}/tests` List disruptive (flaky) tests for a single repository over the last 14 days. Default sort is by disruptiveness ratio descending. **Query parameters** - `limit` (integer, default 10, max 100). - `after` (string) — Opaque offset cursor. - `sort` (`disruptivenessRatio` | `recency`) — `recency` sorts by `latestDisruptionAt` descending. - `q` (string) — Structured query (see grammar below). **`q` grammar** — terms are space-separated and AND'd: - `first-seen:[op]YYYY-MM-DD` — operators `<`, `<=`, `>`, `>=`; no operator = exact day. - `last-seen:[op]YYYY-MM-DD`. - `tags:foo,bar` — matches any listed tag. - bare text — partial, case-insensitive match against `suite`, `name`, `file`, `classname`. Example: `q=last-seen:>=2026-05-01 tags:auth login`. **Response shape** — see OpenAPI spec for full schema; the per-test object includes: ```json { "id": "65d4f1a8b3c2d1e0f9a8b7c6", "name": "test_login_success", "suite": "auth", "class": "AuthTest", "file": "test/auth_test.rb", "disruptiveness": 0.375, "nondeterministic_negative_result_count": 3, "nondeterminism_first_recorded_at": "2026-05-10T08:00:00Z", "latest_nondeterministic_commit_oid": "a1b2c3d4...", "latest_nondeterministic_negative_result": { "recorded_at": "2026-05-17T10:00:00Z", "build_url": "https://github.com/acme/widgets/actions/runs/9001", "message": "AssertionError: expected 200, got 500" }, "latest_nondeterministic_positive_result": { "recorded_at": "2026-05-16T14:30:00Z", "build_url": "https://github.com/acme/widgets/actions/runs/8997", "message": null } } ``` - `disruptiveness` is truncated to 3 decimal places (matches legacy). - `message` on the positive result is always `null` (matches legacy). **Example (Node)** ```javascript const url = new URL("https://platform.buildpulse.io/api/repos/acme/widgets/tests"); url.searchParams.set("q", "last-seen:>=2026-05-01"); url.searchParams.set("sort", "recency"); url.searchParams.set("limit", "50"); const res = await fetch(url, { headers: { Authorization: `token ${process.env.BUILDPULSE_TOKEN}` }, }); const { tests } = await res.json(); for (const t of tests) { console.log(t.name, t.disruptiveness); } ``` --- ### `GET /api/tests/{id}/results` List up to 10 most-recent disruption events for a specific test within the 14-day window. The test must belong to the caller's org. **Path parameter** - `id` — Disruptor (test) ID as a 24-char MongoDB ObjectID hex. This is the `id` from `GET /api/repos/{owner}/{name}/tests` responses. **Response** ```json { "results": [ { "id": "65d4f1a8b3c2d1e0f9a8b7c7", "build_url": "https://github.com/acme/widgets/actions/runs/9001", "commit_oid": "a1b2c3d4...", "conclusion": "failure", "recorded_at": "2026-05-17T10:00:00Z", "message": "timeout: expected task to complete in 5s" } ] } ``` **Example (curl)** ```bash curl -H "Authorization: token $BUILDPULSE_TOKEN" \ https://platform.buildpulse.io/api/tests/65d4f1a8b3c2d1e0f9a8b7c6/results ``` --- ### `GET /api/v1/flaky/tests` Cross-repo flaky inventory with optional field expansion. Versioned (`/v1/`) endpoint. **Identifying the repo** — pass either: - `repository=` (case-insensitive), or - `repository_id=` (legacy numeric ID from the Rails era). **Query parameters** - `limit` (integer, default 25, max 100). - `after` (string) — Opaque offset cursor. - `sort` (`disruptivenessRatio` | `recency`). - `q` (string) — Same grammar as `/api/repos/{owner}/{name}/tests`. - `quarantine` (`true` | `false`, default `false`) — Include quarantined tests. - `include` (string) — Comma-separated list of optional fields to expand. Allowed values: - `disruptiveness_ratio` - `nondeterministic_negative_result_count` - `nondeterminism_first_recorded_at` - `recent_nondeterministic_commit_oid` - `recent_nondeterministic_negative_result` - `recent_nondeterministic_positive_result` - `tags` - `quarantine_date` - `quarantined_by` - `quarantine_type` - `time_consumed` Unknown fields are silently dropped. **Base response** (no `include`): ```json { "count": 14, "tests": [ { "id": "65d4f1a8b3c2d1e0f9a8b7c6", "name": "test_login", "suite": "auth", "class": "AuthTest", "file": "test/auth.rb", "disruptor_type": "flaky" } ], "metadata": { "after": null, "limit": 25 } } ``` **With `include=disruptiveness_ratio,tags,recent_nondeterministic_negative_result`**: ```json { "tests": [ { "id": "65d4f1a8b3c2d1e0f9a8b7c6", "name": "test_login", "suite": "auth", "class": "AuthTest", "file": "test/auth.rb", "disruptor_type": "flaky", "disruptiveness_ratio": 0.375, "tags": ["network", "auth"], "recent_nondeterministic_negative_result": { "recorded_at": "2026-05-17T10:00:00Z", "build_url": "https://github.com/acme/widgets/actions/runs/9001", "message": "AssertionError" } } ] } ``` **Example (Python)** ```python import os, requests r = requests.get( "https://platform.buildpulse.io/api/v1/flaky/tests", headers={"Authorization": f"token {os.environ['BUILDPULSE_TOKEN']}"}, params={ "repository": "widgets", "include": "disruptiveness_ratio,tags", "sort": "recency", "limit": 100, }, ) r.raise_for_status() for t in r.json()["tests"]: print(t["name"], t.get("disruptiveness_ratio"), t.get("tags")) ``` --- ### `GET /api/v1/flaky/badges` SVG badge showing flakiness percentage over the last 14 days. **Query parameter** - `repository` (required) — Repository name, case-insensitive. **Color thresholds** | Pct | Color | |------|---------| | 0% | Green | | 1–20% | Yellow | | >20% | Red | **README embed** ```markdown ![flakiness](https://platform.buildpulse.io/api/v1/flaky/badges?repository=widgets) ``` Note: badge endpoints require token authentication, unlike the legacy Rails badge endpoints (which were unauthenticated — a deliberate correction). --- ### `GET /api/v1/coverage/badges` SVG badge showing the latest coverage percentage. Uses the most- recently-uploaded coverage report, regardless of branch. **Query parameters** - `repository` (required) — Repository name. - `branch_or_pr` (reserved, ignored). **Color thresholds** | Pct | Color | |----------|-------------| | 100% | Green | | 90–99% | Light green | | 70–89% | Yellow | | <70% | Red | **README embed** ```markdown ![coverage](https://platform.buildpulse.io/api/v1/coverage/badges?repository=widgets) ``` --- ## MCP server (for AI agents) A Model Context Protocol server is published so Claude Desktop, Cursor, Cline, and other MCP-aware agents can call this API as tools. The MCP exposes intent-shaped tools (not 1:1 with HTTP endpoints): | Tool | Purpose | |----------------------------|---------| | `find_flaky_tests` | Query the flaky-test inventory with filters (repo, tags, recency). | | `get_test_history` | Recent disruption events for a single test. | | `list_recent_submissions` | Recent CI runs for a repo. | | `get_repo_flakiness` | Current flakiness percentage (numeric, not SVG). | | `get_repo_coverage` | Current coverage percentage (numeric, not SVG). | **Setup — local stdio** ```bash npx @buildpulse/mcp ``` Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): ```json { "mcpServers": { "buildpulse": { "command": "npx", "args": ["-y", "@buildpulse/mcp"], "env": { "BUILDPULSE_TOKEN": "your-40-char-hex-token" } } } } ``` Cursor config (`.cursor/mcp.json`): ```json { "mcpServers": { "buildpulse": { "command": "npx", "args": ["-y", "@buildpulse/mcp"], "env": { "BUILDPULSE_TOKEN": "your-40-char-hex-token" } } } } ``` Optional env: `PLATFORM_API_URL` (default `https://platform.buildpulse.io`) for pointing the MCP at a non-production environment.