Skip to content

feat(discovery): company watchlist + Greenhouse ATS discovery - #30

Merged
Harikeshav-R merged 6 commits into
mainfrom
feat/discovery-greenhouse-watchlist
Aug 5, 2026
Merged

feat(discovery): company watchlist + Greenhouse ATS discovery#30
Harikeshav-R merged 6 commits into
mainfrom
feat/discovery-greenhouse-watchlist

Conversation

@Harikeshav-R

@Harikeshav-R Harikeshav-R commented Aug 5, 2026

Copy link
Copy Markdown
Owner

What & why

Phase 2's first real discovery source (PROJECT.md §5.4-A). Until now the daemon's
scheduled work was only a scoring poll over postings you'd pasted in by hand with
atlas add <url> — it couldn't find anything. This PR adds a company watchlist and an
extensible ATS adapter layer, ships the Greenhouse adapter, and wires a discovery poll
into the daemon before the scoring poll, so Atlas now genuinely discovers jobs on a
schedule and scores them on the same pass.

Scope was deliberately kept to one clean vertical slice: the full extensible interface +
registry, but only the Greenhouse adapter. Lever/Ashby/Workday are fast-follow PRs — each
is a new module in atlas.discovery.ats implementing the same AtsAdapter Protocol plus one
entry in the registry tuple, with no interface change.

Key commits

  1. feat(discovery): add ATS adapter interface + Greenhouse adapter with URL detection — the
    AtsAdapter Protocol (pure offline detect(url) + list_postings over the reused scrape
    Fetcher), the registry (get_adapter / detect_ats / ATS_TYPES), and the Greenhouse
    boards-API adapter + DiscoveredPosting (reuses ScrapedPosting by composition).
  2. feat(discovery): add watchlist and discovery repository + service — an ATS board is a
    JobSource(type="ats") whose config carries ats_type/board_token/company_id;
    add_watchlist_company + persist_discovered (dedup by external id, then by the
    normalized-apply-URL dedupe_hash shared with atlas add).
  3. feat(discovery): add the discovery pollrun_discovery_pollDiscoveryOutcome,
    best-effort per source (mirrors run_scoring_poll).
  4. feat(discovery): add atlas company and atlas discover CLIcompany add <url>
    (URL auto-detects the ATS; unrecognized → exit 1), company list, discover.
  5. feat(daemon): discover before scoring in the daemon poll — the bound job discovers, then
    scores the fresh backlog.
  6. docs: record company watchlist + Greenhouse discovery — STATUS/PROJECT/CHANGELOG/README.

Migration

None. Every column used already exists (migration e5b263970e7e): company.ats_type /
ats_board_ref / domain, job_source.type / config / enabled / last_polled_at,
job_posting.external_id. This is a purely behavioral feature, like the tracking PR.

Testing

Fully hermetic (AGENTS.md §6.2): Greenhouse normalization + all URL-detection forms (and the
unrecognized-URL error) via FakeFetcher and recorded board JSON; the poll over the in-memory
db_engine (discover/persist/stamp, re-poll no-op, a failing source skipped best-effort,
unknown provider skipped without fetching, empty watchlist makes zero fetches); the CLI via
CliRunner + pure build/render tests. Gates green: ruff format --check, ruff check,
mypy --strict (incl. --platform win32), and 864 tests at 100% line+branch coverage. No
new dependency.

Design notes for review

  • company_id lives in JobSource.config JSON (no FK) — chosen to avoid a migration and
    to match the existing single type="url" source pattern; integrity held in code.
  • Company display name is titleized from the board token, with an optional --name
    override, so the single-arg atlas company add <url> works.
  • atlas discover is discovery-only (AI-free) so it runs without a configured backend; the
    daemon is what chains discover → score.
  • ATS sources are global (profile_id=None) this slice; per-profile watchlists are
    deferred to "multiple profiles fully wired" (§15). An explicit --ats/--token override on
    company add is noted as a future enhancement.

…URL detection

The first slice of Phase 2 company-watchlist discovery (PROJECT.md §5.4-A):
an extensible AtsAdapter Protocol + registry and the Greenhouse adapter,
with no discovery persistence or CLI yet (those follow).

- atlas.discovery.structure.DiscoveredPosting reuses the scraper's
  ScrapedPosting by composition, adding only the ATS's external_id.
- atlas.discovery.ats.base.AtsAdapter: a runtime-checkable Protocol with a
  pure offline detect(url) hook and a list_postings() that fetches through
  the injected scrape Fetcher (no new network boundary).
- atlas.discovery.ats.greenhouse.GreenhouseAdapter: detects the board token
  from the boards/job-boards/embed/subdomain URL forms and normalizes the
  public boards JSON API (content=true), unescaping the HTML description via
  the reused extract_main_text and skipping malformed jobs best-effort.
- atlas.discovery.ats registry: get_adapter (UnknownAtsError on miss),
  detect_ats(url) -> (ats_type, board_token), and ATS_TYPES for messaging.
- atlas.discovery.errors: DiscoveryError + UnknownAtsError.

