From 28e40e723fdd0f08ef80a6468258a177b0c48665 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 31 Oct 2025 14:11:57 +0000 Subject: [PATCH 01/19] Session agentic-session-1761919542: update --- components/backend/.dockerignore | 59 ++++++++ components/backend/types/bugfix.go | 96 +++++++++++++ .../manifests/crds/bugfixworkflows-crd.yaml | 129 ++++++++++++++++++ components/manifests/crds/kustomization.yaml | 1 + 4 files changed, 285 insertions(+) create mode 100644 components/backend/.dockerignore create mode 100644 components/backend/types/bugfix.go create mode 100644 components/manifests/crds/bugfixworkflows-crd.yaml diff --git a/components/backend/.dockerignore b/components/backend/.dockerignore new file mode 100644 index 000000000..bbc5ed710 --- /dev/null +++ b/components/backend/.dockerignore @@ -0,0 +1,59 @@ +# Go test and coverage files +*.test +*.out +*.prof +*.cpu +*.mem +coverage.html +coverage.out + +# Build artifacts +backend +main +ambient-code-backend +*.exe +*.dll +*.so +*.dylib + +# Development and debugging +tmp/ +gin-bin +gin.log +debug.log +.air.toml + +# Dependencies (rebuild in container) +vendor/ + +# Environment and secrets +.env +.env.local +private-key.pem +*.pem + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore + +# Documentation and non-code files +*.md +LICENSE +Makefile + +# Logs +*.log + +# macOS +.DS_Store + +# Go workspace +go.work +go.work.sum diff --git a/components/backend/types/bugfix.go b/components/backend/types/bugfix.go new file mode 100644 index 000000000..27cc04a0d --- /dev/null +++ b/components/backend/types/bugfix.go @@ -0,0 +1,96 @@ +package types + +// BugFix Workflow Data Structures +type BugFixWorkflow struct { + ID string `json:"id"` + GithubIssueNumber int `json:"githubIssueNumber"` + GithubIssueURL string `json:"githubIssueURL"` + Title string `json:"title"` + Description string `json:"description"` + BranchName string `json:"branchName"` + UmbrellaRepo *GitRepository `json:"umbrellaRepo,omitempty"` + SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` + JiraTaskKey *string `json:"jiraTaskKey,omitempty"` + LastSyncedAt *string `json:"lastSyncedAt,omitempty"` // RFC3339 format + WorkspacePath string `json:"workspacePath,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Project string `json:"project,omitempty"` + Phase string `json:"phase,omitempty"` // Initializing, Ready + Message string `json:"message,omitempty"` + BugFolderCreated bool `json:"bugFolderCreated,omitempty"` + BugfixMarkdownCreated bool `json:"bugfixMarkdownCreated,omitempty"` +} + +// CreateBugFixWorkflowRequest represents the request to create a BugFix Workspace +type CreateBugFixWorkflowRequest struct { + // Option 1: From GitHub Issue URL + GithubIssueURL *string `json:"githubIssueURL,omitempty"` + + // Option 2: From text description (creates GitHub Issue automatically) + TextDescription *TextDescriptionInput `json:"textDescription,omitempty"` + + // Common fields + UmbrellaRepo GitRepository `json:"umbrellaRepo" binding:"required"` + SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` + BranchName *string `json:"branchName,omitempty"` // Optional, auto-generated if not provided +} + +// TextDescriptionInput represents input for creating workspace from text description +type TextDescriptionInput struct { + Title string `json:"title" binding:"required,min=5,max=200"` + Symptoms string `json:"symptoms" binding:"required,min=20"` + ReproductionSteps *string `json:"reproductionSteps,omitempty"` + ExpectedBehavior *string `json:"expectedBehavior,omitempty"` + ActualBehavior *string `json:"actualBehavior,omitempty"` + AdditionalContext *string `json:"additionalContext,omitempty"` + TargetRepository string `json:"targetRepository" binding:"required"` // Where to create the GitHub Issue +} + +// UpdateBugFixWorkflowRequest represents updates to workspace +type UpdateBugFixWorkflowRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` + JiraTaskKey *string `json:"jiraTaskKey,omitempty"` + LastSyncedAt *string `json:"lastSyncedAt,omitempty"` +} + +// CreateBugFixSessionRequest represents the request to create a session +type CreateBugFixSessionRequest struct { + SessionType string `json:"sessionType" binding:"required"` // bug-review, bug-resolution-plan, bug-implement-fix, generic + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + SelectedAgents []string `json:"selectedAgents,omitempty"` // Agent personas + EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"` + ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"` +} + +// SyncJiraRequest represents the request to sync workspace to Jira +type SyncJiraRequest struct { + Force bool `json:"force,omitempty"` // Force sync even if recently synced +} + +// SyncJiraResponse represents the response from Jira sync +type SyncJiraResponse struct { + Success bool `json:"success"` + JiraTaskKey string `json:"jiraTaskKey,omitempty"` + JiraTaskURL string `json:"jiraTaskURL,omitempty"` + Created bool `json:"created"` // true if newly created, false if updated + Message string `json:"message,omitempty"` + LastSyncedAt string `json:"lastSyncedAt,omitempty"` +} + +// BugFixSession represents a session linked to a BugFix Workspace +type BugFixSession struct { + ID string `json:"id"` + WorkflowID string `json:"workflowId"` + SessionType string `json:"sessionType"` // bug-review, bug-resolution-plan, bug-implement-fix, generic + Title string `json:"title"` + Description string `json:"description,omitempty"` + Phase string `json:"phase"` // Pending, Creating, Running, Completed, Failed, Stopped + CreatedAt string `json:"createdAt"` + CompletedAt *string `json:"completedAt,omitempty"` + AgentPersonas []string `json:"agentPersonas,omitempty"` +} diff --git a/components/manifests/crds/bugfixworkflows-crd.yaml b/components/manifests/crds/bugfixworkflows-crd.yaml new file mode 100644 index 000000000..e6ce99952 --- /dev/null +++ b/components/manifests/crds/bugfixworkflows-crd.yaml @@ -0,0 +1,129 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: bugfixworkflows.vteam.ambient-code +spec: + group: vteam.ambient-code + names: + kind: BugFixWorkflow + listKind: BugFixWorkflowList + plural: bugfixworkflows + singular: bugfixworkflow + shortNames: + - bugfix + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: [githubIssueNumber, githubIssueURL, title, description, umbrellaRepo, branchName] + properties: + githubIssueNumber: + type: integer + description: "GitHub Issue number (e.g., 1234)" + githubIssueURL: + type: string + description: "Full GitHub Issue URL" + title: + type: string + minLength: 5 + maxLength: 200 + description: "Bug title" + description: + type: string + minLength: 20 + maxLength: 5000 + description: "Bug description" + umbrellaRepo: + type: object + description: "Spec repository for documentation" + required: [url] + properties: + url: + type: string + description: "GitHub repository URL" + branch: + type: string + default: "main" + description: "Base branch" + supportingRepos: + type: array + description: "Repositories where fix is implemented" + items: + type: object + required: [url] + properties: + url: + type: string + description: "GitHub repository URL" + branch: + type: string + default: "main" + description: "Base branch" + branchName: + type: string + description: "Feature branch name (format: bugfix/gh-{issue-number})" + jiraTaskKey: + type: string + description: "Jira Task key (e.g., PROJ-5678), populated after first sync" + lastSyncedAt: + type: string + format: date-time + description: "Last Jira sync timestamp (RFC3339 format)" + workspacePath: + type: string + description: "Filesystem path used by sessions" + createdBy: + type: string + description: "User ID who created the workspace" + status: + type: object + properties: + phase: + type: string + enum: + - "Initializing" + - "Ready" + default: "Initializing" + description: "Workspace lifecycle phase" + message: + type: string + description: "Human-readable status message" + bugFolderCreated: + type: boolean + default: false + description: "True if bug-{issue-number}/ exists in spec repo" + bugfixMarkdownCreated: + type: boolean + default: false + description: "True if bugfix-gh-{issue-number}.md exists" + subresources: + status: {} + additionalPrinterColumns: + - name: Issue + type: integer + description: GitHub Issue number + jsonPath: .spec.githubIssueNumber + - name: Phase + type: string + description: Current phase of the BugFix workflow + jsonPath: .status.phase + - name: Jira + type: string + description: Linked Jira Task key + jsonPath: .spec.jiraTaskKey + - name: Age + type: date + jsonPath: .metadata.creationTimestamp diff --git a/components/manifests/crds/kustomization.yaml b/components/manifests/crds/kustomization.yaml index 3ebcc58ce..b64827414 100644 --- a/components/manifests/crds/kustomization.yaml +++ b/components/manifests/crds/kustomization.yaml @@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - agenticsessions-crd.yaml +- bugfixworkflows-crd.yaml - projectsettings-crd.yaml - rfeworkflows-crd.yaml From be80ce2db04344bc21628a45ede8c23929c3ff98 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 31 Oct 2025 16:53:05 +0000 Subject: [PATCH 02/19] feat(bugfix): Phase 1 - Setup & Infrastructure (T001-T009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete foundational infrastructure for BugFix Workspace Type feature: Backend Infrastructure: - CRD CRUD operations (bugfix.go): Get, List, Upsert, Delete, UpdateStatus - GitHub Issues integration (issues.go): Parse, Validate, Create, Update, AddComment - Jira integration extensions: CreateTask, UpdateTask, AddComment, AddRemoteLink - Git operations (bugfix/git_operations.go): CreateBugFolder, UpdateMarkdown, GetContent Integration Features: - Support for both GitHub Issue URL and text description workflows - Bidirectional GitHub-Jira linking via remote links - Automated bug folder and documentation file creation in spec repos - Template generation for bugfix-gh-{issue-number}.md documentation Technical Details: - Uses existing GitHub App authentication with token caching - Jira Cloud and Server/DC support with auto-detection - Shallow git clones for performance - Context-aware cancellation for all API calls Dependencies: Phase 2 (API handlers) blocked until this is complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/backend/bugfix/git_operations.go | 296 ++++++++++++++++++ components/backend/crd/bugfix.go | 320 +++++++++++++++++++ components/backend/github/issues.go | 321 ++++++++++++++++++++ components/backend/jira/integration.go | 194 ++++++++++++ 4 files changed, 1131 insertions(+) create mode 100644 components/backend/bugfix/git_operations.go create mode 100644 components/backend/crd/bugfix.go create mode 100644 components/backend/github/issues.go diff --git a/components/backend/bugfix/git_operations.go b/components/backend/bugfix/git_operations.go new file mode 100644 index 000000000..b035cc8b9 --- /dev/null +++ b/components/backend/bugfix/git_operations.go @@ -0,0 +1,296 @@ +package bugfix + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "ambient-code-backend/git" +) + +// CreateBugFolder creates a bug-{issue-number}/ folder in the spec repository +// Returns error if folder creation or commit fails +func CreateBugFolder(ctx context.Context, specRepoURL string, issueNumber int, branchName, token, userEmail, userName string) error { + // Pre-validate push access + if err := git.ValidatePushAccess(ctx, specRepoURL, token); err != nil { + return fmt.Errorf("cannot write to spec repo: %w. Check repository permissions", err) + } + + // Create temporary directory for clone + repoDir, err := os.MkdirTemp("", fmt.Sprintf("bugfix-%d-*", issueNumber)) + if err != nil { + return fmt.Errorf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(repoDir) + + // Inject token into URL for authentication + authenticatedURL, err := git.InjectGitHubToken(specRepoURL, token) + if err != nil { + return fmt.Errorf("failed to inject token: %v", err) + } + + // Clone repository (shallow clone for speed) + cloneArgs := []string{"clone", "--depth", "1", "--branch", branchName, authenticatedURL, repoDir} + cloneCmd := exec.CommandContext(ctx, "git", cloneArgs...) + if out, err := cloneCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %w (output: %s)", err, string(out)) + } + + // Create bug folder + bugFolderPath := filepath.Join(repoDir, fmt.Sprintf("bug-%d", issueNumber)) + if err := os.MkdirAll(bugFolderPath, 0755); err != nil { + return fmt.Errorf("failed to create bug folder: %v", err) + } + + // Create README.md in bug folder + readmePath := filepath.Join(bugFolderPath, "README.md") + readmeContent := fmt.Sprintf("# Bug #%d\n\nThis folder contains all documentation and artifacts related to GitHub Issue #%d.\n", issueNumber, issueNumber) + if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil { + return fmt.Errorf("failed to create README.md: %v", err) + } + + // Configure git user + if userEmail == "" { + userEmail = "vteam@ambient-code.com" + } + if userName == "" { + userName = "vTeam" + } + + configEmailCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "config", "user.email", userEmail) + if err := configEmailCmd.Run(); err != nil { + return fmt.Errorf("failed to configure git user.email: %v", err) + } + + configNameCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "config", "user.name", userName) + if err := configNameCmd.Run(); err != nil { + return fmt.Errorf("failed to configure git user.name: %v", err) + } + + // Stage bug folder + addCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "add", fmt.Sprintf("bug-%d", issueNumber)) + if out, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add failed: %w (output: %s)", err, string(out)) + } + + // Commit changes + commitMsg := fmt.Sprintf("Create bug folder for issue #%d", issueNumber) + commitCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "commit", "-m", commitMsg) + if out, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git commit failed: %w (output: %s)", err, string(out)) + } + + // Push changes using git config to inject token + cfg := fmt.Sprintf("url.https://x-access-token:%s@github.com/.insteadOf=https://github.com/", token) + pushCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "-c", cfg, "push", "-u", "origin", branchName) + if out, err := pushCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git push failed: %w (output: %s)", err, string(out)) + } + + return nil +} + +// CreateOrUpdateBugfixMarkdown creates or updates the bugfix-gh-{issue-number}.md file +// If the file exists, it appends the new content to the appropriate section +func CreateOrUpdateBugfixMarkdown(ctx context.Context, specRepoURL string, issueNumber int, branchName, token, userEmail, userName, githubIssueURL, jiraTaskURL, sectionName, content string) error { + // Pre-validate push access + if err := git.ValidatePushAccess(ctx, specRepoURL, token); err != nil { + return fmt.Errorf("cannot write to spec repo: %w", err) + } + + // Create temporary directory for clone + repoDir, err := os.MkdirTemp("", fmt.Sprintf("bugfix-%d-*", issueNumber)) + if err != nil { + return fmt.Errorf("failed to create temp directory: %v", err) + } + defer os.RemoveAll(repoDir) + + // Inject token into URL for authentication + authenticatedURL, err := git.InjectGitHubToken(specRepoURL, token) + if err != nil { + return fmt.Errorf("failed to inject token: %v", err) + } + + // Clone repository + cloneArgs := []string{"clone", "--depth", "1", "--branch", branchName, authenticatedURL, repoDir} + cloneCmd := exec.CommandContext(ctx, "git", cloneArgs...) + if out, err := cloneCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %w (output: %s)", err, string(out)) + } + + bugFolderPath := filepath.Join(repoDir, fmt.Sprintf("bug-%d", issueNumber)) + bugfixFilePath := filepath.Join(bugFolderPath, fmt.Sprintf("bugfix-gh-%d.md", issueNumber)) + + // Check if file exists + var fileContent string + if _, err := os.Stat(bugfixFilePath); os.IsNotExist(err) { + // Create new file with template + fileContent = generateBugfixTemplate(issueNumber, githubIssueURL, jiraTaskURL) + } else { + // Read existing file + existingBytes, err := os.ReadFile(bugfixFilePath) + if err != nil { + return fmt.Errorf("failed to read existing bugfix.md: %v", err) + } + fileContent = string(existingBytes) + } + + // Append content to the appropriate section + fileContent = appendToSection(fileContent, sectionName, content) + + // Write updated content + if err := os.WriteFile(bugfixFilePath, []byte(fileContent), 0644); err != nil { + return fmt.Errorf("failed to write bugfix.md: %v", err) + } + + // Configure git user + if userEmail == "" { + userEmail = "vteam@ambient-code.com" + } + if userName == "" { + userName = "vTeam" + } + + configEmailCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "config", "user.email", userEmail) + if err := configEmailCmd.Run(); err != nil { + return fmt.Errorf("failed to configure git user.email: %v", err) + } + + configNameCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "config", "user.name", userName) + if err := configNameCmd.Run(); err != nil { + return fmt.Errorf("failed to configure git user.name: %v", err) + } + + // Stage changes + addCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "add", fmt.Sprintf("bug-%d", issueNumber)) + if out, err := addCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add failed: %w (output: %s)", err, string(out)) + } + + // Commit changes + commitMsg := fmt.Sprintf("Update bugfix documentation for issue #%d: %s", issueNumber, sectionName) + commitCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "commit", "-m", commitMsg) + if out, err := commitCmd.CombinedOutput(); err != nil { + // Check if no changes to commit (not an error) + if strings.Contains(string(out), "nothing to commit") { + return nil + } + return fmt.Errorf("git commit failed: %w (output: %s)", err, string(out)) + } + + // Push changes + cfg := fmt.Sprintf("url.https://x-access-token:%s@github.com/.insteadOf=https://github.com/", token) + pushCmd := exec.CommandContext(ctx, "git", "-C", repoDir, "-c", cfg, "push", "-u", "origin", branchName) + if out, err := pushCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git push failed: %w (output: %s)", err, string(out)) + } + + return nil +} + +// GetBugfixContent retrieves the content of bugfix-gh-{issue-number}.md file via GitHub API +// This is faster than cloning since it doesn't require git operations +func GetBugfixContent(ctx context.Context, owner, repo, branch string, issueNumber int, token string) (string, error) { + filePath := fmt.Sprintf("bug-%d/bugfix-gh-%d.md", issueNumber, issueNumber) + content, err := git.ReadGitHubFile(ctx, owner, repo, branch, filePath, token) + if err != nil { + return "", fmt.Errorf("failed to read bugfix.md: %w", err) + } + return string(content), nil +} + +// CheckBugFolderExists checks if bug-{issue-number}/ folder exists in the repository +// Uses GitHub API to check path existence (faster than cloning) +func CheckBugFolderExists(ctx context.Context, owner, repo, branch string, issueNumber int, token string) (bool, error) { + folderPath := fmt.Sprintf("bug-%d", issueNumber) + + // Use GitHub Contents API to check if path exists + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, folderPath, branch) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("GitHub API request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return true, nil + } else if resp.StatusCode == 404 { + return false, nil + } + + return false, fmt.Errorf("unexpected status code %d from GitHub API", resp.StatusCode) +} + +// generateBugfixTemplate generates the initial template for bugfix-gh-{issue-number}.md +func generateBugfixTemplate(issueNumber int, githubIssueURL, jiraTaskURL string) string { + var template strings.Builder + + template.WriteString(fmt.Sprintf("# Bug Fix: GitHub Issue #%d\n\n", issueNumber)) + template.WriteString(fmt.Sprintf("**GitHub Issue**: %s\n", githubIssueURL)) + + if jiraTaskURL != "" { + template.WriteString(fmt.Sprintf("**Jira Task**: %s\n", jiraTaskURL)) + } + + template.WriteString("**Status**: Open\n\n") + template.WriteString("---\n\n") + template.WriteString("## Root Cause Analysis\n\n") + template.WriteString("*(Updated by Bug-review session)*\n\n") + template.WriteString("---\n\n") + template.WriteString("## Resolution Plan\n\n") + template.WriteString("*(Updated by Bug-resolution-plan session)*\n\n") + template.WriteString("---\n\n") + template.WriteString("## Implementation Steps\n\n") + template.WriteString("*(Updated by Bug-implement-fix session)*\n\n") + template.WriteString("---\n\n") + template.WriteString("## Testing\n\n") + template.WriteString("*(Updated by Bug-implement-fix session)*\n\n") + template.WriteString("---\n\n") + template.WriteString("## Additional Notes\n\n") + + return template.String() +} + +// appendToSection appends content to a specific section in the markdown file +// If the section doesn't exist, it's created before the next section marker +func appendToSection(fileContent, sectionName, content string) string { + // Find the section header + sectionHeader := fmt.Sprintf("## %s", sectionName) + sectionIndex := strings.Index(fileContent, sectionHeader) + + if sectionIndex == -1 { + // Section doesn't exist, append at the end + return fileContent + "\n" + sectionHeader + "\n\n" + content + "\n" + } + + // Find the next section marker (## ) after this section + nextSectionIndex := strings.Index(fileContent[sectionIndex+len(sectionHeader):], "\n##") + + if nextSectionIndex == -1 { + // No next section, append at the end + return fileContent + "\n" + content + "\n" + } + + // Insert content before the next section + insertPosition := sectionIndex + len(sectionHeader) + nextSectionIndex + before := fileContent[:insertPosition] + after := fileContent[insertPosition:] + + return before + "\n" + content + "\n" + after +} diff --git a/components/backend/crd/bugfix.go b/components/backend/crd/bugfix.go new file mode 100644 index 000000000..724a1cb0f --- /dev/null +++ b/components/backend/crd/bugfix.go @@ -0,0 +1,320 @@ +package crd + +import ( + "context" + "fmt" + + "ambient-code-backend/types" + + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +// GetBugFixWorkflowResourceFunc is a function type that returns the BugFixWorkflow GVR +type GetBugFixWorkflowResourceFunc func() schema.GroupVersionResource + +// GetBugFixWorkflowResource is set by main package +var GetBugFixWorkflowResource GetBugFixWorkflowResourceFunc + +// BugFixWorkflowToCRObject converts a BugFixWorkflow to a Kubernetes CR object +func BugFixWorkflowToCRObject(workflow *types.BugFixWorkflow) map[string]interface{} { + // Build spec + spec := map[string]interface{}{ + "githubIssueNumber": workflow.GithubIssueNumber, + "githubIssueURL": workflow.GithubIssueURL, + "title": workflow.Title, + "description": workflow.Description, + "branchName": workflow.BranchName, + "workspacePath": workflow.WorkspacePath, + } + + // Optional fields + if workflow.JiraTaskKey != nil && *workflow.JiraTaskKey != "" { + spec["jiraTaskKey"] = *workflow.JiraTaskKey + } + if workflow.LastSyncedAt != nil && *workflow.LastSyncedAt != "" { + spec["lastSyncedAt"] = *workflow.LastSyncedAt + } + if workflow.CreatedBy != "" { + spec["createdBy"] = workflow.CreatedBy + } + + // Umbrella repo + if workflow.UmbrellaRepo != nil { + u := map[string]interface{}{"url": workflow.UmbrellaRepo.URL} + if workflow.UmbrellaRepo.Branch != nil { + u["branch"] = *workflow.UmbrellaRepo.Branch + } + spec["umbrellaRepo"] = u + } + + // Supporting repos + if len(workflow.SupportingRepos) > 0 { + items := make([]map[string]interface{}, 0, len(workflow.SupportingRepos)) + for _, r := range workflow.SupportingRepos { + rm := map[string]interface{}{"url": r.URL} + if r.Branch != nil { + rm["branch"] = *r.Branch + } + items = append(items, rm) + } + spec["supportingRepos"] = items + } + + // Build status + status := map[string]interface{}{ + "phase": workflow.Phase, + "message": workflow.Message, + "bugFolderCreated": workflow.BugFolderCreated, + "bugfixMarkdownCreated": workflow.BugfixMarkdownCreated, + } + + // Build labels + labels := map[string]string{ + "project": workflow.Project, + "bugfix-workflow": workflow.ID, + "bugfix-issue-number": fmt.Sprintf("%d", workflow.GithubIssueNumber), + } + + return map[string]interface{}{ + "apiVersion": "vteam.ambient-code/v1alpha1", + "kind": "BugFixWorkflow", + "metadata": map[string]interface{}{ + "name": workflow.ID, + "namespace": workflow.Project, + "labels": labels, + }, + "spec": spec, + "status": status, + } +} + +// GetProjectBugFixWorkflowCR retrieves a BugFixWorkflow custom resource by ID +func GetProjectBugFixWorkflowCR(dyn dynamic.Interface, project, id string) (*types.BugFixWorkflow, error) { + if dyn == nil { + return nil, fmt.Errorf("no dynamic client provided") + } + if project == "" || id == "" { + return nil, fmt.Errorf("project and id are required") + } + + gvr := GetBugFixWorkflowResource() + obj, err := dyn.Resource(gvr).Namespace(project).Get(context.TODO(), id, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil // Not found is not an error + } + return nil, fmt.Errorf("failed to get BugFixWorkflow CR: %v", err) + } + + // Parse the unstructured object into BugFixWorkflow + workflow := &types.BugFixWorkflow{ + ID: id, + Project: project, + } + + spec, found, _ := unstructured.NestedMap(obj.Object, "spec") + if found { + if val, ok := spec["githubIssueNumber"].(int64); ok { + workflow.GithubIssueNumber = int(val) + } + if val, ok := spec["githubIssueURL"].(string); ok { + workflow.GithubIssueURL = val + } + if val, ok := spec["title"].(string); ok { + workflow.Title = val + } + if val, ok := spec["description"].(string); ok { + workflow.Description = val + } + if val, ok := spec["branchName"].(string); ok { + workflow.BranchName = val + } + if val, ok := spec["workspacePath"].(string); ok { + workflow.WorkspacePath = val + } + if val, ok := spec["createdBy"].(string); ok { + workflow.CreatedBy = val + } + if val, ok := spec["jiraTaskKey"].(string); ok && val != "" { + workflow.JiraTaskKey = &val + } + if val, ok := spec["lastSyncedAt"].(string); ok && val != "" { + workflow.LastSyncedAt = &val + } + + // Parse umbrellaRepo + if umbrellaMap, ok := spec["umbrellaRepo"].(map[string]interface{}); ok { + repo := &types.GitRepository{} + if url, ok := umbrellaMap["url"].(string); ok { + repo.URL = url + } + if branch, ok := umbrellaMap["branch"].(string); ok && branch != "" { + repo.Branch = &branch + } + workflow.UmbrellaRepo = repo + } + + // Parse supportingRepos + if reposSlice, ok := spec["supportingRepos"].([]interface{}); ok { + repos := make([]types.GitRepository, 0, len(reposSlice)) + for _, item := range reposSlice { + if repoMap, ok := item.(map[string]interface{}); ok { + repo := types.GitRepository{} + if url, ok := repoMap["url"].(string); ok { + repo.URL = url + } + if branch, ok := repoMap["branch"].(string); ok && branch != "" { + repo.Branch = &branch + } + repos = append(repos, repo) + } + } + workflow.SupportingRepos = repos + } + } + + // Parse status + status, found, _ := unstructured.NestedMap(obj.Object, "status") + if found { + if val, ok := status["phase"].(string); ok { + workflow.Phase = val + } + if val, ok := status["message"].(string); ok { + workflow.Message = val + } + if val, ok := status["bugFolderCreated"].(bool); ok { + workflow.BugFolderCreated = val + } + if val, ok := status["bugfixMarkdownCreated"].(bool); ok { + workflow.BugfixMarkdownCreated = val + } + } + + // Parse metadata timestamps + if metadata, found, _ := unstructured.NestedMap(obj.Object, "metadata"); found { + if creationTimestamp, ok := metadata["creationTimestamp"].(string); ok { + workflow.CreatedAt = creationTimestamp + } + } + + return workflow, nil +} + +// ListProjectBugFixWorkflowCRs lists all BugFixWorkflow custom resources in a project +func ListProjectBugFixWorkflowCRs(dyn dynamic.Interface, project string) ([]types.BugFixWorkflow, error) { + if dyn == nil { + return nil, fmt.Errorf("no dynamic client provided") + } + if project == "" { + return nil, fmt.Errorf("project is required") + } + + gvr := GetBugFixWorkflowResource() + list, err := dyn.Resource(gvr).Namespace(project).List(context.TODO(), v1.ListOptions{ + LabelSelector: fmt.Sprintf("project=%s", project), + }) + if err != nil { + return nil, fmt.Errorf("failed to list BugFixWorkflow CRs: %v", err) + } + + workflows := make([]types.BugFixWorkflow, 0, len(list.Items)) + for _, item := range list.Items { + id := item.GetName() + workflow, err := GetProjectBugFixWorkflowCR(dyn, project, id) + if err != nil { + continue // Skip items that fail to parse + } + if workflow != nil { + workflows = append(workflows, *workflow) + } + } + + return workflows, nil +} + +// UpsertProjectBugFixWorkflowCR creates or updates a BugFixWorkflow custom resource +func UpsertProjectBugFixWorkflowCR(dyn dynamic.Interface, workflow *types.BugFixWorkflow) error { + if workflow.Project == "" { + // Only manage CRD for project-scoped workflows + return nil + } + if dyn == nil { + return fmt.Errorf("no dynamic client provided") + } + + gvr := GetBugFixWorkflowResource() + obj := &unstructured.Unstructured{Object: BugFixWorkflowToCRObject(workflow)} + + // Try create, if exists then update + _, err := dyn.Resource(gvr).Namespace(workflow.Project).Create(context.TODO(), obj, v1.CreateOptions{}) + if err != nil { + if errors.IsAlreadyExists(err) { + _, uerr := dyn.Resource(gvr).Namespace(workflow.Project).Update(context.TODO(), obj, v1.UpdateOptions{}) + if uerr != nil { + return fmt.Errorf("failed to update BugFixWorkflow CR: %v", uerr) + } + return nil + } + return fmt.Errorf("failed to create BugFixWorkflow CR: %v", err) + } + return nil +} + +// DeleteProjectBugFixWorkflowCR deletes a BugFixWorkflow custom resource +func DeleteProjectBugFixWorkflowCR(dyn dynamic.Interface, project, id string) error { + if dyn == nil { + return fmt.Errorf("no dynamic client provided") + } + if project == "" || id == "" { + return fmt.Errorf("project and id are required") + } + + gvr := GetBugFixWorkflowResource() + err := dyn.Resource(gvr).Namespace(project).Delete(context.TODO(), id, v1.DeleteOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil // Already deleted is not an error + } + return fmt.Errorf("failed to delete BugFixWorkflow CR: %v", err) + } + return nil +} + +// UpdateBugFixWorkflowStatus updates only the status subresource of a BugFixWorkflow CR +func UpdateBugFixWorkflowStatus(dyn dynamic.Interface, workflow *types.BugFixWorkflow) error { + if workflow.Project == "" || workflow.ID == "" { + return fmt.Errorf("project and id are required") + } + if dyn == nil { + return fmt.Errorf("no dynamic client provided") + } + + gvr := GetBugFixWorkflowResource() + + // Get current CR + obj, err := dyn.Resource(gvr).Namespace(workflow.Project).Get(context.TODO(), workflow.ID, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get BugFixWorkflow for status update: %v", err) + } + + // Update status fields + status := map[string]interface{}{ + "phase": workflow.Phase, + "message": workflow.Message, + "bugFolderCreated": workflow.BugFolderCreated, + "bugfixMarkdownCreated": workflow.BugfixMarkdownCreated, + } + obj.Object["status"] = status + + // Update the status subresource + _, err = dyn.Resource(gvr).Namespace(workflow.Project).UpdateStatus(context.TODO(), obj, v1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update BugFixWorkflow status: %v", err) + } + + return nil +} diff --git a/components/backend/github/issues.go b/components/backend/github/issues.go new file mode 100644 index 000000000..312d50e34 --- /dev/null +++ b/components/backend/github/issues.go @@ -0,0 +1,321 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// GitHubIssue represents a GitHub Issue (subset of fields) +type GitHubIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + URL string `json:"html_url"` +} + +// GitHubComment represents a comment on a GitHub Issue +type GitHubComment struct { + ID int `json:"id"` + Body string `json:"body"` + URL string `json:"html_url"` +} + +// CreateIssueRequest represents the request body for creating an issue +type CreateIssueRequest struct { + Title string `json:"title"` + Body string `json:"body"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` +} + +// UpdateIssueRequest represents the request body for updating an issue +type UpdateIssueRequest struct { + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` // "open" or "closed" + Labels []string `json:"labels,omitempty"` +} + +// AddCommentRequest represents the request body for adding a comment +type AddCommentRequest struct { + Body string `json:"body"` +} + +// ParseGitHubIssueURL parses a GitHub Issue URL and extracts owner, repo, and issue number +// Example: https://github.com/owner/repo/issues/123 -> owner, repo, 123 +func ParseGitHubIssueURL(issueURL string) (owner, repo string, issueNumber int, err error) { + // Pattern: https://github.com/{owner}/{repo}/issues/{number} + pattern := `^https?://github\.com/([^/]+)/([^/]+)/issues/(\d+)/?$` + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(strings.TrimSpace(issueURL)) + + if len(matches) != 4 { + return "", "", 0, fmt.Errorf("invalid GitHub Issue URL format: expected https://github.com/owner/repo/issues/NUMBER") + } + + owner = matches[1] + repo = matches[2] + issueNumber, err = strconv.Atoi(matches[3]) + if err != nil { + return "", "", 0, fmt.Errorf("invalid issue number in URL: %v", err) + } + + return owner, repo, issueNumber, nil +} + +// ValidateIssueURL validates that a GitHub Issue exists and is accessible +// Returns the issue details if valid, error if not found or inaccessible +func ValidateIssueURL(ctx context.Context, issueURL, token string) (*GitHubIssue, error) { + owner, repo, issueNumber, err := ParseGitHubIssueURL(issueURL) + if err != nil { + return nil, err + } + + // GET /repos/{owner}/{repo}/issues/{issue_number} + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, issueNumber) + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "vTeam-Backend") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case 200: + var issue GitHubIssue + if err := json.Unmarshal(body, &issue); err != nil { + return nil, fmt.Errorf("failed to parse GitHub Issue response: %v", err) + } + return &issue, nil + case 404: + return nil, fmt.Errorf("GitHub Issue not found: %s", issueURL) + case 410: + return nil, fmt.Errorf("GitHub Issue has been deleted: %s", issueURL) + case 401, 403: + return nil, fmt.Errorf("authentication failed or no access to GitHub Issue: %s", issueURL) + case 429: + // Rate limit exceeded + resetTime := resp.Header.Get("X-RateLimit-Reset") + return nil, fmt.Errorf("GitHub API rate limit exceeded (reset at %s)", resetTime) + default: + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } +} + +// CreateIssue creates a new GitHub Issue +// Returns the created issue details including issue number and URL +func CreateIssue(ctx context.Context, owner, repo, token string, request *CreateIssueRequest) (*GitHubIssue, error) { + if request.Title == "" || request.Body == "" { + return nil, fmt.Errorf("title and body are required") + } + + // POST /repos/{owner}/{repo}/issues + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues", owner, repo) + + bodyBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "vTeam-Backend") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case 201: + var issue GitHubIssue + if err := json.Unmarshal(body, &issue); err != nil { + return nil, fmt.Errorf("failed to parse GitHub Issue response: %v", err) + } + return &issue, nil + case 401, 403: + return nil, fmt.Errorf("authentication failed or no permission to create issues in %s/%s", owner, repo) + case 404: + return nil, fmt.Errorf("repository not found: %s/%s", owner, repo) + case 429: + resetTime := resp.Header.Get("X-RateLimit-Reset") + return nil, fmt.Errorf("GitHub API rate limit exceeded (reset at %s)", resetTime) + default: + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } +} + +// UpdateIssue updates an existing GitHub Issue +func UpdateIssue(ctx context.Context, owner, repo string, issueNumber int, token string, request *UpdateIssueRequest) (*GitHubIssue, error) { + // PATCH /repos/{owner}/{repo}/issues/{issue_number} + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, issueNumber) + + bodyBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "PATCH", apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "vTeam-Backend") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case 200: + var issue GitHubIssue + if err := json.Unmarshal(body, &issue); err != nil { + return nil, fmt.Errorf("failed to parse GitHub Issue response: %v", err) + } + return &issue, nil + case 401, 403: + return nil, fmt.Errorf("authentication failed or no permission to update issue #%d", issueNumber) + case 404: + return nil, fmt.Errorf("issue #%d not found in %s/%s", issueNumber, owner, repo) + case 429: + resetTime := resp.Header.Get("X-RateLimit-Reset") + return nil, fmt.Errorf("GitHub API rate limit exceeded (reset at %s)", resetTime) + default: + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } +} + +// AddComment adds a comment to a GitHub Issue +// Returns the created comment details +func AddComment(ctx context.Context, owner, repo string, issueNumber int, token, commentBody string) (*GitHubComment, error) { + if commentBody == "" { + return nil, fmt.Errorf("comment body is required") + } + + // POST /repos/{owner}/{repo}/issues/{issue_number}/comments + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d/comments", owner, repo, issueNumber) + + request := AddCommentRequest{Body: commentBody} + bodyBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "vTeam-Backend") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub API request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case 201: + var comment GitHubComment + if err := json.Unmarshal(body, &comment); err != nil { + return nil, fmt.Errorf("failed to parse GitHub comment response: %v", err) + } + return &comment, nil + case 401, 403: + return nil, fmt.Errorf("authentication failed or no permission to comment on issue #%d", issueNumber) + case 404: + return nil, fmt.Errorf("issue #%d not found in %s/%s", issueNumber, owner, repo) + case 429: + resetTime := resp.Header.Get("X-RateLimit-Reset") + return nil, fmt.Errorf("GitHub API rate limit exceeded (reset at %s)", resetTime) + default: + return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, string(body)) + } +} + +// GenerateIssueTemplate generates a standardized GitHub Issue body from text description +func GenerateIssueTemplate(title, symptoms, reproSteps, expectedBehavior, actualBehavior, additionalContext string) string { + var template strings.Builder + + template.WriteString("## Bug Description\n\n") + template.WriteString(symptoms) + template.WriteString("\n\n") + + if reproSteps != "" { + template.WriteString("## Reproduction Steps\n\n") + template.WriteString(reproSteps) + template.WriteString("\n\n") + } + + if expectedBehavior != "" { + template.WriteString("## Expected Behavior\n\n") + template.WriteString(expectedBehavior) + template.WriteString("\n\n") + } + + if actualBehavior != "" { + template.WriteString("## Actual Behavior\n\n") + template.WriteString(actualBehavior) + template.WriteString("\n\n") + } + + if additionalContext != "" { + template.WriteString("## Additional Context\n\n") + template.WriteString(additionalContext) + template.WriteString("\n\n") + } + + template.WriteString("---\n") + template.WriteString("*This issue was automatically created by vTeam BugFix Workspace*\n") + + return template.String() +} diff --git a/components/backend/jira/integration.go b/components/backend/jira/integration.go index 82d1838c5..57dd74a6d 100644 --- a/components/backend/jira/integration.go +++ b/components/backend/jira/integration.go @@ -587,3 +587,197 @@ func (h *Handler) PublishWorkflowFileToJira(c *gin.Context) { "url": fmt.Sprintf("%s/browse/%s", jiraBase, outKey), }) } + +// ============================================================================ +// BugFix Workspace - Jira Integration Functions +// ============================================================================ + +// CreateJiraTaskFromGitHubIssue creates a Jira Task from a GitHub Issue +// Returns the created Jira Task key (e.g., "PROJ-1234") and URL +func CreateJiraTaskFromGitHubIssue(ctx context.Context, githubIssueTitle, githubIssueBody, githubIssueURL, jiraURL, jiraProject, jiraAuthHeader string) (jiraKey, jiraURL string, err error) { + jiraBase := strings.TrimRight(jiraURL, "/") + jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue", jiraBase) + + // Build description with GitHub Issue link at the top + description := fmt.Sprintf("**GitHub Issue:** %s\n\n---\n\n%s", githubIssueURL, githubIssueBody) + + fields := map[string]interface{}{ + "project": map[string]string{"key": jiraProject}, + "summary": githubIssueTitle, + "description": description, + "issuetype": map[string]string{"name": "Task"}, + } + + payload := map[string]interface{}{"fields": fields} + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", "", fmt.Errorf("failed to marshal Jira request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", jiraEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return "", "", fmt.Errorf("failed to create Jira request: %v", err) + } + + req.Header.Set("Authorization", jiraAuthHeader) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("Jira API request failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 201 { + return "", "", fmt.Errorf("failed to create Jira Task (status %d): %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("failed to parse Jira response: %v", err) + } + + key, ok := result["key"].(string) + if !ok { + return "", "", fmt.Errorf("Jira response missing 'key' field") + } + + taskURL := fmt.Sprintf("%s/browse/%s", jiraBase, key) + return key, taskURL, nil +} + +// UpdateJiraTask updates an existing Jira Task's description +func UpdateJiraTask(ctx context.Context, jiraKey, newDescription, jiraURL, jiraAuthHeader string) error { + jiraBase := strings.TrimRight(jiraURL, "/") + jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", jiraBase, jiraKey) + + fields := map[string]interface{}{ + "description": newDescription, + } + + payload := map[string]interface{}{"fields": fields} + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Jira request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "PUT", jiraEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create Jira request: %v", err) + } + + req.Header.Set("Authorization", jiraAuthHeader) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Jira API request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 204 && resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update Jira Task (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// AddJiraComment adds a comment to a Jira Task +func AddJiraComment(ctx context.Context, jiraKey, commentBody, jiraURL, jiraAuthHeader string) error { + jiraBase := strings.TrimRight(jiraURL, "/") + jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue/%s/comment", jiraBase, jiraKey) + + payload := map[string]interface{}{ + "body": commentBody, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Jira request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", jiraEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create Jira request: %v", err) + } + + req.Header.Set("Authorization", jiraAuthHeader) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Jira API request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to add Jira comment (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// AddJiraRemoteLink adds a remote link (bidirectional link to GitHub Issue) +func AddJiraRemoteLink(ctx context.Context, jiraKey, githubIssueURL, githubIssueTitle, jiraURL, jiraAuthHeader string) error { + jiraBase := strings.TrimRight(jiraURL, "/") + jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue/%s/remotelink", jiraBase, jiraKey) + + payload := map[string]interface{}{ + "object": map[string]interface{}{ + "url": githubIssueURL, + "title": fmt.Sprintf("GitHub Issue: %s", githubIssueTitle), + "icon": map[string]string{ + "url16x16": "https://github.com/favicon.ico", + }, + }, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal Jira request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", jiraEndpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("failed to create Jira request: %v", err) + } + + req.Header.Set("Authorization", jiraAuthHeader) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Jira API request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to add Jira remote link (status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// GetJiraAuthHeader determines the correct auth header format (Cloud vs Server) +func GetJiraAuthHeader(jiraURL, jiraToken string) string { + if strings.Contains(jiraURL, "atlassian.net") { + // Jira Cloud - assume token is email:api_token format + encoded := base64.StdEncoding.EncodeToString([]byte(jiraToken)) + return "Basic " + encoded + } + // Jira Server/Data Center + return "Bearer " + jiraToken +} From 5612eb206674f1303b21292133df3d1d8c688738 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 31 Oct 2025 17:00:00 +0000 Subject: [PATCH 03/19] feat(bugfix): Phase 2 - Foundational Features (T010-T018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete backend API infrastructure for BugFix Workspace: API Handlers (handlers/bugfix/): - create.go: POST /bugfix-workflows (from GitHub Issue URL or text description) - get.go: GET /bugfix-workflows/:id (retrieve workspace details) - list.go: GET /bugfix-workflows (list all workspaces in project) - delete.go: DELETE /bugfix-workflows/:id (remove workspace) - sessions.go: POST/GET /bugfix-workflows/:id/sessions (create and list sessions) - status.go: GET /bugfix-workflows/:id/status (get workflow status) WebSocket Support: - bugfix_events.go: 8 event types for real-time updates * workspace-created, session-started/progress/completed/failed * jira-sync-started/completed/failed Route Registration: - routes.go: 7 new endpoints added to project group - main.go: Dependency injection for BugFix handlers - k8s/resources.go: GetBugFixWorkflowResource() function Features: - Supports both creation flows (GitHub Issue URL + text description) - Duplicate workspace detection via bug folder existence check - Session creation with environment variable injection - Label-based session querying (bugfix-workflow, bugfix-session-type) - WebSocket broadcasting for real-time UI updates Dependencies: Phase 3+ (UI components) can now be implemented 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/backend/handlers/bugfix/create.go | 221 ++++++++++++++ components/backend/handlers/bugfix/delete.go | 47 +++ components/backend/handlers/bugfix/get.go | 86 ++++++ components/backend/handlers/bugfix/list.go | 59 ++++ .../backend/handlers/bugfix/sessions.go | 288 ++++++++++++++++++ components/backend/handlers/bugfix/status.go | 58 ++++ components/backend/k8s/resources.go | 9 + components/backend/main.go | 6 + components/backend/routes.go | 10 + components/backend/websocket/bugfix_events.go | 143 +++++++++ 10 files changed, 927 insertions(+) create mode 100644 components/backend/handlers/bugfix/create.go create mode 100644 components/backend/handlers/bugfix/delete.go create mode 100644 components/backend/handlers/bugfix/get.go create mode 100644 components/backend/handlers/bugfix/list.go create mode 100644 components/backend/handlers/bugfix/sessions.go create mode 100644 components/backend/handlers/bugfix/status.go create mode 100644 components/backend/websocket/bugfix_events.go diff --git a/components/backend/handlers/bugfix/create.go b/components/backend/handlers/bugfix/create.go new file mode 100644 index 000000000..91e45c4bf --- /dev/null +++ b/components/backend/handlers/bugfix/create.go @@ -0,0 +1,221 @@ +package bugfix + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "ambient-code-backend/bugfix" + "ambient-code-backend/crd" + "ambient-code-backend/git" + "ambient-code-backend/github" + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// Package-level dependencies (set from main) +var ( + GetK8sClientsForRequest func(*gin.Context) (*kubernetes.Clientset, dynamic.Interface) +) + +// CreateProjectBugFixWorkflow handles POST /api/projects/:projectName/bugfix-workflows +// Creates a new BugFix Workspace from either GitHub Issue URL or text description +func CreateProjectBugFixWorkflow(c *gin.Context) { + project := c.Param("projectName") + + var req types.CreateBugFixWorkflowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) + return + } + + // Validate that either GitHub Issue URL or text description is provided + if req.GithubIssueURL == nil && req.TextDescription == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Either githubIssueURL or textDescription must be provided"}) + return + } + + if req.GithubIssueURL != nil && req.TextDescription != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot provide both githubIssueURL and textDescription"}) + return + } + + // Get K8s clients + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil || reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Get user ID + userID, _ := c.Get("userID") + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) + return + } + + // Get GitHub token + ctx := c.Request.Context() + githubToken, err := git.GetGitHubToken(ctx, reqK8s, reqDyn, project, userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get GitHub token", "details": err.Error()}) + return + } + + var githubIssue *github.GitHubIssue + var githubIssueURL string + + // Flow 1: From GitHub Issue URL + if req.GithubIssueURL != nil { + githubIssueURL = *req.GithubIssueURL + + // Validate GitHub Issue exists and is accessible + issue, err := github.ValidateIssueURL(ctx, githubIssueURL, githubToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid GitHub Issue", "details": err.Error()}) + return + } + githubIssue = issue + } + + // Flow 2: From text description (create GitHub Issue automatically) + if req.TextDescription != nil { + td := req.TextDescription + + // Parse target repository + owner, repo, err := git.ParseGitHubURL(td.TargetRepository) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target repository URL", "details": err.Error()}) + return + } + + // Generate issue body from template + reproSteps := "" + if td.ReproductionSteps != nil { + reproSteps = *td.ReproductionSteps + } + expectedBehavior := "" + if td.ExpectedBehavior != nil { + expectedBehavior = *td.ExpectedBehavior + } + actualBehavior := "" + if td.ActualBehavior != nil { + actualBehavior = *td.ActualBehavior + } + additionalContext := "" + if td.AdditionalContext != nil { + additionalContext = *td.AdditionalContext + } + + issueBody := github.GenerateIssueTemplate( + td.Title, + td.Symptoms, + reproSteps, + expectedBehavior, + actualBehavior, + additionalContext, + ) + + // Create GitHub Issue + createReq := &github.CreateIssueRequest{ + Title: td.Title, + Body: issueBody, + } + + issue, err := github.CreateIssue(ctx, owner, repo, githubToken, createReq) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to create GitHub Issue", "details": err.Error()}) + return + } + + githubIssue = issue + githubIssueURL = issue.URL + } + + // Check for duplicate workspace (same issue number) + owner, repo, err := git.ParseGitHubURL(req.UmbrellaRepo.URL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid umbrella repository URL", "details": err.Error()}) + return + } + + branch := "main" + if req.BranchName != nil && *req.BranchName != "" { + branch = *req.BranchName + } else { + // Auto-generate branch name + branch = fmt.Sprintf("bugfix/gh-%d", githubIssue.Number) + } + + // Check if bug folder already exists (duplicate detection) + exists, err := bugfix.CheckBugFolderExists(ctx, owner, repo, branch, githubIssue.Number, githubToken) + if err == nil && exists { + c.JSON(http.StatusConflict, gin.H{ + "error": fmt.Sprintf("BugFix Workspace already exists for issue #%d (folder bug-%d/ found)", githubIssue.Number, githubIssue.Number), + }) + return + } + + // Generate workspace ID + workspaceID := fmt.Sprintf("bugfix-%d", time.Now().Unix()) + + // Create BugFixWorkflow object + workflow := &types.BugFixWorkflow{ + ID: githubIssue.Number, + GithubIssueNumber: githubIssue.Number, + GithubIssueURL: githubIssueURL, + Title: githubIssue.Title, + Description: githubIssue.Body, + BranchName: branch, + UmbrellaRepo: &req.UmbrellaRepo, + SupportingRepos: req.SupportingRepos, + Project: project, + CreatedBy: userIDStr, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Phase: "Initializing", + Message: "Creating bug folder in spec repository...", + } + + // Create bug folder in spec repository + // Note: This is done synchronously for simplicity, but could be made async with a worker + userEmail := "" + userName := "" + // TODO: Get user email/name from user context if available + + err = bugfix.CreateBugFolder(ctx, req.UmbrellaRepo.URL, githubIssue.Number, branch, githubToken, userEmail, userName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bug folder", "details": err.Error()}) + return + } + + // Update workflow status + workflow.Phase = "Ready" + workflow.Message = "Workspace ready for sessions" + workflow.BugFolderCreated = true + + // Create BugFixWorkflow CR + if err := crd.UpsertProjectBugFixWorkflowCR(reqDyn, workflow); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create workflow CR", "details": err.Error()}) + return + } + + // Return created workflow + c.JSON(http.StatusCreated, gin.H{ + "id": workflow.ID, + "githubIssueNumber": workflow.GithubIssueNumber, + "githubIssueURL": workflow.GithubIssueURL, + "title": workflow.Title, + "description": workflow.Description, + "branchName": workflow.BranchName, + "phase": workflow.Phase, + "message": workflow.Message, + "bugFolderCreated": workflow.BugFolderCreated, + "createdAt": workflow.CreatedAt, + }) +} diff --git a/components/backend/handlers/bugfix/delete.go b/components/backend/handlers/bugfix/delete.go new file mode 100644 index 000000000..120b115ec --- /dev/null +++ b/components/backend/handlers/bugfix/delete.go @@ -0,0 +1,47 @@ +package bugfix + +import ( + "net/http" + + "ambient-code-backend/crd" + + "github.com/gin-gonic/gin" +) + +// DeleteProjectBugFixWorkflow handles DELETE /api/projects/:projectName/bugfix-workflows/:id +// Deletes a BugFix Workspace (CR only, does not delete git branch or GitHub Issue) +func DeleteProjectBugFixWorkflow(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Check if workflow exists + workflow, err := crd.GetProjectBugFixWorkflowCR(reqDyn, project, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check workflow", "details": err.Error()}) + return + } + + if workflow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + + // Delete the CR (Kubernetes will cascade delete owned resources) + if err := crd.DeleteProjectBugFixWorkflowCR(reqDyn, project, id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete workflow", "details": err.Error()}) + return + } + + // Return success + c.JSON(http.StatusOK, gin.H{ + "message": "Workflow deleted successfully", + "note": "Git branch and GitHub Issue are not deleted. Manual cleanup required if desired.", + }) +} diff --git a/components/backend/handlers/bugfix/get.go b/components/backend/handlers/bugfix/get.go new file mode 100644 index 000000000..ef09c297f --- /dev/null +++ b/components/backend/handlers/bugfix/get.go @@ -0,0 +1,86 @@ +package bugfix + +import ( + "net/http" + + "ambient-code-backend/crd" + + "github.com/gin-gonic/gin" +) + +// GetProjectBugFixWorkflow handles GET /api/projects/:projectName/bugfix-workflows/:id +// Retrieves a specific BugFix Workspace by ID +func GetProjectBugFixWorkflow(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Get workflow from CR + workflow, err := crd.GetProjectBugFixWorkflowCR(reqDyn, project, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get workflow", "details": err.Error()}) + return + } + + if workflow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + + // Return workflow details + response := map[string]interface{}{ + "id": workflow.ID, + "githubIssueNumber": workflow.GithubIssueNumber, + "githubIssueURL": workflow.GithubIssueURL, + "title": workflow.Title, + "description": workflow.Description, + "branchName": workflow.BranchName, + "project": workflow.Project, + "phase": workflow.Phase, + "message": workflow.Message, + "bugFolderCreated": workflow.BugFolderCreated, + "bugfixMarkdownCreated": workflow.BugfixMarkdownCreated, + "createdAt": workflow.CreatedAt, + "createdBy": workflow.CreatedBy, + } + + // Add optional fields + if workflow.JiraTaskKey != nil { + response["jiraTaskKey"] = *workflow.JiraTaskKey + } + if workflow.LastSyncedAt != nil { + response["lastSyncedAt"] = *workflow.LastSyncedAt + } + if workflow.WorkspacePath != "" { + response["workspacePath"] = workflow.WorkspacePath + } + + // Add repositories + if workflow.UmbrellaRepo != nil { + u := map[string]interface{}{"url": workflow.UmbrellaRepo.URL} + if workflow.UmbrellaRepo.Branch != nil { + u["branch"] = *workflow.UmbrellaRepo.Branch + } + response["umbrellaRepo"] = u + } + + if len(workflow.SupportingRepos) > 0 { + repos := make([]map[string]interface{}, 0, len(workflow.SupportingRepos)) + for _, r := range workflow.SupportingRepos { + rm := map[string]interface{}{"url": r.URL} + if r.Branch != nil { + rm["branch"] = *r.Branch + } + repos = append(repos, rm) + } + response["supportingRepos"] = repos + } + + c.JSON(http.StatusOK, response) +} diff --git a/components/backend/handlers/bugfix/list.go b/components/backend/handlers/bugfix/list.go new file mode 100644 index 000000000..01ed9143b --- /dev/null +++ b/components/backend/handlers/bugfix/list.go @@ -0,0 +1,59 @@ +package bugfix + +import ( + "net/http" + + "ambient-code-backend/crd" + + "github.com/gin-gonic/gin" +) + +// ListProjectBugFixWorkflows handles GET /api/projects/:projectName/bugfix-workflows +// Lists all BugFix Workspaces in a project +func ListProjectBugFixWorkflows(c *gin.Context) { + project := c.Param("projectName") + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // List workflows from CRs + workflows, err := crd.ListProjectBugFixWorkflowCRs(reqDyn, project) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list workflows", "details": err.Error()}) + return + } + + // Return slim summaries (exclude full description for performance) + summaries := make([]map[string]interface{}, 0, len(workflows)) + for _, w := range workflows { + item := map[string]interface{}{ + "id": w.ID, + "githubIssueNumber": w.GithubIssueNumber, + "githubIssueURL": w.GithubIssueURL, + "title": w.Title, + "branchName": w.BranchName, + "phase": w.Phase, + "project": w.Project, + "createdAt": w.CreatedAt, + "createdBy": w.CreatedBy, + } + + // Add Jira link if present + if w.JiraTaskKey != nil { + item["jiraTaskKey"] = *w.JiraTaskKey + } + + // Add umbrella repo URL + if w.UmbrellaRepo != nil { + item["umbrellaRepoURL"] = w.UmbrellaRepo.URL + } + + summaries = append(summaries, item) + } + + c.JSON(http.StatusOK, gin.H{"workflows": summaries}) +} diff --git a/components/backend/handlers/bugfix/sessions.go b/components/backend/handlers/bugfix/sessions.go new file mode 100644 index 000000000..2844d2834 --- /dev/null +++ b/components/backend/handlers/bugfix/sessions.go @@ -0,0 +1,288 @@ +package bugfix + +import ( + "context" + "fmt" + "net/http" + "time" + + "ambient-code-backend/crd" + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Package-level dependency for AgenticSession GVR (set from main) +var GetAgenticSessionResource func() schema.GroupVersionResource + +// CreateProjectBugFixWorkflowSession handles POST /api/projects/:projectName/bugfix-workflows/:id/sessions +// Creates a new session (bug-review, bug-resolution-plan, bug-implement-fix, or generic) linked to the workflow +func CreateProjectBugFixWorkflowSession(c *gin.Context) { + project := c.Param("projectName") + workflowID := c.Param("id") + + var req types.CreateBugFixSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) + return + } + + // Validate session type + validTypes := map[string]bool{ + "bug-review": true, + "bug-resolution-plan": true, + "bug-implement-fix": true, + "generic": true, + } + if !validTypes[req.SessionType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session type. Must be: bug-review, bug-resolution-plan, bug-implement-fix, or generic"}) + return + } + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Get workflow + workflow, err := crd.GetProjectBugFixWorkflowCR(reqDyn, project, workflowID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get workflow", "details": err.Error()}) + return + } + if workflow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + + // Check workflow is ready + if workflow.Phase != "Ready" { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Workflow is not ready (current phase: %s)", workflow.Phase)}) + return + } + + // Generate session name + sessionName := fmt.Sprintf("%s-%s-%d", workflowID, req.SessionType, time.Now().Unix()) + + // Build session title + title := req.SessionType + " session" + if req.Title != nil { + title = *req.Title + } else { + // Auto-generate title based on session type + switch req.SessionType { + case "bug-review": + title = fmt.Sprintf("Bug Review: Issue #%d", workflow.GithubIssueNumber) + case "bug-resolution-plan": + title = fmt.Sprintf("Resolution Plan: Issue #%d", workflow.GithubIssueNumber) + case "bug-implement-fix": + title = fmt.Sprintf("Implement Fix: Issue #%d", workflow.GithubIssueNumber) + case "generic": + title = fmt.Sprintf("Generic Session: Issue #%d", workflow.GithubIssueNumber) + } + } + + // Build description + description := "" + if req.Description != nil { + description = *req.Description + } + + // Build repositories list (umbrella + supporting, all using feature branch) + repos := make([]map[string]interface{}, 0) + + if workflow.UmbrellaRepo != nil { + repoInput := map[string]interface{}{ + "url": workflow.UmbrellaRepo.URL, + "branch": workflow.BranchName, + } + repoOutput := map[string]interface{}{ + "url": workflow.UmbrellaRepo.URL, + "branch": workflow.BranchName, + } + repos = append(repos, map[string]interface{}{ + "input": repoInput, + "output": repoOutput, + }) + } + + for _, supportingRepo := range workflow.SupportingRepos { + repoInput := map[string]interface{}{ + "url": supportingRepo.URL, + "branch": workflow.BranchName, + } + repoOutput := map[string]interface{}{ + "url": supportingRepo.URL, + "branch": workflow.BranchName, + } + repos = append(repos, map[string]interface{}{ + "input": repoInput, + "output": repoOutput, + }) + } + + // Build environment variables + envVars := map[string]string{ + "GITHUB_ISSUE_NUMBER": fmt.Sprintf("%d", workflow.GithubIssueNumber), + "GITHUB_ISSUE_URL": workflow.GithubIssueURL, + "BUGFIX_WORKFLOW_ID": workflowID, + "SESSION_TYPE": req.SessionType, + "PROJECT_NAME": project, + } + + // Merge user-provided environment variables + if req.EnvironmentVariables != nil { + for k, v := range req.EnvironmentVariables { + envVars[k] = v + } + } + + // Build AgenticSession spec + sessionSpec := map[string]interface{}{ + "title": title, + "description": description, + "repos": repos, + "environmentVariables": envVars, + } + + // Add agent personas if provided + if len(req.SelectedAgents) > 0 { + if len(req.SelectedAgents) == 1 { + sessionSpec["agentPersona"] = req.SelectedAgents[0] + } else { + // Multiple agents: use AGENT_PERSONAS env var + envVars["AGENT_PERSONAS"] = joinStrings(req.SelectedAgents, ",") + } + } + + // Add resource overrides if provided + if req.ResourceOverrides != nil { + sessionSpec["resourceOverrides"] = req.ResourceOverrides + } + + // Build labels for linking to BugFix Workflow + labels := map[string]string{ + "project": project, + "bugfix-workflow": workflowID, + "bugfix-session-type": req.SessionType, + "bugfix-issue-number": fmt.Sprintf("%d", workflow.GithubIssueNumber), + } + + // Create AgenticSession CR + gvr := GetAgenticSessionResource() + sessionObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "vteam.ambient-code/v1alpha1", + "kind": "AgenticSession", + "metadata": map[string]interface{}{ + "name": sessionName, + "namespace": project, + "labels": labels, + }, + "spec": sessionSpec, + }, + } + + created, err := reqDyn.Resource(gvr).Namespace(project).Create(context.TODO(), sessionObj, v1.CreateOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session", "details": err.Error()}) + return + } + + // Return created session info + c.JSON(http.StatusCreated, gin.H{ + "id": created.GetName(), + "title": title, + "description": description, + "sessionType": req.SessionType, + "workflowID": workflowID, + "phase": "Pending", + "createdAt": created.GetCreationTimestamp().Time.UTC().Format(time.RFC3339), + }) +} + +// ListProjectBugFixWorkflowSessions handles GET /api/projects/:projectName/bugfix-workflows/:id/sessions +// Lists all sessions linked to a BugFix Workflow +func ListProjectBugFixWorkflowSessions(c *gin.Context) { + project := c.Param("projectName") + workflowID := c.Param("id") + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Check workflow exists + workflow, err := crd.GetProjectBugFixWorkflowCR(reqDyn, project, workflowID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get workflow", "details": err.Error()}) + return + } + if workflow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + + // Query sessions by label selector + gvr := GetAgenticSessionResource() + selector := fmt.Sprintf("bugfix-workflow=%s,project=%s", workflowID, project) + list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sessions", "details": err.Error()}) + return + } + + // Parse sessions + sessions := make([]map[string]interface{}, 0, len(list.Items)) + for _, item := range list.Items { + spec, _ := item.Object["spec"].(map[string]interface{}) + status, _ := item.Object["status"].(map[string]interface{}) + + session := map[string]interface{}{ + "id": item.GetName(), + "title": spec["title"], + "createdAt": item.GetCreationTimestamp().Time.UTC().Format(time.RFC3339), + } + + // Add session type from labels + labels := item.GetLabels() + if sessionType, ok := labels["bugfix-session-type"]; ok { + session["sessionType"] = sessionType + } + + // Add phase from status + if phase, ok := status["phase"].(string); ok { + session["phase"] = phase + } else { + session["phase"] = "Pending" + } + + // Add completion time if available + if completedAt, ok := status["completedAt"].(string); ok { + session["completedAt"] = completedAt + } + + sessions = append(sessions, session) + } + + c.JSON(http.StatusOK, gin.H{"sessions": sessions}) +} + +// Helper function to join strings +func joinStrings(strs []string, sep string) string { + result := "" + for i, s := range strs { + if i > 0 { + result += sep + } + result += s + } + return result +} diff --git a/components/backend/handlers/bugfix/status.go b/components/backend/handlers/bugfix/status.go new file mode 100644 index 000000000..67f74dd42 --- /dev/null +++ b/components/backend/handlers/bugfix/status.go @@ -0,0 +1,58 @@ +package bugfix + +import ( + "net/http" + + "ambient-code-backend/crd" + + "github.com/gin-gonic/gin" +) + +// GetProjectBugFixWorkflowStatus handles GET /api/projects/:projectName/bugfix-workflows/:id/status +// Returns workflow status including phase, message, and boolean flags +func GetProjectBugFixWorkflowStatus(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get K8s clients + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + + // Get workflow + workflow, err := crd.GetProjectBugFixWorkflowCR(reqDyn, project, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get workflow", "details": err.Error()}) + return + } + if workflow == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + + // Return status fields + status := map[string]interface{}{ + "id": workflow.ID, + "phase": workflow.Phase, + "message": workflow.Message, + "bugFolderCreated": workflow.BugFolderCreated, + "bugfixMarkdownCreated": workflow.BugfixMarkdownCreated, + "githubIssueNumber": workflow.GithubIssueNumber, + "githubIssueURL": workflow.GithubIssueURL, + } + + // Add Jira sync status if available + if workflow.JiraTaskKey != nil { + status["jiraTaskKey"] = *workflow.JiraTaskKey + status["jiraSynced"] = true + if workflow.LastSyncedAt != nil { + status["lastSyncedAt"] = *workflow.LastSyncedAt + } + } else { + status["jiraSynced"] = false + } + + c.JSON(http.StatusOK, status) +} diff --git a/components/backend/k8s/resources.go b/components/backend/k8s/resources.go index 70041fa39..5a806d664 100644 --- a/components/backend/k8s/resources.go +++ b/components/backend/k8s/resources.go @@ -29,6 +29,15 @@ func GetRFEWorkflowResource() schema.GroupVersionResource { } } +// GetBugFixWorkflowResource returns the GroupVersionResource for BugFixWorkflow CRD +func GetBugFixWorkflowResource() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "bugfixworkflows", + } +} + // GetOpenShiftProjectResource returns the GroupVersionResource for OpenShift Project func GetOpenShiftProjectResource() schema.GroupVersionResource { return schema.GroupVersionResource{ diff --git a/components/backend/main.go b/components/backend/main.go index 1d6168a7d..c2456e049 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -9,6 +9,7 @@ import ( "ambient-code-backend/git" "ambient-code-backend/github" "ambient-code-backend/handlers" + bugfixhandlers "ambient-code-backend/handlers/bugfix" "ambient-code-backend/jira" "ambient-code-backend/k8s" "ambient-code-backend/server" @@ -66,6 +67,7 @@ func main() { // Initialize CRD package crd.GetRFEWorkflowResource = k8s.GetRFEWorkflowResource + crd.GetBugFixWorkflowResource = k8s.GetBugFixWorkflowResource // Initialize content handlers handlers.StateBaseDir = server.StateBaseDir @@ -97,6 +99,10 @@ func main() { handlers.CheckBranchExists = checkBranchExists handlers.RfeFromUnstructured = jira.RFEFromUnstructured + // Initialize BugFix workflow handlers + bugfixhandlers.GetK8sClientsForRequest = handlers.GetK8sClientsForRequest + bugfixhandlers.GetAgenticSessionResource = k8s.GetAgenticSessionV1Alpha1Resource + // Initialize Jira handler jiraHandler := &jira.Handler{ GetK8sClientsForRequest: handlers.GetK8sClientsForRequest, diff --git a/components/backend/routes.go b/components/backend/routes.go index 440215e9d..307224790 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -2,6 +2,7 @@ package main import ( "ambient-code-backend/handlers" + bugfixhandlers "ambient-code-backend/handlers/bugfix" "ambient-code-backend/jira" "ambient-code-backend/websocket" @@ -73,6 +74,15 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.POST("/rfe-workflows/:id/sessions/link", handlers.AddProjectRFEWorkflowSession) projectGroup.DELETE("/rfe-workflows/:id/sessions/:sessionName", handlers.RemoveProjectRFEWorkflowSession) + // BugFix Workspace routes + projectGroup.GET("/bugfix-workflows", bugfixhandlers.ListProjectBugFixWorkflows) + projectGroup.POST("/bugfix-workflows", bugfixhandlers.CreateProjectBugFixWorkflow) + projectGroup.GET("/bugfix-workflows/:id", bugfixhandlers.GetProjectBugFixWorkflow) + projectGroup.DELETE("/bugfix-workflows/:id", bugfixhandlers.DeleteProjectBugFixWorkflow) + projectGroup.GET("/bugfix-workflows/:id/status", bugfixhandlers.GetProjectBugFixWorkflowStatus) + projectGroup.POST("/bugfix-workflows/:id/sessions", bugfixhandlers.CreateProjectBugFixWorkflowSession) + projectGroup.GET("/bugfix-workflows/:id/sessions", bugfixhandlers.ListProjectBugFixWorkflowSessions) + projectGroup.GET("/permissions", handlers.ListProjectPermissions) projectGroup.POST("/permissions", handlers.AddProjectPermission) projectGroup.DELETE("/permissions/:subjectType/:subjectName", handlers.RemoveProjectPermission) diff --git a/components/backend/websocket/bugfix_events.go b/components/backend/websocket/bugfix_events.go new file mode 100644 index 000000000..6e4185e03 --- /dev/null +++ b/components/backend/websocket/bugfix_events.go @@ -0,0 +1,143 @@ +package websocket + +import ( + "time" +) + +// BugFix Workspace WebSocket Event Types +const ( + EventBugFixWorkspaceCreated = "bugfix-workspace-created" + EventBugFixSessionStarted = "bugfix-session-started" + EventBugFixSessionProgress = "bugfix-session-progress" + EventBugFixSessionCompleted = "bugfix-session-completed" + EventBugFixSessionFailed = "bugfix-session-failed" + EventBugFixJiraSyncStarted = "bugfix-jira-sync-started" + EventBugFixJiraSyncCompleted = "bugfix-jira-sync-completed" + EventBugFixJiraSyncFailed = "bugfix-jira-sync-failed" +) + +// BroadcastBugFixWorkspaceCreated broadcasts when a BugFix Workspace is created +func BroadcastBugFixWorkspaceCreated(workflowID, githubIssueURL string, issueNumber int) { + message := &SessionMessage{ + SessionID: workflowID, // Use workflowID as session identifier + Type: EventBugFixWorkspaceCreated, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "githubIssueURL": githubIssueURL, + "githubIssueNumber": issueNumber, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixSessionStarted broadcasts when a session starts +func BroadcastBugFixSessionStarted(workflowID, sessionID, sessionType string, issueNumber int) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixSessionStarted, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "sessionId": sessionID, + "sessionType": sessionType, + "githubIssueNumber": issueNumber, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixSessionProgress broadcasts session progress updates +func BroadcastBugFixSessionProgress(workflowID, sessionID, sessionType, phase, progressMessage string, progress float64) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixSessionProgress, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "sessionId": sessionID, + "sessionType": sessionType, + "phase": phase, + "message": progressMessage, + "progress": progress, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixSessionCompleted broadcasts when a session completes successfully +func BroadcastBugFixSessionCompleted(workflowID, sessionID, sessionType string) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixSessionCompleted, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "sessionId": sessionID, + "sessionType": sessionType, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixSessionFailed broadcasts when a session fails +func BroadcastBugFixSessionFailed(workflowID, sessionID, sessionType, errorMsg string) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixSessionFailed, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "sessionId": sessionID, + "sessionType": sessionType, + "error": errorMsg, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixJiraSyncStarted broadcasts when Jira sync starts +func BroadcastBugFixJiraSyncStarted(workflowID string, issueNumber int) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixJiraSyncStarted, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "githubIssueNumber": issueNumber, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixJiraSyncCompleted broadcasts when Jira sync completes +func BroadcastBugFixJiraSyncCompleted(workflowID, jiraTaskKey, jiraTaskURL string, issueNumber int, created bool) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixJiraSyncCompleted, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "jiraTaskKey": jiraTaskKey, + "jiraTaskURL": jiraTaskURL, + "githubIssueNumber": issueNumber, + "created": created, + }, + } + Hub.broadcast <- message +} + +// BroadcastBugFixJiraSyncFailed broadcasts when Jira sync fails +func BroadcastBugFixJiraSyncFailed(workflowID string, issueNumber int, errorMsg string) { + message := &SessionMessage{ + SessionID: workflowID, + Type: EventBugFixJiraSyncFailed, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Payload: map[string]interface{}{ + "workflowId": workflowID, + "githubIssueNumber": issueNumber, + "error": errorMsg, + }, + } + Hub.broadcast <- message +} From a5eaae7c2729a15d525368318c87f13e1e1f0870 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 31 Oct 2025 17:05:56 +0000 Subject: [PATCH 04/19] feat(bugfix): Phase 2 - Frontend API client and WebSocket hook (T019-T020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Phase 2 with frontend integration for BugFix Workspace: API Client (services/api/bugfix.ts): - Complete TypeScript API client with type-safe interfaces - Functions: listBugFixWorkflows, getBugFixWorkflow, createBugFixWorkflow - Functions: deleteBugFixWorkflow, getBugFixWorkflowStatus - Functions: createBugFixSession, listBugFixSessions, syncBugFixToJira - Type definitions: BugFixWorkflow, CreateBugFixWorkflowRequest, TextDescriptionInput, CreateBugFixSessionRequest, BugFixWorkflowStatus, BugFixSession, SyncJiraRequest/Response WebSocket Hook (hooks/useBugFixWebSocket.ts): - Real-time event listener with React Query integration - 8 event types: workspace-created, session-started/progress/completed/failed, jira-sync-started/completed/failed - Auto-reconnection with exponential backoff - Automatic cache invalidation on relevant events - Simplified useBugFixEvent() helper for single event subscriptions Features: - Type-safe API calls with error handling - Automatic React Query cache invalidation - WebSocket connection management with reconnection logic - Event-specific callbacks for granular control - Export through service/hook index files Example Usage: ```tsx // API client const workflow = await bugfixApi.createBugFixWorkflow(projectName, { githubIssueURL: 'https://github.com/org/repo/issues/123', umbrellaRepo: { url: 'https://github.com/org/specs' } }); // WebSocket hook const { isConnected } = useBugFixWebSocket({ projectName: 'my-project', workflowId: 'bugfix-123', onSessionProgress: (event) => console.log(event.payload.message), onJiraSyncCompleted: (event) => toast.success(`Synced: ${event.payload.jiraTaskKey}`) }); ``` Phase 2 Complete: Backend + Frontend infrastructure ready Next: Phase 3+ (User Story implementations) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/frontend/src/hooks/index.ts | 1 + .../frontend/src/hooks/useBugFixWebSocket.ts | 289 ++++++++++++++++++ .../frontend/src/services/api/bugfix.ts | 208 +++++++++++++ components/frontend/src/services/api/index.ts | 1 + 4 files changed, 499 insertions(+) create mode 100644 components/frontend/src/hooks/useBugFixWebSocket.ts create mode 100644 components/frontend/src/services/api/bugfix.ts diff --git a/components/frontend/src/hooks/index.ts b/components/frontend/src/hooks/index.ts index 5532a833f..9a18203d4 100644 --- a/components/frontend/src/hooks/index.ts +++ b/components/frontend/src/hooks/index.ts @@ -6,3 +6,4 @@ export * from './use-clipboard'; export * from './use-debounce'; export * from './use-local-storage'; +export * from './useBugFixWebSocket'; diff --git a/components/frontend/src/hooks/useBugFixWebSocket.ts b/components/frontend/src/hooks/useBugFixWebSocket.ts new file mode 100644 index 000000000..9a53d080e --- /dev/null +++ b/components/frontend/src/hooks/useBugFixWebSocket.ts @@ -0,0 +1,289 @@ +/** + * BugFix Workspace WebSocket hook + * Listens to real-time events for BugFix workflows + */ + +import { useEffect, useCallback, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +export type BugFixEventType = + | 'bugfix-workspace-created' + | 'bugfix-session-started' + | 'bugfix-session-progress' + | 'bugfix-session-completed' + | 'bugfix-session-failed' + | 'bugfix-jira-sync-started' + | 'bugfix-jira-sync-completed' + | 'bugfix-jira-sync-failed'; + +export interface BugFixEvent { + type: BugFixEventType; + timestamp: string; + payload: { + workflowId: string; + sessionId?: string; + sessionType?: string; + githubIssueNumber?: number; + githubIssueURL?: string; + jiraTaskKey?: string; + jiraTaskURL?: string; + phase?: string; + message?: string; + progress?: number; + error?: string; + created?: boolean; + }; +} + +export interface UseBugFixWebSocketOptions { + projectName: string; + workflowId: string; + onWorkspaceCreated?: (event: BugFixEvent) => void; + onSessionStarted?: (event: BugFixEvent) => void; + onSessionProgress?: (event: BugFixEvent) => void; + onSessionCompleted?: (event: BugFixEvent) => void; + onSessionFailed?: (event: BugFixEvent) => void; + onJiraSyncStarted?: (event: BugFixEvent) => void; + onJiraSyncCompleted?: (event: BugFixEvent) => void; + onJiraSyncFailed?: (event: BugFixEvent) => void; + enabled?: boolean; +} + +/** + * Hook to listen to BugFix Workspace WebSocket events + * + * @example + * ```tsx + * const { isConnected } = useBugFixWebSocket({ + * projectName: 'my-project', + * workflowId: 'bugfix-123', + * onSessionProgress: (event) => { + * console.log('Progress:', event.payload.message); + * }, + * onJiraSyncCompleted: (event) => { + * toast.success(`Synced to ${event.payload.jiraTaskKey}`); + * } + * }); + * ``` + */ +export function useBugFixWebSocket(options: UseBugFixWebSocketOptions) { + const { + projectName, + workflowId, + onWorkspaceCreated, + onSessionStarted, + onSessionProgress, + onSessionCompleted, + onSessionFailed, + onJiraSyncStarted, + onJiraSyncCompleted, + onJiraSyncFailed, + enabled = true, + } = options; + + const queryClient = useQueryClient(); + const wsRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const reconnectTimeoutRef = useRef(); + const reconnectAttemptsRef = useRef(0); + const maxReconnectAttempts = 5; + + const handleEvent = useCallback( + (event: BugFixEvent) => { + // Call appropriate callback based on event type + switch (event.type) { + case 'bugfix-workspace-created': + onWorkspaceCreated?.(event); + // Invalidate workflow list query + queryClient.invalidateQueries({ + queryKey: ['bugfix-workflows', projectName], + }); + break; + + case 'bugfix-session-started': + onSessionStarted?.(event); + // Invalidate sessions list + queryClient.invalidateQueries({ + queryKey: ['bugfix-sessions', projectName, workflowId], + }); + break; + + case 'bugfix-session-progress': + onSessionProgress?.(event); + break; + + case 'bugfix-session-completed': + onSessionCompleted?.(event); + queryClient.invalidateQueries({ + queryKey: ['bugfix-sessions', projectName, workflowId], + }); + break; + + case 'bugfix-session-failed': + onSessionFailed?.(event); + queryClient.invalidateQueries({ + queryKey: ['bugfix-sessions', projectName, workflowId], + }); + break; + + case 'bugfix-jira-sync-started': + onJiraSyncStarted?.(event); + break; + + case 'bugfix-jira-sync-completed': + onJiraSyncCompleted?.(event); + // Invalidate workflow status + queryClient.invalidateQueries({ + queryKey: ['bugfix-workflow-status', projectName, workflowId], + }); + queryClient.invalidateQueries({ + queryKey: ['bugfix-workflow', projectName, workflowId], + }); + break; + + case 'bugfix-jira-sync-failed': + onJiraSyncFailed?.(event); + break; + } + }, + [ + projectName, + workflowId, + onWorkspaceCreated, + onSessionStarted, + onSessionProgress, + onSessionCompleted, + onSessionFailed, + onJiraSyncStarted, + onJiraSyncCompleted, + onJiraSyncFailed, + queryClient, + ] + ); + + const connect = useCallback(() => { + if (!enabled || !projectName || !workflowId) return; + + try { + // Build WebSocket URL (using workflow ID as session ID for routing) + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/api/projects/${projectName}/sessions/${workflowId}/ws`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setIsConnected(true); + setError(null); + reconnectAttemptsRef.current = 0; + }; + + ws.onmessage = (messageEvent) => { + try { + const data = JSON.parse(messageEvent.data); + // Filter for BugFix events only + if (data.type && data.type.startsWith('bugfix-')) { + handleEvent(data as BugFixEvent); + } + } catch (err) { + console.error('Failed to parse WebSocket message:', err); + } + }; + + ws.onerror = (event) => { + console.error('WebSocket error:', event); + setError(new Error('WebSocket connection error')); + }; + + ws.onclose = () => { + setIsConnected(false); + wsRef.current = null; + + // Attempt reconnection with exponential backoff + if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); + reconnectAttemptsRef.current += 1; + + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, delay); + } + }; + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to connect')); + } + }, [enabled, projectName, workflowId, handleEvent]); + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + setIsConnected(false); + }, []); + + useEffect(() => { + if (enabled && projectName && workflowId) { + connect(); + } + + return () => { + disconnect(); + }; + }, [enabled, projectName, workflowId, connect, disconnect]); + + return { + isConnected, + error, + reconnect: connect, + disconnect, + }; +} + +/** + * Simplified hook for listening to specific BugFix event types + * + * @example + * ```tsx + * useBugFixEvent('my-project', 'bugfix-123', 'bugfix-session-completed', (event) => { + * toast.success('Session completed!'); + * }); + * ``` + */ +export function useBugFixEvent( + projectName: string, + workflowId: string, + eventType: BugFixEventType, + handler: (event: BugFixEvent) => void, + enabled = true +) { + const handlers = { + 'bugfix-workspace-created': eventType === 'bugfix-workspace-created' ? handler : undefined, + 'bugfix-session-started': eventType === 'bugfix-session-started' ? handler : undefined, + 'bugfix-session-progress': eventType === 'bugfix-session-progress' ? handler : undefined, + 'bugfix-session-completed': eventType === 'bugfix-session-completed' ? handler : undefined, + 'bugfix-session-failed': eventType === 'bugfix-session-failed' ? handler : undefined, + 'bugfix-jira-sync-started': eventType === 'bugfix-jira-sync-started' ? handler : undefined, + 'bugfix-jira-sync-completed': eventType === 'bugfix-jira-sync-completed' ? handler : undefined, + 'bugfix-jira-sync-failed': eventType === 'bugfix-jira-sync-failed' ? handler : undefined, + }; + + return useBugFixWebSocket({ + projectName, + workflowId, + enabled, + onWorkspaceCreated: handlers['bugfix-workspace-created'], + onSessionStarted: handlers['bugfix-session-started'], + onSessionProgress: handlers['bugfix-session-progress'], + onSessionCompleted: handlers['bugfix-session-completed'], + onSessionFailed: handlers['bugfix-session-failed'], + onJiraSyncStarted: handlers['bugfix-jira-sync-started'], + onJiraSyncCompleted: handlers['bugfix-jira-sync-completed'], + onJiraSyncFailed: handlers['bugfix-jira-sync-failed'], + }); +} diff --git a/components/frontend/src/services/api/bugfix.ts b/components/frontend/src/services/api/bugfix.ts new file mode 100644 index 000000000..11ab7c59a --- /dev/null +++ b/components/frontend/src/services/api/bugfix.ts @@ -0,0 +1,208 @@ +/** + * BugFix Workspace API service + * Handles all BugFix workflow-related API calls + */ + +import { apiClient } from './client'; +import type { AgenticSession } from '@/types/api'; + +/** + * BugFix Workspace types + */ +export interface BugFixWorkflow { + id: string; + githubIssueNumber: number; + githubIssueURL: string; + title: string; + description?: string; + branchName: string; + phase: 'Initializing' | 'Ready'; + message?: string; + bugFolderCreated: boolean; + bugfixMarkdownCreated: boolean; + project: string; + createdAt: string; + createdBy: string; + jiraTaskKey?: string; + lastSyncedAt?: string; + workspacePath?: string; + umbrellaRepo?: { + url: string; + branch?: string; + }; + supportingRepos?: Array<{ + url: string; + branch?: string; + }>; +} + +export interface TextDescriptionInput { + title: string; + symptoms: string; + reproductionSteps?: string; + expectedBehavior?: string; + actualBehavior?: string; + additionalContext?: string; + targetRepository: string; +} + +export interface CreateBugFixWorkflowRequest { + githubIssueURL?: string; + textDescription?: TextDescriptionInput; + umbrellaRepo: { + url: string; + branch?: string; + }; + supportingRepos?: Array<{ + url: string; + branch?: string; + }>; + branchName?: string; +} + +export interface CreateBugFixSessionRequest { + sessionType: 'bug-review' | 'bug-resolution-plan' | 'bug-implement-fix' | 'generic'; + title?: string; + description?: string; + selectedAgents?: string[]; + environmentVariables?: Record; + resourceOverrides?: { + cpu?: string; + memory?: string; + storageClass?: string; + priorityClass?: string; + }; +} + +export interface BugFixWorkflowStatus { + id: string; + phase: string; + message: string; + bugFolderCreated: boolean; + bugfixMarkdownCreated: boolean; + githubIssueNumber: number; + githubIssueURL: string; + jiraSynced: boolean; + jiraTaskKey?: string; + lastSyncedAt?: string; +} + +export interface BugFixSession { + id: string; + title: string; + sessionType: string; + phase: string; + createdAt: string; + completedAt?: string; +} + +export interface SyncJiraRequest { + force?: boolean; +} + +export interface SyncJiraResponse { + success: boolean; + jiraTaskKey?: string; + jiraTaskURL?: string; + created: boolean; + message?: string; + lastSyncedAt?: string; +} + +/** + * List BugFix workflows for a project + */ +export async function listBugFixWorkflows(projectName: string): Promise { + const response = await apiClient.get<{ workflows: BugFixWorkflow[] }>( + `/projects/${projectName}/bugfix-workflows` + ); + return response.workflows || []; +} + +/** + * Get a single BugFix workflow + */ +export async function getBugFixWorkflow( + projectName: string, + workflowId: string +): Promise { + return apiClient.get( + `/projects/${projectName}/bugfix-workflows/${workflowId}` + ); +} + +/** + * Create a new BugFix workflow + */ +export async function createBugFixWorkflow( + projectName: string, + data: CreateBugFixWorkflowRequest +): Promise { + return apiClient.post( + `/projects/${projectName}/bugfix-workflows`, + data + ); +} + +/** + * Delete a BugFix workflow + */ +export async function deleteBugFixWorkflow( + projectName: string, + workflowId: string +): Promise { + await apiClient.delete(`/projects/${projectName}/bugfix-workflows/${workflowId}`); +} + +/** + * Get BugFix workflow status + */ +export async function getBugFixWorkflowStatus( + projectName: string, + workflowId: string +): Promise { + return apiClient.get( + `/projects/${projectName}/bugfix-workflows/${workflowId}/status` + ); +} + +/** + * Create a session for a BugFix workflow + */ +export async function createBugFixSession( + projectName: string, + workflowId: string, + data: CreateBugFixSessionRequest +): Promise { + return apiClient.post( + `/projects/${projectName}/bugfix-workflows/${workflowId}/sessions`, + data + ); +} + +/** + * List sessions for a BugFix workflow + */ +export async function listBugFixSessions( + projectName: string, + workflowId: string +): Promise { + const response = await apiClient.get<{ sessions: BugFixSession[] }>( + `/projects/${projectName}/bugfix-workflows/${workflowId}/sessions` + ); + return response.sessions || []; +} + +/** + * Sync BugFix workflow to Jira + */ +export async function syncBugFixToJira( + projectName: string, + workflowId: string, + data?: SyncJiraRequest +): Promise { + return apiClient.post( + `/projects/${projectName}/bugfix-workflows/${workflowId}/sync-jira`, + data || {} + ); +} diff --git a/components/frontend/src/services/api/index.ts b/components/frontend/src/services/api/index.ts index cc5f05420..83374557e 100644 --- a/components/frontend/src/services/api/index.ts +++ b/components/frontend/src/services/api/index.ts @@ -8,6 +8,7 @@ export * as clusterApi from './cluster'; export * as projectsApi from './projects'; export * as sessionsApi from './sessions'; export * as rfeApi from './rfe'; +export * as bugfixApi from './bugfix'; export * as githubApi from './github'; export * as keysApi from './keys'; export * as repoApi from './repo'; From 6486e168d774544ed71bb018da5f217c722c8905 Mon Sep 17 00:00:00 2001 From: sallyom Date: Fri, 31 Oct 2025 17:28:42 +0000 Subject: [PATCH 05/19] Phase 3: User Story 1 - Create BugFix Workspace from GitHub Issue (T021-T031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed User Story 1 - Create Workspace from GitHub Issue: Frontend UI Implementation: - WorkspaceCreator page with dual-tab interface (GitHub Issue URL + Text Description) - Workspace list page with table view and status indicators - Workspace detail page with Overview/Sessions/Actions tabs - WebSocket integration for real-time updates - Form validation with React Hook Form and Zod Test Scaffolds: - Backend handler tests (T021-T023) with contract test documentation - Frontend integration test scaffold with test case documentation Note: T024-T027 (validation, folder creation, branch creation, duplicate detection) were already implemented in Phase 2 as part of the create handler. Progress: 31/79 tasks complete (39%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../app/projects/[name]/bugfix/[id]/page.tsx | 375 +++++++++++++++ .../app/projects/[name]/bugfix/new/page.tsx | 436 ++++++++++++++++++ .../src/app/projects/[name]/bugfix/page.tsx | 187 ++++++++ tests/backend/bugfix/handlers_test.go | 129 ++++++ .../frontend/bugfix/WorkspaceCreator.test.tsx | 158 +++++++ 5 files changed, 1285 insertions(+) create mode 100644 components/frontend/src/app/projects/[name]/bugfix/[id]/page.tsx create mode 100644 components/frontend/src/app/projects/[name]/bugfix/new/page.tsx create mode 100644 components/frontend/src/app/projects/[name]/bugfix/page.tsx create mode 100644 tests/backend/bugfix/handlers_test.go create mode 100644 tests/frontend/bugfix/WorkspaceCreator.test.tsx diff --git a/components/frontend/src/app/projects/[name]/bugfix/[id]/page.tsx b/components/frontend/src/app/projects/[name]/bugfix/[id]/page.tsx new file mode 100644 index 000000000..0fda23b8d --- /dev/null +++ b/components/frontend/src/app/projects/[name]/bugfix/[id]/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Bug, ExternalLink, GitBranch, Clock, Play, Trash2, CheckCircle2 } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +import { bugfixApi } from '@/services/api'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { successToast, errorToast } from '@/hooks/use-toast'; +import { useBugFixWebSocket } from '@/hooks'; + +export default function BugFixWorkspaceDetailPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const projectName = params?.name as string; + const workflowId = params?.id as string; + + const { data: workflow, isLoading: workflowLoading } = useQuery({ + queryKey: ['bugfix-workflow', projectName, workflowId], + queryFn: () => bugfixApi.getBugFixWorkflow(projectName, workflowId), + enabled: !!projectName && !!workflowId, + }); + + const { data: sessions, isLoading: sessionsLoading } = useQuery({ + queryKey: ['bugfix-sessions', projectName, workflowId], + queryFn: () => bugfixApi.listBugFixSessions(projectName, workflowId), + enabled: !!projectName && !!workflowId, + }); + + // WebSocket for real-time updates + useBugFixWebSocket({ + projectName, + workflowId, + onSessionCompleted: () => { + successToast('Session completed successfully'); + }, + onJiraSyncCompleted: (event) => { + successToast(`Synced to Jira: ${event.payload.jiraTaskKey}`); + }, + enabled: !!projectName && !!workflowId, + }); + + const handleDeleteWorkspace = async () => { + try { + await bugfixApi.deleteBugFixWorkflow(projectName, workflowId); + successToast('Workspace deleted successfully'); + router.push(`/projects/${projectName}/bugfix`); + } catch (error) { + errorToast(error instanceof Error ? error.message : 'Failed to delete workspace'); + } + }; + + const getPhaseColor = (phase: string) => { + switch (phase) { + case 'Ready': + case 'Completed': + return 'bg-green-500/10 text-green-500 border-green-500/20'; + case 'Running': + return 'bg-blue-500/10 text-blue-500 border-blue-500/20'; + case 'Initializing': + case 'Pending': + return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20'; + case 'Failed': + return 'bg-red-500/10 text-red-500 border-red-500/20'; + default: + return 'bg-gray-500/10 text-gray-500 border-gray-500/20'; + } + }; + + if (workflowLoading) { + return ( +
+ + +
+ ); + } + + if (!workflow) { + return ( +
+
+

Workspace not found

+ + + +
+
+ ); + } + + return ( +
+ + +
+ + + +
+
+ +

{workflow.title}

+
+
+ + GitHub Issue #{workflow.githubIssueNumber} + + +
+ + {workflow.branchName} +
+
+ + {workflow.createdAt && formatDistanceToNow(new Date(workflow.createdAt), { addSuffix: true })} +
+
+
+ + {workflow.phase} + +
+ + + + Overview + Sessions ({sessions?.length || 0}) + Actions + + + +
+ + + Workspace Status + + +
+
+
Bug Folder
+
+ {workflow.bugFolderCreated ? ( + <> + + Created + + ) : ( + Not created + )} +
+
+
+
Jira Task
+
+ {workflow.jiraTaskKey ? ( + {workflow.jiraTaskKey} + ) : ( + Not synced + )} +
+
+
+ {workflow.description && ( +
+
Description
+
+ {workflow.description} +
+
+ )} +
+
+ + + + Repositories + + +
+ {workflow.umbrellaRepo && ( + + )} + {workflow.supportingRepos && workflow.supportingRepos.length > 0 && ( +
+
Implementation Repositories
+
+ {workflow.supportingRepos.map((repo, idx) => ( + + {repo.url} + + + ))} +
+
+ )} +
+
+
+
+
+ + + + + Sessions + + Agentic sessions for this bug fix workspace + + + + {sessionsLoading && } + {!sessionsLoading && sessions && sessions.length === 0 && ( +
+ No sessions created yet +
+ )} + {!sessionsLoading && sessions && sessions.length > 0 && ( + + + + Title + Type + Status + Created + + + + {sessions.map((session) => ( + router.push(`/projects/${projectName}/sessions/${session.id}`)} + > + {session.title} + + {session.sessionType} + + + + {session.phase} + + + + {formatDistanceToNow(new Date(session.createdAt), { addSuffix: true })} + + + ))} + +
+ )} +
+
+
+ + + + + Workspace Actions + + Manage this bug fix workspace + + + +
+

