Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions components/backend/handlers/rfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/ioutil"
Expand Down Expand Up @@ -574,6 +575,293 @@ func RemoveProjectRFEWorkflowSession(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Session unlinked from RFE", "session": sessionName, "rfe": id})
}

// GetProjectRFEWorkflowAgents fetches agent definitions from the workflow's umbrella repository
// GET /api/projects/:projectName/rfe-workflows/:id/agents
func GetProjectRFEWorkflowAgents(c *gin.Context) {
project := c.Param("projectName")
id := c.Param("id")

// Get the workflow
gvr := GetRFEWorkflowResource()
reqK8s, reqDyn := GetK8sClientsForRequest(c)
if reqDyn == nil || reqK8s == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}

item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), id, v1.GetOptions{})
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"})
return
}
wf := RfeFromUnstructured(item)
if wf == nil || wf.UmbrellaRepo == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No umbrella repo configured"})
return
}

// Get user ID from forwarded identity middleware
userID, _ := c.Get("userID")
userIDStr, ok := userID.(string)
if !ok || userIDStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"})
return
}

githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Parse repo owner/name from umbrella repo URL
repoURL := wf.UmbrellaRepo.URL
owner, repoName, err := parseOwnerRepoFromURL(repoURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository URL: %v", err)})
return
}

// Get ref (branch)
ref := "main"
if wf.UmbrellaRepo.Branch != nil {
ref = *wf.UmbrellaRepo.Branch
}

// Fetch agents from .claude/agents directory
agents, err := fetchAgentsFromRepo(c.Request.Context(), owner, repoName, ref, githubToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{"agents": agents})
}

// parseOwnerRepoFromURL extracts owner and repo name from a GitHub URL
func parseOwnerRepoFromURL(repoURL string) (string, string, error) {
// Remove .git suffix
repoURL = strings.TrimSuffix(repoURL, ".git")

// Handle https://github.com/owner/repo
if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") {
parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(repoURL, "https://"), "http://"), "/")
if len(parts) >= 3 {
return parts[1], parts[2], nil
}
}

// Handle git@github.com:owner/repo
if strings.Contains(repoURL, "@") {
parts := strings.Split(repoURL, ":")
if len(parts) == 2 {
repoParts := strings.Split(parts[1], "/")
if len(repoParts) == 2 {
return repoParts[0], repoParts[1], nil
}
}
}

// Handle owner/repo format
parts := strings.Split(repoURL, "/")
if len(parts) == 2 {
return parts[0], parts[1], nil
}

return "", "", fmt.Errorf("unable to parse repository URL")
}

// Agent represents an agent definition from .claude/agents directory
type Agent struct {
Persona string `json:"persona"`
Name string `json:"name"`
Role string `json:"role"`
Description string `json:"description"`
}

// fetchAgentsFromRepo fetches and parses agent definitions from .claude/agents directory
func fetchAgentsFromRepo(ctx context.Context, owner, repo, ref, token string) ([]Agent, error) {
api := "https://api.github.com"
agentsPath := ".claude/agents"

// Fetch directory listing
treeURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentsPath, ref)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, treeURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+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 nil, fmt.Errorf("GitHub request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
// No .claude/agents directory - return empty array
return []Agent{}, nil
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body))
}

var treeEntries []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&treeEntries); err != nil {
return nil, fmt.Errorf("failed to parse GitHub response: %w", err)
}

// Filter for .md files
var agentFiles []string
for _, entry := range treeEntries {
name, _ := entry["name"].(string)
typ, _ := entry["type"].(string)
if typ == "file" && strings.HasSuffix(name, ".md") {
agentFiles = append(agentFiles, name)
}
}

// Fetch and parse each agent file
agents := make([]Agent, 0, len(agentFiles))
for _, filename := range agentFiles {
agent, err := fetchAndParseAgentFile(ctx, api, owner, repo, ref, filename, token)
if err != nil {
log.Printf("Warning: failed to parse agent file %s: %v", filename, err)
continue
}
agents = append(agents, agent)
}

return agents, nil
}

