diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index bdc919f..af0b503 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -23,6 +23,8 @@ jobs: imageName: 'gh-action-create-github-release' - actionPath: 'actions/github/jsonDiffAlert' imageName: 'gh-action-json-diff-alert' + - actionPath: 'actions/github/enrichPullRequest' + imageName: 'gh-action-enrich-pull-request' uses: ./.github/workflows/_github_createAndReleaseActionDockerImage.yml with: actionPath: ${{ matrix.actionPath }} diff --git a/actions/github/enrichPullRequest/Dockerfile b/actions/github/enrichPullRequest/Dockerfile new file mode 100644 index 0000000..4b61edb --- /dev/null +++ b/actions/github/enrichPullRequest/Dockerfile @@ -0,0 +1,41 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +# Final stage - minimal image +FROM alpine:latest + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates + +# Create a non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Set working directory +WORKDIR /app + +# Copy the binary from builder stage +COPY --from=builder /app/main . + +# Set ownership and permissions +RUN chown appuser:appgroup /app/main && \ + chmod +x /app/main + +# Switch to non-root user +USER appuser + +# Run the binary +ENTRYPOINT ["/app/main"] \ No newline at end of file diff --git a/actions/github/enrichPullRequest/action.yml b/actions/github/enrichPullRequest/action.yml new file mode 100644 index 0000000..d1cd070 --- /dev/null +++ b/actions/github/enrichPullRequest/action.yml @@ -0,0 +1,74 @@ +name: 'Enrich Pull Request' +description: "Enrich's the pull request with information from your project management system." +inputs: + repository: + description: 'The repository name' + required: true + type: string + pullRequestNumber: + description: 'The pull request number' + required: true + type: string + branch: + description: 'The branch name' + required: true + type: string + token: + description: 'GitHub token' + required: true + customFormatting: + description: "User defined custom formatting rules for specific words." + required: false + default: "" + strategy: + type: string + description: 'Which formatting strategy should be used' + required: false + default: 'branch-name' + jiraURL: + type: string + description: 'URL to your jira instance' + required: false + default: '' + jiraEmail: + type: string + description: 'Jira auth email' + required: false + default: '' + jiraToken: + type: string + description: 'Jira auth token' + required: false + default: '' + jiraEnableSyncLabel: + type: boolean + description: 'Use a sync label' + required: false + default: true + jiraEnableSyncDescription: + type: boolean + description: 'Sync Jira description to PR description' + required: false + default: true + jiraSyncLabelName: + type: string + description: 'Name of the sync label' + required: false + default: 'jira-sync-complete' +runs: + using: 'docker' + image: 'docker://ghcr.io/encoredigitalgroup/gh-action-enrich-pull-request:latest' + env: + GH_TOKEN: ${{ inputs.token }} + GH_REPOSITORY: ${{ inputs.repository }} + PR_NUMBER: ${{ inputs.pullRequestNumber }} + BRANCH_NAME: ${{ inputs.branch }} + ENABLE_EXPERIMENTS: ${{ inputs.enableExperiments }} + OPT_FMT_WORDS: ${{ inputs.customFormatting }} + OPT_FMT_STRATEGY: ${{ inputs.strategy }} + OPT_JIRA_URL: ${{ inputs.jiraURL }} + OPT_JIRA_EMAIL: ${{ inputs.jiraEmail }} + OPT_JIRA_TOKEN: ${{ inputs.jiraToken }} + OPT_ENABLE_JIRA_SYNC_LABEL: ${{ inputs.jiraEnableSyncLabel }} + OPT_JIRA_SYNC_LABEL_NAME: ${{ inputs.jiraSyncLabelName }} + OPT_ENABLE_JIRA_SYNC_DESCRIPTION: ${{ inputs.jiraEnableSyncDescription }} \ No newline at end of file diff --git a/actions/github/enrichPullRequest/drivers/branch_name/branch_name.go b/actions/github/enrichPullRequest/drivers/branch_name/branch_name.go new file mode 100644 index 0000000..042209b --- /dev/null +++ b/actions/github/enrichPullRequest/drivers/branch_name/branch_name.go @@ -0,0 +1,65 @@ +package branchname + +import ( + "fmt" + "os" + "regexp" + + "github.com/EncoreDigitalGroup/golib/logger" + + "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/support/github" +) + +var regexWithIssueType = regexp.MustCompile(`^(epic|feature|bugfix|hotfix)/([A-Z]+-[0-9]+)-(.+)$`) +var regexWithoutIssueType = regexp.MustCompile(`^([A-Z]+-[0-9]+)-(.+)$`) +var pullRequestTitle string + +func Format(gh github.GitHub) { + branchName, err := gh.GetBranchName() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + if !gh.BranchNameMatchesPRTitle(branchName) { + formattedTitle := formatTitle(gh, branchName) + gh.UpdatePRTitle(formattedTitle) + } +} + +func GetIssueKeyFromBranchName(branchName string) (string, error) { + if matches := regexWithIssueType.FindStringSubmatch(branchName); matches != nil { + return matches[2], nil + } else if matches := regexWithoutIssueType.FindStringSubmatch(branchName); matches != nil { + return matches[1], nil + } else { + fmt.Println("Title does not match expected format") + logger.Info(pullRequestTitle) + return "", nil + } +} + +func GetIssueNameFromBranchName(branchName string) (string, error) { + if matches := regexWithIssueType.FindStringSubmatch(branchName); matches != nil { + return matches[3], nil + } else if matches := regexWithoutIssueType.FindStringSubmatch(branchName); matches != nil { + return matches[2], nil + } else { + fmt.Println("Title does not match expected format") + logger.Info(pullRequestTitle) + return "", nil + } +} + +func formatTitle(gh github.GitHub, branchName string) string { + issueKey, err := GetIssueKeyFromBranchName(branchName) + issueName, err := GetIssueNameFromBranchName(branchName) + + if err != nil { + fmt.Println("Title does not match expected format") + logger.Error(err.Error()) + return pullRequestTitle + } + + return gh.ApplyFormatting(issueKey, issueName) +} diff --git a/actions/github/enrichPullRequest/drivers/drivers.go b/actions/github/enrichPullRequest/drivers/drivers.go new file mode 100644 index 0000000..84236bc --- /dev/null +++ b/actions/github/enrichPullRequest/drivers/drivers.go @@ -0,0 +1,19 @@ +package drivers + +const BranchName = "branch-name" +const Jira = "jira" + +func Validate(driver string) bool { + validDrivers := []string{ + BranchName, + Jira, + } + + for _, validDriver := range validDrivers { + if driver == validDriver { + return true + } + } + + return false +} diff --git a/actions/github/enrichPullRequest/drivers/jira/jira.go b/actions/github/enrichPullRequest/drivers/jira/jira.go new file mode 100644 index 0000000..fbffd05 --- /dev/null +++ b/actions/github/enrichPullRequest/drivers/jira/jira.go @@ -0,0 +1,235 @@ +package jira + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/EncoreDigitalGroup/golib/logger" + "github.com/ctreminiom/go-atlassian/jira/v3" + "github.com/ctreminiom/go-atlassian/pkg/infra/models" + + branchname "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/drivers/branch_name" + "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/support/github" +) + +type Configuration struct { + Enable bool + URL string + Email string + Token string + IssueKey string +} + +type Information struct { + ParentPrefix string + Title string + Description string + HasJiraInfo bool + AuthFailure bool +} + +type JiraError struct { + IsAuthFailure bool + OriginalError error +} + +func (e *JiraError) Error() string { + return e.OriginalError.Error() +} + +func Format(gh github.GitHub) { + if jiraLabelSyncEnabled() && gh.HasLabel(jiraLabelSyncName()) { + logger.Info("PR already has '" + jiraLabelSyncName() + "' label, skipping Jira sync") + return + } + + branchName, err := gh.GetBranchName() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + envUrl := os.Getenv("OPT_JIRA_URL") + if envUrl == "" { + logger.Error("OPT_JIRA_URL is not set") + os.Exit(1) + } + + envEmail := os.Getenv("OPT_JIRA_EMAIL") + if envEmail == "" { + logger.Error("OPT_JIRA_EMAIL is not set") + os.Exit(1) + } + + envToken := os.Getenv("OPT_JIRA_TOKEN") + if envToken == "" { + logger.Error("OPT_JIRA_TOKEN is not set") + os.Exit(1) + } + + issueKey, err := branchname.GetIssueKeyFromBranchName(branchName) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + if issueKey == "" { + logger.Error("Issue key is empty") + return + } + + config := Configuration{ + Enable: true, + URL: envUrl, + Email: envEmail, + Token: envToken, + IssueKey: issueKey, + } + + jira := getJiraInfo(config) + + if jira.AuthFailure { + logger.Errorf("Jira authentication failed") + + comment := "**Jira Authentication Failed**\n\n" + + "Unable to authenticate with Jira to fetch issue information. " + + "Please verify that the Jira credentials (URL, email, and token) are correctly configured and that the token has not expired.\n\n" + + "**Possible solutions:**\n" + + "- Check that `OPT_JIRA_URL`, `OPT_JIRA_EMAIL`, and `OPT_JIRA_TOKEN` environment variables are set correctly\n" + + "- Verify that the Jira API token is still valid\n" + + "- Ensure the Jira user has permission to access the issue: `" + issueKey + "`" + + gh.AddPRComment(comment) + return + } + + newPRTitle := gh.ApplyFormatting(issueKey, jira.Title) + + if jira.ParentPrefix != "" { + newPRTitle = fmt.Sprintf("[%s]%s", jira.ParentPrefix, newPRTitle) + } + + if jiraDescriptionSyncEnabled() { + gh.UpdatePR(newPRTitle, jira.Description) + } else { + gh.UpdatePRTitle(newPRTitle) + } + + if jiraLabelSyncEnabled() { + gh.EnsureLabelExists(jiraLabelSyncName(), "Indicates that Jira synchronization has been completed for this PR", "0052cc") + gh.AddLabelToPR(jiraLabelSyncName()) + } +} + +func createJiraClient(jiraURL, jiraEmail, jiraToken string) (*v3.Client, error) { + client, err := v3.New(nil, jiraURL) + if err != nil { + return nil, err + } + + client.Auth.SetBasicAuth(jiraEmail, jiraToken) + return client, nil +} + +func getCurrentIssueInfo(client *v3.Client, issueKey string) (*models.IssueScheme, error) { + issue, response, err := client.Issue.Get(context.Background(), issueKey, nil, nil) + if err != nil { + isAuthFailure := response != nil && response.StatusCode == http.StatusUnauthorized + return nil, &JiraError{ + IsAuthFailure: isAuthFailure, + OriginalError: fmt.Errorf("failed to fetch Jira issue %s: %v", issueKey, err), + } + } + + return issue, nil +} + +func getParentIssuePrefix(client *v3.Client, issueKey string) (string, error) { + issue, _, err := client.Issue.Get(context.Background(), issueKey, nil, nil) + if err != nil { + return "", fmt.Errorf("failed to fetch Jira issue %s: %v", issueKey, err) + } + + if issue.Fields.Parent == nil { + return "", nil + } + + parentIssue, _, err := client.Issue.Get(context.Background(), issue.Fields.Parent.Key, nil, nil) + if err != nil { + return "", fmt.Errorf("failed to fetch parent Jira issue %s: %v", issue.Fields.Parent.Key, err) + } + + if strings.ToLower(parentIssue.Fields.IssueType.Name) == "epic" { + return "", nil + } + + return fmt.Sprintf("%s", issue.Fields.Parent.Key), nil +} + +func getJiraInfo(config Configuration) Information { + if !config.Enable { + return Information{HasJiraInfo: false} + } + + if config.URL == "" || config.Email == "" || config.Token == "" { + logger.Error("OPT_JIRA_URL, OPT_JIRA_EMAIL, and OPT_JIRA_TOKEN must be set when configured strategy is 'jira'.") + return Information{HasJiraInfo: false} + } + + client, err := createJiraClient(config.URL, config.Email, config.Token) + if err != nil { + logger.Errorf("Failed to create Jira client: %v", err) + return Information{HasJiraInfo: false} + } + + result := Information{HasJiraInfo: true} + + jiraIssue, err := getCurrentIssueInfo(client, config.IssueKey) + if err != nil { + logger.Errorf("Failed to get current issue info: %v", err) + var jiraErr *JiraError + if errors.As(err, &jiraErr) && jiraErr.IsAuthFailure { + return Information{HasJiraInfo: false, AuthFailure: true} + } + + return Information{HasJiraInfo: false} + } + result.Title = jiraIssue.Fields.Summary + + // Get parent issue prefix if applicable + parentPrefix, err := getParentIssuePrefix(client, config.IssueKey) + if err != nil { + logger.Errorf("Failed to get parent issue info: %v", err) + // Don't fail completely, just continue without parent prefix + } else { + result.ParentPrefix = parentPrefix + } + + if jiraIssue.Fields.Description != nil { + result.Description = jiraIssue.Fields.Description.Text + } + + return result +} + +func jiraLabelSyncEnabled() bool { + return strings.ToLower(os.Getenv("OPT_ENABLE_JIRA_SYNC_LABEL")) == "true" +} + +func jiraDescriptionSyncEnabled() bool { + return strings.ToLower(os.Getenv("OPT_ENABLE_JIRA_SYNC_DESCRIPTION")) == "true" +} + +func jiraLabelSyncName() string { + label := os.Getenv("OPT_JIRA_SYNC_LABEL") + + if label == "" { + return "jira-sync-complete" + } + + return label +} diff --git a/actions/github/enrichPullRequest/go.mod b/actions/github/enrichPullRequest/go.mod new file mode 100644 index 0000000..afa7bd2 --- /dev/null +++ b/actions/github/enrichPullRequest/go.mod @@ -0,0 +1,35 @@ +module github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest + +go 1.24.1 + +require ( + github.com/EncoreDigitalGroup/golib v0.1.1 + github.com/ctreminiom/go-atlassian v1.6.1 + github.com/google/go-github/v70 v70.0.0 + golang.org/x/oauth2 v0.28.0 + golang.org/x/text v0.23.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/log v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/actions/github/enrichPullRequest/go.sum b/actions/github/enrichPullRequest/go.sum new file mode 100644 index 0000000..f3a4fb3 --- /dev/null +++ b/actions/github/enrichPullRequest/go.sum @@ -0,0 +1,79 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/EncoreDigitalGroup/golib v0.1.1 h1:vYcn6phvp1IQnbsczX7SeHLRuvoc0q1A+v6tagRY8KY= +github.com/EncoreDigitalGroup/golib v0.1.1/go.mod h1:CxaCQZp09pWRXqI89reWl/e2qqJbPNteS5NvNDI/3m0= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk= +github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/ctreminiom/go-atlassian v1.6.1 h1:thH/oaWlvWLN5a4AcgQ30yPmnn0mQaTiqsq1M6bA9BY= +github.com/ctreminiom/go-atlassian v1.6.1/go.mod h1:dd5M0O8Co3bALyLQqWxPXoBfQNr6FFlpzUrA19IpLEo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o= +github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/actions/github/enrichPullRequest/main.go b/actions/github/enrichPullRequest/main.go new file mode 100644 index 0000000..01e9aa7 --- /dev/null +++ b/actions/github/enrichPullRequest/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "strconv" + "strings" + + "github.com/EncoreDigitalGroup/golib/logger" + + "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/drivers" + branchname "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/drivers/branch_name" + "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/drivers/jira" + "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/support/github" +) + +var gh github.GitHub + +const envGHRepository = "GH_REPOSITORY" +const envPRNumber = "PR_NUMBER" +const envStrategy = "OPT_FMT_STRATEGY" + +// Retrieve environment variables +var strategy = os.Getenv(envStrategy) +var repo = os.Getenv(envGHRepository) +var prNumberStr = os.Getenv(envPRNumber) +var parts = strings.Split(repo, "/") + +// Main function to execute the program +func main() { + validStrategy := drivers.Validate(strategy) + + if !validStrategy { + logger.Errorf("Invalid strategy: %s", strategy) + os.Exit(1) + } + + checkEnvVars() + repoOwner := parts[0] + repoName := parts[1] + + // Convert PR_NUMBER to integer + prNumber, err := strconv.Atoi(prNumberStr) + if err != nil { + logger.Errorf(envPRNumber+" is not a valid integer: %v", err) + os.Exit(1) + } + + gh = github.New(repoOwner, repoName, prNumber) + + if strategy == drivers.BranchName { + branchname.Format(gh) + } + + if strategy == drivers.Jira { + jira.Format(gh) + } +} + +func checkEnvVars() { + isMissingVar := false + if strategy == "" { + logger.Error(envStrategy + " environment variable is not set") + isMissingVar = true + } + + if repo == "" { + logger.Error(envGHRepository + " environment variable is not set") + isMissingVar = true + } + + if prNumberStr == "" { + logger.Error(envPRNumber + " environment variable is not set") + isMissingVar = true + } + + if len(parts) != 2 { + logger.Error(envGHRepository + " must be in the format owner/repo") + isMissingVar = true + } + + if isMissingVar { + os.Exit(1) + } +} diff --git a/actions/github/enrichPullRequest/support/github/github.go b/actions/github/enrichPullRequest/support/github/github.go new file mode 100644 index 0000000..16ccb6e --- /dev/null +++ b/actions/github/enrichPullRequest/support/github/github.go @@ -0,0 +1,290 @@ +package github + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/EncoreDigitalGroup/golib/logger" + "github.com/google/go-github/v70/github" + "golang.org/x/oauth2" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const envGHToken = "GH_TOKEN" +const envBranchName = "BRANCH_NAME" + +// GitHub interface defines the contract for GitHub operations +type GitHub interface { + GetBranchName() (string, error) + BranchNameMatchesPRTitle(currentPRTitle string) bool + GetPRInformation() *github.PullRequest + UpdatePR(newPRTitle string, newPRDescription string) + UpdatePRTitle(newPRTitle string) + ApplyFormatting(issueKey string, issueName string) string + HasLabel(labelName string) bool + AddLabelToPR(labelName string) + EnsureLabelExists(labelName string, description string, color string) + AddPRComment(comment string) +} + +// GitHubClient implements the GitHub interface +type GitHubClient struct { + client *github.Client + repositoryOwner string + repositoryName string + pullRequestNumber int + pullRequestInfo *github.PullRequest +} + +var ( + client *github.Client + once sync.Once +) + +func New(repoOwner string, repoName string, prNumber int) GitHub { + once.Do(func() { + githubToken := os.Getenv(envGHToken) + + if githubToken == "" { + logger.Error(envGHToken + " is not set") + os.Exit(1) + } + + ctx := context.Background() + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: githubToken}) + tc := oauth2.NewClient(ctx, ts) + client = github.NewClient(tc) + }) + + return &GitHubClient{ + client: client, + repositoryOwner: repoOwner, + repositoryName: repoName, + pullRequestNumber: prNumber, + pullRequestInfo: nil, + } +} + +func (gh *GitHubClient) GetBranchName() (string, error) { + branchName := os.Getenv(envBranchName) + + if branchName == "" { + logger.Error(envBranchName + " is not set") + return "", fmt.Errorf("%s is not set", envBranchName) + } + + return branchName, nil +} + +func (gh *GitHubClient) BranchNameMatchesPRTitle(currentPRTitle string) bool { + pullRequest, _, err := gh.client.PullRequests.Get(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber) + if err != nil { + logger.Errorf("Failed to get pullRequest request: %v", err) + return false + } + + if currentPRTitle == *pullRequest.Title { + logger.Info("Pull Request Titles Match; No Need to Update.") + return true + } + + logger.Info("Pull Request Titles Do Not Match; Update Needed.") + return false +} + +func (gh *GitHubClient) GetPRInformation() *github.PullRequest { + if gh.pullRequestInfo != nil { + return gh.pullRequestInfo + } + + pullRequestInformation, _, err := gh.client.PullRequests.Get(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber) + if err != nil { + logger.Errorf("Failed to get pullRequest information: %v", err) + os.Exit(1) + } + + gh.pullRequestInfo = pullRequestInformation + + return gh.pullRequestInfo +} + +func (gh *GitHubClient) UpdatePRTitle(newPRTitle string) { + logger.Infof("Attempting to Update Pull Request Title to: %s", newPRTitle) + + _, _, err := gh.client.PullRequests.Edit(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber, &github.PullRequest{ + Title: &newPRTitle, + }) + + if err != nil { + logger.Errorf("Failed to update pull request prTitle: %v", err) + return + } + + logger.Infof("Updated Pull Request Title to: %s", newPRTitle) +} + +func (gh *GitHubClient) processDescriptionWithMarkers(existingBody string, newPRDescription string) string { + const jiraStartMarker = "" + const jiraEndMarker = "" + + if existingBody != "" { + // Check if Jira markers already exist + startIndex := strings.Index(existingBody, jiraStartMarker) + endIndex := strings.Index(existingBody, jiraEndMarker) + + if startIndex != -1 && endIndex != -1 && endIndex > startIndex { + // Both markers exist - replace content between them + beforeJira := existingBody[:startIndex] + afterJira := existingBody[endIndex+len(jiraEndMarker):] + return strings.TrimSpace(beforeJira) + "\n\n" + jiraStartMarker + "\n" + newPRDescription + "\n" + jiraEndMarker + strings.TrimSpace(afterJira) + } else { + // Markers don't exist or are malformed - append with markers + return existingBody + "\n\n" + jiraStartMarker + "\n" + newPRDescription + "\n" + jiraEndMarker + } + } else { + // No existing description - add markers and Jira description + return jiraStartMarker + "\n" + newPRDescription + "\n" + jiraEndMarker + } +} + +func (gh *GitHubClient) UpdatePR(newPRTitle string, newPRDescription string) { + pullRequestInformation := gh.GetPRInformation() + + var existingBody string + if pullRequestInformation.Body != nil { + existingBody = *pullRequestInformation.Body + } + + finalDescription := gh.processDescriptionWithMarkers(existingBody, newPRDescription) + + logger.Infof("Attempting to Update Pull Request Title to: %s", newPRTitle) + + _, _, err := gh.client.PullRequests.Edit(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber, &github.PullRequest{ + Title: &newPRTitle, + Body: &finalDescription, + }) + + if err != nil { + logger.Errorf("Failed to update pull request: %v", err) + } else { + logger.Infof("Updated Pull Request Title to: %s", newPRTitle) + logger.Info("Updated Pull Request Description") + } +} + +func (gh *GitHubClient) ApplyFormatting(issueKey string, issueName string) string { + // Replace hyphens with spaces and capitalize each word + formattedIssueName := strings.ReplaceAll(issueName, "-", " ") + titleCaser := cases.Title(language.English) + formattedIssueName = titleCaser.String(formattedIssueName) + + defaultExceptions := map[string]string{ + "Api": "API", + "Css": "CSS", + "Db": "DB", + "Html": "HTML", + "Rest": "REST", + "Rockrms": "RockRMS", + "Mpc": "MPC", + "Myportal": "MyPortal", + "Pco": "PCO", + "Php": "PHP", + "Phpstan": "PHPStan", + "Servicepoint": "ServicePoint", + "Themekit": "ThemeKit", + "Uri": "URI", + "Webcms": "WebCMS", + "Webui": "WebUI", + } + + if userDefinedExceptions := os.Getenv("OPT_FMT_WORDS"); userDefinedExceptions != "" { + pairs := strings.Split(userDefinedExceptions, ",") + for _, pair := range pairs { + kv := strings.SplitN(pair, ":", 2) + if len(kv) == 2 { + key := strings.TrimSpace(kv[0]) + value := strings.TrimSpace(kv[1]) + defaultExceptions[key] = value + } + } + } + words := strings.Fields(formattedIssueName) + for i, word := range words { + if val, ok := defaultExceptions[word]; ok { + words[i] = val + } + } + formattedIssueName = strings.Join(words, " ") + + return fmt.Sprintf("[%s] %s", issueKey, formattedIssueName) +} + +func (gh *GitHubClient) HasLabel(labelName string) bool { + pullRequestInformation := gh.GetPRInformation() + + for _, label := range pullRequestInformation.Labels { + if label.Name != nil && *label.Name == labelName { + return true + } + } + + return false +} + +func (gh *GitHubClient) EnsureLabelExists(labelName string, description string, color string) { + _, _, err := gh.client.Issues.GetLabel(context.Background(), gh.repositoryOwner, gh.repositoryName, labelName) + if err == nil { + // Label already exists + return + } + + label := &github.Label{ + Name: &labelName, + Description: &description, + Color: &color, + } + + _, _, err = gh.client.Issues.CreateLabel(context.Background(), gh.repositoryOwner, gh.repositoryName, label) + if err != nil { + logger.Errorf("Failed to create label '%s': %v", labelName, err) + prComment := "The label `" + labelName + "` does not exist in this repository and we encountered an " + + "error when attempting to create it.\n\n" + + "Please ensure the access token provided has permission to manage labels." + + gh.AddPRComment(prComment) + + } else { + logger.Infof("Created label '%s' in repository", labelName) + } +} + +func (gh *GitHubClient) AddLabelToPR(labelName string) { + labels := []string{labelName} + + _, _, err := gh.client.Issues.AddLabelsToIssue(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber, labels) + if err != nil { + logger.Errorf("Failed to add label '%s' to PR: %v", labelName, err) + prComment := "We failed to add the `" + labelName + "` label to this PR.\n\n" + + "Please ensure the access token provided has permission to manage labels." + gh.AddPRComment(prComment) + } else { + logger.Infof("Added label '%s' to PR #%d", labelName, gh.pullRequestNumber) + } +} + +func (gh *GitHubClient) AddPRComment(comment string) { + issueComment := &github.IssueComment{ + Body: &comment, + } + + _, _, err := gh.client.Issues.CreateComment(context.Background(), gh.repositoryOwner, gh.repositoryName, gh.pullRequestNumber, issueComment) + if err != nil { + logger.Errorf("Failed to add comment to PR: %v", err) + } else { + logger.Infof("Added comment to PR #%d", gh.pullRequestNumber) + } +} diff --git a/docs/Actions/GitHub/enrichPullRequest.md b/docs/Actions/GitHub/enrichPullRequest.md new file mode 100644 index 0000000..f4c0ab3 --- /dev/null +++ b/docs/Actions/GitHub/enrichPullRequest.md @@ -0,0 +1,368 @@ +# GitHub Enrich Pull Request Action + +## Overview + +The `actions/github/enrichPullRequest` action automatically enriches pull request titles and descriptions with information from project management systems. It supports +multiple strategies including branch name parsing and Jira integration, providing enhanced PR context and consistency across development workflows. + +## Language/Tool Support + +- **GitHub**: Pull request management and API integration +- **Jira**: Issue tracking and project management integration +- **Branch Naming**: Automatic parsing of branch name conventions + +## Features + +- **Multiple Enrichment Strategies**: Support for branch-name and Jira strategies +- **Automatic PR Title Formatting**: Converts branch names and issue keys to readable titles +- **Jira Integration**: Syncs Jira issue titles and descriptions to pull requests +- **Custom Formatting Rules**: User-defined formatting preferences +- **Label Management**: Automatic label creation and assignment for Jira sync tracking +- **Parent Issue Support**: Includes parent issue prefixes for hierarchical issues + +## Usage + +```yaml +- name: Enrich Pull Request + uses: EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest@v3 + with: + repository: ${{ github.repository }} + pullRequestNumber: ${{ github.event.number }} + branch: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + strategy: "branch-name" + customFormatting: "api:API,ui:User Interface" +``` + +## Inputs + +| Input | Type | Required | Default | Description | +|-----------------------------|---------|----------|------------------------|--------------------------------------------------------| +| `repository` | string | ✅ | - | GitHub repository in format "owner/repo" | +| `pullRequestNumber` | string | ✅ | - | Pull request number to update | +| `branch` | string | ✅ | - | Branch name to parse for enrichment | +| `token` | string | ✅ | - | GitHub token with pull request write permissions | +| `strategy` | string | ❌ | `"branch-name"` | Enrichment strategy: "branch-name" or "jira" | +| `customFormatting` | string | ❌ | `""` | Custom word formatting rules (comma-separated pairs) | +| `jiraURL` | string | ❌ | `""` | URL to your Jira instance (required for jira strategy) | +| `jiraEmail` | string | ❌ | `""` | Jira authentication email (required for jira strategy) | +| `jiraToken` | string | ❌ | `""` | Jira authentication token (required for jira strategy) | +| `jiraEnableSyncLabel` | boolean | ❌ | `true` | Create and assign sync completion label | +| `jiraEnableSyncDescription` | boolean | ❌ | `true` | Sync Jira description to PR description | +| `jiraSyncLabelName` | string | ❌ | `"jira-sync-complete"` | Name of the sync completion label | + +## Action Implementation + +This action runs in a Docker container: + +- **Image**: `ghcr.io/encoredigitalgroup/gh-action-enrich-pull-request:latest` +- **Environment**: All inputs are passed as environment variables +- **Execution**: Containerized Go application with multiple strategy drivers + +## Enrichment Strategies + +### Branch Name Strategy + +Extracts issue keys and descriptions from branch names using regex patterns: + +**Supported Branch Patterns:** + +- `(epic|feature|bugfix|hotfix)/[A-Z]+-[0-9]+-summary` +- `[A-Z]+-[0-9]+-summary` + +**Examples:** + +- `feature/PROJ-123-user-authentication` → "[PROJ-123] User Authentication" +- `bugfix/ISSUE-456-login-fix` → "[ISSUE-456] Login Fix" +- `TASK-789-api-improvements` → "[TASK-789] API Improvements" + +### Jira Strategy + +Integrates with Jira to fetch issue information and enrich PRs: + +**Features:** + +- Fetches issue title from Jira +- Optionally syncs issue description to PR (enabled by default) +- Adds parent issue prefixes for subtasks +- Creates sync completion labels (enabled by default) +- Prevents duplicate syncing + +## Usage Examples + +### Basic Branch Name Enrichment + +```yaml +name: Enrich PR with Branch Info +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + enrich-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Enrich Pull Request + uses: EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest@v3 + with: + repository: ${{ github.repository }} + pullRequestNumber: ${{ github.event.number }} + branch: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + strategy: "branch-name" +``` + +### Jira Integration + +```yaml +name: Enrich PR with Jira +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + enrich-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Enrich with Jira Info + uses: EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest@v3 + with: + repository: ${{ github.repository }} + pullRequestNumber: ${{ github.event.number }} + branch: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + strategy: "jira" + jiraURL: ${{ vars.JIRA_URL }} + jiraEmail: ${{ vars.JIRA_EMAIL }} + jiraToken: ${{ secrets.JIRA_TOKEN }} + jiraEnableSyncLabel: true + jiraEnableSyncDescription: true +``` + +### Custom Formatting Rules + +```yaml +name: Enrich with Custom Formatting +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + enrich-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Enrich with Custom Rules + uses: EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest@v3 + with: + repository: ${{ github.repository }} + pullRequestNumber: ${{ github.event.number }} + branch: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + strategy: "branch-name" + customFormatting: "api:API Integration,ui:User Interface,db:Database Operations,auth:Authentication" +``` + +## Integration Patterns + +### Complete PR Workflow + +```yaml +name: PR Quality Gate +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + enrich: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Enrich PR Information + uses: EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest@v3 + with: + repository: ${{ github.repository }} + pullRequestNumber: ${{ github.event.number }} + branch: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + strategy: "jira" + jiraURL: ${{ vars.JIRA_URL }} + jiraEmail: ${{ vars.JIRA_EMAIL }} + jiraToken: ${{ secrets.JIRA_TOKEN }} + + validate: + needs: enrich + runs-on: ubuntu-latest + steps: + - name: Validate PR + run: echo "Running validation..." + + test: + needs: enrich + runs-on: ubuntu-latest + steps: + - name: Run Tests + run: echo "Running tests..." +``` + +## Branch Name Patterns + +### Supported Formats + +The branch-name strategy recognizes these patterns: + +**With Issue Type Prefix:** + +``` +epic/PROJ-123-epic-title +feature/PROJ-456-new-feature +bugfix/PROJ-789-bug-description +hotfix/PROJ-101-critical-fix +``` + +**Without Issue Type Prefix:** + +``` +PROJ-123-task-description +ISSUE-456-improvement-title +TICKET-789-maintenance-task +``` + +### Pattern Matching + +- **Issue Key**: `[A-Z]+-[0-9]+` (e.g., PROJ-123, ISSUE-456) +- **Description**: Hyphen-separated words after issue key +- **Type Prefixes**: epic, feature, bugfix, hotfix (optional) + +## Jira Integration Details + +### Authentication + +```yaml +# Using organization variables and secrets +jiraURL: ${{ vars.JIRA_URL }} # https://yourorg.atlassian.net +jiraEmail: ${{ vars.JIRA_EMAIL }} # someone@yourcompany.com +jiraToken: ${{ secrets.JIRA_TOKEN }} # Jira API token or PAT +``` + +### Issue Information + +The Jira strategy fetches and uses: + +- **Issue Summary**: Used as PR title +- **Issue Description**: Optionally synced to PR description +- **Parent Issue**: Added as prefix for subtasks (excluding epics) +- **Issue Status**: Used for validation + +### Label Management + +When `jiraEnableSyncLabel: true`: + +- Creates label if it doesn't exist +- Label description: "Indicates that Jira synchronization has been completed for this PR" +- Prevents duplicate syncing on subsequent runs + +## Required Permissions + +The GitHub token must have the following permissions: + +```yaml +permissions: + pull-requests: write # Required for updating PR title/description and labels + issues: write # Required for creating the sync label if it doesn't exist + contents: read # Required for accessing repository and branch information +``` + +## Custom Formatting Rules + +Define custom word formatting with key-value pairs: + +### Format + +``` +"key1:value1,key2:value2,key3:value3" +``` + +### Examples + +```yaml +# Technical abbreviations +customFormatting: "api:API,ui:UI,db:Database,auth:Authentication,ci:Continuous Integration" + +# Business domains +customFormatting: "crm:Customer Relations,hr:Human Resources,fin:Finance,ops:Operations" + +# Component names +customFormatting: "comp:Component,svc:Service,lib:Library,util:Utility,cfg:Configuration" +``` + +## Troubleshooting + +### Common Issues + +**PR Not Updated** + +- Verify token has `pull-requests: write` permission +- Check pull request number is correct +- Ensure branch name matches expected patterns + +**Jira Connection Issues** + +- Verify Jira URL format: `https://yourorg.atlassian.net` +- Check Jira token permissions +- Ensure issue key exists in Jira + +**Branch Pattern Mismatch** + +- Verify branch name follows supported patterns +- Check regex matching in action logs +- Test with simpler branch names + +### Error Resolution + +**Authentication Failed** + +```yaml +# Ensure proper Jira authentication +jiraToken: ${{ secrets.JIRA_API_TOKEN }} # Use API token, not password +``` + +**Issue Not Found** + +```bash +# Verify issue exists and is accessible +curl -H "Authorization: Bearer $JIRA_TOKEN" \ + "https://yourorg.atlassian.net/rest/api/3/issue/PROJ-123" +``` + +**Permission Denied** + +```yaml +# Ensure proper GitHub permissions +permissions: + pull-requests: write + issues: write + contents: read +``` + +## Best Practices + +### Branch Naming Conventions + +1. **Consistent Prefixes**: Use standard issue type prefixes +2. **Issue Keys**: Always include project issue keys +3. **Descriptive Names**: Use clear, hyphen-separated descriptions +4. **Lowercase**: Use lowercase for consistency + +### Jira Integration + +1. **Service Account**: Create a service account in your Jira system dedicated to integrations. +2. **API Tokens**: Use Jira API tokens instead of passwords +3. **Issue Templates**: Maintain consistent issue title formats +4. **Parent Relationships**: Properly structure issue hierarchies \ No newline at end of file