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
19 changes: 14 additions & 5 deletions .claude/skills/security-cve-allocate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,20 @@ user to confirm. Numbered items:
allocated CVE ID (for the airflow-s adopter, this resolves to
`https://cveprocess.apache.org/cve5/CVE-YYYY-NNNNN`). Patch
only this one field; do not touch the rest of the body. Use
the `security-issue-sync` skill's body-field-surgery recipe —
read the full body, replace the *CVE tool link* field's value
between its `### CVE tool link\n\n` header and the next
`### ` or end-of-body, write back via
`gh issue edit --body-file`.
the
[`github-body-field`](../../../tools/github-body-field/README.md)
tool — it reads, parses, and rewrites just the targeted
`### CVE tool link` section without bringing the issue body
into agent context:
```bash
uv run --directory <framework>/tools/github-body-field \
body-field --repo <tracker> set <N> \
--field "CVE tool link" \
--value "https://cveprocess.apache.org/cve5/CVE-YYYY-NNNNN"
```
Exit code `0` means written; exit `3` means the heading was
absent — fall through to manual edit only if that fires
(legacy trackers from before the issue template existed).
2. **Add the `cve allocated` label.** `gh issue edit <N> --repo
<tracker> --add-label "cve allocated"`.
3. **Append a `CVE allocated` entry to the tracker's
Expand Down
31 changes: 31 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,37 @@ repos:
files: ^tools/jira/(src|tests|pyproject\.toml|bridge\.groovy)
pass_filenames: false

# Project-local checks for the GitHub-body-field tool at
# `tools/github-body-field/`. Lets the security-sync skills update
# one `### Field` section of an issue body without bringing the
# body into agent context.
- repo: local
hooks:
- id: github-body-field-ruff-check
name: ruff check (github-body-field)
language: system
entry: uv run --directory tools/github-body-field ruff check
files: ^tools/github-body-field/(src|tests|pyproject\.toml)
pass_filenames: false
- id: github-body-field-ruff-format
name: ruff format (github-body-field)
language: system
entry: uv run --directory tools/github-body-field ruff format --check
files: ^tools/github-body-field/(src|tests|pyproject\.toml)
pass_filenames: false
- id: github-body-field-mypy
name: mypy (github-body-field)
language: system
entry: uv run --directory tools/github-body-field mypy
files: ^tools/github-body-field/(src|tests|pyproject\.toml)
pass_filenames: false
- id: github-body-field-pytest
name: pytest (github-body-field)
language: system
entry: uv run --directory tools/github-body-field pytest
files: ^tools/github-body-field/(src|tests|pyproject\.toml)
pass_filenames: false

