From 6df489a72255591faf096f8b0f0200e4ffbf4092 Mon Sep 17 00:00:00 2001 From: lghiur Date: Fri, 20 Jun 2025 23:02:49 +0300 Subject: [PATCH 1/2] new jira lint --- .github/actions/jira-lint/README.md | 153 +++++++++++ .github/actions/jira-lint/action.yml | 60 +++++ .github/actions/jira-lint/go.mod | 16 ++ .github/actions/jira-lint/main.go | 385 +++++++++++++++++++++++++++ .github/workflows/jira-lint.yaml | 25 +- 5 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 .github/actions/jira-lint/README.md create mode 100644 .github/actions/jira-lint/action.yml create mode 100644 .github/actions/jira-lint/go.mod create mode 100644 .github/actions/jira-lint/main.go diff --git a/.github/actions/jira-lint/README.md b/.github/actions/jira-lint/README.md new file mode 100644 index 00000000..198c65be --- /dev/null +++ b/.github/actions/jira-lint/README.md @@ -0,0 +1,153 @@ +# Reliable Jira Issue Validation Action + +A Go-based GitHub Action that provides reliable validation of Jira issue IDs in pull requests with improved state checking and comment management. This implementation leverages existing tools from the Tyk ecosystem for maximum reliability and consistency. + +## Key Features + +### 🔧 **Integration with Existing Tools** +- **Uses existing jira-lint**: Leverages `/Users/laurentiughiur/go/src/github.com/TykTechnologies/exp/cmd/jira-cli` for Jira validation +- **Reuses create-update-comment workflow**: Uses `.github/workflows/create-update-comment.yaml` for consistent comment management +- **No duplicate dependencies**: Builds on proven tools already in your ecosystem + +### 🔍 **Comprehensive Validation** +- **PR Title Validation** (mandatory): Ensures PR titles contain valid Jira issue IDs +- **Branch Name Validation**: Checks for Jira issue IDs in branch names +- **PR Body Validation**: Optionally validates Jira issue references in PR descriptions +- **Consistency Checking**: Ensures matching Jira IDs across title, branch, and body + +### 🎯 **Supported Jira Issue Patterns** +- `[A-Z]{2}-[0-9]{4,5}` - Standard format (e.g., TT-0000 through TT-99999) +- `SYSE-[0-9]+` - Specific project patterns (e.g., SYSE-339) +- `[A-Z]+-[0-9]+` - General project patterns + +### 🚀 **Reliability Improvements** +- **No Duplicate Comments**: Uses existing create-update-comment workflow +- **Reliable State Validation**: Uses the proven jira-cli tool for consistent validation +- **Fresh Validation**: No caching issues - validates state on each run +- **Consistent Interface**: Maintains same interface as previous 3rd party actions + +## Architecture + +### Two-Job Workflow Design + +The implementation uses a two-job workflow pattern to properly integrate with existing tools: + +1. **jira-validation job**: Runs the Go action to validate Jira issues +2. **create-comment job**: Uses the existing `create-update-comment.yaml` workflow + +This separation allows us to: +- Use the existing comment management workflow +- Maintain clean separation of concerns +- Follow established patterns in your codebase + +### Integration with jira-lint + +The action uses the existing `jira-lint` tool for Jira validation: + +1. **Installation**: Automatically installs jira-lint via `go install github.com/TykTechnologies/exp/cmd/jira-lint@main` +2. **Environment Setup**: Configures required environment variables: + - `JIRA_API_TOKEN` + - `JIRA_API_EMAIL` + - `JIRA_API_URL` +3. **Validation**: Executes jira-lint for each detected issue +4. **Status Checking**: Uses jira-lint's built-in status validation against allowed states: + - `In Dev` + - `In Code Review` + - `Ready for Testing` + - `In Test` + - `In Progress` + - `In Review` + +### Comment Management + +Uses the existing `create-update-comment.yaml` workflow: + +1. **Validation Output**: The action outputs a validation report +2. **Workflow Integration**: The main workflow passes the report to create-update-comment +3. **No Duplicates**: Leverages the existing logic to update rather than create new comments + +## Usage + +### Workflow Integration + +```yaml +name: JIRA linter + +on: + workflow_call: + secrets: + JIRA_TOKEN: + required: true + JIRA_EMAIL: + required: true + ORG_GH_TOKEN: + required: true + +jobs: + jira-validation: + runs-on: ubuntu-latest + outputs: + validation-report: ${{ steps.validate.outputs.report }} + validation-status: ${{ steps.validate.outputs.status }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Jira Validation + id: validate + uses: ./.github/actions/jira-lint + with: + github-token: ${{ secrets.ORG_GH_TOKEN }} + jira-token: ${{ secrets.JIRA_TOKEN }} + jira-email: ${{ secrets.JIRA_EMAIL }} + jira-base-url: https://tyktech.atlassian.net + skip-branches: '^(release-[0-9.-]+(lts)?|master|main)$' + validate_issue_status: true + skip-comments: true + + create-comment: + needs: jira-validation + if: ${{ needs.jira-validation.outputs.validation-report != '' }} + uses: ./.github/workflows/create-update-comment.yaml + with: + body-includes: "" + body: ${{ needs.jira-validation.outputs.validation-report }} +``` + +## Input Parameters + +| Parameter | Description | Required | Default | +|-----------|-------------|----------|---------| +| `github-token` | GitHub token for API access | ✅ | - | +| `jira-token` | Jira API token | ✅ | - | +| `jira-email` | Jira API email | ✅ | - | +| `jira-base-url` | Jira base URL (e.g., https://tyktech.atlassian.net) | ✅ | - | +| `skip-branches` | Regex pattern for branches to skip validation | ❌ | `''` | +| `skip-comments` | Skip adding lint comments for PR title | ❌ | `false` | +| `validate_issue_status` | Validate Jira issue status using jira-lint | ❌ | `false` | + +## Validation Logic + +### 1. Title Validation (Required) +- ✅ **Pass**: PR title contains valid Jira issue ID +- ❌ **Fail**: No Jira issue ID found in title + +### 2. Branch Validation (Optional) +- ✅ **Pass**: Branch name contains Jira issue ID +- ⚠️ **Warning**: No Jira issue ID in branch name + +### 3. Consistency Check +- ✅ **Pass**: Same Jira issue ID across title and branch +- ❌ **Fail**: Different Jira issue IDs in title vs branch + +### 4. Status Validation (Optional) +When `validate_issue_status: true`: +- ✅ **Pass**: jira-lint validation succeeds (issue exists and status is allowed) +- ❌ **Fail**: jira-lint validation fails (issue doesn't exist or status not allowed) + +## Example Validation Report + +```markdown +## 🔍 Jira Issue Validation Report + +### PR Title \ No newline at end of file diff --git a/.github/actions/jira-lint/action.yml b/.github/actions/jira-lint/action.yml new file mode 100644 index 00000000..83339c86 --- /dev/null +++ b/.github/actions/jira-lint/action.yml @@ -0,0 +1,60 @@ +name: 'Reliable Jira Issue Validation' +description: 'Validates GitHub PR elements against Jira issue IDs with reliable state checking and comment management' + +inputs: + github-token: + description: 'GitHub token for API access' + required: true + jira-token: + description: 'Base64 encoded Jira token (username:api_token)' + required: true + jira-base-url: + description: 'Jira base URL (e.g., https://your-domain.atlassian.net)' + required: true + skip-branches: + description: 'Regex pattern for branches to skip validation' + required: false + default: '' + skip-comments: + description: 'Skip adding lint comments for PR title' + required: false + default: 'false' + validate_issue_status: + description: 'Validate Jira issue status' + required: false + default: 'false' + +outputs: + validation-report: + description: 'The validation report markdown' + value: ${{ steps.validate.outputs.report }} + validation-status: + description: 'The validation status (success/failure)' + value: ${{ steps.validate.outputs.status }} + +runs: + using: 'composite' + steps: + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install jira-lint + shell: bash + run: | + go install github.com/TykTechnologies/exp/cmd/jira-lint@main + + - name: Run Jira Validation + id: validate + shell: bash + run: | + cd ${{ github.action_path }} + go run main.go + env: + INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_JIRA_TOKEN: ${{ inputs.jira-token }} + INPUT_JIRA_BASE_URL: ${{ inputs.jira-base-url }} + INPUT_SKIP_BRANCHES: ${{ inputs.skip-branches }} + INPUT_SKIP_COMMENTS: ${{ inputs.skip-comments }} + INPUT_VALIDATE_ISSUE_STATUS: ${{ inputs.validate_issue_status }} \ No newline at end of file diff --git a/.github/actions/jira-lint/go.mod b/.github/actions/jira-lint/go.mod new file mode 100644 index 00000000..61da9de9 --- /dev/null +++ b/.github/actions/jira-lint/go.mod @@ -0,0 +1,16 @@ +module jira-lint + +go 1.21 + +require ( + github.com/google/go-github/v57 v57.0.0 + golang.org/x/oauth2 v0.15.0 +) + +require ( + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect + golang.org/x/net v0.19.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) \ No newline at end of file diff --git a/.github/actions/jira-lint/main.go b/.github/actions/jira-lint/main.go new file mode 100644 index 00000000..399c309c --- /dev/null +++ b/.github/actions/jira-lint/main.go @@ -0,0 +1,385 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// JiraValidator handles all Jira validation logic +type JiraValidator struct { + jiraToken string + jiraEmail string + jiraBaseURL string + skipBranches string + validateIssueStatus bool +} + +// ValidationResult holds the result of Jira issue validation +type ValidationResult struct { + Valid bool + IssueKey string + Error string +} + +// PullRequest represents the GitHub PR data we need +type PullRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Number int `json:"number"` + Head struct { + Ref string `json:"ref"` + } `json:"head"` +} + +// Repository represents the GitHub repository data +type Repository struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` +} + +// GitHubEvent represents the GitHub event payload +type GitHubEvent struct { + PullRequest *PullRequest `json:"pull_request"` + Repository *Repository `json:"repository"` +} + +// Jira issue patterns +var jiraPatterns = []*regexp.Regexp{ + regexp.MustCompile(`[A-Z]{2}-[0-9]{4,5}`), // TT-0000 through TT-99999 + regexp.MustCompile(`SYSE-[0-9]+`), // SYSE-339 pattern + regexp.MustCompile(`[A-Z]+-[0-9]+`), // General pattern for other projects +} + +// decodeJiraToken decodes the base64 encoded jira token and extracts email and token +func decodeJiraToken(encodedToken string) (email, token string, err error) { + decoded, err := base64.StdEncoding.DecodeString(encodedToken) + if err != nil { + return "", "", fmt.Errorf("failed to decode base64 token: %v", err) + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid token format, expected email:token") + } + + return parts[0], parts[1], nil +} + +// NewJiraValidator creates a new JiraValidator instance +func NewJiraValidator() (*JiraValidator, error) { + validateIssueStatus, _ := strconv.ParseBool(os.Getenv("INPUT_VALIDATE_ISSUE_STATUS")) + + // Decode the base64 encoded JIRA token + encodedToken := os.Getenv("INPUT_JIRA_TOKEN") + email, token, err := decodeJiraToken(encodedToken) + if err != nil { + return nil, fmt.Errorf("failed to decode JIRA token: %v", err) + } + + return &JiraValidator{ + jiraToken: token, + jiraEmail: email, + jiraBaseURL: os.Getenv("INPUT_JIRA_BASE_URL"), + skipBranches: os.Getenv("INPUT_SKIP_BRANCHES"), + validateIssueStatus: validateIssueStatus, + }, nil +} + +// extractJiraIssues extracts Jira issue IDs from text +func (jv *JiraValidator) extractJiraIssues(text string) []string { + if text == "" { + return []string{} + } + + issuesMap := make(map[string]bool) + for _, pattern := range jiraPatterns { + matches := pattern.FindAllString(text, -1) + for _, match := range matches { + issuesMap[strings.ToUpper(match)] = true + } + } + + issues := make([]string, 0, len(issuesMap)) + for issue := range issuesMap { + issues = append(issues, issue) + } + return issues +} + +// validateJiraIssueWithCLI validates a Jira issue using the existing jira-lint tool +func (jv *JiraValidator) validateJiraIssueWithCLI(issueKey string) ValidationResult { + // Set environment variables for jira-lint + env := os.Environ() + env = append(env, fmt.Sprintf("JIRA_API_TOKEN=%s", jv.jiraToken)) + env = append(env, fmt.Sprintf("JIRA_API_EMAIL=%s", jv.jiraEmail)) + env = append(env, fmt.Sprintf("JIRA_API_URL=%s", jv.jiraBaseURL)) + + // Execute jira-lint command + cmd := exec.Command("jira-lint", issueKey) + cmd.Env = env + + output, err := cmd.CombinedOutput() + + if err != nil { + // Check if it's a validation error (non-zero exit code) + if _, ok := err.(*exec.ExitError); ok { + return ValidationResult{ + Valid: false, + IssueKey: issueKey, + Error: string(output), + } + } + // Other errors (command not found, etc.) + return ValidationResult{ + Valid: false, + IssueKey: issueKey, + Error: fmt.Sprintf("Failed to execute jira-lint: %v", err), + } + } + + // Success - issue is valid and in allowed state + return ValidationResult{ + Valid: true, + IssueKey: issueKey, + } +} + +// shouldSkipBranch checks if the branch should be skipped based on regex pattern +func (jv *JiraValidator) shouldSkipBranch(branchName string) bool { + if jv.skipBranches == "" { + return false + } + + regex, err := regexp.Compile(jv.skipBranches) + if err != nil { + fmt.Printf("::warning::Invalid skip-branches regex: %v\n", err) + return false + } + + return regex.MatchString(branchName) +} + +// generateValidationReport generates a markdown report of the validation results +func (jv *JiraValidator) generateValidationReport(titleIssues, branchIssues, bodyIssues []string, validationResults []ValidationResult) string { + report := "\n## 🔍 Jira Issue Validation Report\n\n" + + // Title validation + report += "### PR Title\n" + if len(titleIssues) == 0 { + report += "❌ **No Jira issue found in PR title** (Required)\n\n" + } else { + report += fmt.Sprintf("✅ Found Jira issue(s): %s\n\n", strings.Join(titleIssues, ", ")) + } + + // Branch validation + report += "### Branch Name\n" + if len(branchIssues) == 0 { + report += "⚠️ No Jira issue found in branch name\n\n" + } else { + report += fmt.Sprintf("✅ Found Jira issue(s): %s\n\n", strings.Join(branchIssues, ", ")) + } + + // Body validation + report += "### PR Body\n" + if len(bodyIssues) == 0 { + report += "⚠️ No Jira issue found in PR body\n\n" + } else { + report += fmt.Sprintf("✅ Found Jira issue(s): %s\n\n", strings.Join(bodyIssues, ", ")) + } + + // Consistency check + allIssues := make(map[string]bool) + for _, issue := range titleIssues { + allIssues[issue] = true + } + for _, issue := range branchIssues { + allIssues[issue] = true + } + for _, issue := range bodyIssues { + allIssues[issue] = true + } + + if len(allIssues) > 1 { + report += "### ⚠️ Consistency Warning\n" + report += "Multiple different Jira issues found across title, branch, and body. Please ensure consistency.\n\n" + } + + // Validation results + if len(validationResults) > 0 { + report += "### Jira Issue Details\n" + for _, result := range validationResults { + if result.Valid { + report += fmt.Sprintf("✅ **%s**: Valid issue and status\n", result.IssueKey) + } else { + report += fmt.Sprintf("❌ **%s**: %s\n", result.IssueKey, result.Error) + } + } + } + + return report +} + +// setGitHubOutput sets a GitHub Actions output +func setGitHubOutput(name, value string) { + fmt.Printf("::set-output name=%s::%s\n", name, value) +} + +// run executes the main validation logic +func (jv *JiraValidator) run() error { + // Get GitHub event data + eventPath := os.Getenv("GITHUB_EVENT_PATH") + if eventPath == "" { + return fmt.Errorf("GITHUB_EVENT_PATH not set") + } + + eventData, err := os.ReadFile(eventPath) + if err != nil { + return fmt.Errorf("failed to read event file: %v", err) + } + + var event GitHubEvent + if err := json.Unmarshal(eventData, &event); err != nil { + return fmt.Errorf("failed to parse event data: %v", err) + } + + if event.PullRequest == nil { + return fmt.Errorf("this action can only be run on pull requests") + } + + pr := event.PullRequest + + // Check if we should skip this branch + if jv.shouldSkipBranch(pr.Head.Ref) { + fmt.Printf("Skipping validation for branch: %s\n", pr.Head.Ref) + setGitHubOutput("status", "skipped") + return nil + } + + // Extract Jira issues from different sources + titleIssues := jv.extractJiraIssues(pr.Title) + branchIssues := jv.extractJiraIssues(pr.Head.Ref) + bodyIssues := jv.extractJiraIssues(pr.Body) + + fmt.Printf("Title issues: %s\n", strings.Join(titleIssues, ", ")) + fmt.Printf("Branch issues: %s\n", strings.Join(branchIssues, ", ")) + fmt.Printf("Body issues: %s\n", strings.Join(bodyIssues, ", ")) + + // Validate that PR title contains a Jira issue (mandatory) + if len(titleIssues) == 0 { + report := jv.generateValidationReport(titleIssues, branchIssues, bodyIssues, []ValidationResult{}) + setGitHubOutput("report", report) + setGitHubOutput("status", "failure") + return fmt.Errorf("PR title must contain a valid Jira issue ID") + } + + // Collect all unique issues + allIssuesMap := make(map[string]bool) + for _, issue := range titleIssues { + allIssuesMap[issue] = true + } + for _, issue := range branchIssues { + allIssuesMap[issue] = true + } + for _, issue := range bodyIssues { + allIssuesMap[issue] = true + } + + uniqueIssues := make([]string, 0, len(allIssuesMap)) + for issue := range allIssuesMap { + uniqueIssues = append(uniqueIssues, issue) + } + + // Validate each unique Jira issue using jira-lint + var validationResults []ValidationResult + for _, issueKey := range uniqueIssues { + if jv.validateIssueStatus { + result := jv.validateJiraIssueWithCLI(issueKey) + validationResults = append(validationResults, result) + } else { + // If status validation is disabled, assume all pattern-matched issues are valid + validationResults = append(validationResults, ValidationResult{ + Valid: true, + IssueKey: issueKey, + }) + } + } + + // Check if any issues are invalid (only if status validation is enabled) + if jv.validateIssueStatus { + var invalidIssues []ValidationResult + for _, result := range validationResults { + if !result.Valid { + invalidIssues = append(invalidIssues, result) + } + } + + if len(invalidIssues) > 0 { + report := jv.generateValidationReport(titleIssues, branchIssues, bodyIssues, validationResults) + setGitHubOutput("report", report) + setGitHubOutput("status", "failure") + + var invalidKeys []string + for _, result := range invalidIssues { + invalidKeys = append(invalidKeys, result.IssueKey) + } + return fmt.Errorf("invalid Jira issues found: %s", strings.Join(invalidKeys, ", ")) + } + } + + // Check for consistency issues + if len(branchIssues) > 0 && len(titleIssues) > 0 { + titleSet := make(map[string]bool) + for _, issue := range titleIssues { + titleSet[issue] = true + } + + branchSet := make(map[string]bool) + for _, issue := range branchIssues { + branchSet[issue] = true + } + + hasCommonIssue := false + for issue := range titleSet { + if branchSet[issue] { + hasCommonIssue = true + break + } + } + + if !hasCommonIssue { + report := jv.generateValidationReport(titleIssues, branchIssues, bodyIssues, validationResults) + setGitHubOutput("report", report) + setGitHubOutput("status", "failure") + return fmt.Errorf("mismatch between Jira issues in PR title and branch name") + } + } + + // All validations passed - generate success report + report := jv.generateValidationReport(titleIssues, branchIssues, bodyIssues, validationResults) + setGitHubOutput("report", report) + setGitHubOutput("status", "success") + + fmt.Println("✅ All Jira validations passed successfully") + return nil +} + +func main() { + validator, err := NewJiraValidator() + if err != nil { + fmt.Printf("::error::%v\n", err) + os.Exit(1) + } + + if err := validator.run(); err != nil { + fmt.Printf("::error::%v\n", err) + os.Exit(1) + } +} diff --git a/.github/workflows/jira-lint.yaml b/.github/workflows/jira-lint.yaml index 8c15e0ad..8f1012ec 100644 --- a/.github/workflows/jira-lint.yaml +++ b/.github/workflows/jira-lint.yaml @@ -9,15 +9,30 @@ on: required: true jobs: - jira-lint: + jira-validation: runs-on: ubuntu-latest + outputs: + validation-report: ${{ steps.validate.outputs.report }} + validation-status: ${{ steps.validate.outputs.status }} steps: - - uses: cyrus-za/jira-lint@master - name: jira-lint + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Jira Validation + id: validate + uses: ./.github/actions/jira-lint with: github-token: ${{ secrets.ORG_GH_TOKEN }} jira-token: ${{ secrets.JIRA_TOKEN }} - jira-base-url: https://tyktech.atlassian.net/ + jira-base-url: https://tyktech.atlassian.net skip-branches: '^(release-[0-9.-]+(lts)?|master|main)$' validate_issue_status: true - allowed_issue_statuses: "In Dev,In Code Review,Ready for Testing,In Test,In Progress,In Review" + skip-comments: true # We'll handle comments in the next job + + create-comment: + needs: jira-validation + if: ${{ needs.jira-validation.outputs.validation-report != '' }} + uses: ./.github/workflows/create-update-comment.yaml + with: + body-includes: "" + body: ${{ needs.jira-validation.outputs.validation-report }} From d86b8e056e4aa0e4837c55e2f688371b98e510f0 Mon Sep 17 00:00:00 2001 From: lghiur Date: Fri, 20 Jun 2025 23:12:13 +0300 Subject: [PATCH 2/2] wip --- .github/actions/jira-lint/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/jira-lint/action.yml b/.github/actions/jira-lint/action.yml index 83339c86..ccb9bcbb 100644 --- a/.github/actions/jira-lint/action.yml +++ b/.github/actions/jira-lint/action.yml @@ -52,6 +52,7 @@ runs: cd ${{ github.action_path }} go run main.go env: + GITHUB_EVENT_PATH: ${{ github.event_path }} INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} INPUT_JIRA_TOKEN: ${{ inputs.jira-token }} INPUT_JIRA_BASE_URL: ${{ inputs.jira-base-url }}