From 4235f1460cf8623c27f717d7a799bb45b724f743 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Tue, 10 Nov 2026 11:24:09 +0700 Subject: [PATCH] fix: match webhook repo URLs after normalization and redeploy failed apps GitHub webhooks send clone_url with a ".git" suffix while apps often store the browser URL without it, so FindAllByGitRepo's exact match found no app and the push was silently ignored. Repo URLs are now compared as host/owner/repo (scheme, credentials, ".git", trailing slash and case stripped; scp-style git@host:path supported). DeployByGit also redeploys apps in "failed" status, so an app whose first deploy failed (e.g. image not pushed yet) recovers on the next push. Stopped apps are still skipped. Co-Authored-By: Claude Opus 5.5 --- internal/app/gitrepo.go | 38 +++++++++++++++++++ internal/app/gitrepo_test.go | 71 ++++++++++++++++++++++++++++++++++++ internal/app/repository.go | 15 +++++++- internal/app/service.go | 2 +- 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 internal/app/gitrepo.go create mode 100644 internal/app/gitrepo_test.go diff --git a/internal/app/gitrepo.go b/internal/app/gitrepo.go new file mode 100644 index 0000000..cfd503b --- /dev/null +++ b/internal/app/gitrepo.go @@ -0,0 +1,38 @@ +package app + +import "strings" + +// normalizeGitRepo reduces a git remote URL to "host/owner/repo" so webhook +// payloads match the URL stored on an app regardless of scheme, credentials, +// a trailing ".git" or slash, or letter case. GitHub sends clone_url with +// ".git" while users often paste the browser URL without it. +// +// https://github.com/Org/Repo.git -> github.com/org/repo +// git@github.com:org/repo.git -> github.com/org/repo +// ssh://git@github.com/org/repo/ -> github.com/org/repo +func normalizeGitRepo(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } else if at := strings.Index(s, "@"); at >= 0 { + // scp-like syntax: user@host:path + if colon := strings.Index(s[at:], ":"); colon >= 0 { + s = s[:at+colon] + "/" + s[at+colon+1:] + } + } + if slash := strings.Index(s, "/"); slash >= 0 { + if at := strings.LastIndex(s[:slash], "@"); at >= 0 { + s = s[at+1:] + } + } + s = strings.TrimRight(s, "/") + s = strings.TrimSuffix(s, ".git") + return strings.TrimRight(s, "/") +} + +// webhookDeployable reports whether a push webhook may redeploy an app in the +// given status. Failed apps are included so a deploy that failed before its +// image existed recovers on the next push; stopped apps stay stopped. +func webhookDeployable(status string) bool { + return status == StatusRunning || status == StatusFailed +} diff --git a/internal/app/gitrepo_test.go b/internal/app/gitrepo_test.go new file mode 100644 index 0000000..8b246c7 --- /dev/null +++ b/internal/app/gitrepo_test.go @@ -0,0 +1,71 @@ +package app + +import "testing" + +func TestNormalizeGitRepo(t *testing.T) { + want := "github.com/amg-id/cmsapi-3" + for _, in := range []string{ + "https://github.com/amg-id/cmsapi-3", + "https://github.com/amg-id/cmsapi-3.git", + "https://github.com/amg-id/cmsapi-3/", + " https://GitHub.com/AMG-ID/cmsapi-3.git ", + "http://github.com/amg-id/cmsapi-3", + "https://user:token@github.com/amg-id/cmsapi-3.git", + "git@github.com:amg-id/cmsapi-3.git", + "ssh://git@github.com/amg-id/cmsapi-3.git", + "github.com/amg-id/cmsapi-3", + } { + if got := normalizeGitRepo(in); got != want { + t.Errorf("normalizeGitRepo(%q) = %q, want %q", in, got, want) + } + } + if a, b := normalizeGitRepo("https://github.com/amg-id/cmsapi-3"), normalizeGitRepo("https://github.com/amg-id/cmsapi-2"); a == b { + t.Errorf("different repos normalized equal: %q", a) + } +} + +func TestWebhookDeployable(t *testing.T) { + cases := map[string]bool{ + StatusRunning: true, + StatusFailed: true, + StatusStopped: false, + StatusDeploying: false, + StatusCreated: false, + StatusDraft: false, + } + for status, want := range cases { + if got := webhookDeployable(status); got != want { + t.Errorf("webhookDeployable(%q) = %v, want %v", status, got, want) + } + } +} + +func TestRepo_FindAllByGitRepoNormalizesURL(t *testing.T) { + repo, srvRepo := setupRepo(t) + srvID := createTestServer(t, srvRepo) + mk := func(name, gitRepo, branch, status string) { + t.Helper() + a := &App{Name: name, ServerID: srvID, Compose: "services: {}", GitRepo: gitRepo, GitBranch: branch, Status: status} + if err := repo.Create(a); err != nil { + t.Fatal(err) + } + } + mk("no-suffix", "https://github.com/amg-id/cmsapi-3", "staging", StatusFailed) + mk("with-suffix", "https://github.com/amg-id/cmsapi-3.git", "staging", StatusRunning) + mk("other-branch", "https://github.com/amg-id/cmsapi-3", "main", StatusRunning) + mk("other-repo", "https://github.com/amg-id/cmsapi-2.git", "staging", StatusRunning) + mk("draft", "https://github.com/amg-id/cmsapi-3.git", "staging", StatusDraft) + mk("no-repo", "", "staging", StatusRunning) + + apps, err := repo.FindAllByGitRepo("https://github.com/amg-id/cmsapi-3.git", "staging") + if err != nil { + t.Fatal(err) + } + var names []string + for _, a := range apps { + names = append(names, a.Name) + } + if len(names) != 2 || names[0] != "no-suffix" || names[1] != "with-suffix" { + t.Fatalf("got %v, want [no-suffix with-suffix]", names) + } +} diff --git a/internal/app/repository.go b/internal/app/repository.go index ce38e79..ee6d23e 100644 --- a/internal/app/repository.go +++ b/internal/app/repository.go @@ -69,8 +69,21 @@ func (r *Repository) Get(id int64) (*App, error) { return &a, nil } +// FindAllByGitRepo returns non-draft apps on branch whose git_repo points at +// the same repository as repo, compared with normalizeGitRepo. func (r *Repository) FindAllByGitRepo(repo, branch string) ([]App, error) { - return r.queryApps("WHERE git_repo = ? AND git_branch = ? AND status != 'draft' ORDER BY id", repo, branch) + candidates, err := r.queryApps("WHERE git_repo IS NOT NULL AND git_repo != '' AND git_branch = ? AND status != 'draft' ORDER BY id", branch) + if err != nil { + return nil, err + } + want := normalizeGitRepo(repo) + var apps []App + for _, a := range candidates { + if normalizeGitRepo(a.GitRepo) == want { + apps = append(apps, a) + } + } + return apps, nil } func (r *Repository) ListByServer(serverID int64) ([]App, error) { diff --git a/internal/app/service.go b/internal/app/service.go index b871fd0..4e97041 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -198,7 +198,7 @@ func (s *Service) DeployByGit(repo, branch, commitSHA string) { return } for _, app := range apps { - if app.Status != StatusRunning { + if !webhookDeployable(app.Status) { log.Printf("Webhook: app %q is %s, skipping deploy", app.Name, app.Status) continue }