From 44be09f8bb068650a91d35e4ab2a16b829cb628a Mon Sep 17 00:00:00 2001 From: Chris Trzesniewski Date: Tue, 24 Feb 2026 12:04:35 +0100 Subject: [PATCH 1/3] fix: avoid duplicate package suffix in Go benchmarks [#336] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who worked around #264 by including package name in benchmark names would get duplicate suffixes after #330. This detects when a benchmark name already contains a reference to the package path (full path, normalized with underscores, or ≥2 trailing segments) and skips adding the suffix. --- .claude/skills/github-issues/SKILL.md | 77 +++++++++++++++++++++++++++ src/extract.ts | 15 +++++- test/extractGoResult.spec.ts | 63 ++++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/github-issues/SKILL.md diff --git a/.claude/skills/github-issues/SKILL.md b/.claude/skills/github-issues/SKILL.md new file mode 100644 index 000000000..e38237e43 --- /dev/null +++ b/.claude/skills/github-issues/SKILL.md @@ -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 diff --git a/src/extract.ts b/src/extract.ts index dbac3e46c..5335e0dc4 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -343,6 +343,18 @@ function extractCargoResult(output: string): BenchmarkResult[] { return ret; } +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 function extractGoResult(output: string): BenchmarkResult[] { // Split into sections by "pkg:" lines, keeping package name with each section const sections = output.split(/^pkg:\s+/m).map((section, index) => { @@ -386,7 +398,8 @@ export function extractGoResult(output: string): BenchmarkResult[] { pieces.unshift(pieces[0], remainder.slice(remainder.indexOf(pieces[1]))); } - const baseName = hasMultiplePackages && pkg ? `${name} (${pkg})` : name; + const shouldAddPackageSuffix = hasMultiplePackages && pkg && !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, diff --git a/test/extractGoResult.spec.ts b/test/extractGoResult.spec.ts index 7f73ae9db..93c9d04b7 100644 --- a/test/extractGoResult.spec.ts +++ b/test/extractGoResult.spec.ts @@ -287,4 +287,67 @@ 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)'); + }); + }); }); From 0ad1f40b748888ecd7da7758c1f0062bf6fcd3db Mon Sep 17 00:00:00 2001 From: Chris Trzesniewski Date: Tue, 24 Feb 2026 12:24:13 +0100 Subject: [PATCH 2/3] feat: add go-force-package-suffix option for Go benchmarks Add new action input to force package suffix on Go benchmark names even when the name already contains a package reference. This allows users to opt for consistent naming when the automatic duplicate detection doesn't match their use case. --- README.md | 14 +++++++++++++ action.yml | 4 ++++ src/config.ts | 3 +++ src/extract.ts | 11 +++++++--- test/config.spec.ts | 28 ++++++++++++++++++++++++++ test/extractGoResult.spec.ts | 39 ++++++++++++++++++++++++++++++++++++ test/write.spec.ts | 2 ++ 7 files changed, 98 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7dacb6c4d..9e446ee02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/action.yml b/action.yml index daa6cd343..0651ab9e5 100644 --- a/action.yml +++ b/action.yml @@ -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' diff --git a/src/config.ts b/src/config.ts index 0ce3bd5c0..6a728ba51 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,7 @@ export interface Config { externalDataJsonPath: string | undefined; maxItemsInChart: number | null; ref: string | undefined; + goForcePackageSuffix: boolean; } export const VALID_TOOLS = [ @@ -240,6 +241,7 @@ export async function configFromJobInput(): Promise { 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); @@ -287,5 +289,6 @@ export async function configFromJobInput(): Promise { maxItemsInChart, failThreshold, ref, + goForcePackageSuffix, }; } diff --git a/src/extract.ts b/src/extract.ts index 5335e0dc4..18cfaecb9 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -355,7 +355,11 @@ function containsPackageRef(name: string, pkg: string): boolean { return false; } -export function extractGoResult(output: string): BenchmarkResult[] { +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) }; @@ -398,7 +402,8 @@ export function extractGoResult(output: string): BenchmarkResult[] { pieces.unshift(pieces[0], remainder.slice(remainder.indexOf(pieces[1]))); } - const shouldAddPackageSuffix = hasMultiplePackages && pkg && !containsPackageRef(name, pkg); + 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) => ({ @@ -721,7 +726,7 @@ export async function extractResult(config: Config): Promise { benches = extractCargoResult(output); break; case 'go': - benches = extractGoResult(output); + benches = extractGoResult(output, { forcePackageSuffix: config.goForcePackageSuffix }); break; case 'benchmarkjs': benches = extractBenchmarkJsResult(output); diff --git a/test/config.spec.ts b/test/config.spec.ts index 24094fd2e..31989b3da 100644 --- a/test/config.spec.ts +++ b/test/config.spec.ts @@ -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'/, + ); + }); + }); }); diff --git a/test/extractGoResult.spec.ts b/test/extractGoResult.spec.ts index 93c9d04b7..663d07cb6 100644 --- a/test/extractGoResult.spec.ts +++ b/test/extractGoResult.spec.ts @@ -350,4 +350,43 @@ describe('extractGoResult()', () => { 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)'); + }); + }); }); diff --git a/test/write.spec.ts b/test/write.spec.ts index 63611146a..1cf7ce729 100644 --- a/test/write.spec.ts +++ b/test/write.spec.ts @@ -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 = { @@ -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( From a54e7acbfa4fd66735b9fdc9c6f34d54c7170e60 Mon Sep 17 00:00:00 2001 From: Chris Trzesniewski Date: Tue, 24 Feb 2026 12:31:06 +0100 Subject: [PATCH 3/3] add new input to types manifest --- action-types.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/action-types.yml b/action-types.yml index e199a6627..7f7dee856 100644 --- a/action-types.yml +++ b/action-types.yml @@ -55,3 +55,5 @@ inputs: type: string max-items-in-chart: type: integer + go-force-package-suffix: + type: boolean