Skip to content

feat: phase 2 adapters, config and resolver - #4

Merged
Skeyelab merged 31 commits into
mainfrom
feat/adapters
Oct 7, 2026
Merged

Skeyelab merged 31 commits into
mainfrom
feat/adapters

Conversation

@Skeyelab

@Skeyelab Skeyelab commented Oct 7, 2026

Copy link
Copy Markdown
Member

Phase 2 of the app-green v1 plan: data sources and the polling layer, read-only plus action methods. Stacked on #3 (phase 1 core); retarget to main once #3 merges.

What's here

  • internal/jira: tickets by assignee and by key (status category IDs in JQL, stable category keys in the model), transitions. Malformed fields blank only that field and come back as warnings.
  • internal/github: recent PRs via one GraphQL query per repo (paging by update time), base-branch PRs for stacks (any author, passed to link as context only), compare, rerun. Typed errors with Retry-After, GraphQL rate limits included.
  • internal/aws: pipeline source discovery, per-stage deploy history in one read per pipeline (approval waits with tokens, rejected approvals, retries collapsed, superseded runs keep finished stages), ECS health summed per env, approve/reject, SSO/throttle/access-denied checks.
  • internal/config: config.toml with validation that names the field, day-unit durations, repos per env, watched repos as a union, token commands that never leak tokens.
  • internal/resolver: concurrent polling, per-source and per-account failure isolation keeping last good data, compare cache, staleness per row, backoff and auth stops, refresh, and Approve/Rerun/Transition methods that re-poll.

382 tests, race-clean, golangci-lint clean. Each task had a spec and quality review plus a final phase review.

Decisions made during review

  • Jira: status category IDs in JQL; no projects or bad keys are config errors; decode problems logged once per poll.
  • GitHub: drafts skip review flags; EXPECTED (required check not reported) is a yellow flag; STARTUP_FAILURE fails, STALE doesn't; github-advanced-security counts as code scanning; colleagues' base PRs never appear in my rows or Unlinked.
  • AWS: a rejected or expired approval is red on the gated env; superseded runs keep finished stages; health is running vs desired per env; a failed token lookup is a warning; neutral action names in code.
  • Config: https Jira site (path allowed), profile required, duplicate services rejected, approval stage defaults to the env's own stage, watched repos are github.repos plus env repos.
  • Resolver: env repos auto-filled from discovered sources; until sources are known, envs read "deploy unknown" (no false green); discovered repos watched from the next poll; a rejected token stops that source until refresh; rate-limited compares back off GitHub; AWS throttling skips at least one poll; rows built on stale or missing data are marked stale.

Assisted-by: Claude Code:claude-opus-5-5

…ansitions

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
model.PR gains IsDraft. A draft open PR gets no ready-to-merge,
no-reviewer or stale-review flag; failing checks and requested changes
still flag it red.

Assisted-by: Claude Code:claude-opus-5-5
net/http client with an ETag cache for GETs (copied from git-green),
typed APIError (401 auth, Retry-After / rate-limit backoff), and:

- RecentPRs: one author's open and merged PRs, paged until GitHub has
  no more or the page's last PR predates since. Repo lowercased, rollup
  state passed through, failing CheckRuns and StatusContexts with
  CodeScanning and RunID, Reviewers, MergeSHA only when merged, IsDraft.
- BasePRs: the PR for each missing non-default base branch, any author,
  as aliases in one query per round, following the stack down.
- Compare: ahead/identical included, behind/diverged not, 404 unknown.
- RerunFailedJobs: ported from git-green, whole-run fallback when GitHub
  reports no failed jobs.

Assisted-by: Claude Code:claude-opus-5-5
CheckRun has no app field; GitHub rejected the whole query. The slug
lives on checkSuite{app{slug}}. Checked against a public repo's live
GraphQL schema; the fixture now uses the real shape.

Assisted-by: Claude Code:claude-opus-5-5
…res, GraphQL rate limit

