Skip to content

Commit 7bdc8df

Browse files
docs(webhook): annotate sync pipeline helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 50730c5 commit 7bdc8df

7 files changed

Lines changed: 179 additions & 20 deletions

File tree

internal/webhook/allowlist.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
1+
// @index Repository and branch allowlist rules for webhook-triggered sync.
12
package webhook
23

34
import (
45
"path"
56
"strings"
67
)
78

9+
// allowRule is one compiled allow or deny pattern with optional negation and wildcard prefix.
10+
// @intent represent a single Atlantis-style repo pattern in a form cheap to evaluate per webhook.
811
type allowRule struct {
912
pattern string
1013
negate bool
1114
wildcard bool
1215
prefix string
1316
}
1417

18+
// RepoRule is the user-facing config form pairing a repo pattern with optional branch globs.
19+
// @intent expose a stable shape for CLI/YAML config without leaking the internal compiled rule layout.
1520
type RepoRule struct {
1621
Pattern string
1722
Branches []string
1823
}
1924

25+
// repoFilterRule binds a compiled allow/deny pattern to its per-rule branch allowlist.
26+
// @intent keep branch policy attached to the rule that allowed the repo so order-sensitive evaluation stays local.
2027
type repoFilterRule struct {
2128
allowRule
2229
branches []string
2330
}
2431

32+
// RepoFilter holds the ordered rule list used to decide repo and branch admission.
33+
// @intent provide a single matcher whose result depends on rule declaration order, where later matching rules override earlier ones.
2534
type RepoFilter struct {
2635
rulesFull []repoFilterRule
2736
}
2837