Create Session

+

+ Start a new agentic session for this bug fix +

+ +

+ Session creation coming in next phase +

+
+ +
+

Danger Zone

+

+ Delete this workspace (does not delete GitHub Issue or branch) +

+ + + + + + + Delete Workspace? + + This will delete the BugFix workspace. The GitHub Issue and git branch will not be deleted. + This action cannot be undone. + + + + Cancel + + Delete + + + + +
+
+
+
+
+
+ ); +} diff --git a/components/frontend/src/app/projects/[name]/bugfix/new/page.tsx b/components/frontend/src/app/projects/[name]/bugfix/new/page.tsx new file mode 100644 index 000000000..7e28124e8 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/bugfix/new/page.tsx @@ -0,0 +1,436 @@ +'use client'; + +import React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import Link from 'next/link'; +import { ArrowLeft, Loader2, Bug, GitBranch } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { Breadcrumbs } from '@/components/breadcrumbs'; + +import { bugfixApi, type CreateBugFixWorkflowRequest } from '@/services/api'; +import { successToast, errorToast } from '@/hooks/use-toast'; + +// GitHub Issue URL validation +const githubIssueUrlRegex = /^https?:\/\/github\.com\/[\w-]+\/[\w.-]+\/issues\/\d+\/?$/; + +const repoSchema = z.object({ + url: z.string().url('Please enter a valid repository URL'), + branch: z.string().optional(), +}); + +// Form schema for GitHub Issue URL flow +const issueUrlSchema = z.object({ + githubIssueURL: z.string() + .url('Please enter a valid URL') + .regex(githubIssueUrlRegex, 'Must be a valid GitHub Issue URL (e.g., https://github.com/owner/repo/issues/123)'), + umbrellaRepo: repoSchema, + branchName: z.string().optional(), +}); + +// Form schema for text description flow +const textDescriptionSchema = z.object({ + title: z.string().min(5, 'Title must be at least 5 characters').max(200, 'Title must be less than 200 characters'), + symptoms: z.string().min(20, 'Symptoms must be at least 20 characters'), + reproductionSteps: z.string().optional(), + expectedBehavior: z.string().optional(), + actualBehavior: z.string().optional(), + additionalContext: z.string().optional(), + targetRepository: z.string().url('Please enter a valid repository URL'), + umbrellaRepo: repoSchema, + branchName: z.string().optional(), +}); + +type IssueUrlFormValues = z.infer; +type TextDescriptionFormValues = z.infer; + +export default function NewBugFixWorkspacePage() { + const router = useRouter(); + const params = useParams(); + const projectName = params?.name as string; + const [activeTab, setActiveTab] = React.useState<'issue-url' | 'text-description'>('issue-url'); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // Form for GitHub Issue URL + const issueUrlForm = useForm({ + resolver: zodResolver(issueUrlSchema), + defaultValues: { + githubIssueURL: '', + umbrellaRepo: { url: '', branch: 'main' }, + branchName: '', + }, + }); + + // Form for text description + const textDescriptionForm = useForm({ + resolver: zodResolver(textDescriptionSchema), + defaultValues: { + title: '', + symptoms: '', + reproductionSteps: '', + expectedBehavior: '', + actualBehavior: '', + additionalContext: '', + targetRepository: '', + umbrellaRepo: { url: '', branch: 'main' }, + branchName: '', + }, + }); + + // Auto-generate branch name from GitHub Issue URL + const githubIssueURL = issueUrlForm.watch('githubIssueURL'); + React.useEffect(() => { + const match = githubIssueURL?.match(/\/issues\/(\d+)/); + if (match) { + const issueNumber = match[1]; + issueUrlForm.setValue('branchName', `bugfix/gh-${issueNumber}`, { shouldValidate: false }); + } + }, [githubIssueURL, issueUrlForm]); + + const onSubmitIssueUrl = async (values: IssueUrlFormValues) => { + setIsSubmitting(true); + try { + const request: CreateBugFixWorkflowRequest = { + githubIssueURL: values.githubIssueURL.trim(), + umbrellaRepo: { + url: values.umbrellaRepo.url.trim(), + branch: values.umbrellaRepo.branch?.trim() || 'main', + }, + branchName: values.branchName?.trim() || undefined, + }; + + const workflow = await bugfixApi.createBugFixWorkflow(projectName, request); + + successToast(`BugFix workspace created for Issue #${workflow.githubIssueNumber}`); + router.push(`/projects/${encodeURIComponent(projectName)}/bugfix/${encodeURIComponent(workflow.id)}`); + } catch (error) { + console.error('Failed to create BugFix workspace:', error); + errorToast(error instanceof Error ? error.message : 'Failed to create workspace'); + } finally { + setIsSubmitting(false); + } + }; + + const onSubmitTextDescription = async (values: TextDescriptionFormValues) => { + setIsSubmitting(true); + try { + const request: CreateBugFixWorkflowRequest = { + textDescription: { + title: values.title, + symptoms: values.symptoms, + reproductionSteps: values.reproductionSteps || undefined, + expectedBehavior: values.expectedBehavior || undefined, + actualBehavior: values.actualBehavior || undefined, + additionalContext: values.additionalContext || undefined, + targetRepository: values.targetRepository.trim(), + }, + umbrellaRepo: { + url: values.umbrellaRepo.url.trim(), + branch: values.umbrellaRepo.branch?.trim() || 'main', + }, + branchName: values.branchName?.trim() || undefined, + }; + + const workflow = await bugfixApi.createBugFixWorkflow(projectName, request); + + successToast(`BugFix workspace created for Issue #${workflow.githubIssueNumber}`); + router.push(`/projects/${encodeURIComponent(projectName)}/bugfix/${encodeURIComponent(workflow.id)}`); + } catch (error) { + console.error('Failed to create BugFix workspace:', error); + errorToast(error instanceof Error ? error.message : 'Failed to create workspace'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + +
+ + + +
+

+ + Create BugFix Workspace +

+

+ Create a workspace from a GitHub Issue URL or describe a new bug +

+
+
+ + + + Choose Creation Method + + Create a workspace from an existing GitHub Issue or describe a new bug to create an issue automatically + + + + setActiveTab(v as typeof activeTab)}> + + From GitHub Issue URL + From Bug Description + + + {/* GitHub Issue URL Tab */} + +
+ + ( + + GitHub Issue URL * + + + + + Enter the full URL of the GitHub Issue you want to work on + + + + )} + /> + + ( + + Spec Repository URL * + + + + + Repository where bug documentation will be stored + + + + )} + /> + + ( + + + + Branch Name + + + + + + Auto-generated from issue number. Leave empty to use default. + + + + )} + /> + +
+ + + + +
+ + +
+ + {/* Text Description Tab */} + +
+ + ( + + Bug Title * + + + + + A concise title for the bug (5-200 characters) + + + + )} + /> + + ( + + Bug Symptoms * + +