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
77 changes: 77 additions & 0 deletions .claude/skills/github-issues/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
name: github-issues
description: Manage GitHub issues using the `gh` CLI. Use when the user asks to list, view, create, edit, close, reopen, comment on, or search GitHub issues.
tools: Bash
---

# GitHub Issues Skill

This project's repository is `benchmark-action/github-action-benchmark`. Always use `-R benchmark-action/github-action-benchmark` unless the user explicitly specifies a different repo.

## Common Operations

### List issues
```bash
gh issue list -R benchmark-action/github-action-benchmark
gh issue list -R benchmark-action/github-action-benchmark --state all
gh issue list -R benchmark-action/github-action-benchmark --state closed
gh issue list -R benchmark-action/github-action-benchmark --label "bug"
gh issue list -R benchmark-action/github-action-benchmark --assignee "@me"
gh issue list -R benchmark-action/github-action-benchmark --author monalisa
gh issue list -R benchmark-action/github-action-benchmark --search "error no:assignee"
gh issue list -R benchmark-action/github-action-benchmark --limit 100
gh issue list -R benchmark-action/github-action-benchmark --json number,title,state,labels,url
```

### View an issue
```bash
gh issue view 123 -R benchmark-action/github-action-benchmark
gh issue view 123 -R benchmark-action/github-action-benchmark --comments
gh issue view 123 -R benchmark-action/github-action-benchmark --json title,body,comments,labels,assignees,state,url
```

### Create an issue
```bash
gh issue create -R benchmark-action/github-action-benchmark --title "Title" --body "Body"
gh issue create -R benchmark-action/github-action-benchmark --title "Bug" --label "bug" --assignee "@me"
gh issue create -R benchmark-action/github-action-benchmark --web
```

### Edit an issue
```bash
gh issue edit 123 -R benchmark-action/github-action-benchmark --title "New title"
gh issue edit 123 -R benchmark-action/github-action-benchmark --body "Updated body"
gh issue edit 123 -R benchmark-action/github-action-benchmark --add-label "priority:high" --remove-label "triage"
gh issue edit 123 -R benchmark-action/github-action-benchmark --add-assignee "@me"
```

### Close / Reopen
```bash
gh issue close 123 -R benchmark-action/github-action-benchmark
gh issue close 123 -R benchmark-action/github-action-benchmark --reason "not planned"
gh issue reopen 123 -R benchmark-action/github-action-benchmark
```

### Comment on an issue
```bash
gh issue comment 123 -R benchmark-action/github-action-benchmark --body "My comment"
```

### Pin / Unpin
```bash
gh issue pin 123 -R benchmark-action/github-action-benchmark
gh issue unpin 123 -R benchmark-action/github-action-benchmark
```

## Workflow

1. **Determine the intent** from the user's request (list, view, create, edit, close, comment, etc.)
2. **Always pass** `-R benchmark-action/github-action-benchmark` unless the user says otherwise
3. **Run the appropriate `gh issue` command** using the Bash tool
4. **Present the output** clearly; for `--json` output, summarize the relevant fields rather than dumping raw JSON

## Tips

- Issue numbers and URLs are both valid arguments
- Use `--json fields` + `--jq expression` for precise filtering
- `gh issue status -R benchmark-action/github-action-benchmark` shows issues relevant to you
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,20 @@ which means there is no limit.

If set to `true`, the workflow will skip fetching branch defined with the `gh-pages-branch` variable.

#### `go-force-package-suffix` (Optional)

- Type: Boolean
- Default: `false`

Go-specific option. When running benchmarks across multiple packages, this action automatically appends
the package name as a suffix to benchmark names to disambiguate them (e.g., `BenchmarkFoo (github.com/example/pkg1)`).
By default, if the benchmark name already contains a reference to the package path, the suffix is skipped
to avoid duplication.

If set to `true`, the package suffix is always added regardless of whether the benchmark name already
contains a package reference. This can be useful when you want consistent naming or when the automatic
detection doesn't match your naming conventions.


### Action outputs

Expand Down
2 changes: 2 additions & 0 deletions action-types.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ inputs:
type: string
max-items-in-chart:
type: integer
go-force-package-suffix:
type: boolean
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ inputs:
max-items-in-chart:
description: 'Max data points in a benchmark chart to avoid making the chart too busy. Value must be unsigned integer. No limit by default'
required: false
go-force-package-suffix:
description: 'Force adding package suffix to Go benchmark names even when name already contains package reference'
required: false
default: 'false'

runs:
using: 'node20'
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface Config {
externalDataJsonPath: string | undefined;
maxItemsInChart: number | null;
ref: string | undefined;
goForcePackageSuffix: boolean;
}

