From 5bcae770ebbb77bf04381fab4f8657d49ef49690 Mon Sep 17 00:00:00 2001 From: djk01281 Date: Tue, 30 Jun 2026 17:52:28 +0900 Subject: [PATCH 1/3] :sparkles: feat: add passthrough for verbatim issue keys Recognize full tracker keys (Jira/Linear style, e.g. PROJ-123) in the branch and copy them into the commit message unchanged, via a new `passthrough` config ("uppercase" for any uppercase key shape, or an array of project keys). GitHub-style `rules` are unchanged and take precedence when a branch matches both. Also fix idempotency to detect the resolved reference anywhere in the message, so body-placed templates no longer double-tag on git commit --amend; and evaluate branch protection before the already-tagged check so a pre-tagged message cannot bypass protect. Ship schema.json in the package for editor autocompletion and validation. --- .changeset/commithelper-uppercase-keys.md | 10 + packages/commithelper-go/README.md | 47 +++- packages/commithelper-go/main.go | 293 ++++++++++++++-------- packages/commithelper-go/main_test.go | 230 +++++++++++++++-- packages/commithelper-go/package.json | 3 +- packages/commithelper-go/schema.json | 49 ++++ 6 files changed, 504 insertions(+), 128 deletions(-) create mode 100644 .changeset/commithelper-uppercase-keys.md create mode 100644 packages/commithelper-go/schema.json diff --git a/.changeset/commithelper-uppercase-keys.md b/.changeset/commithelper-uppercase-keys.md new file mode 100644 index 0000000..655174c --- /dev/null +++ b/.changeset/commithelper-uppercase-keys.md @@ -0,0 +1,10 @@ +--- +"@naverpay/commithelper-go": minor +--- + +Add `passthrough` for verbatim issue-key tagging (Jira/Linear-style), and fix two tagging issues. + +- **New `passthrough` option.** For trackers whose key already contains the project (e.g. `PROJ-1871`), the branch carries the full key, so it is copied verbatim — no repo lookup. Set `"passthrough": ["PROJ"]` to recognize specific project keys, or `"passthrough": "uppercase"` to recognize any uppercase `KEY-NUMBER` (which also tags non-tracker tokens like `UTF-8`). A branch `feature/PROJ-1871` is tagged `[PROJ-1871]`. Keys followed by `_` or `-` are not recognized (matching how the tracker links branches). GitHub-style `rules` take precedence when a branch matches both. +- **Fix double-tagging on amend.** Idempotency now checks whether the resolved reference is already present anywhere in the message, so custom templates that put the tag in the body (e.g. `Ref. [#123]`) no longer stack the reference on `git commit --amend` or hook re-runs. +- **Fix protected-branch bypass.** Branch protection is now evaluated before the "already tagged" short-circuit, so a pre-tagged commit message can no longer slip past `protect`. +- **Ship a JSON schema.** `schema.json` is now bundled in the package; reference it from `.commithelperrc.json` via `"$schema": "./node_modules/@naverpay/commithelper-go/schema.json"` for editor autocompletion and validation (works offline, no CDN). diff --git a/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index b26b384..7688a81 100644 --- a/packages/commithelper-go/README.md +++ b/packages/commithelper-go/README.md @@ -86,12 +86,56 @@ This is Basic rule of `.commithelperrc.json`. } ``` +#### $schema (optional — editor autocompletion) + +Add a `$schema` reference so your editor offers autocompletion, inline docs, and validation while editing `.commithelperrc.json`. The schema ships inside the installed package, so a relative path works offline (no CDN, Nexus-friendly): + +```json +{ + "$schema": "./node_modules/@naverpay/commithelper-go/schema.json", + "rules": { "feature": null } +} +``` + +> Prefer the relative path above (resolved from the installed package) over a public CDN URL — consistent with this package shipping everything locally. + #### rules - Key of rules field means branch prefix. By `feature` key, this rule is applied to branches named using the `feature/***` pattern. - Value represents the repository to be tagged. For example, rule with value 'your-org/your-repo' tags 'your-org/your-repo#1'. - A rule with a `null` value tags repository itself. +#### passthrough + +For trackers whose issue key already contains the project (e.g. Jira/Linear `PROJ-123`), the branch carries the full key — there is nothing to look up. `passthrough` declares which keys are copied **verbatim** into the commit message, as opposed to `rules`, which translate a prefix into a repo reference. + +- Array form — recognize only the listed project keys: + + ```json + { "passthrough": ["PROJ", "OPS"] } + ``` + + A branch like `feature/PROJ-1871` is tagged `[PROJ-1871]`. `OPS-9` is tagged only if `OPS` is listed. + +- `"uppercase"` — recognize **any** uppercase key shape, without listing projects: + + ```json + { "passthrough": "uppercase" } + ``` + + Convenient, but it tags any `UPPERCASE-NUMBER` token, including non-tracker ones (e.g. a branch `chore/UTF-8-fix` becomes `[UTF-8]`). + +- Omit the field to disable verbatim tagging (GitHub-style `rules` still apply). + +Recognition rules (a key must be linkable by the tracker): + +- The project part is uppercase letters/digits (`[A-Z][A-Z0-9]+`); the number is 1–7 digits. +- A key immediately followed by `_` or `-` is **not** recognized (e.g. `PROJ-1871_wip`, `PROJ-1871-20260101`) — use `-` for a description suffix (`PROJ-1871-add-login`). +- `PROJECT/123` (slash) is **not** a key — keys use `-` (`PROJECT-123`). The slash form is a `rules`-style prefix, so it tags only when the prefix is in `rules`. +- Array entries that are not valid key shapes (e.g. lowercase) are ignored with a warning. The all-mode is the string `"uppercase"`; a lowercase entry inside the array (e.g. `["uppercase"]`) is treated as an invalid key, not the all-mode. + +`rules` (GitHub prefixes) take precedence over `passthrough` when a branch matches both. + #### protect - Defines branches that are blocked from committing. Supports glob-style wildcard patterns. @@ -114,7 +158,8 @@ This is Basic rule of `.commithelperrc.json`. - `{{.Message}}`: Original commit message - `{{.Number}}`: Issue number extracted from branch name - `{{.Repo}}`: Repository name (empty string if not specified in rules) - - `{{.Prefix}}`: Full prefix (`#123` or `org/repo#123`) + - `{{.Prefix}}`: Full reference (`#123`, `org/repo#123`, or a verbatim key like `PROJ-1871`) +- For `passthrough` (verbatim) branches, `{{.Prefix}}` is the key itself while `{{.Number}}` and `{{.Repo}}` are empty — prefer `{{.Prefix}}` for templates that must work with both styles. ##### Template Examples diff --git a/packages/commithelper-go/main.go b/packages/commithelper-go/main.go index e4789b4..1265c47 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -13,10 +13,44 @@ import ( "text/template" ) +// Passthrough declares which verbatim issue keys (e.g. "PROJ" in +// "PROJ-1871") the helper copies straight into the commit message — +// unchanged — as opposed to `rules`, which translate a prefix into a repo +// reference. In JSON it is either the string "uppercase" (recognize any +// uppercase key shape) or an array of project keys (recognize only those). +// Absent/null = off. +type Passthrough struct { + All bool + Keys []string +} + +func (p *Passthrough) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err == nil { + if s != "uppercase" { + return fmt.Errorf("passthrough: string value must be %q, got %q", "uppercase", s) + } + p.All = true + return nil + } + + var arr []string + if err := json.Unmarshal(data, &arr); err != nil { + return fmt.Errorf("passthrough: must be %q or an array of key strings", "uppercase") + } + p.Keys = arr + return nil +} + type Config struct { - Rules map[string]*string `json:"rules"` - Protect []string `json:"protect"` - Template *string `json:"template,omitempty"` + Rules map[string]*string `json:"rules"` + Passthrough Passthrough `json:"passthrough"` + Protect []string `json:"protect"` + Template *string `json:"template,omitempty"` } type TemplateData struct { @@ -26,6 +60,16 @@ type TemplateData struct { Prefix string } +var ( + // GitHub-style branch: /. The prefix is translated to a + // repo via Config.Rules; the number becomes the issue reference. + prefixPattern = regexp.MustCompile(`^([\w-]+)/(\d+)`) + // Verbatim issue-key shape (uppercase project + number), used for the + // "uppercase" mode and for validating declared keys. + anyKeyPattern = regexp.MustCompile(`\b([A-Z][A-Z0-9]+)-(\d{1,7})\b`) + keyShapePattern = regexp.MustCompile(`^[A-Z][A-Z0-9]+$`) +) + func main() { if len(os.Args) < 2 { fmt.Println("Usage: commithelper-go ") @@ -33,10 +77,11 @@ func main() { } input := os.Args[1] + isFile := false var commitMessage string if _, err := os.Stat(input); err == nil { - // Input is a file path + isFile = true commitMessageBytes, err := ioutil.ReadFile(input) if err != nil { fmt.Printf("Error reading commit message file: %v\n", err) @@ -44,57 +89,161 @@ func main() { } commitMessage = string(commitMessageBytes) } else { - // Input is a direct commit message commitMessage = input } - // Check if commit message is already tagged - if isAlreadyTagged(commitMessage) { - // Do not modify if already tagged - if _, err := os.Stat(input); err == nil { - // Write back unchanged if input was a file - err = ioutil.WriteFile(input, []byte(commitMessage), 0644) - if err != nil { - fmt.Printf("Error writing to commit message file: %v\n", err) - os.Exit(1) - } - } else { - // Print unchanged if input was a direct message - fmt.Println(commitMessage) - } - return - } - branchName := getCurrentBranchName() config := loadConfig() - // Check if current branch is protected - protected, err := isProtectedBranch(branchName, config.Protect) + result, err := processMessage(commitMessage, branchName, config) if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } + + if isFile { + if err := ioutil.WriteFile(input, []byte(result), 0644); err != nil { + fmt.Printf("Error writing to commit message file: %v\n", err) + os.Exit(1) + } + } else { + fmt.Println(result) + } +} + +// processMessage is the pure core: it gates protected branches, resolves the +// issue reference from the branch, and tags the message idempotently. +func processMessage(message, branch string, config Config) (string, error) { + protected, err := isProtectedBranch(branch, config.Protect) + if err != nil { + return "", err + } if protected { - fmt.Printf("Error: Cannot commit to protected branch '%s'\n", branchName) - os.Exit(1) + return "", fmt.Errorf("cannot commit to protected branch %q", branch) } - templateData := generateTemplateData(branchName, config, commitMessage) - if templateData != nil { - commitMessage = applyTemplate(config, templateData) + td := resolve(branch, config) + if td == nil { + return message, nil + } + if alreadyHasRef(message, td.Prefix) { + return message, nil } - if _, err := os.Stat(input); err == nil { - // Write back to the file if input was a file - err = ioutil.WriteFile(input, []byte(commitMessage), 0644) - if err != nil { - fmt.Printf("Error writing to commit message file: %v\n", err) - os.Exit(1) + td.Message = message + return applyTemplate(config, td), nil +} + +// resolve maps a branch to an issue reference, trying the GitHub-style prefix +// rules first, then verbatim passthrough keys. +func resolve(branch string, config Config) *TemplateData { + if td := resolvePrefix(branch, config); td != nil { + return td + } + return resolveVerbatim(branch, config.Passthrough) +} + +// resolvePrefix translates a "/" branch into a reference using +// Config.Rules (nil value → "#N", repo value → "repo#N"). +func resolvePrefix(branch string, config Config) *TemplateData { + matches := prefixPattern.FindStringSubmatch(branch) + if len(matches) < 3 { + return nil + } + + prefixKey := matches[1] + issueNumber := matches[2] + + repo, exists := config.Rules[prefixKey] + if !exists { + return nil + } + + if repo == nil { + return &TemplateData{ + Number: issueNumber, + Prefix: fmt.Sprintf("#%s", issueNumber), } + } + return &TemplateData{ + Number: issueNumber, + Repo: *repo, + Prefix: fmt.Sprintf("%s#%s", *repo, issueNumber), + } +} + +// resolveVerbatim finds an uppercase issue key in the branch and copies it +// as-is. Only keys allowed by Passthrough are recognized. +func resolveVerbatim(branch string, pt Passthrough) *TemplateData { + var pattern *regexp.Regexp + if pt.All { + pattern = anyKeyPattern } else { - // Print the result if input was a direct message - fmt.Println(commitMessage) + valid := validKeys(pt.Keys) + if len(valid) == 0 { + return nil + } + pattern = buildKeyPattern(valid) + } + + // Strict: mirror Jigit's (?!-\d). A key immediately followed by "-" + // (e.g. a date suffix) is not recognized; skip it and try the next match so + // a valid key later in the branch is still found. + for _, loc := range pattern.FindAllStringSubmatchIndex(branch, -1) { + end := loc[1] + if end+1 < len(branch) && branch[end] == '-' && isDigit(branch[end+1]) { + continue + } + return &TemplateData{Prefix: branch[loc[0]:loc[1]]} + } + return nil +} + +// validKeys keeps only entries shaped like an uppercase project key. Invalid +// entries would never link, so they are warned about and skipped. +func validKeys(keys []string) []string { + valid := make([]string, 0, len(keys)) + for _, k := range keys { + if keyShapePattern.MatchString(k) { + valid = append(valid, k) + } else { + fmt.Fprintf(os.Stderr, "commithelper: ignoring invalid passthrough entry %q (must match [A-Z][A-Z0-9]+)\n", k) + } + } + return valid +} + +func buildKeyPattern(keys []string) *regexp.Regexp { + quoted := make([]string, len(keys)) + for i, k := range keys { + quoted[i] = regexp.QuoteMeta(k) + } + return regexp.MustCompile(`\b(` + strings.Join(quoted, "|") + `)-(\d{1,7})\b`) +} + +// alreadyHasRef reports whether the resolved reference is already present in +// the message, so re-runs (e.g. git commit --amend) do not tag twice. The +// reference must appear as a whole token, not as part of a longer number +// (so "#123" does not match "#1234"). +func alreadyHasRef(message, ref string) bool { + if ref == "" { + return false } + for from := 0; from <= len(message); { + j := strings.Index(message[from:], ref) + if j < 0 { + return false + } + start := from + j + end := start + len(ref) + beforeOK := start == 0 || !isWordByte(message[start-1]) + afterOK := end >= len(message) || !isDigit(message[end]) + if beforeOK && afterOK { + return true + } + from = start + 1 + } + return false } func getCurrentBranchName() string { @@ -141,61 +290,6 @@ func loadConfig() Config { return config } -func generatePrefix(branchName string, config Config) string { - pattern := regexp.MustCompile(`^([\w-]+)/(\d+).*`) - matches := pattern.FindStringSubmatch(branchName) - if len(matches) < 3 { - return "" - } - - prefixKey := matches[1] - issueNumber := matches[2] - - repo, exists := config.Rules[prefixKey] - if !exists { - return "" - } - - if repo == nil { - return fmt.Sprintf("#%s", issueNumber) - } - - return fmt.Sprintf("%s#%s", *repo, issueNumber) -} - -func generateTemplateData(branchName string, config Config, message string) *TemplateData { - pattern := regexp.MustCompile(`^([\w-]+)/(\d+).*`) - matches := pattern.FindStringSubmatch(branchName) - if len(matches) < 3 { - return nil - } - - prefixKey := matches[1] - issueNumber := matches[2] - - repo, exists := config.Rules[prefixKey] - if !exists { - return nil - } - - var repoName string - var prefix string - if repo == nil { - repoName = "" - prefix = fmt.Sprintf("#%s", issueNumber) - } else { - repoName = *repo - prefix = fmt.Sprintf("%s#%s", *repo, issueNumber) - } - - return &TemplateData{ - Message: message, - Number: issueNumber, - Repo: repoName, - Prefix: prefix, - } -} - func applyTemplate(config Config, data *TemplateData) string { // If no template is configured, use default format if config.Template == nil || *config.Template == "" { @@ -233,12 +327,11 @@ func isProtectedBranch(branchName string, protectedBranches []string) (bool, err return false, nil } -func isAlreadyTagged(commitMessage string) bool { - // Check if commit message already contains issue tag like [#123] or [org/repo#123] - // This pattern matches: - // - [#123] (simple issue number) - // - [Some-Org/Some_Repo#123] (complex repo with special chars) - pattern := regexp.MustCompile(`^\[.*?#\d+\]`) - trimmedMessage := strings.TrimSpace(commitMessage) - return pattern.MatchString(trimmedMessage) +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +func isWordByte(b byte) bool { + return b == '_' || + (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') } diff --git a/packages/commithelper-go/main_test.go b/packages/commithelper-go/main_test.go index 8cc4a6c..b3718b1 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "encoding/json" + "testing" +) + +func strPtr(s string) *string { return &s } func TestIsProtectedBranch(t *testing.T) { protected := []string{"main", "release/*", "epic/*"} @@ -19,6 +24,7 @@ func TestIsProtectedBranch(t *testing.T) { for _, tt := range tests { t.Run(tt.branch, func(t *testing.T) { + t.Parallel() got, err := isProtectedBranch(tt.branch, protected) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -37,12 +43,10 @@ func TestIsProtectedBranch_InvalidPattern(t *testing.T) { } } -func strPtr(s string) *string { return &s } - -func TestGenerateTemplateData_HyphenatedPrefix(t *testing.T) { +func TestResolvePrefix(t *testing.T) { config := Config{ Rules: map[string]*string{ - "fe-plan": strPtr("card-fe/plan"), + "my-team": strPtr("my-org/my-repo"), "feature": nil, }, } @@ -53,55 +57,229 @@ func TestGenerateTemplateData_HyphenatedPrefix(t *testing.T) { wantNil bool wantPrefix string }{ - {"hyphenated prefix", "fe-plan/11", false, "card-fe/plan#11"}, + {"hyphenated prefix", "my-team/11", false, "my-org/my-repo#11"}, {"simple prefix", "feature/42", false, "#42"}, {"no match", "main", true, ""}, {"unknown prefix", "unknown/99", true, ""}, + {"letter after slash is not prefix-shaped", "feature/PROJ-1", true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + td := resolvePrefix(tt.branch, config) + if tt.wantNil { + if td != nil { + t.Errorf("expected nil, got %+v", td) + } + return + } + if td == nil { + t.Fatal("expected non-nil TemplateData, got nil") + } + if td.Prefix != tt.wantPrefix { + t.Errorf("Prefix = %q, want %q", td.Prefix, tt.wantPrefix) + } + }) + } +} + +func TestResolveVerbatim(t *testing.T) { + tests := []struct { + name string + pt Passthrough + branch string + wantNil bool + wantPrefix string + }{ + {"listed key", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871", false, "PROJ-1871"}, + {"bare key no prefix", Passthrough{Keys: []string{"PROJ"}}, "PROJ-1871", false, "PROJ-1871"}, + {"key with description suffix", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871-add-login", false, "PROJ-1871"}, + {"unlisted project", Passthrough{Keys: []string{"PROJ"}}, "feature/OPS-42", true, ""}, + {"underscore suffix rejected (strict)", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871_wip", true, ""}, + {"trailing -digit rejected (strict)", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871-2024", true, ""}, + {"later valid key found after rejected one", Passthrough{All: true}, "feature/AB-12-34-CD-5", false, "CD-5"}, + {"all matches any shape", Passthrough{All: true}, "feature/OPS-42", false, "OPS-42"}, + {"all accepts non-tracker token (false positive)", Passthrough{All: true}, "chore/UTF-8-fix", false, "UTF-8"}, + {"invalid lowercase key filtered out", Passthrough{Keys: []string{"abc"}}, "feature/abc-1", true, ""}, + {"off when empty", Passthrough{}, "feature/PROJ-1871", true, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data := generateTemplateData(tt.branch, config, "Test") + t.Parallel() + td := resolveVerbatim(tt.branch, tt.pt) if tt.wantNil { - if data != nil { - t.Errorf("expected nil, got %+v", data) + if td != nil { + t.Errorf("expected nil, got %+v", td) } return } - if data == nil { + if td == nil { t.Fatal("expected non-nil TemplateData, got nil") } - if data.Prefix != tt.wantPrefix { - t.Errorf("Prefix = %q, want %q", data.Prefix, tt.wantPrefix) + if td.Prefix != tt.wantPrefix { + t.Errorf("Prefix = %q, want %q", td.Prefix, tt.wantPrefix) } }) } } -func TestGeneratePrefix_HyphenatedPrefix(t *testing.T) { +func TestResolve_Priority(t *testing.T) { config := Config{ - Rules: map[string]*string{ - "fe-plan": strPtr("card-fe/plan"), - "feature": nil, - }, + Rules: map[string]*string{"feature": nil}, + Passthrough: Passthrough{Keys: []string{"ABC"}}, } tests := []struct { - name string - branch string - want string + name string + branch string + wantNil bool + wantPrefix string }{ - {"hyphenated prefix", "fe-plan/11", "card-fe/plan#11"}, - {"simple prefix", "feature/42", "#42"}, - {"no match", "main", ""}, - {"unknown prefix", "unknown/99", ""}, + {"github prefix", "feature/123", false, "#123"}, + {"verbatim key", "feature/ABC-99", false, "ABC-99"}, + {"double match prefers prefix", "feature/12-ABC-34", false, "#12"}, + {"neither", "wip", true, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := generatePrefix(tt.branch, config) + t.Parallel() + td := resolve(tt.branch, config) + if tt.wantNil { + if td != nil { + t.Errorf("expected nil, got %+v", td) + } + return + } + if td == nil { + t.Fatal("expected non-nil TemplateData, got nil") + } + if td.Prefix != tt.wantPrefix { + t.Errorf("Prefix = %q, want %q", td.Prefix, tt.wantPrefix) + } + }) + } +} + +func TestAlreadyHasRef(t *testing.T) { + tests := []struct { + name string + message string + ref string + want bool + }{ + {"default front tag", "[#123] fix", "#123", true}, + {"longer number is not a match", "[#1234] fix", "#123", false}, + {"ref at bottom (template)", "fix\n\nRef. [#123]", "#123", true}, + {"verbatim key present", "[PROJ-1871] fix", "PROJ-1871", true}, + {"cross-repo ref present", "[org/repo#123] fix", "org/repo#123", true}, + {"absent", "fix login", "#123", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := alreadyHasRef(tt.message, tt.ref); got != tt.want { + t.Errorf("alreadyHasRef(%q, %q) = %v, want %v", tt.message, tt.ref, got, tt.want) + } + }) + } +} + +func TestProcessMessage(t *testing.T) { + github := Config{ + Rules: map[string]*string{"feature": nil}, + Protect: []string{"main"}, + } + verbatim := Config{ + Passthrough: Passthrough{Keys: []string{"PROJ"}}, + Protect: []string{"main"}, + } + bottomRef := Config{ + Rules: map[string]*string{"feature": nil}, + Protect: []string{"main"}, + Template: strPtr("{{.Message}}\n\nRef. [{{.Prefix}}]"), + } + + tests := []struct { + name string + message string + branch string + config Config + want string + wantError bool + }{ + {"protected branch blocks", "fix", "main", github, "", true}, + {"protected blocks even when already tagged", "[#1] fix", "main", github, "", true}, + {"github tagging", "fix", "feature/123", github, "[#123] fix", false}, + {"idempotent on re-run", "[#123] fix", "feature/123", github, "[#123] fix", false}, + {"unmatched branch passes through", "fix", "wip", github, "fix", false}, + {"verbatim tagging", "fix", "feature/PROJ-1871", verbatim, "[PROJ-1871] fix", false}, + {"custom template tags in body", "fix", "feature/123", bottomRef, "fix\n\nRef. [#123]", false}, + {"custom template idempotent on amend (D1)", "fix\n\nRef. [#123]", "feature/123", bottomRef, "fix\n\nRef. [#123]", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := processMessage(tt.message, tt.branch, tt.config) + if tt.wantError { + if err == nil { + t.Fatalf("expected error, got nil (result %q)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) + t.Errorf("processMessage(%q, %q) = %q, want %q", tt.message, tt.branch, got, tt.want) + } + }) + } +} + +func TestPassthrough_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + wantAll bool + wantKeys []string + wantErr bool + }{ + {"uppercase means all", `{"passthrough":"uppercase"}`, true, nil, false}, + {"array of keys", `{"passthrough":["PROJ","OPS"]}`, false, []string{"PROJ", "OPS"}, false}, + {"absent means off", `{}`, false, nil, false}, + {"null means off", `{"passthrough":null}`, false, nil, false}, + {"other string is error", `{"passthrough":"all"}`, false, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var c Config + err := json.Unmarshal([]byte(tt.json), &c) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.Passthrough.All != tt.wantAll { + t.Errorf("All = %v, want %v", c.Passthrough.All, tt.wantAll) + } + if len(c.Passthrough.Keys) != len(tt.wantKeys) { + t.Fatalf("Keys = %v, want %v", c.Passthrough.Keys, tt.wantKeys) + } + for i := range tt.wantKeys { + if c.Passthrough.Keys[i] != tt.wantKeys[i] { + t.Errorf("Keys[%d] = %q, want %q", i, c.Passthrough.Keys[i], tt.wantKeys[i]) + } } }) } diff --git a/packages/commithelper-go/package.json b/packages/commithelper-go/package.json index 27731bf..9ec820c 100644 --- a/packages/commithelper-go/package.json +++ b/packages/commithelper-go/package.json @@ -26,6 +26,7 @@ "author": "", "license": "MIT", "files": [ - "bin" + "bin", + "schema.json" ] } diff --git a/packages/commithelper-go/schema.json b/packages/commithelper-go/schema.json new file mode 100644 index 0000000..48f52c4 --- /dev/null +++ b/packages/commithelper-go/schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/NaverPayDev/cli/tree/main/packages/commithelper-go/schema.json", + "title": "Schema for .commithelperrc.json", + "description": "Configuration for @naverpay/commithelper-go.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Path or URL to this schema (enables editor autocompletion)." + }, + "rules": { + "type": "object", + "description": "GitHub-style branch prefixes. The key is a branch prefix, matched as `/`; the value is the repo to tag (`repo#number`), or null to tag the current repo (`#number`).", + "additionalProperties": { + "type": ["string", "null"] + } + }, + "passthrough": { + "description": "Verbatim issue keys (e.g. Jira/Linear `PROJ-123`) copied unchanged into the commit message, as opposed to `rules`, which translate a prefix into a repo reference.", + "oneOf": [ + { + "const": "uppercase", + "description": "Recognize any uppercase key shape ([A-Z][A-Z0-9]+ followed by 1-7 digits). Also tags non-tracker tokens such as UTF-8." + }, + { + "type": "array", + "description": "Project keys recognized verbatim (e.g. \"PROJ\").", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]+$" + } + } + ] + }, + "protect": { + "type": "array", + "description": "Branches blocked from committing. Glob patterns: `*` and `?` do not match across `/`; `[...]` matches a character set.", + "items": { + "type": "string" + } + }, + "template": { + "type": "string", + "description": "Go text/template for the final commit message. Default: `[{{.Prefix}}] {{.Message}}`. Variables: {{.Message}} {{.Number}} {{.Repo}} {{.Prefix}}." + } + } +} From bea4bccdab6884d2e26581fe67fc4f25cc6a8a64 Mon Sep 17 00:00:00 2001 From: djk01281 Date: Wed, 1 Jul 2026 14:24:20 +0900 Subject: [PATCH 2/3] :recycle: refactor: match Jigit for passthrough key recognition - `passthrough` is now an explicit project-key list (`["PROJ"]`); recognition follows Jigit's rule (-<1-7 digits>, anywhere in the branch), so a listed project links identically in commithelper and Jigit - fix idempotency whole-token boundary so a reference is not matched inside a longer key (e.g. PROJ-1871 within MY-PROJ-1871) - update schema, README, tests (incl. a Jigit-parity table), and fold the changeset into a single note Co-Authored-By: Claude Opus 4.8 --- .changeset/commithelper-passthrough.md | 10 ++ .changeset/commithelper-uppercase-keys.md | 10 -- packages/commithelper-go/README.md | 62 ++++++----- packages/commithelper-go/main.go | 103 +++--------------- packages/commithelper-go/main_test.go | 126 +++++++++++----------- packages/commithelper-go/schema.json | 21 ++-- 6 files changed, 132 insertions(+), 200 deletions(-) create mode 100644 .changeset/commithelper-passthrough.md delete mode 100644 .changeset/commithelper-uppercase-keys.md diff --git a/.changeset/commithelper-passthrough.md b/.changeset/commithelper-passthrough.md new file mode 100644 index 0000000..4d133f4 --- /dev/null +++ b/.changeset/commithelper-passthrough.md @@ -0,0 +1,10 @@ +--- +"@naverpay/commithelper-go": minor +--- + +Add `passthrough` for verbatim Jira/Linear-style issue keys, plus two tagging fixes and a bundled JSON schema. + +- **`passthrough`.** List the project keys to recognize (e.g. `"passthrough": ["PROJ", "OPS"]`); a branch like `feature/PROJ-1871` is tagged `[PROJ-1871]`. Key recognition matches Jigit (`-<1–7 digit number>`, found anywhere in the branch), so a listed project links identically in commithelper and Jigit. Only listed projects are tagged, so unrelated `UPPERCASE-NUMBER` tokens (e.g. `UTF-8`) are not mistaken for issues. `rules` (GitHub prefixes) take precedence when a branch matches both. +- **Idempotent re-tagging.** A message is left unchanged when the branch's reference is already present as a whole token, so `git commit --amend` and hook re-runs never stack tags — including template tags placed in the message body. +- **Protected branches** are evaluated before the already-tagged check, so a pre-tagged message cannot bypass `protect`. +- **`schema.json`** is bundled in the package; reference it via `"$schema": "./node_modules/@naverpay/commithelper-go/schema.json"` for editor autocompletion and validation (works offline, no CDN). diff --git a/.changeset/commithelper-uppercase-keys.md b/.changeset/commithelper-uppercase-keys.md deleted file mode 100644 index 655174c..0000000 --- a/.changeset/commithelper-uppercase-keys.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@naverpay/commithelper-go": minor ---- - -Add `passthrough` for verbatim issue-key tagging (Jira/Linear-style), and fix two tagging issues. - -- **New `passthrough` option.** For trackers whose key already contains the project (e.g. `PROJ-1871`), the branch carries the full key, so it is copied verbatim — no repo lookup. Set `"passthrough": ["PROJ"]` to recognize specific project keys, or `"passthrough": "uppercase"` to recognize any uppercase `KEY-NUMBER` (which also tags non-tracker tokens like `UTF-8`). A branch `feature/PROJ-1871` is tagged `[PROJ-1871]`. Keys followed by `_` or `-` are not recognized (matching how the tracker links branches). GitHub-style `rules` take precedence when a branch matches both. -- **Fix double-tagging on amend.** Idempotency now checks whether the resolved reference is already present anywhere in the message, so custom templates that put the tag in the body (e.g. `Ref. [#123]`) no longer stack the reference on `git commit --amend` or hook re-runs. -- **Fix protected-branch bypass.** Branch protection is now evaluated before the "already tagged" short-circuit, so a pre-tagged commit message can no longer slip past `protect`. -- **Ship a JSON schema.** `schema.json` is now bundled in the package; reference it from `.commithelperrc.json` via `"$schema": "./node_modules/@naverpay/commithelper-go/schema.json"` for editor autocompletion and validation (works offline, no CDN). diff --git a/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index 7688a81..df07f8a 100644 --- a/packages/commithelper-go/README.md +++ b/packages/commithelper-go/README.md @@ -65,11 +65,28 @@ feature/1 Your issue number is automatically tagged based on your setting (`.commithelperrc.json`) +Tracker keys that already carry the project (Jira/Linear-style `PROJ-1871`) are supported too — see [passthrough](#passthrough). + ### Blocking commit - Blocks direct commit toward `main`, `develop` `master` branch by throwing error on commit attempt. - To block specific branches, add at `protect` field on `commithelperrc`. +### Re-tagging (idempotency) + +commithelper adds your current branch's reference **unless that exact reference is already in the message**, so re-running the hook or `git commit --amend` never duplicates the tag. + +| Message already contains… | Result | +| --------------------------------------------------------- | ----------------------------------------------------- | +| the branch's reference (e.g. `[#123]`, or `#123` in body) | left unchanged | +| only a _different_ issue's tag (e.g. `[#999]`) | branch's reference is still added → `[#123] [#999] …` | +| no reference | branch's reference is added | + +Two consequences follow: + +- commithelper only recognizes **its own resolved reference**, not other issue tags — a hand-written tag for a different issue does not stop it from adding the branch's reference. +- If the message body already mentions the branch's reference (e.g. `fixes #123` on `feature/123`), it is treated as already tagged and left as-is. + ## Configuration ### commithelperrc @@ -107,32 +124,28 @@ Add a `$schema` reference so your editor offers autocompletion, inline docs, and #### passthrough -For trackers whose issue key already contains the project (e.g. Jira/Linear `PROJ-123`), the branch carries the full key — there is nothing to look up. `passthrough` declares which keys are copied **verbatim** into the commit message, as opposed to `rules`, which translate a prefix into a repo reference. +For trackers whose issue key already contains the project (e.g. Jira/Linear `PROJ-1871`), the branch carries the full key — there is nothing to look up. `passthrough` lists the project keys to copy **verbatim** into the commit message, as opposed to `rules`, which translate a prefix into a repo reference. -- Array form — recognize only the listed project keys: - - ```json - { "passthrough": ["PROJ", "OPS"] } - ``` - - A branch like `feature/PROJ-1871` is tagged `[PROJ-1871]`. `OPS-9` is tagged only if `OPS` is listed. - -- `"uppercase"` — recognize **any** uppercase key shape, without listing projects: - - ```json - { "passthrough": "uppercase" } - ``` +```json +{ "passthrough": ["PROJ", "OPS"] } +``` - Convenient, but it tags any `UPPERCASE-NUMBER` token, including non-tracker ones (e.g. a branch `chore/UTF-8-fix` becomes `[UTF-8]`). +A branch like `feature/PROJ-1871` is tagged `[PROJ-1871]`. Only listed projects are recognized (`OPS-9` is tagged only if `OPS` is listed), so unrelated `UPPERCASE-NUMBER` tokens such as `UTF-8` are never mistaken for issues. Omit the field to disable verbatim tagging (`rules` still apply). -- Omit the field to disable verbatim tagging (GitHub-style `rules` still apply). +**How a key is recognized.** Recognition matches [Jigit](https://marketplace.atlassian.com/apps/1217129) (the Jira↔Git integration), so a listed project links the same way in commithelper and Jigit. A key is `-` found anywhere in the branch: -Recognition rules (a key must be linkable by the tracker): +- `PROJECT` — an uppercase letter followed by uppercase letters/digits (**≥2 chars**: `PROJ`, `OPS`, `AB`, `ABC2`). +- `NUMBER` — 1–7 digits (an 8+ digit run is not a key). -- The project part is uppercase letters/digits (`[A-Z][A-Z0-9]+`); the number is 1–7 digits. -- A key immediately followed by `_` or `-` is **not** recognized (e.g. `PROJ-1871_wip`, `PROJ-1871-20260101`) — use `-` for a description suffix (`PROJ-1871-add-login`). -- `PROJECT/123` (slash) is **not** a key — keys use `-` (`PROJECT-123`). The slash form is a `rules`-style prefix, so it tags only when the prefix is in `rules`. -- Array entries that are not valid key shapes (e.g. lowercase) are ignored with a warning. The all-mode is the string `"uppercase"`; a lowercase entry inside the array (e.g. `["uppercase"]`) is treated as an invalid key, not the all-mode. +| Branch (with `["PROJ"]`) | Result | Why | +| ----------------------------- | ------------- | ------------------------------------------ | +| `feature/PROJ-1871` | `[PROJ-1871]` | standard key | +| `feature/PROJ-1871-add-login` | `[PROJ-1871]` | trailing text after the number is ignored | +| `feature/PROJ-1871-20260101` | `[PROJ-1871]` | a `-` (date) suffix is ignored too | +| `feature_PROJ-1871` | `[PROJ-1871]` | the key may appear anywhere in the branch | +| `feature/OPS-42` | not tagged | `OPS` is not listed | +| `feature/PROJ-12345678` | not tagged | the number has more than 7 digits | +| `PROJECT/123` | not tagged | keys use `-`, not `/` (that's a `rules` prefix) | `rules` (GitHub prefixes) take precedence over `passthrough` when a branch matches both. @@ -160,6 +173,7 @@ Recognition rules (a key must be linkable by the tracker): - `{{.Repo}}`: Repository name (empty string if not specified in rules) - `{{.Prefix}}`: Full reference (`#123`, `org/repo#123`, or a verbatim key like `PROJ-1871`) - For `passthrough` (verbatim) branches, `{{.Prefix}}` is the key itself while `{{.Number}}` and `{{.Repo}}` are empty — prefer `{{.Prefix}}` for templates that must work with both styles. +- Put the reference in your template as `{{.Prefix}}` for safe re-tagging: commithelper skips an already-tagged message by looking for that exact reference, so rendering it another way (e.g. `#{{.Number}}` for a repo-scoped or verbatim rule) can add it twice on `git commit --amend`. ##### Template Examples @@ -170,7 +184,7 @@ Recognition rules (a key must be linkable by the tracker): "rules": { "feature": null }, - "template": "{{.Message}}\n\nRef. [#{{.Number}}]" + "template": "{{.Message}}\n\nRef. [{{.Prefix}}]" } ``` @@ -247,8 +261,8 @@ The appropriate platform-specific binary package is automatically installed via ## Q&A -- What happens if commit has already tagged issue like `[your-org/your-repo#1]`? - - `commithelper-go` do not works. Already tagged issue remains unchanged +- What happens if the commit is already tagged? + - If it already contains the reference for your current branch, it is left unchanged (safe on re-run / `git commit --amend`). A tag for a _different_ issue does **not** prevent your branch's reference from being added — see [Re-tagging](#re-tagging-idempotency). - How does commithelper-go behaves on `feature/1_xxx` `feature/1-xxx` patterned branch name? - It works same as `feature/1` branch. - What's the difference between `@naverpay/commit-helper` and `@naverpay/commithelper-go`? diff --git a/packages/commithelper-go/main.go b/packages/commithelper-go/main.go index 1265c47..14768e6 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -13,42 +13,9 @@ import ( "text/template" ) -// Passthrough declares which verbatim issue keys (e.g. "PROJ" in -// "PROJ-1871") the helper copies straight into the commit message — -// unchanged — as opposed to `rules`, which translate a prefix into a repo -// reference. In JSON it is either the string "uppercase" (recognize any -// uppercase key shape) or an array of project keys (recognize only those). -// Absent/null = off. -type Passthrough struct { - All bool - Keys []string -} - -func (p *Passthrough) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - return nil - } - - var s string - if err := json.Unmarshal(data, &s); err == nil { - if s != "uppercase" { - return fmt.Errorf("passthrough: string value must be %q, got %q", "uppercase", s) - } - p.All = true - return nil - } - - var arr []string - if err := json.Unmarshal(data, &arr); err != nil { - return fmt.Errorf("passthrough: must be %q or an array of key strings", "uppercase") - } - p.Keys = arr - return nil -} - type Config struct { Rules map[string]*string `json:"rules"` - Passthrough Passthrough `json:"passthrough"` + Passthrough []string `json:"passthrough"` Protect []string `json:"protect"` Template *string `json:"template,omitempty"` } @@ -64,10 +31,7 @@ var ( // GitHub-style branch: /. The prefix is translated to a // repo via Config.Rules; the number becomes the issue reference. prefixPattern = regexp.MustCompile(`^([\w-]+)/(\d+)`) - // Verbatim issue-key shape (uppercase project + number), used for the - // "uppercase" mode and for validating declared keys. - anyKeyPattern = regexp.MustCompile(`\b([A-Z][A-Z0-9]+)-(\d{1,7})\b`) - keyShapePattern = regexp.MustCompile(`^[A-Z][A-Z0-9]+$`) + keyPattern = regexp.MustCompile(`([A-Z][A-Z0-9]+)-([0-9]+)`) ) func main() { @@ -140,7 +104,7 @@ func resolve(branch string, config Config) *TemplateData { if td := resolvePrefix(branch, config); td != nil { return td } - return resolveVerbatim(branch, config.Passthrough) + return resolveKey(branch, config.Passthrough) } // resolvePrefix translates a "/" branch into a reference using @@ -172,53 +136,20 @@ func resolvePrefix(branch string, config Config) *TemplateData { } } -// resolveVerbatim finds an uppercase issue key in the branch and copies it -// as-is. Only keys allowed by Passthrough are recognized. -func resolveVerbatim(branch string, pt Passthrough) *TemplateData { - var pattern *regexp.Regexp - if pt.All { - pattern = anyKeyPattern - } else { - valid := validKeys(pt.Keys) - if len(valid) == 0 { - return nil - } - pattern = buildKeyPattern(valid) +func resolveKey(branch string, passthrough []string) *TemplateData { + if len(passthrough) == 0 { + return nil } - - // Strict: mirror Jigit's (?!-\d). A key immediately followed by "-" - // (e.g. a date suffix) is not recognized; skip it and try the next match so - // a valid key later in the branch is still found. - for _, loc := range pattern.FindAllStringSubmatchIndex(branch, -1) { - end := loc[1] - if end+1 < len(branch) && branch[end] == '-' && isDigit(branch[end+1]) { - continue - } - return &TemplateData{Prefix: branch[loc[0]:loc[1]]} + allowed := make(map[string]bool, len(passthrough)) + for _, k := range passthrough { + allowed[k] = true } - return nil -} - -// validKeys keeps only entries shaped like an uppercase project key. Invalid -// entries would never link, so they are warned about and skipped. -func validKeys(keys []string) []string { - valid := make([]string, 0, len(keys)) - for _, k := range keys { - if keyShapePattern.MatchString(k) { - valid = append(valid, k) - } else { - fmt.Fprintf(os.Stderr, "commithelper: ignoring invalid passthrough entry %q (must match [A-Z][A-Z0-9]+)\n", k) + for _, m := range keyPattern.FindAllStringSubmatch(branch, -1) { + if len(m[2]) <= 7 && allowed[m[1]] { + return &TemplateData{Prefix: m[0]} } } - return valid -} - -func buildKeyPattern(keys []string) *regexp.Regexp { - quoted := make([]string, len(keys)) - for i, k := range keys { - quoted[i] = regexp.QuoteMeta(k) - } - return regexp.MustCompile(`\b(` + strings.Join(quoted, "|") + `)-(\d{1,7})\b`) + return nil } // alreadyHasRef reports whether the resolved reference is already present in @@ -236,8 +167,8 @@ func alreadyHasRef(message, ref string) bool { } start := from + j end := start + len(ref) - beforeOK := start == 0 || !isWordByte(message[start-1]) - afterOK := end >= len(message) || !isDigit(message[end]) + beforeOK := start == 0 || !isKeyByte(message[start-1]) + afterOK := end >= len(message) || !isKeyByte(message[end]) if beforeOK && afterOK { return true } @@ -327,11 +258,11 @@ func isProtectedBranch(branchName string, protectedBranches []string) (bool, err return false, nil } -func isDigit(b byte) bool { return b >= '0' && b <= '9' } - func isWordByte(b byte) bool { return b == '_' || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') } + +func isKeyByte(b byte) bool { return b == '-' || isWordByte(b) } diff --git a/packages/commithelper-go/main_test.go b/packages/commithelper-go/main_test.go index b3718b1..ce35db1 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "testing" ) @@ -84,31 +83,31 @@ func TestResolvePrefix(t *testing.T) { } } -func TestResolveVerbatim(t *testing.T) { +func TestResolveKey(t *testing.T) { tests := []struct { - name string - pt Passthrough - branch string - wantNil bool - wantPrefix string + name string + passthrough []string + branch string + wantNil bool + wantPrefix string }{ - {"listed key", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871", false, "PROJ-1871"}, - {"bare key no prefix", Passthrough{Keys: []string{"PROJ"}}, "PROJ-1871", false, "PROJ-1871"}, - {"key with description suffix", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871-add-login", false, "PROJ-1871"}, - {"unlisted project", Passthrough{Keys: []string{"PROJ"}}, "feature/OPS-42", true, ""}, - {"underscore suffix rejected (strict)", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871_wip", true, ""}, - {"trailing -digit rejected (strict)", Passthrough{Keys: []string{"PROJ"}}, "feature/PROJ-1871-2024", true, ""}, - {"later valid key found after rejected one", Passthrough{All: true}, "feature/AB-12-34-CD-5", false, "CD-5"}, - {"all matches any shape", Passthrough{All: true}, "feature/OPS-42", false, "OPS-42"}, - {"all accepts non-tracker token (false positive)", Passthrough{All: true}, "chore/UTF-8-fix", false, "UTF-8"}, - {"invalid lowercase key filtered out", Passthrough{Keys: []string{"abc"}}, "feature/abc-1", true, ""}, - {"off when empty", Passthrough{}, "feature/PROJ-1871", true, ""}, + {"listed key", []string{"PROJ"}, "feature/PROJ-1871", false, "PROJ-1871"}, + {"bare key no prefix", []string{"PROJ"}, "PROJ-1871", false, "PROJ-1871"}, + {"description suffix", []string{"PROJ"}, "feature/PROJ-1871-add-login", false, "PROJ-1871"}, + {"date suffix recognized (Jigit rule)", []string{"PROJ"}, "feature/PROJ-1871-20260101", false, "PROJ-1871"}, + {"underscore recognized (Jigit rule)", []string{"PROJ"}, "feature/PROJ-1871_wip", false, "PROJ-1871"}, + {"unlisted project", []string{"PROJ"}, "feature/OPS-42", true, ""}, + {"eight-digit number rejected", []string{"PROJ"}, "feature/PROJ-12345678", true, ""}, + {"project not matched inside a longer key", []string{"PROJ"}, "XPROJ-123", true, ""}, + {"junk skipped, listed key chosen", []string{"PROJ"}, "chore/UTF-8-PROJ-123", false, "PROJ-123"}, + {"lowercase list entry does not match", []string{"proj"}, "feature/PROJ-1", true, ""}, + {"off when empty", nil, "feature/PROJ-1871", true, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - td := resolveVerbatim(tt.branch, tt.pt) + td := resolveKey(tt.branch, tt.passthrough) if tt.wantNil { if td != nil { t.Errorf("expected nil, got %+v", td) @@ -125,10 +124,47 @@ func TestResolveVerbatim(t *testing.T) { } } +func TestResolveKey_JigitParity(t *testing.T) { + allowed := []string{"ABC"} + tests := []struct { + branch string + want string + }{ + {"ABC-123", "ABC-123"}, + {"feature/ABC-123", "ABC-123"}, + {"feature_ABC-123", "ABC-123"}, + {"feature/ABC-123-modal", "ABC-123"}, + {"ABC-123_modal", "ABC-123"}, + {"[ABC-123] feature", "ABC-123"}, + {"release/test/ABC-123/fix", "ABC-123"}, + {"ABC-123-4", "ABC-123"}, + {"ABC-12345678", ""}, + {"abc-123", ""}, + {"Abc-123", ""}, + {"ABC_123", ""}, + {"ABC123", ""}, + {"ABC-abc", ""}, + } + + for _, tt := range tests { + t.Run(tt.branch, func(t *testing.T) { + t.Parallel() + td := resolveKey(tt.branch, allowed) + got := "" + if td != nil { + got = td.Prefix + } + if got != tt.want { + t.Errorf("resolveKey(%q) = %q, want %q", tt.branch, got, tt.want) + } + }) + } +} + func TestResolve_Priority(t *testing.T) { config := Config{ Rules: map[string]*string{"feature": nil}, - Passthrough: Passthrough{Keys: []string{"ABC"}}, + Passthrough: []string{"ABC"}, } tests := []struct { @@ -176,6 +212,8 @@ func TestAlreadyHasRef(t *testing.T) { {"verbatim key present", "[PROJ-1871] fix", "PROJ-1871", true}, {"cross-repo ref present", "[org/repo#123] fix", "org/repo#123", true}, {"absent", "fix login", "#123", false}, + {"ref before a letter is not a match", "see AB-1abc here", "AB-1", false}, + {"ref inside a longer hyphenated key is not a match", "[MY-PROJ-1871] earlier", "PROJ-1871", false}, } for _, tt := range tests { @@ -194,7 +232,7 @@ func TestProcessMessage(t *testing.T) { Protect: []string{"main"}, } verbatim := Config{ - Passthrough: Passthrough{Keys: []string{"PROJ"}}, + Passthrough: []string{"PROJ"}, Protect: []string{"main"}, } bottomRef := Config{ @@ -219,6 +257,8 @@ func TestProcessMessage(t *testing.T) { {"verbatim tagging", "fix", "feature/PROJ-1871", verbatim, "[PROJ-1871] fix", false}, {"custom template tags in body", "fix", "feature/123", bottomRef, "fix\n\nRef. [#123]", false}, {"custom template idempotent on amend (D1)", "fix\n\nRef. [#123]", "feature/123", bottomRef, "fix\n\nRef. [#123]", false}, + {"embedded key not treated as tagged", "[MY-PROJ-1871] earlier", "feature/PROJ-1871", verbatim, "[PROJ-1871] [MY-PROJ-1871] earlier", false}, + {"key before a letter not treated as tagged", "work on PROJ-1abc", "feature/PROJ-1", verbatim, "[PROJ-1] work on PROJ-1abc", false}, } for _, tt := range tests { @@ -240,47 +280,3 @@ func TestProcessMessage(t *testing.T) { }) } } - -func TestPassthrough_UnmarshalJSON(t *testing.T) { - tests := []struct { - name string - json string - wantAll bool - wantKeys []string - wantErr bool - }{ - {"uppercase means all", `{"passthrough":"uppercase"}`, true, nil, false}, - {"array of keys", `{"passthrough":["PROJ","OPS"]}`, false, []string{"PROJ", "OPS"}, false}, - {"absent means off", `{}`, false, nil, false}, - {"null means off", `{"passthrough":null}`, false, nil, false}, - {"other string is error", `{"passthrough":"all"}`, false, nil, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - var c Config - err := json.Unmarshal([]byte(tt.json), &c) - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if c.Passthrough.All != tt.wantAll { - t.Errorf("All = %v, want %v", c.Passthrough.All, tt.wantAll) - } - if len(c.Passthrough.Keys) != len(tt.wantKeys) { - t.Fatalf("Keys = %v, want %v", c.Passthrough.Keys, tt.wantKeys) - } - for i := range tt.wantKeys { - if c.Passthrough.Keys[i] != tt.wantKeys[i] { - t.Errorf("Keys[%d] = %q, want %q", i, c.Passthrough.Keys[i], tt.wantKeys[i]) - } - } - }) - } -} diff --git a/packages/commithelper-go/schema.json b/packages/commithelper-go/schema.json index 48f52c4..615a4f3 100644 --- a/packages/commithelper-go/schema.json +++ b/packages/commithelper-go/schema.json @@ -18,21 +18,12 @@ } }, "passthrough": { - "description": "Verbatim issue keys (e.g. Jira/Linear `PROJ-123`) copied unchanged into the commit message, as opposed to `rules`, which translate a prefix into a repo reference.", - "oneOf": [ - { - "const": "uppercase", - "description": "Recognize any uppercase key shape ([A-Z][A-Z0-9]+ followed by 1-7 digits). Also tags non-tracker tokens such as UTF-8." - }, - { - "type": "array", - "description": "Project keys recognized verbatim (e.g. \"PROJ\").", - "items": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9]+$" - } - } - ] + "type": ["array", "null"], + "description": "Jira/Linear project keys recognized verbatim in the branch (e.g. \"PROJ\" tags feature/PROJ-123 as [PROJ-123]), as opposed to `rules`, which translate a prefix into a repo reference. Recognition matches Jigit: -<1-7 digit number> found anywhere in the branch. Only listed projects are tagged.", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9]+$" + } }, "protect": { "type": "array", From f5bd805d31fbdc02ab02be57572868a20679d199 Mon Sep 17 00:00:00 2001 From: djk01281 Date: Wed, 1 Jul 2026 15:01:23 +0900 Subject: [PATCH 3/3] :bug: fix: fill Number for passthrough keys so {{.Number}} templates render resolveKey only set Prefix, so a custom template using {{.Number}} (e.g. "Ref. [#{{.Number}}]") rendered "[#]" on a passthrough branch. Set Number to the key's number part; Repo stays empty (a verbatim key has no repo). The default [{{.Prefix}}] template is unaffected. Co-Authored-By: Claude Opus 4.8 --- packages/commithelper-go/README.md | 2 +- packages/commithelper-go/main.go | 2 +- packages/commithelper-go/main_test.go | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index df07f8a..8124427 100644 --- a/packages/commithelper-go/README.md +++ b/packages/commithelper-go/README.md @@ -172,7 +172,7 @@ A branch like `feature/PROJ-1871` is tagged `[PROJ-1871]`. Only listed projects - `{{.Number}}`: Issue number extracted from branch name - `{{.Repo}}`: Repository name (empty string if not specified in rules) - `{{.Prefix}}`: Full reference (`#123`, `org/repo#123`, or a verbatim key like `PROJ-1871`) -- For `passthrough` (verbatim) branches, `{{.Prefix}}` is the key itself while `{{.Number}}` and `{{.Repo}}` are empty — prefer `{{.Prefix}}` for templates that must work with both styles. +- For `passthrough` (verbatim) branches, `{{.Prefix}}` is the full key (e.g. `PROJ-123`) and `{{.Number}}` its number (`123`), while `{{.Repo}}` is empty — prefer `{{.Prefix}}` for templates that must work with both styles. - Put the reference in your template as `{{.Prefix}}` for safe re-tagging: commithelper skips an already-tagged message by looking for that exact reference, so rendering it another way (e.g. `#{{.Number}}` for a repo-scoped or verbatim rule) can add it twice on `git commit --amend`. ##### Template Examples diff --git a/packages/commithelper-go/main.go b/packages/commithelper-go/main.go index 14768e6..d8fa2a3 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -146,7 +146,7 @@ func resolveKey(branch string, passthrough []string) *TemplateData { } for _, m := range keyPattern.FindAllStringSubmatch(branch, -1) { if len(m[2]) <= 7 && allowed[m[1]] { - return &TemplateData{Prefix: m[0]} + return &TemplateData{Prefix: m[0], Number: m[2]} } } return nil diff --git a/packages/commithelper-go/main_test.go b/packages/commithelper-go/main_test.go index ce35db1..ef105f0 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -240,6 +240,11 @@ func TestProcessMessage(t *testing.T) { Protect: []string{"main"}, Template: strPtr("{{.Message}}\n\nRef. [{{.Prefix}}]"), } + numberTmpl := Config{ + Passthrough: []string{"PROJ"}, + Protect: []string{"main"}, + Template: strPtr("{{.Message}} (issue #{{.Number}})"), + } tests := []struct { name string @@ -259,6 +264,7 @@ func TestProcessMessage(t *testing.T) { {"custom template idempotent on amend (D1)", "fix\n\nRef. [#123]", "feature/123", bottomRef, "fix\n\nRef. [#123]", false}, {"embedded key not treated as tagged", "[MY-PROJ-1871] earlier", "feature/PROJ-1871", verbatim, "[PROJ-1871] [MY-PROJ-1871] earlier", false}, {"key before a letter not treated as tagged", "work on PROJ-1abc", "feature/PROJ-1", verbatim, "[PROJ-1] work on PROJ-1abc", false}, + {"passthrough fills Number for custom template", "fix", "feature/PROJ-123", numberTmpl, "fix (issue #123)", false}, } for _, tt := range tests {