Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ jobs:
run: zig run scripts/lint.zig
- name: scripts/zb (build lock)
run: sh scripts/test-zb.sh
- name: scripts/check-kickoff
run: python3 scripts/test-check-kickoff.py

test:
name: Tests (${{ matrix.name }})
Expand Down
13 changes: 10 additions & 3 deletions docs/AGENT_WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ take it; nothing here depends on which model.
branch (`<area>/<slug>`).
3. The `running:*` label is the lock: the first claim wins. `agent-work.yml` removes a second
`running:*` label from the issue, with a comment, and the worker that lost picks another.
4. The worker opens a draft PR on its first push saying `Part of #N`, and works as
4. The worker opens a draft PR on its first push saying `Part of fizzyedit/work#N`, and works as
`CONTRIBUTING.md` says.
5. A claim with no live PR after 24 hours is released: the daily sweep removes `running:*`,
comments, and the issue is available again (`agent` stays). A live PR is an open PR in this
Expand All @@ -25,7 +25,7 @@ removes it, on issues and on pull requests, unless its latest application was by
maintain/admin permission (a failed permission lookup counts as no permission). The daily
sweep does the same for any `merge-ok` the label-time run missed. Nothing auto-merges.

## Kickoff template (the issue body, 1,200 characters at most)
## Kickoff template (the issue body, 1,600 characters at most)

```
Task: <what a user or plugin author sees when it's done>
Expand All @@ -34,11 +34,18 @@ Builds on: <PRs/branches, or main>
Branch: <area>/<slug>
Files/area: <paths to start in>
Gates: scripts/check-fmt.sh; scripts/zb build, scripts/zb build test, scripts/zb build test-integration, scripts/zb build check-web, scripts/zb build test-sdk-version (read "N/N steps succeeded")
Rules: follow AGENTS.md, CONTRIBUTING.md, docs/STYLE.md and the AGENTS.md of each directory touched. One PR, <=800 lines outside tests/docs. If the SDK fingerprint moves, record it and leave sdk_version alone.
Rules: follow AGENTS.md, CONTRIBUTING.md, docs/STYLE.md and the AGENTS.md of each directory touched. One PR, <=800 lines outside tests/docs. PRs say "Part of fizzyedit/work#N". If the SDK fingerprint moves, record it and leave sdk_version alone.
Done: draft PR on first push; gates pass; PR body says what was run and how verified (test, tape, measurement).
Notes: <only what code and plans don't say>
```

Before assigning a worker, run `scripts/check-kickoff <file|issue-number>` (issue numbers
are read from `fizzyedit/work` through `gh`). It reports every missing or empty template
field, bodies over 1,600 characters, invalid branch names, `scripts/zb zig build` in Gates,
and Rules without a `fizzyedit/work#N` reference. It accepts the template above and GitHub
issue-form `### Field` headings. The template is the source of the required field list.
Run `python3 scripts/test-check-kickoff.py` for the validator tests.

## Trust boundary

- Labels are applied only by maintainers and triagers (triage access or higher). Anyone else cannot make
Expand Down
79 changes: 79 additions & 0 deletions scripts/check-kickoff
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Validate kickoff files or fizzyedit/work issues against the documented template."""

import re
import subprocess
import sys
from pathlib import Path

TEMPLATE = Path(__file__).resolve().parents[1] / "docs/AGENT_WORK.md"
MAX_CHARACTERS = 1600


def template_fields():
text = TEMPLATE.read_text(encoding="utf-8")
section = text.split("## Kickoff template", 1)[1].split("```", 2)[1]
fields = re.findall(r"^([^:\n]+):", section, re.MULTILINE)
if not fields or len(fields) != len(set(fields)):
raise ValueError("kickoff template has no fields or duplicate fields")
return fields


def validate(body, fields):
values = {}
current = None
for line in body.splitlines():
# GitHub issue forms emit headings; pasted kickoffs use Label: value.
heading = re.fullmatch(r"### (.+?)\s*", line)
label, separator, value = line.partition(":")
name = heading[1] if heading else label
if name in fields and (heading or separator):
current = name
values.setdefault(name, []).append("" if heading else value)
elif current:
values[current].append(line)
values = {name: "\n".join(lines).strip() for name, lines in values.items()}
failures = []
for name in fields:
if not values.get(name) or values[name] == "_No response_":
failures.append(f"{name}: missing field or empty value")
if len(body) > MAX_CHARACTERS:
failures.append(f"Body: {len(body)} characters exceeds {MAX_CHARACTERS}")
if values.get("Branch") and not re.fullmatch(r"[a-z0-9-]+/[a-z0-9._-]+", values["Branch"]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject branch names that Git cannot create

The custom regex accepts names such as ci/.., ci/.hidden, ci/foo., and ci/foo.lock, even though git check-ref-format --branch rejects each as an invalid branch name. A kickoff using one of these values therefore passes validation but leaves the worker unable to create the prescribed branch; validate with Git's ref rules or add the missing component restrictions.

Useful? React with 👍 / 👎.

failures.append("Branch: must match ^[a-z0-9-]+/[a-z0-9._-]+$")
if re.search(r"scripts/zb\s+zig\s+build", values.get("Gates", "")):
failures.append("Gates: use scripts/zb build, not scripts/zb zig build")
if not re.search(r"fizzyedit/work#(?:[1-9][0-9]*|N)\b", values.get("Rules", "")):
failures.append("Rules: must require a PR reference to fizzyedit/work#N")
return failures


def main():
if len(sys.argv) != 2:
print("usage: scripts/check-kickoff <file|issue-number>", file=sys.stderr)
return 2
source = sys.argv[1]
try:
fields = template_fields()
if re.fullmatch(r"[1-9][0-9]*", source):
body = subprocess.run(
["gh", "issue", "view", source, "--repo", "fizzyedit/work", "--json", "body", "--jq", ".body"],
check=True, capture_output=True, text=True,
).stdout.removesuffix("\n")
else:
body = Path(source).read_text(encoding="utf-8")
failures = validate(body, fields)
except (OSError, ValueError, IndexError, subprocess.CalledProcessError) as error:
print(f"check-kickoff: {source}: {error}", file=sys.stderr)
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
print(error.stderr.strip(), file=sys.stderr)
return 2
for failure in failures:
print(f"{source}: {failure}", file=sys.stderr)
if not failures:
print(f"{source}: kickoff passes")
return bool(failures)


if __name__ == "__main__":
sys.exit(main())
103 changes: 103 additions & 0 deletions scripts/test-check-kickoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Exercise kickoff validation and its file and issue-number interfaces."""

