Skip to content

Commit b9e3dd3

Browse files
Lexus2016Hermes Evolution
andauthored
feat: bootstrap evolution GitHub labels during cron registration (#469)
Adds _ensure_evolution_labels() to register_evolution_cron.py so the labels required by every evolution skill are created idempotently before jobs are registered. This stops the silent 'could not add label' failures from evolution-introspection and evolution-issues on fresh forks. Includes tests for dry-run, already-existing, and failure paths. Closes #468 Co-authored-by: Hermes Evolution <evolution@hermes.ai>
1 parent 9ddfaf9 commit b9e3dd3

2 files changed

Lines changed: 156 additions & 9 deletions

File tree

scripts/register_evolution_cron.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,89 @@ def _install_evolution_helpers(repo_root: Path) -> list[str]:
167167
return installed
168168

169169

170+
# Labels required by the evolution pipeline. Kept in one place so every skill
171+
# stage (issues, introspection, integration, implementation) can rely on them
172+
# existing. Creation is idempotent; failures are warnings, not fatal.
173+
_EVOLUTION_LABELS: list[tuple[str, str, str]] = [
174+
("capability", "5319e7", "Missing ability users needed"),
175+
("introspection", "0e8a16", "Found by session introspection"),
176+
("ux", "fbca04", "Interaction friction"),
177+
("proposal", "0e8a16", "Evolution-generated improvement proposal"),
178+
("research-generated", "1d76db", "Created by the evolution research cycle"),
179+
("needs-work", "d93f0b", "Blocked by code-review (dead code / not integrated)"),
180+
("next-increment", "1d76db", "Roadmap increment merged; more deferred — re-queued"),
181+
("accepted", "0e8a16", "Accepted by evolution — sent to a PR / implemented"),
182+
("rejected", "b60205", "Not accepted by evolution — see closing comment"),
183+
("needs-split", "d4c5f9", "Wanted, but exceeds one cycle — needs decomposition"),
184+
("blocked", "e11d21", "Needs human/infrastructure action — see comment"),
185+
("fix", "1d76db", "Bug or fix"),
186+
("improvement", "a2eeef", "An improvement to existing functionality"),
187+
(
188+
"implemented-on-main",
189+
"0e8a16",
190+
"Capability already exists on main — no code change needed",
191+
),
192+
]
193+
194+
195+
def _ensure_evolution_labels(repo_root: Path, dry_run: bool = False) -> list[str]:
196+
"""Idempotently create the GitHub labels used by the evolution pipeline.
197+
198+
Several evolution skills call ``gh label create`` with the expectation that
199+
the label exists; on a fresh fork the labels are missing and every label
200+
operation fails silently (wasting API calls and leaving issues
201+
uncategorized — #468). This bootstrap step runs once per registration pass.
202+
203+
Returns the list of label names that were created or confirmed present.
204+
Warnings are printed for any failure, but registration continues.
205+
"""
206+
import subprocess
207+
208+
created: list[str] = []
209+
for name, color, description in _EVOLUTION_LABELS:
210+
cmd = [
211+
"gh",
212+
"label",
213+
"create",
214+
name,
215+
"--repo",
216+
"Lexus2016/hermes-agent-evolution",
217+
"--color",
218+
color,
219+
"--description",
220+
description,
221+
]
222+
if dry_run:
223+
print(f"[evolution-cron] dry-run label: {name}")
224+
created.append(name)
225+
continue
226+
try:
227+
result = subprocess.run(
228+
cmd,
229+
cwd=repo_root,
230+
capture_output=True,
231+
text=True,
232+
check=False,
233+
timeout=30,
234+
)
235+
if result.returncode == 0:
236+
created.append(name)
237+
elif "already exists" in (result.stderr or "").lower():
238+
created.append(name)
239+
else:
240+
print(
241+
f"[evolution-cron] warning: could not create label {name}: "
242+
f"{result.stderr or result.stdout}",
243+
file=sys.stderr,
244+
)
245+
except Exception as exc: # pragma: no cover - gh may be missing
246+
print(
247+
f"[evolution-cron] warning: could not create label {name}: {exc}",
248+
file=sys.stderr,
249+
)
250+
return created
251+
252+
170253
def main(argv: list[str]) -> int:
171254
dry_run = "--dry-run" in argv
172255
positional = [a for a in argv[1:] if not a.startswith("--")]
@@ -178,6 +261,10 @@ def main(argv: list[str]) -> int:
178261
# the process when needed, so nobody has to launch us with the right python.
179262
_ensure_venv_python(repo_root, argv)
180263

264+
# Bootstrap the GitHub labels used by every evolution skill. Missing labels
265+
# make issue/PR operations fail silently on fresh forks (#468).
266+
label_ensured = [] if dry_run else _ensure_evolution_labels(repo_root)
267+
181268
src_dir = Path(positional[0]) if positional else repo_root / "cron" / "evolution"
182269
if not src_dir.is_dir():
183270
print(f"[evolution-cron] no evolution cron dir at {src_dir}", file=sys.stderr)
@@ -221,7 +308,11 @@ def main(argv: list[str]) -> int:
221308
# executes the copy in HERMES_HOME/scripts; without this refresh the
222309
# installed script stays frozen at whatever version existed when the
223310
# job was first registered.
224-
if spec.get("no_agent") and str(spec.get("script") or "").strip() and not dry_run:
311+
if (
312+
spec.get("no_agent")
313+
and str(spec.get("script") or "").strip()
314+
and not dry_run
315+
):
225316
_install_script(repo_root, str(spec["script"]).strip())
226317

227318
schedule = str(spec.get("schedule") or "").strip()
@@ -252,7 +343,9 @@ def main(argv: list[str]) -> int:
252343
continue
253344
changes: dict = {}
254345
want_sched = parse_schedule(schedule).get("display", schedule)
255-
cur_sched = (cur.get("schedule") or {}).get("display") or cur.get("schedule_display")
346+
cur_sched = (cur.get("schedule") or {}).get("display") or cur.get(
347+
"schedule_display"
348+
)
256349
if want_sched != cur_sched:
257350
changes["schedule"] = schedule
258351
if not no_agent:
@@ -325,7 +418,7 @@ def main(argv: list[str]) -> int:
325418
print(
326419
f"[evolution-cron] {verb}={len(created)} reconciled={len(updated)} "
327420
f"skipped(unchanged)={len(skipped)} failed={len(failed)} "
328-
f"helper_scripts_installed={len(helper_scripts)}"
421+
f"helper_scripts_installed={len(helper_scripts)} labels_ensured={len(label_ensured)}"
329422
)
330423
for name, jid in created:
331424
print(f" + {name} ({jid})")

tests/scripts/test_register_evolution_cron.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,7 @@ def test_no_agent_without_script_fails(self, tmp_path, monkeypatch):
125125
src_dir = tmp_path / "cron-src"
126126
src_dir.mkdir()
127127
(src_dir / "bad.yaml").write_text(
128-
"name: evolution-bad\n"
129-
'schedule: "0 9 * * *"\n'
130-
"no_agent: true\n"
131-
'prompt: "x"\n'
128+
'name: evolution-bad\nschedule: "0 9 * * *"\nno_agent: true\nprompt: "x"\n'
132129
)
133130
home = tmp_path / "hermes-home"
134131
home.mkdir()
@@ -221,8 +218,9 @@ def _wire(self, mod, jobs_mod, monkeypatch, tmp_path, existing):
221218
monkeypatch.setattr(
222219
jobs_mod,
223220
"update_job",
224-
lambda job_id, updates: calls.update(job_id=job_id, updates=updates)
225-
or {**existing, **updates},
221+
lambda job_id, updates: (
222+
calls.update(job_id=job_id, updates=updates) or {**existing, **updates}
223+
),
226224
)
227225
return calls
228226

@@ -348,3 +346,59 @@ def test_no_family_returns_empty(self, tmp_path, monkeypatch):
348346
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
349347

350348
assert mod._install_evolution_helpers(repo) == []
349+
350+
351+
class TestEnsureEvolutionLabels:
352+
"""``_ensure_evolution_labels`` idempotently creates the GitHub labels used
353+
by every evolution skill. It must succeed when labels already exist and
354+
surface (but not die on) genuine gh failures."""
355+
356+
def test_dry_run_lists_all_labels(self, tmp_path):
357+
mod = _import_module()
358+
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=True)
359+
assert set(ensured) == {name for name, _, _ in mod._EVOLUTION_LABELS}
360+
361+
def test_already_existing_label_is_confirmed(self, tmp_path, monkeypatch):
362+
mod = _import_module()
363+
calls = []
364+
365+
def fake_run(cmd, **kwargs):
366+
class _Result:
367+
returncode = 1
368+
stderr = f"HTTP 422: {cmd[3]} already exists"
369+
stdout = ""
370+
371+
calls.append(cmd)
372+
return _Result()
373+
374+
monkeypatch.setattr("subprocess.run", fake_run)
375+
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=False)
376+
assert set(ensured) == {name for name, _, _ in mod._EVOLUTION_LABELS}
377+
assert len(calls) == len(mod._EVOLUTION_LABELS)
378+
# cmd layout: gh label create <name> --repo <repo> --color <c> --description <d>
379+
assert all(c[0] == "gh" and c[1] == "label" and c[2] == "create" for c in calls)
380+
assert {c[3] for c in calls} == {name for name, _, _ in mod._EVOLUTION_LABELS}
381+
assert all(
382+
"--repo" in c and "--color" in c and "--description" in c for c in calls
383+
)
384+
385+
def test_real_failure_is_warning_not_fatal(self, tmp_path, monkeypatch, capsys):
386+
mod = _import_module()
387+
bad_label = None
388+
389+
def fake_run(cmd, **kwargs):
390+
class _Result:
391+
returncode = 1
392+
stderr = "HTTP 403: Forbidden"
393+
stdout = ""
394+
395+
nonlocal bad_label
396+
bad_label = cmd[3]
397+
return _Result()
398+
399+
monkeypatch.setattr("subprocess.run", fake_run)
400+
ensured = mod._ensure_evolution_labels(tmp_path, dry_run=False)
401+
assert ensured == []
402+
captured = capsys.readouterr()
403+
assert "warning: could not create label" in captured.err
404+
assert bad_label in captured.err

0 commit comments

Comments
 (0)