- RecentPRs stops paging once the last PR on a page was updated before
  since; the list is ordered by update time, so createdAt could stop
  early and miss PRs.
- Checks from the github-advanced-security app count as code scanning;
  CodeQL Actions jobs stay ordinary re-runnable checks.
- A STARTUP_FAILURE conclusion is failing; STALE is not.
- A RATE_LIMITED GraphQL error is a 429 *APIError backing off until
  rateLimit.resetAt (now in both queries), else 60s.
- rules.Thresholds.StaleReview doc notes drafts skip review flags.

Assisted-by: Claude Code:claude-opus-5-5
Link takes a separate context list (the GitHub adapter's base-branch
PRs). They are walked when following a stack to the default branch but
never linked to a ticket and never returned as unlinked, so another
author's base PR naming my key does not join my row.

Assisted-by: Claude Code:claude-opus-5-5
GitHub's EXPECTED rollup state no longer counts as a failing check. It
raises the new yellow FlagCheckExpected, "PR #n waiting on a required
check", ordered after partial prod and before ready to merge.

Assisted-by: Claude Code:claude-opus-5-5
- RecentPRs skips PRs from forks, so a fork's branch cannot hide a
  missing base.
- The ETag cache skips bodies over 256KB, and Compare bypasses it: the
  resolver caches compare results by SHA pair.
- RecentPRs warns when it stops at the page cap, BasePRs when the round
  cap leaves bases unfetched; BasePRs asks at most 20 branches a query.
- A rate-limit signal with a zero or past reset backs off 60s.
- Compare rejects a base or head that is not a 7-40 character hex SHA
  before building the URL.
- A PR with more than 50 checks is a warning. Warnings are deduplicated.

Assisted-by: Claude Code:claude-opus-5-5
…, approve

Sources reads a pipeline's CodeStar source actions into lowercased repo ->
action name, and Repos gives the list for Env.Repos. History builds each
stage's deploys newest first from one read of the pipeline's executions and
action executions (bounded paging), mapping revisions through the sources.
A run waiting at the approval that gates a stage is an AwaitingApproval
deploy carrying the token from GetPipelineState. Superseded and canceled
runs and abandoned actions are dropped. Health sums desired and running
tasks across an Env's ECS services and is unknown when any is missing.
Approve wraps PutApprovalResult with typed stale-token and access-denied
errors. IsSSOExpired spots an expired SSO session.

Assisted-by: Claude Code:claude-opus-5-5
A stage retry re-runs the deploy action under the same execution ID; keep
only the latest attempt so link never sees a stale failure beside it. With
an sso-session profile the SDK does not wrap token refresh errors in
InvalidTokenError, so IsSSOExpired also matches a failed refresh and a
missing cached token by message.

Assisted-by: Claude Code:claude-opus-5-5
A run superseded while waiting at the approval still deployed to Test, so
drop only the actions that never finished: abandoned ones, and ones an
ended run left showing InProgress (including its approval). Finished
successes and failures of superseded, stopped and canceled runs stay.

Assisted-by: Claude Code:claude-opus-5-5
Add model.DeployRejected. CodePipeline has no Rejected action status: a
rejected (or timed out) manual approval is a Failed approval action, so
aws.History turns a run whose latest approval attempt failed, and that never
reached the gated stage's deploy action, into a Rejected deploy there.
link treats it like a failed run (a newer run containing the PR still
supersedes it), and rules names it "<account> <stage> approval rejected"
on the red pipeline-failed flag.

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
GetPipelineState only supplies the approval token, so its failure now keeps
the history and shows the waiting approval without a token, with a warning.
IsThrottled and IsAccessDenied give callers typed checks; approvalError uses
IsAccessDenied. A rejected approval's flag reads "approval rejected or
expired", since a timed-out approval looks the same to CodePipeline.