// fetchAndParseAgentFile fetches a single agent file and parses its metadata
func fetchAndParseAgentFile(ctx context.Context, api, owner, repo, ref, filename, token string) (Agent, error) {
agentPath := fmt.Sprintf(".claude/agents/%s", filename)
url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentPath, ref)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Agent{}, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+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 Agent{}, fmt.Errorf("GitHub request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Agent{}, fmt.Errorf("GitHub returned status %d", resp.StatusCode)
}

var fileData map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil {
return Agent{}, fmt.Errorf("failed to parse GitHub response: %w", err)
}

// Decode base64 content
content, _ := fileData["content"].(string)
encoding, _ := fileData["encoding"].(string)

var decodedContent string
if strings.ToLower(encoding) == "base64" {
raw := strings.ReplaceAll(content, "\n", "")
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return Agent{}, fmt.Errorf("failed to decode base64 content: %w", err)
}
decodedContent = string(data)
} else {
decodedContent = content
}

// Parse persona from filename
persona := strings.TrimSuffix(filename, ".md")

// Generate default name from filename
nameParts := strings.FieldsFunc(persona, func(r rune) bool {
return r == '-' || r == '_'
})
for i, part := range nameParts {
if len(part) > 0 {
nameParts[i] = strings.ToUpper(part[:1]) + part[1:]
}
}
name := strings.Join(nameParts, " ")

role := ""
description := ""

// Try to extract metadata from YAML frontmatter
// Simple regex-based parsing (consider using a YAML library for production)
lines := strings.Split(decodedContent, "\n")
inFrontmatter := false
for i, line := range lines {
if i == 0 && strings.TrimSpace(line) == "---" {
inFrontmatter = true
continue
}
if inFrontmatter && strings.TrimSpace(line) == "---" {
break
}
if inFrontmatter {
if strings.HasPrefix(line, "name:") {
name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
} else if strings.HasPrefix(line, "role:") {
role = strings.TrimSpace(strings.TrimPrefix(line, "role:"))
} else if strings.HasPrefix(line, "description:") {
description = strings.TrimSpace(strings.TrimPrefix(line, "description:"))
}
}
}

// If no description found, use first non-empty line after frontmatter
if description == "" {
afterFrontmatter := false
for _, line := range lines {
if afterFrontmatter {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
description = trimmed
if len(description) > 150 {
description = description[:150]
}
break
}
}
if strings.TrimSpace(line) == "---" {
if afterFrontmatter {
break
}
afterFrontmatter = true
}
}
}

if description == "" {
description = "No description available"
}

return Agent{
Persona: persona,
Name: name,
Role: role,
Description: description,
}, nil
}

// GetWorkflowJira proxies Jira issue fetch for a linked path
// GET /api/projects/:projectName/rfe-workflows/:id/jira?path=...
func GetWorkflowJira(c *gin.Context) {
Expand Down
1 change: 1 addition & 0 deletions components/backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) {
projectGroup.DELETE("/rfe-workflows/:id", handlers.DeleteProjectRFEWorkflow)
projectGroup.POST("/rfe-workflows/:id/seed", handlers.SeedProjectRFEWorkflow)
projectGroup.GET("/rfe-workflows/:id/check-seeding", handlers.CheckProjectRFEWorkflowSeeding)
projectGroup.GET("/rfe-workflows/:id/agents", handlers.GetProjectRFEWorkflowAgents)

projectGroup.GET("/sessions/:sessionId/ws", websocket.HandleSessionWebSocket)
projectGroup.GET("/sessions/:sessionId/messages", websocket.GetSessionMessagesWS)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildForwardHeadersAsync } from '@/lib/auth';
import { BACKEND_URL } from '@/lib/config';

type RouteContext = {
params: Promise<{
name: string;
id: string;
}>;
};

export async function GET(request: NextRequest, context: RouteContext) {
const { name: projectName, id: workflowId } = await context.params;

const headers = await buildForwardHeadersAsync(request);

const resp = await fetch(
`${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/rfe-workflows/${encodeURIComponent(workflowId)}/agents`,
{
method: 'GET',
headers,
}
);

if (!resp.ok) {
const text = await resp.text();
return NextResponse.json(
{ error: text || 'Failed to fetch agents' },
{ status: resp.status }
);
}

const data = await resp.json();
return NextResponse.json(data);
}
Loading