diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/server.go | 1 | ||||
| -rw-r--r-- | internal/api/webhook.go | 56 | ||||
| -rw-r--r-- | internal/api/webhook_test.go | 78 | ||||
| -rw-r--r-- | internal/executor/container.go | 20 | ||||
| -rw-r--r-- | internal/executor/container_test.go | 67 |
5 files changed, 212 insertions, 10 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index 28cfe4a..2dcbf77 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -53,6 +53,7 @@ type Server struct { elaborateLimiter *ipRateLimiter // per-IP rate limiter for elaborate/validate endpoints webhookSecret string // HMAC-SHA256 secret for GitHub webhook validation projects []config.Project // configured projects for webhook routing + githubURLForTest func(fullName string) string // overrides GitHub SSH URL in tests vapidPublicKey string vapidPrivateKey string vapidEmail string diff --git a/internal/api/webhook.go b/internal/api/webhook.go index 3af4cc8..182a85e 100644 --- a/internal/api/webhook.go +++ b/internal/api/webhook.go @@ -10,6 +10,7 @@ import ( "io" "log/slog" "net/http" + "os/exec" "path/filepath" "strings" "time" @@ -115,6 +116,8 @@ func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) { s.handleCheckRunEvent(w, body) case "workflow_run": s.handleWorkflowRunEvent(w, body) + case "push": + s.handlePushEvent(w, body) default: w.WriteHeader(http.StatusNoContent) } @@ -230,7 +233,7 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, State: task.StatePending, CreatedAt: now, UpdatedAt: now, - RepositoryURL: fmt.Sprintf("https://github.com/%s.git", fullName), + RepositoryURL: fmt.Sprintf("git@github.com:%s.git", fullName), } if project != nil { t.Project = project.Name @@ -243,3 +246,54 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName, writeJSON(w, http.StatusOK, map[string]string{"task_id": t.ID}) } + +// pushPayload is a minimal GitHub push webhook payload. +type pushPayload struct { + Ref string `json:"ref"` + Repository struct { + Name string `json:"name"` + FullName string `json:"full_name"` + } `json:"repository"` +} + +// handlePushEvent syncs the local bare repo with GitHub when a push arrives. +// The fetch runs asynchronously so the webhook returns immediately. +func (s *Server) handlePushEvent(w http.ResponseWriter, body []byte) { + var p pushPayload + if err := json.Unmarshal(body, &p); err != nil || p.Repository.FullName == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + project := matchProject(s.projects, p.Repository.Name) + if project == nil { + slog.Info("push event: no matching project, skipping sync", "repo", p.Repository.FullName) + w.WriteHeader(http.StatusNoContent) + return + } + + dbProj, err := s.store.GetProject(project.Name) + if err != nil || dbProj == nil || dbProj.RemoteURL == "" { + slog.Info("push event: project has no local remote, skipping sync", "project", project.Name) + w.WriteHeader(http.StatusNoContent) + return + } + + githubURL := fmt.Sprintf("git@github.com:%s.git", p.Repository.FullName) + if s.githubURLForTest != nil { + githubURL = s.githubURLForTest(p.Repository.FullName) + } + bareRepo := dbProj.RemoteURL + + go func() { + out, err := exec.Command("git", "-C", bareRepo, "fetch", githubURL, + "+refs/heads/*:refs/heads/*", "--prune").CombinedOutput() + if err != nil { + slog.Error("github push sync failed", "repo", p.Repository.FullName, "bare", bareRepo, "error", err, "output", string(out)) + } else { + slog.Info("github push sync succeeded", "repo", p.Repository.FullName, "bare", bareRepo) + } + }() + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go index 967b62b..05fe82c 100644 --- a/internal/api/webhook_test.go +++ b/internal/api/webhook_test.go @@ -8,10 +8,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os/exec" + "path/filepath" "strings" "testing" + "time" "github.com/thepeterstone/claudomator/internal/config" + "github.com/thepeterstone/claudomator/internal/task" ) // signBody computes the HMAC-SHA256 signature for a webhook payload. @@ -124,8 +128,8 @@ func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) { if !strings.Contains(tk.Name, "main") { t.Errorf("task name %q does not contain branch", tk.Name) } - if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { - t.Errorf("task repository url = %q, want https://github.com/owner/myrepo.git", tk.RepositoryURL) + if tk.RepositoryURL != "git@github.com:owner/myrepo.git" { + t.Errorf("task repository url = %q, want git@github.com:owner/myrepo.git", tk.RepositoryURL) } if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") { t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags) @@ -375,8 +379,8 @@ func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) { if err != nil { t.Fatalf("task not found: %v", err) } - if tk.RepositoryURL != "https://github.com/owner/myrepo.git" { - t.Errorf("expected fallback repository url, got %q", tk.RepositoryURL) + if tk.RepositoryURL != "git@github.com:owner/myrepo.git" { + t.Errorf("expected fallback repository url (ssh), got %q", tk.RepositoryURL) } } @@ -400,6 +404,72 @@ func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithGitHubURL(t *testing. } } +func TestGitHubWebhook_PushEvent_Returns204(t *testing.T) { + srv, _ := testServer(t) + srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} + + payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}` + w := webhookPost(t, srv, "push", payload, "") + + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestGitHubWebhook_PushEvent_SyncsLocalBareRepo(t *testing.T) { + dir := t.TempDir() + + // Create a "github" source bare repo with a commit. + githubBare := filepath.Join(dir, "github.git") + localBare := filepath.Join(dir, "local.git") + for _, bare := range []string{githubBare, localBare} { + if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { + t.Fatalf("git init bare %s: %v\n%s", bare, err, out) + } + } + // Seed githubBare with a commit via a temp clone. + clone := filepath.Join(dir, "clone") + run := func(args ...string) { + t.Helper() + if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + run("git", "clone", githubBare, clone) + run("git", "-C", clone, "config", "user.email", "test@test.com") + run("git", "-C", clone, "config", "user.name", "Test") + run("git", "-C", clone, "commit", "--allow-empty", "-m", "initial") + run("git", "-C", clone, "push", "origin", "main") + + // Register DB project with localBare as the remote_url. + srv, store := testServer(t) + proj := &task.Project{ID: "myrepo", Name: "myrepo", RemoteURL: localBare, LocalPath: clone} + if err := store.CreateProject(proj); err != nil { + t.Fatalf("CreateProject: %v", err) + } + srv.projects = []config.Project{{Name: "myrepo", Dir: clone}} + + // Override the github SSH URL to point to our local githubBare for testing. + srv.githubURLForTest = func(fullName string) string { return githubBare } + + payload := `{"ref":"refs/heads/main","repository":{"name":"myrepo","full_name":"owner/myrepo"}}` + w := webhookPost(t, srv, "push", payload, "") + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", w.Code) + } + + // Wait for the async sync goroutine. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + out, err := exec.Command("git", "-C", localBare, "log", "--oneline", "-1").CombinedOutput() + if err == nil && strings.TrimSpace(string(out)) != "" { + return // success — local bare has the commit + } + time.Sleep(50 * time.Millisecond) + } + t.Error("local bare repo was not synced from github within timeout") +} + // contains checks if a string slice contains a value. func contains(slice []string, val string) bool { for _, s := range slice { diff --git a/internal/executor/container.go b/internal/executor/container.go index 61ac29c..23e35b3 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -151,6 +151,14 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } } } + // For non-story tasks (e.g. CI webhook tasks), also resolve local path from the task's + // project field. This lets the runner clone from the local mirror instead of an HTTPS + // remote that may require credentials not available on the host. + if storyLocalPath == "" && t.Project != "" && r.Store != nil { + if proj, err := r.Store.GetProject(t.Project); err == nil && proj != nil && proj.LocalPath != "" { + storyLocalPath = proj.LocalPath + } + } // Fall back to task-level BranchName (e.g. set explicitly by executor or tests). if storyBranch == "" { storyBranch = t.BranchName @@ -171,13 +179,15 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if err := os.Remove(workspace); err != nil { return fmt.Errorf("removing workspace before clone: %w", err) } - r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) - var cloneArgs []string + // Prefer the local path as the clone source to avoid HTTPS auth requirements. + // For story tasks the storyLocalPath is also available as a speed reference, + // but we clone from the local path directly since it is already authoritative. + cloneSrc := repoURL if storyLocalPath != "" { - cloneArgs = []string{"clone", "--reference", storyLocalPath, repoURL, workspace} - } else { - cloneArgs = []string{"clone", repoURL, workspace} + cloneSrc = storyLocalPath } + r.Logger.Info("cloning repository", "src", cloneSrc, "workspace", workspace) + cloneArgs := []string{"clone", cloneSrc, workspace} if out, err := r.command(ctx, "git", cloneArgs...).CombinedOutput(); err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) } diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index f0b2a3a..54b4689 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -607,6 +607,73 @@ func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { } } +// TestContainerRunner_ClonesFromLocalPathForCITask verifies that when a task has +// an HTTPS repository_url but the project has a known local path, the runner +// clones from the local path to avoid HTTPS auth failures. +func TestContainerRunner_ClonesFromLocalPathForCITask(t *testing.T) { + dir := t.TempDir() + localRepo := filepath.Join(dir, "local-repo.git") + if out, err := exec.Command("git", "init", "--bare", localRepo).CombinedOutput(); err != nil { + t.Fatalf("git init bare: %v\n%s", err, out) + } + + store := testStore(t) + proj := &task.Project{ + ID: "nav", + Name: "nav", + RemoteURL: localRepo, + LocalPath: localRepo, + } + if err := store.CreateProject(proj); err != nil { + t.Fatalf("CreateProject: %v", err) + } + + var cloneSrc string + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner := &ContainerRunner{ + Logger: logger, + Image: "busybox", + Store: store, + Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if name == "git" && len(arg) > 0 && arg[0] == "clone" { + // Record what source was cloned from. + for i, a := range arg { + if a != "clone" && a != "--reference" && i > 0 && arg[i-1] != "--reference" { + // last non-dest arg before workspace is the source + } + _ = a + } + // source is second-to-last arg (before workspace dir) + cloneSrc = arg[len(arg)-2] + dir := arg[len(arg)-1] + os.MkdirAll(dir, 0755) + return exec.Command("true") + } + if name == "docker" { + return exec.Command("sh", "-c", "exit 1") + } + return exec.Command("true") + }, + } + + tk := &task.Task{ + ID: "ci-task-1", + RepositoryURL: "https://github.com/thepeterstone/nav.git", + Project: "nav", + Agent: task.AgentConfig{Type: "claude"}, + } + e := &storage.Execution{ID: "exec-ci-1", TaskID: "ci-task-1"} + runner.Run(context.Background(), tk, e) + os.RemoveAll(e.SandboxDir) + + if cloneSrc == "https://github.com/thepeterstone/nav.git" { + t.Error("runner cloned from GitHub HTTPS URL; expected local path to avoid auth failure") + } + if cloneSrc != localRepo { + t.Errorf("expected clone source %q, got %q", localRepo, cloneSrc) + } +} + func TestEnsureStoryBranch_CreatesMissingBranch(t *testing.T) { // Set up a bare repo and a local clone to test branch creation. dir := t.TempDir() |
