feat(tracker): Tracker port + GitHub adapter

Reference implementation; GitLab and Linear follow in separate PRs.
Issue observer loop (poll + ApplyTrackerFacts) is deferred to #35.

Three-layer split mirrors the SCM layout adil is landing in PR #28:
- domain/tracker.go   — value types (TrackerProvider, TrackerID,
  NormalizedIssueState, Issue)
- ports/tracker.go    — the Tracker interface
- adapters/tracker/github/ — REST-backed adapter

v1 is write-mostly: Get, Comment, Transition. No cache, no inflight
dedup, no polling. State mapping is documented in the package doc and
exercised by table-driven tests against an httptest fake — no real
GitHub traffic from CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
harshitsinghbhandari 2026-05-30 13:11:15 +05:30
parent 527d9c8303
commit e5919c7998
6 changed files with 1174 additions and 0 deletions

View File

@ -0,0 +1,52 @@
package github
import (
"context"
"errors"
"os"
"strings"
)
// TokenSource yields a GitHub bearer token on demand. It is intentionally
// tiny so tests can inject a static token and production can layer env-var or
// gh-CLI fallbacks behind the same surface. The Tracker calls Token once at
// construction (fail-fast) and again per request (so rotated tokens are
// picked up without restart).
type TokenSource interface {
Token(ctx context.Context) (string, error)
}
// ErrNoToken is returned when no token source could yield a non-empty token.
var ErrNoToken = errors.New("github tracker: no token configured")
// StaticTokenSource is a literal token, typically used in tests.
type StaticTokenSource string
func (s StaticTokenSource) Token(context.Context) (string, error) {
t := strings.TrimSpace(string(s))
if t == "" {
return "", ErrNoToken
}
return t, nil
}
// EnvTokenSource reads the first non-empty value from the listed env vars,
// falling back to GITHUB_TOKEN. The order matters: a project-configured
// token (e.g. AO_GITHUB_TOKEN) should be preferred over the global default,
// matching the pattern PR #28 uses on the SCM side so both adapters honor
// the same precedence.
type EnvTokenSource struct {
EnvVars []string
}
func (s EnvTokenSource) Token(context.Context) (string, error) {
for _, name := range s.EnvVars {
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
return v, nil
}
}
if v := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); v != "" {
return v, nil
}
return "", ErrNoToken
}

View File

@ -0,0 +1,50 @@
// Package github implements the ports.Tracker outbound port for GitHub
// Issues. v1 is write-mostly: Get returns a normalized Issue snapshot,
// Comment posts an issue comment, and Transition projects the cross-provider
// state vocabulary onto GitHub's open/closed + state_reason + labels surface.
// There is no observer loop or cache — those arrive with issue #35.
//
// # Normalized state mapping
//
// GitHub Issues only have two native states (open, closed) plus a
// state_reason on closed issues (completed, not_planned, reopened). The
// orchestrator's lifecycle vocabulary is richer, so the adapter uses two
// well-known labels — "in-progress" and "in-review" — to project the extra
// states onto open issues.
//
// Normalized state | GitHub API calls performed by Transition
// -----------------+-------------------------------------------------------
// open | PATCH state=open; DELETE labels {in-progress,in-review}
// in_progress | PATCH state=open; POST label in-progress;
// | DELETE label in-review
// review | PATCH state=open; POST label in-review;
// | DELETE label in-progress
// done | PATCH state=closed,state_reason=completed;
// | DELETE labels {in-progress,in-review}
// cancelled | PATCH state=closed,state_reason=not_planned;
// | DELETE labels {in-progress,in-review}
//
// Reverse mapping (Get): GitHub state=closed maps to done if state_reason is
// completed or empty, and to cancelled if state_reason is not_planned. For
// open issues, an "in-review" label wins over "in-progress" (the workflow is
// progress -> review -> done), and the absence of both maps to open.
//
// # Label hygiene and partial failures
//
// DELETE on a label that the issue does not carry returns 404; Transition
// treats that as success so the operation is idempotent.
//
// Transition issues 2-3 HTTP requests sequentially (PATCH, optional POST
// label, DELETE label) and is NOT atomic. If the PATCH succeeds but a
// subsequent label call fails, the issue is left in an intermediate state
// (e.g. closed without the status label cleared). Re-invoking Transition
// with the same target state is safe and converges — callers should treat
// the operation as eventually-consistent and retry on transport errors.
//
// # Out of scope
//
// - No webhook receiver, no polling goroutine, no fact projection into LCM
// (see issue #35 for the observer-loop work).
// - No richer per-provider metadata on Issue (milestones, project boards,
// reactions); the port only carries fields all three v1 providers can fill.
package github

