Skip to content

feat(discovery): key-gated aggregators — Adzuna + USAJOBS - #34

Merged
Harikeshav-R merged 6 commits into
mainfrom
feat/keyed-aggregators
Aug 5, 2026
Merged

feat(discovery): key-gated aggregators — Adzuna + USAJOBS#34
Harikeshav-R merged 6 commits into
mainfrom
feat/keyed-aggregators

Conversation

@Harikeshav-R

Copy link
Copy Markdown
Owner

What & why

Adds the key-gated aggregators the free RemoteOK/Remotive feeds deferred (PROJECT.md §5.4-B; the top "Next up" item in docs/STATUS.md) — the two credential-requiring providers the design names: Adzuna (query-string auth) and USAJOBS (header auth). A key-gated source resolves its credentials from the OS keychain and is shown as "needs API key" (inactive, never failing silently) until a key is stored.

Key commits

  1. feat(config): add [aggregators] config sectionAdzunaConfig / UsajobsConfig / AggregatorsConfig, mirroring [ai.backends] (enable flags + non-secret params + keyring handles, never the secret).
  2. feat(aggregators): add key-gating seam + Adzuna adapterAggregatorAdapter.requires_key, a build_aggregator(name, *, config, store) builder registry (resolves credentials, returns None = inactive when disabled/keyless), adzuna.py, and the poller refactor (config/store params, DiscoveryOutcome.inactive, inactive-skip) wired into atlas discover + the daemon.
  3. feat(aggregators): add USAJOBS adapterusajobs.py, header auth (the reason the Fetcher seam carries headers), nested SearchResult parsing.
  4. feat(cli): add atlas source key for storing aggregator credentials — the first secret-writing path: hidden prompts → SecretStore.set.
  5. feat(cli): report aggregator sources in atlas doctor — a second table (active / needs API key / disabled).
  6. docs: record key-gated aggregators (Adzuna, USAJOBS) — STATUS/PROJECT/CHANGELOG.

