blob: 31af6fb069353378dc36c066216916ffc89d54ce [file]
// Copyright 2026 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy of
// the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations under
// the License.
package pw_ghish
import (
"fmt"
"regexp"
"strings"
)
// gitHubClosingKeywordRegex matches GitHub's issue-closing syntax: one of its
// closing keywords followed by `#<number>`.
//
// GitHub accepts close/closes/closed, fix/fixes/fixed and
// resolve/resolves/resolved. The colon is optional here even though GitHub
// does not require one, because `Fixed: #456` -- someone reaching for a Gerrit
// trailer but supplying a GitHub number -- is exactly as broken and worth
// catching.
//
// The `#` is required. Without it this would match `Fixed: b/12345`, which is
// the correct Gerrit spelling and must never be flagged.
var gitHubClosingKeywordRegex = regexp.MustCompile(
`(?i)\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*:?\s+#(\d+)\b`)
// GitHubIssueRef is a GitHub-style issue reference found in text that is
// destined for a Gerrit commit message.
type GitHubIssueRef struct {
// Match is the matched text, e.g. "Fixes #456".
Match string
// Keyword is the closing keyword as written, e.g. "Fixes".
Keyword string
// Number is the GitHub issue number, e.g. "456".
Number string
}
// FindGitHubIssueRefs returns the GitHub issue-closing references in text.
//
// This exists because the single most transferable piece of `gh` knowledge is
// also the one that silently does nothing on Gerrit. On GitHub, a pull request
// body containing "Fixes #456" closes issue 456 on merge. Gerrit has no such
// parsing: the line is ordinary prose, the bug is never linked, and nothing
// reports a problem. Detecting it is the difference between a broken link and
// an actionable error.
//
// Two kinds of text are deliberately not matched, because a false refusal
// blocks somebody who did nothing wrong:
//
// - A bare "#456" with no keyword. It is far more often prose ("see #3 of
// the design doc") than an issue link.
// - Indented lines, which are code samples. This matches how trailers are
// recognized elsewhere, where indentation likewise means "content".
func FindGitHubIssueRefs(text string) []GitHubIssueRef {
if text == "" {
return nil
}
var refs []GitHubIssueRef
for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
for _, m := range gitHubClosingKeywordRegex.FindAllStringSubmatch(line, -1) {
refs = append(refs, GitHubIssueRef{Match: m[0], Keyword: m[1], Number: m[2]})
}
}
return refs
}
// GitHubIssueSyntaxError builds the diagnostic for GitHub issue syntax found
// in where (for example "--body" or "the HEAD commit message").
//
// The references are reported rather than rewritten. A GitHub issue number is
// repo-local and usually small, while a Buganizer ID is usually eight or nine
// digits, so translating "#456" into "b/456" would link a real but unrelated
// bug. Silently attaching the wrong bug is worse than attaching none.
func GitHubIssueSyntaxError(refs []GitHubIssueRef, where string) error {
if len(refs) == 0 {
return nil
}
var quoted []string
seen := make(map[string]bool)
for _, ref := range refs {
if seen[ref.Match] {
continue
}
seen[ref.Match] = true
quoted = append(quoted, " "+ref.Match)
}
return fmt.Errorf(
"%s contains GitHub issue syntax, which has no effect on Gerrit:\n\n%s\n\n"+
"On GitHub, %q in a pull request body closes that issue when the PR merges.\n"+
"Gerrit does not parse it: the line is ordinary prose, so the bug is never\n"+
"linked, never closed, and nothing warns you. Buganizer links come from a\n"+
"commit message trailer instead:\n\n"+
" Fixed: b/<id> links the bug and closes it when the change is submitted\n"+
" Bug: b/<id> links the bug without closing it\n\n"+
"gh-ish can write the trailer for you, and accepts a bare number, a b/ ID, or\n"+
"an issue URL:\n\n"+
" gh pr edit <change> --fixed b/<id>\n"+
" gh pr edit <change> --bug b/<id>\n\n"+
"This is not translated automatically on purpose: #%s is a repo-local GitHub\n"+
"number, so b/%s would almost certainly be an unrelated Buganizer issue.",
where, strings.Join(quoted, "\n"), refs[0].Match, refs[0].Number, refs[0].Number)
}
// CheckGitHubIssueSyntax returns an actionable error if text uses GitHub
// issue-closing syntax. It is the single entry point commands should call, so
// that every path that accepts commit message text rejects it identically.
func CheckGitHubIssueSyntax(text, where string) error {
return GitHubIssueSyntaxError(FindGitHubIssueRefs(text), where)
}