atsfill 0.0.3.dev5__tar.gz → 0.0.3.dev7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/PKG-INFO +5 -1
  2. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/README.md +4 -0
  3. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/pyproject.toml +1 -1
  4. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/ashby.py +43 -16
  6. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/commands.py +35 -16
  7. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/registry.py +73 -11
  8. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/results.py +2 -0
  9. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/registry.toml +7 -5
  10. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/LICENSE +0 -0
  11. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/__init__.py +0 -0
  12. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/__init__.py +0 -0
  13. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/greenhouse.py +0 -0
  14. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/teamtailor.py +0 -0
  15. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/adapters/wttj.py +0 -0
  16. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/cli.py +0 -0
  17. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/__init__.py +0 -0
  18. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/accounts.py +0 -0
  19. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/adapter.py +0 -0
  20. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/attempts.py +0 -0
  21. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/browser.py +0 -0
  22. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/matching.py +0 -0
  23. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/core/paths.py +0 -0
  24. {atsfill-0.0.3.dev5 → atsfill-0.0.3.dev7}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.3.dev5
3
+ Version: 0.0.3.dev7
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -52,6 +52,10 @@ It is built for agents with a shell: every command prints one JSON document and
52
52
 
53
53
  ## Supported hiring systems
54
54
 
55
+ <!-- badges -->
56
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
57
+ <!-- /badges -->
58
+
55
59
  | System | Account needed | `read` without a browser |
56
60
  | --- | --- | --- |
57
61
  | Ashby | no | yes |
@@ -28,6 +28,10 @@ It is built for agents with a shell: every command prints one JSON document and
28
28
 
29
29
  ## Supported hiring systems
30
30
 