# Validate `.claude/skills/**`, every `tools/<name>/README.md`, and the
# `docs/labels-and-capabilities.md` taxonomy via the
# `skill-and-tool-validate` CLI. Re-fires on validator-source changes so
Expand Down
1 change: 1 addition & 0 deletions docs/labels-and-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Tools under [`tools/`](../tools/). Tools with two values (separated by
| [`tools/dev`](../tools/dev/) | `capability:setup` | Framework dev-loop helpers |
| [`tools/forwarder-relay`](../tools/forwarder-relay/) | `capability:setup` | Adapter contract for inbound-relay backends (ASF Security relay, huntr.com, HackerOne triagers). Pure interface spec; adapters declare detection + credit-extraction + reporter-addressing rules. |
| [`tools/github`](../tools/github/) | `capability:setup` | GitHub REST / GraphQL substrate (called by every lifecycle phase — pure substrate, no single phase) |
| [`tools/github-body-field`](../tools/github-body-field/) | `capability:setup` | Read or rewrite one `### Field` section of a GitHub issue body without bringing the body into agent context — substrate helper for the security-sync skills |
| [`tools/gmail`](../tools/gmail/) | `capability:setup` | Gmail API substrate |
| [`tools/jira`](../tools/jira/) | `capability:setup` | JIRA REST substrate (read-only today; write subcommands tracked in [#301](https://github.com/apache/airflow-steward/issues/301)) |
| [`tools/mail-archive`](../tools/mail-archive/) | `capability:setup` | Adapter contract for public mail-archive backends (PonyMail, Hyperkitty, Discourse, Google Groups, GitHub Discussions). Pure interface spec. |
Expand Down
103 changes: 103 additions & 0 deletions tools/github-body-field/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*

- [github-body-field](#github-body-field)
- [Why](#why)
- [Invocation](#invocation)
- [`get <issue> --field "<name>"`](#get-issue---field-name)
- [`set <issue> --field "<name>" --value "<v>" | --value-file <path>`](#set-issue---field-name---value-v----value-file-path)
- [`list <issue>`](#list-issue)
- [Body format assumptions](#body-format-assumptions)
- [Failure modes](#failure-modes)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->

# github-body-field

**Capability:** capability:setup

Read or rewrite a single `### Field` section of a GitHub issue
body **without bringing the body into agent context**.

## Why

Tracker issues in `<tracker>` carry a structured body — a list of
`### <FieldName>` sections (e.g. *CVE tool link*, *Reporter
credited as*, *Public advisory URL*, *Short public summary for
publish*). The sync workflow PATCHes one of these fields at a time;
the legacy recipe was *read the full 10–15 KB body into agent
context, regex-edit one field, write it back*. That spent ~5 K
tokens per single-field flip, in addition to looping
reporter-supplied content (often the most sensitive content on
the tracker) through the agent for no reason.

This tool does the read / parse / replace / push in a subprocess.
Only the diff summary lands on the agent's stdout. The body never
crosses the boundary.

## Invocation

```bash
uv run --directory tools/github-body-field body-field --repo <owner>/<repo> <subcommand> ...
```

The `--repo` argument is forwarded verbatim to `gh`; omit it when
the current working directory is already inside the right clone.

### `get <issue> --field "<name>"`

Print the field's value to stdout (with a trailing newline added if
the value did not already end with one — convenient for shell
pipelines). Exit 3 if the field is absent or appears more than once.

### `set <issue> --field "<name>" --value "<v>" | --value-file <path>`

Replace the field's value in place. Either `--value` (single argv
string) or `--value-file` (any path; `-` reads stdin) must be
given. The replacement preserves the original heading line
byte-exact and re-uses the spacer-blank-line convention the original
section had.

`--dry-run` prints the diff summary to stderr but skips the push.

Exit 0 when written (or when the new value matched the old, in
which case stderr says `unchanged: ...` and no API call happens);
exit 3 when the field is absent or duplicated.

### `list <issue>`

Print every field heading present in the body, one per line, in
document order. With `--json`, emit a JSON array instead — useful
when an orchestrator wants to programmatically check what fields a
legacy tracker already has populated.

## Body format assumptions

The parser is a small state machine that:

- treats only top-level `^### <Name>$` lines as field headings;
- tracks fenced code blocks (`` ``` `` and `~~~`) so a literal
`### foo` inside a shell snippet never false-matches as a
heading;
- preserves the original body byte-exact when no change is needed
(idempotent rewrite — a `set` of the same value triggers no
API write).

If the body does not use the `### <FieldName>` convention at all
(legacy trackers from before the issue template was standardised),
`set` will report `field not found` and refuse to mutate. Fix the
tracker body first or fall through to the original "edit by hand"
path; this tool intentionally does not invent headings.

## Failure modes

| Exit | Meaning |
|---|---|
| 0 | Success (or `set` was a no-op because new value matched current). |
| 2 | CLI argument error (e.g. both `--value` and `--value-file` given). |
| 3 | Field heading not found, or matched more than once. The body is untouched. |
| other | `gh` returned non-zero; the underlying `gh` stderr is forwarded. |
88 changes: 88 additions & 0 deletions tools/github-body-field/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "github-body-field"
version = "0.1.0"
description = "Read or rewrite a single `### Field` section of a GitHub issue body without bringing the body into agent context."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
# Runtime is stdlib-only; the script shells out to `gh` for GitHub
# access. Keeping the runtime closed lets `uv run` resolve in ms.
dependencies = []

[project.scripts]
body-field = "github_body_field:main"

[dependency-groups]
dev = [
"mypy>=2.1.0",
"pytest>=8.0",
"ruff>=0.15.14",
]

[tool.hatch.build.targets.wheel]
packages = ["src/github_body_field"]

[tool.ruff]
line-length = 110
target-version = "py311"
src = ["src", "tests"]

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"RUF", # ruff-specific
]
ignore = [
"E501", # line-too-long — the 110-char limit above is already generous
]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B", "SIM"] # test clarity beats these

[tool.mypy]
python_version = "3.11"
files = ["src", "tests"]
warn_unused_ignores = true
warn_redundant_casts = true
warn_unreachable = true
check_untyped_defs = true
no_implicit_optional = true
disallow_untyped_defs = true
disallow_incomplete_defs = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false

[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra -q"
testpaths = ["tests"]
31 changes: 31 additions & 0 deletions tools/github-body-field/src/github_body_field/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from github_body_field.cli import main
from github_body_field.parser import (
FieldNotFoundError,
extract_field,
list_fields,
replace_field,
)

__all__ = [
"FieldNotFoundError",
"extract_field",
"list_fields",
"main",
"replace_field",
]
Loading
Loading