Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
24 changes: 17 additions & 7 deletions src/addBenchmarkEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Benchmark } from './extract';
import * as core from '@actions/core';
import { BenchmarkSuites } from './write';
import { normalizeBenchmark } from './normalizeBenchmark';
import { GitGraphAnalyzer } from './gitGraph';

export function addBenchmarkEntry(
benchName: string,
Expand All @@ -11,24 +12,33 @@ export function addBenchmarkEntry(
): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
let prevBench: Benchmark | null = null;
let normalizedCurrentBench: Benchmark = benchEntry;
const gitAnalyzer = new GitGraphAnalyzer();

// Add benchmark result
if (entries[benchName] === undefined) {
entries[benchName] = [benchEntry];
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
} else {
const suites = entries[benchName];
// Get the last suite which has different commit ID for alert comment
for (const e of [...suites].reverse()) {
if (e.commit.id !== benchEntry.commit.id) {
prevBench = e;
break;
}

// Use git-graph aware logic to find previous benchmark
const currentBranch = gitAnalyzer.getCurrentBranch();
core.debug(`Finding previous benchmark for branch: ${currentBranch}`);

prevBench = gitAnalyzer.findPreviousBenchmark(suites, benchEntry.commit.id, currentBranch);

if (prevBench) {
core.debug(`Found previous benchmark: ${prevBench.commit.id}`);
} else {
core.debug('No previous benchmark found');
}

normalizedCurrentBench = normalizeBenchmark(prevBench, benchEntry);

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

if (maxItems !== null && suites.length > maxItems) {
suites.splice(0, suites.length - maxItems);
Expand Down
30 changes: 26 additions & 4 deletions src/default_index_html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ export const DEFAULT_INDEX_HTML = String.raw`<!DOCTYPE html>
};

function init() {
function sortEntriesByGitOrder(entries) {
// Sort benchmarks by commit timestamp instead of execution time
// This provides better git graph ordering for visualization
return [...entries].sort((a, b) => {
const timestampA = new Date(a.commit.timestamp).getTime();
const timestampB = new Date(b.commit.timestamp).getTime();
return timestampA - timestampB;
});
}

function collectBenchesPerTestCase(entries) {
const map = new Map();
for (const entry of entries) {
Expand All @@ -141,6 +151,14 @@ export const DEFAULT_INDEX_HTML = String.raw`<!DOCTYPE html>
}
}
}
// Sort each benchmark's data points by commit timestamp to ensure consistent ordering
for (const [benchName, arr] of map.entries()) {
arr.sort((a, b) => {
const timestampA = new Date(a.commit.timestamp).getTime();
const timestampB = new Date(b.commit.timestamp).getTime();
return timestampA - timestampB;
});
}
return map;
}