31
+ <!-- badges -->
32
+ ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![greenhouse: unverified](https://img.shields.io/badge/greenhouse-unverified-565f89) ![teamtailor: unverified](https://img.shields.io/badge/teamtailor-unverified-565f89) ![wttj: unverified](https://img.shields.io/badge/wttj-unverified-565f89)
33
+ <!-- /badges -->
34
+
31
35
  | System | Account needed | `read` without a browser |
32
36
  | --- | --- | --- |
33
37
  | Ashby | no | yes |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev5"
3
+ version = "0.0.3.dev7"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.3.dev5"
3
+ version = "0.0.3.dev7"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,12 +1,14 @@
1
1
  """Ashby (`jobs.ashbyhq.com`, and career pages embedding it), as docs/adapters/ashby.md observes.
2
2
 
3
- Every change posts `ApiSetFormValue`, whose response holds the form as Ashby will receive it:
4
- that is the read-back, kept per form since a survey answer returns the survey alone.
3
+ Every change posts a mutation whose response holds the form as Ashby will receive it: that is
4
+ the read-back, kept per form since a survey answer returns the survey alone.
5
5
  """
6
6
 
7
7
  import json
8
8
  import time
9
9
  import urllib.request
10
+ from collections.abc import Callable
11
+ from functools import partial
10
12
  from typing import Any
11
13
  from urllib.parse import parse_qs, urlsplit
12
14
 
@@ -31,6 +33,7 @@ TYPES = {
31
33
  "String": FieldType.TEXT,
32
34
  "Email": FieldType.TEXT,
33
35
  "LongText": FieldType.TEXT,
36
+ "Url": FieldType.TEXT,
34
37
  "Date": FieldType.DATE,
35
38
  "Number": FieldType.TEXT,
36
39
  "Phone": FieldType.PHONE,
@@ -42,6 +45,7 @@ TYPES = {
42
45
  }
43
46
  SETTLE_S = 1.5
44
47
  READ_BACK_TIMEOUT_S = 30
48
+ SAVE_TIMEOUT_MS = 30_000
45
49
  SUBMIT_TIMEOUT_MS = 90_000
46
50
 
47
51
 
@@ -70,7 +74,7 @@ class Ashby:
70
74
 
71
75
  def __init__(self) -> None:
72
76
  self._forms: list[dict[str, Any]] = [] # the application form, then each survey
73
- self._pending = 0
77
+ self._pending: set[Any] = set() # GraphQL requests in flight, since `read_form`
74
78
  self._last_change = 0.0
75
79
  self._hosted: str | None = None # the jobs.ashbyhq.com posting a custom domain embeds
76
80
 
@@ -132,6 +136,7 @@ class Ashby:
132
136
  return [self._field(e) for e in self._entries()]
133
137
 
134
138
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
139
+ """One save at a time: saves in flight together overwrite each other at Ashby."""
135
140
  fields = {e["field"]["path"]: self._field(e) for e in self._entries()}
136
141
  for field_id, answer in answers.items():
137
142
  entry = page.locator(f'[data-field-path="{field_id}"]')
@@ -140,30 +145,49 @@ class Ashby:
140
145
  case FieldType.TEXT | FieldType.DATE | FieldType.PHONE, str():
141
146
  box = entry.locator("input, textarea").first
142
147
  box.fill(answer)
143
- box.blur()
148
+ self._saved(page, box.blur)
149
+ case FieldType.CHOICE, str() if entry.locator("input[role=combobox]").count():
150
+ entry.locator("input[role=combobox]").fill(answer)
151
+ option = page.get_by_role("option", name=answer, exact=True)
152
+ self._saved(page, option.click)
144
153
  case FieldType.CHOICE, str():
145
- entry.get_by_text(answer, exact=True).click()
154
+ self._saved(page, entry.get_by_text(answer, exact=True).click)
146
155
  case FieldType.MULTI_CHOICE, list():
147
156
  for value in answer:
148
- entry.get_by_text(value, exact=True).click()
157
+ self._saved(page, entry.get_by_text(value, exact=True).click)
149
158
  case FieldType.TYPEAHEAD, str():
150
- box = entry.locator("input[role=combobox]")
151
- box.fill(answer)
152
- page.get_by_role("option").filter(has_text=answer).first.click()
159
+ entry.locator("input[role=combobox]").fill(answer)
160
+ option = page.get_by_role("option").filter(has_text=answer).first
161
+ self._saved(page, option.click)
153
162
  case FieldType.FILE, str():
154
- entry.locator("input[type=file]").set_input_files(answer)
163
+ upload = entry.locator("input[type=file]")
164
+ self._saved(page, partial(upload.set_input_files, answer))
155
165
  case kind, _:
156
166
  raise Unsupported(
157
167
  f"form_shape: cannot fill {field_id}, a {kind}, with {answer!r}"
158
168
  )
159
169
  self._answered = list(answers)
160
170
 
171
+ def _saved(self, page: Page, change: Callable[[], None]) -> None:
172
+ """Makes the change, then waits for its save and any other request it set off. A
173
+ change Ashby does not save shows in the read-back."""
174
+ try:
175
+ with page.expect_response(
176
+ lambda r: _op(r).startswith("ApiSetFormValue"), timeout=SAVE_TIMEOUT_MS
177
+ ):
178
+ change()
179
+ except Timeout:
180
+ return
181
+ deadline = time.monotonic() + SAVE_TIMEOUT_MS / 1000
182
+ while self._pending and time.monotonic() < deadline:
183
+ page.wait_for_timeout(100)
184
+
161
185
  def read_back(self, page: Page) -> dict[str, Answer]:
162
186
  """Ashby's last returned form, once every answer is held and no request is in flight."""
163
187
  deadline = time.monotonic() + READ_BACK_TIMEOUT_S
164
188
  while time.monotonic() < deadline:
165
189
  values = self._values()
166
- quiet = self._pending == 0 and time.monotonic() - self._last_change > SETTLE_S
190
+ quiet = not self._pending and time.monotonic() - self._last_change > SETTLE_S
167
191
  if quiet and all(values.get(i) is not None for i in self._answered):
168
192
  break
169
193
  page.wait_for_timeout(250)
@@ -248,15 +272,18 @@ class Ashby:
248
272
 
249
273
  def _on_request(self, request: Any) -> None:
250
274
  if "/api/non-user-graphql" in request.url:
251
- self._pending += 1
275
+ self._pending.add(request)
252
276
 
253
277
  def _on_finished(self, request: Any) -> None:
254
278
  if "/api/non-user-graphql" in request.url:
255
- self._pending -= 1
279
+ self._pending.discard(request)
256
280
  self._last_change = time.monotonic()
257
281
 
258
282
  def _on_response(self, response: Response) -> None:
259
- if _op(response) == "ApiSetFormValue" and response.ok:
260
- render = response.json()["data"]["setFormValue"]
261
- if render:
283
+ """Any response holding a form render: a value goes to `ApiSetFormValue`, a file to
284
+ `ApiSetFormValueToFile` once its upload ends, after saves made later."""
285
+ if not _op(response).startswith("ApiSetFormValue") or not response.ok:
286
+ return
287
+ for render in (response.json().get("data") or {}).values():
288
+ if isinstance(render, dict) and "sections" in render:
262
289
  self._replace(render)
@@ -30,12 +30,14 @@ def _login_required(url: str, adapter: Adapter) -> Result:
30
30
 
31
31
  @contextmanager
32
32
  def _opened(
33
- data: Path, url: str, sessions: Sessions | None
33
+ data: Path, url: str, sessions: Sessions | None, detected: list[str]
34
34
  ) -> Generator[tuple[Page, Adapter] | Result]:
35
35
  """The loaded page and the adapter that claims it by its URL, else by its page; or the
36
36
  result that ends the command first. An adapter that needs an account gets its session,
37
- and only that adapter's (ADR 0007)."""
37
+ and only that adapter's (ADR 0007). The adapter's name goes in `detected` once known."""
38
38
  adapter = _detect(url)
39
+ if adapter:
40
+ detected.append(adapter.name)
39
41
  session = None
40
42
  if isinstance(adapter, NeedsAccount):
41
43
  session = (sessions or {}).get(adapter.name) or accounts.session_file(data, adapter.name)
@@ -44,7 +46,8 @@ def _opened(
44
46
  return
45
47
  with open_page(data, session) as page:
46
48
  page.goto(url)
47
- adapter = adapter or _detect(url, page)
49
+ if adapter is None and (adapter := _detect(url, page)):
50
+ detected.append(adapter.name)
48
51
  if adapter is None:
49
52
  yield _no_adapter(url)
50
53
  elif isinstance(adapter, NeedsAccount) and not (
@@ -77,16 +80,18 @@ def _stops(result: Result) -> Generator[None]:
77
80
  result.status, result.reason = Status.BLOCKED, f"covered: {e}"
78
81
 
79
82
 
80
- def _in_budget(url: str, command: Callable[[], Result]) -> Result:
83
+ def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
81
84
  """Runs a command to a status whatever happens: past the click it reports `unknown` itself,
82
- before it an error the adapter did not expect means the page is not one it handles."""
85
+ before it an error the adapter did not expect means the page is not one it handles. The
86
+ result keeps the adapter detected before the stop."""
87
+ detected: list[str] = []
83
88
  try:
84
- return command()
89
+ return command(detected)
85
90
  except OutOfTime as e:
86
- return Result(Status.BLOCKED, url, reason=f"time_budget: {e}")
91
+ return Result(Status.BLOCKED, url, (detected or [None])[-1], f"time_budget: {e}")
87
92
  except Exception as e:
88
93
  reason = f"adapter_error: {type(e).__name__}: {e}"
89
- return Result(Status.UNSUPPORTED, url, reason=reason)
94
+ return Result(Status.UNSUPPORTED, url, (detected or [None])[-1], reason)
90
95
 
91
96
 
92
97
  def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) -> Result:
@@ -104,8 +109,8 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer]) ->
104
109
  return result
