|
3 | 3 | // would let the rate be inflated. |
4 | 4 | import { describe, expect, it } from "vitest"; |
5 | 5 | import { |
6 | | - AUTOMATION_COUNTED_ACTIONS, |
7 | 6 | AUTOMATION_RATE_PROVENANCE_HORIZON_ISO, |
8 | 7 | buildAutomationRateSeries, |
9 | 8 | loadAutomationRateSeries, |
@@ -108,6 +107,28 @@ describe("buildAutomationRateSeries", () => { |
108 | 107 | expect(series.automated).toBe(1); |
109 | 108 | }); |
110 | 109 |
|
| 110 | + it("counts a PR MANUAL when a human signal rides a NON-deciding verdict, not just a deciding one (#10013)", () => { |
| 111 | + // The bug: `queryAutomationRows` restricted rows by `action`, so a human signal carried on a non-deciding |
| 112 | + // verdict (a re-labelled PR, a maintainer-triggered re-run recorded as `label`/`update_branch`) never |
| 113 | + // reached the fold. The PR's only surviving row was the clean `merge`, so a PR a person actually touched |
| 114 | + // read as AUTOMATED. With the filter gone the fold sees the human-signal row and ORs it in. |
| 115 | + const humanOnLabel = buildAutomationRateSeries([ |
| 116 | + row({ pullNumber: 1, action: "label", reevaluationActor: "JSONbored", createdAt: "2026-07-29T10:00:00.000Z" }), |
| 117 | + row({ pullNumber: 1, action: "merge", createdAt: "2026-07-29T11:00:00.000Z" }), |
| 118 | + ]); |
| 119 | + expect(humanOnLabel.decided).toBe(1); |
| 120 | + expect(humanOnLabel.automated).toBe(0); |
| 121 | + expect(humanOnLabel.weeks[0]?.manual).toBe(1); |
| 122 | + |
| 123 | + // Same for a maintainer_request reason riding a non-deciding verdict. |
| 124 | + const requestOnUpdate = buildAutomationRateSeries([ |
| 125 | + row({ pullNumber: 2, action: "update_branch", reevaluationReason: "maintainer_request", createdAt: "2026-07-29T10:00:00.000Z" }), |
| 126 | + row({ pullNumber: 2, action: "merge", createdAt: "2026-07-29T11:00:00.000Z" }), |
| 127 | + ]); |
| 128 | + expect(requestOnUpdate.automated).toBe(0); |
| 129 | + expect(requestOnUpdate.weeks[0]?.manual).toBe(1); |
| 130 | + }); |
| 131 | + |
111 | 132 | it("counts a hold-only PR as decided-and-manual, never as undecided", () => { |
112 | 133 | const series = buildAutomationRateSeries([row({ pullNumber: 1, action: "hold" })]); |
113 | 134 | expect(series.decided).toBe(1); |
@@ -217,12 +238,13 @@ describe("loadAutomationRateSeries", () => { |
217 | 238 | await loadAutomationRateSeries(env); |
218 | 239 | expect(sql).toContain("FROM decision_records"); |
219 | 240 | expect(sql).toContain("reevaluation_actor"); |
220 | | - // The action filter is built FROM the same constant the fold classifies on, so the placeholder count and |
221 | | - // the bind count cannot drift apart -- a mismatch is a D1 error at runtime that no injected-rows test |
222 | | - // above would ever reach. |
223 | | - expect(sql).toContain("action IN ("); |
224 | | - expect((sql.match(/\?/g) ?? []).length).toBe(binds.length); |
225 | | - expect(binds.slice(1)).toEqual([...AUTOMATION_COUNTED_ACTIONS]); |
| 241 | + // #10013: the read must NOT restrict rows by action -- a human signal (reevaluation_actor / |
| 242 | + // reevaluation_reason='maintainer_request') can ride a non-deciding verdict, and the old `action IN (...)` |
| 243 | + // filter dropped exactly those rows, undercounting manual work. The only bind is the `created_at >= ?` |
| 244 | + // window bound: one placeholder, one bind, and no action list. |
| 245 | + expect(sql).not.toContain("action IN ("); |
| 246 | + expect((sql.match(/\?/g) ?? []).length).toBe(1); |
| 247 | + expect(binds).toEqual([expect.any(String)]); |
226 | 248 | }); |
227 | 249 |
|
228 | 250 | it("degrades to an empty series when the read throws, never failing the stats endpoint", async () => { |
|
0 commit comments