Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# The pre-commit hook stack enforces LF line endings. Keep checkout behavior
# aligned across Windows, macOS, and Linux so `pre-commit run --all-files` does
# not rewrite the working tree on Windows clones with global autocrlf enabled.
* text=auto eol=lf

*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
9 changes: 9 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@
<!-- Required for UI change. Delete this section for non-UI PRs. -->


<!--
## Local Beads

Optional opt-in. Only add this section if your team uses a local Beads queue
(see docs/BEADS.md). Uncomment the heading and replace this comment with the
Bead id. GitHub issue linkage below is still required either way.
-->


## Linked issue

Closes #
3 changes: 2 additions & 1 deletion .github/scripts/check_aspirational_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from pathlib import Path

INVARIANTS_DOC = Path("docs/INVARIANTS.md")
GITHUB_API_ERRORS = (urllib.error.URLError, TimeoutError, json.JSONDecodeError)

# A marker line *starts* with one or two asterisks immediately followed by
# `Aspirational` and a word boundary. Avoids picking up mid-sentence prose
Expand Down Expand Up @@ -88,7 +89,7 @@ def _issue_state(repo: str, number: str, token: str) -> str | None:
try:
with urllib.request.urlopen(req, timeout=5) as response: # noqa: S310
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.URLError, TimeoutError, json.JSONDecodeError:
except GITHUB_API_ERRORS:
return None
state = payload.get("state")
return state if isinstance(state, str) else None
Expand Down
24 changes: 21 additions & 3 deletions .github/scripts/check_pin_freshness.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def _load_pin_module() -> ModuleType:

_pins = _load_pin_module()
_API_BASE = "https://api.github.com"
GITHUB_API_ERRORS = (urllib.error.URLError, TimeoutError, json.JSONDecodeError)


def _fetch_json(url: str, token: str) -> dict[str, object] | None:
Expand All @@ -104,11 +105,27 @@ def _fetch_json(url: str, token: str) -> dict[str, object] | None:
try:
with urllib.request.urlopen(req, timeout=10) as response: # noqa: S310
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.URLError, TimeoutError, json.JSONDecodeError:
except GITHUB_API_ERRORS:
return None
return payload if isinstance(payload, dict) else None


def _action_repo(action: str) -> str:
"""Return `owner/repo` for an action string that may carry a sub-path.

Action references can be `owner/repo` or `owner/repo/path/to/subaction`
(e.g. `github/codeql-action/init`). Only the first two slash-segments
name the GitHub repository — the trailing segments are paths within
the repo's tree (containing per-subaction `action.yml` files). The
REST API endpoint we hit (`/repos/<owner>/<repo>/git/...`) only
accepts the `owner/repo` form; passing the full action string would
404 on every sub-path action and surface as a false-positive
"tag no longer resolves" finding.
"""
parts = action.split("/", 2)
return "/".join(parts[:2]) if len(parts) >= 2 else action


def _resolve_tag_sha(action: str, tag: str, token: str) -> str | None:
"""Return the commit SHA the tag points at, or None on missing/error.

Expand All @@ -117,7 +134,8 @@ def _resolve_tag_sha(action: str, tag: str, token: str) -> str | None:
commit. Lightweight tags resolve in one GET (the ref's `object.sha`
is the commit directly).
"""
ref = _fetch_json(f"{_API_BASE}/repos/{action}/git/refs/tags/{tag}", token)
repo = _action_repo(action)
ref = _fetch_json(f"{_API_BASE}/repos/{repo}/git/refs/tags/{tag}", token)
if ref is None:
return None
obj = ref.get("object")
Expand All @@ -131,7 +149,7 @@ def _resolve_tag_sha(action: str, tag: str, token: str) -> str | None:
return obj_sha
if obj_type == "tag":
# Annotated tag — dereference to the commit it points at.
annotated = _fetch_json(f"{_API_BASE}/repos/{action}/git/tags/{obj_sha}", token)
annotated = _fetch_json(f"{_API_BASE}/repos/{repo}/git/tags/{obj_sha}", token)
if annotated is None:
return None
inner = annotated.get("object")
Expand Down
4 changes: 3 additions & 1 deletion .github/scripts/check_tests_present.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import sys
from pathlib import Path