import importlib.machinery
import importlib.util
import os
from pathlib import Path
import subprocess
import tempfile
import unittest

SCRIPT = Path(__file__).with_name("check-kickoff")
loader = importlib.machinery.SourceFileLoader("kickoff", str(SCRIPT))
spec = importlib.util.spec_from_loader(loader.name, loader)
kickoff = importlib.util.module_from_spec(spec)
loader.exec_module(kickoff)


class KickoffTests(unittest.TestCase):
def setUp(self):
self.fields = kickoff.template_fields()
self.values = {name: "example" for name in self.fields}
self.values.update(Branch="ci/check-kickoff", Gates="scripts/zb build",
Rules="PRs say Part of fizzyedit/work#42")

def body(self):
return "\n".join(f"{name}: {value}" for name, value in self.values.items())

def test_valid_and_form(self):
self.assertEqual(kickoff.validate(self.body(), self.fields), [])
form = "\n\n".join(f"### {name}\n\n{value}" for name, value in self.values.items())
self.assertEqual(kickoff.validate(form, self.fields), [])

def test_each_missing_or_empty_field(self):
for name in self.fields:
for value in (None, "", "_No response_"):
with self.subTest(name=name, value=value):
saved = self.values.pop(name)
if value is not None:
self.values[name] = value
failures = kickoff.validate(self.body(), self.fields)
self.assertTrue(any(f.startswith(name + ":") for f in failures))
self.values[name] = saved

def test_all_failures_reported(self):
self.values.update(Branch="Bad branch", Gates="scripts/zb zig build", Rules="no reference")
failures = kickoff.validate(self.body() + "x" * 1600, self.fields)
self.assertEqual({f.split(":")[0] for f in failures}, {"Branch", "Gates", "Rules", "Body"})

def test_length_boundary_and_unicode(self):
body = self.body() + "\né" * 10
body += "x" * (1600 - len(body))
self.assertEqual(kickoff.validate(body, self.fields), [])
self.assertTrue(any(f.startswith("Body:") for f in kickoff.validate(body + "x", self.fields)))

def test_branch_and_multiline_rules(self):
for branch in ("ci/a._-9", "ci/A", "ci/a/b", "ci/", "ci/a extra"):
self.values["Branch"] = branch
failures = kickoff.validate(self.body(), self.fields)
self.assertEqual(bool(failures), branch != "ci/a._-9")
self.values["Branch"] = "ci/check"
self.values["Rules"] = '\n- PRs say "Part of fizzyedit/work#N".'
self.assertEqual(kickoff.validate(self.body(), self.fields), [])

def test_template_fields_are_live(self):
with tempfile.TemporaryDirectory() as directory:
template = Path(directory) / "AGENT_WORK.md"
template.write_text("## Kickoff template\n```\nExtra: value\n```\n")
original = kickoff.TEMPLATE
try:
kickoff.TEMPLATE = template
self.assertEqual(kickoff.template_fields(), ["Extra"])
self.assertIn("Extra: missing field or empty value",
kickoff.validate("", kickoff.template_fields()))
finally:
kickoff.TEMPLATE = original

def test_cli_file_and_issue(self):
with tempfile.TemporaryDirectory() as directory:
body = Path(directory) / "body"
body.write_text(self.body())
result = subprocess.run([str(SCRIPT), str(body)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
gh = Path(directory) / "gh"
gh.write_text('#!/bin/sh\n[ "$*" = "issue view 7 --repo fizzyedit/work --json body --jq .body" ] || exit 9\ncat "$KICKOFF_BODY"\n')
gh.chmod(0o755)
env = dict(os.environ, PATH=directory + os.pathsep + os.environ["PATH"], KICKOFF_BODY=str(body))
result = subprocess.run([str(SCRIPT), "7"], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
body.write_text("Task: incomplete")
result = subprocess.run([str(SCRIPT), str(body)], capture_output=True, text=True)
self.assertEqual(result.returncode, 1)
self.assertIn("Branch:", result.stderr)
gh.write_text('#!/bin/sh\nprintf "lookup failed\\n" >&2\nexit 1\n')
result = subprocess.run([str(SCRIPT), "7"], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertIn("lookup failed", result.stderr)
result = subprocess.run([str(SCRIPT), str(body) + ".missing"], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)


if __name__ == "__main__":
unittest.main()
Loading