From 476a3136a9f55e151ae689cd098795ec865e7850 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 3 Jun 2026 01:23:08 +0000 Subject: fix: clone from local mirror and sync from GitHub push events - ContainerRunner now resolves local project path for non-story (CI) tasks, cloning from the local bare repo instead of the HTTPS GitHub URL to avoid auth failures on the host. - CI failure tasks now use SSH URLs (git@github.com:...) instead of HTTPS to avoid credential prompts even when no local path is found. - GitHub push webhook events now trigger an async git fetch into the local bare repo, keeping the local mirror in sync when work happens directly on GitHub. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/webhook_test.go | 78 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 4 deletions(-) (limited to 'internal/api/webhook_test.go') 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 { -- cgit v1.2.3