105
110
 
106
111
 
107
- def _read_page(data: Path, url: str, sessions: Sessions | None) -> Result:
108
- with _opened(data, url, sessions) as opened:
112
+ def _read_page(data: Path, url: str, sessions: Sessions | None, detected: list[str]) -> Result:
113
+ with _opened(data, url, sessions, detected) as opened:
109
114
  if isinstance(opened, Result):
110
115
  return opened
111
116
  page, adapter = opened
@@ -122,8 +127,9 @@ def _form_command(
122
127
  sessions: Sessions | None,
123
128
  send: bool,
124
129
  resend: bool,
130
+ detected: list[str],
125
131
  ) -> Result:
126
- with _opened(data, url, sessions) as opened:
132
+ with _opened(data, url, sessions, detected) as opened:
127
133
  if isinstance(opened, Result):
128
134
  return opened
129
135
  page, adapter = opened
@@ -165,7 +171,7 @@ def read(url: str, *, data_dir: Path | None = None, sessions: Sessions | None =
165
171
  result.fields = fields
166
172
  return result
167
173
  data = paths.data_dir(data_dir)
168
- return _in_budget(url, lambda: _read_page(data, url, sessions))
174
+ return _in_budget(url, lambda seen: _read_page(data, url, sessions, seen))
169
175
 
170
176
 
171
177
  def fill(
@@ -177,7 +183,7 @@ def fill(
177
183
  ) -> Result:
178
184
  data = paths.data_dir(data_dir)
179
185
  return _in_budget(
180
- url, lambda: _form_command(data, url, answers, sessions, send=False, resend=False)
186
+ url, lambda seen: _form_command(data, url, answers, sessions, False, False, seen)
181
187
  )
182
188
 
183
189
 
@@ -191,13 +197,16 @@ def send(
191
197
  ) -> Result:
192
198
  data = paths.data_dir(data_dir)
193
199
  return _in_budget(
194
- url, lambda: _form_command(data, url, answers, sessions, send=True, resend=resend)
200
+ url, lambda seen: _form_command(data, url, answers, sessions, True, resend, seen)
195
201
  )
196
202
 
197
203
 
198
204
  def _unverified(adapter: str) -> str | None:
199
- """The warning a send carries when no send through the adapter was verified (ADR 0012)."""
205
+ """The warning a send carries when no send through the adapter was verified, or the last
206
+ verified one fails now (ADR 0012)."""
200
207
  system = registry.for_adapter(adapter)
208
+ if system and system.send_broken:
209
+ return f"send_broken: {_broken(system)}"
201
210
  if system and system.send_verified:
202
211
  return None
203
212
  state = system.state if system else None
@@ -223,16 +232,26 @@ def status(*, data_dir: Path | None = None) -> StatusReport:
223
232
  )
224
233
 
225
234
 
235
+ def _broken(system: registry.System) -> str:
236
+ broken, last = system.send_broken, system.send_verified
237
+ assert broken
238
+ since = f"send broken since {broken.date} ({broken.version}), {broken.reason}"
239
+ return since + (f"; last verified {last.date} ({last.version})" if last else "")
240
+
241
+
226
242
  def _adapter_state(adapter: Adapter, data: Path) -> AdapterState:
227
243
  system = registry.for_adapter(adapter.name)
228
244
  verified = system.state if system else None
245
+ v = getattr(system, verified) if system and verified else None
229
246
  return AdapterState(
230
247
  adapter.name,
231
248
  submit_enabled=paths.submit_enabled(adapter.name),
232
249
  side_effects=adapter.side_effects,
233
250
  side_effects_acknowledged=paths.acknowledged(adapter.name),
234
251
  verified=verified,
235
- verified_version=getattr(system, verified).version if system and verified else None,
252
+ verified_version=v.version if v else None,
253
+ verified_release=registry.release(v.version, version("atsfill")) if v else None,
254
+ send_broken=_broken(system) if system and system.send_broken else None,
236
255
  session=_session_state(adapter, data),
237
256
  )
238
257
 
@@ -12,13 +12,28 @@ HOSTING = ("hosted", "embedded")
12
12
  VENDORS = ("DataDome", "Cloudflare", "Akamai", "PerimeterX", "unknown")
13
13
  STEPS = ("posting", "form", "upload")
14
14
  STATES = ("mapped", "dry_run_verified", "send_verified")
15
+ CAUSES = ("system", "code", "unknown")
16
+ RELEASES = ("this_release", "older", "newer")
15
17
 
16
18
 
17
19
  @dataclass(frozen=True)
18
20
  class Verification:
21
+ date: date
22
+ version: str # the release, compared with the installed one
23
+ by: str
24
+ commit: str | None = None # the exact code
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Broken:
29
+ """A send that worked fails now (ADR 0012); `reason` names fields and steps, never postings."""
30
+
19
31
  date: date
20
32
  version: str
33
+ commit: str
21
34
  by: str
35
+ reason: str
36
+ cause: str = "unknown"
22
37
 
23
38
 
24
39
  @dataclass(frozen=True)
@@ -43,7 +58,8 @@ class System:
43
58
  block: Block | None = None
44
59
  mapped: Verification | None = None
45
60
  dry_run_verified: Verification | None = None
46
- send_verified: Verification | None = None
61
+ send_verified: Verification | None = None # the last known good, kept through a break
62
+ send_broken: Broken | None = None
47
63
 
48
64
  @property
49
65
  def state(self) -> str | None:
@@ -70,6 +86,10 @@ def _system(source: str, entry: dict) -> System:
70
86
  _one_of(source, "block.step", block.get("step"), STEPS)
71
87
  block = Block(**block)
72
88
  states = {s: Verification(**entry[s]) for s in STATES if s in entry}
89
+ broken = entry.get("send_broken")
90
+ if broken is not None:
91
+ _one_of(source, "send_broken.cause", broken.get("cause", "unknown"), CAUSES)
92
+ broken = Broken(**broken)
73
93
  return System(
74
94
  source,
75
95
  entry["kind"],
@@ -79,6 +99,7 @@ def _system(source: str, entry: dict) -> System:
79
99
  entry.get("share"),
80
100
  block,
81
101
  **states,
102
+ send_broken=broken,
82
103
  )
83
104
 
84
105
 
@@ -91,20 +112,36 @@ def systems() -> dict[str, System]:
91
112
  return parse(files("atsfill").joinpath("registry.toml").read_text())
92
113
 
93
114
 
94
- def with_state(text: str, source: str, state: str, v: Verification) -> str:
95
- """The registry file's text with `state` of `source` set to `v`, the rest untouched."""
115
+ def _toml(value: object) -> str:
116
+ return str(value) if isinstance(value, date) else f'"{value}"'
117
+
118
+
119
+ def with_state(text: str, source: str, key: str, value: Verification | Broken) -> str:
120
+ """The registry file's text with `key` of `source` set to `value`, the rest untouched.
121
+ Writing `send_verified` ends a `send_broken`."""
96
122
  lines = text.splitlines()
97
123
  start = lines.index(f"[{source}]")
98
124
  end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith("[")), len(lines))
