Skip to content
Open
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
36 changes: 24 additions & 12 deletions packages/enricher/src/comment-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,30 @@ function commentPrefix(languageId: string): string {
function formatFlagComment(flag: EnrichedFlag): string {
const parts: string[] = [`Flag: "${flag.flagKey}"`];

if (flag.flag) {
parts.push(flag.flagType);
if (flag.rollout !== null) {
parts.push(`${flag.rollout}% rolled out`);
}
if (flag.experiment) {
const status = flag.experiment.end_date ? "complete" : "running";
parts.push(`Experiment: "${flag.experiment.name}" (${status})`);
}
if (flag.staleness) {
parts.push(`STALE (${flag.staleness})`);
}
if (!flag.flag) {
parts.push("not in PostHog");
return parts.join(" \u2014 ");
}

parts.push(flag.flagType);
parts.push(flag.flag.active ? "active" : "inactive");
if (flag.rollout !== null) {
parts.push(`${flag.rollout}% rolled out`);
}
Comment on lines +20 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "inactive" appears twice for the same flag

When a flag has active: false, the comment will contain both the explicit "inactive" segment (line 21) and "STALE (inactive)" from the staleness classifier — confirmed by the test that asserts both are present. The staleness segment already communicates the reason the flag is inactive, so displaying "inactive" as a separate part is redundant and violates the OnceAndOnlyOnce simplicity rule.

Consider either suppressing the active/inactive label when flag.staleness === "inactive", or only showing the active/inactive label and dropping it from the staleness display.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/enricher/src/comment-formatter.ts
Line: 20-22

Comment:
**"inactive" appears twice for the same flag**

When a flag has `active: false`, the comment will contain both the explicit `"inactive"` segment (line 21) and `"STALE (inactive)"` from the staleness classifier — confirmed by the test that asserts both are present. The staleness segment already communicates the reason the flag is inactive, so displaying `"inactive"` as a separate part is redundant and violates the OnceAndOnlyOnce simplicity rule.

Consider either suppressing the `active`/`inactive` label when `flag.staleness === "inactive"`, or only showing the active/inactive label and dropping it from the staleness display.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

if (flag.evaluationStats) {
const evals = flag.evaluationStats.evaluations.toLocaleString();
const users = flag.evaluationStats.uniqueUsers.toLocaleString();
parts.push(`${evals} evals / ${users} users (7d)`);
}
if (flag.experiment) {
const status = flag.experiment.end_date ? "complete" : "running";
parts.push(`Experiment: "${flag.experiment.name}" (${status})`);
}
if (flag.staleness) {
parts.push(`STALE (${flag.staleness})`);
}
if (flag.url) {
parts.push(flag.url);
}

return parts.join(" \u2014 ");
Expand Down
12 changes: 12 additions & 0 deletions packages/enricher/src/enriched-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
extractVariants,
} from "./flag-classification.js";
import type { ParseResult } from "./parse-result.js";
import { buildFlagUrl } from "./posthog-api.js";
import { classifyStaleness } from "./stale-flags.js";
import type {
EnrichedEvent,
Expand Down Expand Up @@ -33,10 +34,15 @@ export class EnrichedResult {
const checks = this.parsed.flagChecks;
const experiments = this.context.experiments ?? [];

const { host, projectId } = this.context;
for (const check of checks) {
let entry = flagMap.get(check.flagKey);
if (!entry) {
const flag = this.context.flags?.get(check.flagKey);
const url =
flag && host && projectId !== undefined
? buildFlagUrl(host, projectId, flag.id)
: null;
entry = {
flagKey: check.flagKey,
occurrences: [],
Expand All @@ -53,6 +59,8 @@ export class EnrichedResult {
experiment: experiments.find(
(e) => e.feature_flag_key === check.flagKey,
),
url,
evaluationStats: this.context.flagEvaluationStats?.get(check.flagKey),
};
flagMap.set(check.flagKey, entry);
}
Expand Down Expand Up @@ -120,6 +128,10 @@ export class EnrichedResult {
enriched.flagType = flag.flagType;
enriched.staleness = flag.staleness;
enriched.rollout = flag.rollout;
enriched.active = flag.flag?.active;
enriched.url = flag.url;
enriched.evaluations = flag.evaluationStats?.evaluations;
enriched.evaluationUsers = flag.evaluationStats?.uniqueUsers;
if (flag.experiment) {
enriched.experimentName = flag.experiment.name;
enriched.experimentStatus = flag.experiment.end_date
Expand Down
113 changes: 113 additions & 0 deletions packages/enricher/src/enricher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function mockApiResponses(opts: {
experiments?: Experiment[];
eventDefs?: EventDefinition[];
eventStats?: [string, number, number, string][];
flagEvalStats?: [string, number, number][];
}): void {
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : String(url);
Expand All @@ -96,6 +97,15 @@ function mockApiResponses(opts: {
return Response.json({ results: opts.eventDefs ?? [] });
}
if (urlStr.includes("/query/") && init?.method === "POST") {
const body =
typeof init.body === "string"
? init.body
: init.body instanceof Uint8Array
? new TextDecoder().decode(init.body)
: "";
if (body.includes("$feature_flag_called")) {
return Response.json({ results: opts.flagEvalStats ?? [] });
}
return Response.json({ results: opts.eventStats ?? [] });
}
return Response.json({});
Expand Down Expand Up @@ -383,6 +393,109 @@ describeWithGrammars("PostHogEnricher", () => {
expect(annotated).toContain("1,200 users");
});

test("enrichedFlags includes url and evaluation stats", async () => {
const code = `posthog.getFeatureFlag('my-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({
flags: [makeFlag("my-flag", { id: 42 })],
flagEvalStats: [["my-flag", 1240, 230]],
});
const enriched = await result.enrichFromApi(API_CONFIG);

expect(enriched.flags[0].url).toBe(
"https://test.posthog.com/project/1/feature_flags/42",
);
expect(enriched.flags[0].evaluationStats).toEqual({
evaluations: 1240,
uniqueUsers: 230,
});
});

test("toComments renders rich flag line with url, active, rollout, evals", async () => {
const code = `posthog.getFeatureFlag('my-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({
flags: [
makeFlag("my-flag", {
id: 42,
filters: { groups: [{ rollout_percentage: 60, properties: [] }] },
}),
],
flagEvalStats: [["my-flag", 1240, 230]],
});
const enriched = await result.enrichFromApi(API_CONFIG);

const annotated = enriched.toComments();
expect(annotated).toContain(`Flag: "my-flag"`);
expect(annotated).toContain("active");
expect(annotated).toContain("60% rolled out");
expect(annotated).toContain("1,240 evals / 230 users (7d)");
expect(annotated).toContain(
"https://test.posthog.com/project/1/feature_flags/42",
);
});

test("toComments marks inactive flags explicitly", async () => {
const code = `posthog.getFeatureFlag('off-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({ flags: [makeFlag("off-flag", { active: false })] });
const enriched = await result.enrichFromApi(API_CONFIG);

const annotated = enriched.toComments();
expect(annotated).toContain("inactive");
expect(annotated).toContain("STALE (inactive)");
});

test("toComments handles flag not in PostHog", async () => {
const code = `posthog.getFeatureFlag('ghost-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({ flags: [] });
const enriched = await result.enrichFromApi(API_CONFIG);

const annotated = enriched.toComments();
expect(annotated).toContain(`Flag: "ghost-flag" \u2014 not in PostHog`);
});

test("toComments omits evaluation segment when stats missing", async () => {
const code = `posthog.getFeatureFlag('quiet-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({ flags: [makeFlag("quiet-flag", { id: 7 })] });
const enriched = await result.enrichFromApi(API_CONFIG);

const annotated = enriched.toComments();
expect(annotated).toContain(`Flag: "quiet-flag"`);
expect(annotated).not.toContain("evals /");
expect(annotated).toContain(
"https://test.posthog.com/project/1/feature_flags/7",
);
});

test("getFlagEvaluationStats is called with detected flag keys", async () => {
const code = `posthog.getFeatureFlag('tracked-flag');`;
const result = await enricher.parse(code, "javascript");

mockApiResponses({
flags: [makeFlag("tracked-flag")],
flagEvalStats: [["tracked-flag", 10, 5]],
});
await result.enrichFromApi(API_CONFIG);

const calls = vi.mocked(fetch).mock.calls;
const queryPost = calls.find(
([url, init]) =>
String(url).includes("/query/") && init?.method === "POST",
);
expect(queryPost).toBeDefined();
const body = String(queryPost?.[1]?.body ?? "");
expect(body).toContain("$feature_flag_called");
expect(body).toContain("tracked-flag");
});

test("enrichFromApi with no detected usage returns empty enrichment", async () => {
Comment on lines +402 to 499
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests

The five new toComments tests share the same three-step skeleton — parse a snippet, call mockApiResponses, assert on toComments() output — and differ only in the flag configuration and expected substrings. Per the team's convention they should be a single test.each table, e.g.:

test.each([
  {
    name: "rich line with url, active, rollout, evals",
    flagOpts: { id: 42, filters: { groups: [{ rollout_percentage: 60, properties: [] }] } },
    flagKey: "my-flag",
    evalStats: [["my-flag", 1240, 230]] as [string, number, number][],
    contains: ["active", "60% rolled out", "1,240 evals / 230 users (7d)", "feature_flags/42"],
    notContains: [],
  },
  // … inactive, ghost, quiet-flag variants …
])("toComments $name", async ({ flagKey, flagOpts, evalStats, contains, notContains }) => {
  const result = await enricher.parse(`posthog.getFeatureFlag('${flagKey}');`, "javascript");
  mockApiResponses({ flags: [makeFlag(flagKey, flagOpts)], flagEvalStats: evalStats });
  const annotated = (await result.enrichFromApi(API_CONFIG)).toComments();
  contains.forEach((s) => expect(annotated).toContain(s));
  notContains.forEach((s) => expect(annotated).not.toContain(s));
});

This reduces repetition without losing coverage.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/enricher/src/enricher.test.ts
Line: 402-499

Comment:
**Prefer parameterised tests**

The five new `toComments` tests share the same three-step skeleton — parse a snippet, call `mockApiResponses`, assert on `toComments()` output — and differ only in the flag configuration and expected substrings. Per the team's convention they should be a single `test.each` table, e.g.:

```ts
test.each([
  {
    name: "rich line with url, active, rollout, evals",
    flagOpts: { id: 42, filters: { groups: [{ rollout_percentage: 60, properties: [] }] } },
    flagKey: "my-flag",
    evalStats: [["my-flag", 1240, 230]] as [string, number, number][],
    contains: ["active", "60% rolled out", "1,240 evals / 230 users (7d)", "feature_flags/42"],
    notContains: [],
  },
  // … inactive, ghost, quiet-flag variants …
])("toComments $name", async ({ flagKey, flagOpts, evalStats, contains, notContains }) => {
  const result = await enricher.parse(`posthog.getFeatureFlag('${flagKey}');`, "javascript");
  mockApiResponses({ flags: [makeFlag(flagKey, flagOpts)], flagEvalStats: evalStats });
  const annotated = (await result.enrichFromApi(API_CONFIG)).toComments();
  contains.forEach((s) => expect(annotated).toContain(s));
  notContains.forEach((s) => expect(annotated).not.toContain(s));
});
```

This reduces repetition without losing coverage.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const code = `const x = 1;`;
const result = await enricher.parse(code, "javascript");
Expand Down
21 changes: 19 additions & 2 deletions packages/enricher/src/parse-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
EventStats,
FlagAssignment,
FlagCheck,
FlagEvaluationStats,
FunctionInfo,
ListItem,
PostHogCall,
Expand Down Expand Up @@ -113,16 +114,25 @@ export class ParseResult {
eventNames.length > 0
? api.getEventStats(eventNames)
: Promise.resolve(new Map()),
flagKeys.length > 0
? api.getFlagEvaluationStats(flagKeys, 7)
: Promise.resolve(new Map()),
]);

const [flagsResult, experimentsResult, eventDefsResult, eventStatsResult] =
settled;
const [
flagsResult,
experimentsResult,
eventDefsResult,
eventStatsResult,
flagEvalStatsResult,
] = settled;

const labels = [
"getFeatureFlags",
"getExperiments",
"getEventDefinitions",
"getEventStats",
"getFlagEvaluationStats",
];
settled.forEach((r, i) => {
if (r.status === "rejected") {
Expand All @@ -140,6 +150,10 @@ export class ParseResult {
eventStatsResult.status === "fulfilled"
? eventStatsResult.value
: new Map<string, EventStats>();
const flagEvaluationStats =
flagEvalStatsResult.status === "fulfilled"
? flagEvalStatsResult.value
: new Map<string, FlagEvaluationStats>();

const flagKeySet = new Set(flagKeys);
const flags = new Map(
Expand All @@ -161,6 +175,9 @@ export class ParseResult {
experiments,
eventDefinitions,
eventStats,
flagEvaluationStats,
host: config.host,
projectId: config.projectId,
});
}
}
47 changes: 47 additions & 0 deletions packages/enricher/src/posthog-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@ import type {
EventStats,
Experiment,
FeatureFlag,
FlagEvaluationStats,
} from "./types.js";

export function buildFlagUrl(
host: string,
projectId: number,
flagId: number,
): string {
return `${host.replace(/\/$/, "")}/project/${projectId}/feature_flags/${flagId}`;
}

export class PostHogApi {
private config: EnricherApiConfig;

Expand Down Expand Up @@ -122,4 +131,42 @@ export class PostHogApi {
}
return stats;
}

async getFlagEvaluationStats(
flagKeys: string[],
daysBack = 7,
): Promise<Map<string, FlagEvaluationStats>> {
if (flagKeys.length === 0) {
return new Map();
}

const days = Math.max(1, Math.min(365, Math.floor(daysBack)));
const query = `
SELECT
properties.$feature_flag AS flag_key,
count() AS evaluations,
count(DISTINCT person_id) AS unique_users
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag IN {flagKeys}
AND timestamp >= now() - INTERVAL ${days} DAY
GROUP BY flag_key
`;

const data = await this.post<{
results: [string, number, number][];
}>("/query/", {
query: {
kind: "HogQLQuery",
query,
values: { flagKeys },
},
});

const stats = new Map<string, FlagEvaluationStats>();
for (const [flagKey, evaluations, uniqueUsers] of data.results) {
stats.set(flagKey, { evaluations, uniqueUsers });
}
return stats;
}
}
14 changes: 14 additions & 0 deletions packages/enricher/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export interface EnrichedListItem extends ListItem {
flagType?: FlagType;
staleness?: StalenessReason | null;
rollout?: number | null;
active?: boolean;
url?: string | null;
evaluations?: number;
evaluationUsers?: number;
experimentName?: string | null;
experimentStatus?: "running" | "complete" | null;
verified?: boolean;
Expand All @@ -177,12 +181,20 @@ export interface EventStats {
lastSeenAt?: string | null;
}

export interface FlagEvaluationStats {
evaluations: number;
uniqueUsers: number;
}

export interface EnrichmentContext {
flags?: Map<string, FeatureFlag>;
experiments?: Experiment[];
eventDefinitions?: Map<string, EventDefinition>;
eventStats?: Map<string, EventStats>;
flagEvaluationStats?: Map<string, FlagEvaluationStats>;
stalenessOptions?: StalenessCheckOptions;
host?: string;
projectId?: number;
}

export interface StalenessCheckOptions {
Expand All @@ -198,6 +210,8 @@ export interface EnrichedFlag {
rollout: number | null;
variants: { key: string; rollout_percentage: number }[];
experiment: Experiment | undefined;
url: string | null;
evaluationStats: FlagEvaluationStats | undefined;
}

export interface EnrichedEvent {
Expand Down
Loading