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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,19 @@ async function runInCI(

const queries = buildQueries(allResults, config);

// POST to Site API first so we get the run ID for the PR comment link
let runId: string | null = null;
// POST to Site API first so we get the run ID (for baseline exclusion) and
// the unified CI-signal metadata for the PR comment.
let runResult = null;
if (siteApiEndpoint) {
runId = await postToSiteApi(siteApiEndpoint, queries, reportContext.statisticsMode, reportContext.computedStats);
runResult = await postToSiteApi(siteApiEndpoint, queries, reportContext.statisticsMode, reportContext.computedStats);
}
const runId: string | null = runResult?.id ?? null;

// Build the run URL and query base URL for the PR comment
if (siteApiEndpoint && runId) {
// SITE_API_ENDPOINT is e.g. https://api.querydoctor.com
// The app lives at https://app.querydoctor.com — derive from the API URL
const appUrl =
process.env.SITE_APP_URL ??
siteApiEndpoint.replace(/\/api\/?$/, "").replace("api.", "app.");
const baseUrl = appUrl.replace(/\/$/, "");
reportContext.runUrl = `${baseUrl}/ixr/ci/${runId}`;
reportContext.queryBaseUrl = baseUrl;
// Run link and per-query links come straight from the API response. Both
// degrade gracefully: `url` is null and `queries` is empty for an unlinked repo.
if (runResult) {
reportContext.runUrl = runResult.url ?? undefined;
reportContext.runMetadata = runResult.metadata ?? undefined;
}

// Fetch previous run for comparison
Expand Down Expand Up @@ -145,12 +142,14 @@ async function runInCI(

// Block PR if regressions exceed thresholds
if (reportContext.comparison && reportContext.comparison.regressed.length > 0) {
const queryLinks = new Map(
(reportContext.runMetadata?.queries ?? []).map((q) => [q.hash, q.link]),
);
const messages = reportContext.comparison.regressed.map((q) => {
const preview = queryPreview(q.formattedQuery);
const cost = `cost ${formatCost(q.previousCost)} → ${formatCost(q.currentCost)} (+${q.regressionPercentage.toFixed(1)}%)`;
const link = reportContext.runUrl
? `\n ${reportContext.runUrl}/${q.hash}`
: "";
const queryLink = queryLinks.get(q.hash);
const link = queryLink ? `\n ${queryLink}` : "";
return ` - ${preview}: ${cost}${link}`;
});
core.setFailed(
Expand Down
47 changes: 47 additions & 0 deletions src/reporters/github/__snapshots__/github.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`CI-signal metadata parity (analyzer#141) > linked repo: renders rollup line, per-query links, footer, run link, and docs link 1`] = `
"### Query Doctor Analysis

28 queries analyzed
2 regressed · 1 improved · 3 new · 0 removed

#### This PR improves queries

- [<code>SELECT 2</code>](https://app.querydoctor.com/alice/proj/ci/9f3a1c20/improved-1)<br>cost 500 → 100 (80% reduction)

#### This PR has regressions on queries

- [<code>SELECT 1</code>](https://app.querydoctor.com/alice/proj/ci/9f3a1c20/regressed-1)<br>cost 120 → 170 (+42%)




<sub>Using assumed statistics (10000 rows/table). For better results, <a href="https://docs.querydoctor.com/reference/statistics">sync production stats</a>.</sub>

<sub>More detail → get_ci_run({ runId: "9f3a1c20" }) · <a href="https://app.querydoctor.com/alice/proj/ci/9f3a1c20">view run</a> · <a href="https://docs.querydoctor.com">docs</a></sub>
"
`;

exports[`CI-signal metadata parity (analyzer#141) > unlinked repo: rollup + footer + docs only, no run link, no per-query links 1`] = `
"### Query Doctor Analysis

28 queries analyzed
2 regressed · 1 improved · 3 new · 0 removed

#### This PR improves queries

- <code>SELECT 2</code><br>cost 500 → 100 (80% reduction)

#### This PR has regressions on queries

- <code>SELECT 1</code><br>cost 120 → 170 (+42%)




<sub>Using assumed statistics (10000 rows/table). For better results, <a href="https://docs.querydoctor.com/reference/statistics">sync production stats</a>.</sub>

<sub>More detail → get_ci_run({ runId: "9f3a1c20" }) · <a href="https://docs.querydoctor.com">docs</a></sub>
"
`;
119 changes: 118 additions & 1 deletion src/reporters/github/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
import n from "nunjucks";
import { formatCost, queryPreview, buildViewModel } from "./github.ts";
import { isQueryLong, renderExplain, type ReportContext } from "../reporter.ts";
import type { RunComparison } from "../site-api.ts";
import type { CiRunMetadata, RunComparison } from "../site-api.ts";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -340,3 +340,120 @@ describe("template rendering", () => {
expect(output).toContain("3 queries analyzed");
});
});

function makeMetadata(overrides: Partial<CiRunMetadata> = {}): CiRunMetadata {
return {
rollup: { regressed: 2, improved: 1, new: 3, removed: 0 },
rollupText: "2 regressed · 1 improved · 3 new · 0 removed",
footer: 'More detail → get_ci_run({ runId: "9f3a1c20" })',
docsUrl: "https://docs.querydoctor.com",
signalKeys: {
new: "signal.new",
regressed: "signal.regressed",
improved: "signal.improved",
index: "signal.index",
},
queries: [
{ hash: "regressed-1", link: "https://app.querydoctor.com/alice/proj/ci/9f3a1c20/regressed-1" },
{ hash: "improved-1", link: "https://app.querydoctor.com/alice/proj/ci/9f3a1c20/improved-1" },
],
...overrides,
};
}

describe("CI-signal metadata parity (analyzer#141)", () => {
const regressedComparison = makeComparison({
regressed: [
{
hash: "regressed-1",
query: "SELECT 1",
formattedQuery: "SELECT 1",
previousCost: 120,
currentCost: 170,
regressionPercentage: 42,
},
],
improved: [
{
hash: "improved-1",
query: "SELECT 2",
formattedQuery: "SELECT 2",
previousCost: 500,
currentCost: 100,
improvementPercentage: 80,
previousIndexes: [],
currentIndexes: [],
},
],
});

test("linked repo: renders rollup line, per-query links, footer, run link, and docs link", () => {
const ctx = makeContext({
comparison: regressedComparison,
runUrl: "https://app.querydoctor.com/alice/proj/ci/9f3a1c20",
runMetadata: makeMetadata(),
});
const output = renderTemplate(ctx);

// Roll-up line rendered verbatim (single source of truth — no re-derived grammar).
expect(output).toContain("2 regressed · 1 improved · 3 new · 0 removed");
// Footer rendered verbatim.
expect(output).toContain('More detail → get_ci_run({ runId: "9f3a1c20" })');
// Per-query rows link via metadata.queries, not a re-derived /ixr/ route.
expect(output).toContain("https://app.querydoctor.com/alice/proj/ci/9f3a1c20/regressed-1");
expect(output).toContain("https://app.querydoctor.com/alice/proj/ci/9f3a1c20/improved-1");
expect(output).not.toContain("/ixr/");
// Run link and small docs link in the meta row.
expect(output).toContain('<a href="https://app.querydoctor.com/alice/proj/ci/9f3a1c20">view run</a>');
expect(output).toContain('<a href="https://docs.querydoctor.com">docs</a>');
// Per-signal icons aren't rendered yet (assets pending Site follow-up).
expect(output).not.toContain("<img");

expect(output).toMatchSnapshot();
});

test("unlinked repo: rollup + footer + docs only, no run link, no per-query links", () => {
const ctx = makeContext({
comparison: regressedComparison,
runUrl: undefined,
runMetadata: makeMetadata({ queries: [] }),
});
const output = renderTemplate(ctx);

// Shared elements still present.
expect(output).toContain("2 regressed · 1 improved · 3 new · 0 removed");
expect(output).toContain('More detail → get_ci_run({ runId: "9f3a1c20" })');
expect(output).toContain('<a href="https://docs.querydoctor.com">docs</a>');
// No run link, no per-query links when the repo isn't linked.
expect(output).not.toContain("view run");
expect(output).not.toContain("https://app.querydoctor.com/alice/proj/ci");
// Query previews still render, just without anchors.
expect(output).toContain("<code>SELECT 1</code>");

expect(output).toMatchSnapshot();
});

test("no metadata (degraded API response): no rollup or footer row", () => {
const ctx = makeContext({
comparison: regressedComparison,
runMetadata: undefined,
});
const output = renderTemplate(ctx);

expect(output).not.toContain("regressed · ");
expect(output).not.toContain("get_ci_run");
expect(output).not.toContain("docs</a>");
});

test("null docsUrl: docs link omitted, footer still rendered", () => {
const ctx = makeContext({
comparison: regressedComparison,
runUrl: "https://app.querydoctor.com/alice/proj/ci/9f3a1c20",
runMetadata: makeMetadata({ docsUrl: null }),
});
const output = renderTemplate(ctx);

expect(output).toContain('More detail → get_ci_run({ runId: "9f3a1c20" })');
expect(output).not.toContain(">docs</a>");
});
});
17 changes: 17 additions & 0 deletions src/reporters/github/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import type { ImprovedQuery, RegressedQuery } from "../site-api.ts";

n.configure({ autoescape: false, trimBlocks: true, lstripBlocks: true });

// NOTE: The Site API exposes presentation-agnostic `signalKeys` (available in
// the template via `runMetadata.signalKeys`), but we don't render per-signal
// icons yet — the image assets don't exist. Rendering is deferred until the
// Site follow-up that hosts them. See Query-Doctor/Site (analyzer#141 follow-up).

interface DisplayRecommendation extends ReportIndexRecommendation {
queryPreview: string;
}
Expand Down Expand Up @@ -85,8 +90,18 @@ function addImprovementPreviews(
}));
}

