refactor: make ARNES external-repo based with ticket publish flow
This commit is contained in:
@@ -8,7 +8,9 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATUS_PATH = ROOT / 'work' / 'runtime-status.json'
|
||||
MATRIX_PATH = ROOT / 'harness' / 'agents.matrix.yml'
|
||||
WORKFLOW_PATH = ROOT / 'harness' / 'workflow.stages.yml'
|
||||
ARTIFACTS_DIR = ROOT / 'work' / 'artifacts'
|
||||
VALID_RUNTIME_STATES = {'idle', 'waiting', 'running', 'blocked', 'done'}
|
||||
|
||||
DEFAULT_EMOJIS = {
|
||||
'leader': '🧭',
|
||||
@@ -26,6 +28,7 @@ GATE_FILES = {
|
||||
'security': 'security.json',
|
||||
'qa': 'qa.json',
|
||||
'documenter': 'documenter.md',
|
||||
'publish': 'publish.json',
|
||||
'leader': 'leader-close.json',
|
||||
}
|
||||
|
||||
@@ -60,6 +63,28 @@ def load_role_emojis():
|
||||
return emojis
|
||||
|
||||
|
||||
def load_roles():
|
||||
roles = []
|
||||
if not MATRIX_PATH.exists():
|
||||
return roles
|
||||
for line in MATRIX_PATH.read_text(encoding='utf-8').splitlines():
|
||||
match_role = re.match(r'^ ([a-z_]+):\s*$', line)
|
||||
if match_role:
|
||||
roles.append(match_role.group(1))
|
||||
return roles
|
||||
|
||||
|
||||
def load_stage_names():
|
||||
stages = []
|
||||
if not WORKFLOW_PATH.exists():
|
||||
return stages
|
||||
for line in WORKFLOW_PATH.read_text(encoding='utf-8').splitlines():
|
||||
match_stage = re.match(r'^ - name:\s*([a-z_]+)\s*$', line)
|
||||
if match_stage:
|
||||
stages.append(match_stage.group(1))
|
||||
return stages
|
||||
|
||||
|
||||
def default_status():
|
||||
return {
|
||||
'feature_id': None,
|
||||
@@ -99,7 +124,8 @@ def gate_status(feature_id):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
gates[gate] = 'approved' if payload.get('verdict') == 'APPROVED' else 'present'
|
||||
wanted = 'PUBLISHED' if gate == 'publish' else 'APPROVED'
|
||||
gates[gate] = 'approved' if payload.get('verdict') == wanted else 'present'
|
||||
except Exception:
|
||||
gates[gate] = 'invalid'
|
||||
return gates
|
||||
@@ -115,10 +141,25 @@ def render_gate(gate, state, emojis):
|
||||
label = {
|
||||
'leader': 'close',
|
||||
'documenter': 'docs',
|
||||
'publish': 'publish',
|
||||
}.get(gate, gate)
|
||||
return f"{icon} {emojis.get(gate, '•')} {label}: {state.upper()}"
|
||||
|
||||
|
||||
def validate_runtime_args(args):
|
||||
roles = set(load_roles()) or set(DEFAULT_EMOJIS)
|
||||
stages = set(load_stage_names()) | {'idle'}
|
||||
|
||||
if args.agent is not None and args.agent not in roles:
|
||||
raise SystemExit(f"Invalid agent: {args.agent}. Allowed: {', '.join(sorted(roles))}")
|
||||
if args.next_agent is not None and args.next_agent not in roles:
|
||||
raise SystemExit(f"Invalid next-agent: {args.next_agent}. Allowed: {', '.join(sorted(roles))}")
|
||||
if args.stage is not None and args.stage not in stages:
|
||||
raise SystemExit(f"Invalid stage: {args.stage}. Allowed: {', '.join(sorted(stages))}")
|
||||
if args.state is not None and args.state not in VALID_RUNTIME_STATES:
|
||||
raise SystemExit(f"Invalid state: {args.state}. Allowed: {', '.join(sorted(VALID_RUNTIME_STATES))}")
|
||||
|
||||
|
||||
def show_status():
|
||||
status = load_status()
|
||||
emojis = load_role_emojis()
|
||||
@@ -141,7 +182,7 @@ def show_status():
|
||||
print()
|
||||
print('Gates')
|
||||
if gates:
|
||||
for gate in ['reviewer', 'security', 'qa', 'documenter', 'leader']:
|
||||
for gate in ['reviewer', 'security', 'qa', 'documenter', 'publish', 'leader']:
|
||||
print(f" {render_gate(gate, gates.get(gate, 'pending'), emojis)}")
|
||||
else:
|
||||
print(' — Sin feature activa —')
|
||||
@@ -162,6 +203,7 @@ def show_status():
|
||||
|
||||
|
||||
def set_status(args):
|
||||
validate_runtime_args(args)
|
||||
status = load_status()
|
||||
if args.feature_id is not None:
|
||||
status['feature_id'] = args.feature_id or None
|
||||
|
||||
66
scripts/install_into_repo.sh
Executable file
66
scripts/install_into_repo.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TARGET_INPUT="${1:-}"
|
||||
|
||||
if [ -z "$TARGET_INPUT" ]; then
|
||||
echo "Usage: ./scripts/install_into_repo.sh /path/to/target-repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TARGET_INPUT"
|
||||
TARGET_ROOT="$(cd "$TARGET_INPUT" && pwd)"
|
||||
|
||||
if [ "$TARGET_ROOT" = "$SRC_ROOT" ]; then
|
||||
echo "Refusing to install ARNES into its own source repository. Use a different project repo."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git -C "$TARGET_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "No git repo detected at target. Initializing git repository..."
|
||||
git -C "$TARGET_ROOT" init >/dev/null
|
||||
fi
|
||||
|
||||
copy_item() {
|
||||
local item="$1"
|
||||
if [ -d "$SRC_ROOT/$item" ]; then
|
||||
mkdir -p "$TARGET_ROOT/$item"
|
||||
cp -R "$SRC_ROOT/$item"/. "$TARGET_ROOT/$item"/
|
||||
else
|
||||
cp "$SRC_ROOT/$item" "$TARGET_ROOT/$item"
|
||||
fi
|
||||
}
|
||||
|
||||
ITEMS=(
|
||||
AGENTS.md
|
||||
AGENTS.local.md.example
|
||||
CHECKPOINTS.md
|
||||
HOWTO.md
|
||||
HOWTO-FEATURE.md
|
||||
README.md
|
||||
TEMPLATE.md
|
||||
Makefile
|
||||
requirements.txt
|
||||
backlog
|
||||
defaults
|
||||
docs
|
||||
features
|
||||
harness
|
||||
platforms
|
||||
project
|
||||
scripts
|
||||
spec
|
||||
starter-pack
|
||||
work
|
||||
)
|
||||
|
||||
for item in "${ITEMS[@]}"; do
|
||||
copy_item "$item"
|
||||
done
|
||||
|
||||
echo "Installed ARNES core into: $TARGET_ROOT"
|
||||
echo "Next steps:"
|
||||
echo " cd $TARGET_ROOT"
|
||||
echo " ./scripts/start.sh"
|
||||
echo " ./scripts/verify.sh"
|
||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKLOG = ROOT / 'backlog' / 'features.json'
|
||||
TYPE_CHOICES = ('feature', 'fix', 'bug', 'chore')
|
||||
LEVEL_CHOICES = ('low', 'med', 'high')
|
||||
|
||||
|
||||
def ask(prompt, default=''):
|
||||
@@ -12,10 +14,23 @@ def ask(prompt, default=''):
|
||||
return value if value else default
|
||||
|
||||
|
||||
def ask_choice(prompt, choices, default):
|
||||
while True:
|
||||
value = ask(prompt, default).lower()
|
||||
if value in choices:
|
||||
return value
|
||||
print(f"Invalid value. Use one of: {', '.join(choices)}")
|
||||
|
||||
|
||||
def ask_list(prompt, default_csv=''):
|
||||
raw = ask(prompt, default_csv)
|
||||
return [item.strip() for item in raw.split(',') if item.strip()]
|
||||
|
||||
|
||||
def next_id(features):
|
||||
nums = []
|
||||
for f in features:
|
||||
fid = str(f.get('id', ''))
|
||||
for feature in features:
|
||||
fid = str(feature.get('id', ''))
|
||||
if fid.startswith('F-') and fid[2:].isdigit():
|
||||
nums.append(int(fid[2:]))
|
||||
return f"F-{(max(nums) + 1) if nums else 1:03d}"
|
||||
@@ -26,14 +41,14 @@ def main():
|
||||
features = data.get('features', [])
|
||||
|
||||
print('Create ticket (English caveman style).')
|
||||
ttype = ask('Type (feature/fix/bug/chore)', 'feature')
|
||||
title = ask('Title (short EN)', f'{ttype.capitalize()} TODO')
|
||||
ticket_type = ask_choice('Type (feature/fix/bug/chore)', TYPE_CHOICES, 'feature')
|
||||
title = ask('Title (short EN)', f'{ticket_type.capitalize()} TODO')
|
||||
problem = ask('Problem (short EN)', 'Need change')
|
||||
goal = ask('Goal (short EN)', 'Make flow better')
|
||||
scope_in = ask('Scope IN (comma list EN)', 'Core flow')
|
||||
scope_out = ask('Scope OUT (comma list EN)', 'No redesign')
|
||||
risk = ask('Risk (low/med/high)', 'low')
|
||||
priority = ask('Priority (low/med/high)', 'med')
|
||||
scope_in = ask_list('Scope IN (comma list EN)', 'Core flow')
|
||||
scope_out = ask_list('Scope OUT (comma list EN)', 'No redesign')
|
||||
risk = ask_choice('Risk (low/med/high)', LEVEL_CHOICES, 'low')
|
||||
priority = ask_choice('Priority (low/med/high)', LEVEL_CHOICES, 'med')
|
||||
|
||||
print('Acceptance bullets (EN caveman). Empty line to end.')
|
||||
acceptance = []
|
||||
@@ -47,29 +62,38 @@ def main():
|
||||
acceptance = [
|
||||
'Flow works end to end',
|
||||
'No break old behavior',
|
||||
'verify.sh is green'
|
||||
'verify.sh is green',
|
||||
]
|
||||
|
||||
fid = next_id(features)
|
||||
desc = (
|
||||
f"Problem: {problem}. "
|
||||
f"Goal: {goal}. "
|
||||
f"Scope IN: {scope_in}. "
|
||||
f"Scope OUT: {scope_out}. "
|
||||
f"Type: {ttype}. Priority: {priority}. Risk: {risk}."
|
||||
f"Scope IN: {', '.join(scope_in) or 'none'}. "
|
||||
f"Scope OUT: {', '.join(scope_out) or 'none'}. "
|
||||
f"Type: {ticket_type}. Priority: {priority}. Risk: {risk}."
|
||||
)
|
||||
|
||||
features.append({
|
||||
'id': fid,
|
||||
'type': ticket_type,
|
||||
'title': title,
|
||||
'problem': problem,
|
||||
'goal': goal,
|
||||
'scope_in': scope_in,
|
||||
'scope_out': scope_out,
|
||||
'priority': priority,
|
||||
'risk': risk,
|
||||
'description': desc,
|
||||
'acceptance': acceptance,
|
||||
'status': 'pending',
|
||||
'created_at': str(date.today()),
|
||||
'gates': {'review': False, 'security': False, 'qa': False}
|
||||
'gates': {'review': False, 'security': False, 'qa': False},
|
||||
})
|
||||
|
||||
data['features'] = features
|
||||
rules = data.setdefault('rules', {})
|
||||
rules.setdefault('valid_types', list(TYPE_CHOICES))
|
||||
BACKLOG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
print(f'Created {fid}: {title}')
|
||||
|
||||
|
||||
133
scripts/publish_ticket.py
Executable file
133
scripts/publish_ticket.py
Executable file
@@ -0,0 +1,133 @@
|
||||
#!/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()
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script para arrancar el servidor ARNES UI API
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Configuración
|
||||
PORT=${1:-8000}
|
||||
HOST="0.0.0.0"
|
||||
|
||||
# Colores
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} ARNES API - Starting...${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e " URL: ${YELLOW}http://localhost:${PORT}/ui/login.html${NC}"
|
||||
echo -e " Host: ${YELLOW}${HOST}:${PORT}${NC}"
|
||||
echo ""
|
||||
echo -e " Credenciales de prueba:"
|
||||
echo -e " Email: ${YELLOW}alice@example.com${NC}"
|
||||
echo -e " Password: ${YELLOW}SecurePass123!${NC}"
|
||||
echo ""
|
||||
|
||||
# Instalar dependencias si falta
|
||||
if ! python3 -c "import fastapi" 2>/dev/null; then
|
||||
echo -e "${YELLOW}Instalando dependencias...${NC}"
|
||||
pip3 install -q fastapi uvicorn pydantic PyJWT bcrypt httpx
|
||||
fi
|
||||
|
||||
# Arrancar servidor
|
||||
exec python3 -m uvicorn src.main:app --host "$HOST" --port "$PORT" --reload
|
||||
@@ -16,12 +16,16 @@ ask() {
|
||||
}
|
||||
|
||||
echo "=== ARNES start wizard ==="
|
||||
echo "Mode: use this template in a new repo or copy core ARNES into an existing repo."
|
||||
|
||||
echo "Mode: clone arnes-fork, put your app folder inside, run this wizard."
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "No git repo detected. Initializing local git repository..."
|
||||
git init >/dev/null
|
||||
fi
|
||||
|
||||
PROJECT_NAME="$(ask 'Project name' 'my-project')"
|
||||
PROJECT_DESC="$(ask 'Project description' 'Project using ARNES template')"
|
||||
APP_DIR="$(ask 'App directory (relative)' 'app')"
|
||||
APP_DIR="$(ask 'App directory (relative)' 'project')"
|
||||
|
||||
STACK_CHOICE="$(ask 'Stack preset (1=default Flask+MariaDB+Skeleton, 2=custom)' '1')"
|
||||
if [ "$STACK_CHOICE" = "2" ]; then
|
||||
@@ -40,12 +44,22 @@ MODEL_MODE="$(ask 'Model mode (lean/balanced/power)' 'lean')"
|
||||
ADD_BOOTSTRAP="$(ask 'Create bootstrap ticket F-001 now? (y/n)' 'y')"
|
||||
|
||||
mkdir -p "$APP_DIR"
|
||||
[ -f "$APP_DIR/README.md" ] || cat > "$APP_DIR/README.md" <<EOF
|
||||
# Project code
|
||||
|
||||
This directory holds the real project code.
|
||||
Configured by ARNES start wizard.
|
||||
EOF
|
||||
|
||||
if [ "$BACKEND" = "python/flask" ]; then
|
||||
mkdir -p "$APP_DIR/templates" "$APP_DIR/static/js" "$APP_DIR/static/css" "$APP_DIR/static/images"
|
||||
fi
|
||||
|
||||
if [ "$CSSFW" = "skeleton" ]; then
|
||||
mkdir -p "$APP_DIR/static/css" "$APP_DIR/static/images"
|
||||
cp -n defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css" || true
|
||||
cp -n defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css" || true
|
||||
cp -n defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png" || true
|
||||
[ -f "$APP_DIR/static/css/normalize.css" ] || cp defaults/flask-skeleton/static/css/normalize.css "$APP_DIR/static/css/normalize.css"
|
||||
[ -f "$APP_DIR/static/css/skeleton.css" ] || cp defaults/flask-skeleton/static/css/skeleton.css "$APP_DIR/static/css/skeleton.css"
|
||||
[ -f "$APP_DIR/static/images/favicon.png" ] || cp defaults/flask-skeleton/static/images/favicon.png "$APP_DIR/static/images/favicon.png"
|
||||
fi
|
||||
|
||||
cat > harness/project.config.json <<JSON
|
||||
@@ -82,7 +96,7 @@ APP_DIR=$(python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
cfg=json.loads(Path('harness/project.config.json').read_text())
|
||||
print(cfg.get('app_dir','app'))
|
||||
print(cfg.get('app_dir','project'))
|
||||
PY
|
||||
)
|
||||
TEST_CMD=$(python3 - <<'PY'
|
||||
@@ -126,25 +140,34 @@ import json
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
|
||||
b=Path('backlog/features.json')
|
||||
data=json.loads(b.read_text(encoding='utf-8'))
|
||||
data['project']='$PROJECT_NAME'
|
||||
data['description']='$PROJECT_DESC'
|
||||
features=data.get('features',[])
|
||||
b = Path('backlog/features.json')
|
||||
data = json.loads(b.read_text(encoding='utf-8'))
|
||||
data['project'] = '$PROJECT_NAME'
|
||||
data['description'] = '$PROJECT_DESC'
|
||||
rules = data.setdefault('rules', {})
|
||||
rules.setdefault('valid_types', ['feature', 'fix', 'bug', 'chore'])
|
||||
features = data.get('features', [])
|
||||
|
||||
if '$ADD_BOOTSTRAP'.lower().startswith('y') and not features:
|
||||
features.append({
|
||||
'id':'F-001',
|
||||
'title':'Bootstrap ARNES on project',
|
||||
'description':'Setup ARNES pipeline and run first complete feature cycle.',
|
||||
'acceptance':['verify.sh is green','runtime status works','first feature closes with gates'],
|
||||
'status':'pending',
|
||||
'created_at':str(date.today()),
|
||||
'gates':{'review':False,'security':False,'qa':False}
|
||||
'id': 'F-001',
|
||||
'type': 'chore',
|
||||
'title': 'Bootstrap ARNES on project',
|
||||
'problem': 'Need base workflow and control',
|
||||
'goal': 'Make ARNES ready on this repo',
|
||||
'scope_in': ['Harness setup', 'Runtime status', 'First verify cycle'],
|
||||
'scope_out': ['Business feature work', 'Product redesign'],
|
||||
'priority': 'med',
|
||||
'risk': 'low',
|
||||
'description': 'Problem: Need base workflow and control. Goal: Make ARNES ready on this repo. Scope IN: Harness setup, Runtime status, First verify cycle. Scope OUT: Business feature work, Product redesign. Type: chore. Priority: med. Risk: low.',
|
||||
'acceptance': ['verify.sh is green', 'runtime status works', 'first feature closes with gates'],
|
||||
'status': 'pending',
|
||||
'created_at': str(date.today()),
|
||||
'gates': {'review': False, 'security': False, 'qa': False}
|
||||
})
|
||||
|
||||
data['features']=features
|
||||
b.write_text(json.dumps(data,indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
|
||||
data['features'] = features
|
||||
b.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
PY
|
||||
|
||||
cat > work/current.md <<EOF
|
||||
@@ -169,5 +192,6 @@ echo "Done. Project configured."
|
||||
echo "- Config: harness/project.config.json"
|
||||
echo "- Local checks: scripts/verify.local.sh"
|
||||
echo "- Ticket tool: python3 scripts/new_ticket.py"
|
||||
echo "- Publish tool: python3 scripts/publish_ticket.py --feature-id F-001"
|
||||
echo "- Verify: ./scripts/verify.sh"
|
||||
echo "- Runtime: python3 scripts/agent_status.py show"
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Test script for the API."""
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import requests
|
||||
from threading import Thread
|
||||
|
||||
SERVER_URL = "http://127.0.0.1:8000"
|
||||
|
||||
def start_server():
|
||||
"""Start the uvicorn server."""
|
||||
subprocess.run([
|
||||
"python3", "-m", "uvicorn",
|
||||
"src.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "8000"
|
||||
])
|
||||
|
||||
def wait_for_server(timeout=10):
|
||||
"""Wait for server to be ready."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
response = requests.get(f"{SERVER_URL}/health", timeout=1)
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
def test_health():
|
||||
"""Test health endpoint."""
|
||||
response = requests.get(f"{SERVER_URL}/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
print("✅ Health check passed")
|
||||
|
||||
def test_login():
|
||||
"""Test login endpoint."""
|
||||
response = requests.post(
|
||||
f"{SERVER_URL}/api/v1/auth/login",
|
||||
json={"email": "alice@example.com", "password": "SecurePass123!"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] == True
|
||||
assert "access_token" in data["data"]
|
||||
print("✅ Login endpoint passed")
|
||||
return data["data"]["access_token"]
|
||||
|
||||
def test_login_invalid():
|
||||
"""Test login with invalid credentials."""
|
||||
response = requests.post(
|
||||
f"{SERVER_URL}/api/v1/auth/login",
|
||||
json={"email": "alice@example.com", "password": "WrongPassword!"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
print("✅ Invalid login returns 401")
|
||||
|
||||
def test_profile():
|
||||
"""Test profile endpoint."""
|
||||
response = requests.get(f"{SERVER_URL}/api/v1/profile/me")
|
||||
assert response.status_code == 200
|
||||
print("✅ Profile endpoint passed")
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests."""
|
||||
print("🔧 Starting server...")
|
||||
server_thread = Thread(target=start_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
print("⏳ Waiting for server...")
|
||||
if not wait_for_server():
|
||||
print("❌ Server failed to start")
|
||||
return False
|
||||
|
||||
print("✅ Server is ready!\n")
|
||||
|
||||
try:
|
||||
test_health()
|
||||
test_login()
|
||||
test_login_invalid()
|
||||
test_profile()
|
||||
print("\n🎉 All tests passed!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -18,6 +18,11 @@ echo "── 1) Verificando estructura base ────────────
|
||||
required=(
|
||||
"AGENTS.md"
|
||||
"CHECKPOINTS.md"
|
||||
"README.md"
|
||||
"HOWTO.md"
|
||||
"TEMPLATE.md"
|
||||
"docs/repository-layout.md"
|
||||
"docs/scripts-reference.md"
|
||||
"harness/agents.matrix.yml"
|
||||
"harness/workflow.stages.yml"
|
||||
"harness/policies/governance.md"
|
||||
@@ -31,14 +36,24 @@ required=(
|
||||
"spec/product.md"
|
||||
"spec/tech.md"
|
||||
"spec/acceptance.md"
|
||||
"spec/bdd/README.md"
|
||||
"spec/bdd/features/README.md"
|
||||
"spec/sdd/README.md"
|
||||
"spec/sdd/components/README.md"
|
||||
"spec/sdd/decisions/README.md"
|
||||
"features/README.md"
|
||||
"project/README.md"
|
||||
"backlog/features.json"
|
||||
"work/current.md"
|
||||
"work/history.md"
|
||||
"work/runtime-status.json"
|
||||
"scripts/agent_status.py"
|
||||
"scripts/new_ticket.py"
|
||||
"scripts/publish_ticket.py"
|
||||
"scripts/install_into_repo.sh"
|
||||
"scripts/start.sh"
|
||||
"platforms/pi/README.md"
|
||||
"platforms/opencode/README.md"
|
||||
)
|
||||
|
||||
for f in "${required[@]}"; do
|
||||
@@ -50,6 +65,20 @@ for f in "${required[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "── 1.5) Validando git repo ───────────────────────────"
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
ok "Git repo detectado"
|
||||
if git remote | grep -q .; then
|
||||
ok "Git remote configurado"
|
||||
else
|
||||
warn "Sin git remote configurado (publish requerirá remote)"
|
||||
fi
|
||||
else
|
||||
fail "Este proyecto debe vivir dentro de un git repo"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── 2) Validando backlog + gates ───────────────────────"
|
||||
python3 - <<'PY'
|
||||
@@ -59,6 +88,7 @@ import sys
|
||||
|
||||
root = pathlib.Path('.')
|
||||
path = root / 'backlog' / 'features.json'
|
||||
level_choices = {'low', 'med', 'high'}
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
@@ -66,7 +96,9 @@ except Exception as e:
|
||||
print(f"[FAIL] backlog/features.json inválido: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
valid = set(data.get('rules', {}).get('valid_status', ["pending", "in_progress", "blocked", "done"]))
|
||||
rules = data.get('rules', {})
|
||||
valid_status = set(rules.get('valid_status', ["pending", "in_progress", "blocked", "done"]))
|
||||
valid_types = set(rules.get('valid_types', ["feature", "fix", "bug", "chore"]))
|
||||
features = data.get('features', [])
|
||||
if not isinstance(features, list):
|
||||
print('[FAIL] features debe ser una lista')
|
||||
@@ -85,25 +117,65 @@ if len(in_progress) > 1:
|
||||
for f in features:
|
||||
fid = str(f.get('id', '')).strip()
|
||||
status = f.get('status')
|
||||
if status not in valid:
|
||||
title = str(f.get('title', '')).strip()
|
||||
acceptance = f.get('acceptance')
|
||||
gates = f.get('gates', {})
|
||||
|
||||
if not fid:
|
||||
print('[FAIL] Hay una feature sin id')
|
||||
sys.exit(1)
|
||||
if not title:
|
||||
print(f"[FAIL] Feature {fid} sin title")
|
||||
sys.exit(1)
|
||||
if status not in valid_status:
|
||||
print(f"[FAIL] Estado inválido en feature {fid}: {status}")
|
||||
sys.exit(1)
|
||||
if not isinstance(acceptance, list) or not acceptance or any(not str(item).strip() for item in acceptance):
|
||||
print(f"[FAIL] Feature {fid} debe tener acceptance como lista no vacía")
|
||||
sys.exit(1)
|
||||
|
||||
ticket_type = f.get('type')
|
||||
if ticket_type is not None and ticket_type not in valid_types:
|
||||
print(f"[FAIL] Feature {fid} tiene type inválido: {ticket_type}")
|
||||
sys.exit(1)
|
||||
|
||||
for field in ('priority', 'risk'):
|
||||
value = f.get(field)
|
||||
if value is not None and value not in level_choices:
|
||||
print(f"[FAIL] Feature {fid} tiene {field} inválido: {value}")
|
||||
sys.exit(1)
|
||||
|
||||
for field in ('scope_in', 'scope_out'):
|
||||
value = f.get(field)
|
||||
if value is not None:
|
||||
if not isinstance(value, list) or any(not str(item).strip() for item in value):
|
||||
print(f"[FAIL] Feature {fid} tiene {field} inválido")
|
||||
sys.exit(1)
|
||||
|
||||
if gates:
|
||||
for gate_name in ('review', 'security', 'qa'):
|
||||
gate_value = gates.get(gate_name)
|
||||
if not isinstance(gate_value, bool):
|
||||
print(f"[FAIL] Feature {fid} tiene gates.{gate_name} inválido")
|
||||
sys.exit(1)
|
||||
|
||||
if status == 'done':
|
||||
d = root / 'work' / 'artifacts' / fid
|
||||
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md']
|
||||
req = ['reviewer.json', 'security.json', 'qa.json', 'leader-close.json', 'documenter.md', 'publish.json']
|
||||
missing = [name for name in req if not (d / name).is_file()]
|
||||
if missing:
|
||||
print(f"[FAIL] Feature {fid} done sin artefactos: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
expected = {
|
||||
'reviewer.json': 'reviewer',
|
||||
'security.json': 'security',
|
||||
'qa.json': 'qa',
|
||||
'leader-close.json': 'leader',
|
||||
'reviewer.json': ('reviewer', 'APPROVED'),
|
||||
'security.json': ('security', 'APPROVED'),
|
||||
'qa.json': ('qa', 'APPROVED'),
|
||||
'leader-close.json': ('leader', 'APPROVED'),
|
||||
'publish.json': ('leader', 'PUBLISHED'),
|
||||
}
|
||||
for filename, agent in expected.items():
|
||||
for filename, rule in expected.items():
|
||||
agent, verdict = rule
|
||||
try:
|
||||
obj = json.loads((d / filename).read_text(encoding='utf-8'))
|
||||
except Exception as e:
|
||||
@@ -113,8 +185,11 @@ for f in features:
|
||||
if obj.get('agent') != agent:
|
||||
print(f"[FAIL] {fid}/{filename} agent debe ser '{agent}'")
|
||||
sys.exit(1)
|
||||
if obj.get('verdict') != 'APPROVED':
|
||||
print(f"[FAIL] {fid}/{filename} no está APPROVED")
|
||||
if obj.get('verdict') != verdict:
|
||||
print(f"[FAIL] {fid}/{filename} no está {verdict}")
|
||||
sys.exit(1)
|
||||
if filename == 'publish.json' and obj.get('pushed') is not True:
|
||||
print(f"[FAIL] {fid}/{filename} debe tener pushed=true")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] backlog válido ({len(features)} features)")
|
||||
|
||||
Reference in New Issue
Block a user