#!/usr/bin/env python3 import argparse import json import subprocess from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] BACKLOG = ROOT / 'backlog' / 'features.json' ARTIFACTS = ROOT / 'work' / 'artifacts' def now_iso(): return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z') def run_git(*args, check=True): result = subprocess.run( ['git', *args], cwd=ROOT, text=True, capture_output=True, ) if check and result.returncode != 0: msg = result.stderr.strip() or result.stdout.strip() or f'git command failed: {args}' raise SystemExit(msg) return result def ensure_git_repo(): result = run_git('rev-parse', '--is-inside-work-tree', check=False) if result.returncode != 0 or result.stdout.strip() != 'true': raise SystemExit('This project is not inside a git repository. Run git init or clone a repo first.') def load_backlog(): return json.loads(BACKLOG.read_text(encoding='utf-8')) def find_feature(feature_id): data = load_backlog() for feature in data.get('features', []): if str(feature.get('id')) == feature_id: return feature raise SystemExit(f'Feature not found in backlog: {feature_id}') def current_branch(): branch = run_git('symbolic-ref', '--quiet', '--short', 'HEAD', check=False).stdout.strip() if not branch: branch = run_git('rev-parse', '--abbrev-ref', 'HEAD', check=False).stdout.strip() if not branch or branch == 'HEAD': raise SystemExit('Detached HEAD is not supported for publish. Checkout a branch first.') return branch def ensure_remote(remote): remotes = [line.strip() for line in run_git('remote').stdout.splitlines() if line.strip()] if remote not in remotes: raise SystemExit(f'Remote not found: {remote}. Add it first with git remote add {remote} .') def status_porcelain(): return run_git('status', '--porcelain').stdout.strip() def default_commit_message(feature): feature_id = feature['id'] ticket_type = feature.get('type') title = str(feature.get('title', '')).strip() if ticket_type: return f'{feature_id} {ticket_type}: {title}' return f'{feature_id}: {title}' def write_publish_artifact(feature_id, payload): feature_dir = ARTIFACTS / feature_id feature_dir.mkdir(parents=True, exist_ok=True) path = feature_dir / 'publish.json' path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') return path def ensure_git_identity(): name = run_git('config', 'user.name', check=False).stdout.strip() email = run_git('config', 'user.email', check=False).stdout.strip() if not name or not email: raise SystemExit('Missing git identity. Configure git user.name and user.email before publish.') def main(): parser = argparse.ArgumentParser(description='Commit and push one ticket, then write publish.json evidence.') parser.add_argument('--feature-id', required=True) parser.add_argument('--remote', default='origin') parser.add_argument('--branch', default='') parser.add_argument('--commit-message', default='') args = parser.parse_args() ensure_git_repo() ensure_git_identity() feature = find_feature(args.feature_id) remote = args.remote.strip() or 'origin' branch = args.branch.strip() or current_branch() ensure_remote(remote) if not status_porcelain(): raise SystemExit('No git changes to publish. Nothing to commit.') commit_message = args.commit_message.strip() or default_commit_message(feature) payload = { 'agent': 'leader', 'verdict': 'PUBLISHED', 'feature_id': args.feature_id, 'branch': branch, 'remote': remote, 'message': commit_message, 'pushed': True, 'published_at': now_iso(), 'note': 'This artifact is committed inside the publish commit for this ticket.' } artifact_path = write_publish_artifact(args.feature_id, payload) run_git('add', '-A') if not status_porcelain(): raise SystemExit('No staged git changes after git add -A. Nothing to commit.') run_git('commit', '-m', commit_message) run_git('push', remote, branch) print(f'done -> {artifact_path}') if __name__ == '__main__': main()