| # 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. |
| """CLI front-end for the body-field parser. |
| |
| Operates on GitHub issue bodies via the ``gh`` CLI. The whole point |
| of the tool is that the body never crosses the agent boundary on a |
| ``set`` — the read, parse, replace, and write all happen in this |
| subprocess, and the agent only sees the diff summary on stdout. |
| """ |
| |
| from __future__ import annotations |
| |
| import argparse |
| import json |
| import subprocess |
| import sys |
| from collections.abc import Sequence |
| from pathlib import Path |
| |
| from github_body_field.parser import ( |
| FieldNotFoundError, |
| extract_field, |
| list_fields, |
| replace_field, |
| ) |
| |
| |
| def _gh_get_body(issue: str, repo: str | None) -> str: |
| cmd = ["gh", "issue", "view", issue, "--json", "body", "--jq", ".body"] |
| if repo: |
| cmd.extend(["--repo", repo]) |
| result = subprocess.run(cmd, capture_output=True, text=True, check=False) |
| if result.returncode != 0: |
| sys.stderr.write(result.stderr) |
| raise SystemExit(result.returncode or 1) |
| # `gh --jq .body` returns the body with a trailing newline appended |
| # by gh's JSON-to-text serialiser; strip exactly one to keep the |
| # round-trip byte-exact. |
| body = result.stdout |
| if body.endswith("\n"): |
| body = body[:-1] |
| return body |
| |
| |
| def _gh_set_body(issue: str, repo: str | None, body: str) -> None: |
| # `gh issue edit --body-file -` reads from stdin and avoids the |
| # tmpfile dance plus any shell-quoting hazards. |
| cmd = ["gh", "issue", "edit", issue, "--body-file", "-"] |
| if repo: |
| cmd.extend(["--repo", repo]) |
| result = subprocess.run(cmd, input=body, text=True, capture_output=True, check=False) |
| if result.returncode != 0: |
| sys.stderr.write(result.stderr) |
| raise SystemExit(result.returncode or 1) |
| |
| |
| def _read_value(args: argparse.Namespace) -> str: |
| if args.value is not None and args.value_file is not None: |
| sys.stderr.write("error: pass either --value or --value-file, not both\n") |
| raise SystemExit(2) |
| if args.value is not None: |
| return args.value |
| if args.value_file is not None: |
| if args.value_file == "-": |
| return sys.stdin.read() |
| return Path(args.value_file).read_text(encoding="utf-8") |
| sys.stderr.write("error: one of --value / --value-file is required\n") |
| raise SystemExit(2) |
| |
| |
| def _cmd_get(args: argparse.Namespace) -> int: |
| body = _gh_get_body(args.issue, args.repo) |
| try: |
| value = extract_field(body, args.field) |
| except FieldNotFoundError as exc: |
| sys.stderr.write(f"{exc}\n") |
| return 3 |
| sys.stdout.write(value) |
| if not value.endswith("\n"): |
| sys.stdout.write("\n") |
| return 0 |
| |
| |
| def _cmd_list(args: argparse.Namespace) -> int: |
| body = _gh_get_body(args.issue, args.repo) |
| names = list_fields(body) |
| if args.json: |
| sys.stdout.write(json.dumps(names, indent=2)) |
| sys.stdout.write("\n") |
| else: |
| for name in names: |
| sys.stdout.write(f"{name}\n") |
| return 0 |
| |
| |
| def _cmd_set(args: argparse.Namespace) -> int: |
| new_value = _read_value(args) |
| body = _gh_get_body(args.issue, args.repo) |
| try: |
| new_body = replace_field(body, args.field, new_value) |
| except FieldNotFoundError as exc: |
| sys.stderr.write(f"{exc}\n") |
| return 3 |
| |
| if new_body == body: |
| sys.stderr.write(f"unchanged: {args.field!r} already matches new value\n") |
| return 0 |
| |
| # Compact diff summary on stderr so an orchestrator can log what |
| # changed without us streaming the body itself. |
| try: |
| old_value = extract_field(body, args.field) |
| except FieldNotFoundError: |
| # Should not happen — we just rewrote this exact field — but |
| # don't crash the apply if the post-rewrite re-parse drifts. |
| old_value = "<unknown>" |
| sys.stderr.write(f"field={args.field!r} old_len={len(old_value)} new_len={len(new_value)}\n") |
| |
| if args.dry_run: |
| sys.stderr.write("dry-run: not pushing\n") |
| return 0 |
| |
| _gh_set_body(args.issue, args.repo, new_body) |
| return 0 |
| |
| |
| def _build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser( |
| prog="body-field", |
| description=( |
| "Read or rewrite a single `### Field` section of a GitHub " |
| "issue body without bringing the body into agent context." |
| ), |
| ) |
| parser.add_argument( |
| "--repo", |
| help="owner/repo. Defaults to the repository of the current working directory.", |
| ) |
| |
| sub = parser.add_subparsers(dest="command", required=True) |
| |
| p_get = sub.add_parser("get", help="print one field's value to stdout") |
| p_get.add_argument("issue", help="issue number") |
| p_get.add_argument("--field", required=True, help="exact heading text (without `### `)") |
| p_get.set_defaults(func=_cmd_get) |
| |
| p_set = sub.add_parser("set", help="rewrite one field's value in place") |
| p_set.add_argument("issue", help="issue number") |
| p_set.add_argument("--field", required=True, help="exact heading text (without `### `)") |
| p_set.add_argument("--value", help="new value (use --value-file for multi-line / shell-unsafe text)") |
| p_set.add_argument( |
| "--value-file", |
| help="path to a file containing the new value; pass '-' to read stdin", |
| ) |
| p_set.add_argument( |
| "--dry-run", |
| action="store_true", |
| help="print the diff summary to stderr but skip the push", |
| ) |
| p_set.set_defaults(func=_cmd_set) |
| |
| p_list = sub.add_parser("list", help="print every field heading present in the body") |
| p_list.add_argument("issue", help="issue number") |
| p_list.add_argument("--json", action="store_true", help="emit a JSON array instead of one name per line") |
| p_list.set_defaults(func=_cmd_list) |
| |
| return parser |
| |
| |
| def main(argv: Sequence[str] | None = None) -> int: |
| parser = _build_parser() |
| args = parser.parse_args(argv) |
| return int(args.func(args)) |
| |
| |
| if __name__ == "__main__": |
| raise SystemExit(main()) |