99
125
  while lines[end - 1] == "":
100
126
  end -= 1
101
- entry = f'{state} = {{ date = {v.date}, version = "{v.version}", by = "{v.by}" }}'
102
- at = next((i for i in range(start, end) if lines[i].startswith(f"{state} =")), None)
103
- if at is None:
104
- lines.insert(end, entry)
105
- else:
106
- lines[at] = entry
107
- return "\n".join(lines) + "\n"
127
+ gone = {key, "send_broken"} if key == "send_verified" else {key}
128
+ entry = lines[start:end]
129
+ at = next((i for i, x in enumerate(entry) if x.split(" =")[0] in gone), len(entry))
130
+ entry = [x for x in entry if x.split(" =")[0] not in gone]
131
+ pairs = ", ".join(
132
+ f"{f.name} = {_toml(getattr(value, f.name))}"
133
+ for f in fields(value)
134
+ if getattr(value, f.name) is not None
135
+ )
136
+ entry.insert(min(at, len(entry)), f"{key} = {{ {pairs} }}")
137
+ return "\n".join(lines[:start] + entry + lines[end:]) + "\n"
138
+
139
+
140
+ def release(verified: str, installed: str) -> str:
141
+ """Whether a verified release is the installed one, `older` or `newer`."""
142
+ as_tuple = lambda v: tuple(int(n) for n in v.split("+")[0].split(".")) # noqa: E731
143
+ a, b = as_tuple(verified), as_tuple(installed)
144
+ return "this_release" if a == b else "older" if a < b else "newer"
108
145
 