Design notes

  • Credentials resolve at construction (matching the base.py docstring's documented intent and build_openrouter_provider): the poll builds the adapter with resolved credentials or gets None. search(...)'s signature is unchanged; the secret never touches it and is never logged.
  • No migration — key-gated sources are the same JobSource(type="aggregator") rows; credentials live in the keychain, non-secret params in config.
  • No silent skips — an unconfigured key-gated source is counted in the new DiscoveryOutcome.inactive and surfaced by atlas discover + atlas doctor, distinct from a failure.
  • First secret-writing CLI pathatlas source key prompts with hidden input (never echoed / in shell history) rather than flags.
  • No new dependency (both APIs are plain JSON over the existing Fetcher).

Follow-ups (out of scope)

The free HN "Who is hiring" / arbeitnow aggregators drop into the same registry later.

Testing

  • ruff format --check + ruff check — clean
  • mypy --strict on Linux and win32 — no issues (271 files each)
  • pytest --cov=atlas --cov-branch --cov-fail-under=1001065 passed, 100% line + branch

Adapters are exercised offline via recorded fixtures through a FakeFetcher; credential resolution / storage via a FakeKeyring; the source key hidden prompts via CliRunner(input=...). No live network or real keychain in the suite.

Refs PROJECT.md §5.4-B, §15 (Phase 2).

Model the key-gated aggregator providers (PROJECT.md §5.4-B) that the
Adzuna/USAJOBS adapters will consume, mirroring the [ai.backends] shape:
per-provider `_Base` models with defaulted keyring *handle* strings (never
the secret itself).

- AdzunaConfig (enabled / app_id_handle / app_key_handle / country) —
  query-string auth.
- UsajobsConfig (enabled / email / api_key_handle) — header auth; the
  non-secret registering email lives in config, the key in the keychain.
- AggregatorsConfig groups them; Config gains an `aggregators` field.

All defaulted (a missing config still yields a valid Config) and
forward-compatible (`extra="ignore"`), so the section loads before the
adapters that read it land. Re-exported from atlas.config for tests. No
secret ever appears here — only handles.
Introduce the key-gated aggregator path (PROJECT.md §5.4-B) and the first
provider that uses it — Adzuna (query-string auth).

- base.py: add `requires_key` to the AggregatorAdapter Protocol (free feeds
  set False, key-gated True).
- __init__.py: a builder registry replacing the bare instance tuple —
  `build_aggregator(name, *, config, store)` resolves a key-gated adapter's
  credentials from the keychain and returns None (an inactive source) when
  disabled or keyless; `validate_aggregator` / `aggregator_requires_key` are
  the pure name checks the CLI uses without a store.
- adzuna.py: AdzunaAdapter (constructed with resolved app_id/app_key/country
  by build_adzuna) GETs the search API with credentials in the query string,
  normalizes {results:[...]}, and applies the shared matches_search.
- poller.py: run_aggregator_poll gains `config` + `store` params; a key-gated
  source that builds to None is skipped as *inactive* — reported via the new
  DiscoveryOutcome.inactive (defaulted, so ATS/JSON round-trips are unchanged)
  rather than silently dropped or counted as a failure.
- cli: discover and the daemon tick thread config.aggregators + the secret
  store into the aggregator poll; discover surfaces the inactive count; source
  add points key-gated providers at `atlas source key`.

100% line+branch on the aggregator package + poller; mypy --strict incl. win32.
The second key-gated provider (PROJECT.md §5.4-B), exercising header auth —
the reason the Fetcher seam carries a `headers` param.

- usajobs.py: UsajobsAdapter GETs the USAJOBS search API with the credential
  in an `Authorization-Key` header + the registering email as `User-Agent`
  (both resolved by build_usajobs from config + keychain), parses the nested
  SearchResult.SearchResultItems shape (tolerating a list-valued ApplyURI and
  a missing UserArea/Details), and applies the shared matches_search.
- build_usajobs returns None (inactive) when disabled, no email, or no key.
- Registered on the builder registry; AGGREGATOR_TYPES is now four.

100% line+branch; mypy --strict incl. win32.
The first secret-writing path in the codebase (every SecretStore use so far
was read-only). `atlas source key <aggregator>` prompts for each credential
with hidden input — never echoed, never in shell history — and writes it to
the OS keychain under the handle from config, so a key-gated source can be
activated without secrets ever touching config or the command line.

- registry: credential_prompts(aggregator, config) returns the (label, handle)
  pairs a key-gated provider stores (Adzuna: app id + app key; USAJOBS: api
  key), keeping per-provider credential knowledge in the package.
- command: validates the name is a key-gated aggregator (unknown / free →
  exit 1), then prompts + SecretStore.set per credential; a ConfigError (e.g.
  no keychain) exits 1 cleanly. The secret is never printed or logged.

100% line+branch; mypy --strict incl. win32; full suite green (1061 tests).
Surface each registered aggregator's configuration health so a user can see
at a glance which job sources are usable and which need a key.

- doctor.py: build_aggregator_health(config, store) reports per provider —
  a free feed is "active"; a key-gated one is "active" (enabled + keyed),
  "needs API key" (enabled, keyless), or "disabled" — mirroring the backend
  build->availability->detail shape and never leaking the secret. A new
  DoctorReport.aggregators field (defaulted, JSON round-trippable) carries it,
  rendered as a second Rich table; `healthy` stays AI-only since aggregators
  are optional.
- doctor command builds the aggregator health and attaches it to the report.

100% line+branch; mypy --strict incl. win32; full suite green (1065 tests).
Tick the key-gated aggregator work: STATUS.md gains a "What has landed"
entry and an updated phase-progress row, and its "Next up" pointer moves to
multiple profiles / IPC (key-gated aggregators are done). PROJECT.md §15
notes Adzuna + USAJOBS + the [aggregators] config / source key landed, and
§10's config example gains an [aggregators] block. CHANGELOG records the two
adapters, the config section, atlas source key, and the doctor aggregator
report under Unreleased.
@Harikeshav-R
Harikeshav-R merged commit 8080d98 into main Aug 5, 2026
10 checks passed
@Harikeshav-R
Harikeshav-R deleted the feat/keyed-aggregators branch August 5, 2026 18:24
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