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 }