Tests drive detection over URL strings and list_postings through a
FakeFetcher replaying a recorded board payload; 100% line+branch on the
discovery package, mypy --strict clean (incl. win32). No new dependency.
The persistence layer beneath the discovery poll (PROJECT.md §5.4, §6):
thin repository functions over an open session plus the two domain
operations, with no poller or CLI yet.

- repository: an ATS board is a JobSource(type="ats") whose config JSON
  carries ats_type / board_token / company_id (no new column, so no
  migration). get_ats_source / get_or_create_ats_source dedup by
  (ats_type, board_token); list_enabled_ats_sources feeds the poll
  (excludes disabled + the shared url source); stamp_last_polled_at;
  get_posting_by_source_external is the stable per-source re-poll key.
- service.add_watchlist_company: get-or-create the Company (recording
  ats_type / ats_board_ref / domain) + the ATS source, idempotent.
- service.persist_discovered: insert new postings, deduplicating first by
  (source, external_id) and then by the normalized-apply-URL dedupe_hash
  (cross-source collapse), reusing the scraper's dedupe_hash_for and
  create_job_posting. Clock injected for deterministic fetched_at.

Tested over the in-memory db_engine; 100% line+branch on the discovery
package, mypy --strict clean (incl. win32).
The discovery counterpart to the daemon's scoring poll (PROJECT.md §4.1,
§5.4): run_discovery_poll, a pure function over an open session returning a
DiscoveryOutcome (sources_polled / discovered / skipped / failed_sources).

For each enabled ATS source it resolves the provider's adapter, lists the
board through the injected fetcher, persists the new postings, and stamps
last_polled_at. Polling is best-effort per source — an unknown provider, an
unusable board response, or a fetch failure is counted (failed_sources) and
skipped rather than aborting the batch, mirroring run_scoring_poll's handling
of a MatchingError. The scoring poll picks up the new unscored postings on its
own pass, so the two compose without coupling.

Tested over the in-memory db_engine with a FakeFetcher: discovers + persists +
stamps, re-poll is a no-op, a failing source is skipped while a good one still
polls, an unknown provider is skipped without fetching, and an empty watchlist
makes zero fetch calls. 100% line+branch, mypy --strict clean (incl. win32).
The scriptable surface for the watchlist + discovery poll (PROJECT.md §9):

- atlas company add <url>: auto-detects the ATS provider + board token from
  the URL (no --ats flag) via detect_ats, then watchlists the company and its
  board. An optional --name overrides the token-derived display name; an
  unrecognized URL exits 1 naming the supported providers. Re-adding is a no-op.
- atlas company list: renders the watchlisted boards (company, ATS, board,
  enabled, last polled) or --json; an empty list hints at 'atlas company add'.
- atlas discover: runs one discovery poll now over the injected default_fetcher
  and reports the DiscoveryOutcome (or --json). Discovery is AI-free — it hints
  at 'atlas score' / the daemon to score the newly-found postings.

The pure build_watchlist_report / render_* split lives in atlas.cli.discovery
(mirroring atlas.cli.scrape), tested against db_engine; the commands are tested
through the CliRunner with a shared in-memory engine and a FakeFetcher replaying
a recorded board (no network). Full suite green at 100% line+branch, mypy
--strict clean (incl. win32).
Wire the discovery poll into the daemon's scheduled work (PROJECT.md §4.1):
the bound job now runs run_discovery_poll over the ATS watchlist first, then
run_scoring_poll. Each runs in its own short session_scope, so discovery
commits its new postings before the scoring poll's list_unscored_postings
picks them up on the same tick — the two compose without coupling.

The daemon-start test gains a case that seeds a watchlisted board + an
injected FakeFetcher and drives the bound job, asserting the posting is
discovered and persisted (the empty-watchlist case still makes no fetch, so
the suite stays hermetic). Full suite green at 100% line+branch, mypy
--strict clean (incl. win32).
Update the docs for the discovery slice (Definition of Done, AGENTS.md §8):

- STATUS.md: bump 'Last updated' and 'Current phase'; add the 'What has
  landed' entry for atlas.discovery; move the 'Next up' pointer to the
  remaining ATS adapters (Lever/Ashby/Workday), aggregators, the Discover
  queue, and the IPC surface; tick the Phase-2 progress row.
- PROJECT.md §15: mark Greenhouse done under the watchlist item, noting the
  remaining adapters drop into the same registry.
- CHANGELOG.md (Unreleased/Added): the watchlist + Greenhouse discovery, the
  atlas company / atlas discover commands, and discover-before-score in the
  daemon.
- README.md: a 'Discover jobs from company ATS boards' section (atlas company
  add|list, atlas discover) and the updated daemon paragraph (discover→score).
@Harikeshav-R
Harikeshav-R merged commit 1ba0dd5 into main Aug 5, 2026
10 checks passed
@Harikeshav-R
Harikeshav-R deleted the feat/discovery-greenhouse-watchlist branch August 5, 2026 06:48
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