View File

@ -0,0 +1,458 @@
package github
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
defaultBaseURL = "https://api.github.com"
defaultUserAgent = "ao-agent-orchestrator/tracker-github"
labelInProgress = "in-progress"
labelInReview = "in-review"
stateOpenGH = "open"
stateClosedGH = "closed"
reasonComplete = "completed"
reasonNotPlan = "not_planned"
)
// Sentinel errors. Adapter-level callers should match on these via
// errors.Is; the orchestrator's lifecycle code is intentionally insulated
// from raw HTTP status codes.
var (
ErrNotFound = errors.New("github tracker: issue not found")
ErrRateLimited = errors.New("github tracker: rate limited")
ErrEmptyBody = errors.New("github tracker: comment body is empty")
ErrWrongProvider = errors.New("github tracker: id is not a github tracker id")
ErrUnknownState = errors.New("github tracker: unknown normalized state")
ErrBadID = errors.New("github tracker: malformed native id")
)
// RateLimitError is returned when GitHub reports the request was rate-limited.
// Callers that want to back off intelligently can extract ResetAt /
// RetryAfter via errors.As; callers that only need the category can use
// errors.Is(err, ErrRateLimited).
type RateLimitError struct {
ResetAt time.Time
RetryAfter time.Duration
Message string
}
func (e *RateLimitError) Error() string {
if e == nil {
return ErrRateLimited.Error()
}
if e.Message != "" {
return "github tracker: rate limited: " + e.Message
}
return ErrRateLimited.Error()
}
func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited }
// Options configures a Tracker. All fields except Token are optional —
// production code typically sets Token alone; tests inject HTTPClient and
// BaseURL to point at an httptest fake.
type Options struct {
Token TokenSource
HTTPClient *http.Client
BaseURL string
UserAgent string
}
// Tracker implements ports.Tracker against the GitHub REST API.
//
// Construction performs a fail-fast token presence check (no network call —
// validating the token's authorization scope against GitHub requires a real
// request, and that is the first operation any caller will make anyway).
type Tracker struct {
http *http.Client
tokens TokenSource
baseURL string
userAgent string
}
// New returns a Tracker. It fails fast when no token can be obtained so
// daemons crash at startup rather than at first issue lookup.
func New(opts Options) (*Tracker, error) {
src := opts.Token
if src == nil {
return nil, ErrNoToken
}
if _, err := src.Token(context.Background()); err != nil {
return nil, err
}
t := &Tracker{
http: opts.HTTPClient,
tokens: src,
baseURL: opts.BaseURL,
userAgent: opts.UserAgent,
}
if t.http == nil {
t.http = &http.Client{Timeout: 30 * time.Second}
}
if t.baseURL == "" {
t.baseURL = defaultBaseURL
}
if t.userAgent == "" {
t.userAgent = defaultUserAgent
}
return t, nil
}
// Statically assert Tracker satisfies the port. If this stops compiling, the
// port shape changed and the adapter needs to follow.
var _ ports.Tracker = (*Tracker)(nil)
// ---------------------------------------------------------------------------
// Get
// ---------------------------------------------------------------------------
// ghIssue is the subset of fields we read off the REST issue payload.
type ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
StateReason string `json:"state_reason"`
HTMLURL string `json:"html_url"`
Labels []ghLabel `json:"labels"`
Assignees []ghUser `json:"assignees"`
}
type ghLabel struct {
Name string `json:"name"`
}
type ghUser struct {
Login string `json:"login"`
}
func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) {
owner, repo, number, err := t.parseID(id)
if err != nil {
return domain.Issue{}, err
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number)
resp, err := t.do(ctx, http.MethodGet, path, nil)
if err != nil {
return domain.Issue{}, err
}
var raw ghIssue
if err := json.Unmarshal(resp, &raw); err != nil {
return domain.Issue{}, fmt.Errorf("github tracker: decode issue: %w", err)
}
labels := make([]string, 0, len(raw.Labels))
for _, l := range raw.Labels {
labels = append(labels, l.Name)
}
assignees := make([]string, 0, len(raw.Assignees))
for _, a := range raw.Assignees {
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},
Title: raw.Title,
Body: raw.Body,
State: mapStateFromGitHub(raw.State, raw.StateReason, labels),
URL: raw.HTMLURL,
Labels: labels,
Assignees: assignees,
}
if len(out.Labels) == 0 {
out.Labels = nil
}
if len(out.Assignees) == 0 {
out.Assignees = nil
}
return out, nil
}
// mapStateFromGitHub projects GitHub's open/closed + state_reason + labels
// surface onto the normalized state. "in-review" wins over "in-progress"
// when both labels are present (the workflow is progress -> review -> done).
func mapStateFromGitHub(state, reason string, labels []string) domain.NormalizedIssueState {
switch strings.ToLower(state) {
case stateClosedGH:
if strings.EqualFold(reason, reasonNotPlan) {
return domain.IssueCancelled
}
return domain.IssueDone
}
var hasProgress, hasReview bool
for _, l := range labels {
switch l {
case labelInProgress:
hasProgress = true
case labelInReview:
hasReview = true
}
}
switch {
case hasReview:
return domain.IssueInReview
case hasProgress:
return domain.IssueInProgress
default:
return domain.IssueOpen
}
}
// ---------------------------------------------------------------------------
// Comment
// ---------------------------------------------------------------------------
func (t *Tracker) Comment(ctx context.Context, id domain.TrackerID, body string) error {
if strings.TrimSpace(body) == "" {
return ErrEmptyBody
}
owner, repo, number, err := t.parseID(id)
if err != nil {
return err
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, number)
_, err = t.do(ctx, http.MethodPost, path, map[string]string{"body": body})
return err
}
// ---------------------------------------------------------------------------
// Transition
// ---------------------------------------------------------------------------
// transitionPlan is the per-target-state list of mutations to apply. Every
// transition issues exactly one PATCH on the issue, optionally adds one
// status label, and removes any other status labels the issue may carry.
type transitionPlan struct {
patch map[string]any
addLabel string // "" means none
removeLabel []string
}
func planForState(state domain.NormalizedIssueState) (transitionPlan, error) {
switch state {
case domain.IssueOpen:
return transitionPlan{
patch: map[string]any{"state": stateOpenGH},
removeLabel: []string{labelInProgress, labelInReview},
}, nil
case domain.IssueInProgress:
return transitionPlan{
patch: map[string]any{"state": stateOpenGH},
addLabel: labelInProgress,
removeLabel: []string{labelInReview},
}, nil
case domain.IssueInReview:
return transitionPlan{
patch: map[string]any{"state": stateOpenGH},
addLabel: labelInReview,
removeLabel: []string{labelInProgress},
}, nil
case domain.IssueDone:
return transitionPlan{
patch: map[string]any{"state": stateClosedGH, "state_reason": reasonComplete},
removeLabel: []string{labelInProgress, labelInReview},
}, nil
case domain.IssueCancelled:
return transitionPlan{
patch: map[string]any{"state": stateClosedGH, "state_reason": reasonNotPlan},
removeLabel: []string{labelInProgress, labelInReview},
}, nil
default:
return transitionPlan{}, fmt.Errorf("%w: %q", ErrUnknownState, state)
}
}
func (t *Tracker) Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error {
plan, err := planForState(state)
if err != nil {
return err
}
owner, repo, number, err := t.parseID(id)
if err != nil {
return err
}
issuePath := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number)
// 1. Patch state (+ state_reason for closed transitions).
if _, err := t.do(ctx, http.MethodPatch, issuePath, plan.patch); err != nil {
return err
}
// 2. Add the target status label (no-op when target is open/done/cancelled).
if plan.addLabel != "" {
body := map[string][]string{"labels": {plan.addLabel}}
if _, err := t.do(ctx, http.MethodPost, issuePath+"/labels", body); err != nil {
return err
}
}
// 3. Remove the other status labels. 404 from GitHub means "label is not
// on this issue", which is the success state we want — swallow it so
// the operation is idempotent.
for _, label := range plan.removeLabel {
labelPath := issuePath + "/labels/" + url.PathEscape(label)
if _, err := t.do(ctx, http.MethodDelete, labelPath, nil); err != nil {
if errors.Is(err, ErrNotFound) {
continue
}
return err
}
}
return nil
}
// ---------------------------------------------------------------------------
// HTTP plumbing
// ---------------------------------------------------------------------------
func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("github tracker: encode body: %w", err)
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, rdr)
if err != nil {
return nil, fmt.Errorf("github tracker: build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", t.userAgent)
tok, err := t.tokens.Token(ctx)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := t.http.Do(req)
if err != nil {
return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return respBody, classifyError(resp, respBody)
}
func classifyError(resp *http.Response, body []byte) error {
msg := githubMessage(body)
switch resp.StatusCode {
case http.StatusNotFound:
return fmt.Errorf("%w: %s", ErrNotFound, msg)
case http.StatusTooManyRequests:
return rateLimited(resp, msg)
case http.StatusForbidden, http.StatusUnauthorized:
// GitHub returns 403 for primary rate-limit exhaustion, for
// secondary/abuse limits, and for genuine auth failures. Three
// signals disambiguate the rate-limit cases from auth: the primary
// limit sets X-RateLimit-Remaining=0; the secondary/abuse limit
// sets Retry-After (and often omits X-RateLimit-Remaining); and
// either case mentions "rate limit" / "abuse" in the body.
if isRateLimited(resp, msg) {
return rateLimited(resp, msg)
}
}
return fmt.Errorf("github tracker: %d %s", resp.StatusCode, msg)
}
func isRateLimited(resp *http.Response, msg string) bool {
if rem := resp.Header.Get("X-RateLimit-Remaining"); rem != "" {
if n, err := strconv.Atoi(rem); err == nil && n == 0 {
return true
}
}
if resp.Header.Get("Retry-After") != "" {
return true
}
low := strings.ToLower(msg)
return strings.Contains(low, "rate limit") || strings.Contains(low, "abuse detection")
}
func rateLimited(resp *http.Response, msg string) error {
e := &RateLimitError{Message: msg}
if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" {
if sec, err := strconv.ParseInt(reset, 10, 64); err == nil && sec > 0 {
e.ResetAt = time.Unix(sec, 0)
}
}
if ra := resp.Header.Get("Retry-After"); ra != "" {
if sec, err := strconv.Atoi(ra); err == nil && sec >= 0 {
e.RetryAfter = time.Duration(sec) * time.Second
}
}
return e
}
func githubMessage(body []byte) string {
var p struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &p) == nil && p.Message != "" {
return p.Message
}
return strings.TrimSpace(string(body))
}
// ---------------------------------------------------------------------------
// ID parsing
// ---------------------------------------------------------------------------
func (t *Tracker) parseID(id domain.TrackerID) (owner, repo string, number int, err error) {
// Strict: the Session Manager picks an adapter by Provider, so reaching
// this adapter with a non-github Provider is a routing bug, not user
// input. Empty Provider is treated the same way — it would round-trip
// to an Issue whose ID can't be re-routed.
if id.Provider != domain.TrackerProviderGitHub {
return "", "", 0, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider)
}
return parseGitHubID(id.Native)
}
// parseGitHubID accepts "owner/repo#NUM" and returns the three components.
// Forms like "owner/repo/issues/NUM" or bare numbers are intentionally
// rejected so the rest of the system has one canonical id shape.
func parseGitHubID(native string) (owner, repo string, number int, err error) {
hash := strings.IndexByte(native, '#')
if hash < 0 {
return "", "", 0, fmt.Errorf("%w: missing #issue", ErrBadID)
}
repoPart := native[:hash]
numPart := native[hash+1:]
slash := strings.IndexByte(repoPart, '/')
if slash < 0 {
return "", "", 0, fmt.Errorf("%w: missing owner/repo separator", ErrBadID)
}
owner = repoPart[:slash]
repo = repoPart[slash+1:]
if owner == "" || repo == "" {
return "", "", 0, fmt.Errorf("%w: empty owner or repo", ErrBadID)
}
n, parseErr := strconv.Atoi(numPart)
if parseErr != nil || n <= 0 {
return "", "", 0, fmt.Errorf("%w: bad issue number %q", ErrBadID, numPart)
}
return owner, repo, n, nil
}

