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/packages/commithelper-go/README.md b/packages/commithelper-go/README.md index b26b384..8124427 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 @@ -86,12 +103,52 @@ 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-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. + +```json +{ "passthrough": ["PROJ", "OPS"] } +``` + +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). + +**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: + +- `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). + +| 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. + #### protect - Defines branches that are blocked from committing. Supports glob-style wildcard patterns. @@ -114,7 +171,9 @@ 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 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 @@ -125,7 +184,7 @@ This is Basic rule of `.commithelperrc.json`. "rules": { "feature": null }, - "template": "{{.Message}}\n\nRef. [#{{.Number}}]" + "template": "{{.Message}}\n\nRef. [{{.Prefix}}]" } ``` @@ -202,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 e4789b4..d8fa2a3 100644 --- a/packages/commithelper-go/main.go +++ b/packages/commithelper-go/main.go @@ -14,9 +14,10 @@ import ( ) 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 []string `json:"passthrough"` + Protect []string `json:"protect"` + Template *string `json:"template,omitempty"` } type TemplateData struct { @@ -26,6 +27,13 @@ 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+)`) + keyPattern = regexp.MustCompile(`([A-Z][A-Z0-9]+)-([0-9]+)`) +) + func main() { if len(os.Args) < 2 { fmt.Println("Usage: commithelper-go ") @@ -33,10 +41,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,59 +53,130 @@ 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 resolveKey(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), } - } else { - // Print the result if input was a direct message - fmt.Println(commitMessage) + } + return &TemplateData{ + Number: issueNumber, + Repo: *repo, + Prefix: fmt.Sprintf("%s#%s", *repo, issueNumber), } } +func resolveKey(branch string, passthrough []string) *TemplateData { + if len(passthrough) == 0 { + return nil + } + allowed := make(map[string]bool, len(passthrough)) + for _, k := range passthrough { + allowed[k] = true + } + for _, m := range keyPattern.FindAllStringSubmatch(branch, -1) { + if len(m[2]) <= 7 && allowed[m[1]] { + return &TemplateData{Prefix: m[0], Number: m[2]} + } + } + return nil +} + +// 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 || !isKeyByte(message[start-1]) + afterOK := end >= len(message) || !isKeyByte(message[end]) + if beforeOK && afterOK { + return true + } + from = start + 1 + } + return false +} + func getCurrentBranchName() string { cmd := exec.Command("git", "branch", "--show-current") output, err := cmd.Output() @@ -141,61 +221,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 +258,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 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 8cc4a6c..ef105f0 100644 --- a/packages/commithelper-go/main_test.go +++ b/packages/commithelper-go/main_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "testing" +) + +func strPtr(s string) *string { return &s } func TestIsProtectedBranch(t *testing.T) { protected := []string{"main", "release/*", "epic/*"} @@ -19,6 +23,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 +42,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 +56,232 @@ 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) { - data := generateTemplateData(tt.branch, config, "Test") + t.Parallel() + td := resolvePrefix(tt.branch, config) 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) { - config := Config{ - Rules: map[string]*string{ - "fe-plan": strPtr("card-fe/plan"), - "feature": nil, - }, +func TestResolveKey(t *testing.T) { + tests := []struct { + name string + passthrough []string + branch string + wantNil bool + wantPrefix string + }{ + {"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 := resolveKey(tt.branch, tt.passthrough) + 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 TestResolveKey_JigitParity(t *testing.T) { + allowed := []string{"ABC"} tests := []struct { - name string branch string want string }{ - {"hyphenated prefix", "fe-plan/11", "card-fe/plan#11"}, - {"simple prefix", "feature/42", "#42"}, - {"no match", "main", ""}, - {"unknown prefix", "unknown/99", ""}, + {"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: []string{"ABC"}, + } + + tests := []struct { + name string + branch string + wantNil bool + wantPrefix string + }{ + {"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}, + {"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 { + 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: []string{"PROJ"}, + Protect: []string{"main"}, + } + bottomRef := Config{ + Rules: map[string]*string{"feature": nil}, + 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 + 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}, + {"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 { + 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) } }) } 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..615a4f3 --- /dev/null +++ b/packages/commithelper-go/schema.json @@ -0,0 +1,40 @@ +{ + "$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": { + "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", + "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}}." + } + } +}