* feat(mcp): add workspace root validation to prevent path traversal - Add workspaceRoot field to Service for restricting file operations - Add WithWorkspaceRoot() option for configuring the workspace directory - Add validatePath() helper to check paths are within workspace - Apply validation to all file operation handlers - Default to current working directory for security - Add comprehensive tests for path validation Closes #82 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move CLI commands from pkg/ to internal/cmd/ - Move 18 CLI command packages to internal/cmd/ (not externally importable) - Keep 16 library packages in pkg/ (externally importable) - Update all import paths throughout codebase - Cleaner separation between CLI logic and reusable libraries CLI commands moved: ai, ci, dev, docs, doctor, gitcmd, go, monitor, php, pkgcmd, qa, sdk, security, setup, test, updater, vm, workspace Libraries remaining: agentic, build, cache, cli, container, devops, errors, framework, git, i18n, io, log, mcp, process, release, repos Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(mcp): use pkg/io Medium for sandboxed file operations Replace manual path validation with pkg/io.Medium for all file operations. This delegates security (path traversal, symlink bypass) to the sandboxed local.Medium implementation. Changes: - Add io.NewSandboxed() for creating sandboxed Medium instances - Refactor MCP Service to use io.Medium instead of direct os.* calls - Remove validatePath and resolvePathWithSymlinks functions - Update tests to verify Medium-based behaviour Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct import path and workflow references - Fix pkg/io/io.go import from core-gui to core - Update CI workflows to use internal/cmd/updater path Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(security): address CodeRabbit review issues for path validation - pkg/io/local: add symlink resolution and boundary-aware containment - Reject absolute paths in sandboxed Medium - Use filepath.EvalSymlinks to prevent symlink bypass attacks - Fix prefix check to prevent /tmp/root matching /tmp/root2 - pkg/mcp: fix resolvePath to validate and return errors - Changed resolvePath from (string) to (string, error) - Update deleteFile, renameFile, listDirectory, fileExists to handle errors - Changed New() to return (*Service, error) instead of *Service - Properly propagate option errors instead of silently discarding - pkg/io: wrap errors with E() helper for consistent context - Copy() and MockMedium.Read() now use coreerr.E() - tests: rename to use _Good/_Bad/_Ugly suffixes per coding guidelines - Fix hardcoded /tmp in TestPath to use t.TempDir() - Add TestResolvePath_Bad_SymlinkTraversal test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix gofmt formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix gofmt formatting across all files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
444 lines
12 KiB
Go
444 lines
12 KiB
Go
// cmd_watch.go implements the 'qa watch' command for monitoring GitHub Actions.
|
|
//
|
|
// Usage:
|
|
// core qa watch # Watch current repo's latest push
|
|
// core qa watch --repo X # Watch specific repo
|
|
// core qa watch --commit SHA # Watch specific commit
|
|
// core qa watch --timeout 5m # Custom timeout (default: 10m)
|
|
|
|
package qa
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/cli"
|
|
"github.com/host-uk/core/pkg/errors"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
)
|
|
|
|
// Watch command flags
|
|
var (
|
|
watchRepo string
|
|
watchCommit string
|
|
watchTimeout time.Duration
|
|
)
|
|
|
|
// WorkflowRun represents a GitHub Actions workflow run
|
|
type WorkflowRun struct {
|
|
ID int64 `json:"databaseId"`
|
|
Name string `json:"name"`
|
|
DisplayTitle string `json:"displayTitle"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
HeadSha string `json:"headSha"`
|
|
URL string `json:"url"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// WorkflowJob represents a job within a workflow run
|
|
type WorkflowJob struct {
|
|
ID int64 `json:"databaseId"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// JobStep represents a step within a job
|
|
type JobStep struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
Number int `json:"number"`
|
|
}
|
|
|
|
// addWatchCommand adds the 'watch' subcommand to the qa command.
|
|
func addWatchCommand(parent *cli.Command) {
|
|
watchCmd := &cli.Command{
|
|
Use: "watch",
|
|
Short: i18n.T("cmd.qa.watch.short"),
|
|
Long: i18n.T("cmd.qa.watch.long"),
|
|
RunE: func(cmd *cli.Command, args []string) error {
|
|
return runWatch()
|
|
},
|
|
}
|
|
|
|
watchCmd.Flags().StringVarP(&watchRepo, "repo", "r", "", i18n.T("cmd.qa.watch.flag.repo"))
|
|
watchCmd.Flags().StringVarP(&watchCommit, "commit", "c", "", i18n.T("cmd.qa.watch.flag.commit"))
|
|
watchCmd.Flags().DurationVarP(&watchTimeout, "timeout", "t", 10*time.Minute, i18n.T("cmd.qa.watch.flag.timeout"))
|
|
|
|
parent.AddCommand(watchCmd)
|
|
}
|
|
|
|
func runWatch() error {
|
|
// Check gh is available
|
|
if _, err := exec.LookPath("gh"); err != nil {
|
|
return errors.E("qa.watch", i18n.T("error.gh_not_found"), nil)
|
|
}
|
|
|
|
// Determine repo
|
|
repoFullName, err := resolveRepo(watchRepo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Determine commit
|
|
commitSha, err := resolveCommit(watchCommit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("repo")), repoFullName)
|
|
// Safe prefix for display - handle short SHAs gracefully
|
|
shaPrefix := commitSha
|
|
if len(commitSha) > 8 {
|
|
shaPrefix = commitSha[:8]
|
|
}
|
|
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.qa.watch.commit")), shaPrefix)
|
|
cli.Blank()
|
|
|
|
// Create context with timeout for all gh commands
|
|
ctx, cancel := context.WithTimeout(context.Background(), watchTimeout)
|
|
defer cancel()
|
|
|
|
// Poll for workflow runs
|
|
pollInterval := 3 * time.Second
|
|
var lastStatus string
|
|
|
|
for {
|
|
// Check if context deadline exceeded
|
|
if ctx.Err() != nil {
|
|
cli.Blank()
|
|
return errors.E("qa.watch", i18n.T("cmd.qa.watch.timeout", map[string]interface{}{"Duration": watchTimeout}), nil)
|
|
}
|
|
|
|
runs, err := fetchWorkflowRunsForCommit(ctx, repoFullName, commitSha)
|
|
if err != nil {
|
|
return errors.Wrap(err, "qa.watch", "failed to fetch workflow runs")
|
|
}
|
|
|
|
if len(runs) == 0 {
|
|
// No workflows triggered yet, keep waiting
|
|
cli.Print("\033[2K\r%s", dimStyle.Render(i18n.T("cmd.qa.watch.waiting_for_workflows")))
|
|
time.Sleep(pollInterval)
|
|
continue
|
|
}
|
|
|
|
// Check status of all runs
|
|
allComplete := true
|
|
var pending, success, failed int
|
|
for _, run := range runs {
|
|
switch run.Status {
|
|
case "completed":
|
|
if run.Conclusion == "success" {
|
|
success++
|
|
} else {
|
|
// Count all non-success conclusions as failed
|
|
// (failure, cancelled, timed_out, action_required, stale, etc.)
|
|
failed++
|
|
}
|
|
default:
|
|
allComplete = false
|
|
pending++
|
|
}
|
|
}
|
|
|
|
// Build status line
|
|
status := fmt.Sprintf("%d workflow(s): ", len(runs))
|
|
if pending > 0 {
|
|
status += warningStyle.Render(fmt.Sprintf("%d running", pending))
|
|
if success > 0 || failed > 0 {
|
|
status += ", "
|
|
}
|
|
}
|
|
if success > 0 {
|
|
status += successStyle.Render(fmt.Sprintf("%d passed", success))
|
|
if failed > 0 {
|
|
status += ", "
|
|
}
|
|
}
|
|
if failed > 0 {
|
|
status += errorStyle.Render(fmt.Sprintf("%d failed", failed))
|
|
}
|
|
|
|
// Only print if status changed
|
|
if status != lastStatus {
|
|
cli.Print("\033[2K\r%s", status)
|
|
lastStatus = status
|
|
}
|
|
|
|
if allComplete {
|
|
cli.Blank()
|
|
cli.Blank()
|
|
return printResults(ctx, repoFullName, runs)
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
}
|
|
|
|
// resolveRepo determines the repo to watch
|
|
func resolveRepo(specified string) (string, error) {
|
|
if specified != "" {
|
|
// If it contains /, assume it's already full name
|
|
if strings.Contains(specified, "/") {
|
|
return specified, nil
|
|
}
|
|
// Try to get org from current directory
|
|
org := detectOrgFromGit()
|
|
if org != "" {
|
|
return org + "/" + specified, nil
|
|
}
|
|
return "", errors.E("qa.watch", i18n.T("cmd.qa.watch.error.repo_format"), nil)
|
|
}
|
|
|
|
// Detect from current directory
|
|
return detectRepoFromGit()
|
|
}
|
|
|
|
// resolveCommit determines the commit to watch
|
|
func resolveCommit(specified string) (string, error) {
|
|
if specified != "" {
|
|
return specified, nil
|
|
}
|
|
|
|
// Get HEAD commit
|
|
cmd := exec.Command("git", "rev-parse", "HEAD")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "qa.watch", "failed to get HEAD commit")
|
|
}
|
|
|
|
return strings.TrimSpace(string(output)), nil
|
|
}
|
|
|
|
// detectRepoFromGit detects the repo from git remote
|
|
func detectRepoFromGit() (string, error) {
|
|
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", errors.E("qa.watch", i18n.T("cmd.qa.watch.error.not_git_repo"), nil)
|
|
}
|
|
|
|
url := strings.TrimSpace(string(output))
|
|
return parseGitHubRepo(url)
|
|
}
|
|
|
|
// detectOrgFromGit tries to detect the org from git remote
|
|
func detectOrgFromGit() string {
|
|
repo, err := detectRepoFromGit()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parts := strings.Split(repo, "/")
|
|
if len(parts) >= 1 {
|
|
return parts[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseGitHubRepo extracts org/repo from a git URL
|
|
func parseGitHubRepo(url string) (string, error) {
|
|
// Handle SSH URLs: git@github.com:org/repo.git
|
|
if strings.HasPrefix(url, "git@github.com:") {
|
|
path := strings.TrimPrefix(url, "git@github.com:")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
return path, nil
|
|
}
|
|
|
|
// Handle HTTPS URLs: https://github.com/org/repo.git
|
|
if strings.Contains(url, "github.com/") {
|
|
parts := strings.Split(url, "github.com/")
|
|
if len(parts) >= 2 {
|
|
path := strings.TrimSuffix(parts[1], ".git")
|
|
return path, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url)
|
|
}
|
|
|
|
// fetchWorkflowRunsForCommit fetches workflow runs for a specific commit
|
|
func fetchWorkflowRunsForCommit(ctx context.Context, repoFullName, commitSha string) ([]WorkflowRun, error) {
|
|
args := []string{
|
|
"run", "list",
|
|
"--repo", repoFullName,
|
|
"--commit", commitSha,
|
|
"--json", "databaseId,name,displayTitle,status,conclusion,headSha,url,createdAt,updatedAt",
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// Check if context was cancelled/deadline exceeded
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr)))
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var runs []WorkflowRun
|
|
if err := json.Unmarshal(output, &runs); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return runs, nil
|
|
}
|
|
|
|
// printResults prints the final results with actionable information
|
|
func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun) error {
|
|
var failures []WorkflowRun
|
|
var successes []WorkflowRun
|
|
|
|
for _, run := range runs {
|
|
if run.Conclusion == "success" {
|
|
successes = append(successes, run)
|
|
} else {
|
|
// Treat all non-success as failures (failure, cancelled, timed_out, etc.)
|
|
failures = append(failures, run)
|
|
}
|
|
}
|
|
|
|
// Print successes briefly
|
|
for _, run := range successes {
|
|
cli.Print("%s %s\n", successStyle.Render(cli.Glyph(":check:")), run.Name)
|
|
}
|
|
|
|
// Print failures with details
|
|
for _, run := range failures {
|
|
cli.Print("%s %s\n", errorStyle.Render(cli.Glyph(":cross:")), run.Name)
|
|
|
|
// Fetch failed job details
|
|
failedJob, failedStep, errorLine := fetchFailureDetails(ctx, repoFullName, run.ID)
|
|
if failedJob != "" {
|
|
cli.Print(" %s Job: %s", dimStyle.Render("->"), failedJob)
|
|
if failedStep != "" {
|
|
cli.Print(" (step: %s)", failedStep)
|
|
}
|
|
cli.Blank()
|
|
}
|
|
if errorLine != "" {
|
|
cli.Print(" %s Error: %s\n", dimStyle.Render("->"), errorLine)
|
|
}
|
|
cli.Print(" %s %s\n", dimStyle.Render("->"), run.URL)
|
|
}
|
|
|
|
// Exit with error if any failures
|
|
if len(failures) > 0 {
|
|
cli.Blank()
|
|
return cli.Err("%s", i18n.T("cmd.qa.watch.workflows_failed", map[string]interface{}{"Count": len(failures)}))
|
|
}
|
|
|
|
cli.Blank()
|
|
cli.Print("%s\n", successStyle.Render(i18n.T("cmd.qa.watch.all_passed")))
|
|
return nil
|
|
}
|
|
|
|
// fetchFailureDetails fetches details about why a workflow failed
|
|
func fetchFailureDetails(ctx context.Context, repoFullName string, runID int64) (jobName, stepName, errorLine string) {
|
|
// Fetch jobs for this run
|
|
args := []string{
|
|
"run", "view", fmt.Sprintf("%d", runID),
|
|
"--repo", repoFullName,
|
|
"--json", "jobs",
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", "", ""
|
|
}
|
|
|
|
var result struct {
|
|
Jobs []struct {
|
|
Name string `json:"name"`
|
|
Conclusion string `json:"conclusion"`
|
|
Steps []struct {
|
|
Name string `json:"name"`
|
|
Conclusion string `json:"conclusion"`
|
|
Number int `json:"number"`
|
|
} `json:"steps"`
|
|
} `json:"jobs"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return "", "", ""
|
|
}
|
|
|
|
// Find the failed job and step
|
|
for _, job := range result.Jobs {
|
|
if job.Conclusion == "failure" {
|
|
jobName = job.Name
|
|
for _, step := range job.Steps {
|
|
if step.Conclusion == "failure" {
|
|
stepName = fmt.Sprintf("%d: %s", step.Number, step.Name)
|
|
break
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Try to get the error line from logs (if available)
|
|
errorLine = fetchErrorFromLogs(ctx, repoFullName, runID)
|
|
|
|
return jobName, stepName, errorLine
|
|
}
|
|
|
|
// fetchErrorFromLogs attempts to extract the first error line from workflow logs
|
|
func fetchErrorFromLogs(ctx context.Context, repoFullName string, runID int64) string {
|
|
// Use gh run view --log-failed to get failed step logs
|
|
args := []string{
|
|
"run", "view", fmt.Sprintf("%d", runID),
|
|
"--repo", repoFullName,
|
|
"--log-failed",
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Parse output to find the first meaningful error line
|
|
lines := strings.Split(string(output), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip common metadata/progress lines
|
|
lower := strings.ToLower(line)
|
|
if strings.HasPrefix(lower, "##[") { // GitHub Actions command markers
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "Run ") || strings.HasPrefix(line, "Running ") {
|
|
continue
|
|
}
|
|
|
|
// Look for error indicators
|
|
if strings.Contains(lower, "error") ||
|
|
strings.Contains(lower, "failed") ||
|
|
strings.Contains(lower, "fatal") ||
|
|
strings.Contains(lower, "panic") ||
|
|
strings.Contains(line, ": ") { // Likely a file:line or key: value format
|
|
// Truncate long lines
|
|
if len(line) > 120 {
|
|
line = line[:117] + "..."
|
|
}
|
|
return line
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|