Public: anyone can read this; the crew can change it.
A pure read lists the pairs of open pulls that touch the same files, most files shared first #6557
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6549-a-pure-read-lists-the-pairs-of-open-pull → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +83
−0
src/flow/conflict_pairs.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * Conflict pairs (#6549): pairs of open pulls that touch the same files, most | |
| 3 | * files shared first, then lowest numbers, at most 20. A pull touching more | |
| 4 | * than 60 files is left out, as the hot-file limit does for wide pulls. | |
| 5 | * Pure: plain data in, plain data out. | |
| 6 | */ | |
| 7 | ||
| 8 | export interface PullFiles { | |
| 9 | readonly number: number; | |
| 10 | readonly files: readonly string[]; | |
| 11 | } | |
| 12 | ||
| 13 | export interface ConflictPair { | |
| 14 | readonly a: number; | |
| 15 | readonly b: number; | |
| 16 | readonly shared: number; | |
| 17 | } | |
| 18 | ||
| 19 | const WIDE = 60; | |
| 20 | const LIMIT = 20; | |
| 21 | ||
| 22 | export function conflictPairs(pulls: readonly PullFiles[]): ConflictPair[] { | |
| 23 | const narrow = pulls | |
| 24 | .filter((p) => new Set(p.files).size <= WIDE) | |
| 25 | .map((p) => ({ number: p.number, files: new Set(p.files) })); | |
| 26 | const out: ConflictPair[] = []; | |
| 27 | for (let i = 0; i < narrow.length; i++) { | |
| 28 | for (let j = i + 1; j < narrow.length; j++) { | |
| 29 | const x = narrow[i]!; | |
| 30 | const y = narrow[j]!; | |
| 31 | if (x.number === y.number) continue; | |
| 32 | let shared = 0; | |
| 33 | for (const f of x.files) if (y.files.has(f)) shared++; | |
| 34 | if (shared > 0) out.push({ a: Math.min(x.number, y.number), b: Math.max(x.number, y.number), shared }); | |
| 35 | } | |
| 36 | } | |
| 37 | out.sort((p, q) => q.shared - p.shared || p.a - q.a || p.b - q.b); | |
| 38 | return out.slice(0, LIMIT); | |
| 39 | } |
test/flow-conflict-pairs.test.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { conflictPairs } from "../src/flow/conflict_pairs.ts"; | |
| 3 | ||
| 4 | const many = (n: number, p = "f") => Array.from({ length: n }, (_, i) => `${p}${i}`); | |
| 5 | ||
| 6 | test("no overlap gives no pairs", () => { | |
| 7 | expect(conflictPairs([{ number: 1, files: ["a"] }, { number: 2, files: ["b"] }])).toEqual([]); | |
| 8 | expect(conflictPairs([])).toEqual([]); | |
| 9 | }); | |
| 10 | ||
| 11 | test("one pair, a < b whatever the input order", () => { | |
| 12 | expect(conflictPairs([{ number: 9, files: ["a", "b"] }, { number: 3, files: ["b", "c"] }])).toEqual([ | |
| 13 | { a: 3, b: 9, shared: 1 }, | |
| 14 | ]); | |
| 15 | }); | |
| 16 | ||
| 17 | test("most shared first, then lowest numbers", () => { | |
| 18 | const r = conflictPairs([ | |
| 19 | { number: 5, files: ["x"] }, | |
| 20 | { number: 4, files: ["x", "y"] }, | |
| 21 | { number: 3, files: ["x", "y"] }, | |
| 22 | { number: 2, files: ["x"] }, | |
| 23 | ]); | |
| 24 | expect(r.map((p) => [p.a, p.b, p.shared])).toEqual([ | |
| 25 | [3, 4, 2], | |
| 26 | [2, 3, 1], | |
| 27 | [2, 4, 1], | |
| 28 | [2, 5, 1], | |
| 29 | [3, 5, 1], | |
| 30 | [4, 5, 1], | |
| 31 | ]); | |
| 32 | }); | |
| 33 | ||
| 34 | test("pulls touching more than 60 files are left out, 60 stays", () => { | |
| 35 | expect(conflictPairs([{ number: 1, files: many(61) }, { number: 2, files: ["f0"] }])).toEqual([]); | |
| 36 | expect(conflictPairs([{ number: 1, files: many(60) }, { number: 2, files: ["f0"] }])).toHaveLength(1); | |
| 37 | }); | |
| 38 | ||
| 39 | test("at most 20 pairs", () => { | |
| 40 | const pulls = Array.from({ length: 10 }, (_, i) => ({ number: i + 1, files: ["x"] })); | |
| 41 | const r = conflictPairs(pulls); | |
| 42 | expect(r).toHaveLength(20); | |
| 43 | expect(r[0]).toEqual({ a: 1, b: 2, shared: 1 }); | |
| 44 | }); |
new above
offline
Set is built twice per pull (filter, then map). Map to sets first, then filter on .size.