refactor(tracker): address code-review findings

Six fixes from the second code review pass — none load-bearing but all
improve the contract honesty or prevent future churn. Tests pass with
-race (49 in package, 299 across the backend).

1. Preflight: atomic.Bool fast-path before the mutex so cached-success
   calls don't contend on the lock. Double-checked locking on the
   mutex side so concurrent first-callers still serialize the single
   GET /user request.

2. Preflight godoc: tighten to say it verifies token validity, not
   repo authorization — Get/List against a specific repo may still
   return ErrAuthFailed after a green Preflight if the token lacks
   the scope or the repo isn't visible.

3. domain.ListFilter.Limit godoc: explicitly note that exceeding the
   provider per-page max is SILENTLY capped (no error, no truncation
   signal) and that auto-pagination is deferred to #35.

4. Extract issueFromGH helper. Get and List were duplicating the
   identical ghIssue -> domain.Issue projection; consolidating now
   prevents a 3-way merge when #40 (Comment/Transition) lands.

5. parseGitHubRepo: reject whitespace and # in both owner and repo
   segments. Leading dots stay legal (the "owner/.github" repo
   convention). New test cases cover the rejections plus a positive
   guard for the leading-dot case.

6. fakeGH test helper: lock the handlers map on both read and write,
   so future tests registering handlers from goroutines won't trip
   -race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-05-30 22:32:45 +05:30
parent d6cd245833
commit a0020e06be
3 changed files with 77 additions and 47 deletions

View File

