134 lines
4.3 KiB
Python
Executable File
134 lines
4.3 KiB
Python
Executable File
#!/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} <url>.')
|
|
|
|
|
|
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()
|