Expand All @@ -162,10 +180,14 @@ export const DEFAULT_INDEX_HTML = String.raw`<!DOCTYPE html>
};

// Prepare data points for charts
return Object.keys(data.entries).map(name => ({
name,
dataSet: collectBenchesPerTestCase(data.entries[name]),
}));
return Object.keys(data.entries).map(name => {
const entries = data.entries[name];
const sortedEntries = sortEntriesByGitOrder(entries);
return {
name,
dataSet: collectBenchesPerTestCase(sortedEntries),
};
});
}

function renderAllChars(dataSets) {
Expand Down
205 changes: 205 additions & 0 deletions src/gitGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as github from '@actions/github';
import { execSync } from 'child_process';
import * as core from '@actions/core';
import { Benchmark } from './extract';

export class GitGraphAnalyzer {
private readonly gitCliAvailable: boolean;

constructor() {
// Check if we're in GitHub Actions environment (git CLI available)
this.gitCliAvailable = process.env.GITHUB_ACTIONS === 'true' && Boolean(process.env.GITHUB_WORKSPACE);
}

/**
* Get current branch from GitHub context
*/
getCurrentBranch(): string {
const context = github.context;

// For pull requests, get the head branch
if (context.payload.pull_request) {
return context.payload.pull_request.head.ref;
}

// For pushes, get the branch from ref
if (context.ref) {
// Remove 'refs/heads/' prefix if present
return context.ref.replace('refs/heads/', '');
}

// Fallback to 'main' if we can't determine branch
return 'main';
}

/**
* Get git ancestry using topological order (only works in GitHub Actions environment)
*/
getBranchAncestry(branch: string): string[] {
if (!this.gitCliAvailable) {
core.warning('Git CLI not available, cannot determine ancestry');
return [];
}

try {
const output = execSync(`git log --oneline --topo-order ${branch}`, {
encoding: 'utf8',
cwd: process.env.GITHUB_WORKSPACE ?? process.cwd(),
});

return output
.split('\n')
.filter((line) => line.trim())
.map((line) => line.split(' ')[0]); // Extract SHA from "sha message"
} catch (error) {
core.warning(`Failed to get ancestry for branch ${branch}: ${error}`);
return [];
}
}

/**
* Find previous benchmark commit based on git graph structure
*/
findPreviousBenchmark(suites: Benchmark[], currentSha: string, branch: string): Benchmark | null {
const ancestry = this.getBranchAncestry(branch);

if (ancestry.length === 0) {
core.warning(`No ancestry found for branch ${branch}, falling back to execution time ordering`);
return this.findPreviousByExecutionTime(suites, currentSha);
}

// Find position of current commit in ancestry
const currentIndex = ancestry.indexOf(currentSha);
if (currentIndex === -1) {
core.warning(`Current commit ${currentSha} not found in ancestry, falling back to execution time ordering`);
return this.findPreviousByExecutionTime(suites, currentSha);
}

// Look for next commit in ancestry that exists in benchmarks
for (let i = currentIndex + 1; i < ancestry.length; i++) {
const previousSha = ancestry[i];
const previousBenchmark = suites.find((suite) => suite.commit.id === previousSha);

if (previousBenchmark) {
core.debug(`Found previous benchmark: ${previousSha} based on git ancestry`);
return previousBenchmark;
}
}

// Fallback: no previous commit found in ancestry
core.debug('No previous benchmark found in git ancestry');
return null;
}

/**
* Sort benchmark data by commit timestamp (for GitHub Pages visualization)
* This doesn't need git CLI - just uses the commit timestamps already stored
*/
sortByGitOrder(suites: Benchmark[]): Benchmark[] {
if (suites.length === 0) return suites;

// For GitHub Pages, we don't have git CLI, so sort by commit timestamp
// This gives a reasonable approximation of git order
const sortedSuites = [...suites].sort((a, b) => {
const timestampA = new Date(a.commit.timestamp ?? '1970-01-01T00:00:00Z').getTime();
const timestampB = new Date(b.commit.timestamp ?? '1970-01-01T00:00:00Z').getTime();
return timestampA - timestampB;
});

core.debug('Sorted benchmarks by commit timestamp (GitHub Pages mode)');
return sortedSuites;
}

/**
* Advanced sorting using git CLI (only for GitHub Actions)
*/
sortByGitOrderWithCLI(suites: Benchmark[]): Benchmark[] {
if (!this.gitCliAvailable) {
return this.sortByGitOrder(suites);
}

if (suites.length === 0) return suites;

// Create a map of SHA to benchmark for quick lookup
const benchmarkMap = new Map<string, Benchmark>();
for (const suite of suites) {
benchmarkMap.set(suite.commit.id, suite);
}

// Get ancestry from all commits (use the branch of the first commit)
const firstSuite = suites[0];
const ancestry = this.getBranchAncestry(firstSuite.commit.id);

if (ancestry.length === 0) {
core.warning('Could not determine git ancestry, falling back to timestamp sort');
return this.sortByGitOrder(suites);
}

// Sort benchmarks according to git ancestry
const sortedSuites: Benchmark[] = [];
for (const sha of ancestry) {
const benchmark = benchmarkMap.get(sha);
if (benchmark) {
sortedSuites.push(benchmark);
}
}

// Add any benchmarks not found in ancestry (shouldn't happen, but be safe)
for (const suite of suites) {
if (!sortedSuites.includes(suite)) {
sortedSuites.push(suite);
}
}

core.debug(`Sorted ${sortedSuites.length} benchmarks using git CLI ancestry`);
return sortedSuites;
}

/**
* Find the insertion index for a new benchmark entry based on git ancestry.
* Returns the index after which the new entry should be inserted.
* If no ancestor is found, returns -1 (insert at beginning) or suites.length (append to end).
*/
findInsertionIndex(suites: Benchmark[], newCommitSha: string): number {
if (!this.gitCliAvailable || suites.length === 0) {
// Fallback: append to end
return suites.length;
}

const ancestry = this.getBranchAncestry(newCommitSha);
if (ancestry.length === 0) {
core.debug('No ancestry found, appending to end');
return suites.length;
}

// Create a set of ancestor SHAs for quick lookup (excluding the commit itself)
const ancestorSet = new Set(ancestry.slice(1)); // Skip first element (the commit itself)

// Find the most recent ancestor in the existing suites
// Iterate through suites from end to beginning to find the most recent one
for (let i = suites.length - 1; i >= 0; i--) {
const suite = suites[i];
if (ancestorSet.has(suite.commit.id)) {
core.debug(`Found ancestor ${suite.commit.id} at index ${i}, inserting after it`);
return i + 1; // Insert after this ancestor
}
}

// No ancestor found in existing suites - this commit is likely from a different branch
// or is very old. Append to end as fallback.
core.debug('No ancestor found in existing suites, appending to end');
return suites.length;
}

/**
* Fallback method: find previous by execution time (original logic)
*/
private findPreviousByExecutionTime(suites: Benchmark[], currentSha: string): Benchmark | null {
for (const suite of [...suites].reverse()) {
if (suite.commit.id !== currentSha) {
return suite;
}
}
return null;
}
}
Loading
Loading