EVENT_READ_ERRORS = (OSError, json.JSONDecodeError)

# Prefixes that declare a behaviour change → tests required.
BLOCKING_PREFIXES: frozenset[str] = frozenset({"feat", "fix"})

Expand All @@ -59,7 +61,7 @@ def pr_title_from_event() -> str | None:
return None
try:
data = json.loads(Path(event_path).read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
except EVENT_READ_ERRORS:
return None
pr = data.get("pull_request")
if not isinstance(pr, dict):
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/check_version_bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
PYPROJECT = Path("pyproject.toml")
UV_LOCK = Path("uv.lock")
PACKAGE_NAME = "harness-python-react"
EVENT_READ_ERRORS = (OSError, json.JSONDecodeError)

# Match the project's self-version block in uv.lock:
#
Expand Down Expand Up @@ -105,7 +106,7 @@ def pr_title_from_event() -> str | None:
return None
try:
data = json.loads(Path(event_path).read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
except EVENT_READ_ERRORS:
return None
pr = data.get("pull_request")
if not isinstance(pr, dict):
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -44,7 +44,7 @@ jobs:
# Pure in-process tests — completes fast so PR authors get quick feedback.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -57,7 +57,7 @@ jobs:
# Enforces [tool.coverage.report].fail_under from pyproject.toml (75%).
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -69,7 +69,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -84,7 +84,7 @@ jobs:
# secret past the first defence layer.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand Down Expand Up @@ -218,7 +218,7 @@ jobs:
# actual workflow jobs on disk.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand All @@ -234,7 +234,7 @@ jobs:
# while PR titles fail in CI (or vice versa).
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
37 changes: 22 additions & 15 deletions .github/workflows/eval-nightly.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
# Eval harness nightly — disabled-by-default.
#
# This workflow runs the golden QA dataset against the agent / LLM loop. It
# is `workflow_dispatch`-only by default to prevent accidental LLM API
# spend. To enable nightly runs:
# This workflow runs the golden QA dataset + worked-pattern cases against a
# real Azure OpenAI deployment. It is `workflow_dispatch`-only by default
# to prevent accidental API spend. To enable nightly runs:
#
# 1. Set the Azure OpenAI secrets in repo settings:
# AZURE_OPENAI_ENDPOINT e.g. https://my.openai.azure.com
# AZURE_OPENAI_API_KEY the Azure resource key
# AZURE_OPENAI_DEPLOYMENT deployment name, e.g. gpt-4o-mini
# AZURE_OPENAI_API_VERSION optional, defaults to 2024-10-21
#
# 1. Set the LLM secrets in repo settings (LLM_API_KEY at minimum;
# LLM_BASE_URL / LLM_MODEL / LLM_PROVIDER if your judge differs from
# OpenAI defaults).
# 2. Replace the `on:` block below with:
#
# on:
# schedule:
# - cron: "0 6 * * *" # daily 06:00 UTC
# workflow_dispatch:
#
# 3. Add the `eval-nightly.yml` to EXEMPT_WORKFLOWS in
# `.github/scripts/check_required_contexts.py` if it's not already
# there (it is, by default — scheduled runs never gate PRs).
# 3. Confirm `eval-nightly.yml` is in EXEMPT_WORKFLOWS in
# `.github/scripts/check_required_contexts.py` (it is, by default
# — scheduled runs never gate PRs).
#
# When the Azure secrets are absent, eval/test_golden_patterns.py is
# skipped via pytestmark — the toy eval/test_golden_qa.py case still
# runs as a smoke check on the runner mechanics.
#
# See docs/EVAL_HARNESS.md for the full setup story.

Expand All @@ -39,15 +46,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ inputs.python_version || '3.14' }}
- run: uv sync --frozen --extra dev
- run: uv sync --frozen --extra dev --extra eval
- name: Run pytest eval/
env:
LLM_PROVIDER: ${{ secrets.LLM_PROVIDER }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
LLM_MODEL: ${{ secrets.LLM_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }}
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
run: uv run pytest eval/ -v
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
# annotation when a new release lands and you've reviewed the diff.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
.claude/bash-log.txt
.claude/worktrees/

# Optional local Beads queue state
.beads/
beads/

# Node / Frontend
node_modules/
frontend/dist/
Expand Down
37 changes: 31 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,51 @@ The subject is **lowercase** after the colon. Title Case prose (`Add the thing`)

1. Open the issue first. Use a feature/bug template; fill every section.
2. Branch off `develop` with the matching name.
3. Land one logical change per PR. Stack PRs if the work is naturally split.
4. The PR template asks five things — answer each (`None` is valid where applicable):
3. If your team uses Beads, mirror or claim the linked issue in the local Beads queue after the issue exists. Beads track local ready/blocked execution only; GitHub Issues remain canonical for scope, discussion, PR linkage, and closure.
4. Land one logical change per PR. Stack PRs if the work is naturally split.
5. The PR template asks five things — answer each (`None` is valid where applicable):
- **What & why** (1–3 lines)
- **Test plan** (checkbox list; CI covers most of it)
- **Invariants affected** — cite numbered rules from `docs/INVARIANTS.md`
- **New deps / actions / external surface** (anchor for supply-chain review)
- **Screenshots** (UI changes only)
5. Wait for green CI + a code-owner review before merging.
6. Wait for green CI + a code-owner review before merging.

### Solo-owner merge policy

This repo runs with a single code owner (`* @constk` in `CODEOWNERS`). GitHub forbids a PR author from approving their own PR, so the standard "1 code-owner review" gate cannot be satisfied without an admin override. While in this state, the **intended workflow is**:
> **Transitional — only while this repo has a single code owner.** Standard practice is a code-owner review on every PR. The flow below exists because GitHub forbids self-approval, so a single-owner repo cannot satisfy the "1 code-owner review" gate any other way. The exemption is **removed** the moment a second collaborator with merge rights joins.

This repo currently runs with a single code owner (`* @constk` in `CODEOWNERS`). While in this state, the intended merge command is:

```sh
gh pr merge <N> --admin --squash --delete-branch
```

…for `feat:` / `fix:` / `chore:` PRs, and `--admin --merge` (preserves history) for `release:` PRs. The `enforce_admins: false` line in `.github/branch-protection/{develop,main}.json` is the documented escape hatch — admin merge here is the policy, not a deviation from it.
…for `feat:` / `fix:` / `chore:` PRs, and `--admin --merge` (preserves history) for `release:` PRs. The `enforce_admins: false` line in `.github/branch-protection/{develop,main}.json` is the documented escape hatch — admin merge here is the documented single-owner workaround, not bypass of the gates (every required status check still has to pass).

**When the exemption ends.** As soon as a second collaborator with merge rights is onboarded:

1. Drop the `--admin` flag from the merge command and adopt standard PR review.
2. Remove this entire subsection.
3. Update `CODEOWNERS` to add the new collaborator.
4. Flip `enforce_admins` to `true` in the branch-protection JSON for both branches. Leaving it `false` would keep the admin-bypass door open even after the single-owner workaround is no longer needed — defeats the point of removing the workaround.

All four changes land in a single PR.

## Line endings (Windows clones)

This repo enforces LF line endings via `.gitattributes` (`* text=auto eol=lf`)
and the pre-commit hygiene hook. If you cloned on Windows with
`core.autocrlf=true`, the first checkout after pulling the `.gitattributes`
change can leave the working tree out of sync with the index. Renormalise
once:

```sh
git add --renormalize .
git commit -m "chore: renormalise line endings"
```

When a second collaborator joins, drop the `--admin` flag and adopt standard PR review. Update this section + `CODEOWNERS` in the same PR.
After that, day-to-day work is unaffected.

## Local pre-push gate

Expand Down
Loading
Loading