|
4 | 4 | "embed" |
5 | 5 | "encoding/json" |
6 | 6 | "fmt" |
7 | | - "math/rand/v2" |
| 7 | + "math/rand" |
8 | 8 | "os" |
9 | 9 | "path/filepath" |
10 | 10 | "strings" |
@@ -109,95 +109,253 @@ func NewTemplater(templateFile string, hist *history.CommitHistory) (*Templater, |
109 | 109 |
|
110 | 110 | // GetMessage selects and formats a commit message |
111 | 111 | func (t *Templater) GetMessage(msg *analyzer.CommitMessage) (string, error) { |
112 | | - // First, normalize the action |
113 | | - actionUpper := strings.ToUpper(msg.Action) |
114 | | - actionTemplates, ok := t.templates[actionUpper] |
| 112 | + // Map analyzer action names (feat, fix, refactor, chore, docs, test, etc.) |
| 113 | + // to the template groups used in templates.json (A, M, D, R, DOC, MISC) |
| 114 | + actionMap := map[string]string{ |
| 115 | + "feat": "A", |
| 116 | + "add": "A", |
| 117 | + "fix": "M", |
| 118 | + "bugfix": "M", |
| 119 | + "refactor": "R", |
| 120 | + "chore": "D", |
| 121 | + "test": "M", |
| 122 | + "docs": "DOC", |
| 123 | + "ci": "M", |
| 124 | + "perf": "M", |
| 125 | + "style": "MISC", |
| 126 | + "build": "MISC", |
| 127 | + } |
| 128 | + |
| 129 | + // Normalize and resolve action group |
| 130 | + actionLower := strings.ToLower(msg.Action) |
| 131 | + var actionKey string |
| 132 | + if key, ok := actionMap[actionLower]; ok { |
| 133 | + actionKey = key |
| 134 | + } else if len(msg.Action) == 1 { |
| 135 | + // Already a single-letter action like A/M/D/R |
| 136 | + actionKey = strings.ToUpper(msg.Action) |
| 137 | + } else { |
| 138 | + // fallback to MISC |
| 139 | + actionKey = "MISC" |
| 140 | + } |
| 141 | + |
| 142 | + actionTemplates, ok := t.templates[actionKey] |
115 | 143 | if !ok { |
116 | | - // For offline use, we have a more detailed fallback strategy |
117 | | - fallbackActions := []string{"MISC", "A", "M"} // Priority order for fallbacks |
118 | | - for _, fallback := range fallbackActions { |
119 | | - if templates, exists := t.templates[fallback]; exists { |
| 144 | + // Try fallbacks: specific order prefers DOC then A then M then MISC |
| 145 | + fallbackActions := []string{"DOC", "A", "M", "R", "D", "MISC"} |
| 146 | + for _, fb := range fallbackActions { |
| 147 | + if templates, exists := t.templates[fb]; exists { |
120 | 148 | actionTemplates = templates |
121 | 149 | ok = true |
122 | 150 | break |
123 | 151 | } |
124 | 152 | } |
125 | 153 | if !ok { |
126 | | - return "", fmt.Errorf("no suitable templates found for action: %s (tried fallbacks: %v)", msg.Action, fallbackActions) |
| 154 | + return "", fmt.Errorf("no suitable templates found for action: %s (resolved key: %s)", msg.Action, actionKey) |
127 | 155 | } |
128 | 156 | } |
129 | 157 |
|
130 | | - // Topic selection with smart fallback for offline use |
| 158 | + // Topic selection with improved matching and weighting |
| 159 | + normalizedTopic := strings.ToLower(strings.TrimSpace(msg.Topic)) |
131 | 160 | var topicTemplates []string |
132 | | - normalizedTopic := strings.ToLower(msg.Topic) |
133 | 161 |
|
134 | | - // Try exact match first |
135 | | - if templates, exists := actionTemplates[normalizedTopic]; exists && len(templates) > 0 { |
136 | | - topicTemplates = templates |
137 | | - } else { |
138 | | - // Try fuzzy matching for similar topics |
| 162 | + // exact match |
| 163 | + if normalizedTopic != "" { |
| 164 | + if templates, exists := actionTemplates[normalizedTopic]; exists && len(templates) > 0 { |
| 165 | + topicTemplates = templates |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + // fuzzy match if exact not found |
| 170 | + if len(topicTemplates) == 0 { |
139 | 171 | for topic, templates := range actionTemplates { |
140 | | - if strings.Contains(topic, normalizedTopic) || strings.Contains(normalizedTopic, topic) { |
| 172 | + if topic == "_default" { |
| 173 | + continue |
| 174 | + } |
| 175 | + tname := strings.ToLower(topic) |
| 176 | + if normalizedTopic != "" && (strings.Contains(tname, normalizedTopic) || strings.Contains(normalizedTopic, tname)) { |
141 | 177 | topicTemplates = templates |
142 | 178 | break |
143 | 179 | } |
144 | 180 | } |
| 181 | + } |
145 | 182 |
|
146 | | - // If no match found, fall back to _default |
147 | | - if len(topicTemplates) == 0 { |
148 | | - if defaults, exists := actionTemplates["_default"]; exists && len(defaults) > 0 { |
149 | | - topicTemplates = defaults |
150 | | - } else { |
151 | | - return "", fmt.Errorf("no suitable templates found for topic: %s (action: %s)", msg.Topic, actionUpper) |
152 | | - } |
| 183 | + // fall back to _default |
| 184 | + if len(topicTemplates) == 0 { |
| 185 | + if defaults, exists := actionTemplates["_default"]; exists && len(defaults) > 0 { |
| 186 | + topicTemplates = defaults |
| 187 | + } else { |
| 188 | + return "", fmt.Errorf("no suitable templates found for topic: %s (action: %s)", msg.Topic, actionKey) |
153 | 189 | } |
154 | 190 | } |
155 | 191 |
|
156 | | - // Prepare replacer for placeholders |
| 192 | + // Prepare placeholder values |
157 | 193 | source := "" |
158 | | - if len(msg.RenamedFiles) > 0 { |
159 | | - source = msg.RenamedFiles[0].Source |
160 | | - } |
161 | 194 | target := "" |
162 | 195 | if len(msg.RenamedFiles) > 0 { |
| 196 | + source = msg.RenamedFiles[0].Source |
163 | 197 | target = msg.RenamedFiles[0].Target |
164 | 198 | } |
165 | 199 |
|
166 | | - replacer := strings.NewReplacer( |
| 200 | + // Scoring-based selection: prefer templates that use available context |
| 201 | + type scored struct { |
| 202 | + tmpl string |
| 203 | + score int |
| 204 | + } |
| 205 | + |
| 206 | + var candidates []scored |
| 207 | + |
| 208 | + for _, tmpl := range topicTemplates { |
| 209 | + score := 0 |
| 210 | + // reward templates that include placeholders we can fill |
| 211 | + if strings.Contains(tmpl, "{item}") && msg.Item != "" { |
| 212 | + score += 3 |
| 213 | + } |
| 214 | + if strings.Contains(tmpl, "{purpose}") && msg.Purpose != "" && msg.Purpose != "general update" { |
| 215 | + score += 2 |
| 216 | + } |
| 217 | + if strings.Contains(tmpl, "{source}") && source != "" { |
| 218 | + score += 3 |
| 219 | + } |
| 220 | + if strings.Contains(tmpl, "{target}") && target != "" { |
| 221 | + score += 3 |
| 222 | + } |
| 223 | + if strings.Contains(tmpl, "{topic}") && normalizedTopic != "" { |
| 224 | + score += 1 |
| 225 | + } |
| 226 | + // small randomness to diversify choices |
| 227 | + score += rand.Intn(2) |
| 228 | + |
| 229 | + candidates = append(candidates, scored{tmpl: tmpl, score: score}) |
| 230 | + } |
| 231 | + |
| 232 | + // sort candidates by score (simple selection of best score) |
| 233 | + bestScore := -1 |
| 234 | + var bestCandidates []string |
| 235 | + for _, c := range candidates { |
| 236 | + if c.score > bestScore { |
| 237 | + bestScore = c.score |
| 238 | + bestCandidates = []string{c.tmpl} |
| 239 | + } else if c.score == bestScore { |
| 240 | + bestCandidates = append(bestCandidates, c.tmpl) |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + // Prefer a template that is not in recent history |
| 245 | + replacerForCheck := strings.NewReplacer( |
167 | 246 | "{topic}", msg.Topic, |
168 | 247 | "{item}", msg.Item, |
169 | 248 | "{purpose}", msg.Purpose, |
170 | 249 | "{source}", source, |
171 | 250 | "{target}", target, |
172 | 251 | ) |
173 | 252 |
|
174 | | - // Select a random template, avoiding recent duplicates |
175 | | - var selectedTemplate string |
176 | | - shuffledTemplates := make([]string, len(topicTemplates)) |
177 | | - copy(shuffledTemplates, topicTemplates) |
178 | | - rand.Shuffle(len(shuffledTemplates), func(i, j int) { |
179 | | - shuffledTemplates[i], shuffledTemplates[j] = shuffledTemplates[j], shuffledTemplates[i] |
180 | | - }) |
181 | | - |
182 | | - for _, tmpl := range shuffledTemplates { |
183 | | - potentialMessage := replacer.Replace(tmpl) |
184 | | - if !t.history.Contains(potentialMessage) { |
185 | | - selectedTemplate = tmpl |
| 253 | + var chosen string |
| 254 | + for _, tmpl := range bestCandidates { |
| 255 | + candidateMsg := replacerForCheck.Replace(tmpl) |
| 256 | + if !t.history.Contains(candidateMsg) { |
| 257 | + chosen = tmpl |
186 | 258 | break |
187 | 259 | } |
188 | 260 | } |
189 | 261 |
|
190 | | - if selectedTemplate == "" { |
191 | | - // If all templates are recent duplicates, just pick a random one |
192 | | - selectedTemplate = topicTemplates[rand.IntN(len(topicTemplates))] |
| 262 | + // If all best candidates are in history, pick a random best candidate |
| 263 | + if chosen == "" { |
| 264 | + if len(bestCandidates) > 0 { |
| 265 | + chosen = bestCandidates[rand.Intn(len(bestCandidates))] |
| 266 | + } else { |
| 267 | + // final fallback: random from topicTemplates |
| 268 | + chosen = topicTemplates[rand.Intn(len(topicTemplates))] |
| 269 | + } |
193 | 270 | } |
194 | 271 |
|
195 | | - formattedMsg := replacer.Replace(selectedTemplate) |
| 272 | + // Final replacement |
| 273 | + replacer := strings.NewReplacer( |
| 274 | + "{topic}", msg.Topic, |
| 275 | + "{item}", msg.Item, |
| 276 | + "{purpose}", msg.Purpose, |
| 277 | + "{source}", source, |
| 278 | + "{target}", target, |
| 279 | + ) |
| 280 | + |
| 281 | + formattedMsg := replacer.Replace(chosen) |
196 | 282 |
|
197 | | - // Handle scope |
| 283 | + // If scope exists, prefer replacing the topic scope pattern when present |
198 | 284 | if msg.Scope != "" { |
| 285 | + // try common patterns |
199 | 286 | formattedMsg = strings.Replace(formattedMsg, "("+msg.Topic+")", "("+msg.Scope+")", 1) |
200 | 287 | } |
201 | 288 |
|
202 | 289 | return formattedMsg, nil |
203 | 290 | } |
| 291 | + |
| 292 | +// DebugInfo returns the resolved action key and the candidate templates for a CommitMessage |
| 293 | +func (t *Templater) DebugInfo(msg *analyzer.CommitMessage) (string, []string) { |
| 294 | + // same mapping as in GetMessage |
| 295 | + actionMap := map[string]string{ |
| 296 | + "feat": "A", |
| 297 | + "add": "A", |
| 298 | + "fix": "M", |
| 299 | + "bugfix": "M", |
| 300 | + "refactor": "R", |
| 301 | + "chore": "D", |
| 302 | + "test": "M", |
| 303 | + "docs": "DOC", |
| 304 | + "ci": "M", |
| 305 | + "perf": "M", |
| 306 | + "style": "MISC", |
| 307 | + "build": "MISC", |
| 308 | + } |
| 309 | + |
| 310 | + actionLower := strings.ToLower(msg.Action) |
| 311 | + var actionKey string |
| 312 | + if key, ok := actionMap[actionLower]; ok { |
| 313 | + actionKey = key |
| 314 | + } else if len(msg.Action) == 1 { |
| 315 | + actionKey = strings.ToUpper(msg.Action) |
| 316 | + } else { |
| 317 | + actionKey = "MISC" |
| 318 | + } |
| 319 | + |
| 320 | + actionTemplates, ok := t.templates[actionKey] |
| 321 | + if !ok { |
| 322 | + fallbackActions := []string{"DOC", "A", "M", "R", "D", "MISC"} |
| 323 | + for _, fb := range fallbackActions { |
| 324 | + if templates, exists := t.templates[fb]; exists { |
| 325 | + actionTemplates = templates |
| 326 | + ok = true |
| 327 | + break |
| 328 | + } |
| 329 | + } |
| 330 | + if !ok { |
| 331 | + return actionKey, nil |
| 332 | + } |
| 333 | + } |
| 334 | + |
| 335 | + normalizedTopic := strings.ToLower(strings.TrimSpace(msg.Topic)) |
| 336 | + var topicTemplates []string |
| 337 | + if normalizedTopic != "" { |
| 338 | + if templates, exists := actionTemplates[normalizedTopic]; exists && len(templates) > 0 { |
| 339 | + topicTemplates = templates |
| 340 | + } |
| 341 | + } |
| 342 | + if len(topicTemplates) == 0 { |
| 343 | + for topic, templates := range actionTemplates { |
| 344 | + if topic == "_default" { |
| 345 | + continue |
| 346 | + } |
| 347 | + tname := strings.ToLower(topic) |
| 348 | + if normalizedTopic != "" && (strings.Contains(tname, normalizedTopic) || strings.Contains(normalizedTopic, tname)) { |
| 349 | + topicTemplates = templates |
| 350 | + break |
| 351 | + } |
| 352 | + } |
| 353 | + } |
| 354 | + if len(topicTemplates) == 0 { |
| 355 | + if defaults, exists := actionTemplates["_default"]; exists && len(defaults) > 0 { |
| 356 | + topicTemplates = defaults |
| 357 | + } |
| 358 | + } |
| 359 | + |
| 360 | + return actionKey, topicTemplates |
| 361 | +} |
0 commit comments