Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ If any commit contains "skip-issuebot" (and no issue is mentioned from other
commits), a stub issue will be created for the PR that you can fill out later.
This also makes the CI check pass, like with "#cleanup".

## Jira issue references

By default only GitHub issue references satisfy the requirement. Set
`--jira-project-keys` to a comma-separated list of Jira project keys to also
accept Jira-style references for those projects:

```shell
issuebot --jira-project-keys=EDGE,CORP
```

With that set, `Fixes EDGE-9` and `Fixes [EDGE-12]` count as links.

## Installation

```go
Expand Down
8 changes: 4 additions & 4 deletions cmd/issuebot/autoissue.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ func (p pullRequest) createStubIssue(ctx context.Context, cli *github.Client) (i
prAuthor := p.pr.GetUser().GetLogin()
labels := []string{issuebotStubLabel}
issue, _, err := cli.Issues.Create(ctx, owner, repoName, &github.IssueRequest{
Title: github.Ptr(fmt.Sprintf(issueTitleTemplate, prNumber)),
Assignee: github.Ptr(prAuthor),
Body: github.Ptr(fmt.Sprintf("TODO(@%s): Add details about PR #%d",
Title: new(fmt.Sprintf(issueTitleTemplate, prNumber)),
Assignee: new(prAuthor),
Body: new(fmt.Sprintf("TODO(@%s): Add details about PR #%d",
prAuthor, prNumber)),
Labels: &labels,
})
Expand All @@ -100,7 +100,7 @@ func (p pullRequest) createStubIssue(ctx context.Context, cli *github.Client) (i

// Add a comment to the PR thread indicating what we did.
if _, _, err := cli.Issues.CreateComment(ctx, owner, repoName, prNumber, &github.IssueComment{
Body: github.Ptr(fmt.Sprintf(issueCommentTemplate, issueNumber)),
Body: new(fmt.Sprintf(issueCommentTemplate, issueNumber)),
}); err != nil {
p.logf("error adding comment (continuing): %v", err)
}
Expand Down
94 changes: 89 additions & 5 deletions cmd/issuebot/issuebot.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
// If any commit contains "skip-issuebot" (and no issue is mentioned from other
// commits), a stub issue will be created for the PR that you can fill out
// later. This also makes the CI check pass, like with "#cleanup".
//
// By default only GitHub issue references satisfy the requirement. If
// --jira-project-keys is set, Jira-style references for those projects are
// also accepted, so that "Fixes EDGE-9" counts alongside "Fixes #9".
package main

import (
Expand Down Expand Up @@ -57,6 +61,8 @@ var (
"If set, fetch secrets from this service (https://hostname)")
botAuthorEmail = flag.String("bot-author-regexp", "",
"If set, a regexp matching author e-mails to be treated as automation bots (RE2)")
jiraProjectKeys = flag.String("jira-project-keys", "",
`If set, a comma-separated list of Jira project keys (e.g. "EDGE,CORP") whose issue references satisfy the link requirement`)

// Access tokens
//
Expand All @@ -69,16 +75,44 @@ var (

client *github.Client
botAuthorRE *regexp.Regexp

// jiraKeyRE matches a Jira issue reference for one of the project keys
// named by --jira-project-keys. It is nil if the flag is unset, in which
// case only GitHub issue references are accepted.
jiraKeyRE *regexp.Regexp

// missingCommitDescription is the commit status description posted when
// no commit in a PR links to an issue. It is replaced at startup if Jira
// matching is enabled.
missingCommitDescription = missingCommitExplanation
)

const (
appPrivateKeyName = "prod/issuebot/app-private-key"
githubWebhookSecretName = "prod/issuebot/github-webhook-secret"

// The description is limited to 140 characters, so be brief.
// GitHub limits a commit status description to this many characters.
maxStatusDescriptionLen = 140

// Both descriptions must fit in maxStatusDescriptionLen, so be brief.
missingCommitExplanation = `Any non-trivial git commit must link to a GitHub issue tracking the work. Edit each commit with a tag like "Updates #nn", and update the PR.`

// As above, but for when Jira references are also accepted. The "%s" is
// filled in with a configured project key.
missingCommitExplanationJira = `Any non-trivial commit must link to an issue. Edit each commit with a tag like "Updates #nn" or "Fixes %s-nn", then update the PR.`
)

// jiraMissingCommitDescription returns the commit status description to post
// when no commit in a PR links to an issue and Jira matching is enabled for
// key. If key is too long for the description to fit the GitHub limit, a
// generic placeholder is used instead.
func jiraMissingCommitDescription(key string) string {
if s := fmt.Sprintf(missingCommitExplanationJira, key); len(s) <= maxStatusDescriptionLen {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like overkill, if we're going to fall back to "KEY" anyway, we should probably just do that. (Listing out all the available options that might be worthwhile, but for a single example this seems excessive)

return s
}
return fmt.Sprintf(missingCommitExplanationJira, "KEY")
}

// Return an HTTP client suitable to use with the GitHub API, initialized with
// our API keys and certificate.
//
Expand Down Expand Up @@ -130,6 +164,15 @@ func (p pullRequest) checkCommitMessage(message string) pullRequestStatus {
p.logf("accept: %q", line)
return prLinked
}
// A Jira reference like "Fixes EDGE-9" or "Fixes [EDGE-12]" counts
// too, but only for the project keys we were configured with. We
// match the unfolded line because Jira keys are conventionally
// upper-case, and folding invites false positives on things like
// source file names ("edge-12.go").
if jiraKeyRE != nil && jiraKeyRE.MatchString(line) {
p.logf("accept: %q", line)
return prLinked
}
}
}

Expand All @@ -144,6 +187,38 @@ func (p pullRequest) checkCommitMessage(message string) pullRequestStatus {
return prFailed
}

// parseJiraProjectKeys parses the comma-separated Jira project keys in spec,
// as given to --jira-project-keys. It reports an error if any key is not a
// plausible Jira project key (letters and digits, starting with a letter).
// Requiring an explicit allowlist keeps a bare "PROJ-123" shape from matching
// incidental text like "Fixes UTF-8 handling" or "Fixes RFC-2119".
func parseJiraProjectKeys(spec string) ([]string, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider maybe folding the parse and compile together?

func parseJiraProjectKeys(spec string) (*regexp.Regexp, error) {

since the caller is just going to compile them anyway, and nil means skip.

var keys []string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend

Suggested change
var keys []string
if spec == "" {
return nil, nil
}
var keys []string

and then reject an empty string below. Rationale: Other than "" itself, the only way we get an empty string is if someone mangles the flag value, and that should probably be an error rather than a silent skip.

for key := range strings.SplitSeq(spec, ",") {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if !jiraProjectKeyRE.MatchString(key) {
return nil, fmt.Errorf("invalid Jira project key %q", key)
}
keys = append(keys, key)
}
return keys, nil
}

// jiraProjectKeyRE matches a well-formed Jira project key.
var jiraProjectKeyRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to allow lowercase? It seems like the lexical convention is ALL UPPER.


// compileJiraKeyRE builds a regexp matching a Jira issue reference for any of
// the given project keys as a standalone token, so "EDGE,CORP" yields a regexp
// matching "EDGE-9" and "CORP-123". Surrounding brackets need no special
// handling: "[EDGE-12]" matches on the word boundary. The keys must already
// have been validated by parseJiraProjectKeys.
func compileJiraKeyRE(keys []string) *regexp.Regexp {
return regexp.MustCompile(`\b(?:` + strings.Join(keys, "|") + `)-\d+\b`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For extra paranoia, we should probably regexp.QuoteMeta each of them.

}

func (p pullRequest) checkCommitMetadata(repoCommit *github.RepositoryCommit) pullRequestStatus {
// Requiring bots to link to a bug means they'd link all of their commits to
// the same bug, which wouldn't be useful.
Expand Down Expand Up @@ -201,14 +276,14 @@ func isAutomationBotAuthor(u *github.CommitAuthor) bool {
func (p pullRequest) annotateCommitStatus(headSHA string, failed bool) {
now := time.Now()
status := &github.RepoStatus{
Context: github.Ptr("issuebot"),
Context: new("issuebot"),
UpdatedAt: &github.Timestamp{Time: now},
}
if failed {
status.State = github.Ptr("failure")
status.Description = github.Ptr(missingCommitExplanation)
status.State = new("failure")
status.Description = new(missingCommitDescription)
} else {
status.State = github.Ptr("success")
status.State = new("success")
}

ctx := context.Background()
Expand Down Expand Up @@ -375,6 +450,15 @@ func main() {
botAuthorRE = regexp.MustCompile(*botAuthorEmail)
log.Printf("Enabled bot regexp matching: %q", botAuthorRE)
}
jiraKeys, err := parseJiraProjectKeys(*jiraProjectKeys)
if err != nil {
log.Fatalf("Invalid --jira-project-keys: %v", err)
}
if len(jiraKeys) > 0 {
jiraKeyRE = compileJiraKeyRE(jiraKeys)
missingCommitDescription = jiraMissingCommitDescription(jiraKeys[0])
log.Printf("Enabled Jira issue matching: %q", jiraKeyRE)
}

// Fetch secrets from the secrets service, if configured.
if *useSecretsService != "" {
Expand Down
138 changes: 137 additions & 1 deletion cmd/issuebot/issuebot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package main

import (
"regexp"
"slices"
"strings"
"testing"

"github.com/google/go-github/v72/github"
Expand Down Expand Up @@ -72,7 +74,10 @@ func TestCheckCommitMessage(t *testing.T) {
{"prLinked GitHub resolved number\nResolved #1", prLinked},
{"prLinked GitHub for number\nFor #1", prLinked},

{"prLinked Linear number\nUpdates XXX-123", prFailed}, // https://github.com/tailscale/corp/issues/21347
// With no --jira-project-keys configured, a bare project-style
// reference does not count. https://github.com/tailscale/corp/issues/21347
{"prFailed Linear number\nUpdates XXX-123", prFailed},
{"prFailed Jira number\nFixes EDGE-9", prFailed},

{"Revert 0123456789abcdef", prRevert},
{"prCleanup\nJust a #cleanup", prCleanup},
Expand All @@ -88,3 +93,134 @@ func TestCheckCommitMessage(t *testing.T) {
}
}
}

// enableJiraKeys enables Jira issue matching for the comma-separated project
// keys in spec for the duration of the test.
func enableJiraKeys(t *testing.T, spec string) {
keys, err := parseJiraProjectKeys(spec)
if err != nil {
t.Fatalf("parseJiraProjectKeys(%q): unexpected error: %v", spec, err)
}
jiraKeyRE = compileJiraKeyRE(keys)
t.Cleanup(func() { jiraKeyRE = nil })
}

func TestCheckCommitMessageJira(t *testing.T) {
enableJiraKeys(t, "EDGE, CORP")

tests := []struct {
commit string
result pullRequestStatus
}{
{"prLinked Jira fixes\nFixes EDGE-9", prLinked},
{"prLinked Jira bracketed\nFixes [EDGE-12]", prLinked},
{"prLinked Jira updates\nUpdates EDGE-12", prLinked},
{"prLinked Jira second key\nResolves CORP-4321", prLinked},

// GitHub references keep working.
{"prLinked GitHub still works\nFixes #1", prLinked},

// Unconfigured project keys are still rejected.
{"prFailed unknown key\nFixes XXX-123", prFailed},

// Keys are matched against the unfolded line, so source file names
// and other lower-case incidentals do not count.
{"prFailed lower-case\nFixes edge-12.go formatting", prFailed},

// No verb, no link, even with a well-formed key.
{"prFailed no verb\nSomething about EDGE-9", prFailed},
}
for _, tc := range tests {
p := pullRequest{}
got := p.checkCommitMessage(tc.commit)
if got != tc.result {
t.Errorf("checkCommitMessage(%q): got %v, want %v", tc.commit, got, tc.result)
}
}
}

func TestParseJiraProjectKeys(t *testing.T) {
t.Run("Empty", func(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should reject this entirely.

for _, spec := range []string{"", " ", ",", " , ,, "} {
keys, err := parseJiraProjectKeys(spec)
if err != nil {
t.Errorf("parseJiraProjectKeys(%q): unexpected error: %v", spec, err)
} else if len(keys) != 0 {
t.Errorf("parseJiraProjectKeys(%q): got %q, want none", spec, keys)
}
}
})

t.Run("Invalid", func(t *testing.T) {
// Keys that are not plausible Jira project keys are rejected rather
// than silently compiled into a regexp that matches the wrong things.
for _, spec := range []string{"EDGE-1", "1EDGE", "ED GE", "ED.GE", "EDGE|CORP", ".*", "EDGE,(", "EDGE,1"} {
if keys, err := parseJiraProjectKeys(spec); err == nil {
t.Errorf("parseJiraProjectKeys(%q): got %q, want error", spec, keys)
}
}
})

t.Run("Valid", func(t *testing.T) {
keys, err := parseJiraProjectKeys(" EDGE, CORP ,")
if err != nil {
t.Fatalf("parseJiraProjectKeys: unexpected error: %v", err)
}
if want := []string{"EDGE", "CORP"}; !slices.Equal(keys, want) {
t.Errorf("parseJiraProjectKeys: got %q, want %q", keys, want)
}
})
}

func TestCompileJiraKeyRE(t *testing.T) {
re := compileJiraKeyRE([]string{"EDGE"})
tests := []struct {
line string
match bool
}{
{"Fixes EDGE-9", true},
{"Fixes [EDGE-12]", true},
{"Fixes (EDGE-12)", true},
{"Updates EDGE-1, EDGE-2", true},
{"Fixes EDGE-9.", true},

{"Fixes EDGE", false},
{"Fixes EDGE-", false},
{"Fixes edge-9", false},
{"Fixes EDGETEAM-1", false},
{"Fixes NOTEDGE-1", false},
{"Fixes CORP-1", false},
}
for _, tc := range tests {
if got := re.MatchString(tc.line); got != tc.match {
t.Errorf("MatchString(%q): got %v, want %v", tc.line, got, tc.match)
}
}
}

func TestMissingCommitDescription(t *testing.T) {
if n := len(missingCommitExplanation); n > maxStatusDescriptionLen {
t.Errorf("missingCommitExplanation: length %d exceeds %d", n, maxStatusDescriptionLen)
}

tests := []struct {
key string
want string // substring the description must mention
}{
{"EDGE", "EDGE-nn"},
{"INFRASTRUCT", "INFRASTRUCT-nn"}, // Jira's default maximum key length is 10; this is 11

// A key too long to fit falls back to a generic placeholder rather
// than dropping the Jira wording entirely.
{strings.Repeat("X", 40), "KEY-nn"},
}
for _, tc := range tests {
got := jiraMissingCommitDescription(tc.key)
if n := len(got); n > maxStatusDescriptionLen {
t.Errorf("jiraMissingCommitDescription(%q): length %d exceeds %d", tc.key, n, maxStatusDescriptionLen)
}
if !strings.Contains(got, tc.want) {
t.Errorf("jiraMissingCommitDescription(%q): got %q, want it to mention %q", tc.key, got, tc.want)
}
}
}
19 changes: 9 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
module github.com/tailscale/issuebot

go 1.24.2
go 1.26.6

require (
github.com/bradleyfalzon/ghinstallation/v2 v2.16.0
github.com/google/go-github/v72 v72.0.0
github.com/tailscale/setec v0.0.0-20250611230422-f66888ab66d4
tailscale.com v1.84.3
github.com/tailscale/setec v0.0.0-20251203133219-2ab774e4129a
tailscale.com v1.102.4
)

require (
github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d // indirect
github.com/creachadair/msync v0.8.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sys v0.47.0 // indirect
)
Loading
Loading