diff --git a/.github/workflows/jira-evidence-example.yml b/.github/workflows/jira-evidence-example.yml index c4d1244..6ea4605 100644 --- a/.github/workflows/jira-evidence-example.yml +++ b/.github/workflows/jira-evidence-example.yml @@ -1,100 +1,89 @@ -name: jira-evidence-example +name: "Jira evidence integration example" on: - workflow_dispatch: # This allows manual triggering of the workflow - push: - branches: - - CCS-2-Additional_evidence_examples - pull_request: - branches: - - CCS-2-Additional_evidence_examples -permissions: - id-token: write - contents: read + workflow_dispatch: + inputs: + start_commit: + description: "Starting commit (excluded from evidence filter)" + required: true + fetch_depth: + description: "Number of previous commits to fetch (default is 10)" + required: false + default: "10" jobs: docker-build-with-jira-evidence: runs-on: ubuntu-latest env: - DOCKER_REPO: 'test-docker-local' - IMAGE_NAME: 'my-very-cool-image:${{ github.run_number }}' + REGISTRY_DOMAIN: ${{ vars.JF_URL }} + REPO_NAME: 'docker-jira-repo-local' + IMAGE_NAME: 'docker-jira-image' + VERSION: ${{ github.run_number }} + BUILD_NAME: 'jira-docker-build' + JIRA_ID_REGEX: '[A-Z]+-[0-9]+' + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + JIRA_URL: ${{ vars.JIRA_URL }} + JIRA_USERNAME: ${{ secrets.JIRA_USERNAME }} + ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE: true steps: - - name: Install jfrog cli - id: setup-cli + - name: Setup jfrog cli uses: jfrog/setup-jfrog-cli@v4 env: JF_URL: ${{ vars.ARTIFACTORY_URL }} + JF_ACCESS_TOKEN: ${{ secrets.ARTIFACTORY_ACCESS_TOKEN }} + - name: Checkout repository + uses: actions/checkout@v4 with: - oidc-provider-name: jfrog-github-oidc - - - uses: actions/checkout@v4 + fetch-depth: ${{ github.event.inputs.fetch_depth }} + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Build JIRA helper binary + run: | + cd examples/jira/helper + chmod +x build.sh + ./build.sh + cd - - name: Log in to Artifactory Docker Registry uses: docker/login-action@v3 with: registry: ${{ vars.ARTIFACTORY_URL }} - username: ${{ steps.setup-cli.outputs.oidc-user }} - password: ${{ steps.setup-cli.outputs.oidc-token }} - + username: ${{ secrets.JF_USER }} + password: ${{ secrets.ARTIFACTORY_ACCESS_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Build and Push Docker image - uses: docker/build-push-action@v6 - id: docker-build - with: - push: true - provenance: false - platforms: linux/amd64 #, linux/arm64 - build-args: REPO_URL=${{ vars.JF_URL }}/example-project-docker-dev-remote - tags: ${{ vars.JF_URL }}/${{ env.DOCKER_REPO }}/${{ env.IMAGE_NAME }} - - - name: add docker package to build - run: | - echo "${{ vars.JF_URL }}/${{ env.DOCKER_REPO }}/${{ env.IMAGE_NAME }}@${{ steps.docker-build.outputs.digest }}" > metadata.json - jf rt build-docker-create ${{ env.DOCKER_REPO }} --image-file metadata.json --build-name $GITHUB_WORKFLOW --build-number ${{ github.run_number }} - - - name: Publish build info - if: ${{ true }} + - name: Build and publish Docker Image to Artifactory run: | + cd examples/jira + docker build . --file Dockerfile --tag $REGISTRY_DOMAIN/$REPO_NAME/$IMAGE_NAME:$VERSION + + IMAGE_TAG="$REGISTRY_DOMAIN/$REPO_NAME/$IMAGE_NAME:$VERSION" + docker push $IMAGE_TAG + + REGISTRY_DIGEST=$(docker inspect $IMAGE_TAG | jq -r '.[0].RepoDigests[0]' | cut -d'@' -f2) + echo "$IMAGE_TAG@$REGISTRY_DIGEST" > metadata.txt + + jf rt build-docker-create $REPO_NAME --image-file metadata.txt --build-name=$BUILD_NAME --build-number=$VERSION jf rt build-collect-env jf rt build-add-git - jf rt build-publish + jf rt build-publish $BUILD_NAME $VERSION + cd - + - name: Fetch details from jira + run: | + cd examples/jira/helper + ./main "${{ github.event.inputs.start_commit }}" + cd - + - name: Create JIRA evidence - env: - jira_token: ${{ secrets.JIRA_TOKEN }} - jira_username: ${{ secrets.JIRA_USERNAME }} - jira_url: ${{ secrets.JIRA_URL }} run: | - BRANCH_NAME=$(git branch --show-current) - jira_id=$(echo "$BRANCH_NAME" | sed -E 's/^([^-]+-[0-9]+).*/\1/') - echo "The branch name is: $BRANCH_NAME" - echo "The jira_id is: $jira_id" - # uncomment the line below to use the commit message instead of the branch name - #START_COMMIT=$(git log -1 --format="%H %s") - #jira_id=$(echo "$BRANCH_NAME" | cut -d' ' -f2) - - # Check if the jira_id matches the JIRA ID format - if [[ $jira_id =~ ^[A-Z]+-[0-9]+$ ]]; then - echo "A valid JIRA ID was found in branch name: $jira_id" - set +e - ./examples/jira-transition-example/bin/jira-transition-checker-linux-amd64 "Done" $jira_id > predicate.json - # add --failOnMissingTransition to fail the build if the JIRA does not pass the transition check - EXIT_CODE=$? - set -e - # create evidence only if the jira transition checker was successful - if [ $EXIT_CODE -eq 0 ]; then - # Attach evidence onto build using JFrog CLI - jf evd create \ - --build-name $GITHUB_WORKFLOW \ - --build-number "${{ github.run_number }}" \ - --predicate ./predicate.json \ - --predicate-type https://jfrog.com/evidence/build-jira-transition/v1 \ - --key "${{ secrets.JIRA_TEST_PKEY }}" \ - --key-alias ${{ vars.JIRA_TEST_KEY }} - else - echo "JIRA transition checked completed with an error, or not all JIRAs pass the transition checked" - fi - else - echo "No valid JIRA ID located in branch name: $BRANCH_NAME" - fi + jf evd create \ + --build-name $BUILD_NAME \ + --build-number ${{ github.run_number }} \ + --key "${{ secrets.PRIVATE_KEY }}" \ + --key-alias "${{ vars.EVIDENCE_KEY_ALIAS }}" \ + --predicate ./examples/jira/helper/transformed_jira_data.json \ + --predicate-type http://atlassian.com/jira/issues/v1 \ + ${{ env.ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE == 'true' && '--markdown "examples/jira/helper/transformed_jira_data.md"' || '' }} + diff --git a/examples/jira-transition-example/README.md b/examples/jira-transition-example/README.md deleted file mode 100644 index 3339a2c..0000000 --- a/examples/jira-transition-example/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Create JIRA Transition Evidence from the build CI and attach it to the build info -JIRA is an important tool for tracking issues and managing projects and holds all requirements for software changes as Tasks. -For compliant software development, it is important to track requirements review and approval process as these confirm proper approval for code changes done and released. -To allow automation of proper requirements review and approval, we create an evidence of any JIRA linked to the code commits during the build with confirmation it went through approval status before code was committed. -Every company defines a different approval status, so in our example we allow the calling code send the name of the transition that shold be checked. - -pre-requisites: -1. Hold a cloud JIRA server (for selfhosted jira server, few code adjustments are required) -2. Allow network access from your CI server to Jira server -3. Define few environment variables: jira_url, jira_token, jira_username -4. Commit comments must include the JIRA issue ID (e.g. -1234) - -The example is based on the following steps: -1. get the relevant commit IDs -2. extract the JIRA IDs from all the build commits -3. call the jira-transition-checker utility (use the binary for your build platform) with these arguments: "transition name" JIRA-ID [,JIRA-ID] -for example: - ``./examples/jira-transition-example/bin/jira-transition-checker-linux-amd64 "Finance Approval" JIRA-486 PROJ-111 > predicate.json`` -optional arg: `--failOnMissingTransition` whihc will fail the script if any of the JIRA IDs sent did not pass the transition check -4. call the evidence create cli with the predicate.json file -for example: -``jf evd create \ - --build-name "${{ env.BUILD_NAME }}" \ - --build-number "${{ github.run_number }}" \ - --predicate ./predicate.json \ - --predicate-type https://jfrog.com/evidence/requirements-approval/v1 \ - --key "${{ secrets.JIRA_TEST_PKEY }}" \ - --key-alias ${{ vars.JIRA_TEST_KEY }}`` diff --git a/examples/jira-transition-example/build-binary.sh b/examples/jira-transition-example/build-binary.sh deleted file mode 100755 index 0784b28..0000000 --- a/examples/jira-transition-example/build-binary.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -# Script inspired by https://www.digitalocean.com/community/tutorials/how-to-build-go-executables-for-multiple-platforms-on-ubuntu-16-04 - -errorExit () { - echo; echo "ERROR: $1"; echo - exit 1 -} - -BIN=jira-transition-checker -rm -rf bin -mkdir -p bin - -echo "Building $BIN" -#platforms=("darwin/amd64" "linux/arm64" "linux/amd64" "windows/amd64" "windows/386") -platforms=("linux/arm64" "linux/amd64" "darwin/arm64" ) - -for p in "${platforms[@]}"; do - platform_array=(${p//\// }) - GOOS=${platform_array[0]} - GOARCH=${platform_array[1]} - - echo -e "\nBuilding" - echo "OS: $GOOS" - echo "ARCH: $GOARCH" - final_name=$BIN'-'$GOOS'-'$GOARCH - if [ "$GOOS" = "windows" ]; then - final_name+='.exe' - fi - - env GOOS="$GOOS" GOARCH="$GOARCH" go build -o bin/$final_name . || errorExit "Building $final_name failed" -done - -echo -e "\nDone!\nThe following binaries were created in the bin/ directory:" -ls -1 bin/ -echo \ No newline at end of file diff --git a/examples/jira-transition-example/main.go b/examples/jira-transition-example/main.go deleted file mode 100644 index 23b7600..0000000 --- a/examples/jira-transition-example/main.go +++ /dev/null @@ -1,155 +0,0 @@ -package main -import ( - "context" - "encoding/json" - "os" - "fmt" - jira "github.com/andygrunwald/go-jira/v2/cloud" - ) - /* - JiraTransitionResponse is the json formatted predicate that will be returned to the calling build process for cresting an evidence - its structure should be: - - { - "transition": "", - "allJiraTransitionsFound": "", - "tasks": [ - { - "jira_id": "", - "summary": "", - "transition_found": "" - "author": "", - "author_user_name": "", - "transition_time": "2025-02-04T08:14:03.559+0200" - } - ] - } - - notice that the calling client should first check that return value was 0 before using the response JSON, - otherwise the response is an error message which cannot be parsed - */ - -type TransitionCheckResponse struct { - Transition string `json:"transition"` - AllJiraTransitionsFound bool `json:"allJiraTransitionsFound"` - Tasks []JiraTransitionResult `json:"tasks"` -} - -type JiraTransitionResult struct { - JiraId string `json:"jira_id"` - Summary string `json:"summary"` - TransitionFound bool `json:"transition_found"` - Author string `json:"author"` - AuthorEmail string `json:"author_user_name"` - TransitionTime string `json:"transition_time"` -} - -func main() { - // get checked transition name and JIRA IDs from command-line arguments - if len(os.Args) < 3 { - fmt.Println("Insufficient command-line arguments provided, please send checked transition name and at least one JIRA ID(s)") - return - } - transitionArgPosition := 1 - jiraArgPosition := 2 - failOnMissingTransition := false - for i, arg := range os.Args { - if i == 0 { - continue - } - if strings.HasPrefix(arg, "--failOnMissingTransition") { - failOnMissingTransition = true - // removing the argument from the list - os.Args = append(os.Args[:indexToRemove], os.Args[indexToRemove+1:]...) - } - } - - - transitionChecked := os.Args[transitionArgPosition] - // Create a new Jira client - jira_token := os.Getenv("jira_token") - if jira_token == "" { - fmt.Println("JIRA token not found, set jira_token variable") - os.Exit(1) - } - jira_url := os.Getenv("jira_url") - if jira_url == "" { - fmt.Println("JIRA URL not found, set jira_url variable") - os.Exit(1) - } - jira_username := os.Getenv("jira_username") - if jira_username == "" { - fmt.Println("JIRA username not found, set jira_username variable") - os.Exit(1) - } - // connect to JIRA - tp := jira.BasicAuthTransport{ - Username: jira_username, - APIToken: jira_token, - } - client, err := jira.NewClient(jira_url, tp.Client()) - if err != nil { - fmt.Println("jira.NewClient error: %v\n", err) - os.Exit(1) - } - // initialize the response - transitionCheckResponse := TransitionCheckResponse{} - transitionCheckResponse.AllJiraTransitionsFound = true - transitionCheckResponse.Transition = transitionChecked - transitionFound := false - // loop over all JIRAs sent to the fucntion - for _, jiraId := range os.Args[jiraArgPosition:] { - //fmt.Println("-----------Checking JIRA ", jiraId) - transitionFound = false - issue, _, _ := client.Issue.Get(context.Background(), jiraId , &jira.GetQueryOptions{Expand: "changelog"}) - if issue == nil { - fmt.Println("Got error for extracting issue with jira id: ", jiraId, "error", err) - os.Exit(1) - } - // adding the jira result to the list of results - jiraTransitionResult := JiraTransitionResult{ - JiraId: jiraId, - Summary: issue.Fields.Summary, - } - - if len(issue.Changelog.Histories) > 0 { - //fmt.Println("history found for jira id:", jiraId) - for _, history := range issue.Changelog.Histories { - for _, changelogItems := range history.Items { - //fmt.Println("jira id:", jiraId, "field", changelogItems.Field, "FieldType", changelogItems.FieldType, "toString", changelogItems.ToString) - if changelogItems.Field == "status" { - //fmt.Println("Transition for jira", jiraId, "FromString", changelogItems.FromString, "ToString" , changelogItems.ToString, "Created", history.Created, "Author", history.Author) - if changelogItems.ToString == transitionChecked { - transitionFound = true - jiraTransitionResult.Author = history.Author.DisplayName - jiraTransitionResult.AuthorEmail = history.Author.EmailAddress - jiraTransitionResult.TransitionTime = history.Created - // fmt.Println("Transition name for jira", jiraId, "found") - break // once found we can continue to the next jira - } - } - } - } - } - jiraTransitionResult.TransitionFound = transitionFound - transitionCheckResponse.Tasks = append(transitionCheckResponse.Tasks, jiraTransitionResult) - // check if all transitions are found - if !transitionFound { - transitionCheckResponse.AllJiraTransitionsFound = false - } - } - if failOnMissingTransition && !transitionCheckResponse.AllJiraTransitionsFound { - fmt.Println("Not all JIRA transitions found") - os.Exit(1) - } - // marshal the response to JSON - jsonBytes, err := json.Marshal(transitionCheckResponse) - if err != nil { - fmt.Println("Error marshaling JSON", err) - os.Exit(1) - } - //logger.Println("returning response", response) - - // return response to caller through stdout - os.Stdout.Write(jsonBytes) - } diff --git a/examples/jira/.gitignore b/examples/jira/.gitignore new file mode 100644 index 0000000..02f570f --- /dev/null +++ b/examples/jira/.gitignore @@ -0,0 +1,62 @@ +# Go build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +main + +# Go test files +*.test +*.out + +# Go coverage files +*.cover + +# Go module files (keep go.mod and go.sum, but ignore vendor) +vendor/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log files +*.log + +# Temporary files +*.tmp +*.temp + +# Build directories +build/ +dist/ +bin/ + +# Docker artifacts +*.tar +*.tar.gz + +# JIRA API response files (if generated during testing) +jira_transitions.json +jira_response.json + +# Environment files +.env +.env.local +.env.*.local + +# Backup files +*.bak +*.backup \ No newline at end of file diff --git a/examples/jira/Dockerfile b/examples/jira/Dockerfile new file mode 100644 index 0000000..85673f9 --- /dev/null +++ b/examples/jira/Dockerfile @@ -0,0 +1,10 @@ +# Use the official lightweight Python image based on Alpine +FROM python:3.12-alpine + +# Set the working directory +WORKDIR /app + +# Add a simple script that prints a message +RUN echo 'print("Hello from a alpine Docker!")' > hello.py + +CMD ["python", "hello.py"] \ No newline at end of file diff --git a/examples/jira/README.md b/examples/jira/README.md new file mode 100644 index 0000000..3eefbf3 --- /dev/null +++ b/examples/jira/README.md @@ -0,0 +1,187 @@ +# **Jira Ticket Tracking Evidence Example** + +This repository provides a working example of a GitHub Actions workflow that automates Jira ticket tracking and validation. It extracts Jira ticket IDs from git commit messages, validates their existence and workflow transitions, and attaches the resulting ticket summary as signed, verifiable evidence to the package in **JFrog Artifactory**. + +This workflow is an essential pattern for DevSecOps and project management, creating a traceable, compliant, and auditable software development process that links code changes to Jira tickets. + +## **Key Features** + +* **Automated Jira ID Extraction**: Extracts Jira ticket IDs from git commit messages using configurable regex patterns. +* **Ticket Validation**: Validates that extracted Jira tickets exist and retrieves their current status and metadata. +* **Workflow Transition Tracking**: Captures the complete workflow history and transitions for each ticket. +* **Evidence Generation**: Creates a jira-results.json predicate file with comprehensive ticket information. +* **Optional Markdown Report**: Integrated markdown generation for human-readable summaries from the Jira JSON results. +* **Signed Evidence Attachment**: Attaches the ticket tracking results to the corresponding package version in Artifactory using `jf evd create`, cryptographically signing it for integrity. +* **Consolidated Tool**: All functionality is now consolidated into a single Go application for simplicity and maintainability. + +## **Workflow Overview** + +The following diagram illustrates the sequence of operations performed by the GitHub Actions workflow. + +```mermaid +graph TD + A[Workflow Dispatch Trigger] --> B[Setup JFrog CLI] + B --> C[Checkout Repository] + C --> D[Build JIRA Helper Binary] + D --> E[Build & Push Docker Image] + E --> F[Extract Jira IDs & Fetch Details] + F --> G{Generate Markdown Report?} + G -->|Yes| H[Generate Custom Markdown Report] + G -->|No| I[Skip Markdown Report] + H --> J[Attach Evidence to Package] + I --> J[Attach Evidence to Package] +``` + +## **Architecture** + +### **Components** + +1. **GitHub Actions Workflow** (`.github/workflows/jira-evidence-example.yml`) + - Orchestrates the complete CI/CD process + - Manages environment variables and secrets + - Triggers JIRA evidence gathering + +2. **JIRA Helper Application** (`helper/main.go`) + - Consolidated Go application for all JIRA operations + - Extracts JIRA IDs from git commits + - Fetches comprehensive ticket details from JIRA API + - Generates structured JSON output + +3. **Markdown Generation** (integrated into `helper/main.go`) + - Optional markdown report generation + - Converts JSON data to formatted Markdown tables + - Controlled by `ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE` environment variable + +4. **Docker Application** (`Dockerfile`, `app/`) + - Sample application for demonstration + - Gets packaged and pushed to Artifactory + +## **Prerequisites** + +Before running this workflow, you must have: + +* JFrog CLI 2.65.0 or above (installed automatically in the workflow) +* An Artifactory repository (e.g., jira-evidence-repo) +* A private key and a corresponding key alias configured in your JFrog Platform for signing evidence +* Access to a Jira instance with API credentials +* Go 1.21 or later (for building the helper application) + +## **Configuration** + +### **GitHub Secrets** + +Navigate to Settings > Secrets and variables > Actions and create the following secrets: + +| Secret Name | Description | +| :---- | :---- | +| ARTIFACTORY_ACCESS_TOKEN | A valid JFrog Access Token with permissions to read, write, and annotate in your target repository. | +| PRIVATE_KEY | The private key used to sign the evidence. This key corresponds to the alias configured in JFrog Platform. | +| JF_USER | The username for Artifactory Docker registry authentication. | +| JIRA_USERNAME | The username of the Jira user account used for API authentication. | +| JIRA_API_TOKEN | The API token for the Jira user account. Generate this from your Jira account settings. | + +### **GitHub Variables** + +Navigate to Settings > Secrets and variables > Actions and create the following variables: + +| Variable Name | Description | Example Value | +| :---- | :---- | :---- | +| JF_URL | The base URL of your JFrog Platform instance. | https://mycompany.jfrog.io | +| JIRA_URL | The base URL of your Jira instance. | https://mycompany.atlassian.net | +| JIRA_PROJECT_KEY | The project key for Jira tickets to validate. | EV | +| JIRA_ID_REGEX | Regex pattern to extract Jira IDs from commit messages. | (EV-\d+) | +| EVIDENCE_KEY_ALIAS | The alias for the public key in JFrog Platform used to verify the evidence signature. | my-signing-key-alias | + +### **Workflow Environment Variables** + +You can customize the workflow's behavior by modifying the env block in the `.github/workflows/jira-evidence-example.yml` file: + +| Variable Name | Description | Default Value | +| :---- | :---- | :---- | +| REPO_NAME | The name of the target Docker repository in Artifactory. | docker-jira-repo-local | +| IMAGE_NAME | The name of the Docker image to be built and pushed. | docker-jira-image | +| BUILD_NAME | The name assigned to the build information in Artifactory. | jira-docker-build | +| ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE | Set to true to generate and attach a Markdown report alongside the JSON evidence. Set to false to skip this step. | true | + +## **Usage** + +### **Running the Workflow** + +1. Navigate to the **Actions** tab of your forked repository. +2. In the left sidebar, click on the **Jira evidence integration example** workflow. +3. Click the **Run workflow** dropdown button. You can leave the default branch selected. +4. Click the green **Run workflow** button. + +### **Workflow Inputs** + +- **start_commit**: Starting commit hash (excluded from evidence filter) - **Required** +- **fetch_depth**: Number of previous commits to fetch (default is 10) - **Optional** + +## **Output Schema** + +The workflow generates a structured JSON file (`transformed_jira_data.json`) with the following schema: + +```json +{ + "ticketRequested": ["EV-123", "EV-456"], + "tasks": [ + { + "key": "EV-123", + "status": "In Progress", + "description": "Task description", + "type": "Task", + "project": "EV", + "created": "2020-01-01T12:11:56.063+0530", + "updated": "2020-01-01T12:12:01.876+0530", + "assignee": "John Doe", + "reporter": "Jane Smith", + "priority": "Medium", + "transitions": [ + { + "from_status": "To Do", + "to_status": "In Progress", + "author": "John Doe", + "author_user_name": "john.doe@company.com", + "transition_time": "2020-07-28T16:39:54.620+0530" + } + ] + } + ] +} +``` + +## **Jira ID Extraction Patterns** + +The workflow supports configurable regex patterns for extracting Jira IDs from commit messages. Common patterns include: + +* **Simple Project Key**: `(EV-\d+)` - Matches tickets like EV-123, EV-456 +* **Multiple Projects**: `((EV|PROJ|BUG)-\d+)` - Matches tickets from multiple projects +* **Case Insensitive**: `(?i)(ev-\d+)` - Matches case variations like ev-123, EV-123 + +## **Error Handling** + +The workflow includes comprehensive error handling: + +* **Invalid Jira IDs**: Tickets that don't match the configured regex pattern are logged and skipped +* **Non-existent Tickets**: Tickets that don't exist in Jira are marked as "Error" in the results +* **API Failures**: Network or authentication issues are logged with appropriate error messages +* **Empty Results**: If no valid Jira IDs are found, the workflow continues with an empty result set + +## **Technical Implementation** + +For detailed technical information about the JIRA helper application, including: + +- Command-line options and usage +- Environment variables +- Building and deployment +- API integration details +- Error handling specifics + +Please refer to the [helper directory README](helper/README.md). + +## **References** + +* [Jira REST API Documentation](https://developer.atlassian.com/cloud/jira/platform/rest/v3/) +* [JFrog Evidence Management](https://jfrog.com/help/r/jfrog-artifactory-documentation/evidence-management) +* [JFrog CLI Documentation](https://jfrog.com/getcli/) +* [Git Commit Message Conventions](https://www.conventionalcommits.org/) diff --git a/examples/jira/helper/README.md b/examples/jira/helper/README.md new file mode 100644 index 0000000..d7424eb --- /dev/null +++ b/examples/jira/helper/README.md @@ -0,0 +1,340 @@ +# JIRA Helper - Technical Documentation + +This directory contains the technical implementation of the JIRA evidence gathering tool. The `main.go` application is a consolidated Go program that handles all JIRA-related operations including git commit extraction, JIRA API integration, and evidence generation. + +## Quick Start + +```bash +# Build the application +go build -o main main.go + +# Basic usage - extract JIRA IDs and fetch details +./main + +# Direct JIRA ticket processing +./main EV-123 EV-456 EV-789 + +# Extract only mode +./main --extract-only + +# Get help +./main --help +``` + +## Command Line Interface + +### Primary Mode: Git-based Evidence Gathering +```bash +./main [OPTIONS] +``` + +**Arguments:** +- `start_commit`: Starting commit hash (excluded from evidence filter) + +**Options:** +- `-r, --regex PATTERN`: JIRA ID regex pattern (default: `[A-Z]+-[0-9]+`) +- `-o, --output FILE`: Output file for JIRA data (default: `transformed_jira_data.json`) +- `--extract-only`: Only extract JIRA IDs, don't fetch details +- `-h, --help`: Display help message + +### Direct Mode: Process Specific JIRA Tickets +```bash +./main [jira_id2] [jira_id3] ... +``` + +### Legacy Mode: Backward Compatibility +```bash +./main --extract-from-git +``` + +## Environment Variables + +| Variable | Description | Required | Default | +|----------|-------------|----------|---------| +| `JIRA_API_TOKEN` | JIRA API token for authentication | Yes | - | +| `JIRA_URL` | JIRA instance URL | Yes | - | +| `JIRA_USERNAME` | JIRA username for authentication | Yes | - | +| `JIRA_ID_REGEX` | JIRA ID regex pattern | No | `[A-Z]+-[0-9]+` | +| `OUTPUT_FILE` | Output file path | No | `transformed_jira_data.json` | +| `ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE` | Generate markdown report | No | `false` | + +## Usage Examples + +### Basic Evidence Gathering +```bash +export JIRA_API_TOKEN="your_token" +export JIRA_URL="https://your-domain.atlassian.net" +export JIRA_USERNAME="your_email@domain.com" + +./main abc123def456 +``` + +### Custom Configuration +```bash +./main -r 'EV-\d+' -o my_results.json abc123def456 +``` + +### Extract Only (for debugging) +```bash +./main --extract-only abc123def456 +``` + +### Direct Ticket Processing +```bash +./main EV-123 EV-456 EV-789 +``` + +## Technical Architecture + +### Core Functions + +#### Git Operations +- `getBranchInfo()`: Extracts current branch, commit hash, and JIRA ID from latest commit +- `validateHEAD()`: Validates that HEAD commit exists in repository +- `validateCommit()`: Validates commit existence in repository +- `extractJiraIDs()`: Extracts JIRA IDs from commit messages using regex +- `checkGitRepository()`: Validates git repository state + +#### JIRA API Integration +- `fetchJiraDetails()`: Creates JIRA client and fetches ticket details +- `fetchJiraDetailsWithClient()`: Core JIRA data fetching logic +- `getDescription()`: Extracts description from JIRA description field +- `getAssignee()`: Handles assignee information +- `getTimeAsString()`: Converts JIRA time fields to strings + +#### File Operations +- `writeToFile()`: Writes data to file with directory creation +- `displayUsage()`: Shows command-line help + +#### Markdown Generation +- `formatWorkflow()`: Formats workflow transitions into readable strings +- `escapeMarkdown()`: Escapes special characters for markdown tables +- `generateMarkdownContent()`: Generates markdown content from JIRA data +- `generateMarkdownReport()`: Creates and writes markdown report files + +### Data Structures + +```go +type TransitionCheckResponse struct { + TicketRequested []string `json:"ticketRequested"` + Tasks []JiraTransitionResult `json:"tasks"` +} + +type JiraTransitionResult struct { + Key string `json:"key"` + Status string `json:"status"` + Description string `json:"description"` + Type string `json:"type"` + Project string `json:"project"` + Created string `json:"created"` + Updated string `json:"updated"` + Assignee *string `json:"assignee"` + Reporter string `json:"reporter"` + Priority string `json:"priority"` + Transitions []Transition `json:"transitions"` +} + +type Transition struct { + FromStatus string `json:"from_status"` + ToStatus string `json:"to_status"` + Author string `json:"author"` + AuthorEmail string `json:"author_user_name"` + TransitionTime string `json:"transition_time"` +} +``` + +## Building and Development + +### Prerequisites +- Go 1.21 or later +- Git (for git operations) +- JIRA Cloud API access + +### Build Commands +```bash +# Standard build +go build -o main main.go + +# Using build script +./build.sh + +# Cross-platform build +GOOS=linux GOARCH=amd64 go build -o main main.go +``` + +### Dependencies +```go +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "strings" + "time" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) +``` + +### Testing +```bash +# Run tests +go test ./... + +# Test specific functionality +go test -v -run TestExtractJiraIDs +``` + +## Error Handling + +### Git Errors +- Repository validation failures +- HEAD commit existence checks +- Commit existence checks +- Branch information extraction errors + +### JIRA API Errors +- Authentication failures +- Network connectivity issues +- Invalid ticket IDs +- API rate limiting + +### File System Errors +- Output file creation failures +- Directory permission issues +- JSON marshaling errors + +### Error Response Format +```json +{ + "ticketRequested": ["EV-123"], + "tasks": [ + { + "key": "EV-123", + "status": "Error", + "description": "Error: Could not retrieve issue", + "type": "Error", + "project": "", + "created": "", + "updated": "", + "assignee": null, + "reporter": "", + "priority": "", + "transitions": [] + } + ] +} +``` + +## Integration with CI/CD + +### GitHub Actions +```yaml +- name: Fetch details from jira + run: | + cd examples/jira/helper + ./main "${{ github.event.inputs.start_commit }}" + cd - +``` + +### Docker Integration +```dockerfile +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o main main.go + +FROM alpine:latest +RUN apk --no-cache add git +COPY --from=builder /app/main /main +ENTRYPOINT ["/main"] +``` + +## Performance Considerations + +### Git Operations +- Uses `git log` with specific format for efficiency +- Validates commits before processing +- Handles large commit ranges gracefully + +### JIRA API +- Processes tickets sequentially to avoid rate limiting +- Graceful error handling for individual ticket failures +- Continues processing even if some tickets fail + +### Memory Usage +- Streams JSON output to avoid large memory allocations +- Uses maps for deduplication of JIRA IDs +- Efficient string handling for large commit histories + +## Troubleshooting + +### Common Issues + +1. **Git Repository Not Found** + ``` + Error: not in a git repository + ``` + **Solution**: Ensure you're running the command from a git repository + +2. **JIRA Authentication Failed** + ``` + JIRA token not found, set jira_token variable + ``` + **Solution**: Set the required environment variables + +3. **Invalid Regex Pattern** + ``` + Error: invalid JIRA ID regex + ``` + **Solution**: Check your regex pattern syntax + +4. **Commit Not Found** + ``` + ❌ commit 'abc123' not found + ``` + **Solution**: Verify the commit hash exists and fetch depth is sufficient + +5. **HEAD Commit Not Found** + ``` + ❌ HEAD commit not found. Repository may be empty or corrupted + ``` + **Solution**: Ensure the repository has at least one commit and is not corrupted + +### Debug Mode +```bash +# Enable verbose output +export DEBUG=true +./main + +# Extract only to debug git operations +./main --extract-only +``` + +## Contributing + +### Code Style +- Follow Go conventions and `gofmt` +- Add comments for exported functions +- Include error handling for all external calls + +### Testing +- Add unit tests for new functions +- Test error conditions +- Validate JSON output format + +### Dependencies +- Keep dependencies minimal +- Use specific versions in `go.mod` +- Document any new dependencies + +## License + +This tool is part of the Evidence-Examples repository and follows the same licensing terms. \ No newline at end of file diff --git a/examples/jira/helper/build.sh b/examples/jira/helper/build.sh new file mode 100755 index 0000000..2cb0945 --- /dev/null +++ b/examples/jira/helper/build.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Build script for JIRA transition checker +# This script checks if the main binary exists and builds it if it doesn't + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if Go is installed +if ! command -v go &> /dev/null; then + print_error "Go is not installed. Please install Go first." + exit 1 +fi + +# Check if main.go exists +if [ ! -f "main.go" ]; then + print_error "main.go not found in current directory" + exit 1 +fi + +# Check if main binary exists and is newer than main.go +if [ -f "main" ] && [ "main" -nt "main.go" ]; then + print_status "Binary 'main' already exists and is up to date" + print_status "Binary location: $(pwd)/main" +else + print_status "Building main binary..." + + # Check if go.mod exists, if not initialize module + if [ ! -f "go.mod" ]; then + print_warning "go.mod not found, initializing Go module..." + go mod init jira-helper + fi + + # Download dependencies + print_status "Downloading dependencies..." + go mod tidy + + # Build the binary + print_status "Running: go build -o main ." + if go build -o main .; then + print_status "Build successful!" + + # Make it executable + chmod +x main + print_status "Binary is now executable" + else + print_error "Build failed!" + exit 1 + fi +fi + +print_status "Build process completed successfully!" +print_status "You can now run: ./main [JIRA-ID1] [JIRA-ID2] ..." +print_status "Binary location: $(pwd)/main" \ No newline at end of file diff --git a/examples/jira/helper/go.mod b/examples/jira/helper/go.mod new file mode 100644 index 0000000..9bdfda9 --- /dev/null +++ b/examples/jira/helper/go.mod @@ -0,0 +1,12 @@ +module jira-helper + +go 1.24.5 + +require github.com/andygrunwald/go-jira/v2 v2.0.0-20250706111204-51c7813d292d + +require ( + github.com/fatih/structs v1.1.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/trivago/tgo v1.0.7 // indirect +) diff --git a/examples/jira/helper/go.sum b/examples/jira/helper/go.sum new file mode 100644 index 0000000..9825a5b --- /dev/null +++ b/examples/jira/helper/go.sum @@ -0,0 +1,14 @@ +github.com/andygrunwald/go-jira/v2 v2.0.0-20250706111204-51c7813d292d h1:YgPN1Enyjf1ECbsuwcqAtyomCC+vL2nLgD9TGnwbHXo= +github.com/andygrunwald/go-jira/v2 v2.0.0-20250706111204-51c7813d292d/go.mod h1:PmolOmLs9fDr4F240qyXuTuurFxblZiQKTztY+xAmKw= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +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-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= +github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/examples/jira/helper/jira.go b/examples/jira/helper/jira.go new file mode 100644 index 0000000..6532914 --- /dev/null +++ b/examples/jira/helper/jira.go @@ -0,0 +1,390 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "time" + + jira "github.com/andygrunwald/go-jira/v2/cloud" +) + +/* + JiraTransitionResponse is the json formatted predicate that will be returned to the calling build process for cresting an evidence + its structure should be: + + { + "ticketRequested": [ "EV-1", "EV-2" ], + "tasks": [ + { + "key": "EV-1", + "status": "QA in Progress", + "description": "", + "type": "Task", + "project": "EV", + "created": "2020-01-01T12:11:56.063+0530", + "updated": "2020-01-01T12:12:01.876+0530", + "assignee": "", + "reporter": "", + "priority": "Medium", + "transitions": [ + { + "from_status": "To Do", + "to_status": "In Progress", + "author": "<>author name>", + "author_user_name": "", + "transition_time": "2020-07-28T16:39:54.620+0530" + } + ] + }, + { + "key": "EV-2", + "status": "Error", + "description": "Error: Could not retrieve issue", + "type": "Error", + "project": "", + "created": "", + "updated": "", + "assignee": null, + "reporter": "", + "priority": "", + "transitions": [] + } + ] + } + + notice that the calling client should first check that return value was 0 before using the response JSON, + otherwise the response is an error message which cannot be parsed +*/ + +type TransitionCheckResponse struct { + TicketRequested []string `json:"ticketRequested"` + Tasks []JiraTransitionResult `json:"tasks"` +} + +type JiraTransitionResult struct { + Key string `json:"key"` + Status string `json:"status"` + Description string `json:"description"` + Type string `json:"type"` + Project string `json:"project"` + Created string `json:"created"` + Updated string `json:"updated"` + Assignee *string `json:"assignee"` + Reporter string `json:"reporter"` + Priority string `json:"priority"` + Transitions []Transition `json:"transitions"` +} + +type Transition struct { + FromStatus string `json:"from_status"` + ToStatus string `json:"to_status"` + Author string `json:"author"` + AuthorEmail string `json:"author_user_name"` + TransitionTime string `json:"transition_time"` +} + +// JiraClient wraps the JIRA client and provides methods for JIRA operations +type JiraClient struct { + client *jira.Client +} + +// NewJiraClient creates a new JIRA client with authentication +func NewJiraClient() (*JiraClient, error) { + jira_token := os.Getenv("JIRA_API_TOKEN") + if jira_token == "" { + return nil, fmt.Errorf("JIRA token not found, set jira_token variable") + } + jira_url := os.Getenv("JIRA_URL") + if jira_url == "" { + return nil, fmt.Errorf("JIRA URL not found, set jira_url variable") + } + jira_username := os.Getenv("JIRA_USERNAME") + if jira_username == "" { + return nil, fmt.Errorf("JIRA username not found, set JIRA_USERNAME variable") + } + + // connect to JIRA + tp := jira.BasicAuthTransport{ + Username: jira_username, + APIToken: jira_token, + } + client, err := jira.NewClient(jira_url, tp.Client()) + if err != nil { + return nil, fmt.Errorf("jira.NewClient error: %v", err) + } + + return &JiraClient{client: client}, nil +} + +func (jc *JiraClient) FetchJiraDetails(jiraIDs []string) TransitionCheckResponse { + // initialize the response + transitionCheckResponse := TransitionCheckResponse{} + transitionCheckResponse.TicketRequested = jiraIDs + + // loop over all JIRAs sent to the function + for _, jiraId := range jiraIDs { + issue, _, err := jc.client.Issue.Get(context.Background(), jiraId, &jira.GetQueryOptions{Expand: "changelog"}) + if issue == nil { + fmt.Fprintf(os.Stderr, "Got error for extracting issue with jira id: %s error %v\n", jiraId, err) + // Skip this ticket and continue with the next one + jiraTransitionResult := JiraTransitionResult{ + Key: jiraId, + Status: "Error", + Description: "Error: Could not retrieve issue", + Type: "Error", + Project: "", + Created: "", + Updated: "", + Assignee: nil, + Reporter: "", + Priority: "", + Transitions: []Transition{}, + } + transitionCheckResponse.Tasks = append(transitionCheckResponse.Tasks, jiraTransitionResult) + continue + } + + // adding the jira result to the list of results + jiraTransitionResult := JiraTransitionResult{ + Key: issue.Key, + Status: issue.Fields.Status.Name, + Description: getDescription(issue.Fields.Description), + Type: issue.Fields.Type.Name, + Project: issue.Fields.Project.Key, + Created: getTimeAsString(issue.Fields.Created), + Updated: getTimeAsString(issue.Fields.Updated), + Assignee: getAssignee(issue.Fields.Assignee), + Reporter: issue.Fields.Reporter.DisplayName, + Priority: issue.Fields.Priority.Name, + Transitions: []Transition{}, + } + + if len(issue.Changelog.Histories) > 0 { + for _, history := range issue.Changelog.Histories { + for _, changelogItems := range history.Items { + if changelogItems.Field == "status" { + transition := Transition{ + FromStatus: changelogItems.FromString, + ToStatus: changelogItems.ToString, + Author: history.Author.DisplayName, + AuthorEmail: history.Author.EmailAddress, + TransitionTime: history.Created, + } + jiraTransitionResult.Transitions = append(jiraTransitionResult.Transitions, transition) + } + } + } + } + transitionCheckResponse.Tasks = append(transitionCheckResponse.Tasks, jiraTransitionResult) + } + + return transitionCheckResponse +} + +// Helper function to extract description text from JIRA description field +func getDescription(desc interface{}) string { + if desc == nil { + return "" + } + + // Handle the Atlassian Document Format (ADF) structure + if descMap, ok := desc.(map[string]interface{}); ok { + if content, exists := descMap["content"]; exists { + if contentArray, ok := content.([]interface{}); ok { + var result strings.Builder + for _, item := range contentArray { + if itemMap, ok := item.(map[string]interface{}); ok { + if itemType, exists := itemMap["type"]; exists && itemType == "paragraph" { + if itemContent, exists := itemMap["content"]; exists { + if itemContentArray, ok := itemContent.([]interface{}); ok { + for _, textItem := range itemContentArray { + if textMap, ok := textItem.(map[string]interface{}); ok { + if textType, exists := textMap["type"]; exists && textType == "text" { + if text, exists := textMap["text"]; exists { + if textStr, ok := text.(string); ok { + result.WriteString(textStr) + } + } + } + } + } + } + } + } + } + } + return result.String() + } + } + } + + // Fallback to string representation + return fmt.Sprintf("%v", desc) +} + +// Helper function to get assignee display name or nil if not assigned +func getAssignee(assignee *jira.User) *string { + if assignee == nil { + return nil + } + return &assignee.DisplayName +} + +// Helper function to get time as string from JIRA time field +func getTimeAsString(timeField interface{}) string { + if timeField == nil { + return "" + } + + // Try to marshal to JSON and then unmarshal as string + jsonBytes, err := json.Marshal(timeField) + if err == nil { + var timeStr string + if json.Unmarshal(jsonBytes, &timeStr) == nil { + return timeStr + } + } + + // Use reflection to check the actual type + val := reflect.ValueOf(timeField) + if val.Kind() == reflect.String { + return val.String() + } + + // Try to access the Time field if it's a struct + if val.Kind() == reflect.Struct { + timeField := val.FieldByName("Time") + if timeField.IsValid() && timeField.CanInterface() { + if t, ok := timeField.Interface().(time.Time); ok { + return t.Format("2006-01-02T15:04:05.000-0700") + } + } + } + + // Fallback to string representation + return fmt.Sprintf("%v", timeField) +} + +// formatWorkflow formats workflow transitions into a readable string +func formatWorkflow(transitions []Transition) string { + if len(transitions) == 0 { + return "No transitions available" + } + + // Sort transitions by time (newest first) and reverse to get chronological order + sortedTransitions := make([]Transition, len(transitions)) + copy(sortedTransitions, transitions) + sort.Slice(sortedTransitions, func(i, j int) bool { + return sortedTransitions[i].TransitionTime > sortedTransitions[j].TransitionTime + }) + + // Extract status names in chronological order + statuses := make(map[string]bool) + var statusList []string + + // Process transitions in reverse order to get chronological sequence + for i := len(sortedTransitions) - 1; i >= 0; i-- { + transition := sortedTransitions[i] + fromStatus := transition.FromStatus + toStatus := transition.ToStatus + + if fromStatus != "" && !statuses[fromStatus] { + statuses[fromStatus] = true + statusList = append(statusList, fromStatus) + } + if toStatus != "" && !statuses[toStatus] { + statuses[toStatus] = true + statusList = append(statusList, toStatus) + } + } + + // If no transitions, return current status + if len(statusList) == 0 { + return "No workflow data" + } + + return strings.Join(statusList, " → ") +} + +// escapeMarkdown escapes pipe characters for markdown table +func escapeMarkdown(text string) string { + return strings.ReplaceAll(text, "|", "\\|") +} + +// generateMarkdownContent generates markdown content from JIRA data +func generateMarkdownContent(data TransitionCheckResponse) string { + tasks := data.Tasks + ticketCount := len(tasks) + + // Header + content := "# Jira Tickets Summary\n" + content += fmt.Sprintf("Found %d associated tickets.\n\n", ticketCount) + + // Table header + content += "| Key | Summary | Type | Priority | Workflow |\n" + content += "|-----|---------|------|----------|----------|\n" + + // Process each task + for _, task := range tasks { + key := task.Key + description := task.Description + taskType := task.Type + priority := task.Priority + transitions := task.Transitions + + // Format workflow + workflow := formatWorkflow(transitions) + + // Handle error cases + if taskType == "Error" { + description = task.Description + if description == "" { + description = "Error retrieving ticket data" + } + taskType = "Error" + priority = "N/A" + workflow = "N/A" + } + + // Escape pipe characters in content for markdown table + key = escapeMarkdown(key) + description = escapeMarkdown(description) + taskType = escapeMarkdown(taskType) + priority = escapeMarkdown(priority) + workflow = escapeMarkdown(workflow) + + // Add row to table + content += fmt.Sprintf("| %s | %s | %s | %s | %s |\n", key, description, taskType, priority, workflow) + } + + return content +} + +// GenerateMarkdownReport generates and writes markdown report +func GenerateMarkdownReport(data TransitionCheckResponse, outputFile string) error { + fmt.Println("Step 4: Generating markdown report...") + + content := generateMarkdownContent(data) + + // Determine markdown file path + markdownFile := "transformed_jira_data.md" + if outputFile != "transformed_jira_data.json" { + // If custom output file is specified, use similar name for markdown + baseName := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + markdownFile = baseName + ".md" + } + + // Write markdown file + if err := writeToFile(markdownFile, []byte(content)); err != nil { + return fmt.Errorf("error writing markdown file: %v", err) + } + + fmt.Printf("Markdown report saved to: %s\n", markdownFile) + return nil +} diff --git a/examples/jira/helper/main.go b/examples/jira/helper/main.go new file mode 100644 index 0000000..194d696 --- /dev/null +++ b/examples/jira/helper/main.go @@ -0,0 +1,424 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + + + +// Git-related functions + +// getBranchInfo returns current branch name, latest commit hash, and JIRA ID from latest commit +func getBranchInfo() (string, string, string, error) { + // Get current branch + branchCmd := exec.Command("git", "branch", "--show-current") + branchOutput, err := branchCmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("failed to get branch name: %v", err) + } + branchName := strings.TrimSpace(string(branchOutput)) + + // Get latest commit hash + commitCmd := exec.Command("git", "log", "-1", "--format=%H") + commitOutput, err := commitCmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("failed to get latest commit: %v", err) + } + commitHash := strings.TrimSpace(string(commitOutput)) + + // Get JIRA ID from latest commit message + subjectCmd := exec.Command("git", "log", "-1", "--format=%s") + subjectOutput, err := subjectCmd.Output() + if err != nil { + return "", "", "", fmt.Errorf("failed to get commit subject: %v", err) + } + subject := strings.TrimSpace(string(subjectOutput)) + + // Use default JIRA ID regex pattern to extract valid JIRA IDs + jiraIDRegex := "[A-Z]+-[0-9]+" + regex, err := regexp.Compile(jiraIDRegex) + if err != nil { + return branchName, commitHash, "", nil + } + + // Find all JIRA IDs in the commit message + matches := regex.FindAllString(subject, -1) + if len(matches) > 0 { + // Return the first valid JIRA ID found + return branchName, commitHash, matches[0], nil + } + + return branchName, commitHash, "", nil +} + +// validateCommit checks if a commit exists in the repository +func validateCommit(commit string) error { + cmd := exec.Command("git", "rev-parse", "--verify", commit) + if err := cmd.Run(); err != nil { + return fmt.Errorf("commit '%s' not found. Check fetch depth or commit existence", commit) + } + return nil +} + +// validateHEAD checks if HEAD commit exists in the repository +func validateHEAD() error { + cmd := exec.Command("git", "rev-parse", "--verify", "HEAD") + if err := cmd.Run(); err != nil { + return fmt.Errorf("HEAD commit not found. Repository may be empty or corrupted") + } + return nil +} + +// extractJiraIDs extracts JIRA IDs from git commit messages in a given range +func extractJiraIDs(startCommit, jiraIDRegex, currentJiraID string) ([]string, error) { + // Get commit messages from startCommit to HEAD + cmd := exec.Command("git", "log", "--pretty=format:%s", startCommit+"..HEAD") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get commit messages: %v", err) + } + + // Parse regex + regex, err := regexp.Compile(jiraIDRegex) + if err != nil { + return nil, fmt.Errorf("invalid JIRA ID regex: %v", err) + } + + // Extract JIRA IDs from commit messages + jiraIDs := make(map[string]bool) + + // Add current JIRA ID if it matches the pattern + if currentJiraID != "" && regex.MatchString(currentJiraID) { + jiraIDs[currentJiraID] = true + } + + // Extract from commit messages + lines := strings.Split(string(output), "\n") + for _, line := range lines { + matches := regex.FindAllString(line, -1) + for _, match := range matches { + jiraIDs[match] = true + } + } + + // Convert map to slice + var result []string + for jiraID := range jiraIDs { + if jiraID != "" { + result = append(result, jiraID) + } + } + + if len(result) == 0 { + fmt.Fprintf(os.Stderr, "⚠️ No JIRA IDs found in commit range %s..HEAD\n", startCommit) + } + + return result, nil +} + + + +// checkGitRepository checks if we're in a git repository +func checkGitRepository() error { + cmd := exec.Command("git", "rev-parse", "--git-dir") + if err := cmd.Run(); err != nil { + return fmt.Errorf("not in a git repository") + } + return nil +} + +// writeToFile writes data to a file +func writeToFile(filename string, data []byte) error { + // Create directory if it doesn't exist + dir := filepath.Dir(filename) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + } + + return os.WriteFile(filename, data, 0644) +} + +// displayUsage shows the usage information +func displayUsage() { + fmt.Println("JIRA Evidence Tool - Enhanced") + fmt.Println("") + fmt.Println("Usage:") + fmt.Println(" ./main [OPTIONS] ") + fmt.Println(" ./main [jira_id2] [jira_id3] ...") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" -r, --regex PATTERN JIRA ID regex pattern (default: '[A-Z]+-[0-9]+')") + fmt.Println(" -o, --output FILE Output file for JIRA data (default: transformed_jira_data.json)") + fmt.Println(" --extract-only Only extract JIRA IDs, don't fetch details") + fmt.Println(" --extract-from-git Extract JIRA IDs from git commits (legacy mode)") + fmt.Println(" -h, --help Display this help message") + fmt.Println("") + fmt.Println("Arguments:") + fmt.Println(" start_commit Starting commit hash (excluded from evidence filter)") + fmt.Println("") + fmt.Println("Environment Variables:") + fmt.Println(" JIRA_API_TOKEN JIRA API token") + fmt.Println(" JIRA_URL JIRA instance URL") + fmt.Println(" JIRA_USERNAME JIRA username") + fmt.Println(" JIRA_ID_REGEX JIRA ID regex pattern (can be overridden with -r)") + fmt.Println(" OUTPUT_FILE Output file path (can be overridden with -o)") + fmt.Println(" ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE Generate markdown report (true/false)") + fmt.Println("") + fmt.Println("Examples:") + fmt.Println(" ./main abc123def456") + fmt.Println(" ./main -r 'EV-\\d+' -o jira_results.json abc123def456") + fmt.Println(" ./main --extract-only abc123def456") + fmt.Println(" ./main EV-123 EV-456 EV-789") +} + + + +func main() { + // Parse command line flags + var ( + jiraIDRegex = flag.String("r", "", "JIRA ID regex pattern") + outputFile = flag.String("o", "", "Output file for JIRA data") + extractOnly = flag.Bool("extract-only", false, "Only extract JIRA IDs, don't fetch details") + extractFromGit = flag.Bool("extract-from-git", false, "Extract JIRA IDs from git commits (legacy mode)") + help = flag.Bool("h", false, "Display help message") + helpLong = flag.Bool("help", false, "Display help message") + ) + flag.Parse() + + // Handle help flags + if *help || *helpLong { + displayUsage() + return + } + + // Handle legacy extract-from-git mode + if *extractFromGit { + args := flag.Args() + if len(args) < 2 { + fmt.Println("Usage: ./main --extract-from-git ") + os.Exit(1) + } + + startCommit := args[0] + regex := args[1] + + // Get branch info + branchName, commitHash, currentJiraID, err := getBranchInfo() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting branch info: %v\n", err) + os.Exit(1) + } + + fmt.Printf("BRANCH_NAME: %s\n", branchName) + fmt.Printf("JIRA ID: %s\n", currentJiraID) + fmt.Printf("START_COMMIT: %s\n", commitHash) + + // Validate HEAD + if err := validateHEAD(); err != nil { + fmt.Fprintf(os.Stderr, "❌ %v\n", err) + os.Exit(0) // Exit gracefully as per original behavior + } + + // Validate commit + if err := validateCommit(startCommit); err != nil { + fmt.Fprintf(os.Stderr, "❌ %v\n", err) + os.Exit(0) // Exit gracefully as per original behavior + } + + // Extract JIRA IDs + jiraIDs, err := extractJiraIDs(startCommit, regex, currentJiraID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error extracting JIRA IDs: %v\n", err) + os.Exit(1) + } + + if len(jiraIDs) == 0 { + fmt.Println("No JIRA IDs found") + os.Exit(0) + } + + // Print comma-separated JIRA IDs + fmt.Println(strings.Join(jiraIDs, ",")) + return + } + + // Get remaining arguments + args := flag.Args() + + // Check if we have a start_commit argument + if len(args) == 0 { + fmt.Println("Error: start_commit is required") + displayUsage() + os.Exit(1) + } + + startCommit := args[0] + + // Check if we have arguments for direct JIRA ID processing (only if not in extract-only mode) + if !*extractOnly && len(args) > 0 && !strings.HasPrefix(args[0], "-") { + // Check if the argument matches the JIRA ID pattern + pattern := "[A-Z]+-[0-9]+" + if *jiraIDRegex != "" { + pattern = *jiraIDRegex + } + regex, err := regexp.Compile(pattern) + if err == nil && regex.MatchString(args[0]) { + // Direct JIRA ID processing mode + processJiraIDs(args) + return + } + // If it doesn't match the pattern, treat it as a start commit + } + + // Set default values from environment variables if not provided + if *jiraIDRegex == "" { + *jiraIDRegex = os.Getenv("JIRA_ID_REGEX") + if *jiraIDRegex == "" { + *jiraIDRegex = "[A-Z]+-[0-9]+" + } + } + + if *outputFile == "" { + *outputFile = os.Getenv("OUTPUT_FILE") + if *outputFile == "" { + *outputFile = "transformed_jira_data.json" + } + } + + // Check if we're in a git repository + if err := checkGitRepository(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Println("=== JIRA Details Fetching Process ===") + fmt.Printf("Start Commit: %s\n", startCommit) + fmt.Printf("JIRA ID Regex: %s\n", *jiraIDRegex) + fmt.Printf("Output File: %s\n", *outputFile) + fmt.Println("") + + // Step 1: Extract JIRA IDs from git commits + fmt.Println("Step 1: Extracting JIRA IDs from git commits...") + + // Get branch info + branchName, commitHash, currentJiraID, err := getBranchInfo() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting branch info: %v\n", err) + os.Exit(1) + } + + // Display branch information + fmt.Printf("Branch: %s\n", branchName) + fmt.Printf("Latest Commit: %s\n", commitHash) + + // Validate HEAD + if err := validateHEAD(); err != nil { + fmt.Fprintf(os.Stderr, "❌ %v\n", err) + os.Exit(0) // Exit gracefully as per original behavior + } + + // Validate commit + if err := validateCommit(startCommit); err != nil { + fmt.Fprintf(os.Stderr, "❌ %v\n", err) + os.Exit(0) // Exit gracefully as per original behavior + } + + // Extract JIRA IDs + jiraIDs, err := extractJiraIDs(startCommit, *jiraIDRegex, currentJiraID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error extracting JIRA IDs: %v\n", err) + os.Exit(1) + } + + if len(jiraIDs) == 0 { + fmt.Println("No JIRA IDs found in commit range") + os.Exit(0) + } + + fmt.Printf("Found JIRA IDs: %s\n", strings.Join(jiraIDs, ", ")) + + // If extract-only mode, just return the JIRA IDs + if *extractOnly { + fmt.Println(strings.Join(jiraIDs, ",")) + return + } + + // Step 2: Fetch JIRA details + fmt.Println("") + fmt.Println("Step 2: Fetching JIRA details...") + + // Create JIRA client and process JIRA IDs + jiraClient, err := NewJiraClient() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating JIRA client: %v\n", err) + os.Exit(1) + } + + // Process JIRA IDs and get results + response := jiraClient.FetchJiraDetails(jiraIDs) + + // Step 3: Write results to file + fmt.Println("") + fmt.Println("Step 3: Writing results...") + + jsonBytes, err := json.MarshalIndent(response, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + os.Exit(1) + } + + if err := writeToFile(*outputFile, jsonBytes); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("JIRA data saved to: %s\n", *outputFile) + + // Step 4: Generate markdown report if requested + attachMarkdown := os.Getenv("ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE") + if attachMarkdown == "true" { + if err := GenerateMarkdownReport(response, *outputFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to generate markdown report: %v\n", err) + // Don't exit on markdown generation failure + } + } else { + fmt.Println("Step 4: Skipping markdown report generation (ATTACH_OPTIONAL_CUSTOM_MARKDOWN_TO_EVIDENCE != 'true')") + } + + fmt.Println("") + fmt.Println("=== Process completed successfully ===") +} + +// processJiraIDs handles direct JIRA ID processing (original functionality) +func processJiraIDs(jiraIDs []string) { + // Create a new Jira client + jiraClient, err := NewJiraClient() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating JIRA client: %v\n", err) + os.Exit(1) + } + + // Get response + response := jiraClient.FetchJiraDetails(jiraIDs) + + // marshal the response to JSON + jsonBytes, err := json.Marshal(response) + if err != nil { + fmt.Println("Error marshaling JSON", err) + os.Exit(1) + } + + // return response to caller through stdout + os.Stdout.Write(jsonBytes) +} + +