109
146
 
110
147
  def for_adapter(name: str) -> System | None:
@@ -118,7 +155,7 @@ def _backlog_key(s: System) -> tuple:
118
155
  def _row(s: System) -> str:
119
156
  share = f"{s.share:.1%}" if s.share is not None else ""
120
157
  block = f"{s.block.vendor} at {s.block.step}" if s.block else ""
121
- state = s.state or ""
158
+ state = "send_broken" if s.send_broken else s.state or ""
122
159
  if s.adapter:
123
160
  state = f"[{state}]({s.adapter}.md)" if state else f"[adapter]({s.adapter}.md)"
124
161
  cells = [s.source, s.kind, share, s.account, ", ".join(s.hosting), block, state]
@@ -151,3 +188,28 @@ def backlog(registry: dict[str, System] | None = None) -> str:
151
188
  text += ["", "Aggregators and company-built forms, counted and not adapted:", "", head]
152
189
  text += map(_row, others)
153
190
  return "\n".join(text) + "\n"
191
+
192
+
193
+ BADGES = ("<!-- badges -->", "<!-- /badges -->")
194
+ COLORS = {"send_verified": "9ece6a", "dry_run_verified": "7aa2f7", "mapped": "a9b1d6"}
195
+
196
+
197
+ def _badge(s: System) -> str:
198
+ if s.send_broken:
199
+ message, color = f"send broken {s.send_broken.date}", "f7768e"
200
+ elif s.state:
201
+ v: Verification = getattr(s, s.state)
202
+ message, color = f"{s.state.replace('_', ' ')} {v.date}", COLORS[s.state]
203
+ else:
204
+ message, color = "unverified", "565f89"
205
+ escape = lambda t: t.replace("-", "--").replace("_", "__").replace(" ", "%20") # noqa: E731
206
+ url = f"https://img.shields.io/badge/{escape(s.adapter or s.source)}-{escape(message)}-{color}"
207
+ return f"![{s.adapter}: {message}]({url})"
208
+
209
+
210
+ def with_badges(readme: str, registry: dict[str, System] | None = None) -> str:
211
+ """The README with one badge per adapter between its badge markers."""
212
+ registry = systems() if registry is None else registry
213
+ start, end = readme.index(BADGES[0]) + len(BADGES[0]), readme.index(BADGES[1])
214
+ badges = " ".join(_badge(s) for s in sorted(registry.values(), key=_backlog_key) if s.adapter)
215
+ return f"{readme[:start]}\n{badges}\n{readme[end:]}"
@@ -72,6 +72,8 @@ class AdapterState:
72
72
  side_effects_acknowledged: bool