View File

@ -0,0 +1,540 @@
package github
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// recordedReq captures one inbound HTTP request so tests can assert against
// the exact GitHub API surface the adapter touched.
type recordedReq struct {
Method string
Path string
Body string
}
// fakeGH is a programmable httptest.Server that matches requests by
// "METHOD path" and records every call. Unmatched requests fail the test —
// that is the point of TDD here, so an accidental extra call is loud.
type fakeGH struct {
t *testing.T
server *httptest.Server
mu sync.Mutex
requests []recordedReq
handlers map[string]http.HandlerFunc
}
func newFakeGH(t *testing.T) *fakeGH {
t.Helper()
f := &fakeGH{t: t, handlers: map[string]http.HandlerFunc{}}
f.server = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.server.Close)
return f
}
func (f *fakeGH) on(method, path string, h http.HandlerFunc) {
f.handlers[method+" "+path] = h
}
func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
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]
if !ok {
f.t.Errorf("unexpected request: %s", key)
http.Error(w, "no handler", http.StatusNotImplemented)
return
}
r.Body = io.NopCloser(strings.NewReader(string(body)))
h(w, r)
}
func (f *fakeGH) calls() []recordedReq {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]recordedReq, len(f.requests))
copy(out, f.requests)
return out
}
// newTrackerForTest constructs an adapter pointed at the fake server with a
// static dev token. Production code uses EnvTokenSource; tests skip that to
// keep the surface tiny.
func newTrackerForTest(t *testing.T, f *fakeGH) *Tracker {
t.Helper()
tr, err := New(Options{
BaseURL: f.server.URL,
Token: StaticTokenSource("tkn-test"),
HTTPClient: f.server.Client(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
return tr
}
func ctx() context.Context { return context.Background() }
func TestNewRejectsMissingToken(t *testing.T) {
if _, err := New(Options{Token: StaticTokenSource("")}); !errors.Is(err, ErrNoToken) {
t.Fatalf("New with empty token = %v, want ErrNoToken", err)
}
if _, err := New(Options{}); !errors.Is(err, ErrNoToken) {
t.Fatalf("New with no source = %v, want ErrNoToken", err)
}
}
func TestParseID(t *testing.T) {
cases := []struct {
name string
native string
wantOwner string
wantRepo string
wantNum int
wantErr bool
}{
{"happy", "octocat/hello-world#42", "octocat", "hello-world", 42, false},
{"missing hash", "octocat/hello-world", "", "", 0, true},
{"missing slash", "octocat#42", "", "", 0, true},
{"empty owner", "/repo#1", "", "", 0, true},
{"empty repo", "owner/#1", "", "", 0, true},
{"non-numeric", "o/r#abc", "", "", 0, true},
{"zero", "o/r#0", "", "", 0, true},
{"negative", "o/r#-1", "", "", 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
owner, repo, num, err := parseGitHubID(tc.native)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %s/%s#%d", owner, repo, num)
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if owner != tc.wantOwner || repo != tc.wantRepo || num != tc.wantNum {
t.Fatalf("got %s/%s#%d, want %s/%s#%d", owner, repo, num, tc.wantOwner, tc.wantRepo, tc.wantNum)
}
})
}
}
func TestGet_HappyPath(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/repos/octocat/hello-world/issues/42", func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer tkn-test" {
t.Errorf("Authorization = %q, want Bearer tkn-test", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "Found a bug",
"body": "It does not work",
"state": "open",
"html_url": "https://github.com/octocat/hello-world/issues/42",
"labels": [{"name":"bug"},{"name":"in-progress"}],
"assignees": [{"login":"alice"},{"login":"bob"}]
}`))
})
tr := newTrackerForTest(t, f)
issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "octocat/hello-world#42"})
if err != nil {
t.Fatalf("Get: %v", err)
}
want := domain.Issue{
ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "octocat/hello-world#42"},
Title: "Found a bug",
Body: "It does not work",
State: domain.IssueInProgress, // the "in-progress" label wins over plain "open"
URL: "https://github.com/octocat/hello-world/issues/42",
Labels: []string{"bug", "in-progress"},
Assignees: []string{"alice", "bob"},
}
if !reflect.DeepEqual(issue, want) {
t.Fatalf("issue = %#v\nwant %#v", issue, want)
}
}
func TestGet_StateMappingFromGitHubFields(t *testing.T) {
cases := []struct {
name string
ghState string
ghReason string
labels []string
wantState domain.NormalizedIssueState
}{
{"plain open", "open", "", nil, domain.IssueOpen},
{"open with in-progress label", "open", "", []string{"in-progress"}, domain.IssueInProgress},
{"open with in-review label", "open", "", []string{"in-review"}, domain.IssueInReview},
{"review wins over progress when both present", "open", "", []string{"in-progress", "in-review"}, domain.IssueInReview},
{"closed completed", "closed", "completed", nil, domain.IssueDone},
{"closed not_planned", "closed", "not_planned", nil, domain.IssueCancelled},
{"closed unknown reason maps to done", "closed", "", nil, domain.IssueDone},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFakeGH(t)
payload := map[string]any{
"number": 1,
"title": "t",
"body": "",
"state": tc.ghState,
"html_url": "https://github.com/o/r/issues/1",
}
if tc.ghReason != "" {
payload["state_reason"] = tc.ghReason
}
if tc.labels != nil {
ls := make([]map[string]string, len(tc.labels))
for i, n := range tc.labels {
ls[i] = map[string]string{"name": n}
}
payload["labels"] = ls
}
b, _ := json.Marshal(payload)
f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(b)
})
tr := newTrackerForTest(t, f)
issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"})
if err != nil {
t.Fatalf("Get: %v", err)
}
if issue.State != tc.wantState {
t.Fatalf("state = %q, want %q", issue.State, tc.wantState)
}
})
}
}
func TestGet_NotFound(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
})
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestGet_RateLimited(t *testing.T) {
f := newFakeGH(t)
reset := time.Now().Add(2 * time.Minute).Unix()
f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
})
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"})
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("err = %v, want ErrRateLimited", err)
}
var rle *RateLimitError
if !errors.As(err, &rle) {
t.Fatalf("err = %v, want *RateLimitError", err)
}
if got := rle.ResetAt.Unix(); got != reset {
t.Fatalf("ResetAt = %d, want %d", got, reset)
}
}
// TestGet_SecondaryRateLimit covers the GitHub "abuse detection"
// response — it lacks X-RateLimit-Remaining but sets Retry-After, and the
// body mentions the limit. The classifier must still surface this as
// ErrRateLimited rather than mis-categorizing it as auth failure.
func TestGet_SecondaryRateLimit(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"message":"You have exceeded a secondary rate limit"}`, http.StatusForbidden)
})
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"})
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("err = %v, want ErrRateLimited", err)
}
var rle *RateLimitError
if !errors.As(err, &rle) {
t.Fatalf("err = %v, want *RateLimitError", err)
}
if rle.RetryAfter != 60*time.Second {
t.Fatalf("RetryAfter = %v, want 60s", rle.RetryAfter)
}
}
func TestGet_RejectsWrongProvider(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitLab, Native: "g/p#1"})
if !errors.Is(err, ErrWrongProvider) {
t.Fatalf("err = %v, want ErrWrongProvider", err)
}
}
func TestGet_RejectsEmptyProvider(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
_, err := tr.Get(ctx(), domain.TrackerID{Native: "o/r#1"})
if !errors.Is(err, ErrWrongProvider) {
t.Fatalf("err = %v, want ErrWrongProvider", err)
}
}
// TestGet_CanonicalizesProviderOnOutput pins the contract that returned
// Issues always carry domain.TrackerProviderGitHub, so callers can re-route
// without inspecting which adapter they originally talked to.
func TestGet_CanonicalizesProviderOnOutput(t *testing.T) {
f := newFakeGH(t)
f.on("GET", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"number":1,"title":"t","body":"","state":"open","html_url":"https://github.com/o/r/issues/1"}`))
})
tr := newTrackerForTest(t, f)
issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"})
if err != nil {
t.Fatalf("Get: %v", err)
}
if issue.ID.Provider != domain.TrackerProviderGitHub {
t.Fatalf("issue.ID.Provider = %q, want %q", issue.ID.Provider, domain.TrackerProviderGitHub)
}
if issue.ID.Native != "o/r#1" {
t.Fatalf("issue.ID.Native = %q, want o/r#1", issue.ID.Native)
}
}
func TestComment_HappyPath(t *testing.T) {
f := newFakeGH(t)
f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) {
var got struct {
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Body != "hello world" {
t.Errorf("body = %q, want %q", got.Body, "hello world")
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1}`))
})
tr := newTrackerForTest(t, f)
if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, "hello world"); err != nil {
t.Fatalf("Comment: %v", err)
}
}
// TestComment_PreservesMarkdownBody locks in that we POST the body verbatim
// — no trimming, no escape-and-unescape round trip — so multi-line markdown
// notifications from the SM survive.
func TestComment_PreservesMarkdownBody(t *testing.T) {
f := newFakeGH(t)
body := "## status\n\n- step 1: done\n- step 2: **in progress**\n\n```go\nfmt.Println(\"hi\")\n```\n"
f.on("POST", "/repos/o/r/issues/1/comments", func(w http.ResponseWriter, r *http.Request) {
var got struct {
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Body != body {
t.Errorf("body = %q, want %q", got.Body, body)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1}`))
})
tr := newTrackerForTest(t, f)
if err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body); err != nil {
t.Fatalf("Comment: %v", err)
}
}
func TestComment_RejectsEmptyBody(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
for _, body := range []string{"", " ", "\n\t"} {
err := tr.Comment(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, body)
if !errors.Is(err, ErrEmptyBody) {
t.Fatalf("body %q: err = %v, want ErrEmptyBody", body, err)
}
}
if calls := f.calls(); len(calls) != 0 {
t.Fatalf("unexpected calls on empty body: %#v", calls)
}
}
// transitionCall is the normalized record of one GH API call made by
// Transition. The tests compare a sorted slice of these against the expected
// call set so we don't depend on call ordering.
type transitionCall struct {
Method string
Path string
// for PATCH /issues/N — JSON keys we care about
State string
StateReason string
// for POST .../labels — labels added
AddLabels []string
}
func TestTransition_MapsToCorrectGitHubCalls(t *testing.T) {
cases := []struct {
name string
state domain.NormalizedIssueState
want []transitionCall
}{
{
name: "open clears status labels and reopens",
state: domain.IssueOpen,
want: []transitionCall{
{Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"},
},
},
{
name: "in_progress adds in-progress label, removes in-review",
state: domain.IssueInProgress,
want: []transitionCall{
{Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"},
{Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-progress"}},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"},
},
},
{
name: "review adds in-review label, removes in-progress",
state: domain.IssueInReview,
want: []transitionCall{
{Method: "PATCH", Path: "/repos/o/r/issues/1", State: "open"},
{Method: "POST", Path: "/repos/o/r/issues/1/labels", AddLabels: []string{"in-review"}},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"},
},
},
{
name: "done closes as completed and cleans status labels",
state: domain.IssueDone,
want: []transitionCall{
{Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "completed"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"},
},
},
{
name: "cancelled closes as not_planned and cleans status labels",
state: domain.IssueCancelled,
want: []transitionCall{
{Method: "PATCH", Path: "/repos/o/r/issues/1", State: "closed", StateReason: "not_planned"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-progress"},
{Method: "DELETE", Path: "/repos/o/r/issues/1/labels/in-review"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFakeGH(t)
// PATCH endpoint returns an updated issue body
f.on("PATCH", "/repos/o/r/issues/1", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"number":1,"state":"open"}`))
})
// label-add endpoint
f.on("POST", "/repos/o/r/issues/1/labels", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[]`))
})
// label-remove endpoints — return 404 sometimes to confirm we ignore it
f.on("DELETE", "/repos/o/r/issues/1/labels/in-progress", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"Label does not exist"}`, http.StatusNotFound)
})
f.on("DELETE", "/repos/o/r/issues/1/labels/in-review", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[]`))
})
tr := newTrackerForTest(t, f)
if err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, tc.state); err != nil {
t.Fatalf("Transition: %v", err)
}
got := normalizeCalls(t, f.calls())
want := append([]transitionCall(nil), tc.want...)
sortCalls(got)
sortCalls(want)
if !reflect.DeepEqual(got, want) {
t.Fatalf("calls:\n got %#v\n want %#v", got, want)
}
})
}
}
func TestTransition_RejectsUnknownState(t *testing.T) {
f := newFakeGH(t)
tr := newTrackerForTest(t, f)
err := tr.Transition(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}, domain.NormalizedIssueState("frobnicated"))
if !errors.Is(err, ErrUnknownState) {
t.Fatalf("err = %v, want ErrUnknownState", err)
}
if calls := f.calls(); len(calls) != 0 {
t.Fatalf("unexpected calls: %#v", calls)
}
}
// normalizeCalls converts the recordedReq slice into transitionCall records
// the test cases assert against, decoding the PATCH/label-add bodies.
func normalizeCalls(t *testing.T, reqs []recordedReq) []transitionCall {
t.Helper()
out := make([]transitionCall, 0, len(reqs))
for _, r := range reqs {
tc := transitionCall{Method: r.Method, Path: r.Path}
switch {
case r.Method == "PATCH":
var body struct {
State string `json:"state"`
StateReason string `json:"state_reason"`
}
if r.Body != "" {
if err := json.Unmarshal([]byte(r.Body), &body); err != nil {
t.Fatalf("patch body: %v", err)
}
}
tc.State = body.State
tc.StateReason = body.StateReason
case r.Method == "POST" && strings.HasSuffix(r.Path, "/labels"):
var body struct {
Labels []string `json:"labels"`
}
if r.Body != "" {
if err := json.Unmarshal([]byte(r.Body), &body); err != nil {
t.Fatalf("labels body: %v", err)
}
}
tc.AddLabels = body.Labels
}
out = append(out, tc)
}
return out
}
func sortCalls(s []transitionCall) {
sort.Slice(s, func(i, j int) bool {
if s[i].Method != s[j].Method {
return s[i].Method < s[j].Method
}
return s[i].Path < s[j].Path
})
}

View File

@ -0,0 +1,49 @@
package domain
// TrackerProvider identifies an issue-tracker provider implementation.
// Provider differences (label-driven vs state-machine vs close-reason) are
// absorbed inside each adapter; the rest of the system only sees
// NormalizedIssueState.
type TrackerProvider string
const (
TrackerProviderGitHub TrackerProvider = "github"
TrackerProviderGitLab TrackerProvider = "gitlab"
TrackerProviderLinear TrackerProvider = "linear"
)
// TrackerID identifies a single issue across providers. Native is the
// provider's own canonical form ("owner/repo#123" for GitHub,
// "group/project#456" for GitLab, "ABC-789" for Linear) and is parsed by the
// adapter. Provider is the discriminator the Session Manager uses to pick an
// adapter.
type TrackerID struct {
Provider TrackerProvider `json:"provider"`
Native string `json:"native"`
}
// NormalizedIssueState is the cross-provider issue-state vocabulary every
// adapter must implement. The closed list is intentional — adding a value
// here is a port-level decision because every adapter must map it.
type NormalizedIssueState string
const (
IssueOpen NormalizedIssueState = "open"
IssueInProgress NormalizedIssueState = "in_progress"
IssueInReview NormalizedIssueState = "review"
IssueDone NormalizedIssueState = "done"
IssueCancelled NormalizedIssueState = "cancelled"
)
// Issue is the minimum projection every tracker can produce. Fields are
// added only when all v1 providers (GitHub, GitLab, Linear) can populate
// them faithfully; richer metadata stays inside provider-specific code paths.
type Issue struct {
ID TrackerID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
State NormalizedIssueState `json:"state"`
URL string `json:"url"`
Labels []string `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
}

View File

@ -0,0 +1,25 @@
package ports
import (
"context"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
// Tracker is the outbound port for issue trackers (GitHub Issues, GitLab
// Issues, Linear). v1 is write-mostly: spawn-bootstrap reads with Get, the
// Session Manager posts status updates with Comment, and lifecycle
// transitions (start, hand-off-to-review, close) propagate with Transition.
// There is no observer loop yet; polling and ApplyTrackerFacts arrive with
// issue #35.
//
// All three v1 providers share this interface. Provider differences (label
// vs state machine vs close reason) are absorbed inside each adapter via
// domain.NormalizedIssueState. Fields on domain.Issue exist only when every
// provider can populate them; richer per-provider metadata belongs behind a
// separate port.
type Tracker interface {
Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error)
Comment(ctx context.Context, id domain.TrackerID, body string) error
Transition(ctx context.Context, id domain.TrackerID, state domain.NormalizedIssueState) error
}