@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
@ -94,8 +95,12 @@ type Tracker struct {
baseURL string
userAgent string
// preflightOK is the fast-path: once a Preflight succeeds, every
// subsequent call short-circuits via atomic.Load without touching the
// mutex. preflightMu serializes the one-time network call so concurrent
// first-callers don't all fire GET /user against GitHub.
preflightOK atomic.Bool
preflightMu sync.Mutex
preflightOK bool
}
// New returns a Tracker. It fails fast when no token can be obtained so
@ -173,6 +178,15 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e
if err := json.Unmarshal(resp, &raw); err != nil {
return domain.Issue{}, fmt.Errorf("github tracker: decode issue: %w", err)
}
return issueFromGH(owner, repo, raw), nil
}
// issueFromGH projects a raw GitHub issue payload into the normalized
// domain.Issue. owner and repo are passed in because the TrackerID.Native
// shape is "owner/repo#N" and we want the returned ID to round-trip
// through the same adapter even if the original caller used a zero
// Provider.
func issueFromGH(owner, repo string, raw ghIssue) domain.Issue {
labels := make([]string, 0, len(raw.Labels))
for _, l := range raw.Labels {
labels = append(labels, l.Name)
@ -182,9 +196,10 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e
assignees = append(assignees, a.Login)
}
out := domain.Issue{
// Canonicalize Provider so the returned Issue always re-routes back
// to this adapter, even if the caller built id with a zero Provider.
ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: id.Native},
ID: domain.TrackerID{
Provider: domain.TrackerProviderGitHub,
Native: fmt.Sprintf("%s/%s#%d", owner, repo, raw.Number),
},
Title: raw.Title,
Body: raw.Body,
State: mapStateFromGitHub(raw.State, raw.StateReason, labels),
@ -198,7 +213,7 @@ func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, e
if len(out.Assignees) == 0 {
out.Assignees = nil
}
return out, nil
return out
}
// mapStateFromGitHub projects GitHub's open/closed + state_reason + labels
@ -286,33 +301,7 @@ func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter doma
if r.PullRequest != nil {
continue
}
labels := make([]string, 0, len(r.Labels))
for _, l := range r.Labels {
labels = append(labels, l.Name)
}
assignees := make([]string, 0, len(r.Assignees))
for _, a := range r.Assignees {
assignees = append(assignees, a.Login)
}
issue := domain.Issue{
ID: domain.TrackerID{
Provider: domain.TrackerProviderGitHub,
Native: fmt.Sprintf("%s/%s#%d", owner, repoName, r.Number),
},
Title: r.Title,
Body: r.Body,
State: mapStateFromGitHub(r.State, r.StateReason, labels),
URL: r.HTMLURL,
Labels: labels,
Assignees: assignees,
}
if len(issue.Labels) == 0 {
issue.Labels = nil
}
if len(issue.Assignees) == 0 {
issue.Assignees = nil
}
out = append(out, issue)
out = append(out, issueFromGH(owner, repoName, r))
}
return out, nil
}
@ -321,20 +310,34 @@ func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter doma
// Preflight
// ---------------------------------------------------------------------------
// Preflight verifies the configured token is accepted by GitHub by making a
// single GET /user request. A successful check is cached for the lifetime
// of the Tracker; failures are never cached so a transient network glitch
// at startup is recoverable on a subsequent call.
// Preflight verifies the configured token is currently accepted by GitHub
// (one GET /user). It does NOT prove the token has the repo scope or
// visibility needed for any specific Get/List call — those may still fail
// with ErrAuthFailed even after a successful Preflight. The guarantee is
// "token exists and is valid against GitHub's identity endpoint", not
// "token can do everything the SM will ask of it." Per-repo authorization
// is detected lazily at the first Get/List against that repo.
//
// Successful checks are cached for the lifetime of the Tracker via a
// double-checked atomic+mutex pattern: the hot path is one atomic.Load
// with no contention; concurrent first-callers serialize on the mutex so
// only one GET /user is in flight. Failures are intentionally NOT cached
// so a transient startup glitch is recoverable on a subsequent call.
func (t *Tracker) Preflight(ctx context.Context) error {
if t.preflightOK.Load() {
return nil
}
t.preflightMu.Lock()
defer t.preflightMu.Unlock()
if t.preflightOK {
// Re-check after acquiring the lock — another goroutine may have raced
// us through the network call and stored success while we were waiting.
if t.preflightOK.Load() {
return nil
}
if _, err := t.do(ctx, http.MethodGet, "/user", nil); err != nil {
return err
}
t.preflightOK = true
t.preflightOK.Store(true)
return nil
}
@ -485,8 +488,9 @@ func parseGitHubID(native string) (owner, repo string, number int, err error) {
return owner, repo, n, nil
}
// parseGitHubRepo accepts "owner/repo" and rejects anything containing
// additional slashes or a "#" segment. Used by List.
// parseGitHubRepo accepts "owner/repo" and rejects empty segments,
// embedded slashes, "#", and whitespace. Leading dots are kept legal —
// "owner/.github" is a real GitHub convention for repo-level config repos.
func parseGitHubRepo(native string) (owner, repo string, err error) {
if native == "" {
return "", "", fmt.Errorf("%w: empty repo", ErrBadID)
@ -500,7 +504,10 @@ func parseGitHubRepo(native string) (owner, repo string, err error) {
if owner == "" || repo == "" {
return "", "", fmt.Errorf("%w: empty owner or repo segment", ErrBadID)
}
if strings.ContainsAny(repo, "/#") {
if strings.ContainsAny(owner, "/# \t\n\r") {
return "", "", fmt.Errorf("%w: invalid owner segment %q", ErrBadID, owner)
}
if strings.ContainsAny(repo, "/# \t\n\r") {
return "", "", fmt.Errorf("%w: invalid repo segment %q", ErrBadID, repo)
}
return owner, repo, nil

View File

@ -45,16 +45,18 @@ func newFakeGH(t *testing.T) *fakeGH {
}
func (f *fakeGH) on(method, path string, h http.HandlerFunc) {
f.mu.Lock()
defer f.mu.Unlock()
f.handlers[method+" "+path] = h
}
func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
key := r.Method + " " + r.URL.Path
f.mu.Lock()
f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Body: string(body)})
f.mu.Unlock()
key := r.Method + " " + r.URL.Path
h, ok := f.handlers[key]
f.mu.Unlock()
if !ok {
f.t.Errorf("unexpected request: %s", key)
http.Error(w, "no handler", http.StatusNotImplemented)
@ -526,7 +528,23 @@ func TestList_RejectsWrongProvider(t *testing.T) {
}
func TestList_RejectsBadRepo(t *testing.T) {
cases := []string{"", "noseparator", "/repo", "owner/", "a/b/c"}
cases := []string{
"", // empty
"noseparator", // missing /
"/repo", // empty owner
"owner/", // empty repo
"a/b/c", // extra slash
" owner/repo", // leading whitespace in owner
"owner/repo ", // trailing whitespace in repo
"own er/repo", // embedded space in owner
"owner/re#po", // embedded # in repo
"\towner/repo", // tab in owner
"owner/repo\n", // newline in repo
}
// Sanity: a benign leading-dot repo (".github" convention) must pass.
if _, _, err := parseGitHubRepo("octocat/.github"); err != nil {
t.Fatalf("leading-dot repo rejected unexpectedly: %v", err)
}
for _, native := range cases {
t.Run(native, func(t *testing.T) {
f := newFakeGH(t)

View File

@ -72,9 +72,14 @@ const (
)
// ListFilter is the query the Session Manager passes to Tracker.List.
// Empty / zero values mean "no filter on this dimension". Limit is the
// requested page size; the adapter applies its own default when zero and
// caps at the provider's per-page maximum.
// Empty / zero values mean "no filter on this dimension".
//
// Limit is the requested page size. The adapter applies its own default
// when zero and SILENTLY CAPS at the provider's per-page maximum — a
// caller asking for more than the cap gets exactly cap items back with no
// error and no indication of truncation. v1 has no auto-pagination;
// callers needing more results need to wait for the observer/polling work
// in issue #35.
type ListFilter struct {
State ListStateFilter `json:"state,omitempty"`
Labels []string `json:"labels,omitempty"`