diff --git a/cmd/test.go b/cmd/test.go index 70ecb4f2..071e712e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -21,6 +21,7 @@ import ( "strconv" "strings" "time" + "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" @@ -39,6 +40,9 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { filteredOperations string operationsHeaders string oAuth2Context string + autoFileIssue bool + githubToken string + githubRepo string ) var testCmd = &cobra.Command{ @@ -205,6 +209,34 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) + // Auto-file GitHub Issue if test failed and flag is set + if !success && autoFileIssue { + if githubToken == "" || githubRepo == "" { + fmt.Println("Warning: --auto-file-issue requires --github-token and --github-repo flags") + } else { + // Collect failed operations + var failedOps []string + testResultDetails, err := mc.GetTestResultDetails(testResultID) + if err == nil { + for _, tc := range testResultDetails.TestCaseResults { + if !tc.Success { + failedOps = append(failedOps, tc.OperationName) + } + } + } + + title := fmt.Sprintf("[Microcks] Test Failed: %s", serviceRef) + body := connectors.BuildIssueBody(serviceRef, testEndpoint, runnerType, serverAddr, testResultID, failedOps) + + err = connectors.CreateGitHubIssue(githubToken, githubRepo, title, body) + if err != nil { + fmt.Printf("Warning: Failed to create GitHub Issue: %s\n", err) + } else { + fmt.Printf("GitHub Issue created successfully in %s\n", githubRepo) + } + } + } + if !success { os.Exit(1) } @@ -216,6 +248,9 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { testCmd.Flags().StringVar(&filteredOperations, "filteredOperations", "", "List of operations to launch a test for") testCmd.Flags().StringVar(&operationsHeaders, "operationsHeaders", "", "Override of operations headers as JSON string") testCmd.Flags().StringVar(&oAuth2Context, "oAuth2Context", "", "Spec of an OAuth2 client context as JSON string") + testCmd.Flags().BoolVar(&autoFileIssue, "auto-file-issue", false, "Automatically create a GitHub Issue when test fails") +testCmd.Flags().StringVar(&githubToken, "github-token", "", "GitHub personal access token for creating issues") +testCmd.Flags().StringVar(&githubRepo, "github-repo", "", "GitHub repository in format 'owner/repo' for filing issues") return testCmd } diff --git a/pkg/connectors/github_client.go b/pkg/connectors/github_client.go new file mode 100644 index 00000000..e56f6f9a --- /dev/null +++ b/pkg/connectors/github_client.go @@ -0,0 +1,111 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package connectors + + import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + ) + + // GitHubIssueRequest represents the payload for creating a GitHub Issue + type GitHubIssueRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Labels []string `json:"labels"` + } + + // CreateGitHubIssue creates a GitHub Issue using the GitHub REST API + func CreateGitHubIssue(token, repo, title, body string) error { + url := fmt.Sprintf("https://api.github.com/repos/%s/issues", repo) + + issueReq := GitHubIssueRequest{ + Title: title, + Body: body, + Labels: []string{"bug", "microcks-test-failure"}, + } + + payload, err := json.Marshal(issueReq) + if err != nil { + return fmt.Errorf("failed to marshal issue request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to call GitHub API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + return nil + } + + // BuildIssueBody builds a structured markdown body for the GitHub Issue + func BuildIssueBody(serviceRef, testEndpoint, runnerType, serverAddr, testResultID string, failedOps []string) string { + ops := "" + for _, op := range failedOps { + ops += fmt.Sprintf("- ❌ `%s`\n", op) + } + if ops == "" { + ops = "- ❌ Test failed (no operation details available)\n" + } + + return fmt.Sprintf(`## 🔴 Microcks Contract Test Failed + + ### Failed Operations + %s + + ### Test Details + | Field | Value | + |-------|-------| + | **Service** | %s | + | **Endpoint** | %s | + | **Runner** | %s | + + ### Reproduction Command + `+"```"+`bash + microcks test '%s' %s %s \ + --microcksURL= \ + --keycloakClientId= \ + --keycloakClientSecret= + `+"```"+` + + ### Full Test Results + [View on Microcks UI](%s/#/tests/%s) + + --- + *This issue was automatically created by [microcks-cli](https://github.com/microcks/microcks-cli)*`, + ops, + serviceRef, testEndpoint, runnerType, + serviceRef, testEndpoint, runnerType, + serverAddr, testResultID, + ) + } \ No newline at end of file diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index f76884b8..7568219d 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "encoding/json" + errs "errors" "fmt" "io" @@ -50,6 +51,7 @@ type MicrocksClient interface { SetOAuthToken(oauthToken string) CreateTestResult(serviceID string, testEndpoint string, runnerType string, secretName string, timeout int64, filteredOperations string, operationsHeaders string, oAuth2Context string) (string, error) GetTestResult(testResultID string) (*TestResultSummary, error) + GetTestResultDetails(testResultID string) (*TestResult, error) UploadArtifact(specificationFilePath string, mainArtifact bool) (string, error) DownloadArtifact(artifactURL string, mainArtifact bool, secret string) (string, error) } @@ -66,7 +68,28 @@ type TestResultSummary struct { Success bool `json:"success"` InProgress bool `json:"inProgress"` } +// TestStepResult represents a single step result in a test case +type TestStepResult struct { + RequestName string `json:"requestName"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// TestCaseResult represents results for one operation +type TestCaseResult struct { + OperationName string `json:"operationName"` + Success bool `json:"success"` + TestStepResults []TestStepResult `json:"testStepResults"` +} +// TestResult represents the full detailed test result +type TestResult struct { + ID string `json:"id"` + Success bool `json:"success"` + InProgress bool `json:"inProgress"` + TestedEndpoint string `json:"testedEndpoint"` + TestCaseResults []TestCaseResult `json:"testCaseResults"` +} // HeaderDTO represents an operation header passed for Test type HeaderDTO struct { Name string `json:"name"` @@ -570,3 +593,32 @@ func ensureValidOAuth2Context(oAuth2Context string) bool { } return true } +// GetTestResultDetails fetches full test result including per-operation results. +func (c *microcksClient) GetTestResultDetails(testResultID string) (*TestResult, error) { + rel := &url.URL{Path: "tests/" + testResultID} + u := c.APIURL.ResolveReference(rel) + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AuthToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := TestResult{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse detailed test result: %w", err) + } + return &result, nil +} \ No newline at end of file