c375916f05
CI / test (push) Successful in 8s
Add SyncChanged (and the ChangedSlugs git-diff helper it's built on) as an opt-in narrower version of SyncDir: pass the push's "before" SHA and only the campaign directories that actually changed get looked at at all, instead of every campaign getting resolved and diffed against listmonk on every push. An empty or unresolvable since (new branch's first push, a shallow clone missing that commit, any git failure) always falls back to a full scan -- this can only ever cause more work than strictly necessary, never a silently-skipped campaign. SyncDir itself is now a thin wrapper (SyncChanged with since=""), so every existing caller and test is unaffected. campaigns sync PATH now accepts an optional trailing SINCE_SHA.
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package campaign
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
|
)
|
|
|
|
// SyncResult summarizes what a sync run did, split so the CI log (and the
|
|
// person reading it) can tell at a glance what actually happened — no bad
|
|
// campaign silently skipped or overwritten, no preview silently swallowed.
|
|
type SyncResult struct {
|
|
Synced []string // created or updated (content changed) in listmonk
|
|
Unchanged []string // existing draft, content identical — no API write, no preview
|
|
Skipped []string // already sent/running/paused/etc. in listmonk — a historical record, left untouched, not an error
|
|
Rejected []string // validation/segmentation/list-lookup failure — nothing written
|
|
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
|
|
// Mode is purely for CI log visibility into how this run was scoped:
|
|
// "full scan", or "since <sha>: N of M campaign(s) touched" — see
|
|
// SyncChanged.
|
|
Mode string
|
|
}
|
|
|
|
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
|
|
// listmonk draft campaign. Equivalent to SyncChanged with an empty since —
|
|
// see SyncChanged for the git-diff-scoped version this wraps.
|
|
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
|
|
return SyncChanged(lm, root, defaultPreviewEmails, "")
|
|
}
|
|
|
|
// SyncChanged is SyncDir optionally scoped to just the campaigns whose
|
|
// files changed since a given commit (via ChangedSlugs' git diff), so a
|
|
// push that only touched one campaign doesn't need to fetch and diff every
|
|
// other campaign in the repo against listmonk to confirm nothing changed.
|
|
// An empty since, or ChangedSlugs failing to resolve it for any reason
|
|
// (e.g. a shallow clone missing that commit's history), falls back to a
|
|
// full scan exactly like SyncDir — this can never cause a campaign to be
|
|
// silently skipped, only ever process more than strictly necessary.
|
|
func SyncChanged(lm *listmonk.Client, root string, defaultPreviewEmails []string, since string) (*SyncResult, error) {
|
|
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mode := "full scan"
|
|
if since != "" {
|
|
if slugs, ok := ChangedSlugs(root, since); ok {
|
|
total := len(matches)
|
|
filtered := matches[:0]
|
|
for _, path := range matches {
|
|
if slugs[filepath.Base(filepath.Dir(path))] {
|
|
filtered = append(filtered, path)
|
|
}
|
|
}
|
|
matches = filtered
|
|
mode = fmt.Sprintf("since %s: %d of %d campaign(s) touched", since, len(matches), total)
|
|
} else {
|
|
mode = fmt.Sprintf("full scan (could not resolve since %s)", since)
|
|
}
|
|
}
|
|
|
|
result := &SyncResult{Mode: mode}
|
|
for _, path := range matches {
|
|
dir := filepath.Dir(path)
|
|
slug := filepath.Base(dir)
|
|
|
|
res, err := syncOne(lm, slug, dir, path, defaultPreviewEmails)
|
|
if err != nil {
|
|
result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err))
|
|
continue
|
|
}
|
|
if res.Skipped != "" {
|
|
result.Skipped = append(result.Skipped, fmt.Sprintf("%s: %s", slug, res.Skipped))
|
|
continue
|
|
}
|
|
if !res.Changed {
|
|
result.Unchanged = append(result.Unchanged, slug)
|
|
continue
|
|
}
|
|
result.Synced = append(result.Synced, slug)
|
|
if res.PreviewErr != nil {
|
|
result.PreviewFailed = append(result.PreviewFailed, fmt.Sprintf("%s: %v", slug, res.PreviewErr))
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ChangedSlugs returns the set of campaign slugs (directory names under
|
|
// campaigns/) whose files differ between since and HEAD, by shelling out to
|
|
// git diff. The second return is false when since is empty, the all-zero
|
|
// placeholder SHA (what gitea's push event sends for a brand-new branch's
|
|
// first push, since there's no real "before" commit), or the diff itself
|
|
// fails for any reason — most commonly a shallow clone that doesn't have
|
|
// since's history available locally. All of those mean "can't determine
|
|
// what changed," never "nothing changed," so callers must treat false as
|
|
// "fall back to processing everything," not as an empty result.
|
|
func ChangedSlugs(root, since string) (map[string]bool, bool) {
|
|
if since == "" || since == strings.Repeat("0", 40) {
|
|
return nil, false
|
|
}
|
|
cmd := exec.Command("git", "diff", "--name-only", since, "HEAD", "--", "campaigns")
|
|
cmd.Dir = root
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
slugs := map[string]bool{}
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Lines look like "campaigns/<slug>/campaign.md" or
|
|
// "campaigns/<slug>/assets/whatever.pdf" — the pathspec above
|
|
// already guarantees the "campaigns/" prefix.
|
|
parts := strings.SplitN(line, "/", 3)
|
|
if len(parts) >= 2 {
|
|
slugs[parts[1]] = true
|
|
}
|
|
}
|
|
return slugs, true
|
|
}
|
|
|
|
type syncOneResult struct {
|
|
Changed bool
|
|
Skipped string // non-empty (the listmonk status) when left untouched because it's no longer a draft
|
|
PreviewErr error // set only when Changed and the automatic preview send failed
|
|
}
|
|
|
|
func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) {
|
|
fm, body, err := ParseFile(path)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
|
|
existing, err := lm.FindCampaignByName(slug)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
if existing != nil && existing.Status != "draft" {
|
|
// Once a campaign has actually gone out (or is running/paused/etc.),
|
|
// its campaign.md is a historical record of what was sent and when —
|
|
// not something sync should touch or fail the build over. Forcing it
|
|
// to be deleted just to keep CI green would throw away exactly the
|
|
// record someone would want to look back at later. Check this before
|
|
// resolving anything else below, so a since-renamed list or a
|
|
// since-changed attachment on an old campaign can never break the
|
|
// build either — an archived campaign has nothing left to resolve.
|
|
return syncOneResult{Skipped: existing.Status}, nil
|
|
}
|
|
|
|
listIDs, err := resolveLists(lm, fm.Lists)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
if fm.SegmentQuery != "" {
|
|
segID, err := resolveSegment(lm, slug, fm.SegmentQuery)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
listIDs = append(listIDs, segID)
|
|
}
|
|
|
|
mediaIDs, err := resolveAttachments(lm, slug, dir, fm.Attachments)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
|
|
input := listmonk.CampaignInput{
|
|
Name: slug, // the directory name IS the identity — see internal/campaign/parse.go doc
|
|
Subject: fm.Subject,
|
|
FromEmail: fm.FromEmail,
|
|
TemplateID: fm.TemplateID,
|
|
Type: fm.Type,
|
|
Tags: fm.Tags,
|
|
ListIDs: listIDs,
|
|
Body: body,
|
|
MediaIDs: mediaIDs,
|
|
}
|
|
|
|
var campaignID int
|
|
var changed bool
|
|
switch {
|
|
case existing == nil:
|
|
created, err := lm.CreateCampaign(input)
|
|
if err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
campaignID, changed = created.ID, true
|
|
|
|
case campaignUnchanged(existing, input):
|
|
campaignID, changed = existing.ID, false
|
|
|
|
default:
|
|
if _, err := lm.UpdateCampaign(existing.ID, input); err != nil {
|
|
return syncOneResult{}, err
|
|
}
|
|
campaignID, changed = existing.ID, true
|
|
}
|
|
|
|
if !changed {
|
|
return syncOneResult{Changed: false}, nil
|
|
}
|
|
|
|
recipients := fm.PreviewEmails
|
|
if len(recipients) == 0 {
|
|
recipients = defaultPreviewEmails
|
|
}
|
|
var previewErr error
|
|
if len(recipients) > 0 {
|
|
previewErr = lm.TestCampaign(campaignID, input, recipients)
|
|
}
|
|
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
|
|
}
|
|
|
|
func resolveLists(lm *listmonk.Client, names []string) ([]int, error) {
|
|
ids := make([]int, 0, len(names))
|
|
for _, name := range names {
|
|
id, err := lm.FindListByName(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list %q: %w", name, err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// resolveSegment materializes a segment_query into list membership, since a
|
|
// listmonk campaign can only target whole list(s), not an arbitrary query
|
|
// directly (still an open, unmerged upstream feature request — see the
|
|
// plan's Open items). listmonk applies the query server-side in one call
|
|
// (QueryAddToList) — no subscriber IDs are ever fetched client-side. The
|
|
// list is a point-in-time snapshot: re-running sync re-evaluates the query
|
|
// and re-syncs membership, so a later push before send picks up
|
|
// newly-matching subscribers.
|
|
func resolveSegment(lm *listmonk.Client, slug, query string) (int, error) {
|
|
listName := "segment:" + slug
|
|
listID, err := lm.FindOrCreateListByName(listName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("segment list %q: %w", listName, err)
|
|
}
|
|
if err := lm.QueryAddToList(query, listID); err != nil {
|
|
return 0, fmt.Errorf("segment_query: %w", err)
|
|
}
|
|
return listID, nil
|
|
}
|
|
|
|
// resolveAttachments uploads each attachment under a content-hash-synthesized
|
|
// filename, so editing a file naturally produces a fresh upload while an
|
|
// unchanged file is recognized and reused — no local manifest of what's
|
|
// already been uploaded needed, listmonk's own media library is the only
|
|
// state store.
|
|
func resolveAttachments(lm *listmonk.Client, slug, dir string, relPaths []string) ([]int, error) {
|
|
ids := make([]int, 0, len(relPaths))
|
|
for _, rel := range relPaths {
|
|
full := filepath.Join(dir, rel)
|
|
content, err := os.ReadFile(full)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
|
}
|
|
name := attachmentFilename(slug, rel, content)
|
|
|
|
id, found, err := lm.FindMediaByFilename(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
|
}
|
|
if !found {
|
|
id, err = lm.UploadMedia(name, bytes.NewReader(content))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("attachment %s: %w", rel, err)
|
|
}
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func attachmentFilename(slug, relPath string, content []byte) string {
|
|
sum := sha256.Sum256(content)
|
|
prefix := hex.EncodeToString(sum[:])[:8]
|
|
return fmt.Sprintf("%s-%s-%s", slug, prefix, filepath.Base(relPath))
|
|
}
|
|
|
|
// campaignUnchanged reports whether input's content already matches what's
|
|
// stored in listmonk, so an unchanged campaign costs zero API writes and
|
|
// never re-fires a preview. List/tag/media comparisons are order-independent
|
|
// since neither side's ordering is meaningful.
|
|
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
|
|
return existing.Subject == input.Subject &&
|
|
existing.Body == input.Body &&
|
|
// altbody always mirrors body (see CampaignInput.payload) -- comparing
|
|
// it here is what catches a campaign synced before that started, whose
|
|
// stored altbody is still null even though its body hasn't changed.
|
|
existing.AltBody == input.Body &&
|
|
existing.FromEmail == input.FromEmail &&
|
|
// input.TemplateID == 0 means "unspecified, use whatever's assigned"
|
|
// (see CampaignInput.TemplateID) -- listmonk always assigns some real,
|
|
// nonzero template_id server-side even when it's omitted from the
|
|
// create/update payload, so comparing 0 against that would flag every
|
|
// campaign.md without an explicit template_id as changed on every
|
|
// sync, forever.
|
|
(input.TemplateID == 0 || existing.TemplateID == input.TemplateID) &&
|
|
existing.Type == input.Type &&
|
|
equalStringSets(existing.Tags, input.Tags) &&
|
|
equalIntSets(existing.ListIDs, input.ListIDs) &&
|
|
equalIntSets(existing.MediaIDs, input.MediaIDs)
|
|
}
|
|
|
|
func equalStringSets(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
as, bs := append([]string(nil), a...), append([]string(nil), b...)
|
|
sort.Strings(as)
|
|
sort.Strings(bs)
|
|
for i := range as {
|
|
if as[i] != bs[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func equalIntSets(a, b []int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
as, bs := append([]int(nil), a...), append([]int(nil), b...)
|
|
sort.Ints(as)
|
|
sort.Ints(bs)
|
|
for i := range as {
|
|
if as[i] != bs[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|