Assisted-by: Claude Code:claude-opus-5-5
Warn when the action page cap (now 10) cuts the window short, and when a
spec's deploy or approval action matches nothing. Health accepts service
ARNs, approval summaries are cut to AWS's 512 characters, and the docs
cover the approval-to-deploy gap, duplicate source repos, and the checked
facts that make the early stop and superseded runs safe.

Assisted-by: Claude Code:claude-opus-5-5
Load validates jira (https site, project keys), github (author, owner/name
repos), aws accounts and envs, rejects unknown keys, and resolves tokens
from token_env or token_command. Envs, StageSpecs, Services and Thresholds
give the resolver what the adapters need. An env with no repos loads with
a warning: history fallback is a last resort.

Assisted-by: Claude Code:claude-opus-5-5
WatchedRepos is github.repos plus every env's repos, so github.repos is now
optional; Load fails only when there is nothing to watch. Durations accept
a leading day count ("2d", "1d12h"). settings.poll_interval defaults to 60s
and must be at least 15s. approval_stage's default is documented on the
field.

Assisted-by: Claude Code:claude-opus-5-5
- jira.site may have a path but no query or fragment; duplicate projects
  are an error.
- aws.accounts[].profile is required; account and env names are trimmed,
  and pipeline, stage and action names must use CodePipeline's characters.
- Env errors name the env: envs[i] (account/pipeline/stage).field.
- Repo names follow GitHub's rules, and "." and ".." are rejected.
- An approval_action equal to deploy_action in the same stage is an error,
  as is a service listed twice in one env (ARNs reduced to names).
- "1d-23h" is rejected instead of meaning 1h.
- StageSpecs returns pipelines in file order.
- A relative XDG_CONFIG_HOME is ignored, quoted stderr is cut on a rune
  boundary, and resolveToken documents its worst-case delay and that
  stderr is quoted.

Assisted-by: Claude Code:claude-opus-5-5
Every read now goes through GraphQL (a POST) or Compare, which skipped the
cache already, so the transport cached nothing. The client keeps one plain
http.Client; the resolver caches Compare results by SHA pair.

Assisted-by: Claude Code:claude-opus-5-5
Poll fetches Jira, GitHub (my recent PRs plus their base-branch PRs as
link context, per watched repo) and each AWS account (sources once per
pipeline for the session, one History call per pipeline, health per env)
concurrently, then looks up the tickets my open PRs name that MyTickets
missed, and runs link and rules over the last good data.

- Compare answers are cached by (repo, base, head); only Included and
  NotIncluded are kept. A compare error is Unknown plus a warning.
- Each source, and each AWS account, keeps its previous result when it
  fails and reports it in Snapshot.Status: the error, SSO expiry, and a
  rate limit that skips the source until RetryAt.
- An env with no repos in config takes them from its pipeline's sources.
- Warnings are deduplicated per poll, put in the Snapshot and logged once.

Assisted-by: Claude Code:claude-opus-5-5
Run polls now, then every poll interval and on each refresh, one poll at a
time, and returns when ctx is canceled. FromConfig resolves both tokens
once, concurrently, and builds the Jira, GitHub and per-account AWS
clients. An account with no client is a status error, not a panic.

Assisted-by: Claude Code:claude-opus-5-5
Fetches cut short by shutdown fail with the context's error; Poll now
returns the last good data without marking sources failed, logging, or
asking GitHub for compares.

Assisted-by: Claude Code:claude-opus-5-5
A Jira or GitHub error worded like an expired token no longer sets SSO.
Env health reaches the slots and keeps its last good value when
DescribeServices fails.

Assisted-by: Claude Code:claude-opus-5-5
- GitHub watches the configured repos plus every repo the known pipeline
  sources name, as of the start of the poll, so a repo discovered only
  from Sources has its PRs fetched from the next poll.
- A Jira or GitHub 401 sets AdapterStatus.Auth with "token rejected" and
  stops calling that source until a refresh, which retries it once.
