Skip to content

Commit e936a38

Browse files
authored
Merge pull request #3 from paradedb/ankitml/git-graph-awareness
Ankitml/git graph awareness
2 parents 2e6f4e3 + 15b0628 commit e936a38

8 files changed

Lines changed: 615 additions & 13 deletions

src/addBenchmarkEntry.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Benchmark } from './extract';
22
import * as core from '@actions/core';
33
import { BenchmarkSuites } from './write';
44
import { normalizeBenchmark } from './normalizeBenchmark';
5+
import { GitGraphAnalyzer } from './gitGraph';
56

67
export function addBenchmarkEntry(
78
benchName: string,
@@ -11,24 +12,32 @@ export function addBenchmarkEntry(
1112
): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
1213
let prevBench: Benchmark | null = null;
1314
let normalizedCurrentBench: Benchmark = benchEntry;
15+
const gitAnalyzer = new GitGraphAnalyzer();
1416

1517
// Add benchmark result
1618
if (entries[benchName] === undefined) {
1719
entries[benchName] = [benchEntry];
1820
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
1921
} else {
2022
const suites = entries[benchName];
21-
// Get the last suite which has different commit ID for alert comment
22-
for (const e of [...suites].reverse()) {
23-
if (e.commit.id !== benchEntry.commit.id) {
24-
prevBench = e;
25-
break;
26-
}
23+
24+
// Find previous benchmark using git ancestry
25+
core.debug(`Finding previous benchmark for commit: ${benchEntry.commit.id}`);
26+
27+
prevBench = gitAnalyzer.findPreviousBenchmark(suites, benchEntry.commit.id);
28+
29+
if (prevBench) {
30+
core.debug(`Found previous benchmark: ${prevBench.commit.id}`);
31+
} else {
32+
core.debug('No previous benchmark found');
2733
}
2834

2935
normalizedCurrentBench = normalizeBenchmark(prevBench, benchEntry);
3036

31-
suites.push(normalizedCurrentBench);
37+
// Insert at the correct position based on git ancestry
38+
const insertionIndex = gitAnalyzer.findInsertionIndex(suites, benchEntry.commit.id);
39+
core.debug(`Inserting benchmark at index ${insertionIndex} (of ${suites.length} existing entries)`);
40+
suites.splice(insertionIndex, 0, normalizedCurrentBench);
3241

3342
if (maxItems !== null && suites.length > maxItems) {
3443
suites.splice(0, suites.length - maxItems);

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as os from 'os';
44
import * as path from 'path';
55

66
export type ToolType = typeof VALID_TOOLS[number];
7+
78
export interface Config {
89
name: string;
910
tool: ToolType;

src/default_index_html.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,14 @@ export const DEFAULT_INDEX_HTML = String.raw`<!DOCTYPE html>
161161
a.click();
162162
};
163163
164-
// Prepare data points for charts
165-
return Object.keys(data.entries).map(name => ({
166-
name,
167-
dataSet: collectBenchesPerTestCase(data.entries[name]),
168-
}));
164+
// Prepare data points for charts (uses server-side ordering)
165+
return Object.keys(data.entries).map(name => {
166+
const entries = data.entries[name];
167+
return {
168+
name,
169+
dataSet: collectBenchesPerTestCase(entries),
170+
};
171+
});
169172
}
170173
171174
function renderAllChars(dataSets) {

src/gitGraph.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { execSync } from 'child_process';
2+
import * as core from '@actions/core';
3+
import { Benchmark } from './extract';
4+
5+
export class GitGraphAnalyzer {
6+
private readonly gitCliAvailable: boolean;
7+
8+
constructor() {
9+
try {
10+
execSync('git --version', { stdio: 'ignore' });
11+
this.gitCliAvailable = true;
12+
} catch (e) {
13+
this.gitCliAvailable = false;
14+
}
15+
}
16+
17+
/**
18+
* Check if git CLI is available
19+
*/
20+
isGitAvailable(): boolean {
21+
return this.gitCliAvailable;
22+
}
23+
24+
/**
25+
* Get git ancestry using topological order
26+
*/
27+
getAncestry(ref: string): string[] {
28+
if (!this.gitCliAvailable) {
29+
core.warning('Git CLI not available, cannot determine ancestry');
30+
return [];
31+
}
32+
33+
try {
34+
const output = execSync(`git log --oneline --topo-order ${ref}`, {
35+
encoding: 'utf8',
36+
cwd: process.env.GITHUB_WORKSPACE ?? process.cwd(),
37+
});
38+
39+
return output
40+
.split('\n')
41+
.filter((line) => line.trim())
42+
.map((line) => line.split(' ')[0]); // Extract SHA from "sha message"
43+
} catch (error) {
44+
core.warning(`Failed to get ancestry for ref ${ref}: ${error}`);
45+
return [];
46+
}
47+
}
48+
49+
/**
50+
* Find previous benchmark commit based on git ancestry.
51+
* Falls back to execution time ordering if git ancestry is not available.
52+
*/
53+
findPreviousBenchmark(suites: Benchmark[], currentSha: string): Benchmark | null {
54+
const ancestry = this.getAncestry(currentSha);
55+
56+
if (ancestry.length === 0) {
57+
core.warning(`No ancestry found for commit ${currentSha}, falling back to execution time ordering`);
58+
return this.findPreviousByExecutionTime(suites, currentSha);
59+
}
60+
61+
// Find position of current commit in ancestry
62+
const currentIndex = ancestry.indexOf(currentSha);
63+
if (currentIndex === -1) {
64+
core.warning(`Current commit ${currentSha} not found in ancestry, falling back to execution time ordering`);
65+
return this.findPreviousByExecutionTime(suites, currentSha);
66+
}
67+
68+
// Look for next commit in ancestry that exists in benchmarks
69+
for (let i = currentIndex + 1; i < ancestry.length; i++) {
70+
const previousSha = ancestry[i];
71+
const previousBenchmark = suites.find((suite) => suite.commit.id === previousSha);
72+
73+
if (previousBenchmark) {
74+
core.debug(`Found previous benchmark: ${previousSha} based on git ancestry`);
75+
return previousBenchmark;
76+
}
77+
}
78+
79+
// Fallback: no previous commit found in ancestry
80+
core.debug('No previous benchmark found in git ancestry');
81+
return null;
82+
}
83+
84+
/**
85+
* Find the insertion index for a new benchmark entry based on git ancestry.
86+
* Inserts after the most recent ancestor in the existing suites.
87+
*/
88+
findInsertionIndex(suites: Benchmark[], newCommitSha: string): number {
89+
if (!this.gitCliAvailable || suites.length === 0) {
90+
return suites.length;
91+
}
92+
93+
const ancestry = this.getAncestry(newCommitSha);
94+
if (ancestry.length === 0) {
95+
core.debug('No ancestry found, appending to end');
96+
return suites.length;
97+
}
98+
99+
// Create a set of ancestor SHAs for quick lookup (excluding the commit itself)
100+
const ancestorSet = new Set(ancestry.slice(1)); // Skip first element (the commit itself)
101+
102+
// Find the most recent ancestor in the existing suites
103+
// Iterate through suites from end to beginning to find the most recent one
104+
for (let i = suites.length - 1; i >= 0; i--) {
105+
const suite = suites[i];
106+
if (ancestorSet.has(suite.commit.id)) {
107+
core.debug(`Found ancestor ${suite.commit.id} at index ${i}, inserting after it`);
108+
return i + 1; // Insert after this ancestor
109+
}
110+
}
111+
112+
// No ancestor found in existing suites - this commit is likely from a different branch
113+
// or is very old. Append to end as fallback.
114+
core.debug('No ancestor found in existing suites, appending to end');
115+
return suites.length;
116+
}
117+
118+
/**
119+
* Fallback method: find previous by execution time (original logic)
120+
*/
121+
private findPreviousByExecutionTime(suites: Benchmark[], currentSha: string): Benchmark | null {
122+
for (const suite of [...suites].reverse()) {
123+
if (suite.commit.id !== currentSha) {
124+
return suite;
125+
}
126+
}
127+
return null;
128+
}
129+
}

src/write.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ async function writeBenchmarkToExternalJson(
490490
jsonFilePath: string,
491491
config: Config,
492492
): Promise<{ prevBench: Benchmark | null; normalizedCurrentBench: Benchmark }> {
493-
const { name, maxItemsInChart, saveDataFile } = config;
493+
const { name, saveDataFile, maxItemsInChart } = config;
494494
const data = await loadDataJson(jsonFilePath);
495495
const { prevBench, normalizedCurrentBench } = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
496496

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Mock modules before imports
2+
const mockDebug = jest.fn();
3+
const mockFindPreviousBenchmark = jest.fn();
4+
const mockFindInsertionIndex = jest.fn();
5+
6+
jest.mock('@actions/core', () => ({
7+
debug: mockDebug,
8+
}));
9+
10+
jest.mock('../src/gitGraph', () => ({
11+
GitGraphAnalyzer: jest.fn().mockImplementation(() => ({
12+
findPreviousBenchmark: mockFindPreviousBenchmark,
13+
findInsertionIndex: mockFindInsertionIndex,
14+
})),
15+
}));
16+
17+
import { addBenchmarkEntry } from '../src/addBenchmarkEntry';
18+
import { Benchmark } from '../src/extract';
19+
20+
describe('addBenchmarkEntry with Git Graph', () => {
21+
const createMockBenchmark = (id: string, timestamp?: string): Benchmark => ({
22+
commit: {
23+
id,
24+
timestamp: timestamp ?? '2025-01-01T00:00:00Z',
25+
message: `Commit ${id}`,
26+
url: `https://github.com/test/repo/commit/${id}`,
27+
author: { username: 'testuser' },
28+
committer: { username: 'testuser' },
29+
},
30+
date: Date.now(),
31+
tool: 'cargo',
32+
benches: [
33+
{
34+
name: 'test_bench',
35+
value: 100,
36+
unit: 'ms',
37+
},
38+
],
39+
});
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
});
44+
45+
it('should use git graph analyzer to find previous benchmark', () => {
46+
const benchName = 'test-suite';
47+
const benchEntry = createMockBenchmark('abc123');
48+
const existingEntry = createMockBenchmark('def456');
49+
const entries = {
50+
[benchName]: [existingEntry],
51+
};
52+
53+
mockFindPreviousBenchmark.mockReturnValue(existingEntry);
54+
mockFindInsertionIndex.mockReturnValue(1);
55+
56+
const result = addBenchmarkEntry(benchName, benchEntry, entries, null);
57+
58+
expect(mockFindPreviousBenchmark).toHaveBeenCalledWith(expect.arrayContaining([existingEntry]), 'abc123');
59+
expect(mockDebug).toHaveBeenCalledWith('Finding previous benchmark for commit: abc123');
60+
expect(mockDebug).toHaveBeenCalledWith('Found previous benchmark: def456');
61+
expect(result.prevBench).toBe(existingEntry);
62+
});
63+
64+
it('should handle no previous benchmark found', () => {
65+
const benchName = 'test-suite';
66+
const benchEntry = createMockBenchmark('abc123');
67+
const existingEntry = createMockBenchmark('def456');
68+
const entries = {
69+
[benchName]: [existingEntry],
70+
};
71+
72+
mockFindPreviousBenchmark.mockReturnValue(null);
73+
mockFindInsertionIndex.mockReturnValue(1);
74+
75+
const result = addBenchmarkEntry(benchName, benchEntry, entries, null);
76+
77+
expect(mockDebug).toHaveBeenCalledWith('Finding previous benchmark for commit: abc123');
78+
expect(mockDebug).toHaveBeenCalledWith('No previous benchmark found');
79+
expect(result.prevBench).toBeNull();
80+
});
81+
82+
it('should create new benchmark suite when none exists', () => {
83+
const benchName = 'test-suite';
84+
const benchEntry = createMockBenchmark('abc123');
85+
const entries: any = {};
86+
87+
mockFindPreviousBenchmark.mockReturnValue(null);
88+
89+
const result = addBenchmarkEntry(benchName, benchEntry, entries, null);
90+
91+
expect(entries[benchName]).toEqual([benchEntry]);
92+
expect(result.prevBench).toBeNull();
93+
expect(result.normalizedCurrentBench).toBe(benchEntry);
94+
expect(mockDebug).toHaveBeenCalledWith(
95+
"No suite was found for benchmark 'test-suite' in existing data. Created",
96+
);
97+
});
98+
99+
it('should add to existing benchmark suite', () => {
100+
const benchName = 'test-suite';
101+
const benchEntry = createMockBenchmark('abc123');
102+
const existingEntry = createMockBenchmark('def456');
103+
const entries = {
104+
[benchName]: [existingEntry],
105+
};
106+
107+
mockFindPreviousBenchmark.mockReturnValue(existingEntry);
108+
mockFindInsertionIndex.mockReturnValue(1);
109+
110+
const result = addBenchmarkEntry(benchName, benchEntry, entries, null);
111+
112+
expect(entries[benchName]).toHaveLength(2);
113+
expect(entries[benchName][1]).toEqual(
114+
expect.objectContaining({
115+
commit: benchEntry.commit,
116+
tool: benchEntry.tool,
117+
}),
118+
);
119+
expect(result.prevBench).toBe(existingEntry);
120+
});
121+
122+
it('should respect maxItems limit', () => {
123+
const benchName = 'test-suite';
124+
const benchEntry = createMockBenchmark('new123');
125+
const oldEntries = [createMockBenchmark('old1'), createMockBenchmark('old2'), createMockBenchmark('old3')];
126+
const entries = {
127+
[benchName]: oldEntries,
128+
};
129+
130+
mockFindPreviousBenchmark.mockReturnValue(oldEntries[oldEntries.length - 1]);
131+
mockFindInsertionIndex.mockReturnValue(3);
132+
133+
addBenchmarkEntry(benchName, benchEntry, entries, 3);
134+
135+
// Should have 3 items total (maxItems)
136+
expect(entries[benchName]).toHaveLength(3);
137+
expect(entries[benchName][2]).toEqual(
138+
expect.objectContaining({
139+
commit: benchEntry.commit,
140+
tool: benchEntry.tool,
141+
}),
142+
);
143+
// Should have removed oldest entries to maintain maxItems
144+
// We started with 3 old entries + 1 new = 4, so maxItems=3 keeps the 3 most recent
145+
expect(entries[benchName]).toHaveLength(3);
146+
// The oldest entry (old1) should have been removed
147+
expect(entries[benchName].map((e) => e.commit.id)).not.toContain('old1');
148+
expect(mockDebug).toHaveBeenCalledWith(
149+
"Number of data items for 'test-suite' was truncated to 3 due to max-items-in-charts",
150+
);
151+
});
152+
153+
it('should not truncate when under maxItems limit', () => {
154+
const benchName = 'test-suite';
155+
const benchEntry = createMockBenchmark('new123');
156+
const oldEntries = [createMockBenchmark('old1')];
157+
const entries = {
158+
[benchName]: oldEntries,
159+
};
160+
161+
mockFindPreviousBenchmark.mockReturnValue(oldEntries[0]);
162+
mockFindInsertionIndex.mockReturnValue(1);
163+
164+
addBenchmarkEntry(benchName, benchEntry, entries, 5);
165+
166+
expect(entries[benchName]).toHaveLength(2);
167+
// Should not call debug about truncation
168+
expect(mockDebug).not.toHaveBeenCalledWith(expect.stringContaining('was truncated to'));
169+
});
170+
});

0 commit comments

Comments
 (0)