diff --git a/.github/workflows/ace-editor.lock.yml b/.github/workflows/ace-editor.lock.yml index 2064fd45062..371d60f9ef3 100644 --- a/.github/workflows/ace-editor.lock.yml +++ b/.github/workflows/ace-editor.lock.yml @@ -165,9 +165,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index 5aecf272a27..6ce887e0e69 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -163,9 +163,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index ad81c26e5a0..17f192a5bba 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -155,9 +155,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 060fb146e3d..5521b918250 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -160,9 +160,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index 8b275aab671..1412de095a8 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -160,9 +160,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/daily-team-evolution-insights.lock.yml b/.github/workflows/daily-team-evolution-insights.lock.yml index 34daf45a20a..4766f5759ee 100644 --- a/.github/workflows/daily-team-evolution-insights.lock.yml +++ b/.github/workflows/daily-team-evolution-insights.lock.yml @@ -161,9 +161,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 3364736d814..8212f7b262e 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -178,9 +178,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/example-permissions-warning.lock.yml b/.github/workflows/example-permissions-warning.lock.yml index 917a60564df..582b40ee80a 100644 --- a/.github/workflows/example-permissions-warning.lock.yml +++ b/.github/workflows/example-permissions-warning.lock.yml @@ -154,9 +154,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/gpclean.lock.yml b/.github/workflows/gpclean.lock.yml index 53269f18b23..dd210b0d32a 100644 --- a/.github/workflows/gpclean.lock.yml +++ b/.github/workflows/gpclean.lock.yml @@ -161,9 +161,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 082429d7dfd..ee2e51d5a3c 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -206,9 +206,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); await main(core, context); - name: Enforce strict mode policy - if: ${{ contains(toJSON(vars), '"GH_AW_POLICY_STRICT":') }} + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} run: | - echo "::error::GH_AW_POLICY_STRICT is set but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." exit 1 - name: Restore daily AIC usage cache id: restore-daily-aic-cache diff --git a/pkg/actionpins/README.md b/pkg/actionpins/README.md index 4a2cac53eac..5d7393d67bc 100644 --- a/pkg/actionpins/README.md +++ b/pkg/actionpins/README.md @@ -24,7 +24,7 @@ Resolution supports two modes: | `SHAResolver` | interface | Resolves a SHA for `repo@version` dynamically | | `ResolutionErrorType` | string | Classifies unresolved action-ref pinning outcomes for auditing | | `ResolutionFailure` | struct | Captures an unresolved action-ref pinning event (repo, ref, error type) | -| `PinContext` | struct | Runtime context for resolution (resolver, strict mode, warning dedupe map) | +| `PinContext` | struct | Runtime context for resolution (resolver, strict mode, warning dedupe map, action-pin mappings) | ### Functions @@ -75,6 +75,23 @@ actionpins.ResolveActionPin("unknown/action", "v1", ctx) // failures[0].ErrorType == actionpins.ResolutionErrorTypePinNotFound ``` +### Action Pin Mappings + +`PinContext.Mappings` redirects `owner/repo@ref` references to replacement references before pin resolution. This is used to substitute private or mirror repositories for well-known public actions (set from `aw.json` `action_pins`). + +Keys and values use the format `"owner/repo@ref"`. When a key matches the incoming `actionRepo@version`, resolution proceeds against the mapped value instead. An informational message is emitted once per mapping via `PinContext.Warnings`. + +```go +ctx := &actionpins.PinContext{ + Warnings: make(map[string]bool), + Mappings: map[string]string{ + "actions/checkout@v4": "acme-corp/checkout@v4", + }, +} +reference, err := actionpins.ResolveActionPin("actions/checkout", "v4", ctx) +// reference resolves against acme-corp/checkout@v4 pins +``` + ### Container Pins `ContainerPin` provides a pinned image reference for container images: diff --git a/pkg/intent/README.md b/pkg/intent/README.md new file mode 100644 index 00000000000..7310a92bd78 --- /dev/null +++ b/pkg/intent/README.md @@ -0,0 +1,105 @@ +# intent Package + +> Intent attribution and resolution utilities for mapping pull requests and issues to labelled intent records. + +## Overview + +The `intent` package resolves the intent behind a pull request or issue by inspecting labels, closing issues, and explicit metadata. It produces `IntentRecord` values that classify attribution status and source, allowing downstream consumers to route work items or report on coverage. + +Resolution is performed by a `Resolver`, which holds a label matcher function and a version string. The resolver applies a priority chain: + +1. Explicit intent metadata (`PullRequestData.ExplicitIntent`) — used as-is. +2. A single closing issue — resolved from the issue's labels. +3. PR labels — used as an artifact fallback when no closing issues are present. +4. No supported sources — returns an `AttributionUnlinked` record. + +## Public API + +### Types + +| Type | Kind | Description | +|------|------|-------------| +| `AttributionStatus` | string | Classifies the outcome of intent attribution | +| `AttributionSource` | string | Identifies the data source used for attribution | +| `IntentRecord` | struct | Holds the attribution result for a pull request or issue | +| `RootReference` | struct | Represents a referenced issue or artifact root (node ID, type, URL, labels) | +| `PullRequestData` | struct | Input data for pull request resolution (node ID, URL, labels, explicit intent, closing issues) | +| `Resolver` | struct | Stateless resolver that maps labels to intent records | + +### AttributionStatus constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `AttributionMapped` | `"mapped"` | Labels matched a known intent category | +| `AttributionUnmapped` | `"unmapped"` | Labels were present but matched no category | +| `AttributionUnlinked` | `"unlinked"` | No supported intent source was found | +| `AttributionAmbiguous` | `"ambiguous"` | Multiple competing sources were found | +| `AttributionSuggested` | `"suggested"` | Attribution was inferred by suggestion (not confirmed) | + +### AttributionSource constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `SourceExplicitMetadata` | `"explicit_metadata"` | Explicitly provided intent metadata | +| `SourceClosingIssue` | `"closing_issue"` | Derived from a closing issue | +| `SourceParentIssue` | `"parent_issue"` | Derived from a parent issue | +| `SourceReferencedIssue` | `"referenced_issue"` | Derived from a referenced issue | +| `SourceProject` | `"project"` | Derived from a project assignment | +| `SourceMilestone` | `"milestone"` | Derived from a milestone assignment | +| `SourceIssueLabels` | `"issue_labels"` | Derived from labels on an issue | +| `SourceArtifactLabels` | `"artifact_labels"` | Derived from labels on the artifact (pull request) | +| `SourceSuggestion` | `"suggestion"` | Derived from a suggestion | +| `SourceNone` | `"none"` | No source was used | + +### Resolver methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `ResolvePullRequest` | `func (r Resolver) ResolvePullRequest(pr PullRequestData) IntentRecord` | Resolves intent for a pull request using explicit intent, closing issues, or PR labels | +| `ResolveIssue` | `func (r Resolver) ResolveIssue(nodeID, url string, labels []string) IntentRecord` | Resolves intent for an issue using its labels | + +## Usage Examples + +```go +resolver := intent.Resolver{ + ResolverVersion: "v1", + MatchLabels: func(labels []string) []string { + for _, l := range labels { + if l == "security" { + return []string{l} + } + } + return nil + }, +} + +record := resolver.ResolvePullRequest(intent.PullRequestData{ + NodeID: "PR_kwDOAAABCD4", + URL: "https://github.com/owner/repo/pull/42", + Labels: []string{"security"}, +}) + +fmt.Println(record.Status) // "mapped" +fmt.Println(record.Source) // "artifact_labels" +``` + +### Resolving an issue + +```go +record := resolver.ResolveIssue( + "I_kwDOAAABCQ4", + "https://github.com/owner/repo/issues/1", + []string{"security"}, +) + +fmt.Println(record.Status) // "mapped" +fmt.Println(record.Source) // "issue_labels" +``` + +## Thread Safety + +`Resolver` holds no mutable state and is safe for concurrent use. `IntentRecord` values are returned by value and do not share mutable state with the caller. + +--- + +*This specification is automatically maintained by the [spec-extractor](../../.github/workflows/spec-extractor.md) workflow.* diff --git a/pkg/intent/spec_test.go b/pkg/intent/spec_test.go new file mode 100644 index 00000000000..1cbf2ef57fc --- /dev/null +++ b/pkg/intent/spec_test.go @@ -0,0 +1,205 @@ +//go:build !integration + +package intent_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/github/gh-aw/pkg/intent" +) + +// TestSpec tests derive from pkg/intent/README.md. They enforce the documented +// public surface of the intent package without coupling to implementation internals. + +// TestSpec_PublicAPI_AttributionStatusConstants validates the documented +// AttributionStatus constant values. +// Spec (README "AttributionStatus constants"): +// +// AttributionMapped="mapped", AttributionUnmapped="unmapped", AttributionUnlinked="unlinked", +// AttributionAmbiguous="ambiguous", AttributionSuggested="suggested" +func TestSpec_PublicAPI_AttributionStatusConstants(t *testing.T) { + assert.Equal(t, intent.AttributionMapped, intent.AttributionStatus("mapped"), + "AttributionMapped should equal \"mapped\" per the spec") + assert.Equal(t, intent.AttributionUnmapped, intent.AttributionStatus("unmapped"), + "AttributionUnmapped should equal \"unmapped\" per the spec") + assert.Equal(t, intent.AttributionUnlinked, intent.AttributionStatus("unlinked"), + "AttributionUnlinked should equal \"unlinked\" per the spec") + assert.Equal(t, intent.AttributionAmbiguous, intent.AttributionStatus("ambiguous"), + "AttributionAmbiguous should equal \"ambiguous\" per the spec") + assert.Equal(t, intent.AttributionSuggested, intent.AttributionStatus("suggested"), + "AttributionSuggested should equal \"suggested\" per the spec") +} + +// TestSpec_PublicAPI_AttributionSourceConstants validates the documented +// AttributionSource constant values. +// Spec (README "AttributionSource constants"): +// +// SourceExplicitMetadata="explicit_metadata", SourceClosingIssue="closing_issue", etc. +func TestSpec_PublicAPI_AttributionSourceConstants(t *testing.T) { + cases := []struct { + name string + got intent.AttributionSource + expected intent.AttributionSource + }{ + {"SourceExplicitMetadata", intent.SourceExplicitMetadata, "explicit_metadata"}, + {"SourceClosingIssue", intent.SourceClosingIssue, "closing_issue"}, + {"SourceParentIssue", intent.SourceParentIssue, "parent_issue"}, + {"SourceReferencedIssue", intent.SourceReferencedIssue, "referenced_issue"}, + {"SourceProject", intent.SourceProject, "project"}, + {"SourceMilestone", intent.SourceMilestone, "milestone"}, + {"SourceIssueLabels", intent.SourceIssueLabels, "issue_labels"}, + {"SourceArtifactLabels", intent.SourceArtifactLabels, "artifact_labels"}, + {"SourceSuggestion", intent.SourceSuggestion, "suggestion"}, + {"SourceNone", intent.SourceNone, "none"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.expected, c.got, "%s should equal %q per the spec", c.name, c.expected) + }) + } +} + +// TestSpec_PublicAPI_ResolvePullRequest_ExplicitIntent validates that an explicit +// intent record is returned as-is, with ResolverVersion stamped when absent. +// Spec: "Explicit intent metadata (PullRequestData.ExplicitIntent) — used as-is." +func TestSpec_PublicAPI_ResolvePullRequest_ExplicitIntent(t *testing.T) { + resolver := intent.Resolver{ResolverVersion: "v1"} + + explicit := &intent.IntentRecord{ + Status: intent.AttributionMapped, + Source: intent.SourceExplicitMetadata, + Rule: "explicit", + } + record := resolver.ResolvePullRequest(intent.PullRequestData{ExplicitIntent: explicit}) + + assert.Equal(t, intent.AttributionMapped, record.Status, + "ResolvePullRequest should honour explicit intent status") + assert.Equal(t, intent.SourceExplicitMetadata, record.Source, + "ResolvePullRequest should honour explicit intent source") + assert.Equal(t, "v1", record.ResolverVersion, + "ResolvePullRequest should stamp ResolverVersion when absent from explicit intent") +} + +// TestSpec_PublicAPI_ResolvePullRequest_SingleClosingIssue validates that a single +// closing issue produces a closing-issue attribution. +// Spec: "A single closing issue — resolved from the issue's labels." +func TestSpec_PublicAPI_ResolvePullRequest_SingleClosingIssue(t *testing.T) { + resolver := intent.Resolver{ + MatchLabels: func(labels []string) []string { return labels }, + } + + record := resolver.ResolvePullRequest(intent.PullRequestData{ + ClosingIssues: []intent.RootReference{{ + NodeID: "I_kwDOAAABCQ4", + Type: "issue", + URL: "https://github.com/owner/repo/issues/1", + Labels: []string{"security"}, + }}, + }) + + assert.Equal(t, intent.AttributionMapped, record.Status, + "single closing issue with matching labels should produce mapped status") + assert.Equal(t, intent.SourceClosingIssue, record.Source, + "single closing issue should produce closing_issue source") + assert.Equal(t, "single_closing_issue", record.Rule, + "single closing issue should produce single_closing_issue rule") +} + +// TestSpec_PublicAPI_ResolvePullRequest_MultipleClosingIssues validates that +// multiple closing issues produce an ambiguous attribution. +// Spec: "Multiple competing sources were found → ambiguous." +func TestSpec_PublicAPI_ResolvePullRequest_MultipleClosingIssues(t *testing.T) { + resolver := intent.Resolver{} + + record := resolver.ResolvePullRequest(intent.PullRequestData{ + ClosingIssues: []intent.RootReference{ + {URL: "https://github.com/owner/repo/issues/1"}, + {URL: "https://github.com/owner/repo/issues/2"}, + }, + }) + + assert.Equal(t, intent.AttributionAmbiguous, record.Status, + "multiple closing issues should produce ambiguous status") + assert.Equal(t, intent.SourceClosingIssue, record.Source, + "multiple closing issues should produce closing_issue source") + assert.Equal(t, "multiple_closing_issues", record.Rule, + "multiple closing issues should produce multiple_closing_issues rule") +} + +// TestSpec_PublicAPI_ResolvePullRequest_ArtifactFallback validates that PR labels +// are used when no closing issues are present. +// Spec: "PR labels — used as an artifact fallback when no closing issues are present." +func TestSpec_PublicAPI_ResolvePullRequest_ArtifactFallback(t *testing.T) { + resolver := intent.Resolver{ + MatchLabels: func(labels []string) []string { return labels }, + } + + record := resolver.ResolvePullRequest(intent.PullRequestData{ + NodeID: "PR_kwDOAAABCD4", + URL: "https://github.com/owner/repo/pull/77", + Labels: []string{"automation"}, + }) + + assert.Equal(t, intent.AttributionMapped, record.Status, + "PR labels should produce mapped status when labels match") + assert.Equal(t, intent.SourceArtifactLabels, record.Source, + "PR label fallback should produce artifact_labels source") + assert.Equal(t, "pull_request_label_fallback", record.Rule, + "PR label fallback should produce pull_request_label_fallback rule") +} + +// TestSpec_PublicAPI_ResolvePullRequest_NoSources validates that no supported +// sources produces an unlinked attribution. +// Spec: "No supported sources — returns an AttributionUnlinked record." +func TestSpec_PublicAPI_ResolvePullRequest_NoSources(t *testing.T) { + resolver := intent.Resolver{} + + record := resolver.ResolvePullRequest(intent.PullRequestData{}) + + assert.Equal(t, intent.AttributionUnlinked, record.Status, + "no sources should produce unlinked status") + assert.Equal(t, intent.SourceNone, record.Source, + "no sources should produce none source") + assert.Equal(t, "no_supported_intent_source", record.Rule, + "no sources should produce no_supported_intent_source rule") +} + +// TestSpec_PublicAPI_ResolveIssue_Mapped validates that an issue with matching +// labels produces a mapped intent record. +// Spec: "Resolver.ResolveIssue — resolves intent for an issue using its labels." +func TestSpec_PublicAPI_ResolveIssue_Mapped(t *testing.T) { + resolver := intent.Resolver{ + MatchLabels: func(labels []string) []string { return labels }, + } + + record := resolver.ResolveIssue( + "I_kwDOAAABCQ4", + "https://github.com/owner/repo/issues/42", + []string{"security"}, + ) + + assert.Equal(t, intent.AttributionMapped, record.Status, + "issue with matching labels should produce mapped status") + assert.Equal(t, intent.SourceIssueLabels, record.Source, + "ResolveIssue should produce issue_labels source") + assert.Equal(t, "issue_label_fallback", record.Rule, + "ResolveIssue should produce issue_label_fallback rule") + assert.Equal(t, "issue", record.RootType, + "ResolveIssue should set RootType to issue") +} + +// TestSpec_PublicAPI_ResolveIssue_NoLabelsUnlinked validates that an issue with +// no labels produces an unlinked record. +// Spec: "No supported sources — returns an AttributionUnlinked record." +func TestSpec_PublicAPI_ResolveIssue_NoLabelsUnlinked(t *testing.T) { + resolver := intent.Resolver{} + + record := resolver.ResolveIssue("I_kwDOAAABCQ4", "https://github.com/owner/repo/issues/1", nil) + + assert.Equal(t, intent.AttributionUnlinked, record.Status, + "issue with no labels should produce unlinked status") + assert.Equal(t, "no_supported_intent_source", record.Rule, + "issue with no labels should produce no_supported_intent_source rule") +} diff --git a/pkg/linters/README.md b/pkg/linters/README.md index f936e7fa6d3..3ffa4ca6608 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -21,6 +21,7 @@ This package currently provides custom Go analyzers in the following subpackages - `httpnoctx` — reports HTTP client and package-level HTTP calls that do not accept a `context.Context`. - `jsonmarshalignoredeerror` — reports `json.Marshal` and `json.Unmarshal` calls where the error return is discarded. - `largefunc` — reports function bodies that exceed a configurable line-count threshold. +- `lenstringsplit` — reports `len(strings.Split(s, sep))` expressions with a non-empty separator that should use `strings.Count(s, sep)+1` to avoid an intermediate slice allocation. - `lenstringzero` — reports `len(s) == 0` / `len(s) != 0` comparisons on string values that should use `s == ""` / `s != ""`. - `manualmutexunlock` — reports non-deferred mutex `Unlock()` calls that can lead to deadlocks on early returns or panics. - `osexitinlibrary` — reports `os.Exit` calls in library packages (`pkg/*`) where process termination should be delegated to `cmd/*` entry points. @@ -30,8 +31,11 @@ This package currently provides custom Go analyzers in the following subpackages - `regexpcompileinfunction` — reports `regexp.MustCompile` / `regexp.Compile` calls inside functions that should be package-level. - `seenmapbool` — reports `map[string]bool` used as a set (values always `true`) that should use `map[string]struct{}` instead. - `sortslice` — reports `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc`. +- `sprintferrdot` — reports redundant `.Error()` calls on error values passed to `fmt` format functions where the fmt package calls `.Error()` automatically. +- `sprintferrorsnew` — reports `errors.New(fmt.Sprintf(...))` calls that should use `fmt.Errorf` instead. - `ssljson` — validates `ssl.json` skill artifacts found in `.github/skills/` against the SSL spec (enum membership, graph integrity, transition targets, entry pointer validity). - `strconvparseignorederror` — reports `strconv` parsing calls (`Atoi`, `ParseInt`, etc.) where the error return is discarded with `_`. +- `stringreplaceminusone` — reports `strings.Replace` calls whose `n` argument is `-1`, which should use the more readable `strings.ReplaceAll`. - `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first. - `timesleepnocontext` — reports `time.Sleep` calls inside functions that already receive a `context.Context`, where a context-aware `select` should be used instead. - `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`. @@ -60,6 +64,7 @@ This package currently provides custom Go analyzers in the following subpackages | `httpnoctx` | Custom `go/analysis` analyzer that flags HTTP calls that do not accept a `context.Context` | | `jsonmarshalignoredeerror` | Custom `go/analysis` analyzer that flags `json.Marshal`/`json.Unmarshal` calls where the error return is discarded | | `largefunc` | Custom `go/analysis` analyzer that flags large functions with actionable diagnostics | +| `lenstringsplit` | Custom `go/analysis` analyzer that flags `len(strings.Split(s, sep))` with a non-empty separator that should use `strings.Count(s, sep)+1` | | `lenstringzero` | Custom `go/analysis` analyzer that flags `len(s) == 0` / `len(s) != 0` on string values that should use `s == ""` / `s != ""` | | `manualmutexunlock` | Custom `go/analysis` analyzer that flags mutex `Unlock()` calls that are not deferred | | `osexitinlibrary` | Custom `go/analysis` analyzer that flags `os.Exit` usage in library packages | @@ -69,8 +74,11 @@ This package currently provides custom Go analyzers in the following subpackages | `regexpcompileinfunction` | Custom `go/analysis` analyzer that flags regexp compilation inside function bodies | | `seenmapbool` | Custom `go/analysis` analyzer that flags `map[string]bool` used as a set that should use `map[string]struct{}` | | `sortslice` | Custom `go/analysis` analyzer that flags `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc` | +| `sprintferrdot` | Custom `go/analysis` analyzer that flags redundant `.Error()` calls on error values passed to `fmt` format functions | +| `sprintferrorsnew` | Custom `go/analysis` analyzer that flags `errors.New(fmt.Sprintf(...))` calls that should use `fmt.Errorf` instead | | `ssljson` | Custom `go/analysis` analyzer that validates SSL JSON skill artifacts in `.github/skills/` | | `strconvparseignorederror` | Custom `go/analysis` analyzer that flags `strconv` parsing calls where the error return is discarded with `_` | +| `stringreplaceminusone` | Custom `go/analysis` analyzer that flags `strings.Replace` calls with `n=-1` that should use `strings.ReplaceAll` | | `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first | | `timesleepnocontext` | Custom `go/analysis` analyzer that flags `time.Sleep` calls in context-aware functions | | `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` | @@ -140,6 +148,7 @@ _ = ssljson.Analyzer - `github.com/github/gh-aw/pkg/linters/hardcodedfilepath` — hard-coded-file-path analyzer subpackage - `github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror` — json-marshal-ignored-error analyzer subpackage - `github.com/github/gh-aw/pkg/linters/largefunc` — large-func analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/lenstringsplit` — len-strings-split analyzer subpackage - `github.com/github/gh-aw/pkg/linters/lenstringzero` — len-string-zero analyzer subpackage - `github.com/github/gh-aw/pkg/linters/manualmutexunlock` — manual-mutex-unlock analyzer subpackage - `github.com/github/gh-aw/pkg/linters/osexitinlibrary` — os-exit-in-library analyzer subpackage @@ -149,8 +158,11 @@ _ = ssljson.Analyzer - `github.com/github/gh-aw/pkg/linters/regexpcompileinfunction` — regexp-compile-in-function analyzer subpackage - `github.com/github/gh-aw/pkg/linters/seenmapbool` — seen-map-bool analyzer subpackage - `github.com/github/gh-aw/pkg/linters/sortslice` — sort-slice analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/sprintferrdot` — sprintf-err-dot analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/sprintferrorsnew` — sprintf-errors-new analyzer subpackage - `github.com/github/gh-aw/pkg/linters/ssljson` — ssl-json analyzer subpackage - `github.com/github/gh-aw/pkg/linters/strconvparseignorederror` — strconv-parse-ignored-error analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/stringreplaceminusone` — string-replace-minus-one analyzer subpackage - `github.com/github/gh-aw/pkg/linters/timeafterleak` — time-after-leak analyzer subpackage - `github.com/github/gh-aw/pkg/linters/tolowerequalfold` — to-lower-equal-fold analyzer subpackage - `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion` — unchecked-type-assertion analyzer subpackage diff --git a/pkg/linters/spec_test.go b/pkg/linters/spec_test.go index 74b3f4d0558..2fa62c9ea22 100644 --- a/pkg/linters/spec_test.go +++ b/pkg/linters/spec_test.go @@ -26,6 +26,7 @@ import ( "github.com/github/gh-aw/pkg/linters/httpnoctx" "github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror" "github.com/github/gh-aw/pkg/linters/largefunc" + "github.com/github/gh-aw/pkg/linters/lenstringsplit" "github.com/github/gh-aw/pkg/linters/lenstringzero" "github.com/github/gh-aw/pkg/linters/manualmutexunlock" "github.com/github/gh-aw/pkg/linters/osexitinlibrary" @@ -35,8 +36,11 @@ import ( "github.com/github/gh-aw/pkg/linters/regexpcompileinfunction" "github.com/github/gh-aw/pkg/linters/seenmapbool" "github.com/github/gh-aw/pkg/linters/sortslice" + "github.com/github/gh-aw/pkg/linters/sprintferrdot" + "github.com/github/gh-aw/pkg/linters/sprintferrorsnew" "github.com/github/gh-aw/pkg/linters/ssljson" "github.com/github/gh-aw/pkg/linters/strconvparseignorederror" + "github.com/github/gh-aw/pkg/linters/stringreplaceminusone" "github.com/github/gh-aw/pkg/linters/timeafterleak" "github.com/github/gh-aw/pkg/linters/timesleepnocontext" "github.com/github/gh-aw/pkg/linters/tolowerequalfold" @@ -57,7 +61,7 @@ type docAnalyzer struct { } // documentedAnalyzers returns the analyzer subpackages documented in the README -// "Public API > Subpackages" table. The README documents 31 analyzer +// "Public API > Subpackages" table. The README documents 35 analyzers // subpackages (the non-analyzer `internal` helper subpackage is excluded because // it exposes no Analyzer). // @@ -65,10 +69,11 @@ type docAnalyzer struct { // // contextcancelnotdeferred, ctxbackground, deferinloop, errorfwrapv, excessivefuncparams, errormessage, // errstringmatch, execcommandwithoutcontext, fileclosenotdeferred, fmterrorfnoverbs, fprintlnsprintf, -// hardcodedfilepath, httpnoctx, jsonmarshalignoredeerror, largefunc, lenstringzero, +// hardcodedfilepath, httpnoctx, jsonmarshalignoredeerror, largefunc, lenstringsplit, lenstringzero, // manualmutexunlock, osexitinlibrary, ossetenvlibrary, panic-in-library-code, rawloginlib, -// regexpcompileinfunction, seenmapbool, sortslice, ssljson, strconvparseignorederror, -// timeafterleak, timesleepnocontext, tolowerequalfold, uncheckedtypeassertion, wgdonenotdeferred +// regexpcompileinfunction, seenmapbool, sortslice, sprintferrdot, sprintferrorsnew, ssljson, +// strconvparseignorederror, stringreplaceminusone, timeafterleak, timesleepnocontext, +// tolowerequalfold, uncheckedtypeassertion, wgdonenotdeferred func documentedAnalyzers() []docAnalyzer { return []docAnalyzer{ {"contextcancelnotdeferred", contextcancelnotdeferred.Analyzer}, @@ -86,6 +91,7 @@ func documentedAnalyzers() []docAnalyzer { {"httpnoctx", httpnoctx.Analyzer}, {"jsonmarshalignoredeerror", jsonmarshalignoredeerror.Analyzer}, {"largefunc", largefunc.Analyzer}, + {"lenstringsplit", lenstringsplit.Analyzer}, {"lenstringzero", lenstringzero.Analyzer}, {"manualmutexunlock", manualmutexunlock.Analyzer}, {"osexitinlibrary", osexitinlibrary.Analyzer}, @@ -95,8 +101,11 @@ func documentedAnalyzers() []docAnalyzer { {"regexpcompileinfunction", regexpcompileinfunction.Analyzer}, {"seenmapbool", seenmapbool.Analyzer}, {"sortslice", sortslice.Analyzer}, + {"sprintferrdot", sprintferrdot.Analyzer}, + {"sprintferrorsnew", sprintferrorsnew.Analyzer}, {"ssljson", ssljson.Analyzer}, {"strconvparseignorederror", strconvparseignorederror.Analyzer}, + {"stringreplaceminusone", stringreplaceminusone.Analyzer}, {"timeafterleak", timeafterleak.Analyzer}, {"timesleepnocontext", timesleepnocontext.Analyzer}, {"tolowerequalfold", tolowerequalfold.Analyzer},