73
73
  verified: str | None = None # mapped | dry_run_verified | send_verified (ADR 0012)
74
74
  verified_version: str | None = None
75
+ verified_release: str | None = None # this_release | older | newer than the installed one
76
+ send_broken: str | None = None # since when and why, with the last verified send
75
77
  session: str | None = None # signed_in | signed_out | missing, for a system with accounts
76
78
 
77
79
 
@@ -8,32 +8,34 @@
8
8
  # adapter the adapter's name, when there is one
9
9
  # block vendor, signal, step (posting | form | upload), date, version, camoufox, browser
10
10
  # mapped, dry_run_verified, send_verified
11
- # date, version, by; only the user sets send_verified
11
+ # date, version (the release), commit (the exact code), by; only the user sets
12
+ # send_verified
13
+ # send_broken
14
+ # date, version, commit, by, cause (system | code | unknown), reason: a send that
15
+ # worked fails now; send_verified stays as the last known good
12
16
 
13
17
  [ashby]
14
18
  kind = "ats"
15
19
  account = "none"
16
20
  hosting = ["hosted", "embedded"]
17
21
  adapter = "ashby"
18
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
22
+ send_verified = { date = 2026-10-19, version = "0.0.2", by = "shbernal", commit = "7622376" }
23
+ dry_run_verified = { date = 2026-10-19, version = "0.0.2", by = "gate", commit = "c230c5e" }
19
24
 
20
25
  [teamtailor]
21
26
  kind = "ats"
22
27
  account = "none"
23
28
  hosting = ["hosted"]
24
29
  adapter = "teamtailor"
25
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
26
30
 
27
31
  [greenhouse]
28
32
  kind = "ats"
29
33
  account = "none"
30
34
  hosting = ["hosted", "embedded"]
31
35
  adapter = "greenhouse"
32
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
33
36
 
34
37
  [wttj]
35
38
  kind = "board"
36
39
  account = "to_send"
37
40
  hosting = ["hosted"]
38
41
  adapter = "wttj"
39
- send_verified = { date = 2026-10-18, version = "0.0.2", by = "shbernal" }
File without changes