/** Per-query detail links keyed by query hash, sourced from the run metadata. */
function buildQueryLinks(ctx: ReportContext): Record<string, string> {
const links: Record<string, string> = {};
for (const q of ctx.runMetadata?.queries ?? []) {
links[q.hash] = q.link;
}
return links;
}

export function buildViewModel(ctx: ReportContext) {
const hasComparison = !!ctx.comparison;
const queryLinks = buildQueryLinks(ctx);

if (!hasComparison) {
return {
Expand All @@ -97,6 +112,7 @@ export function buildViewModel(ctx: ReportContext) {
preExistingRecommendations: [] as DisplayRecommendation[],
newQueryCount: 0,
hasComparison: false,
queryLinks,
};
}

Expand All @@ -123,6 +139,7 @@ export function buildViewModel(ctx: ReportContext) {
preExistingRecommendations,
newQueryCount: ctx.comparison!.newQueries.length,
hasComparison: true,
queryLinks,
};
}

Expand Down
23 changes: 16 additions & 7 deletions src/reporters/github/success.md.j2
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
### Query Doctor Analysis
{% if runUrl %}
[View full run details]({{ runUrl }})
{% endif %}

{% if hasComparison %}
{{ queryStats.analyzed | default('?') }} queries analyzed
Expand All @@ -11,20 +8,26 @@

> **No baseline on `{{ comparisonBranch }}`** — the analyzer cannot detect regressions without a previous run. To establish a baseline, add a `push` trigger for your comparison branch so the analyzer runs on merges to `{{ comparisonBranch }}`. See the [CI integration guide](https://docs.querydoctor.com/guides/ci-integration/#workflow-trigger) for setup instructions.
{% endif %}
{% if runMetadata %}

{{ runMetadata.rollupText }}
{% endif %}

{% if displayImproved.length > 0 %}
#### This PR improves queries

{% for q in displayImproved %}
- {% if runUrl %}[<code>{{ q.queryPreview }}</code>]({{ runUrl }}/{{ q.hash }}){% else %}<code>{{ q.queryPreview }}</code>{% endif %}<br>cost {{ formatCost(q.previousCost) }} → {{ formatCost(q.currentCost) }} ({{ q.improvementPercentage | round(0) }}% reduction){% if q.indexesChanged %}{% if q.previousIndexes.length > 0 %}<br>was using: {% for idx in q.previousIndexes %}<code>{{ idx }}</code>{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% if q.currentIndexes.length > 0 %}<br>now using: {% for idx in q.currentIndexes %}<code>{{ idx }}</code>{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% endif %}{{""}}
{% set link = queryLinks[q.hash] %}
- {% if link %}[<code>{{ q.queryPreview }}</code>]({{ link }}){% else %}<code>{{ q.queryPreview }}</code>{% endif %}<br>cost {{ formatCost(q.previousCost) }} → {{ formatCost(q.currentCost) }} ({{ q.improvementPercentage | round(0) }}% reduction){% if q.indexesChanged %}{% if q.previousIndexes.length > 0 %}<br>was using: {% for idx in q.previousIndexes %}<code>{{ idx }}</code>{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% if q.currentIndexes.length > 0 %}<br>now using: {% for idx in q.currentIndexes %}<code>{{ idx }}</code>{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% endif %}{{""}}
{% endfor %}
{% endif %}

{% if displayRegressed.length > 0 %}
#### This PR has regressions on queries

{% for q in displayRegressed %}
- {% if runUrl %}[<code>{{ q.queryPreview }}</code>]({{ runUrl }}/{{ q.hash }}){% else %}<code>{{ q.queryPreview }}</code>{% endif %}<br>cost {{ formatCost(q.previousCost) }} → {{ formatCost(q.currentCost) }} (+{{ q.regressionPercentage | round(0) }}%)
{% set link = queryLinks[q.hash] %}
- {% if link %}[<code>{{ q.queryPreview }}</code>]({{ link }}){% else %}<code>{{ q.queryPreview }}</code>{% endif %}<br>cost {{ formatCost(q.previousCost) }} → {{ formatCost(q.currentCost) }} (+{{ q.regressionPercentage | round(0) }}%)
{% endfor %}
{% endif %}

Expand All @@ -42,7 +45,8 @@
#### This PR introduces queries with recommendations

{% for r in displayRecommendations %}
- {% if runUrl %}[<code>{{ r.queryPreview }}</code>]({{ runUrl }}/{{ r.fingerprint }}){% else %}<code>{{ r.queryPreview }}</code>{% endif %}<br>recommended index <code>{{ r.proposedIndexes | join("</code>, <code>") }}</code><br>cost {{ formatCost(r.baseCost) }} → {{ formatCost(r.optimizedCost) }} ({{ (((r.baseCost - r.optimizedCost) / r.baseCost) * 100) | round(0) }}% reduction)
{% set link = queryLinks[r.fingerprint] %}
- {% if link %}[<code>{{ r.queryPreview }}</code>]({{ link }}){% else %}<code>{{ r.queryPreview }}</code>{% endif %}<br>recommended index <code>{{ r.proposedIndexes | join("</code>, <code>") }}</code><br>cost {{ formatCost(r.baseCost) }} → {{ formatCost(r.optimizedCost) }} ({{ (((r.baseCost - r.optimizedCost) / r.baseCost) * 100) | round(0) }}% reduction)
{% endfor %}
{% endif %}

Expand All @@ -51,11 +55,16 @@
<summary>{{ preExistingRecommendations.length }} pre-existing issue{{ "s" if preExistingRecommendations.length != 1 else "" }}</summary>

{% for r in preExistingRecommendations %}
- {% if runUrl %}[<code>{{ r.queryPreview }}</code>]({{ runUrl }}/{{ r.fingerprint }}){% else %}<code>{{ r.queryPreview }}</code>{% endif %}<br>index <code>{{ r.proposedIndexes | join("</code>, <code>") }}</code><br>cost {{ formatCost(r.baseCost) }} → {{ formatCost(r.optimizedCost) }} ({{ (((r.baseCost - r.optimizedCost) / r.baseCost) * 100) | round(0) }}% reduction)
{% set link = queryLinks[r.fingerprint] %}
- {% if link %}[<code>{{ r.queryPreview }}</code>]({{ link }}){% else %}<code>{{ r.queryPreview }}</code>{% endif %}<br>index <code>{{ r.proposedIndexes | join("</code>, <code>") }}</code><br>cost {{ formatCost(r.baseCost) }} → {{ formatCost(r.optimizedCost) }} ({{ (((r.baseCost - r.optimizedCost) / r.baseCost) * 100) | round(0) }}% reduction)
{% endfor %}
</details>
{% endif %}

{% if statisticsMode.kind == "fromAssumption" %}
<sub>Using assumed statistics ({{ statisticsMode.reltuples }} rows/table). For better results, <a href="https://docs.querydoctor.com/reference/statistics">sync production stats</a>.</sub>
{% endif %}
{% if runMetadata %}

<sub>{{ runMetadata.footer }}{% if runUrl %} · <a href="{{ runUrl }}">view run</a>{% endif %}{% if runMetadata.docsUrl %} · <a href="{{ runMetadata.docsUrl }}">docs</a>{% endif %}</sub>
{% endif %}
6 changes: 4 additions & 2 deletions src/reporters/reporter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComputedStats, IndexIdentifier, StatisticsMode } from "@query-doctor/core";
import type { RunComparison } from "./site-api.ts";
import type { CiRunMetadata, RunComparison } from "./site-api.ts";

export interface Reporter {
provider(): string;
Expand Down Expand Up @@ -83,8 +83,10 @@ export interface ReportContext {
error?: Error;
comparison?: RunComparison;
comparisonBranch?: string;
/** The run page link (`metadata.url` from `POST /ci/runs`). Absent when the repo isn't linked. */
runUrl?: string;
queryBaseUrl?: string;
/** Unified CI-signal metadata: roll-up line, footer, per-query links, docs link, icon keys. */
runMetadata?: CiRunMetadata;
}

export interface IndexStatistic {
Expand Down
Loading
Loading