-
Notifications
You must be signed in to change notification settings - Fork 0
README,cmd/issuebot,go.{sum,mod}: update issuebot to support Jira keys #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ( | ||||||||||||
|
|
@@ -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 | ||||||||||||
| // | ||||||||||||
|
|
@@ -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 { | ||||||||||||
| 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. | ||||||||||||
| // | ||||||||||||
|
|
@@ -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 | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
|
|
@@ -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) { | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||
| var keys []string | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend
Suggested change
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]*$`) | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`) | ||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For extra paranoia, we should probably |
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| 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. | ||||||||||||
|
|
@@ -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() | ||||||||||||
|
|
@@ -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 != "" { | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,8 @@ package main | |
|
|
||
| import ( | ||
| "regexp" | ||
| "slices" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-github/v72/github" | ||
|
|
@@ -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}, | ||
|
|
@@ -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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
| ) |
There was a problem hiding this comment.
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)