- A rate-limited Compare throttles GitHub until its RetryAfter: the rest
  of the poll's compare misses are Unknown without a call or a further
  warning, and PR fetches and compares wait for RetryAt.
- AWS throttling backs off the longer of one minute and the poll interval.

Assisted-by: Claude Code:claude-opus-5-5
An env with no config repos whose pipeline sources were never discovered
(an expired SSO session at startup) dropped out of every chain, so the
other prod env alone could show "in prod". It now counts as applying with
SlotUnknown for any chain with a merged PR on its way to the default
branch, which rules shows as a yellow "deploy unknown". Env.Repos stays
empty.

AWS throttling backed off max(AWSThrottleBackoff, interval) from the poll's
start, which expires just before the next poll (polls land one interval
after the last one ends). It now backs off that plus one interval, from
when the error is recorded, so at least one poll is skipped.

Assisted-by: Claude Code:claude-opus-5-5
A failed source keeps its last good data, so a row can be behind while
the header shows only that some source failed. model.Chain now carries
Stale and StaleReason: set when a repo one of the chain's PRs comes from,
or an env whose slot applies, did not load this poll (failed, skipped or
never loaded). An env counts as loaded when both its pipeline history and
its health did.

Snapshot exposes the same freshness as RepoAt (by watched repo) and EnvAt
(by Env.ID()): each input's last success, zero if never.

Assisted-by: Claude Code:claude-opus-5-5
The resolver now owns the user's actions, so the UI talks to one thing:
Approve (refuses read-only envs, empty tokens, envs with no approval
configured and accounts with no client), Rerun (failed jobs of an Actions
run), Transitions and Transition (Jira workflow moves). Each successful
action asks Run for a poll through an internal channel, without the token
retry a user's refresh does. PipelineURL builds an env's AWS console link.

model.Env gains Region, ApprovalStage and ApprovalAction, filled by
config.Envs() (approval_stage defaults to the env's own stage). Deployer,
CodeHost and Tracker widen to the adapter methods the actions call.

Assisted-by: Claude Code:claude-opus-5-5
- A refresh asks every pipeline for its sources again, so a changed
  pipeline is picked up; until one answers, its previous sources stay in
  use, so a failed rediscovery loses nothing.
- Extra tickets are not looked up when MyTickets failed this poll, and a
  failed lookup is a warning rather than a Jira failure (a rejected token
  or a rate limit still stops or backs off Jira).
- A GitHub 401 during compares stops GitHub like any other 401 and skips
  the poll's remaining compares.
- The compare cache drops answers nobody asked for in 50 polls.
- A rate-limited compare backs GitHub off from when the error came back,
  like the other sources.
- warnings drops a mutex nothing needed; resolveToken's doc gives the
  concurrent worst case (~11s); Snapshot's doc says the UI must treat it
  as read-only; TestCancelledContext is TestCanceledContext; a comment
  notes the first poll's compare latency, left as is.

Assisted-by: Claude Code:claude-opus-5-5
…hed sources

- A chain with no PRs is stale ("GitHub not refreshed") when GitHub
  failed or was skipped this poll: nobody can tell whether a PR exists.
- Rerun refuses a run ID of 0 or less (a check that is not an Actions
  run) without calling GitHub.
- After a refresh, a pipeline whose Sources call fails (other than an
  expired SSO session or throttling) reads its history with its cached
  sources, with a warning, instead of skipping the poll.
- Tests pin that a skipped source keeps dependent rows stale for the
  whole backoff window, and that a 401 or rate limit from TicketsByKey
  still stops or backs off Jira.

Assisted-by: Claude Code:claude-opus-5-5
@Skeyelab
Skeyelab changed the base branch from feat/core to main October 7, 2026 03:55
@Skeyelab
Skeyelab merged commit 5ceb3f5 into main Oct 7, 2026
7 checks passed
@Skeyelab
Skeyelab deleted the feat/adapters branch October 7, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant