From 359ff7c751b5db75b30ff8f300438dc509754aca Mon Sep 17 00:00:00 2001 From: Marc Beinder Date: Tue, 18 Nov 2025 09:16:05 -0600 Subject: [PATCH 1/4] [Actions] Add General HTTP Driver to Enrich Pull Request --- .../enrichPullRequest/drivers/drivers.go | 2 + .../drivers/general/general.go | 226 ++++++++++++++++++ actions/github/enrichPullRequest/main.go | 5 + 3 files changed, 233 insertions(+) create mode 100644 actions/github/enrichPullRequest/drivers/general/general.go diff --git a/actions/github/enrichPullRequest/drivers/drivers.go b/actions/github/enrichPullRequest/drivers/drivers.go index 84236bc..21f5de5 100644 --- a/actions/github/enrichPullRequest/drivers/drivers.go +++ b/actions/github/enrichPullRequest/drivers/drivers.go @@ -2,11 +2,13 @@ package drivers const BranchName = "branch-name" const Jira = "jira" +const General = "general" func Validate(driver string) bool { validDrivers := []string{ BranchName, Jira, + General, } for _, validDriver := range validDrivers { diff --git a/actions/github/enrichPullRequest/drivers/general/general.go b/actions/github/enrichPullRequest/drivers/general/general.go new file mode 100644 index 0000000..3081f26 --- /dev/null +++ b/actions/github/enrichPullRequest/drivers/general/general.go @@ -0,0 +1,226 @@ +package general + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" + + "github.com/EncoreDigitalGroup/golib/logger" + + 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 + Endpoint string + AuthToken string + TicketID string +} + +type Label struct { + Title string `json:"title"` + Description *string `json:"description"` + Color *string `json:"color"` +} + +type APIResponse struct { + Title string `json:"title"` + Description *string `json:"description"` + Assignee *string `json:"assignee"` + Labels *[]Label `json:"labels"` +} + +type HTTPError struct { + StatusCode int + Message string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) +} + +func Format(gh github.GitHub) { + branchName, err := gh.GetBranchName() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + endpoint := os.Getenv("OPT_HTTP_ENDPOINT") + if endpoint == "" { + logger.Error("OPT_HTTP_ENDPOINT is not set") + os.Exit(1) + } + + authToken := os.Getenv("OPT_AUTH_TOKEN") + if authToken == "" { + logger.Error("OPT_AUTH_TOKEN is not set") + os.Exit(1) + } + + ticketID, err := branchname.GetIssueKeyFromBranchName(branchName) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + if ticketID == "" { + logger.Error("Ticket ID is empty") + return + } + + config := Configuration{ + Enable: true, + Endpoint: endpoint, + AuthToken: authToken, + TicketID: ticketID, + } + + apiResponse, err := getTicketInfo(config) + if err != nil { + var httpErr *HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == 404 { + logger.Errorf("Ticket not found: %s", ticketID) + comment := fmt.Sprintf("**Ticket Not Found**\n\n"+ + "Unable to find ticket information for: `%s`\n\n"+ + "Please verify that the ticket ID in your branch name is correct.", ticketID) + gh.AddPRComment(comment) + return + } + + logger.Errorf("HTTP API error: %v", httpErr) + comment := "**API Error**\n\n" + + "Failed to fetch ticket information due to an API error. " + + "Please check the GitHub Action logs for specific error information." + gh.AddPRComment(comment) + return + } + + logger.Errorf("Failed to get ticket info: %v", err) + comment := "Failed to get information from the API.\n\n" + + "Please check the GitHub Action logs for specific error information." + gh.AddPRComment(comment) + return + } + + newPRTitle := apiResponse.Title + if newPRTitle == "" { + logger.Error("API response missing required 'title' field") + comment := "**Invalid API Response**\n\n" + + "The API response is missing the required 'title' field." + gh.AddPRComment(comment) + return + } + + var newPRDescription string + if apiResponse.Description != nil { + newPRDescription = *apiResponse.Description + gh.UpdatePR(newPRTitle, newPRDescription) + } else { + gh.UpdatePRTitle(newPRTitle) + } + + if apiResponse.Assignee != nil && *apiResponse.Assignee != "" { + logger.Infof("Assignee information received: %s (Note: GitHub assignee setting not implemented)", *apiResponse.Assignee) + } + + if apiResponse.Labels != nil { + for _, label := range *apiResponse.Labels { + if label.Title == "" { + continue + } + + description := "" + if label.Description != nil { + description = *label.Description + } + + color := "" + if label.Color != nil { + color = *label.Color + } + + gh.EnsureLabelExists(label.Title, description, color) + gh.AddLabelToPR(label.Title) + } + } +} + +func getTicketInfo(config Configuration) (*APIResponse, error) { + if !config.Enable { + return nil, errors.New("general driver is not enabled") + } + + if config.Endpoint == "" || config.AuthToken == "" || config.TicketID == "" { + return nil, errors.New("missing required configuration: endpoint, auth token, or ticket ID") + } + + reqURL, err := url.Parse(config.Endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %v", err) + } + + query := reqURL.Query() + query.Set("id", config.TicketID) + reqURL.RawQuery = query.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+config.AuthToken) + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make HTTP request: %v", err) + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + logger.Errorf("Failed to close response body: %v", err) + } + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + if resp.StatusCode == 404 { + return nil, &HTTPError{ + StatusCode: resp.StatusCode, + Message: "ticket not found", + } + } + + if resp.StatusCode != 200 { + return nil, &HTTPError{ + StatusCode: resp.StatusCode, + Message: string(body), + } + } + + var apiResponse APIResponse + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + return &apiResponse, nil +} diff --git a/actions/github/enrichPullRequest/main.go b/actions/github/enrichPullRequest/main.go index 01e9aa7..e751b62 100644 --- a/actions/github/enrichPullRequest/main.go +++ b/actions/github/enrichPullRequest/main.go @@ -10,6 +10,7 @@ import ( "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" + httpdriver "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/drivers/general" "github.com/EncoreDigitalGroup/ci-workflows/actions/github/enrichPullRequest/support/github" ) @@ -54,6 +55,10 @@ func main() { if strategy == drivers.Jira { jira.Format(gh) } + + if strategy == drivers.General { + httpdriver.Format(gh) + } } func checkEnvVars() { From af84fcb773cc561498ce251451680c14951b7ee2 Mon Sep 17 00:00:00 2001 From: Marc Beinder <50760632+onairmarc@users.noreply.github.com> Date: Tue, 18 Nov 2025 09:24:43 -0600 Subject: [PATCH 2/4] Update actions/github/enrichPullRequest/drivers/general/general.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- actions/github/enrichPullRequest/drivers/general/general.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/actions/github/enrichPullRequest/drivers/general/general.go b/actions/github/enrichPullRequest/drivers/general/general.go index 3081f26..c2772e1 100644 --- a/actions/github/enrichPullRequest/drivers/general/general.go +++ b/actions/github/enrichPullRequest/drivers/general/general.go @@ -183,9 +183,7 @@ func getTicketInfo(config Configuration) (*APIResponse, error) { req.Header.Set("Authorization", "Bearer "+config.AuthToken) req.Header.Set("Accept", "application/json") - client := &http.Client{ - Timeout: 30 * time.Second, - } + client := &http.Client{} resp, err := client.Do(req) if err != nil { From 5d2f3d6e956ea7e3f4b5d25ebc42fad9ea4b004f Mon Sep 17 00:00:00 2001 From: Marc Beinder <50760632+onairmarc@users.noreply.github.com> Date: Tue, 18 Nov 2025 09:26:07 -0600 Subject: [PATCH 3/4] Update actions/github/enrichPullRequest/drivers/general/general.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../github/enrichPullRequest/drivers/general/general.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/actions/github/enrichPullRequest/drivers/general/general.go b/actions/github/enrichPullRequest/drivers/general/general.go index c2772e1..f5127e4 100644 --- a/actions/github/enrichPullRequest/drivers/general/general.go +++ b/actions/github/enrichPullRequest/drivers/general/general.go @@ -189,12 +189,11 @@ func getTicketInfo(config Configuration) (*APIResponse, error) { if err != nil { return nil, fmt.Errorf("failed to make HTTP request: %v", err) } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { + defer func() { + if err := resp.Body.Close(); err != nil { logger.Errorf("Failed to close response body: %v", err) } - }(resp.Body) + }() body, err := io.ReadAll(resp.Body) if err != nil { From 7a05f13f5f97d397be2975addd59e3d653563eaf Mon Sep 17 00:00:00 2001 From: Marc Beinder Date: Tue, 18 Nov 2025 09:49:01 -0600 Subject: [PATCH 4/4] [Actions] Add General HTTP API Strategy to Enrich Pull Request --- actions/github/enrichPullRequest/action.yml | 12 +- docs/Actions/GitHub/enrichPullRequest.md | 172 ++++++++++++++++++-- 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/actions/github/enrichPullRequest/action.yml b/actions/github/enrichPullRequest/action.yml index d1cd070..6e595cb 100644 --- a/actions/github/enrichPullRequest/action.yml +++ b/actions/github/enrichPullRequest/action.yml @@ -55,6 +55,14 @@ inputs: description: 'Name of the sync label' required: false default: 'jira-sync-complete' + httpEndpoint: + type: string + description: 'Endpoint for the action to use' + required: false + authToken: + type: string + description: 'Authentication token for the endpoint' + required: false runs: using: 'docker' image: 'docker://ghcr.io/encoredigitalgroup/gh-action-enrich-pull-request:latest' @@ -71,4 +79,6 @@ runs: 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 + OPT_ENABLE_JIRA_SYNC_DESCRIPTION: ${{ inputs.jiraEnableSyncDescription }} + OPT_HTTP_ENDPOINT: ${{ inputs.httpEndpoint }} + OPT_AUTH_TOKEN: ${{ inputs.authToken }} \ No newline at end of file diff --git a/docs/Actions/GitHub/enrichPullRequest.md b/docs/Actions/GitHub/enrichPullRequest.md index f4c0ab3..8b4058b 100644 --- a/docs/Actions/GitHub/enrichPullRequest.md +++ b/docs/Actions/GitHub/enrichPullRequest.md @@ -13,7 +13,7 @@ multiple strategies including branch name parsing and Jira integration, providin ## Features -- **Multiple Enrichment Strategies**: Support for branch-name and Jira strategies +- **Multiple Enrichment Strategies**: Support for branch-name, Jira, and general HTTP API 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 @@ -36,20 +36,22 @@ multiple strategies including branch name parsing and Jira integration, providin ## 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 | +| 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", "jira", or "general" | +| `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 | +| `httpEndpoint` | string | ❌ | `""` | HTTP API endpoint URL (required for general strategy) | +| `authToken` | string | ❌ | `""` | Authentication token for HTTP API (required for general strategy) | ## Action Implementation @@ -88,6 +90,43 @@ Integrates with Jira to fetch issue information and enrich PRs: - Creates sync completion labels (enabled by default) - Prevents duplicate syncing +### General HTTP API Strategy + +Integrates with any HTTP API that follows the expected response format: + +**Features:** + +- Fetches ticket information from any HTTP API endpoint +- Supports Bearer token authentication +- Updates PR title from API response +- Optionally syncs description from API +- Automatic label creation and assignment +- Error handling with PR comments for missing tickets + +**API Response Format:** + +```json +{ + "title": "Required: Issue title for PR", + "description": "Optional: Issue description", + "assignee": "Optional: Assignee username", + "labels": [ + { + "title": "Required: Label name", + "description": "Optional: Label description", + "color": "Optional: Hex color code (without #)" + } + ] +} +``` + +**API Request:** + +- Method: GET +- URL: `{httpEndpoint}?id={ticket_id}` +- Headers: `Authorization: Bearer {authToken}`, `Accept: application/json` +- Timeout: 30 seconds + ## Usage Examples ### Basic Branch Name Enrichment @@ -143,6 +182,32 @@ jobs: jiraEnableSyncDescription: true ``` +### General HTTP API Integration + +```yaml +name: Enrich PR with Custom API +on: + pull_request: + types: [ opened, synchronize ] + +jobs: + enrich-pr: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Enrich with Custom API + 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: "general" + httpEndpoint: ${{ vars.TICKET_API_ENDPOINT }} + authToken: ${{ secrets.TICKET_API_TOKEN }} +``` + ### Custom Formatting Rules ```yaml @@ -268,6 +333,56 @@ When `jiraEnableSyncLabel: true`: - Label description: "Indicates that Jira synchronization has been completed for this PR" - Prevents duplicate syncing on subsequent runs +## General HTTP API Integration Details + +### Authentication + +```yaml +# Using organization variables and secrets +httpEndpoint: ${{ vars.TICKET_API_ENDPOINT }} # https://api.yourcompany.com/tickets +authToken: ${{ secrets.TICKET_API_TOKEN }} # Bearer token for API access +``` + +### API Implementation Requirements + +**Endpoint Requirements:** + +- Must accept GET requests with ticket ID as query parameter: `?id={ticket_id}` +- Must support Bearer token authentication +- Must return JSON response with required fields +- Should respond within 30 seconds (request timeout) + +**Response Requirements:** + +```json +{ + "title": "string (required)", + "description": "string (optional)", + "assignee": "string (optional)", + "labels": [ + { + "title": "string (required)", + "description": "string (optional)", + "color": "string (optional, hex without #)" + } + ] +} +``` + +**Error Handling:** + +- 404 responses trigger "Ticket Not Found" PR comments +- Non-200 responses trigger "API Error" PR comments +- Network/timeout errors trigger generic error comments + +### Ticket ID Extraction + +The general strategy uses the same branch name parsing as the branch-name strategy: + +- Extracts ticket ID using pattern: `[A-Z]+-[0-9]+` +- Examples: `feature/PROJ-123-description` → `PROJ-123` +- Calls API with: `GET {endpoint}?id=PROJ-123` + ## Required Permissions The GitHub token must have the following permissions: @@ -318,6 +433,13 @@ customFormatting: "comp:Component,svc:Service,lib:Library,util:Utility,cfg:Confi - Check Jira token permissions - Ensure issue key exists in Jira +**General API Connection Issues** + +- Verify API endpoint is accessible and returns proper format +- Check Bearer token permissions and validity +- Ensure ticket ID exists in the target system +- Verify API response follows required JSON structure + **Branch Pattern Mismatch** - Verify branch name follows supported patterns @@ -341,6 +463,15 @@ curl -H "Authorization: Bearer $JIRA_TOKEN" \ "https://yourorg.atlassian.net/rest/api/3/issue/PROJ-123" ``` +**API Connectivity Issues** + +```bash +# Test general API endpoint +curl -H "Authorization: Bearer $API_TOKEN" \ + -H "Accept: application/json" \ + "https://api.yourcompany.com/tickets?id=PROJ-123" +``` + **Permission Denied** ```yaml @@ -365,4 +496,13 @@ permissions: 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 +4. **Parent Relationships**: Properly structure issue hierarchies + +### General API Integration + +1. **API Design**: Follow the required JSON response format consistently +2. **Authentication**: Use secure Bearer tokens with appropriate scoping +3. **Error Handling**: Implement proper HTTP status codes (404 for not found) +4. **Performance**: Ensure API responds within 30 seconds +5. **Security**: Use environment variables and secrets for sensitive data +6. **Monitoring**: Log API requests for debugging and monitoring \ No newline at end of file