export const VALID_TOOLS = [
Expand Down Expand Up @@ -240,6 +241,7 @@ export async function configFromJobInput(): Promise<Config> {
let externalDataJsonPath: undefined | string = core.getInput('external-data-json-path');
const maxItemsInChart = getUintInput('max-items-in-chart');
let failThreshold = getPercentageInput('fail-threshold');
const goForcePackageSuffix = getBoolInput('go-force-package-suffix');

validateToolType(tool);
outputFilePath = await validateOutputFilePath(outputFilePath);
Expand Down Expand Up @@ -287,5 +289,6 @@ export async function configFromJobInput(): Promise<Config> {
maxItemsInChart,
failThreshold,
ref,
goForcePackageSuffix,
};
}
24 changes: 21 additions & 3 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
}

interface PullRequest {
[key: string]: any;

Check warning on line 37 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
number: number;
html_url?: string;
body?: string;
Expand Down Expand Up @@ -343,7 +343,23 @@
return ret;
}

export function extractGoResult(output: string): BenchmarkResult[] {
function containsPackageRef(name: string, pkg: string): boolean {
const segments = pkg.split('/');
// Require at least 2 segments to avoid false positives (e.g., "cache" appearing in BenchmarkCache)
const minSegments = 2;
for (let i = 0; i <= segments.length - minSegments; i++) {
const suffix = segments.slice(i).join('/');
if (name.includes(suffix)) return true;
if (name.includes(suffix.replace(/\//g, '_'))) return true;
}
return false;
}

export interface GoExtractOptions {
forcePackageSuffix?: boolean;
}

export function extractGoResult(output: string, options: GoExtractOptions = {}): BenchmarkResult[] {
// Split into sections by "pkg:" lines, keeping package name with each section
const sections = output.split(/^pkg:\s+/m).map((section, index) => {
if (index === 0) return { pkg: '', lines: section.split(/\r?\n/g) };
Expand Down Expand Up @@ -386,7 +402,9 @@
pieces.unshift(pieces[0], remainder.slice(remainder.indexOf(pieces[1])));
}

const baseName = hasMultiplePackages && pkg ? `${name} (${pkg})` : name;
const shouldAddPackageSuffix =
hasMultiplePackages && pkg && (options.forcePackageSuffix || !containsPackageRef(name, pkg));
const baseName = shouldAddPackageSuffix ? `${name} (${pkg})` : name;
// Chunk into [value, unit] pairs and map to results
return chunkPairs(pieces).map(([valueStr, unit], i) => ({
name: i > 0 ? `${baseName} - ${unit}` : baseName,
Expand Down Expand Up @@ -447,7 +465,7 @@
const extra = `mean: ${mean} ${meanUnit}\nrounds: ${stats.rounds}`;
return { name, value, unit, range, extra };
});
} catch (err: any) {

Check warning on line 468 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
throw new Error(
`Output file for 'pytest' must be JSON file generated by --benchmark-json option: ${err.message}`,
);
Expand All @@ -458,7 +476,7 @@
let json: GoogleCppBenchmarkJson;
try {
json = JSON.parse(output);
} catch (err: any) {

Check warning on line 479 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
throw new Error(
`Output file for 'googlecpp' must be JSON file generated by --benchmark_format=json option: ${err.message}`,
);
Expand Down Expand Up @@ -586,7 +604,7 @@
return ret;
}

function extractJuliaBenchmarkHelper([_, bench]: JuliaBenchmarkGroup, labels: string[] = []): BenchmarkResult[] {

Check warning on line 607 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

'_' is defined but never used
const res: BenchmarkResult[] = [];
for (const key in bench.data) {
const value = bench.data[key];
Expand Down Expand Up @@ -617,7 +635,7 @@
let json: JuliaBenchmarkJson;
try {
json = JSON.parse(output);
} catch (err: any) {

Check warning on line 638 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
throw new Error(
`Output file for 'julia' must be JSON file generated by BenchmarkTools.save("output.json", suit::BenchmarkGroup) : ${err.message}`,
);
Expand All @@ -635,7 +653,7 @@
let json: JmhBenchmarkJson[];
try {
json = JSON.parse(output);
} catch (err: any) {

Check warning on line 656 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
throw new Error(`Output file for 'jmh' must be JSON file generated by -rf json option: ${err.message}`);
}
return json.map((b) => {
Expand All @@ -652,7 +670,7 @@
let json: BenchmarkDotNetBenchmarkJson;
try {
json = JSON.parse(output);
} catch (err: any) {

Check warning on line 673 in src/extract.ts

View workflow job for this annotation

GitHub Actions / Run linting and formatting check

Unexpected any. Specify a different type
throw new Error(
`Output file for 'benchmarkdotnet' must be JSON file generated by '--exporters json' option or by adding the JsonExporter to your run config: ${err.message}`,
);
Expand Down Expand Up @@ -708,7 +726,7 @@
benches = extractCargoResult(output);
break;
case 'go':
benches = extractGoResult(output);
benches = extractGoResult(output, { forcePackageSuffix: config.goForcePackageSuffix });
break;
case 'benchmarkjs':
benches = extractBenchmarkJsResult(output);
Expand Down
28 changes: 28 additions & 0 deletions test/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,32 @@ describe('configFromJobInput()', function () {
A.ok(path.isAbsolute(config.benchmarkDataDirPath), config.benchmarkDataDirPath);
A.equal(config.benchmarkDataDirPath, path.join(absCwd, 'outdir'));
});

describe('go-force-package-suffix', () => {
it('parses go-force-package-suffix as true', async () => {
mockInputs({ ...defaultInputs, 'go-force-package-suffix': 'true' });
const config = await configFromJobInput();
A.equal(config.goForcePackageSuffix, true);
});

it('parses go-force-package-suffix as false', async () => {
mockInputs({ ...defaultInputs, 'go-force-package-suffix': 'false' });
const config = await configFromJobInput();
A.equal(config.goForcePackageSuffix, false);
});

it('defaults go-force-package-suffix to false when not set', async () => {
mockInputs({ ...defaultInputs });
const config = await configFromJobInput();
A.equal(config.goForcePackageSuffix, false);
});

it('throws on invalid go-force-package-suffix value', async () => {
mockInputs({ ...defaultInputs, 'go-force-package-suffix': 'invalid' });
await A.rejects(
configFromJobInput,
/'go-force-package-suffix' input must be boolean value 'true' or 'false' but got 'invalid'/,
);
});
});
});
102 changes: 102 additions & 0 deletions test/extractGoResult.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,106 @@ describe('extractGoResult()', () => {
expect(results[3].name).toBe('BenchmarkFoo (github.com/example/pkg2)');
});
});

describe('avoiding duplicate package suffix', () => {
it('skips suffix when name contains full package path', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_github.com/example/pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
const results = extractGoResult(output);
expect(results[0].name).toBe('BenchmarkFoo_github.com/example/pkg1');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});

it('skips suffix when name contains normalized full package path', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_github.com_example_pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
const results = extractGoResult(output);
expect(results[0].name).toBe('BenchmarkFoo_github.com_example_pkg1');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});

it('skips suffix when name contains last package segments', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_example_pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
const results = extractGoResult(output);
expect(results[0].name).toBe('BenchmarkFoo_example_pkg1');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});

it('skips suffix for gofiber-style workaround', () => {
const output = dedent`
pkg: github.com/gofiber/fiber/v3/middleware/cache
BenchmarkAppendMsgitem_middleware_cache-8 5000000 100 ns/op
pkg: github.com/gofiber/fiber/v3/middleware/csrf
BenchmarkAppendMsgitem-8 3000000 200 ns/op
`;
const results = extractGoResult(output);
expect(results[0].name).toBe('BenchmarkAppendMsgitem_middleware_cache');
expect(results[1].name).toBe('BenchmarkAppendMsgitem (github.com/gofiber/fiber/v3/middleware/csrf)');
});

it('still adds suffix when only single segment matches (avoid false positives)', () => {
const output = dedent`
pkg: github.com/example/cache
BenchmarkCache-8 5000000 100 ns/op
pkg: github.com/example/store
BenchmarkStore-8 3000000 200 ns/op
`;
const results = extractGoResult(output);
// "cache" appears in name but it's not a deliberate suffix - still add package
expect(results[0].name).toBe('BenchmarkCache (github.com/example/cache)');
expect(results[1].name).toBe('BenchmarkStore (github.com/example/store)');
});
});

describe('forcePackageSuffix option', () => {
it('forces package suffix when forcePackageSuffix is true', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_example_pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
const results = extractGoResult(output, { forcePackageSuffix: true });
expect(results[0].name).toBe('BenchmarkFoo_example_pkg1 (github.com/example/pkg1)');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});

it('skips suffix by default when name contains package ref', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_example_pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
// No options passed - should use default behavior
const results = extractGoResult(output);
expect(results[0].name).toBe('BenchmarkFoo_example_pkg1');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});

it('skips suffix when forcePackageSuffix is explicitly false', () => {
const output = dedent`
pkg: github.com/example/pkg1
BenchmarkFoo_example_pkg1-8 5000000 100 ns/op
pkg: github.com/example/pkg2
BenchmarkBar-8 3000000 200 ns/op
`;
const results = extractGoResult(output, { forcePackageSuffix: false });
expect(results[0].name).toBe('BenchmarkFoo_example_pkg1');
expect(results[1].name).toBe('BenchmarkBar (github.com/example/pkg2)');
});
});
});
2 changes: 2 additions & 0 deletions test/write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ describe.each(['https://github.com', 'https://github.enterprise.corp'])('writeBe
maxItemsInChart: null,
failThreshold: 2.0,
ref: undefined,
goForcePackageSuffix: false,
};

const savedRepository = {
Expand Down Expand Up @@ -1006,6 +1007,7 @@ describe.each(['https://github.com', 'https://github.enterprise.corp'])('writeBe
maxItemsInChart: null,
failThreshold: 2.0,
ref: undefined,
goForcePackageSuffix: false,
};

function gitHistory(
Expand Down
Loading