38+
// NewRepoFilter expands simple string patterns into webhook repo rules.
39+
// @intent keep CLI-style allowlist configuration compatible with the richer rule matcher.
2940
func NewRepoFilter(patterns []string) *RepoFilter {
3041
rules := make([]RepoRule, 0, len(patterns))
3142
for _, p := range patterns {
@@ -34,6 +45,8 @@ func NewRepoFilter(patterns []string) *RepoFilter {
3445
return NewRepoFilterFromRules(rules)
3546
}
3647

48+
// NewRepoFilterFromRules compiles repository and branch rules into a matcher.
49+
// @intent centralize Atlantis-style repo filtering so webhook dispatch can make one consistent allow decision.
3750
func NewRepoFilterFromRules(rules []RepoRule) *RepoFilter {
3851
full := make([]repoFilterRule, 0, len(rules))
3952
for _, r := range rules {
@@ -55,6 +68,10 @@ func NewRepoFilterFromRules(rules []RepoRule) *RepoFilter {
5568

5669
var defaultBranches = []string{"main", "master"}
5770

71+
// IsAllowed reports whether a repository matches the configured allow and deny rules.
72+
// @intent let callers gate repository-level sync before looking at branch-specific restrictions.
73+
// @domainRule rules are evaluated in declaration order and the last matching rule wins; a later allow rule overrides an earlier deny match and vice versa.
74+
// @domainRule a repository with no matching rule is denied (default-deny).
5875
func (f *RepoFilter) IsAllowed(repoFullName string) bool {
5976
allowed := false
6077
for _, r := range f.rulesFull {
@@ -70,6 +87,8 @@ func (f *RepoFilter) IsAllowed(repoFullName string) bool {
7087
return allowed
7188
}
7289

90+
// IsAllowedRef resolves a git ref to a branch and applies branch allowlist rules.
91+
// @intent reject non-branch webhook refs before they can enter the sync pipeline.
7392
func (f *RepoFilter) IsAllowedRef(repoFullName, ref string) bool {
7493
branch, ok := NormalizeBranchRef(ref)
7594
if !ok {
@@ -78,6 +97,11 @@ func (f *RepoFilter) IsAllowedRef(repoFullName, ref string) bool {
7897
return f.IsAllowedBranch(repoFullName, branch)
7998
}
8099

100+
// IsAllowedBranch reports whether a repository and branch pair should be processed.
101+
// @intent combine repo allowlist and branch policy so webhook handlers can skip unsupported pushes cheaply.
102+
// @domainRule with no rules configured the result is always false (default-deny).
103+
// @domainRule rules are evaluated in declaration order; each later matching rule replaces the prior decision, so a negate rule clears any previous allow and a subsequent allow rule re-enables the repo.
104+
// @domainRule when a non-negate rule matches but specifies no branches, the built-in defaults ("main", "master") are used.
81105
func (f *RepoFilter) IsAllowedBranch(repoFullName, branch string) bool {
82106
if len(f.rulesFull) == 0 {
83107
return false
@@ -101,6 +125,7 @@ func (f *RepoFilter) IsAllowedBranch(repoFullName, branch string) bool {
101125
return allowed
102126
}
103127

128+
// @intent evaluate branch globs consistently across repo rules without duplicating path-match logic at call sites.
104129
func matchBranchPatterns(branch string, patterns []string) bool {
105130
for _, p := range patterns {
106131
if matched, _ := path.Match(p, branch); matched {
@@ -110,6 +135,8 @@ func matchBranchPatterns(branch string, patterns []string) bool {
110135
return false
111136
}
112137

138+
// ParseRepoRule decodes a repo rule string with optional comma-separated branch patterns.
139+
// @intent preserve compact CLI config while still supporting per-repository branch restrictions.
113140
func ParseRepoRule(s string) RepoRule {
114141
pattern, branchStr, found := strings.Cut(s, ":")
115142
if !found {
@@ -119,6 +146,7 @@ func ParseRepoRule(s string) RepoRule {
119146
return RepoRule{Pattern: pattern, Branches: branches}
120147
}
121148

149+
// @intent apply one compiled allow or deny pattern to a repository full name during filter evaluation.
122150
func (r *allowRule) match(repoFullName string) bool {
123151
if r.wildcard {
124152
parts := strings.SplitN(repoFullName, "/", 2)

internal/webhook/auth.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index Authentication helpers for webhook-driven git access.
12
package webhook
23

34
import (
@@ -18,6 +19,7 @@ import (
1819
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
1920
)
2021

22+
// @intent bundle the supported git authentication inputs (SSH key, GitHub App credentials, install token) for webhook clone and fetch.
2123
type GitAuth struct {
2224
SSHKeyPath string
2325
SSHKeyData []byte
@@ -27,6 +29,8 @@ type GitAuth struct {
2729
InstallToken string
2830
}
2931

32+
// Resolve picks the first configured git authentication strategy.
33+
// @intent allow webhook sync to switch between SSH and GitHub App token auth without branching at call sites.
3034
func (a *GitAuth) Resolve() (transport.AuthMethod, error) {
3135
switch {
3236
case a.SSHKeyPath != "":
@@ -43,6 +47,8 @@ func (a *GitAuth) Resolve() (transport.AuthMethod, error) {
4347
}
4448
}
4549

50+
// GenerateAppJWT signs a short-lived GitHub App JWT from the configured private key.
51+
// @intent mint the app identity token needed to exchange for installation-scoped repository access.
4652
func GenerateAppJWT(appID int64, privateKeyPEM []byte) (string, error) {
4753
block, _ := pem.Decode(privateKeyPEM)
4854
if block == nil {

internal/webhook/cloneurl.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index Clone URL resolution for webhook-triggered repository sync.
12
package webhook
23

34
import (
@@ -7,6 +8,8 @@ import (
78
"strings"
89
)
910

11+
// ResolveCloneURL selects a safe clone target from configured base URLs or the payload.
12+
// @intent keep repository sync deterministic and prevent untrusted webhook payloads from choosing arbitrary clone endpoints unless explicitly allowed.
1013
func ResolveCloneURL(repoFullName, payloadCloneURL string, baseURLs []string, allowPayload bool) (string, error) {
1114
repoPath, err := normalizeRepoPath(repoFullName)
1215
if err != nil {
@@ -34,6 +37,13 @@ func ResolveCloneURL(repoFullName, payloadCloneURL string, baseURLs []string, al
3437
return "", fmt.Errorf("clone URL is not configured for repo %q", repoFullName)
3538
}
3639

40+
// normalizeRepoPath validates that a repo full name is a clean two-or-more
41+
// segment path with no empty, ".", or ".." components and no leading slash.
42+
//
43+
// @intent reject repo identifiers that could traverse outside the intended path when joined onto a base URL.
44+
// @domainRule each path segment must be non-empty and not equal to "." or "..".
45+
// @domainRule path.Clean(repoFullName) must equal repoFullName and must not start with "/".
46+
// @ensures returned path equals the input when err == nil.
3747
func normalizeRepoPath(repoFullName string) (string, error) {
3848
parts := strings.Split(repoFullName, "/")
3949
if len(parts) < 2 {
@@ -51,6 +61,11 @@ func normalizeRepoPath(repoFullName string) (string, error) {
5161
return cleaned, nil
5262
}
5363

64+
// buildCloneURL joins repoPath onto a validated base URL and appends ".git".
65+
//
66+
// @intent derive a clone URL from a trusted base URL plus a normalized repo path so the host/scheme are not taken from webhook payload data.
67+
// @domainRule resulting path is path.Join(base.Path, repoPath) + ".git".
68+
// @ensures returned URL preserves the base URL's scheme and host when err == nil.
5469
func buildCloneURL(baseURL, repoPath string) (string, error) {
5570
parsed, err := parseCloneBaseURL(baseURL)
5671
if err != nil {
@@ -61,6 +76,11 @@ func buildCloneURL(baseURL, repoPath string) (string, error) {
6176
return cloneURL.String(), nil
6277
}
6378

79+
// parseCloneBaseURL parses and validates a configured clone base URL.
80+
//
81+
// @intent ensure each configured base URL is an absolute URL with scheme and host before it is used to construct clone targets.
82+
// @domainRule baseURL must parse via url.ParseRequestURI and url.Parse without error.
83+
// @domainRule parsed scheme and host must be non-empty and Opaque must be empty.
6484
func parseCloneBaseURL(baseURL string) (*url.URL, error) {
6585
if _, err := url.ParseRequestURI(baseURL); err != nil {
6686
return nil, fmt.Errorf("parse repo clone base URL: %w", err)

internal/webhook/gitops.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @index Repository locking and clone-or-pull operations for webhook sync.
12
package webhook
23

34
import (
@@ -19,30 +20,39 @@ import (
1920
"github.com/go-git/go-git/v5/plumbing/transport"
2021
)
2122

23+
// @intent signal that a per-repository lock could not be acquired within the configured wait window.
2224
var ErrRepoLockTimeout = errors.New("repo lock timeout")
2325

26+
// @intent treat repository lock files older than this threshold as abandoned by a previous process.
2427
const repoLockStaleAfter = 30 * time.Minute
2528

29+
// @intent keep repository-scoped git operations serialized across concurrent webhook deliveries.
2630
type RepoLocker struct {
2731
timeout time.Duration
2832
mu sync.Mutex
2933
locks map[string]chan struct{}
3034
}
3135

36+
// @intent persist enough lock provenance to detect and clean up stale repository lock files safely.
3237
type repoLockMetadata struct {
3338
Repo string `json:"repo"`
3439
PID int `json:"pid"`
3540
Hostname string `json:"hostname"`
3641
CreatedAt time.Time `json:"created_at"`
3742
}
3843

44+
// NewRepoLocker creates a per-repository lock coordinator with a bounded wait time.
45+
// @intent serialize concurrent webhook sync for the same repo so git operations do not corrupt the working tree.
3946
func NewRepoLocker(timeout time.Duration) *RepoLocker {
4047
if timeout <= 0 {
4148
timeout = 30 * time.Second
4249
}
4350
return &RepoLocker{timeout: timeout, locks: make(map[string]chan struct{})}
4451
}
4552

53+
// WithLock runs a sync operation while holding both in-process and filesystem repo locks.
54+
// @intent coordinate webhook workers across goroutines and processes before touching a repository checkout.
55+
// @sideEffect creates and removes filesystem lock files under the repo root.
4656
func (l *RepoLocker) WithLock(ctx context.Context, lockRoot, repoFullName string, fn func(context.Context) error) error {
4757
if ctx == nil {
4858
ctx = context.Background()
@@ -65,6 +75,7 @@ func (l *RepoLocker) WithLock(ctx context.Context, lockRoot, repoFullName string
6575
return fn(ctx)
6676
}
6777

78+
// @intent gate same-process sync attempts for a repository before filesystem locking is attempted.
6879
func (l *RepoLocker) acquireLocal(ctx context.Context, repoFullName string) (chan struct{}, func(chan struct{}), error) {
6980
l.mu.Lock()
7081
lock := l.locks[repoFullName]
@@ -83,6 +94,8 @@ func (l *RepoLocker) acquireLocal(ctx context.Context, repoFullName string) (cha
8394
}
8495
}
8596

97+
// @intent coordinate repository sync across processes by creating an exclusive lock file under the repo root.
98+
// @sideEffect creates and removes repository lock files on disk.
8699
func acquireFilesystemLock(ctx context.Context, lockRoot, repoFullName string) (*os.File, func(*os.File), error) {
87100
lockFile := filepath.Join(lockRoot, ".locks", lockFileName(repoFullName))
88101
if err := os.MkdirAll(filepath.Dir(lockFile), 0755); err != nil {
@@ -123,6 +136,8 @@ func acquireFilesystemLock(ctx context.Context, lockRoot, repoFullName string) (
123136
}
124137
}
125138

139+
// @intent write lock ownership metadata so stale lock cleanup can be diagnosed from the filesystem.
140+
// @sideEffect writes JSON metadata and fsyncs the lock file.
126141
func writeRepoLockMetadata(file *os.File, repoFullName string) error {
127142
hostname, err := os.Hostname()
128143
if err != nil {
@@ -140,6 +155,8 @@ func writeRepoLockMetadata(file *os.File, repoFullName string) error {
140155
return file.Sync()
141156
}
142157

158+
// @intent discard abandoned repository lock files after the stale timeout elapses.
159+
// @sideEffect may remove an on-disk lock file.
143160
func removeStaleFilesystemLock(lockFile string) (bool, time.Duration) {
144161
info, err := os.Stat(lockFile)
145162
if err != nil {
@@ -155,19 +172,27 @@ func removeStaleFilesystemLock(lockFile string) (bool, time.Duration) {
155172
return true, age
156173
}
157174

175+
// @intent convert repository names into stable lock-safe filenames.
158176
func lockFileName(repoFullName string) string {
159177
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-")
160178
return replacer.Replace(repoFullName) + ".lock"
161179
}
162180

181+
// RepoDir maps a namespace to the checkout directory used for repository sync.
182+
// @intent keep workspace naming stable across clone, pull, and downstream build steps.
163183
func RepoDir(repoRoot, namespace string) string {
164184
return filepath.Join(repoRoot, namespace)
165185
}
166186

187+
// CloneOrPull syncs the repository namespace to the default remote branch.
188+
// @intent give webhook handlers a branch-agnostic entry point for standard repo refresh.
167189
func CloneOrPull(ctx context.Context, repoURL, repoRoot, namespace string, auth transport.AuthMethod) error {
168190
return CloneOrPullBranch(ctx, repoURL, repoRoot, namespace, "", auth)
169191
}
170192

193+
// CloneOrPullBranch ensures the namespace checkout exists and matches the requested branch head.
194+
// @intent reuse the same repo sync path for first clone and subsequent updates.
195+
// @sideEffect creates or hard-resets the namespace checkout on disk.
171196
func CloneOrPullBranch(ctx context.Context, repoURL, repoRoot, namespace, branch string, auth transport.AuthMethod) error {
172197
dest := RepoDir(repoRoot, namespace)
173198

@@ -187,6 +212,8 @@ func CloneOrPullBranch(ctx context.Context, repoURL, repoRoot, namespace, branch
187212
return syncRepoBranch(ctx, repo, branch, auth)
188213
}
189214

215+
// CloneOrPullBranchLocked wraps branch sync with repository locking.
216+
// @intent prevent overlapping webhook deliveries from cloning or resetting the same checkout simultaneously.
190217
func CloneOrPullBranchLocked(ctx context.Context, locker *RepoLocker, repoURL, repoRoot, repoFullName, namespace, branch string, auth transport.AuthMethod) error {
191218
if locker == nil {
192219
locker = NewRepoLocker(30 * time.Second)
@@ -196,6 +223,7 @@ func CloneOrPullBranchLocked(ctx context.Context, locker *RepoLocker, repoURL, r
196223
})
197224
}
198225

226+
// @intent log clone URLs without leaking embedded credentials.
199227
func sanitizeURL(raw string) string {
200228
u, err := url.Parse(raw)
201229
if err != nil {
@@ -207,6 +235,8 @@ func sanitizeURL(raw string) string {
207235
return u.String()
208236
}
209237

238+
// @intent perform the first namespace clone via a temp directory so partially cloned repos are never promoted.
239+
// @sideEffect creates temporary directories and renames the completed clone into place.
210240
func cloneRepo(ctx context.Context, repoURL, repoRoot, dest, namespace, branch string, auth transport.AuthMethod) error {
211241
slog.Info("cloning repository", "url", sanitizeURL(repoURL), "dest", dest)
212242

@@ -251,6 +281,8 @@ func cloneRepo(ctx context.Context, repoURL, repoRoot, dest, namespace, branch s
251281
return nil
252282
}
253283

284+
// @intent hard-reset an existing checkout to the latest remote branch head for deterministic rebuilds.
285+
// @sideEffect fetches from origin and rewrites the local worktree state.
254286
func syncRepoBranch(ctx context.Context, repo *git.Repository, branch string, auth transport.AuthMethod) error {
255287
slog.Info("syncing repository to remote branch", "branch", branch)
256288

@@ -294,6 +326,7 @@ func syncRepoBranch(ctx context.Context, repo *git.Repository, branch string, au
294326
return nil
295327
}
296328

329+
// @intent build fetch options that keep sync traffic branch-scoped and shallow when possible.
297330
func fetchOptions(remoteName, branch string, auth transport.AuthMethod) *git.FetchOptions {
298331
opts := &git.FetchOptions{RemoteName: remoteName, Depth: 1}
299332
if branch != "" {

0 commit comments

Comments
 (0)