Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# The API_SECRET from your OrgMyLife .env on the VPS
|
||||
ORGMYLIFE_API_SECRET=your_api_secret_here
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Deploy to VPS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to VPS via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: 217.160.174.2
|
||||
username: root
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/AI-Orchestrator
|
||||
git pull origin main
|
||||
docker compose up -d --build
|
||||
echo "Deploy complete"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: PAT Lifecycle Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
check-pats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: pip install -r scripts/pat_manager/requirements.txt
|
||||
- name: Run PAT Manager
|
||||
env:
|
||||
GITLAB_PAT: ${{ secrets.GITLAB_PAT }}
|
||||
GITLAB_URL: https://gitlab.com
|
||||
JIRA_PAT: ${{ secrets.JIRA_PAT }}
|
||||
JIRA_URL: https://jira.atlassian.net
|
||||
CONFLUENCE_PAT: ${{ secrets.CONFLUENCE_PAT }}
|
||||
CONFLUENCE_URL: https://confluence.atlassian.net
|
||||
ORGMYLIFE_API_KEY: ${{ secrets.ORGMYLIFE_API_SECRET }}
|
||||
ORGMYLIFE_URL: https://orgmylife.app
|
||||
ORGMYLIFE_USER: ${{ secrets.ORGMYLIFE_USER }}
|
||||
ORGMYLIFE_PASS: ${{ secrets.ORGMYLIFE_PASS }}
|
||||
GH_TOKEN: ${{ secrets.PAT_MANAGER_TOKEN }}
|
||||
ALERT_CHANNEL: orgmylife
|
||||
run: python -m scripts.pat_manager
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Sync Tasks to OrgMyLife
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['PLAN.md', 'TASKS.md', 'SETUP_TODO.md']
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse open tasks and sync to OrgMyLife
|
||||
run: |
|
||||
REPO_NAME="AI-Orchestrator"
|
||||
|
||||
TASKS="[]"
|
||||
for file in SETUP_TODO.md PLAN.md TASKS.md; do
|
||||
if [ -f "$file" ]; then
|
||||
ITEMS=$(grep -E '^\s*- \[ \]' "$file" | sed 's/.*- \[ \] //' | head -50)
|
||||
if [ -n "$ITEMS" ]; then
|
||||
TASKS=$(echo "$ITEMS" | jq -R -s 'split("\n") | map(select(length > 0)) | map({"title": ., "status": "backlog"})')
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Found tasks: $TASKS"
|
||||
|
||||
PAYLOAD=$(jq -n --arg repo "$REPO_NAME" --argjson tasks "$TASKS" '{"repo": $repo, "tasks": $tasks}')
|
||||
|
||||
curl -s -X POST \
|
||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "X-API-Key: ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"https://api.andreknie.de/api/projects/sync"
|
||||
@@ -0,0 +1,38 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
inclusion: auto
|
||||
---
|
||||
|
||||
# AI-Orchestrator Standards
|
||||
|
||||
## Quality Gates (before every commit)
|
||||
|
||||
1. `gitleaks detect --source . --no-git` — no secrets in code
|
||||
2. `ruff check .` — linting passes
|
||||
3. `pytest` — all tests pass, 80%+ coverage target
|
||||
4. `mypy src/` — type checking passes
|
||||
|
||||
## Conventional Commits
|
||||
|
||||
```
|
||||
feat(orchestrator): add retry backoff
|
||||
fix(tracker): handle timeout on OrgMyLife API
|
||||
test(alerter): add property test for deduplication
|
||||
docs(spec): update current state section
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- pytest + hypothesis for property-based testing
|
||||
- Property test files: `test_<module>_properties.py`
|
||||
- Given-When-Then structure in all tests
|
||||
- 80% coverage minimum, critical paths 95%+
|
||||
|
||||
## Autonomous Mode
|
||||
|
||||
- Never push directly to main — use feature branches
|
||||
- Create PRs targeting the current base branch
|
||||
- If stuck after 3 attempts: stop and document
|
||||
- Report: `PROGRESS: X%` / `HELP: description`
|
||||
|
||||
## Security
|
||||
|
||||
- Secrets via env vars or `.secrets` file (gitignored)
|
||||
- No tokens in source code
|
||||
- PAT registry tracks token expiry
|
||||
@@ -0,0 +1,110 @@
|
||||
# AGENTS.md
|
||||
|
||||
---
|
||||
|
||||
## Coding rules
|
||||
|
||||
1. Read `SPEC.md` first and treat it as source of truth ("the spec"). If `SPEC.md` does not exist, **stop and ask**.
|
||||
2. Follow the user prompt exactly; do not omit explicitly requested steps.
|
||||
3. **Avoid over-engineering.** Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused:
|
||||
- Scope: Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability.
|
||||
- Documentation: Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.
|
||||
- Defensive coding: Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs).
|
||||
- Abstractions: Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task.
|
||||
- Custom coding: Don't reinvent the wheel, use the functions the framework provides you with. If you think that using framework is not possible, consult the relevant online or MCP docs to make sure. If you still cannot find a solution within the framework, **stop and ask**.
|
||||
4. Do not hard-code values or create solutions that only work for specific test inputs; implement the actual logic that solves the problem generally.
|
||||
5. The resulting code must be robust, maintainable, and extendable.
|
||||
6. The resulting code must not contradict the spec.
|
||||
7. **Before writing code**, post a short and concise "Plan + spec mapping" summary and **ask for approval to proceed**. Do not implement until you receive the go-ahead.
|
||||
8. Documentation Rules:
|
||||
- filenames are always capitalized.
|
||||
- describes the **current state only**. Do not include change/history phrasing.
|
||||
- must always be coherent end-to-end never have stale or conflicting info.
|
||||
9. Always use repo virtual environment (unless there is none).
|
||||
|
||||
---
|
||||
|
||||
## Testing policy
|
||||
|
||||
- **Property-based tests (hypothesis)** for all domain logic — required.
|
||||
- Property test files use `_properties` suffix: `test_<module>_properties.py`
|
||||
- Run full suite: `pytest`
|
||||
- Run specific: `pytest tests/test_<module>.py`
|
||||
- Type check: `mypy src/`
|
||||
- Lint: `ruff check .`
|
||||
|
||||
---
|
||||
|
||||
## Base branch rule
|
||||
|
||||
**BASE_BRANCH = the branch that new work branches are created from and PRs target.**
|
||||
|
||||
Default:
|
||||
- If the user did not explicitly specify a base/target branch, set:
|
||||
- `BASE_BRANCH=$(git branch --show-current)` (from the user's main checkout at session start)
|
||||
|
||||
Override:
|
||||
- If the user explicitly specifies a base/target branch in the prompt, use that as `BASE_BRANCH`.
|
||||
|
||||
Remote base ref:
|
||||
- If `origin/$BASE_BRANCH` exists, use that as `BASE_REF`, else use `$BASE_BRANCH`:
|
||||
- `git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH" && BASE_REF="origin/$BASE_BRANCH" || BASE_REF="$BASE_BRANCH"`
|
||||
|
||||
---
|
||||
|
||||
## Before work
|
||||
1. Determine base branch (unless user specified it explicitly):
|
||||
- `BASE_BRANCH=$(git branch --show-current)`
|
||||
2. Fetch latest refs:
|
||||
- `git fetch origin --prune`
|
||||
3. Update base branch (fast-forward only) if it has an `origin/` tracking ref:
|
||||
- `git switch "$BASE_BRANCH"`
|
||||
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git pull --ff-only origin "$BASE_BRANCH"; fi`
|
||||
- If the pull fails for any reason, stop and ask.
|
||||
4. Read `SPEC.md` + `README.md` once per session. Re-read only if changed/unsure:
|
||||
- `git log -1 --oneline -- SPEC.md README.md`
|
||||
5. If deviating from `SPEC.md`: **stop and ask**.
|
||||
6. Create a new local work branch (once per session) **from BASE_BRANCH**. **ALWAYS push to the branch you created.**
|
||||
- `git switch -c "agent/<topic>-YYYYMMDD-<shortid>"`
|
||||
|
||||
## Tests
|
||||
- Run tests if the prompt requests or `SPEC.md` requires them; run all requested tests.
|
||||
- If any cannot be run: **stop and ask**.
|
||||
|
||||
## After work
|
||||
1. Make sure you did not miss requested tests.
|
||||
2. If you are addressing a **remote github issue** with
|
||||
- "to-do" checklist: for **each** item, make sure your implementation is **fully complete** and:
|
||||
- if yes, check it off
|
||||
- if not, finish implementation and recheck.
|
||||
- "acceptance" checklist: for **each** item, make the acceptance criterion is **completely fulfilled** and:
|
||||
- if yes, check it off
|
||||
- if not, implement missing code and recheck.
|
||||
3. Update all relevant documentation appropriately.
|
||||
4. **Before making a commit**: Stop and ask user to `/review` → `Review uncommited changes`.
|
||||
5. Commit **only after** user confirms code review passed.
|
||||
6. Open a PR or use already opened PR
|
||||
- **use your created branch**.
|
||||
- **Target `BASE_BRANCH`**.
|
||||
- PR text requirements:
|
||||
- commands in backticks
|
||||
- real newlines (ANSI-C quoting/heredoc)
|
||||
- if addressing a **remote github issue**, reference it so that github can close automatically after merging.
|
||||
7. **Only after** PR is merged:
|
||||
- switch back to base branch and sync it
|
||||
- `git switch "$BASE_BRANCH"`
|
||||
- `git fetch origin --prune`
|
||||
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git reset --hard "origin/$BASE_BRANCH"; fi`
|
||||
- If the reset fails, **stop and ask**.
|
||||
- delete the local merged work branch
|
||||
|
||||
---
|
||||
|
||||
## Required completion checklist (final response)
|
||||
- [ ] Base/target branch used correctly (BASE_BRANCH)
|
||||
- [ ] **all** Coding rules followed
|
||||
- [ ] All requested tests run (commands + results), or blocked → asked
|
||||
- [ ] Documentation updated appropriately: **list updated files**
|
||||
- [ ] PR opened (summary + command log) targeting BASE_BRANCH
|
||||
- [ ] After merge (if applicable): base branch synced; merged branch deleted
|
||||
---
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
COPY WORKFLOW.orgmylife.md WORKFLOW.md
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["ai-orchestrator", "WORKFLOW.md", "--port", "8080"]
|
||||
@@ -0,0 +1,33 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
@@ -0,0 +1,110 @@
|
||||
# AI-Orchestrator
|
||||
|
||||
A Python implementation of the [Symphony specification](https://github.com/openai/symphony/blob/main/SPEC.md) — a service that orchestrates coding agents to get project work done.
|
||||
|
||||
Symphony turns project work into isolated, autonomous implementation runs, allowing teams to manage work instead of supervising coding agents.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AI-Orchestrator │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Policy Layer │ WORKFLOW.md (repo-defined) │
|
||||
│ Configuration Layer │ Typed config from front matter │
|
||||
│ Coordination Layer │ Orchestrator (poll/dispatch) │
|
||||
│ Execution Layer │ Workspace + Agent subprocess │
|
||||
│ Integration Layer │ Linear adapter │
|
||||
│ Observability Layer │ Logs + HTTP status surface │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
- **Workflow Loader** — Reads and parses `WORKFLOW.md` (YAML front matter + prompt template)
|
||||
- **Config Layer** — Typed getters, defaults, env var resolution, validation
|
||||
- **Issue Tracker Client** — Linear GraphQL adapter with pagination and normalization
|
||||
- **Orchestrator** — Poll loop, dispatch, concurrency, retries, reconciliation
|
||||
- **Workspace Manager** — Per-issue workspace lifecycle and hooks
|
||||
- **Agent Runner** — Codex app-server subprocess integration
|
||||
- **HTTP Server** — Optional REST API and dashboard for observability
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.11+
|
||||
- A Linear API key (set `LINEAR_API_KEY` env var)
|
||||
- Codex CLI installed (`codex app-server`)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd AI-Orchestrator
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `WORKFLOW.md` in your project root:
|
||||
|
||||
```markdown
|
||||
---
|
||||
tracker:
|
||||
kind: linear
|
||||
api_key: $LINEAR_API_KEY
|
||||
project_slug: your-project-slug
|
||||
polling:
|
||||
interval_ms: 30000
|
||||
workspace:
|
||||
root: ~/symphony_workspaces
|
||||
agent:
|
||||
max_concurrent_agents: 5
|
||||
max_turns: 20
|
||||
codex:
|
||||
command: codex app-server
|
||||
approval_policy: auto-edit
|
||||
turn_timeout_ms: 3600000
|
||||
---
|
||||
|
||||
You are working on issue {{ issue.identifier }}: {{ issue.title }}
|
||||
|
||||
{{ issue.description }}
|
||||
|
||||
{% if attempt %}
|
||||
This is retry attempt {{ attempt }}. Review previous work and continue.
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Run with default WORKFLOW.md in current directory
|
||||
ai-orchestrator
|
||||
|
||||
# Run with explicit workflow path
|
||||
ai-orchestrator /path/to/WORKFLOW.md
|
||||
|
||||
# Run with HTTP server
|
||||
ai-orchestrator --port 8080
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run linter
|
||||
ruff check .
|
||||
|
||||
# Type checking
|
||||
mypy src/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0
|
||||
@@ -0,0 +1,96 @@
|
||||
# Setup TODO — Getting AI-Orchestrator + OrgMyLife Running
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] **Python 3.11+** installed on your machine
|
||||
- [x] **PostgreSQL** running (Docker on VPS)
|
||||
- [ ] **Codex CLI** installed (`codex app-server` must be available in PATH)
|
||||
- [x] **Git** available in PATH
|
||||
|
||||
---
|
||||
|
||||
## 1. OrgMyLife Setup (Your Task Board) ✅ DONE
|
||||
|
||||
- [x] PostgreSQL running in Docker on VPS
|
||||
- [x] `.env` configured with all credentials
|
||||
- [x] App deployed and running at https://api.andreknie.de
|
||||
- [x] Login page working (session-based auth)
|
||||
- [x] Nextcloud calendar sync working
|
||||
- [x] Nextcloud ToDo sync working (two-way)
|
||||
- [x] Email/IMAP sync working
|
||||
- [x] Backlog.md sync working
|
||||
- [x] Delete button, AI toggle, sort buttons in UI
|
||||
|
||||
---
|
||||
|
||||
## 2. AI-Orchestrator Setup (NEXT)
|
||||
|
||||
### Install
|
||||
|
||||
- [ ] `cd AI-Orchestrator && pip install -e ".[dev]"`
|
||||
|
||||
### Configure
|
||||
|
||||
- [ ] Copy the OrgMyLife workflow file:
|
||||
```bash
|
||||
cp WORKFLOW.orgmylife.md WORKFLOW.md
|
||||
```
|
||||
- [ ] Edit `WORKFLOW.md`:
|
||||
- `tracker.endpoint` → `https://api.andreknie.de`
|
||||
- `tracker.api_key` → your `API_SECRET` from the VPS .env
|
||||
- `workspace.root` → where you want agent workspaces locally
|
||||
- `codex.command` → your agent CLI command
|
||||
|
||||
### Verify Agent CLI
|
||||
|
||||
- [ ] Confirm your coding agent works:
|
||||
```bash
|
||||
codex --version
|
||||
```
|
||||
- [ ] If using a different agent (Claude, local LLM), update `codex.command`
|
||||
|
||||
### Run
|
||||
|
||||
- [ ] Start the orchestrator:
|
||||
```bash
|
||||
ai-orchestrator WORKFLOW.md --port 8080
|
||||
```
|
||||
- [ ] Check the dashboard: http://localhost:8080
|
||||
- [ ] Verify it connects to OrgMyLife (check logs)
|
||||
|
||||
---
|
||||
|
||||
## 3. End-to-End Test
|
||||
|
||||
- [ ] Mark a coding task as agent_ready (🤖 button in UI)
|
||||
- [ ] Watch the orchestrator pick it up
|
||||
- [ ] Verify the agent creates a workspace and starts working
|
||||
- [ ] Check that OrgMyLife shows the task as "in_progress"
|
||||
- [ ] When the agent finishes, verify the task moves to "completed"
|
||||
|
||||
---
|
||||
|
||||
## 4. Optional Enhancements
|
||||
|
||||
- [x] Add authentication to OrgMyLife (login page + API_SECRET for orchestrator)
|
||||
- [x] Add 🤖 indicator in OrgMyLife frontend for agent_ready tasks
|
||||
- [ ] Set up the orchestrator as a systemd service or Docker container
|
||||
- [ ] Configure workspace hooks to clone repos automatically
|
||||
- [ ] Set up auto-sync (cron) on the VPS
|
||||
- [ ] Set up email notifications when agents complete tasks
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Service | URL | Purpose |
|
||||
|---------|-----|---------|
|
||||
| OrgMyLife | https://api.andreknie.de | Your task board + data source |
|
||||
| AI-Orchestrator | http://localhost:8080 | Agent dispatch + monitoring |
|
||||
| Orchestrator API | http://localhost:8080/api/v1/state | JSON system state |
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `docker compose up -d --build` | Rebuild & restart OrgMyLife on VPS |
|
||||
| `ai-orchestrator WORKFLOW.md --port 8080` | Start orchestrator locally |
|
||||
| `git pull && docker compose up -d --build` | Deploy updates to VPS |
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPEC.md — AI-Orchestrator
|
||||
|
||||
## Purpose
|
||||
|
||||
A Python service implementing the Symphony specification — it polls a task board (OrgMyLife) for agent-ready tasks, dispatches them to a coding agent (Codex CLI or alternative), manages isolated workspaces, and reports results back. Teams manage work; the orchestrator handles execution.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Language | Python 3.11+ (strict typing) |
|
||||
| Package manager | Hatch (pyproject.toml) |
|
||||
| Testing | pytest + pytest-asyncio + hypothesis (property-based) |
|
||||
| Linting | ruff |
|
||||
| Type checking | mypy (strict) |
|
||||
| HTTP | httpx (client), starlette + uvicorn (server) |
|
||||
| Templating | Jinja2 |
|
||||
| Config format | YAML (WORKFLOW.md with front matter) |
|
||||
| Deployment | Docker / systemd |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/ai_orchestrator/
|
||||
├── cli.py # Entry point (ai-orchestrator command)
|
||||
├── config.py # Typed config from WORKFLOW.md front matter
|
||||
├── workflow.py # WORKFLOW.md parser (YAML + Jinja2 template)
|
||||
├── models.py # Data models (Issue, WorkspaceState, etc.)
|
||||
├── orchestrator.py # Poll loop, dispatch, concurrency, retries
|
||||
├── tracker.py # Abstract tracker interface
|
||||
├── tracker_orgmylife.py # OrgMyLife adapter (REST API)
|
||||
├── workspace.py # Per-issue workspace lifecycle and hooks
|
||||
├── agent_runner.py # Codex subprocess integration
|
||||
├── prompt.py # Jinja2 prompt rendering
|
||||
├── http_server.py # Optional REST API + dashboard
|
||||
└── watcher.py # File system watcher for hot-reload
|
||||
|
||||
scripts/pat_manager/ # PAT lifecycle management (check, alert, rotate)
|
||||
├── checker.py # Token expiry checking
|
||||
├── alerter.py # Alert dispatch (OrgMyLife task creation)
|
||||
├── rotator.py # Token rotation logic
|
||||
├── reporter.py # Status reporting
|
||||
├── registry.py # pat-registry.json I/O
|
||||
├── models.py # PAT data models
|
||||
└── errors.py # Error types
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Orchestrator Core
|
||||
- **Workflow Loader** — Parses `WORKFLOW.md` (YAML front matter + Jinja2 prompt template)
|
||||
- **Config Layer** — Typed getters, defaults, env var resolution (`$VAR_NAME`), validation
|
||||
- **Tracker Client** — OrgMyLife REST adapter with pagination and state mapping
|
||||
- **Orchestrator** — Poll loop, task dispatch, concurrency limits, retry with backoff
|
||||
- **Workspace Manager** — Per-issue workspace creation, hooks, cleanup
|
||||
- **Agent Runner** — Codex app-server subprocess integration
|
||||
- **HTTP Server** — REST API (`/api/v1/state`) and dashboard for observability
|
||||
|
||||
### PAT Manager
|
||||
- **Checker** — Reads pat-registry.json, calculates days until expiry
|
||||
- **Alerter** — Creates OrgMyLife tasks for expiring/expired tokens, deduplicates
|
||||
- **Rotator** — Automated token rotation (where supported)
|
||||
- **Reporter** — Generates status reports
|
||||
|
||||
## Configuration
|
||||
|
||||
The orchestrator reads `WORKFLOW.md` in the working directory. Format:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tracker:
|
||||
kind: orgmylife
|
||||
endpoint: https://api.andreknie.de
|
||||
api_key: $ORGMYLIFE_API_SECRET
|
||||
polling:
|
||||
interval_ms: 30000
|
||||
workspace:
|
||||
root: ~/ai_orchestrator_workspaces
|
||||
agent:
|
||||
max_concurrent_agents: 3
|
||||
max_turns: 15
|
||||
codex:
|
||||
command: codex app-server
|
||||
approval_policy: auto-edit
|
||||
---
|
||||
|
||||
<Jinja2 prompt template>
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- All source in `src/ai_orchestrator/` (installed as package)
|
||||
- Tests in `tests/` — mirror source structure with `test_` prefix
|
||||
- Property-based tests use `_properties` suffix (e.g., `test_alerter_properties.py`)
|
||||
- PAT manager scripts in `scripts/pat_manager/` (standalone, own requirements.txt)
|
||||
- File naming: snake_case for Python modules
|
||||
- Config via environment variables (prefixed as needed) or WORKFLOW.md
|
||||
|
||||
## Rules
|
||||
|
||||
1. Never store secrets in code — use env vars or `.secrets` file.
|
||||
2. All tracker interactions go through the abstract `Tracker` interface.
|
||||
3. Property-based tests (hypothesis) for all domain logic.
|
||||
4. Async by default for I/O operations.
|
||||
5. Typed models for all data transfer (no raw dicts at boundaries).
|
||||
6. PAT manager must deduplicate alerts (check existing tasks before creating new ones).
|
||||
|
||||
## Current State
|
||||
|
||||
- Orchestrator core: implemented (poll, dispatch, workspace, agent runner)
|
||||
- OrgMyLife tracker adapter: implemented
|
||||
- PAT manager: fully implemented + tested (checker, alerter, rotator, reporter)
|
||||
- HTTP server: implemented
|
||||
- Deployment: Docker + docker-compose ready, systemd pending
|
||||
- Blocked: needs Codex CLI or alternative agent installed on target server
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
tracker:
|
||||
kind: orgmylife
|
||||
endpoint: https://api.andreknie.de
|
||||
api_key: $ORGMYLIFE_API_SECRET
|
||||
active_states:
|
||||
- Todo
|
||||
- In Progress
|
||||
terminal_states:
|
||||
- Done
|
||||
- completed
|
||||
|
||||
polling:
|
||||
interval_ms: 30000
|
||||
|
||||
workspace:
|
||||
root: ~/ai_orchestrator_workspaces
|
||||
|
||||
hooks:
|
||||
after_create: |
|
||||
echo "Workspace created for {{ issue.identifier }}"
|
||||
before_run: |
|
||||
echo "Starting work on {{ issue.identifier }}"
|
||||
timeout_ms: 60000
|
||||
|
||||
agent:
|
||||
max_concurrent_agents: 3
|
||||
max_turns: 15
|
||||
max_retry_backoff_ms: 300000
|
||||
|
||||
codex:
|
||||
command: codex app-server
|
||||
approval_policy: auto-edit
|
||||
thread_sandbox: local-network
|
||||
turn_sandbox_policy: permissive
|
||||
turn_timeout_ms: 3600000
|
||||
stall_timeout_ms: 300000
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
---
|
||||
|
||||
You are an autonomous coding agent working on task **{{ issue.identifier }}**: {{ issue.title }}
|
||||
|
||||
## Task Details
|
||||
|
||||
{{ issue.description or "No description provided." }}
|
||||
|
||||
## Priority
|
||||
|
||||
{{ issue.priority or "Unset" }}
|
||||
|
||||
{% if issue.labels %}
|
||||
## Labels
|
||||
{% for label in issue.labels %}- {{ label }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if attempt %}
|
||||
## Retry Information
|
||||
This is attempt #{{ attempt }}. Review your previous work in this workspace and continue from where you left off.
|
||||
{% endif %}
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Understand the task requirements fully before making changes.
|
||||
2. Write clean, well-tested code that follows the project's conventions.
|
||||
3. Run the test suite and fix any failures.
|
||||
4. Commit your changes with a clear message.
|
||||
5. Report completion when done.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
tracker:
|
||||
kind: linear
|
||||
api_key: $LINEAR_API_KEY
|
||||
project_slug: your-project-slug
|
||||
active_states:
|
||||
- Todo
|
||||
- In Progress
|
||||
terminal_states:
|
||||
- Done
|
||||
- Closed
|
||||
- Cancelled
|
||||
- Canceled
|
||||
- Duplicate
|
||||
|
||||
polling:
|
||||
interval_ms: 30000
|
||||
|
||||
workspace:
|
||||
root: ~/symphony_workspaces
|
||||
|
||||
hooks:
|
||||
after_create: |
|
||||
git clone $REPO_URL .
|
||||
git checkout -b symphony/{{ issue.identifier }}
|
||||
before_run: |
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
timeout_ms: 120000
|
||||
|
||||
agent:
|
||||
max_concurrent_agents: 5
|
||||
max_turns: 20
|
||||
max_retry_backoff_ms: 300000
|
||||
max_concurrent_agents_by_state:
|
||||
"in progress": 3
|
||||
"todo": 2
|
||||
|
||||
codex:
|
||||
command: codex app-server
|
||||
approval_policy: auto-edit
|
||||
thread_sandbox: local-network
|
||||
turn_sandbox_policy: permissive
|
||||
turn_timeout_ms: 3600000
|
||||
stall_timeout_ms: 300000
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
---
|
||||
|
||||
You are an autonomous coding agent working on issue **{{ issue.identifier }}**: {{ issue.title }}
|
||||
|
||||
## Issue Details
|
||||
|
||||
{{ issue.description or "No description provided." }}
|
||||
|
||||
## Priority
|
||||
|
||||
{{ issue.priority or "Unset" }}
|
||||
|
||||
## Labels
|
||||
|
||||
{% for label in issue.labels %}- {{ label }}
|
||||
{% endfor %}
|
||||
|
||||
{% if issue.blocked_by %}
|
||||
## Blockers
|
||||
{% for blocker in issue.blocked_by %}- {{ blocker.identifier }} ({{ blocker.state }})
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if attempt %}
|
||||
## Retry Information
|
||||
This is attempt #{{ attempt }}. Review your previous work in this workspace and continue from where you left off. Check git log and status for context.
|
||||
{% endif %}
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Understand the issue requirements fully before making changes.
|
||||
2. Write clean, well-tested code that follows the project's conventions.
|
||||
3. Run the test suite and fix any failures.
|
||||
4. Create a pull request with a clear description of your changes.
|
||||
5. Move the Linear issue to "Human Review" when ready.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
tracker:
|
||||
kind: orgmylife
|
||||
endpoint: https://api.andreknie.de
|
||||
api_key: $ORGMYLIFE_API_SECRET
|
||||
active_states:
|
||||
- Todo
|
||||
- In Progress
|
||||
terminal_states:
|
||||
- Done
|
||||
- completed
|
||||
|
||||
polling:
|
||||
interval_ms: 30000
|
||||
|
||||
workspace:
|
||||
root: ~/ai_orchestrator_workspaces
|
||||
|
||||
hooks:
|
||||
after_create: |
|
||||
echo "Workspace created for {{ issue.identifier }}"
|
||||
before_run: |
|
||||
echo "Starting work on {{ issue.identifier }}"
|
||||
timeout_ms: 60000
|
||||
|
||||
agent:
|
||||
max_concurrent_agents: 3
|
||||
max_turns: 15
|
||||
max_retry_backoff_ms: 300000
|
||||
|
||||
codex:
|
||||
command: codex app-server
|
||||
approval_policy: auto-edit
|
||||
thread_sandbox: local-network
|
||||
turn_sandbox_policy: permissive
|
||||
turn_timeout_ms: 3600000
|
||||
stall_timeout_ms: 300000
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
---
|
||||
|
||||
You are an autonomous coding agent working on task **{{ issue.identifier }}**: {{ issue.title }}
|
||||
|
||||
## Task Details
|
||||
|
||||
{{ issue.description or "No description provided." }}
|
||||
|
||||
## Priority
|
||||
|
||||
{{ issue.priority or "Unset" }}
|
||||
|
||||
{% if issue.labels %}
|
||||
## Labels
|
||||
{% for label in issue.labels %}- {{ label }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if attempt %}
|
||||
## Retry Information
|
||||
This is attempt #{{ attempt }}. Review your previous work in this workspace and continue from where you left off.
|
||||
{% endif %}
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Understand the task requirements fully before making changes.
|
||||
2. Write clean, well-tested code that follows the project's conventions.
|
||||
3. Run the test suite and fix any failures.
|
||||
4. Commit your changes with a clear message.
|
||||
5. Report completion when done.
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
orchestrator:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
ORGMYLIFE_API_SECRET: ${ORGMYLIFE_API_SECRET}
|
||||
volumes:
|
||||
- workspaces:/workspaces
|
||||
|
||||
volumes:
|
||||
workspaces:
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-pipeline-token",
|
||||
"expiry_date": "2025-08-15",
|
||||
"renewal_method": "auto"
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-api-access",
|
||||
"expiry_date": "2025-07-20",
|
||||
"renewal_method": "manual"
|
||||
},
|
||||
{
|
||||
"service": "Confluence",
|
||||
"token_name": "confluence-bot",
|
||||
"expiry_date": "2025-09-01",
|
||||
"renewal_method": "manual"
|
||||
},
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "orchestrator-api-key",
|
||||
"expiry_date": null,
|
||||
"renewal_method": "manual"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"tokens": []
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ai-orchestrator"
|
||||
version = "0.1.0"
|
||||
description = "A Python implementation of the Symphony specification for orchestrating coding agents"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "DoctoDre" },
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"httpx>=0.27.0",
|
||||
"pyyaml>=6.0",
|
||||
"jinja2>=3.1",
|
||||
"watchdog>=4.0",
|
||||
"uvicorn>=0.29.0",
|
||||
"starlette>=0.37.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.4.0",
|
||||
"mypy>=1.10",
|
||||
"types-PyYAML>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ai-orchestrator = "ai_orchestrator.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/ai_orchestrator"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1 @@
|
||||
"""Scripts package for the AI-Orchestrator project."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""PAT Manager - Automated Personal Access Token lifecycle management."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Main orchestrator for the PAT Manager system.
|
||||
|
||||
Entry point for running the PAT lifecycle check workflow.
|
||||
Coordinates all modules: registry loading, expiry checking, rotation,
|
||||
secret updates, alerting, and summary reporting.
|
||||
|
||||
Usage:
|
||||
python -m scripts.pat_manager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .alerter import AlertSender
|
||||
from .checker import ExpiryChecker
|
||||
from .errors import AlertError, RegistryValidationError, SecretUpdateError
|
||||
from .models import AlertChannel, CheckResult, PatEntry, PatStatus, RotationResult
|
||||
from .registry import PatRegistry
|
||||
from .reporter import SummaryReporter
|
||||
from .rotator import GitLabRotator
|
||||
from .secret_updater import SecretUpdater
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Repository root: __main__.py is at scripts/pat_manager/__main__.py
|
||||
# so parent.parent.parent gets us to the repo root
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||
REGISTRY_PATH = REPO_ROOT / "pat-registry.json"
|
||||
|
||||
|
||||
def _load_secrets() -> dict[str, str]:
|
||||
"""Load all PAT secrets from environment variables.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping secret names to their values.
|
||||
"""
|
||||
secret_names = [
|
||||
"GITLAB_PAT",
|
||||
"JIRA_PAT",
|
||||
"CONFLUENCE_PAT",
|
||||
"ORGMYLIFE_API_KEY",
|
||||
"GH_TOKEN",
|
||||
]
|
||||
return {name: os.environ.get(name, "") for name in secret_names}
|
||||
|
||||
|
||||
def _mask_secrets(secrets: dict[str, str]) -> None:
|
||||
"""Mask all secret values in GitHub Actions logs.
|
||||
|
||||
Prints ::add-mask:: directives for each non-empty secret value
|
||||
so that GitHub Actions replaces any occurrence with ***.
|
||||
|
||||
Args:
|
||||
secrets: Dictionary of secret name → value pairs.
|
||||
"""
|
||||
for value in secrets.values():
|
||||
if value:
|
||||
print(f"::add-mask::{value}")
|
||||
|
||||
|
||||
def _load_config() -> dict[str, str]:
|
||||
"""Load configuration from environment variables.
|
||||
|
||||
Returns:
|
||||
Dictionary of configuration values for alert sending.
|
||||
"""
|
||||
return {
|
||||
"alert_channel": os.environ.get("ALERT_CHANNEL", "orgmylife"),
|
||||
"orgmylife_url": os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
|
||||
"orgmylife_user": os.environ.get("ORGMYLIFE_USER", ""),
|
||||
"orgmylife_pass": os.environ.get("ORGMYLIFE_PASS", ""),
|
||||
}
|
||||
|
||||
|
||||
def _get_alert_channel(config: dict[str, str]) -> AlertChannel:
|
||||
"""Determine the alert channel from configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with 'alert_channel' key.
|
||||
|
||||
Returns:
|
||||
The AlertChannel enum value.
|
||||
"""
|
||||
channel_str = config.get("alert_channel", "orgmylife").lower()
|
||||
if channel_str == "email":
|
||||
return AlertChannel.EMAIL
|
||||
return AlertChannel.ORGMYLIFE
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
"""Main orchestrator function coordinating all PAT Manager modules.
|
||||
|
||||
Flow:
|
||||
1. Mask all token values with ::add-mask:: before any operations
|
||||
2. Load the PAT registry (create empty if not found)
|
||||
3. Run expiry checks on all entries
|
||||
4. For GitLab tokens classified as "expiring soon": attempt rotation
|
||||
5. For successful rotations: update GitHub secret, update registry expiry_date
|
||||
6. If rotation succeeds but secret update fails: trigger alert flow
|
||||
7. For manual-renewal tokens classified as "expiring soon" or "expired": send alerts
|
||||
8. For GitLab tokens that failed rotation: also send alerts
|
||||
9. Generate and print summary report
|
||||
10. Return appropriate exit code
|
||||
|
||||
Returns:
|
||||
Exit code:
|
||||
0 - All checks passed, all actions succeeded
|
||||
1 - One or more alerts could not be delivered
|
||||
2 - Registry validation failed (malformed registry file)
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# Step 1: Load and mask secrets
|
||||
secrets = _load_secrets()
|
||||
_mask_secrets(secrets)
|
||||
|
||||
config = _load_config()
|
||||
|
||||
# Step 2: Load the PAT registry
|
||||
registry = PatRegistry()
|
||||
try:
|
||||
if not REGISTRY_PATH.exists():
|
||||
logger.info("Registry file not found, creating empty registry at %s", REGISTRY_PATH)
|
||||
registry.create_empty(REGISTRY_PATH)
|
||||
entries: list[PatEntry] = []
|
||||
else:
|
||||
entries = registry.load(REGISTRY_PATH)
|
||||
logger.info("Loaded %d entries from registry", len(entries))
|
||||
except (json.JSONDecodeError, RegistryValidationError) as exc:
|
||||
logger.error("Registry validation failed: %s", exc)
|
||||
print(f"::error::Registry validation failed: {exc}")
|
||||
return 2
|
||||
|
||||
# If no entries, just report and exit
|
||||
if not entries:
|
||||
reporter = SummaryReporter()
|
||||
summary = reporter.report([], [])
|
||||
print(summary)
|
||||
return 0
|
||||
|
||||
# Step 3: Run expiry checks on all entries
|
||||
checker = ExpiryChecker()
|
||||
check_results = await checker.check_all(entries, secrets)
|
||||
|
||||
# Step 4-6: Handle GitLab tokens classified as "expiring soon"
|
||||
rotator = GitLabRotator()
|
||||
rotations: list[RotationResult] = []
|
||||
alert_needed: list[CheckResult] = []
|
||||
alert_delivery_failed = False
|
||||
|
||||
gh_token = secrets.get("GH_TOKEN", "")
|
||||
repo_owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "")
|
||||
repo_name = os.environ.get("GITHUB_REPOSITORY_NAME", "")
|
||||
|
||||
# If GITHUB_REPOSITORY is set (format: owner/repo), parse it
|
||||
github_repository = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repository and "/" in github_repository:
|
||||
parts = github_repository.split("/", 1)
|
||||
if not repo_owner:
|
||||
repo_owner = parts[0]
|
||||
if not repo_name:
|
||||
repo_name = parts[1]
|
||||
|
||||
# Map service to GitHub secret name for updates
|
||||
service_to_secret: dict[str, str] = {
|
||||
"GitLab": "GITLAB_PAT",
|
||||
}
|
||||
|
||||
for result in check_results:
|
||||
if (
|
||||
result.entry.service == "GitLab"
|
||||
and result.entry.renewal_method == "auto"
|
||||
and result.status == PatStatus.EXPIRING_SOON
|
||||
):
|
||||
# Attempt rotation for GitLab tokens expiring soon
|
||||
old_expiry = result.entry.expiry_date
|
||||
token_id = os.environ.get("GITLAB_TOKEN_ID", "")
|
||||
current_token = secrets.get("GITLAB_PAT", "")
|
||||
|
||||
if not token_id or not current_token:
|
||||
logger.warning(
|
||||
"Cannot rotate %s: missing GITLAB_TOKEN_ID or GITLAB_PAT",
|
||||
result.entry.token_name,
|
||||
)
|
||||
rotation_result = RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message="Missing GITLAB_TOKEN_ID or GITLAB_PAT environment variable",
|
||||
entry=result.entry,
|
||||
old_expiry_date=old_expiry,
|
||||
)
|
||||
rotations.append(rotation_result)
|
||||
alert_needed.append(result)
|
||||
continue
|
||||
|
||||
rotation_result = await rotator.rotate(
|
||||
token_id=token_id,
|
||||
current_token=current_token,
|
||||
expiry_date=old_expiry,
|
||||
)
|
||||
rotation_result.entry = result.entry
|
||||
rotation_result.old_expiry_date = old_expiry
|
||||
|
||||
if rotation_result.success:
|
||||
# Log rotation event (Req 3.6)
|
||||
print(
|
||||
f"Rotated GitLab token '{result.entry.token_name}': "
|
||||
f"old expiry={old_expiry}, new expiry={rotation_result.new_expiry_date}"
|
||||
)
|
||||
|
||||
# Mask the new token value immediately
|
||||
if rotation_result.new_token:
|
||||
print(f"::add-mask::{rotation_result.new_token}")
|
||||
|
||||
# Step 5: Update GitHub secret with new token
|
||||
secret_update_success = False
|
||||
if gh_token and repo_owner and repo_name:
|
||||
secret_name = service_to_secret.get(result.entry.service, "GITLAB_PAT")
|
||||
updater = SecretUpdater(
|
||||
github_token=gh_token,
|
||||
repo_owner=repo_owner,
|
||||
repo_name=repo_name,
|
||||
)
|
||||
try:
|
||||
await updater.update_secret(secret_name, rotation_result.new_token or "")
|
||||
secret_update_success = True
|
||||
logger.info(
|
||||
"Updated GitHub secret '%s' after rotation of '%s'",
|
||||
secret_name,
|
||||
result.entry.token_name,
|
||||
)
|
||||
except SecretUpdateError as exc:
|
||||
logger.error(
|
||||
"Secret update failed for '%s' after rotation: %s",
|
||||
result.entry.token_name,
|
||||
exc,
|
||||
)
|
||||
secret_update_success = False
|
||||
else:
|
||||
logger.error(
|
||||
"Cannot update GitHub secret: missing GH_TOKEN, "
|
||||
"GITHUB_REPOSITORY_OWNER, or GITHUB_REPOSITORY_NAME"
|
||||
)
|
||||
secret_update_success = False
|
||||
|
||||
if secret_update_success:
|
||||
# Update registry with new expiry date
|
||||
result.entry.expiry_date = rotation_result.new_expiry_date
|
||||
registry.save(REGISTRY_PATH, entries)
|
||||
else:
|
||||
# Step 6: Rotation succeeded but secret update failed → alert
|
||||
alert_needed.append(result)
|
||||
else:
|
||||
# Rotation failed → alert
|
||||
logger.warning(
|
||||
"Rotation failed for '%s': %s",
|
||||
result.entry.token_name,
|
||||
rotation_result.error_message,
|
||||
)
|
||||
alert_needed.append(result)
|
||||
|
||||
rotations.append(rotation_result)
|
||||
|
||||
elif (
|
||||
result.entry.renewal_method == "manual"
|
||||
and result.status in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED)
|
||||
):
|
||||
# Step 7: Manual-renewal tokens expiring soon or expired → alert
|
||||
alert_needed.append(result)
|
||||
|
||||
elif (
|
||||
result.entry.service == "GitLab"
|
||||
and result.status == PatStatus.EXPIRED
|
||||
):
|
||||
# Expired GitLab tokens cannot be auto-rotated → alert
|
||||
alert_needed.append(result)
|
||||
|
||||
# Step 8: Send alerts for all tokens that need them
|
||||
if alert_needed:
|
||||
alert_channel = _get_alert_channel(config)
|
||||
alert_config = {
|
||||
"orgmylife_url": config.get("orgmylife_url", ""),
|
||||
"orgmylife_api_key": secrets.get("ORGMYLIFE_API_KEY", ""),
|
||||
"orgmylife_user": config.get("orgmylife_user", ""),
|
||||
"orgmylife_pass": config.get("orgmylife_pass", ""),
|
||||
}
|
||||
alerter = AlertSender(channel=alert_channel, config=alert_config)
|
||||
|
||||
for result in alert_needed:
|
||||
try:
|
||||
alert_result = await alerter.send_alert(result)
|
||||
if alert_result.sent:
|
||||
logger.info(
|
||||
"Alert sent for %s/%s via %s",
|
||||
result.entry.service,
|
||||
result.entry.token_name,
|
||||
alert_channel.value,
|
||||
)
|
||||
except AlertError as exc:
|
||||
logger.error(
|
||||
"Alert delivery failed for %s/%s: %s",
|
||||
result.entry.service,
|
||||
result.entry.token_name,
|
||||
exc,
|
||||
)
|
||||
alert_delivery_failed = True
|
||||
|
||||
# Step 9: Generate and print summary report
|
||||
reporter = SummaryReporter()
|
||||
summary = reporter.report(check_results, rotations)
|
||||
print(summary)
|
||||
|
||||
# Step 10: Return appropriate exit code
|
||||
if alert_delivery_failed:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1,437 @@
|
||||
"""Alert Sender module for the PAT Manager system.
|
||||
|
||||
Sends alerts via OrgMyLife task creation or email when PATs require
|
||||
manual renewal. Supports idempotent alerting by checking for existing
|
||||
open tasks before creating duplicates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import AlertError
|
||||
from .models import AlertChannel, AlertResult, CheckResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertSender:
|
||||
"""Sends alert notifications for PATs requiring manual renewal.
|
||||
|
||||
Supports two channels:
|
||||
- OrgMyLife: Creates a task via the OrgMyLife API
|
||||
- Email: Sends an email via SMTP
|
||||
|
||||
Attributes:
|
||||
channel: The alert delivery channel (OrgMyLife or email).
|
||||
config: Configuration dict containing API keys, email settings, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, channel: AlertChannel, config: dict) -> None:
|
||||
"""Initialize the AlertSender.
|
||||
|
||||
Args:
|
||||
channel: The alert channel to use (OrgMyLife or email).
|
||||
config: Configuration dictionary. Expected keys depend on channel:
|
||||
OrgMyLife:
|
||||
- orgmylife_url: Base URL (default: https://orgmylife.app)
|
||||
- orgmylife_api_key: API key for X-API-Key header
|
||||
- orgmylife_user: Basic auth username (alternative)
|
||||
- orgmylife_pass: Basic auth password (alternative)
|
||||
Email:
|
||||
- smtp_host: SMTP server hostname
|
||||
- smtp_port: SMTP server port
|
||||
- smtp_user: SMTP username
|
||||
- smtp_pass: SMTP password
|
||||
- alert_email_to: Recipient email address
|
||||
"""
|
||||
self.channel = channel
|
||||
self.config = config
|
||||
|
||||
async def send_alert(self, result: CheckResult) -> AlertResult:
|
||||
"""Send an alert for a PAT that requires manual renewal.
|
||||
|
||||
Creates an OrgMyLife task or sends an email based on the configured
|
||||
channel. Implements retry logic (1 retry with 2s backoff) on failure.
|
||||
|
||||
Args:
|
||||
result: The CheckResult for the PAT requiring an alert.
|
||||
|
||||
Returns:
|
||||
AlertResult indicating whether the alert was sent successfully.
|
||||
|
||||
Raises:
|
||||
AlertError: If the alert cannot be delivered after retries.
|
||||
"""
|
||||
if self.channel == AlertChannel.ORGMYLIFE:
|
||||
return await self._send_orgmylife_alert(result)
|
||||
elif self.channel == AlertChannel.EMAIL:
|
||||
return await self._send_email_alert(result)
|
||||
else:
|
||||
raise AlertError(
|
||||
channel=self.channel.value,
|
||||
reason=f"Unsupported alert channel: {self.channel.value}",
|
||||
)
|
||||
|
||||
async def check_existing_task(
|
||||
self, service: str, token_name: str, expiry_date: str
|
||||
) -> bool:
|
||||
"""Check if an open OrgMyLife task already exists for this PAT.
|
||||
|
||||
Queries the OrgMyLife API for open tasks and checks if one matches
|
||||
the expected title pattern for this PAT and expiry date.
|
||||
|
||||
Args:
|
||||
service: The service name (e.g. "Jira").
|
||||
token_name: The token identifier.
|
||||
expiry_date: The expiry date string (ISO 8601).
|
||||
|
||||
Returns:
|
||||
True if a matching open task already exists, False otherwise.
|
||||
"""
|
||||
base_url = self.config.get(
|
||||
"orgmylife_url",
|
||||
os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
|
||||
)
|
||||
url = f"{base_url}/api/tasks"
|
||||
headers = self._get_orgmylife_headers()
|
||||
|
||||
expected_title = f"[PAT Renewal] {service} - {token_name}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
# Cannot verify existing tasks — proceed with creation
|
||||
logger.warning(
|
||||
"Could not check existing tasks (HTTP %d), proceeding with creation",
|
||||
response.status_code,
|
||||
)
|
||||
return False
|
||||
|
||||
tasks = response.json()
|
||||
# Search for an open task with matching title
|
||||
if isinstance(tasks, list):
|
||||
for task in tasks:
|
||||
title = task.get("title", "")
|
||||
if title == expected_title:
|
||||
return True
|
||||
elif isinstance(tasks, dict):
|
||||
# Handle paginated or wrapped response
|
||||
task_list = tasks.get("tasks", tasks.get("items", []))
|
||||
for task in task_list:
|
||||
title = task.get("title", "")
|
||||
if title == expected_title:
|
||||
return True
|
||||
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
logger.warning(
|
||||
"Could not check existing tasks (%s), proceeding with creation",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
def _get_orgmylife_headers(self) -> dict[str, str]:
|
||||
"""Build authentication headers for OrgMyLife API.
|
||||
|
||||
Prefers API key authentication. Falls back to basic auth if
|
||||
API key is not available.
|
||||
|
||||
Returns:
|
||||
Dictionary of HTTP headers for authentication.
|
||||
"""
|
||||
api_key = self.config.get(
|
||||
"orgmylife_api_key",
|
||||
os.environ.get("ORGMYLIFE_API_KEY", ""),
|
||||
)
|
||||
|
||||
if api_key:
|
||||
return {
|
||||
"X-API-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Fall back to basic auth
|
||||
user = self.config.get(
|
||||
"orgmylife_user",
|
||||
os.environ.get("ORGMYLIFE_USER", ""),
|
||||
)
|
||||
password = self.config.get(
|
||||
"orgmylife_pass",
|
||||
os.environ.get("ORGMYLIFE_PASS", ""),
|
||||
)
|
||||
|
||||
if user and password:
|
||||
import base64
|
||||
|
||||
credentials = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
return {"Content-Type": "application/json"}
|
||||
|
||||
def _build_task_title(self, result: CheckResult) -> str:
|
||||
"""Build the OrgMyLife task title for a PAT alert.
|
||||
|
||||
Args:
|
||||
result: The CheckResult for the PAT.
|
||||
|
||||
Returns:
|
||||
Task title in format: [PAT Renewal] {service} - {token_name}
|
||||
"""
|
||||
return f"[PAT Renewal] {result.entry.service} - {result.entry.token_name}"
|
||||
|
||||
def _build_task_description(self, result: CheckResult) -> str:
|
||||
"""Build the OrgMyLife task description for a PAT alert.
|
||||
|
||||
Args:
|
||||
result: The CheckResult for the PAT.
|
||||
|
||||
Returns:
|
||||
Task description with days remaining and renewal instructions.
|
||||
"""
|
||||
service = result.entry.service
|
||||
token_name = result.entry.token_name
|
||||
expiry_date = result.entry.expiry_date or "unknown"
|
||||
days_remaining = result.days_remaining if result.days_remaining is not None else 0
|
||||
|
||||
if days_remaining == 0:
|
||||
return (
|
||||
f"Token has expired (0 days remaining). Manual renewal required.\n\n"
|
||||
f"Service: {service}\n"
|
||||
f"Token: {token_name}\n"
|
||||
f"Expiry date: {expiry_date}"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Token expires in {days_remaining} days ({expiry_date}). "
|
||||
f"Manual renewal required.\n\n"
|
||||
f"Service: {service}\n"
|
||||
f"Token: {token_name}\n"
|
||||
f"Expiry date: {expiry_date}"
|
||||
)
|
||||
|
||||
async def _send_orgmylife_alert(self, result: CheckResult) -> AlertResult:
|
||||
"""Send an alert by creating an OrgMyLife task.
|
||||
|
||||
Checks for existing tasks first (idempotent alerting), then creates
|
||||
a new task if none exists. Retries once with 2s backoff on failure.
|
||||
|
||||
Args:
|
||||
result: The CheckResult for the PAT.
|
||||
|
||||
Returns:
|
||||
AlertResult indicating success or failure.
|
||||
|
||||
Raises:
|
||||
AlertError: If task creation fails after retry.
|
||||
"""
|
||||
service = result.entry.service
|
||||
token_name = result.entry.token_name
|
||||
expiry_date = result.entry.expiry_date or ""
|
||||
|
||||
# Check for existing task (idempotent alerting)
|
||||
if expiry_date:
|
||||
existing = await self.check_existing_task(service, token_name, expiry_date)
|
||||
if existing:
|
||||
logger.info(
|
||||
"Open task already exists for %s/%s, skipping creation",
|
||||
service,
|
||||
token_name,
|
||||
)
|
||||
return AlertResult(
|
||||
sent=False,
|
||||
channel=AlertChannel.ORGMYLIFE,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
base_url = self.config.get(
|
||||
"orgmylife_url",
|
||||
os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
|
||||
)
|
||||
url = f"{base_url}/api/tasks"
|
||||
headers = self._get_orgmylife_headers()
|
||||
|
||||
title = self._build_task_title(result)
|
||||
description = self._build_task_description(result)
|
||||
payload = {"title": title, "description": description}
|
||||
|
||||
# Attempt with 1 retry and 2s backoff
|
||||
last_error: str | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
logger.info(
|
||||
"Created OrgMyLife task for %s/%s: %s",
|
||||
service,
|
||||
token_name,
|
||||
title,
|
||||
)
|
||||
return AlertResult(
|
||||
sent=True,
|
||||
channel=AlertChannel.ORGMYLIFE,
|
||||
error_message=None,
|
||||
)
|
||||
else:
|
||||
last_error = (
|
||||
f"OrgMyLife API returned HTTP {response.status_code}"
|
||||
)
|
||||
logger.warning(
|
||||
"OrgMyLife task creation failed (attempt %d): HTTP %d",
|
||||
attempt + 1,
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
last_error = f"OrgMyLife API unreachable: {exc}"
|
||||
logger.warning(
|
||||
"OrgMyLife task creation failed (attempt %d): %s",
|
||||
attempt + 1,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Retry after 2s backoff (only on first attempt)
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# All attempts failed
|
||||
error_msg = last_error or "Unknown error creating OrgMyLife task"
|
||||
logger.error("Alert delivery failed via orgmylife: %s", error_msg)
|
||||
raise AlertError(channel="orgmylife", reason=error_msg)
|
||||
|
||||
async def _send_email_alert(self, result: CheckResult) -> AlertResult:
|
||||
"""Send an alert via email using SMTP.
|
||||
|
||||
Retries once with 2s backoff on failure.
|
||||
|
||||
Args:
|
||||
result: The CheckResult for the PAT.
|
||||
|
||||
Returns:
|
||||
AlertResult indicating success or failure.
|
||||
|
||||
Raises:
|
||||
AlertError: If email sending fails after retry.
|
||||
"""
|
||||
smtp_host = self.config.get(
|
||||
"smtp_host", os.environ.get("SMTP_HOST", "")
|
||||
)
|
||||
smtp_port = int(
|
||||
self.config.get("smtp_port", os.environ.get("SMTP_PORT", "587"))
|
||||
)
|
||||
smtp_user = self.config.get(
|
||||
"smtp_user", os.environ.get("SMTP_USER", "")
|
||||
)
|
||||
smtp_pass = self.config.get(
|
||||
"smtp_pass", os.environ.get("SMTP_PASS", "")
|
||||
)
|
||||
email_to = self.config.get(
|
||||
"alert_email_to", os.environ.get("ALERT_EMAIL_TO", "")
|
||||
)
|
||||
|
||||
if not smtp_host or not email_to:
|
||||
error_msg = "SMTP configuration incomplete (missing smtp_host or alert_email_to)"
|
||||
logger.error("Alert delivery failed via email: %s", error_msg)
|
||||
raise AlertError(channel="email", reason=error_msg)
|
||||
|
||||
service = result.entry.service
|
||||
token_name = result.entry.token_name
|
||||
expiry_date = result.entry.expiry_date or "unknown"
|
||||
days_remaining = result.days_remaining if result.days_remaining is not None else 0
|
||||
|
||||
subject = f"[PAT Renewal] {service} - {token_name}"
|
||||
|
||||
if days_remaining == 0:
|
||||
body = (
|
||||
f"Token has expired (0 days remaining). Manual renewal required.\n\n"
|
||||
f"Service: {service}\n"
|
||||
f"Token: {token_name}\n"
|
||||
f"Expiry date: {expiry_date}\n\n"
|
||||
f"Please renew this token as soon as possible."
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"Token expires in {days_remaining} days ({expiry_date}). "
|
||||
f"Manual renewal required.\n\n"
|
||||
f"Service: {service}\n"
|
||||
f"Token: {token_name}\n"
|
||||
f"Expiry date: {expiry_date}\n\n"
|
||||
f"Please renew this token before it expires."
|
||||
)
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_user or "pat-manager@noreply.local"
|
||||
msg["To"] = email_to
|
||||
msg.set_content(body)
|
||||
|
||||
# Attempt with 1 retry and 2s backoff
|
||||
last_error: str | None = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._send_smtp_message, smtp_host, smtp_port, smtp_user, smtp_pass, msg
|
||||
)
|
||||
logger.info(
|
||||
"Sent email alert for %s/%s to %s",
|
||||
service,
|
||||
token_name,
|
||||
email_to,
|
||||
)
|
||||
return AlertResult(
|
||||
sent=True,
|
||||
channel=AlertChannel.EMAIL,
|
||||
error_message=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
last_error = f"Email sending failed: {exc}"
|
||||
logger.warning(
|
||||
"Email alert failed (attempt %d): %s",
|
||||
attempt + 1,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Retry after 2s backoff (only on first attempt)
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# All attempts failed
|
||||
error_msg = last_error or "Unknown error sending email"
|
||||
logger.error("Alert delivery failed via email: %s", error_msg)
|
||||
raise AlertError(channel="email", reason=error_msg)
|
||||
|
||||
@staticmethod
|
||||
def _send_smtp_message(
|
||||
host: str, port: int, user: str, password: str, msg: EmailMessage
|
||||
) -> None:
|
||||
"""Send an email message via SMTP (blocking, run in thread).
|
||||
|
||||
Args:
|
||||
host: SMTP server hostname.
|
||||
port: SMTP server port.
|
||||
user: SMTP username for authentication.
|
||||
password: SMTP password for authentication.
|
||||
msg: The email message to send.
|
||||
"""
|
||||
with smtplib.SMTP(host, port, timeout=30) as server:
|
||||
server.ehlo()
|
||||
if port != 25:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if user and password:
|
||||
server.login(user, password)
|
||||
server.send_message(msg)
|
||||
@@ -0,0 +1,560 @@
|
||||
"""Expiry Checker module for the PAT Manager system.
|
||||
|
||||
Queries each service API to determine PAT status and classifies tokens
|
||||
as healthy, expiring soon, expired, or check failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import CheckResult, PatEntry, PatStatus
|
||||
|
||||
|
||||
def classify_by_date(
|
||||
expiry_date: str, reference_date: date, window: int
|
||||
) -> tuple[PatStatus, int]:
|
||||
"""Pure function that classifies a PAT based on its expiry date.
|
||||
|
||||
Compares the expiry_date against the reference_date and window to determine
|
||||
the token's status.
|
||||
|
||||
Args:
|
||||
expiry_date: ISO 8601 date string (YYYY-MM-DD) representing token expiry.
|
||||
reference_date: The date to compare against (typically today).
|
||||
window: Number of days before expiry to classify as "expiring soon".
|
||||
|
||||
Returns:
|
||||
A tuple of (status, days_remaining) where:
|
||||
- status is one of PatStatus.EXPIRED, EXPIRING_SOON, or HEALTHY
|
||||
- days_remaining is max(0, days until expiry)
|
||||
|
||||
Classification rules:
|
||||
- "expired" if expiry_date < reference_date
|
||||
- "expiring soon" if reference_date <= expiry_date <= reference_date + timedelta(days=window)
|
||||
- "healthy" if expiry_date > reference_date + timedelta(days=window)
|
||||
"""
|
||||
parsed_expiry = date.fromisoformat(expiry_date)
|
||||
days_remaining = (parsed_expiry - reference_date).days
|
||||
|
||||
if parsed_expiry < reference_date:
|
||||
return PatStatus.EXPIRED, 0
|
||||
elif parsed_expiry <= reference_date + timedelta(days=window):
|
||||
return PatStatus.EXPIRING_SOON, days_remaining
|
||||
else:
|
||||
return PatStatus.HEALTHY, days_remaining
|
||||
|
||||
|
||||
class ExpiryChecker:
|
||||
"""Checks PAT expiry status across all registered services.
|
||||
|
||||
Attributes:
|
||||
expiry_window_days: Number of days before expiry to classify as "expiring soon".
|
||||
timeout: HTTP request timeout in seconds.
|
||||
max_retries: Maximum number of retry attempts for failed API calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
expiry_window_days: int = 14,
|
||||
timeout: int = 30,
|
||||
max_retries: int = 2,
|
||||
) -> None:
|
||||
self.expiry_window_days = expiry_window_days
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
|
||||
async def check_all(
|
||||
self, entries: list[PatEntry], secrets: dict[str, str]
|
||||
) -> list[CheckResult]:
|
||||
"""Check expiry status for all PAT entries.
|
||||
|
||||
Iterates all entries and calls the appropriate service-specific check
|
||||
method for each one. Returns exactly one CheckResult per entry.
|
||||
|
||||
Args:
|
||||
entries: List of PAT entries to check.
|
||||
secrets: Mapping of environment variable names to token values
|
||||
(e.g. {"GITLAB_PAT": "glpat-xxx", "JIRA_PAT": "token"}).
|
||||
|
||||
Returns:
|
||||
A list of CheckResult objects, one per entry, each with exactly
|
||||
one status from {healthy, expiring soon, expired, check failed}.
|
||||
"""
|
||||
results: list[CheckResult] = []
|
||||
|
||||
# Map service names to their secret environment variable names
|
||||
service_secret_map: dict[str, str] = {
|
||||
"GitLab": "GITLAB_PAT",
|
||||
"Jira": "JIRA_PAT",
|
||||
"Confluence": "CONFLUENCE_PAT",
|
||||
"OrgMyLife": "ORGMYLIFE_API_KEY",
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
secret_key = service_secret_map.get(entry.service, "")
|
||||
token = secrets.get(secret_key, "")
|
||||
|
||||
if not token:
|
||||
results.append(
|
||||
CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"No secret found for service '{entry.service}' (expected env var: {secret_key})",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if entry.service == "GitLab":
|
||||
result = await self.check_gitlab(entry, token)
|
||||
elif entry.service == "Jira":
|
||||
result = await self.check_jira(entry, token)
|
||||
elif entry.service == "Confluence":
|
||||
result = await self.check_confluence(entry, token)
|
||||
elif entry.service == "OrgMyLife":
|
||||
result = await self.check_orgmylife(entry, token)
|
||||
else:
|
||||
result = CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Unsupported service: {entry.service}",
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Unexpected error checking {entry.service}/{entry.token_name}: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
service: str,
|
||||
token_name: str,
|
||||
) -> httpx.Response:
|
||||
"""Make an HTTP request with retry logic on timeout/connection errors.
|
||||
|
||||
Retries up to self.max_retries times with 5s backoff between attempts.
|
||||
|
||||
Args:
|
||||
client: The httpx async client to use.
|
||||
method: HTTP method (GET, POST, etc.).
|
||||
url: The full URL to request.
|
||||
headers: Request headers.
|
||||
service: Service name for error messages.
|
||||
token_name: Token name for error messages.
|
||||
|
||||
Returns:
|
||||
The HTTP response on success.
|
||||
|
||||
Raises:
|
||||
httpx.TimeoutException: If all retries are exhausted due to timeouts.
|
||||
httpx.ConnectError: If all retries are exhausted due to connection errors.
|
||||
"""
|
||||
last_exception: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = await client.request(method, url, headers=headers)
|
||||
return response
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
last_exception = exc
|
||||
if attempt < self.max_retries:
|
||||
await asyncio.sleep(5)
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
def _check_stale_expiry(
|
||||
self, entry: PatEntry, reference_date: date
|
||||
) -> bool:
|
||||
"""Check if the stored expiry_date has passed (stale).
|
||||
|
||||
Args:
|
||||
entry: The PAT entry to check.
|
||||
reference_date: The current date.
|
||||
|
||||
Returns:
|
||||
True if the stored expiry_date has passed, False otherwise.
|
||||
"""
|
||||
if entry.expiry_date is None:
|
||||
return False
|
||||
try:
|
||||
stored_expiry = date.fromisoformat(entry.expiry_date)
|
||||
return stored_expiry < reference_date
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
async def check_gitlab(self, entry: PatEntry, token: str) -> CheckResult:
|
||||
"""Check a GitLab PAT's expiry status via the GitLab API.
|
||||
|
||||
Uses GET /personal_access_tokens/self to retrieve the expires_at field
|
||||
and classifies the token based on the expiry window.
|
||||
|
||||
Args:
|
||||
entry: The PAT registry entry to check.
|
||||
token: The GitLab personal access token value.
|
||||
|
||||
Returns:
|
||||
CheckResult with the classification status.
|
||||
"""
|
||||
gitlab_url = os.environ.get("GITLAB_URL", "https://gitlab.com")
|
||||
url = f"{gitlab_url}/api/v4/personal_access_tokens/self"
|
||||
headers = {"PRIVATE-TOKEN": token}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await self._request_with_retry(
|
||||
client, "GET", url, headers, entry.service, entry.token_name
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
if response.status_code == 403:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# Successful response — parse expires_at
|
||||
data = response.json()
|
||||
expires_at = data.get("expires_at")
|
||||
reference_date = date.today()
|
||||
|
||||
if expires_at is None:
|
||||
# No expiry date from API — token has no expiry set
|
||||
# Check if stored expiry_date is stale
|
||||
if self._check_stale_expiry(entry, reference_date):
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
stale_warning=True,
|
||||
)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.HEALTHY,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Classify based on the API-returned expiry date
|
||||
status, days_remaining = classify_by_date(
|
||||
expires_at, reference_date, self.expiry_window_days
|
||||
)
|
||||
|
||||
# Check for stale stored expiry_date
|
||||
stale_warning = False
|
||||
if self._check_stale_expiry(entry, reference_date):
|
||||
stale_warning = True
|
||||
# If API says token is still valid but stored date has passed,
|
||||
# classify as expired with stale warning
|
||||
if status == PatStatus.HEALTHY or status == PatStatus.EXPIRING_SOON:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
stale_warning=True,
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
stale_warning=stale_warning,
|
||||
)
|
||||
|
||||
async def check_jira(self, entry: PatEntry, token: str) -> CheckResult:
|
||||
"""Check a Jira PAT's expiry status via an authenticated API call.
|
||||
|
||||
Interprets HTTP 401 as an expired or revoked token.
|
||||
|
||||
Args:
|
||||
entry: The PAT registry entry to check.
|
||||
token: The Jira personal access token value.
|
||||
|
||||
Returns:
|
||||
CheckResult with the classification status.
|
||||
"""
|
||||
jira_url = os.environ.get("JIRA_URL", "")
|
||||
if not jira_url:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: JIRA_URL environment variable not set",
|
||||
)
|
||||
|
||||
url = f"{jira_url}/rest/api/2/myself"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await self._request_with_retry(
|
||||
client, "GET", url, headers, entry.service, entry.token_name
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
|
||||
)
|
||||
|
||||
reference_date = date.today()
|
||||
|
||||
if response.status_code == 401:
|
||||
# Token is expired/revoked
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
# Non-auth HTTP error
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# API call succeeded — token is working
|
||||
# Check for stale expiry_date
|
||||
if self._check_stale_expiry(entry, reference_date):
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
stale_warning=True,
|
||||
)
|
||||
|
||||
# If no expiry_date stored, classify as healthy
|
||||
if entry.expiry_date is None:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.HEALTHY,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Classify based on stored expiry_date
|
||||
status, days_remaining = classify_by_date(
|
||||
entry.expiry_date, reference_date, self.expiry_window_days
|
||||
)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
async def check_confluence(self, entry: PatEntry, token: str) -> CheckResult:
|
||||
"""Check a Confluence PAT's expiry status via an authenticated API call.
|
||||
|
||||
Interprets HTTP 401 as an expired or revoked token.
|
||||
|
||||
Args:
|
||||
entry: The PAT registry entry to check.
|
||||
token: The Confluence personal access token value.
|
||||
|
||||
Returns:
|
||||
CheckResult with the classification status.
|
||||
"""
|
||||
confluence_url = os.environ.get("CONFLUENCE_URL", "")
|
||||
if not confluence_url:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: CONFLUENCE_URL environment variable not set",
|
||||
)
|
||||
|
||||
url = f"{confluence_url}/rest/api/user/current"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await self._request_with_retry(
|
||||
client, "GET", url, headers, entry.service, entry.token_name
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
|
||||
)
|
||||
|
||||
reference_date = date.today()
|
||||
|
||||
if response.status_code == 401:
|
||||
# Token is expired/revoked
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
# Non-auth HTTP error
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# API call succeeded — token is working
|
||||
# Check for stale expiry_date
|
||||
if self._check_stale_expiry(entry, reference_date):
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
stale_warning=True,
|
||||
)
|
||||
|
||||
# If no expiry_date stored, classify as healthy
|
||||
if entry.expiry_date is None:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.HEALTHY,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Classify based on stored expiry_date
|
||||
status, days_remaining = classify_by_date(
|
||||
entry.expiry_date, reference_date, self.expiry_window_days
|
||||
)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
async def check_orgmylife(self, entry: PatEntry, token: str) -> CheckResult:
|
||||
"""Check an OrgMyLife token's expiry status via an authenticated API call.
|
||||
|
||||
Interprets HTTP 401 or HTTP 403 as an expired or revoked token.
|
||||
|
||||
Args:
|
||||
entry: The PAT registry entry to check.
|
||||
token: The OrgMyLife API key value.
|
||||
|
||||
Returns:
|
||||
CheckResult with the classification status.
|
||||
"""
|
||||
orgmylife_url = os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app")
|
||||
url = f"{orgmylife_url}/api/tasks"
|
||||
headers = {"X-API-Key": token}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await self._request_with_retry(
|
||||
client, "GET", url, headers, entry.service, entry.token_name
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as exc:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
|
||||
)
|
||||
|
||||
reference_date = date.today()
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
# Token is expired/revoked
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
# Non-auth HTTP error
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
|
||||
)
|
||||
|
||||
# API call succeeded — token is working
|
||||
# Check for stale expiry_date
|
||||
if self._check_stale_expiry(entry, reference_date):
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
stale_warning=True,
|
||||
)
|
||||
|
||||
# If no expiry_date stored, classify as healthy
|
||||
if entry.expiry_date is None:
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=PatStatus.HEALTHY,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Classify based on stored expiry_date
|
||||
status, days_remaining = classify_by_date(
|
||||
entry.expiry_date, reference_date, self.expiry_window_days
|
||||
)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Custom exception classes for the PAT Manager system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class PatManagerError(Exception):
|
||||
"""Base exception for PAT Manager errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RegistryValidationError(PatManagerError):
|
||||
"""Raised when a registry entry fails validation.
|
||||
|
||||
Attributes:
|
||||
entry: The raw dictionary that failed validation.
|
||||
failed_fields: List of field names that failed validation checks.
|
||||
"""
|
||||
|
||||
def __init__(self, entry: dict, failed_fields: list[str]) -> None:
|
||||
self.entry = entry
|
||||
self.failed_fields = failed_fields
|
||||
fields_str = ", ".join(failed_fields)
|
||||
super().__init__(
|
||||
f"Registry entry validation failed for fields: {fields_str}. "
|
||||
f"Entry: {entry}"
|
||||
)
|
||||
|
||||
|
||||
class ServiceCheckError(PatManagerError):
|
||||
"""Raised when a service API check fails after retries.
|
||||
|
||||
Attributes:
|
||||
service: The service name that failed (e.g. "GitLab").
|
||||
token_name: The token identifier that was being checked.
|
||||
reason: Human-readable description of the failure.
|
||||
"""
|
||||
|
||||
def __init__(self, service: str, token_name: str, reason: str) -> None:
|
||||
self.service = service
|
||||
self.token_name = token_name
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Service check failed for {service}/{token_name}: {reason}"
|
||||
)
|
||||
|
||||
|
||||
class RotationError(PatManagerError):
|
||||
"""Raised when GitLab token rotation fails.
|
||||
|
||||
Attributes:
|
||||
token_name: The token identifier that failed rotation.
|
||||
http_status: The HTTP status code returned, or None if no response.
|
||||
reason: Human-readable description of the failure.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, token_name: str, http_status: int | None, reason: str
|
||||
) -> None:
|
||||
self.token_name = token_name
|
||||
self.http_status = http_status
|
||||
self.reason = reason
|
||||
status_info = f" (HTTP {http_status})" if http_status else ""
|
||||
super().__init__(
|
||||
f"Token rotation failed for {token_name}{status_info}: {reason}"
|
||||
)
|
||||
|
||||
|
||||
class SecretUpdateError(PatManagerError):
|
||||
"""Raised when GitHub secret update fails.
|
||||
|
||||
Attributes:
|
||||
secret_name: The name of the secret that could not be updated.
|
||||
reason: Human-readable description of the failure.
|
||||
"""
|
||||
|
||||
def __init__(self, secret_name: str, reason: str) -> None:
|
||||
self.secret_name = secret_name
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Failed to update GitHub secret '{secret_name}': {reason}"
|
||||
)
|
||||
|
||||
|
||||
class AlertError(PatManagerError):
|
||||
"""Raised when alert delivery fails.
|
||||
|
||||
Attributes:
|
||||
channel: The alert channel that failed (e.g. "orgmylife", "email").
|
||||
reason: Human-readable description of the failure.
|
||||
"""
|
||||
|
||||
def __init__(self, channel: str, reason: str) -> None:
|
||||
self.channel = channel
|
||||
self.reason = reason
|
||||
super().__init__(
|
||||
f"Alert delivery failed via {channel}: {reason}"
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Data models for the PAT Manager system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PatStatus(Enum):
|
||||
"""Classification status for a PAT after expiry check."""
|
||||
|
||||
HEALTHY = "healthy"
|
||||
EXPIRING_SOON = "expiring soon"
|
||||
EXPIRED = "expired"
|
||||
CHECK_FAILED = "check failed"
|
||||
|
||||
|
||||
class AlertChannel(Enum):
|
||||
"""Supported alert delivery channels."""
|
||||
|
||||
ORGMYLIFE = "orgmylife"
|
||||
EMAIL = "email"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatEntry:
|
||||
"""A single PAT entry in the registry.
|
||||
|
||||
Attributes:
|
||||
service: The service this PAT belongs to (GitLab, Jira, Confluence, OrgMyLife).
|
||||
token_name: Human-readable identifier for the token (max 128 chars).
|
||||
expiry_date: ISO 8601 date string (YYYY-MM-DD) or None if no expiry.
|
||||
renewal_method: How the token is renewed - "auto" or "manual".
|
||||
"""
|
||||
|
||||
service: str
|
||||
token_name: str
|
||||
expiry_date: str | None
|
||||
renewal_method: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""Result of checking a single PAT's expiry status.
|
||||
|
||||
Attributes:
|
||||
entry: The PAT entry that was checked.
|
||||
status: Classification result (healthy, expiring soon, expired, check failed).
|
||||
days_remaining: Days until expiry, or None if check failed or no expiry date.
|
||||
error_message: Error details, populated only for check_failed status.
|
||||
stale_warning: True if API succeeds but stored expiry_date has passed.
|
||||
"""
|
||||
|
||||
entry: PatEntry
|
||||
status: PatStatus
|
||||
days_remaining: int | None
|
||||
error_message: str | None
|
||||
stale_warning: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RotationResult:
|
||||
"""Result of a GitLab token rotation attempt.
|
||||
|
||||
Attributes:
|
||||
success: Whether the rotation completed successfully.
|
||||
new_token: The new token value from GitLab, or None on failure.
|
||||
new_expiry_date: The new expiry date from GitLab, or None on failure.
|
||||
error_message: Error details on failure, or None on success.
|
||||
entry: The PAT entry that was rotated, or None if not set.
|
||||
old_expiry_date: The previous expiry date before rotation, or None.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
new_token: str | None
|
||||
new_expiry_date: str | None
|
||||
error_message: str | None
|
||||
entry: PatEntry | None = None
|
||||
old_expiry_date: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlertResult:
|
||||
"""Result of sending an alert notification.
|
||||
|
||||
Attributes:
|
||||
sent: Whether the alert was delivered successfully.
|
||||
channel: Which alert channel was used.
|
||||
error_message: Error details on failure, or None on success.
|
||||
"""
|
||||
|
||||
sent: bool
|
||||
channel: AlertChannel
|
||||
error_message: str | None
|
||||
@@ -0,0 +1,220 @@
|
||||
"""PAT Registry module for loading, validating, and persisting the PAT registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import RegistryValidationError
|
||||
from .models import PatEntry
|
||||
|
||||
# Valid service names and their required renewal methods
|
||||
VALID_SERVICES: dict[str, str] = {
|
||||
"GitLab": "auto",
|
||||
"Jira": "manual",
|
||||
"Confluence": "manual",
|
||||
"OrgMyLife": "manual",
|
||||
}
|
||||
|
||||
# ISO 8601 date pattern (YYYY-MM-DD)
|
||||
_ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
# Maximum token name length
|
||||
_MAX_TOKEN_NAME_LENGTH = 128
|
||||
|
||||
|
||||
class PatRegistry:
|
||||
"""Manages loading, validation, and persistence of the PAT registry.
|
||||
|
||||
The registry is a JSON file with structure:
|
||||
{"version": "1.0", "tokens": [...]}
|
||||
|
||||
Each token entry contains: service, token_name, expiry_date, renewal_method.
|
||||
"""
|
||||
|
||||
def load(self, path: Path) -> list[PatEntry]:
|
||||
"""Load and validate all entries from a PAT registry JSON file.
|
||||
|
||||
Args:
|
||||
path: Path to the registry JSON file.
|
||||
|
||||
Returns:
|
||||
List of validated PatEntry objects.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the registry file does not exist.
|
||||
json.JSONDecodeError: If the file contains invalid JSON.
|
||||
RegistryValidationError: If any entry fails validation.
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise RegistryValidationError(
|
||||
entry={},
|
||||
failed_fields=["root"],
|
||||
)
|
||||
|
||||
tokens = data.get("tokens", [])
|
||||
if not isinstance(tokens, list):
|
||||
raise RegistryValidationError(
|
||||
entry={},
|
||||
failed_fields=["tokens"],
|
||||
)
|
||||
|
||||
entries: list[PatEntry] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for raw_entry in tokens:
|
||||
entry = self.validate_entry(raw_entry)
|
||||
# Check for duplicates
|
||||
key = (entry.service, entry.token_name)
|
||||
if key in seen:
|
||||
raise RegistryValidationError(
|
||||
entry=raw_entry,
|
||||
failed_fields=["service", "token_name"],
|
||||
)
|
||||
seen.add(key)
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
def validate_entry(self, entry: dict) -> PatEntry:
|
||||
"""Validate a single registry entry dictionary.
|
||||
|
||||
Checks:
|
||||
- All required fields are present (service, token_name, renewal_method)
|
||||
- service is one of the supported values
|
||||
- token_name is non-empty and max 128 characters
|
||||
- expiry_date is null or valid ISO 8601 (YYYY-MM-DD)
|
||||
- renewal_method matches the service (GitLab -> "auto", others -> "manual")
|
||||
|
||||
Args:
|
||||
entry: Raw dictionary representing a PAT entry.
|
||||
|
||||
Returns:
|
||||
A validated PatEntry instance.
|
||||
|
||||
Raises:
|
||||
RegistryValidationError: If validation fails, with specific failed fields.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
raise RegistryValidationError(
|
||||
entry=entry if isinstance(entry, dict) else {"_raw": str(entry)},
|
||||
failed_fields=["entry_type"],
|
||||
)
|
||||
|
||||
failed_fields: list[str] = []
|
||||
|
||||
# Check required fields presence
|
||||
required_fields = ["service", "token_name", "renewal_method"]
|
||||
for field in required_fields:
|
||||
if field not in entry:
|
||||
failed_fields.append(field)
|
||||
|
||||
# If required fields are missing, raise immediately
|
||||
if failed_fields:
|
||||
raise RegistryValidationError(entry=entry, failed_fields=failed_fields)
|
||||
|
||||
# Validate service
|
||||
service = entry["service"]
|
||||
if service not in VALID_SERVICES:
|
||||
failed_fields.append("service")
|
||||
|
||||
# Validate token_name
|
||||
token_name = entry["token_name"]
|
||||
if (
|
||||
not isinstance(token_name, str)
|
||||
or len(token_name) == 0
|
||||
or len(token_name) > _MAX_TOKEN_NAME_LENGTH
|
||||
):
|
||||
failed_fields.append("token_name")
|
||||
|
||||
# Validate expiry_date (can be null/None or valid ISO 8601 YYYY-MM-DD)
|
||||
expiry_date = entry.get("expiry_date")
|
||||
if expiry_date is not None:
|
||||
if not isinstance(expiry_date, str) or not _is_valid_iso_date(expiry_date):
|
||||
failed_fields.append("expiry_date")
|
||||
|
||||
# Validate renewal_method matches service
|
||||
renewal_method = entry["renewal_method"]
|
||||
if service in VALID_SERVICES:
|
||||
expected_method = VALID_SERVICES[service]
|
||||
if renewal_method != expected_method:
|
||||
failed_fields.append("renewal_method")
|
||||
elif renewal_method not in ("auto", "manual"):
|
||||
# If service is invalid, still check renewal_method is at least valid
|
||||
failed_fields.append("renewal_method")
|
||||
|
||||
if failed_fields:
|
||||
raise RegistryValidationError(entry=entry, failed_fields=failed_fields)
|
||||
|
||||
return PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method=renewal_method,
|
||||
)
|
||||
|
||||
def save(self, path: Path, entries: list[PatEntry]) -> None:
|
||||
"""Persist a list of PAT entries to a registry JSON file.
|
||||
|
||||
Args:
|
||||
path: Path where the registry file should be written.
|
||||
entries: List of PatEntry objects to serialize.
|
||||
"""
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": entry.service,
|
||||
"token_name": entry.token_name,
|
||||
"expiry_date": entry.expiry_date,
|
||||
"renewal_method": entry.renewal_method,
|
||||
}
|
||||
for entry in entries
|
||||
],
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
def create_empty(self, path: Path) -> None:
|
||||
"""Create a new empty registry file with valid JSON structure.
|
||||
|
||||
Args:
|
||||
path: Path where the empty registry file should be created.
|
||||
"""
|
||||
self.save(path, [])
|
||||
|
||||
|
||||
def _is_valid_iso_date(date_str: str) -> bool:
|
||||
"""Check if a string is a valid ISO 8601 date (YYYY-MM-DD).
|
||||
|
||||
Validates both the format and that the date values are actually valid
|
||||
(e.g., rejects 2025-02-30).
|
||||
"""
|
||||
if not _ISO_DATE_PATTERN.match(date_str):
|
||||
return False
|
||||
|
||||
# Validate actual date values
|
||||
try:
|
||||
year, month, day = date_str.split("-")
|
||||
year_int, month_int, day_int = int(year), int(month), int(day)
|
||||
|
||||
# Basic range checks
|
||||
if month_int < 1 or month_int > 12:
|
||||
return False
|
||||
if day_int < 1 or day_int > 31:
|
||||
return False
|
||||
|
||||
# Use datetime for full validation (handles leap years, etc.)
|
||||
import datetime
|
||||
|
||||
datetime.date(year_int, month_int, day_int)
|
||||
return True
|
||||
except (ValueError, OverflowError):
|
||||
return False
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Summary Reporter module for the PAT Manager system.
|
||||
|
||||
Generates a human-readable summary report of PAT lifecycle check results
|
||||
suitable for GitHub Actions log output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import CheckResult, PatStatus, RotationResult
|
||||
|
||||
|
||||
class SummaryReporter:
|
||||
"""Produces a formatted summary report of PAT check and rotation results.
|
||||
|
||||
The report includes a table of all checked PATs with their status and
|
||||
details, plus a rotation results section if any tokens were rotated.
|
||||
"""
|
||||
|
||||
def report(
|
||||
self, results: list[CheckResult], rotations: list[RotationResult]
|
||||
) -> str:
|
||||
"""Generate a plain-text summary report.
|
||||
|
||||
Args:
|
||||
results: List of check results for all PATs in the registry.
|
||||
rotations: List of rotation results for tokens that were rotated.
|
||||
|
||||
Returns:
|
||||
A formatted plain-text string suitable for printing to the
|
||||
GitHub Actions log.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append("=== PAT Lifecycle Check Summary ===")
|
||||
lines.append("")
|
||||
|
||||
# Build the table
|
||||
lines.append(self._build_table(results))
|
||||
|
||||
# Add rotation section if there are rotation results
|
||||
successful_rotations = [r for r in rotations if r.success]
|
||||
if successful_rotations:
|
||||
lines.append("")
|
||||
lines.append("--- Rotation Results ---")
|
||||
for rotation in successful_rotations:
|
||||
lines.append(self._format_rotation(rotation))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_table(self, results: list[CheckResult]) -> str:
|
||||
"""Build the formatted results table.
|
||||
|
||||
Args:
|
||||
results: List of check results to display.
|
||||
|
||||
Returns:
|
||||
Formatted table string with header, separator, and data rows.
|
||||
"""
|
||||
# Column headers
|
||||
header_service = "Service"
|
||||
header_token = "Token Name"
|
||||
header_status = "Status"
|
||||
header_details = "Details"
|
||||
|
||||
# Calculate column widths based on content
|
||||
service_width = max(
|
||||
len(header_service),
|
||||
*(len(r.entry.service) for r in results) if results else [0],
|
||||
)
|
||||
token_width = max(
|
||||
len(header_token),
|
||||
*(len(r.entry.token_name) for r in results) if results else [0],
|
||||
)
|
||||
status_width = max(
|
||||
len(header_status),
|
||||
*(len(r.status.value) for r in results) if results else [0],
|
||||
)
|
||||
details_width = max(
|
||||
len(header_details),
|
||||
*(len(self._get_details(r)) for r in results) if results else [0],
|
||||
)
|
||||
|
||||
# Format header
|
||||
header = (
|
||||
f"{header_service:<{service_width}} | "
|
||||
f"{header_token:<{token_width}} | "
|
||||
f"{header_status:<{status_width}} | "
|
||||
f"{header_details:<{details_width}}"
|
||||
)
|
||||
|
||||
# Format separator
|
||||
separator = (
|
||||
f"{'-' * service_width}-+-"
|
||||
f"{'-' * token_width}-+-"
|
||||
f"{'-' * status_width}-+-"
|
||||
f"{'-' * details_width}"
|
||||
)
|
||||
|
||||
# Format data rows
|
||||
rows: list[str] = []
|
||||
for result in results:
|
||||
details = self._get_details(result)
|
||||
row = (
|
||||
f"{result.entry.service:<{service_width}} | "
|
||||
f"{result.entry.token_name:<{token_width}} | "
|
||||
f"{result.status.value:<{status_width}} | "
|
||||
f"{details:<{details_width}}"
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
table_lines = [header, separator] + rows
|
||||
return "\n".join(table_lines)
|
||||
|
||||
def _get_details(self, result: CheckResult) -> str:
|
||||
"""Get the details string for a check result.
|
||||
|
||||
Args:
|
||||
result: The check result to describe.
|
||||
|
||||
Returns:
|
||||
A human-readable details string based on the PAT status.
|
||||
"""
|
||||
if result.status == PatStatus.CHECK_FAILED:
|
||||
return result.error_message or "Unknown error"
|
||||
if result.status == PatStatus.EXPIRED:
|
||||
return "0 days remaining"
|
||||
if result.days_remaining is not None:
|
||||
return f"{result.days_remaining} days remaining"
|
||||
return ""
|
||||
|
||||
def _format_rotation(self, rotation: RotationResult) -> str:
|
||||
"""Format a single rotation result line.
|
||||
|
||||
Args:
|
||||
rotation: A successful rotation result.
|
||||
|
||||
Returns:
|
||||
Formatted string like:
|
||||
"GitLab/token-name: Rotated successfully (old: 2025-07-01, new: 2025-08-01)"
|
||||
"""
|
||||
if rotation.entry is not None:
|
||||
service = rotation.entry.service
|
||||
token_name = rotation.entry.token_name
|
||||
else:
|
||||
service = "Unknown"
|
||||
token_name = "unknown-token"
|
||||
|
||||
old_date = rotation.old_expiry_date or "N/A"
|
||||
new_date = rotation.new_expiry_date or "N/A"
|
||||
|
||||
return (
|
||||
f"{service}/{token_name}: "
|
||||
f"Rotated successfully (old: {old_date}, new: {new_date})"
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
hypothesis>=6.100
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
pytest-cov>=5.0
|
||||
respx>=0.21
|
||||
@@ -0,0 +1,2 @@
|
||||
httpx>=0.27
|
||||
pynacl>=1.5
|
||||
@@ -0,0 +1,154 @@
|
||||
"""GitLab Rotator module for the PAT Manager system.
|
||||
|
||||
Handles automatic token rotation for GitLab PATs using the
|
||||
GitLab Personal Access Tokens API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import RotationError
|
||||
from .models import RotationResult
|
||||
|
||||
|
||||
class GitLabRotator:
|
||||
"""Rotates GitLab Personal Access Tokens via the GitLab API.
|
||||
|
||||
Uses POST /api/v4/personal_access_tokens/{id}/rotate to obtain a new
|
||||
token with a fresh expiry date. Implements retry logic (one retry on
|
||||
failure) and skips rotation for already-expired tokens.
|
||||
|
||||
Attributes:
|
||||
timeout: HTTP request timeout in seconds.
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: int = 30) -> None:
|
||||
self.timeout = timeout
|
||||
self._gitlab_url = os.environ.get("GITLAB_URL", "https://gitlab.com")
|
||||
|
||||
async def rotate(
|
||||
self, token_id: str, current_token: str, expiry_date: str | None = None
|
||||
) -> RotationResult:
|
||||
"""Rotate a GitLab PAT to obtain a new token and expiry date.
|
||||
|
||||
If the token is already expired (based on the provided expiry_date),
|
||||
rotation is skipped and an error result is returned indicating manual
|
||||
intervention is needed.
|
||||
|
||||
On HTTP error or timeout, retries once. If the retry also fails,
|
||||
returns an error RotationResult.
|
||||
|
||||
Args:
|
||||
token_id: The GitLab personal access token ID (numeric string).
|
||||
current_token: The current token value used for authentication.
|
||||
expiry_date: Optional ISO 8601 date string (YYYY-MM-DD) of the
|
||||
token's current expiry. Used to skip expired tokens.
|
||||
|
||||
Returns:
|
||||
RotationResult with success=True and new token/expiry on success,
|
||||
or success=False with an error message on failure.
|
||||
"""
|
||||
# Skip rotation for expired tokens
|
||||
if expiry_date is not None:
|
||||
try:
|
||||
parsed_expiry = date.fromisoformat(expiry_date)
|
||||
if parsed_expiry < date.today():
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"Token {token_id} is expired (expiry: {expiry_date}). "
|
||||
"Manual intervention required — cannot rotate an expired token."
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid date format — proceed with rotation attempt
|
||||
pass
|
||||
|
||||
url = f"{self._gitlab_url}/api/v4/personal_access_tokens/{token_id}/rotate"
|
||||
headers = {"PRIVATE-TOKEN": current_token}
|
||||
|
||||
# First attempt
|
||||
result = await self._attempt_rotation(url, headers, token_id)
|
||||
if result.success:
|
||||
return result
|
||||
|
||||
# Retry once on failure
|
||||
result = await self._attempt_rotation(url, headers, token_id)
|
||||
return result
|
||||
|
||||
async def _attempt_rotation(
|
||||
self, url: str, headers: dict[str, str], token_id: str
|
||||
) -> RotationResult:
|
||||
"""Make a single rotation API call.
|
||||
|
||||
Args:
|
||||
url: The full rotation endpoint URL.
|
||||
headers: Request headers including PRIVATE-TOKEN.
|
||||
token_id: The token ID for error messages.
|
||||
|
||||
Returns:
|
||||
RotationResult indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(url, headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"Rotation request timed out for token {token_id}",
|
||||
)
|
||||
except httpx.ConnectError as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"Connection error during rotation for token {token_id}: {exc}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"HTTP error during rotation for token {token_id}: {exc}",
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"GitLab API returned HTTP {response.status_code} "
|
||||
f"for token {token_id} rotation"
|
||||
),
|
||||
)
|
||||
|
||||
# Parse successful response
|
||||
try:
|
||||
data = response.json()
|
||||
new_token = data["token"]
|
||||
new_expiry_date = data["expires_at"]
|
||||
except (KeyError, ValueError) as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"Failed to parse rotation response for token {token_id}: {exc}"
|
||||
),
|
||||
)
|
||||
|
||||
return RotationResult(
|
||||
success=True,
|
||||
new_token=new_token,
|
||||
new_expiry_date=new_expiry_date,
|
||||
error_message=None,
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""GitHub Actions secret updater using the GitHub API.
|
||||
|
||||
Updates repository secrets by encrypting values with the repository's
|
||||
public key using libsodium sealed box encryption (NaCl).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from nacl.public import PublicKey, SealedBox
|
||||
|
||||
from .errors import SecretUpdateError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
|
||||
|
||||
class SecretUpdater:
|
||||
"""Updates GitHub Actions repository secrets via the GitHub API.
|
||||
|
||||
Uses libsodium sealed box encryption to encrypt secret values before
|
||||
sending them to the GitHub API.
|
||||
|
||||
Attributes:
|
||||
github_token: GitHub PAT with repo scope for secret management.
|
||||
repo_owner: Repository owner (user or organization).
|
||||
repo_name: Repository name.
|
||||
"""
|
||||
|
||||
def __init__(self, github_token: str, repo_owner: str, repo_name: str) -> None:
|
||||
self._github_token = github_token
|
||||
self._repo_owner = repo_owner
|
||||
self._repo_name = repo_name
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build authorization headers for GitHub API requests."""
|
||||
return {
|
||||
"Authorization": f"Bearer {self._github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
def _encrypt_secret(self, public_key_b64: str, secret_value: str) -> str:
|
||||
"""Encrypt a secret value using the repository's public key.
|
||||
|
||||
Args:
|
||||
public_key_b64: Base64-encoded repository public key from GitHub API.
|
||||
secret_value: The plaintext secret value to encrypt.
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted value ready for the GitHub API.
|
||||
"""
|
||||
public_key_bytes = base64.b64decode(public_key_b64)
|
||||
public_key = PublicKey(public_key_bytes)
|
||||
sealed_box = SealedBox(public_key)
|
||||
encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
|
||||
async def update_secret(self, secret_name: str, secret_value: str) -> bool:
|
||||
"""Update a GitHub Actions repository secret.
|
||||
|
||||
Fetches the repository's public key, encrypts the secret value using
|
||||
libsodium sealed box encryption, and updates the secret via the GitHub API.
|
||||
|
||||
On failure, the previous secret value remains unchanged (GitHub API is atomic).
|
||||
|
||||
Args:
|
||||
secret_name: Name of the secret to update.
|
||||
secret_value: New plaintext value for the secret.
|
||||
|
||||
Returns:
|
||||
True if the secret was updated successfully.
|
||||
|
||||
Raises:
|
||||
SecretUpdateError: If the update fails for any reason.
|
||||
The error message never contains the secret value.
|
||||
"""
|
||||
base_url = (
|
||||
f"{GITHUB_API_BASE}/repos/{self._repo_owner}/{self._repo_name}"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Step 1: Get the repository's public key for secret encryption
|
||||
try:
|
||||
key_response = await client.get(
|
||||
f"{base_url}/actions/secrets/public-key",
|
||||
headers=self._headers(),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SecretUpdateError(
|
||||
secret_name,
|
||||
f"Failed to fetch repository public key: {type(exc).__name__}",
|
||||
) from exc
|
||||
|
||||
if key_response.status_code != 200:
|
||||
raise SecretUpdateError(
|
||||
secret_name,
|
||||
f"Failed to fetch repository public key "
|
||||
f"(HTTP {key_response.status_code})",
|
||||
)
|
||||
|
||||
key_data = key_response.json()
|
||||
public_key_b64 = key_data["key"]
|
||||
key_id = key_data["key_id"]
|
||||
|
||||
# Step 2: Encrypt the secret value using the public key
|
||||
try:
|
||||
encrypted_value = self._encrypt_secret(public_key_b64, secret_value)
|
||||
except Exception as exc:
|
||||
raise SecretUpdateError(
|
||||
secret_name,
|
||||
f"Failed to encrypt secret value: {type(exc).__name__}",
|
||||
) from exc
|
||||
|
||||
# Step 3: Update the secret via PUT request
|
||||
try:
|
||||
put_response = await client.put(
|
||||
f"{base_url}/actions/secrets/{secret_name}",
|
||||
headers=self._headers(),
|
||||
json={
|
||||
"encrypted_value": encrypted_value,
|
||||
"key_id": key_id,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SecretUpdateError(
|
||||
secret_name,
|
||||
f"Failed to update secret: {type(exc).__name__}",
|
||||
) from exc
|
||||
|
||||
# GitHub returns 201 (created) or 204 (updated) on success
|
||||
if put_response.status_code not in (201, 204):
|
||||
raise SecretUpdateError(
|
||||
secret_name,
|
||||
f"Failed to update secret (HTTP {put_response.status_code})",
|
||||
)
|
||||
|
||||
logger.info("Successfully updated GitHub secret '%s'", secret_name)
|
||||
return True
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit tests for the GitLab Rotator module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from datetime import date, timedelta
|
||||
|
||||
from .rotator import GitLabRotator
|
||||
from .models import RotationResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rotator(monkeypatch):
|
||||
"""Create a GitLabRotator with a test GitLab URL."""
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
return GitLabRotator(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_rotation(rotator):
|
||||
"""Test successful token rotation returns new token and expiry."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/123/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-new-token-value", "expires_at": "2025-12-15"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await rotator.rotate("123", "glpat-old-token")
|
||||
|
||||
assert result.success is True
|
||||
assert result.new_token == "glpat-new-token-value"
|
||||
assert result.new_expiry_date == "2025-12-15"
|
||||
assert result.error_message is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_skips_rotation(rotator):
|
||||
"""Test that expired tokens are not rotated — returns error for manual intervention."""
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
|
||||
result = await rotator.rotate("123", "glpat-old-token", expiry_date=yesterday)
|
||||
|
||||
assert result.success is False
|
||||
assert result.new_token is None
|
||||
assert result.new_expiry_date is None
|
||||
assert "expired" in result.error_message.lower()
|
||||
assert "manual intervention" in result.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_first_failure_then_success(rotator):
|
||||
"""Test that rotation retries once on failure and succeeds on second attempt."""
|
||||
with respx.mock:
|
||||
route = respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/456/rotate"
|
||||
)
|
||||
route.side_effect = [
|
||||
httpx.Response(500, json={"error": "internal error"}),
|
||||
httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-retry-token", "expires_at": "2025-11-30"},
|
||||
),
|
||||
]
|
||||
|
||||
result = await rotator.rotate("456", "glpat-current")
|
||||
|
||||
assert result.success is True
|
||||
assert result.new_token == "glpat-retry-token"
|
||||
assert result.new_expiry_date == "2025-11-30"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_exhausted_returns_error(rotator):
|
||||
"""Test that after retry is exhausted, an error result is returned."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/789/rotate"
|
||||
).mock(return_value=httpx.Response(503, json={"error": "unavailable"}))
|
||||
|
||||
result = await rotator.rotate("789", "glpat-current")
|
||||
|
||||
assert result.success is False
|
||||
assert result.new_token is None
|
||||
assert result.new_expiry_date is None
|
||||
assert "503" in result.error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_error(rotator):
|
||||
"""Test that timeout on both attempts returns error result."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/101/rotate"
|
||||
).mock(side_effect=httpx.TimeoutException("timed out"))
|
||||
|
||||
result = await rotator.rotate("101", "glpat-current")
|
||||
|
||||
assert result.success is False
|
||||
assert result.new_token is None
|
||||
assert "timed out" in result.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_error_returns_error(rotator):
|
||||
"""Test that connection error on both attempts returns error result."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/202/rotate"
|
||||
).mock(side_effect=httpx.ConnectError("connection refused"))
|
||||
|
||||
result = await rotator.rotate("202", "glpat-current")
|
||||
|
||||
assert result.success is False
|
||||
assert result.new_token is None
|
||||
assert "connection" in result.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_expired_token_proceeds_with_rotation(rotator):
|
||||
"""Test that a token not yet expired proceeds with rotation."""
|
||||
future_date = (date.today() + timedelta(days=5)).isoformat()
|
||||
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/303/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-fresh", "expires_at": "2026-01-01"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await rotator.rotate("303", "glpat-old", expiry_date=future_date)
|
||||
|
||||
assert result.success is True
|
||||
assert result.new_token == "glpat-fresh"
|
||||
assert result.new_expiry_date == "2026-01-01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_expiry_date_proceeds_with_rotation(rotator):
|
||||
"""Test that when no expiry_date is provided, rotation proceeds normally."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/404/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-no-expiry", "expires_at": "2025-10-01"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await rotator.rotate("404", "glpat-current", expiry_date=None)
|
||||
|
||||
assert result.success is True
|
||||
assert result.new_token == "glpat-no-expiry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_response_returns_error(rotator):
|
||||
"""Test that a response missing expected fields returns error."""
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/505/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"unexpected": "data"},
|
||||
)
|
||||
)
|
||||
|
||||
result = await rotator.rotate("505", "glpat-current")
|
||||
|
||||
assert result.success is False
|
||||
assert result.new_token is None
|
||||
assert "parse" in result.error_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_header_sent(rotator):
|
||||
"""Test that the PRIVATE-TOKEN header is sent with the current token."""
|
||||
with respx.mock:
|
||||
route = respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/606/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-new", "expires_at": "2025-12-01"},
|
||||
)
|
||||
)
|
||||
|
||||
await rotator.rotate("606", "glpat-my-secret-token")
|
||||
|
||||
assert route.calls[0].request.headers["PRIVATE-TOKEN"] == "glpat-my-secret-token"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""AI-Orchestrator: A Python implementation of the Symphony specification."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Agent runner — Codex app-server subprocess integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Issue, LiveSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""Base class for agent runner errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentStartupError(AgentError):
|
||||
"""Raised when the agent process fails to start."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentTurnError(AgentError):
|
||||
"""Raised when an agent turn fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentTimeoutError(AgentError):
|
||||
"""Raised when an agent turn times out."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentEvent:
|
||||
"""Structured event from the agent process."""
|
||||
|
||||
event: str
|
||||
timestamp: datetime
|
||||
codex_app_server_pid: str | None = None
|
||||
usage: dict[str, int] | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnResult:
|
||||
"""Result of a single agent turn."""
|
||||
|
||||
success: bool
|
||||
session: LiveSession
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# Type alias for event callback
|
||||
EventCallback = Callable[[str, AgentEvent], None]
|
||||
|
||||
|
||||
class AgentSession:
|
||||
"""Manages a Codex app-server subprocess session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ServiceConfig,
|
||||
workspace_path: str,
|
||||
issue: Issue,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._workspace_path = workspace_path
|
||||
self._issue = issue
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._session: LiveSession = LiveSession()
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def session(self) -> LiveSession:
|
||||
"""Get the current live session state."""
|
||||
return self._session
|
||||
|
||||
async def start(self) -> LiveSession:
|
||||
"""Start the Codex app-server subprocess.
|
||||
|
||||
Returns:
|
||||
LiveSession with initial state.
|
||||
|
||||
Raises:
|
||||
AgentStartupError: If the process fails to start.
|
||||
"""
|
||||
workspace = Path(self._workspace_path)
|
||||
if not workspace.is_dir():
|
||||
raise AgentStartupError(
|
||||
f"Invalid workspace directory: {self._workspace_path}"
|
||||
)
|
||||
|
||||
command = self._config.codex.command
|
||||
logger.info(
|
||||
f"Starting agent session for {self._issue.identifier} "
|
||||
f"in {self._workspace_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
"bash", "-lc", command,
|
||||
cwd=self._workspace_path,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise AgentStartupError(f"codex command not found: {e}") from e
|
||||
except OSError as e:
|
||||
raise AgentStartupError(f"Failed to start agent: {e}") from e
|
||||
|
||||
self._started = True
|
||||
|
||||
# Send initialization message
|
||||
init_msg = self._build_init_message()
|
||||
await self._send_message(init_msg)
|
||||
|
||||
# Wait for initialization response
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self._read_message(),
|
||||
timeout=self._config.codex.read_timeout_ms / 1000.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await self.stop()
|
||||
raise AgentStartupError("Agent startup timed out")
|
||||
|
||||
if response and response.get("type") == "error":
|
||||
await self.stop()
|
||||
raise AgentStartupError(
|
||||
f"Agent startup error: {response.get('message', 'unknown')}"
|
||||
)
|
||||
|
||||
# Extract session identifiers
|
||||
thread_id = response.get("thread_id", "") if response else ""
|
||||
self._session.thread_id = thread_id
|
||||
self._session.session_id = thread_id
|
||||
|
||||
if self._process.pid:
|
||||
self._session.codex_app_server_pid = str(self._process.pid)
|
||||
|
||||
logger.info(
|
||||
f"Agent session started: thread_id={thread_id}, "
|
||||
f"pid={self._process.pid}"
|
||||
)
|
||||
|
||||
return self._session
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
prompt: str,
|
||||
on_event: EventCallback | None = None,
|
||||
) -> TurnResult:
|
||||
"""Run a single agent turn.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send for this turn.
|
||||
on_event: Optional callback for streaming events.
|
||||
|
||||
Returns:
|
||||
TurnResult indicating success/failure.
|
||||
|
||||
Raises:
|
||||
AgentTurnError: If the turn fails.
|
||||
AgentTimeoutError: If the turn times out.
|
||||
"""
|
||||
if not self._started or not self._process:
|
||||
raise AgentTurnError("Session not started")
|
||||
|
||||
self._session.turn_count += 1
|
||||
|
||||
# Send turn start message
|
||||
turn_msg = self._build_turn_message(prompt)
|
||||
await self._send_message(turn_msg)
|
||||
|
||||
# Stream turn events until completion
|
||||
turn_timeout = self._config.codex.turn_timeout_ms / 1000.0
|
||||
start_time = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.monotonic() - start_time
|
||||
remaining = turn_timeout - elapsed
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
message = await asyncio.wait_for(
|
||||
self._read_message(), timeout=remaining
|
||||
)
|
||||
|
||||
if message is None:
|
||||
# Process exited
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error="Agent process exited unexpectedly",
|
||||
)
|
||||
|
||||
event = self._process_message(message)
|
||||
if event and on_event:
|
||||
on_event(self._issue.id, event)
|
||||
|
||||
# Check for turn completion
|
||||
msg_type = message.get("type", "")
|
||||
if msg_type in ("turn_completed", "turn_complete"):
|
||||
self._extract_usage(message)
|
||||
return TurnResult(success=True, session=self._session)
|
||||
elif msg_type in ("turn_failed", "turn_cancelled", "error"):
|
||||
error_msg = message.get("message", msg_type)
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error=error_msg,
|
||||
)
|
||||
elif msg_type == "turn_input_required":
|
||||
# Per spec: user-input-required is treated as failure
|
||||
return TurnResult(
|
||||
success=False,
|
||||
session=self._session,
|
||||
error="Agent requested user input (not supported)",
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise AgentTimeoutError(
|
||||
f"Turn timed out after {self._config.codex.turn_timeout_ms}ms"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the agent subprocess."""
|
||||
if self._process and self._process.returncode is None:
|
||||
try:
|
||||
self._process.terminate()
|
||||
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
self._started = False
|
||||
logger.info(f"Agent session stopped for {self._issue.identifier}")
|
||||
|
||||
async def _send_message(self, message: dict[str, Any]) -> None:
|
||||
"""Send a JSON message to the agent process stdin."""
|
||||
if not self._process or not self._process.stdin:
|
||||
raise AgentTurnError("Process stdin not available")
|
||||
|
||||
line = json.dumps(message) + "\n"
|
||||
self._process.stdin.write(line.encode("utf-8"))
|
||||
await self._process.stdin.drain()
|
||||
|
||||
async def _read_message(self) -> dict[str, Any] | None:
|
||||
"""Read a JSON message from the agent process stdout.
|
||||
|
||||
Returns:
|
||||
Parsed message dict, or None if process exited.
|
||||
"""
|
||||
if not self._process or not self._process.stdout:
|
||||
return None
|
||||
|
||||
try:
|
||||
line = await self._process.stdout.readline()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not line:
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(line.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Malformed agent message: {e}")
|
||||
return {"type": "malformed", "raw": line.decode("utf-8", errors="replace")}
|
||||
|
||||
def _build_init_message(self) -> dict[str, Any]:
|
||||
"""Build the session initialization message."""
|
||||
return {
|
||||
"type": "thread_start",
|
||||
"cwd": self._workspace_path,
|
||||
"approval_policy": self._config.codex.approval_policy,
|
||||
"sandbox": self._config.codex.thread_sandbox,
|
||||
}
|
||||
|
||||
def _build_turn_message(self, prompt: str) -> dict[str, Any]:
|
||||
"""Build a turn start message."""
|
||||
return {
|
||||
"type": "turn_start",
|
||||
"prompt": prompt,
|
||||
"title": f"{self._issue.identifier}: {self._issue.title}",
|
||||
"cwd": self._workspace_path,
|
||||
"sandbox_policy": self._config.codex.turn_sandbox_policy,
|
||||
}
|
||||
|
||||
def _process_message(self, message: dict[str, Any]) -> AgentEvent | None:
|
||||
"""Process an incoming agent message and update session state."""
|
||||
msg_type = message.get("type", "unknown")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
self._session.last_codex_event = msg_type
|
||||
self._session.last_codex_timestamp = now
|
||||
|
||||
# Extract turn_id if present
|
||||
turn_id = message.get("turn_id", "")
|
||||
if turn_id:
|
||||
self._session.turn_id = turn_id
|
||||
self._session.session_id = (
|
||||
f"{self._session.thread_id}-{turn_id}"
|
||||
)
|
||||
|
||||
# Extract usage/tokens
|
||||
self._extract_usage(message)
|
||||
|
||||
# Build summary message
|
||||
summary = message.get("message", "")
|
||||
if not summary and msg_type == "notification":
|
||||
summary = message.get("text", "")
|
||||
if summary:
|
||||
self._session.last_codex_message = summary[:200]
|
||||
|
||||
return AgentEvent(
|
||||
event=msg_type,
|
||||
timestamp=now,
|
||||
codex_app_server_pid=self._session.codex_app_server_pid,
|
||||
usage=message.get("usage"),
|
||||
payload=message,
|
||||
)
|
||||
|
||||
def _extract_usage(self, message: dict[str, Any]) -> None:
|
||||
"""Extract token usage from a message."""
|
||||
usage = message.get("usage") or message.get("total_token_usage") or {}
|
||||
if not usage:
|
||||
return
|
||||
|
||||
input_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0)
|
||||
output_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
|
||||
total_tokens = usage.get("total_tokens", 0)
|
||||
|
||||
if not total_tokens:
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Use absolute totals with delta tracking
|
||||
if input_tokens > self._session.last_reported_input_tokens:
|
||||
delta_in = input_tokens - self._session.last_reported_input_tokens
|
||||
delta_out = output_tokens - self._session.last_reported_output_tokens
|
||||
delta_total = total_tokens - self._session.last_reported_total_tokens
|
||||
|
||||
self._session.codex_input_tokens += max(delta_in, 0)
|
||||
self._session.codex_output_tokens += max(delta_out, 0)
|
||||
self._session.codex_total_tokens += max(delta_total, 0)
|
||||
|
||||
self._session.last_reported_input_tokens = input_tokens
|
||||
self._session.last_reported_output_tokens = output_tokens
|
||||
self._session.last_reported_total_tokens = total_tokens
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CLI entry point for the AI-Orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
from ai_orchestrator.watcher import WorkflowWatcher
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure structured logging."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ai-orchestrator",
|
||||
description="AI-Orchestrator: Orchestrate coding agents for project work.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"workflow_path",
|
||||
nargs="?",
|
||||
default="./WORKFLOW.md",
|
||||
help="Path to WORKFLOW.md (default: ./WORKFLOW.md)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Enable HTTP server on this port",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def run(workflow_path: Path, port: int | None) -> None:
|
||||
"""Main async entry point."""
|
||||
logger = logging.getLogger("ai_orchestrator")
|
||||
|
||||
# Validate workflow path exists
|
||||
if not workflow_path.exists():
|
||||
logger.error(f"Workflow file not found: {workflow_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create orchestrator
|
||||
orchestrator = Orchestrator(workflow_path=workflow_path, port=port)
|
||||
|
||||
# Set up workflow file watcher
|
||||
watcher = WorkflowWatcher(
|
||||
workflow_path=workflow_path,
|
||||
on_change=orchestrator._reload_workflow,
|
||||
)
|
||||
watcher.start()
|
||||
|
||||
# Handle shutdown signals
|
||||
loop = asyncio.get_event_loop()
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def handle_signal() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, handle_signal)
|
||||
except NotImplementedError:
|
||||
# Windows doesn't support add_signal_handler
|
||||
signal.signal(sig, lambda s, f: handle_signal())
|
||||
|
||||
# Start orchestrator
|
||||
try:
|
||||
await orchestrator.start()
|
||||
|
||||
# Start HTTP server if port specified
|
||||
http_server = None
|
||||
if port is not None or (orchestrator.config and orchestrator.config.server.port):
|
||||
effective_port = port or (
|
||||
orchestrator.config.server.port if orchestrator.config else None
|
||||
)
|
||||
if effective_port is not None:
|
||||
from ai_orchestrator.http_server import create_app
|
||||
import uvicorn
|
||||
|
||||
app = create_app(orchestrator)
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=effective_port,
|
||||
log_level="warning",
|
||||
)
|
||||
http_server = uvicorn.Server(config)
|
||||
asyncio.create_task(http_server.serve())
|
||||
logger.info(f"HTTP server started on http://127.0.0.1:{effective_port}")
|
||||
|
||||
# Wait for shutdown
|
||||
await shutdown_event.wait()
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
watcher.stop()
|
||||
await orchestrator.stop()
|
||||
logger.info("Shutdown complete.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
setup_logging()
|
||||
args = parse_args()
|
||||
|
||||
workflow_path = Path(args.workflow_path).resolve()
|
||||
|
||||
try:
|
||||
asyncio.run(run(workflow_path, args.port))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Configuration layer — typed getters, defaults, env resolution, validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigValidationError(Exception):
|
||||
"""Raised when configuration validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerConfig:
|
||||
"""Tracker configuration."""
|
||||
|
||||
kind: str = ""
|
||||
endpoint: str = ""
|
||||
api_key: str = ""
|
||||
project_slug: str = ""
|
||||
active_states: list[str] = field(default_factory=lambda: ["Todo", "In Progress"])
|
||||
terminal_states: list[str] = field(
|
||||
default_factory=lambda: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollingConfig:
|
||||
"""Polling configuration."""
|
||||
|
||||
interval_ms: int = 30000
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkspaceConfig:
|
||||
"""Workspace configuration."""
|
||||
|
||||
root: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HooksConfig:
|
||||
"""Hooks configuration."""
|
||||
|
||||
after_create: str | None = None
|
||||
before_run: str | None = None
|
||||
after_run: str | None = None
|
||||
before_remove: str | None = None
|
||||
timeout_ms: int = 60000
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Agent configuration."""
|
||||
|
||||
max_concurrent_agents: int = 10
|
||||
max_turns: int = 20
|
||||
max_retry_backoff_ms: int = 300000
|
||||
max_concurrent_agents_by_state: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexConfig:
|
||||
"""Codex configuration."""
|
||||
|
||||
command: str = "codex app-server"
|
||||
approval_policy: str = "auto-edit"
|
||||
thread_sandbox: str = "local-network"
|
||||
turn_sandbox_policy: str = "permissive"
|
||||
turn_timeout_ms: int = 3600000
|
||||
read_timeout_ms: int = 5000
|
||||
stall_timeout_ms: int = 300000
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""HTTP server extension configuration."""
|
||||
|
||||
port: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceConfig:
|
||||
"""Complete typed service configuration."""
|
||||
|
||||
tracker: TrackerConfig = field(default_factory=TrackerConfig)
|
||||
polling: PollingConfig = field(default_factory=PollingConfig)
|
||||
workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
|
||||
hooks: HooksConfig = field(default_factory=HooksConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
codex: CodexConfig = field(default_factory=CodexConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
|
||||
|
||||
def resolve_env_var(value: str) -> str:
|
||||
"""Resolve $VAR_NAME references in a string value.
|
||||
|
||||
Args:
|
||||
value: String that may contain $VAR_NAME.
|
||||
|
||||
Returns:
|
||||
Resolved string value.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if value.startswith("$"):
|
||||
var_name = value[1:]
|
||||
resolved = os.environ.get(var_name, "")
|
||||
return resolved
|
||||
return value
|
||||
|
||||
|
||||
def expand_path(value: str, workflow_dir: Path) -> str:
|
||||
"""Expand ~ and resolve relative paths.
|
||||
|
||||
Args:
|
||||
value: Path string to expand.
|
||||
workflow_dir: Directory containing WORKFLOW.md for relative path resolution.
|
||||
|
||||
Returns:
|
||||
Absolute path string.
|
||||
"""
|
||||
if not value:
|
||||
return str(Path(tempfile.gettempdir()) / "symphony_workspaces")
|
||||
|
||||
# Resolve env vars in path
|
||||
value = resolve_env_var(value)
|
||||
|
||||
# Expand ~
|
||||
expanded = Path(os.path.expanduser(value))
|
||||
|
||||
# Resolve relative paths against workflow directory
|
||||
if not expanded.is_absolute():
|
||||
expanded = workflow_dir / expanded
|
||||
|
||||
return str(expanded.resolve())
|
||||
|
||||
|
||||
def build_config(raw_config: dict[str, Any], workflow_dir: Path) -> ServiceConfig:
|
||||
"""Build typed ServiceConfig from raw YAML config dict.
|
||||
|
||||
Args:
|
||||
raw_config: Parsed YAML front matter.
|
||||
workflow_dir: Directory containing the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
Fully resolved ServiceConfig.
|
||||
"""
|
||||
config = ServiceConfig()
|
||||
|
||||
# Tracker
|
||||
tracker_raw = raw_config.get("tracker", {}) or {}
|
||||
config.tracker.kind = str(tracker_raw.get("kind", ""))
|
||||
config.tracker.project_slug = str(tracker_raw.get("project_slug", ""))
|
||||
|
||||
# Endpoint defaults
|
||||
if config.tracker.kind == "linear":
|
||||
config.tracker.endpoint = str(
|
||||
tracker_raw.get("endpoint", "https://api.linear.app/graphql")
|
||||
)
|
||||
elif config.tracker.kind == "orgmylife":
|
||||
config.tracker.endpoint = str(
|
||||
tracker_raw.get("endpoint", "http://localhost:8000")
|
||||
)
|
||||
else:
|
||||
config.tracker.endpoint = str(tracker_raw.get("endpoint", ""))
|
||||
|
||||
# API key with env var resolution
|
||||
api_key_raw = str(tracker_raw.get("api_key", "$LINEAR_API_KEY"))
|
||||
config.tracker.api_key = resolve_env_var(api_key_raw)
|
||||
|
||||
# States
|
||||
if "active_states" in tracker_raw:
|
||||
config.tracker.active_states = [str(s) for s in tracker_raw["active_states"]]
|
||||
if "terminal_states" in tracker_raw:
|
||||
config.tracker.terminal_states = [str(s) for s in tracker_raw["terminal_states"]]
|
||||
|
||||
# Polling
|
||||
polling_raw = raw_config.get("polling", {}) or {}
|
||||
config.polling.interval_ms = int(polling_raw.get("interval_ms", 30000))
|
||||
|
||||
# Workspace
|
||||
workspace_raw = raw_config.get("workspace", {}) or {}
|
||||
config.workspace.root = expand_path(
|
||||
str(workspace_raw.get("root", "")), workflow_dir
|
||||
)
|
||||
|
||||
# Hooks
|
||||
hooks_raw = raw_config.get("hooks", {}) or {}
|
||||
config.hooks.after_create = hooks_raw.get("after_create")
|
||||
config.hooks.before_run = hooks_raw.get("before_run")
|
||||
config.hooks.after_run = hooks_raw.get("after_run")
|
||||
config.hooks.before_remove = hooks_raw.get("before_remove")
|
||||
config.hooks.timeout_ms = int(hooks_raw.get("timeout_ms", 60000))
|
||||
|
||||
# Agent
|
||||
agent_raw = raw_config.get("agent", {}) or {}
|
||||
config.agent.max_concurrent_agents = int(agent_raw.get("max_concurrent_agents", 10))
|
||||
config.agent.max_turns = int(agent_raw.get("max_turns", 20))
|
||||
config.agent.max_retry_backoff_ms = int(agent_raw.get("max_retry_backoff_ms", 300000))
|
||||
|
||||
by_state_raw = agent_raw.get("max_concurrent_agents_by_state", {}) or {}
|
||||
for state_name, limit in by_state_raw.items():
|
||||
try:
|
||||
limit_int = int(limit)
|
||||
if limit_int > 0:
|
||||
config.agent.max_concurrent_agents_by_state[state_name.lower()] = limit_int
|
||||
except (ValueError, TypeError):
|
||||
pass # Ignore invalid entries per spec
|
||||
|
||||
# Codex
|
||||
codex_raw = raw_config.get("codex", {}) or {}
|
||||
config.codex.command = str(codex_raw.get("command", "codex app-server"))
|
||||
config.codex.approval_policy = str(codex_raw.get("approval_policy", "auto-edit"))
|
||||
config.codex.thread_sandbox = str(codex_raw.get("thread_sandbox", "local-network"))
|
||||
config.codex.turn_sandbox_policy = str(codex_raw.get("turn_sandbox_policy", "permissive"))
|
||||
config.codex.turn_timeout_ms = int(codex_raw.get("turn_timeout_ms", 3600000))
|
||||
config.codex.read_timeout_ms = int(codex_raw.get("read_timeout_ms", 5000))
|
||||
config.codex.stall_timeout_ms = int(codex_raw.get("stall_timeout_ms", 300000))
|
||||
|
||||
# Server (extension)
|
||||
server_raw = raw_config.get("server", {}) or {}
|
||||
port = server_raw.get("port")
|
||||
config.server.port = int(port) if port is not None else None
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def validate_dispatch_config(config: ServiceConfig) -> list[str]:
|
||||
"""Validate configuration for dispatch readiness.
|
||||
|
||||
Args:
|
||||
config: The service configuration to validate.
|
||||
|
||||
Returns:
|
||||
List of validation error messages. Empty list means valid.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not config.tracker.kind:
|
||||
errors.append("tracker.kind is required")
|
||||
elif config.tracker.kind not in ("linear", "orgmylife"):
|
||||
errors.append(f"Unsupported tracker.kind: {config.tracker.kind}")
|
||||
|
||||
if config.tracker.kind == "linear" and not config.tracker.api_key:
|
||||
errors.append("tracker.api_key is required (set LINEAR_API_KEY env var)")
|
||||
|
||||
if config.tracker.kind == "linear" and not config.tracker.project_slug:
|
||||
errors.append("tracker.project_slug is required for Linear tracker")
|
||||
|
||||
if config.tracker.kind == "orgmylife" and not config.tracker.endpoint:
|
||||
errors.append("tracker.endpoint is required for OrgMyLife tracker")
|
||||
|
||||
if not config.codex.command:
|
||||
errors.append("codex.command must be non-empty")
|
||||
|
||||
if config.agent.max_turns < 1:
|
||||
errors.append("agent.max_turns must be a positive integer")
|
||||
|
||||
if config.hooks.timeout_ms < 0:
|
||||
errors.append("hooks.timeout_ms must be non-negative")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,227 @@
|
||||
"""HTTP server extension — REST API and dashboard for observability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app(orchestrator: "Orchestrator") -> Starlette:
|
||||
"""Create the Starlette ASGI application.
|
||||
|
||||
Args:
|
||||
orchestrator: The orchestrator instance for state access.
|
||||
|
||||
Returns:
|
||||
Configured Starlette application.
|
||||
"""
|
||||
|
||||
async def dashboard(request: Request) -> Response:
|
||||
"""Serve the human-readable dashboard."""
|
||||
snapshot = orchestrator.get_snapshot()
|
||||
html = _render_dashboard(snapshot)
|
||||
return HTMLResponse(html)
|
||||
|
||||
async def api_state(request: Request) -> Response:
|
||||
"""GET /api/v1/state — current system state."""
|
||||
snapshot = orchestrator.get_snapshot()
|
||||
return JSONResponse(snapshot)
|
||||
|
||||
async def api_issue(request: Request) -> Response:
|
||||
"""GET /api/v1/{issue_identifier} — issue-specific details."""
|
||||
identifier = request.path_params["identifier"]
|
||||
|
||||
# Search running entries
|
||||
for entry in orchestrator.state.running.values():
|
||||
if entry.identifier == identifier:
|
||||
return JSONResponse({
|
||||
"issue_identifier": entry.identifier,
|
||||
"issue_id": entry.issue_id,
|
||||
"status": "running",
|
||||
"workspace": {
|
||||
"path": f"{orchestrator.config.workspace.root}/{entry.identifier}"
|
||||
if orchestrator.config
|
||||
else None,
|
||||
},
|
||||
"attempts": {
|
||||
"restart_count": entry.retry_attempt,
|
||||
"current_retry_attempt": entry.retry_attempt,
|
||||
},
|
||||
"running": {
|
||||
"session_id": entry.session.session_id,
|
||||
"turn_count": entry.session.turn_count,
|
||||
"state": entry.issue.state,
|
||||
"started_at": (
|
||||
entry.started_at.isoformat()
|
||||
if entry.started_at
|
||||
else None
|
||||
),
|
||||
"last_event": entry.session.last_codex_event,
|
||||
"last_message": entry.session.last_codex_message,
|
||||
"last_event_at": (
|
||||
entry.session.last_codex_timestamp.isoformat()
|
||||
if entry.session.last_codex_timestamp
|
||||
else None
|
||||
),
|
||||
"tokens": {
|
||||
"input_tokens": entry.session.codex_input_tokens,
|
||||
"output_tokens": entry.session.codex_output_tokens,
|
||||
"total_tokens": entry.session.codex_total_tokens,
|
||||
},
|
||||
},
|
||||
"retry": None,
|
||||
"last_error": None,
|
||||
})
|
||||
|
||||
# Search retry entries
|
||||
for retry in orchestrator.state.retry_attempts.values():
|
||||
if retry.identifier == identifier:
|
||||
return JSONResponse({
|
||||
"issue_identifier": retry.identifier,
|
||||
"issue_id": retry.issue_id,
|
||||
"status": "retrying",
|
||||
"workspace": {
|
||||
"path": f"{orchestrator.config.workspace.root}/{retry.identifier}"
|
||||
if orchestrator.config
|
||||
else None,
|
||||
},
|
||||
"attempts": {
|
||||
"restart_count": retry.attempt,
|
||||
"current_retry_attempt": retry.attempt,
|
||||
},
|
||||
"running": None,
|
||||
"retry": {
|
||||
"attempt": retry.attempt,
|
||||
"due_at": retry.due_at_ms,
|
||||
"error": retry.error,
|
||||
},
|
||||
"last_error": retry.error,
|
||||
})
|
||||
|
||||
return JSONResponse(
|
||||
{"error": {"code": "issue_not_found", "message": f"Issue {identifier} not found"}},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
async def api_refresh(request: Request) -> Response:
|
||||
"""POST /api/v1/refresh — trigger immediate poll cycle."""
|
||||
# Queue an immediate tick (best-effort)
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
asyncio.create_task(orchestrator._tick())
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"queued": True,
|
||||
"coalesced": False,
|
||||
"requested_at": datetime.now(timezone.utc).isoformat(),
|
||||
"operations": ["poll", "reconcile"],
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
routes = [
|
||||
Route("/", dashboard),
|
||||
Route("/api/v1/state", api_state, methods=["GET"]),
|
||||
Route("/api/v1/refresh", api_refresh, methods=["POST"]),
|
||||
Route("/api/v1/{identifier}", api_issue, methods=["GET"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
def _render_dashboard(snapshot: dict) -> str:
|
||||
"""Render a simple HTML dashboard from the snapshot."""
|
||||
running_count = snapshot["counts"]["running"]
|
||||
retrying_count = snapshot["counts"]["retrying"]
|
||||
totals = snapshot["codex_totals"]
|
||||
|
||||
running_rows = ""
|
||||
for r in snapshot["running"]:
|
||||
running_rows += f"""
|
||||
<tr>
|
||||
<td>{r['issue_identifier']}</td>
|
||||
<td>{r['state']}</td>
|
||||
<td>{r['turn_count']}</td>
|
||||
<td>{r['last_event'] or '-'}</td>
|
||||
<td>{r['last_message'] or '-'}</td>
|
||||
<td>{r['tokens']['total_tokens']}</td>
|
||||
</tr>"""
|
||||
|
||||
retry_rows = ""
|
||||
for r in snapshot["retrying"]:
|
||||
retry_rows += f"""
|
||||
<tr>
|
||||
<td>{r['issue_identifier']}</td>
|
||||
<td>{r['attempt']}</td>
|
||||
<td>{r['error'] or '-'}</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AI-Orchestrator Dashboard</title>
|
||||
<meta http-equiv="refresh" content="10">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
margin: 2rem; background: #1a1a2e; color: #e0e0e0; }}
|
||||
h1 {{ color: #00d4aa; }}
|
||||
h2 {{ color: #7c83ff; margin-top: 2rem; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 1rem; }}
|
||||
th, td {{ border: 1px solid #333; padding: 0.5rem 1rem; text-align: left; }}
|
||||
th {{ background: #16213e; color: #00d4aa; }}
|
||||
tr:nth-child(even) {{ background: #0f3460; }}
|
||||
.stats {{ display: flex; gap: 2rem; margin: 1rem 0; }}
|
||||
.stat {{ background: #16213e; padding: 1rem 2rem; border-radius: 8px; }}
|
||||
.stat-value {{ font-size: 2rem; font-weight: bold; color: #00d4aa; }}
|
||||
.stat-label {{ color: #888; font-size: 0.9rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🎵 AI-Orchestrator</h1>
|
||||
<p>Generated: {snapshot['generated_at']}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">{running_count}</div>
|
||||
<div class="stat-label">Running</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{retrying_count}</div>
|
||||
<div class="stat-label">Retrying</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{totals['total_tokens']:,}</div>
|
||||
<div class="stat-label">Total Tokens</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">{totals['seconds_running']:.0f}s</div>
|
||||
<div class="stat-label">Runtime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Running Sessions</h2>
|
||||
<table>
|
||||
<tr><th>Issue</th><th>State</th><th>Turns</th><th>Last Event</th><th>Message</th><th>Tokens</th></tr>
|
||||
{running_rows or '<tr><td colspan="6">No active sessions</td></tr>'}
|
||||
</table>
|
||||
|
||||
<h2>Retry Queue</h2>
|
||||
<table>
|
||||
<tr><th>Issue</th><th>Attempt</th><th>Error</th></tr>
|
||||
{retry_rows or '<tr><td colspan="3">No retries queued</td></tr>'}
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Core domain models for the AI-Orchestrator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
# --- Issue Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockerRef:
|
||||
"""A reference to a blocking issue."""
|
||||
|
||||
id: str | None = None
|
||||
identifier: str | None = None
|
||||
state: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Normalized issue record from the tracker."""
|
||||
|
||||
id: str
|
||||
identifier: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
priority: int | None = None
|
||||
state: str = ""
|
||||
branch_name: str | None = None
|
||||
url: str | None = None
|
||||
labels: list[str] = field(default_factory=list)
|
||||
blocked_by: list[BlockerRef] = field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# --- Workflow Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowDefinition:
|
||||
"""Parsed WORKFLOW.md payload."""
|
||||
|
||||
config: dict[str, Any]
|
||||
prompt_template: str
|
||||
|
||||
|
||||
# --- Workspace Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workspace:
|
||||
"""Filesystem workspace assigned to one issue."""
|
||||
|
||||
path: str
|
||||
workspace_key: str
|
||||
created_now: bool = False
|
||||
|
||||
|
||||
# --- Run Attempt Model ---
|
||||
|
||||
|
||||
class RunStatus(Enum):
|
||||
"""Status of a run attempt."""
|
||||
|
||||
PREPARING_WORKSPACE = "preparing_workspace"
|
||||
BUILDING_PROMPT = "building_prompt"
|
||||
LAUNCHING_AGENT = "launching_agent"
|
||||
INITIALIZING_SESSION = "initializing_session"
|
||||
STREAMING_TURN = "streaming_turn"
|
||||
FINISHING = "finishing"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
TIMED_OUT = "timed_out"
|
||||
STALLED = "stalled"
|
||||
CANCELED = "canceled_by_reconciliation"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunAttempt:
|
||||
"""One execution attempt for one issue."""
|
||||
|
||||
issue_id: str
|
||||
issue_identifier: str
|
||||
attempt: int | None = None
|
||||
workspace_path: str = ""
|
||||
started_at: datetime | None = None
|
||||
status: RunStatus = RunStatus.PREPARING_WORKSPACE
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# --- Live Session Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveSession:
|
||||
"""State tracked while a coding-agent subprocess is running."""
|
||||
|
||||
session_id: str = ""
|
||||
thread_id: str = ""
|
||||
turn_id: str = ""
|
||||
codex_app_server_pid: str | None = None
|
||||
last_codex_event: str | None = None
|
||||
last_codex_timestamp: datetime | None = None
|
||||
last_codex_message: str = ""
|
||||
codex_input_tokens: int = 0
|
||||
codex_output_tokens: int = 0
|
||||
codex_total_tokens: int = 0
|
||||
last_reported_input_tokens: int = 0
|
||||
last_reported_output_tokens: int = 0
|
||||
last_reported_total_tokens: int = 0
|
||||
turn_count: int = 0
|
||||
|
||||
|
||||
# --- Retry Entry Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryEntry:
|
||||
"""Scheduled retry state for an issue."""
|
||||
|
||||
issue_id: str
|
||||
identifier: str
|
||||
attempt: int
|
||||
due_at_ms: float
|
||||
timer_handle: Any = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# --- Running Entry Model ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunningEntry:
|
||||
"""Entry in the orchestrator's running map."""
|
||||
|
||||
issue_id: str
|
||||
identifier: str
|
||||
issue: Issue
|
||||
session: LiveSession = field(default_factory=LiveSession)
|
||||
retry_attempt: int = 0
|
||||
started_at: datetime | None = None
|
||||
worker_task: Any = None
|
||||
|
||||
|
||||
# --- Orchestrator State ---
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodexTotals:
|
||||
"""Aggregate token and runtime totals."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
seconds_running: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestratorState:
|
||||
"""Single authoritative in-memory state owned by the orchestrator."""
|
||||
|
||||
poll_interval_ms: int = 30000
|
||||
max_concurrent_agents: int = 10
|
||||
running: dict[str, RunningEntry] = field(default_factory=dict)
|
||||
claimed: set[str] = field(default_factory=set)
|
||||
retry_attempts: dict[str, RetryEntry] = field(default_factory=dict)
|
||||
completed: set[str] = field(default_factory=set)
|
||||
codex_totals: CodexTotals = field(default_factory=CodexTotals)
|
||||
codex_rate_limits: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,779 @@
|
||||
"""Orchestrator — poll loop, dispatch, concurrency, retries, reconciliation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ai_orchestrator.agent_runner import (
|
||||
AgentError,
|
||||
AgentSession,
|
||||
AgentTimeoutError,
|
||||
AgentEvent,
|
||||
)
|
||||
from ai_orchestrator.config import (
|
||||
ServiceConfig,
|
||||
build_config,
|
||||
validate_dispatch_config,
|
||||
)
|
||||
from ai_orchestrator.models import (
|
||||
CodexTotals,
|
||||
Issue,
|
||||
LiveSession,
|
||||
OrchestratorState,
|
||||
RetryEntry,
|
||||
RunningEntry,
|
||||
)
|
||||
from ai_orchestrator.prompt import (
|
||||
TemplateRenderError,
|
||||
build_continuation_prompt,
|
||||
render_prompt,
|
||||
)
|
||||
from ai_orchestrator.tracker import LinearClient, TrackerError
|
||||
from ai_orchestrator.tracker_orgmylife import OrgMyLifeClient
|
||||
from ai_orchestrator.workflow import WorkflowDefinition, WorkflowError, load_workflow
|
||||
from ai_orchestrator.workspace import (
|
||||
WorkspaceError,
|
||||
cleanup_workspace,
|
||||
create_workspace,
|
||||
run_hook,
|
||||
run_hook_best_effort,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Main orchestration engine.
|
||||
|
||||
Owns the poll tick, in-memory runtime state, and dispatch decisions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_path: Path,
|
||||
port: int | None = None,
|
||||
) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._workflow_dir = self._workflow_path.parent
|
||||
self._port = port
|
||||
|
||||
self._state = OrchestratorState()
|
||||
self._config: ServiceConfig | None = None
|
||||
self._workflow: WorkflowDefinition | None = None
|
||||
self._tracker: LinearClient | None = None
|
||||
self._dispatch_lock = asyncio.Lock() # Prevent race conditions
|
||||
|
||||
self._running = False
|
||||
self._tick_task: asyncio.Task[None] | None = None
|
||||
self._observer_callbacks: list[Any] = []
|
||||
|
||||
@property
|
||||
def state(self) -> OrchestratorState:
|
||||
"""Get the current orchestrator state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def config(self) -> ServiceConfig | None:
|
||||
"""Get the current service configuration."""
|
||||
return self._config
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the orchestrator service.
|
||||
|
||||
Raises:
|
||||
WorkflowError: If workflow cannot be loaded.
|
||||
ConfigValidationError: If config validation fails at startup.
|
||||
"""
|
||||
logger.info("Starting AI-Orchestrator...")
|
||||
|
||||
# Load and validate workflow
|
||||
self._reload_workflow()
|
||||
|
||||
assert self._config is not None
|
||||
|
||||
# Validate config
|
||||
errors = validate_dispatch_config(self._config)
|
||||
if errors:
|
||||
error_msg = "; ".join(errors)
|
||||
logger.error(f"Startup validation failed: {error_msg}")
|
||||
raise SystemExit(f"Configuration errors: {error_msg}")
|
||||
|
||||
# Initialize tracker client based on tracker.kind
|
||||
if self._config.tracker.kind == "orgmylife":
|
||||
self._tracker = OrgMyLifeClient(self._config)
|
||||
else:
|
||||
self._tracker = LinearClient(self._config)
|
||||
|
||||
# Apply config to state
|
||||
self._state.poll_interval_ms = self._config.polling.interval_ms
|
||||
self._state.max_concurrent_agents = self._config.agent.max_concurrent_agents
|
||||
|
||||
# Startup terminal workspace cleanup
|
||||
await self._startup_cleanup()
|
||||
|
||||
# Start polling
|
||||
self._running = True
|
||||
self._tick_task = asyncio.create_task(self._poll_loop())
|
||||
|
||||
logger.info(
|
||||
f"Orchestrator started. Polling every {self._state.poll_interval_ms}ms, "
|
||||
f"max {self._state.max_concurrent_agents} concurrent agents."
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the orchestrator gracefully."""
|
||||
logger.info("Stopping orchestrator...")
|
||||
self._running = False
|
||||
|
||||
if self._tick_task:
|
||||
self._tick_task.cancel()
|
||||
try:
|
||||
await self._tick_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop all running workers
|
||||
for issue_id, entry in list(self._state.running.items()):
|
||||
if entry.worker_task and not entry.worker_task.done():
|
||||
entry.worker_task.cancel()
|
||||
|
||||
# Cancel retry timers
|
||||
for issue_id, retry in list(self._state.retry_attempts.items()):
|
||||
if retry.timer_handle:
|
||||
retry.timer_handle.cancel()
|
||||
|
||||
# Close tracker client
|
||||
if self._tracker:
|
||||
await self._tracker.close()
|
||||
|
||||
logger.info("Orchestrator stopped.")
|
||||
|
||||
def _reload_workflow(self) -> bool:
|
||||
"""Reload workflow from disk.
|
||||
|
||||
Returns:
|
||||
True if reload succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
self._workflow = load_workflow(self._workflow_path)
|
||||
self._config = build_config(
|
||||
self._workflow.config, self._workflow_dir
|
||||
)
|
||||
|
||||
# Re-apply dynamic settings
|
||||
self._state.poll_interval_ms = self._config.polling.interval_ms
|
||||
self._state.max_concurrent_agents = self._config.agent.max_concurrent_agents
|
||||
|
||||
logger.info("Workflow reloaded successfully")
|
||||
return True
|
||||
except WorkflowError as e:
|
||||
logger.error(f"Workflow reload failed: {e}")
|
||||
return False
|
||||
|
||||
async def _startup_cleanup(self) -> None:
|
||||
"""Clean workspaces for issues in terminal states."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
try:
|
||||
terminal_issues = await self._tracker.fetch_issues_by_states(
|
||||
self._config.tracker.terminal_states
|
||||
)
|
||||
for issue in terminal_issues:
|
||||
await cleanup_workspace(self._config, issue.identifier)
|
||||
except TrackerError as e:
|
||||
logger.warning(f"Startup terminal cleanup failed: {e}")
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Main polling loop."""
|
||||
# Immediate first tick
|
||||
await self._tick()
|
||||
|
||||
while self._running:
|
||||
await asyncio.sleep(self._state.poll_interval_ms / 1000.0)
|
||||
if self._running:
|
||||
await self._tick()
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute one poll-and-dispatch tick."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
# Step 1: Reconcile running issues
|
||||
await self._reconcile()
|
||||
|
||||
# Step 2: Validate config
|
||||
errors = validate_dispatch_config(self._config)
|
||||
if errors:
|
||||
logger.error(f"Dispatch validation failed: {'; '.join(errors)}")
|
||||
self._notify_observers()
|
||||
return
|
||||
|
||||
# Step 3: Fetch candidate issues
|
||||
try:
|
||||
candidates = await self._tracker.fetch_candidate_issues()
|
||||
except TrackerError as e:
|
||||
logger.error(f"Failed to fetch candidates: {e}")
|
||||
self._notify_observers()
|
||||
return
|
||||
|
||||
# Step 4: Sort by dispatch priority
|
||||
candidates = self._sort_for_dispatch(candidates)
|
||||
|
||||
# Step 5: Dispatch eligible issues
|
||||
for issue in candidates:
|
||||
if self._available_slots() <= 0:
|
||||
break
|
||||
if self._should_dispatch(issue):
|
||||
await self._dispatch_issue(issue, attempt=None)
|
||||
|
||||
# Step 6: Notify observers
|
||||
self._notify_observers()
|
||||
|
||||
def _available_slots(self) -> int:
|
||||
"""Calculate available global concurrency slots."""
|
||||
return max(
|
||||
self._state.max_concurrent_agents - len(self._state.running), 0
|
||||
)
|
||||
|
||||
def _available_slots_for_state(self, state: str) -> int:
|
||||
"""Calculate available per-state concurrency slots."""
|
||||
assert self._config is not None
|
||||
|
||||
state_lower = state.lower()
|
||||
by_state = self._config.agent.max_concurrent_agents_by_state
|
||||
|
||||
if state_lower not in by_state:
|
||||
return self._available_slots()
|
||||
|
||||
max_for_state = by_state[state_lower]
|
||||
current_in_state = sum(
|
||||
1
|
||||
for entry in self._state.running.values()
|
||||
if entry.issue.state.lower() == state_lower
|
||||
)
|
||||
return max(max_for_state - current_in_state, 0)
|
||||
|
||||
def _should_dispatch(self, issue: Issue) -> bool:
|
||||
"""Check if an issue is eligible for dispatch."""
|
||||
assert self._config is not None
|
||||
|
||||
# Required fields
|
||||
if not all([issue.id, issue.identifier, issue.title, issue.state]):
|
||||
return False
|
||||
|
||||
# State checks
|
||||
state_lower = issue.state.lower()
|
||||
active_lower = [s.lower() for s in self._config.tracker.active_states]
|
||||
terminal_lower = [s.lower() for s in self._config.tracker.terminal_states]
|
||||
|
||||
if state_lower not in active_lower:
|
||||
return False
|
||||
if state_lower in terminal_lower:
|
||||
return False
|
||||
|
||||
# Already running or claimed
|
||||
if issue.id in self._state.running:
|
||||
return False
|
||||
if issue.id in self._state.claimed:
|
||||
return False
|
||||
|
||||
# Per-state concurrency
|
||||
if self._available_slots_for_state(issue.state) <= 0:
|
||||
return False
|
||||
|
||||
# Blocker rule for Todo state
|
||||
if state_lower == "todo" and issue.blocked_by:
|
||||
terminal_states_lower = set(terminal_lower)
|
||||
for blocker in issue.blocked_by:
|
||||
blocker_state = (blocker.state or "").lower()
|
||||
if blocker_state not in terminal_states_lower:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _sort_for_dispatch(self, issues: list[Issue]) -> list[Issue]:
|
||||
"""Sort issues by dispatch priority."""
|
||||
|
||||
def sort_key(issue: Issue) -> tuple:
|
||||
# Priority ascending (1..4 preferred, None sorts last)
|
||||
priority = issue.priority if issue.priority is not None else 999
|
||||
# Created at oldest first
|
||||
created = issue.created_at or datetime.max.replace(tzinfo=timezone.utc)
|
||||
# Identifier as tiebreaker
|
||||
return (priority, created, issue.identifier)
|
||||
|
||||
return sorted(issues, key=sort_key)
|
||||
|
||||
async def _dispatch_issue(
|
||||
self, issue: Issue, attempt: int | None
|
||||
) -> None:
|
||||
"""Dispatch a single issue for execution."""
|
||||
logger.info(
|
||||
f"Dispatching {issue.identifier} "
|
||||
f"(attempt={'first' if attempt is None else attempt})"
|
||||
)
|
||||
|
||||
# Claim the issue
|
||||
self._state.claimed.add(issue.id)
|
||||
|
||||
# Remove from retry queue if present
|
||||
if issue.id in self._state.retry_attempts:
|
||||
retry = self._state.retry_attempts.pop(issue.id)
|
||||
if retry.timer_handle:
|
||||
retry.timer_handle.cancel()
|
||||
|
||||
# Mark as in_progress in the tracker (OrgMyLife two-way sync)
|
||||
if self._config and self._config.tracker.kind == "orgmylife":
|
||||
from ai_orchestrator.tracker_orgmylife import OrgMyLifeClient
|
||||
if isinstance(self._tracker, OrgMyLifeClient):
|
||||
await self._tracker.update_task_state(issue.id, "in_progress")
|
||||
|
||||
# Create running entry
|
||||
entry = RunningEntry(
|
||||
issue_id=issue.id,
|
||||
identifier=issue.identifier,
|
||||
issue=issue,
|
||||
session=LiveSession(),
|
||||
retry_attempt=attempt or 0,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Spawn worker task
|
||||
entry.worker_task = asyncio.create_task(
|
||||
self._run_worker(issue, attempt)
|
||||
)
|
||||
self._state.running[issue.id] = entry
|
||||
|
||||
async def _run_worker(self, issue: Issue, attempt: int | None) -> None:
|
||||
"""Worker task: workspace + prompt + agent turns."""
|
||||
assert self._config is not None
|
||||
assert self._workflow is not None
|
||||
|
||||
try:
|
||||
# Create/reuse workspace
|
||||
workspace = await create_workspace(self._config, issue.identifier)
|
||||
|
||||
# Run before_run hook
|
||||
if self._config.hooks.before_run:
|
||||
await run_hook(
|
||||
self._config.hooks.before_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
|
||||
# Start agent session
|
||||
agent = AgentSession(self._config, workspace.path, issue)
|
||||
session = await agent.start()
|
||||
|
||||
# Update running entry with session info
|
||||
if issue.id in self._state.running:
|
||||
self._state.running[issue.id].session = session
|
||||
|
||||
# Turn loop
|
||||
max_turns = self._config.agent.max_turns
|
||||
for turn_number in range(1, max_turns + 1):
|
||||
# Build prompt
|
||||
if turn_number == 1:
|
||||
prompt = render_prompt(
|
||||
self._workflow.prompt_template, issue, attempt
|
||||
)
|
||||
else:
|
||||
prompt = build_continuation_prompt(
|
||||
issue, turn_number, max_turns
|
||||
)
|
||||
|
||||
# Run turn
|
||||
result = await agent.run_turn(
|
||||
prompt,
|
||||
on_event=self._on_agent_event,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
raise AgentError(result.error or "Turn failed")
|
||||
|
||||
# Re-check issue state
|
||||
try:
|
||||
refreshed = await self._tracker.fetch_issue_states_by_ids(
|
||||
[issue.id]
|
||||
)
|
||||
if refreshed:
|
||||
current_state = refreshed[0].state.lower()
|
||||
active_lower = [
|
||||
s.lower()
|
||||
for s in self._config.tracker.active_states
|
||||
]
|
||||
if current_state not in active_lower:
|
||||
break
|
||||
# Update issue state
|
||||
issue.state = refreshed[0].state
|
||||
except TrackerError:
|
||||
# State refresh failed — stop the worker
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
raise AgentError("Issue state refresh failed")
|
||||
|
||||
# Clean exit
|
||||
await agent.stop()
|
||||
await run_hook_best_effort(
|
||||
self._config.hooks.after_run,
|
||||
Path(workspace.path),
|
||||
self._config.hooks.timeout_ms,
|
||||
)
|
||||
|
||||
# Normal exit — schedule continuation retry
|
||||
self._on_worker_exit(issue.id, normal=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Worker failed for {issue.identifier}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
self._on_worker_exit(issue.id, normal=False, error=str(e))
|
||||
|
||||
def _on_worker_exit(
|
||||
self, issue_id: str, normal: bool, error: str | None = None
|
||||
) -> None:
|
||||
"""Handle worker task completion."""
|
||||
entry = self._state.running.pop(issue_id, None)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# Add runtime seconds to totals
|
||||
if entry.started_at:
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - entry.started_at
|
||||
).total_seconds()
|
||||
self._state.codex_totals.seconds_running += elapsed
|
||||
|
||||
# Add token totals
|
||||
self._state.codex_totals.input_tokens += entry.session.codex_input_tokens
|
||||
self._state.codex_totals.output_tokens += entry.session.codex_output_tokens
|
||||
self._state.codex_totals.total_tokens += entry.session.codex_total_tokens
|
||||
|
||||
if normal:
|
||||
self._state.completed.add(issue_id)
|
||||
# Schedule short continuation retry
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=1,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=1000,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
# Exponential backoff retry
|
||||
next_attempt = entry.retry_attempt + 1
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=next_attempt,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=self._compute_backoff(next_attempt),
|
||||
error=error,
|
||||
)
|
||||
|
||||
def _compute_backoff(self, attempt: int) -> int:
|
||||
"""Compute exponential backoff delay.
|
||||
|
||||
Formula: min(10000 * 2^(attempt-1), max_retry_backoff_ms)
|
||||
"""
|
||||
assert self._config is not None
|
||||
delay = 10000 * (2 ** (attempt - 1))
|
||||
return min(delay, self._config.agent.max_retry_backoff_ms)
|
||||
|
||||
def _schedule_retry(
|
||||
self,
|
||||
issue_id: str,
|
||||
attempt: int,
|
||||
identifier: str,
|
||||
delay_ms: int,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""Schedule a retry for an issue."""
|
||||
# Cancel existing retry timer
|
||||
if issue_id in self._state.retry_attempts:
|
||||
old = self._state.retry_attempts[issue_id]
|
||||
if old.timer_handle:
|
||||
old.timer_handle.cancel()
|
||||
|
||||
due_at_ms = time.monotonic() * 1000 + delay_ms
|
||||
|
||||
# Create timer
|
||||
loop = asyncio.get_event_loop()
|
||||
timer = loop.call_later(
|
||||
delay_ms / 1000.0,
|
||||
lambda: asyncio.create_task(self._on_retry_timer(issue_id)),
|
||||
)
|
||||
|
||||
self._state.retry_attempts[issue_id] = RetryEntry(
|
||||
issue_id=issue_id,
|
||||
identifier=identifier,
|
||||
attempt=attempt,
|
||||
due_at_ms=due_at_ms,
|
||||
timer_handle=timer,
|
||||
error=error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Scheduled retry for {identifier}: "
|
||||
f"attempt={attempt}, delay={delay_ms}ms"
|
||||
+ (f", error={error}" if error else "")
|
||||
)
|
||||
|
||||
async def _on_retry_timer(self, issue_id: str) -> None:
|
||||
"""Handle a retry timer firing."""
|
||||
async with self._dispatch_lock:
|
||||
await self._on_retry_timer_inner(issue_id)
|
||||
|
||||
async def _on_retry_timer_inner(self, issue_id: str) -> None:
|
||||
"""Inner retry logic (called under lock)."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
retry = self._state.retry_attempts.pop(issue_id, None)
|
||||
if not retry:
|
||||
return
|
||||
|
||||
# Fetch active candidates
|
||||
try:
|
||||
candidates = await self._tracker.fetch_candidate_issues()
|
||||
except TrackerError as e:
|
||||
# Re-schedule retry
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=retry.attempt + 1,
|
||||
identifier=retry.identifier,
|
||||
delay_ms=self._compute_backoff(retry.attempt + 1),
|
||||
error=f"retry poll failed: {e}",
|
||||
)
|
||||
return
|
||||
|
||||
# Find the issue
|
||||
issue = next((i for i in candidates if i.id == issue_id), None)
|
||||
|
||||
if issue is None:
|
||||
# Issue no longer active — release claim
|
||||
self._state.claimed.discard(issue_id)
|
||||
logger.info(f"Released claim on {retry.identifier} (no longer active)")
|
||||
return
|
||||
|
||||
# Check slots
|
||||
if self._available_slots() <= 0:
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=retry.attempt + 1,
|
||||
identifier=retry.identifier,
|
||||
delay_ms=self._compute_backoff(retry.attempt + 1),
|
||||
error="no available orchestrator slots",
|
||||
)
|
||||
return
|
||||
|
||||
# Re-dispatch
|
||||
await self._dispatch_issue(issue, attempt=retry.attempt)
|
||||
|
||||
async def _reconcile(self) -> None:
|
||||
"""Reconcile running issues with tracker state."""
|
||||
assert self._config is not None
|
||||
assert self._tracker is not None
|
||||
|
||||
if not self._state.running:
|
||||
return
|
||||
|
||||
# Part A: Stall detection
|
||||
stall_timeout_ms = self._config.codex.stall_timeout_ms
|
||||
if stall_timeout_ms > 0:
|
||||
now = datetime.now(timezone.utc)
|
||||
for issue_id, entry in list(self._state.running.items()):
|
||||
reference_time = (
|
||||
entry.session.last_codex_timestamp or entry.started_at
|
||||
)
|
||||
if reference_time:
|
||||
elapsed_ms = (now - reference_time).total_seconds() * 1000
|
||||
if elapsed_ms > stall_timeout_ms:
|
||||
logger.warning(
|
||||
f"Stall detected for {entry.identifier} "
|
||||
f"({elapsed_ms:.0f}ms since last event)"
|
||||
)
|
||||
await self._terminate_worker(issue_id, cleanup=False)
|
||||
self._schedule_retry(
|
||||
issue_id,
|
||||
attempt=entry.retry_attempt + 1,
|
||||
identifier=entry.identifier,
|
||||
delay_ms=self._compute_backoff(
|
||||
entry.retry_attempt + 1
|
||||
),
|
||||
error="stall detected",
|
||||
)
|
||||
|
||||
# Part B: Tracker state refresh
|
||||
running_ids = list(self._state.running.keys())
|
||||
if not running_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
refreshed = await self._tracker.fetch_issue_states_by_ids(running_ids)
|
||||
except TrackerError as e:
|
||||
logger.debug(f"State refresh failed, keeping workers: {e}")
|
||||
return
|
||||
|
||||
terminal_lower = {
|
||||
s.lower() for s in self._config.tracker.terminal_states
|
||||
}
|
||||
active_lower = {
|
||||
s.lower() for s in self._config.tracker.active_states
|
||||
}
|
||||
|
||||
refreshed_map = {issue.id: issue for issue in refreshed}
|
||||
|
||||
for issue_id in running_ids:
|
||||
if issue_id not in self._state.running:
|
||||
continue # Already removed by stall detection
|
||||
|
||||
issue = refreshed_map.get(issue_id)
|
||||
if not issue:
|
||||
continue
|
||||
|
||||
state_lower = issue.state.lower()
|
||||
|
||||
if state_lower in terminal_lower:
|
||||
await self._terminate_worker(issue_id, cleanup=True)
|
||||
elif state_lower in active_lower:
|
||||
# Update the issue snapshot
|
||||
self._state.running[issue_id].issue.state = issue.state
|
||||
else:
|
||||
# Non-active, non-terminal — stop without cleanup
|
||||
await self._terminate_worker(issue_id, cleanup=False)
|
||||
|
||||
async def _terminate_worker(
|
||||
self, issue_id: str, cleanup: bool
|
||||
) -> None:
|
||||
"""Terminate a running worker."""
|
||||
assert self._config is not None
|
||||
|
||||
entry = self._state.running.pop(issue_id, None)
|
||||
if not entry:
|
||||
return
|
||||
|
||||
# Cancel the worker task
|
||||
if entry.worker_task and not entry.worker_task.done():
|
||||
entry.worker_task.cancel()
|
||||
|
||||
# Release claim
|
||||
self._state.claimed.discard(issue_id)
|
||||
|
||||
# Add runtime
|
||||
if entry.started_at:
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - entry.started_at
|
||||
).total_seconds()
|
||||
self._state.codex_totals.seconds_running += elapsed
|
||||
|
||||
logger.info(
|
||||
f"Terminated worker for {entry.identifier} "
|
||||
f"(cleanup={'yes' if cleanup else 'no'})"
|
||||
)
|
||||
|
||||
# Cleanup workspace if terminal
|
||||
if cleanup:
|
||||
await cleanup_workspace(self._config, entry.identifier)
|
||||
|
||||
def _on_agent_event(self, issue_id: str, event: AgentEvent) -> None:
|
||||
"""Handle an agent event callback."""
|
||||
if issue_id in self._state.running:
|
||||
entry = self._state.running[issue_id]
|
||||
entry.session.last_codex_event = event.event
|
||||
entry.session.last_codex_timestamp = event.timestamp
|
||||
|
||||
if event.codex_app_server_pid:
|
||||
entry.session.codex_app_server_pid = event.codex_app_server_pid
|
||||
|
||||
# Update rate limits if present
|
||||
if event.payload and "rate_limits" in event.payload:
|
||||
self._state.codex_rate_limits = event.payload["rate_limits"]
|
||||
|
||||
def _notify_observers(self) -> None:
|
||||
"""Notify registered observer callbacks."""
|
||||
for callback in self._observer_callbacks:
|
||||
try:
|
||||
callback(self._state)
|
||||
except Exception as e:
|
||||
logger.warning(f"Observer callback failed: {e}")
|
||||
|
||||
def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Get a runtime snapshot for monitoring/API.
|
||||
|
||||
Returns:
|
||||
Dict with running, retrying, totals, and rate limits.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
running_rows = []
|
||||
for entry in self._state.running.values():
|
||||
running_rows.append({
|
||||
"issue_id": entry.issue_id,
|
||||
"issue_identifier": entry.identifier,
|
||||
"state": entry.issue.state,
|
||||
"session_id": entry.session.session_id,
|
||||
"turn_count": entry.session.turn_count,
|
||||
"last_event": entry.session.last_codex_event,
|
||||
"last_message": entry.session.last_codex_message,
|
||||
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||
"last_event_at": (
|
||||
entry.session.last_codex_timestamp.isoformat()
|
||||
if entry.session.last_codex_timestamp
|
||||
else None
|
||||
),
|
||||
"tokens": {
|
||||
"input_tokens": entry.session.codex_input_tokens,
|
||||
"output_tokens": entry.session.codex_output_tokens,
|
||||
"total_tokens": entry.session.codex_total_tokens,
|
||||
},
|
||||
})
|
||||
|
||||
retry_rows = []
|
||||
for retry in self._state.retry_attempts.values():
|
||||
retry_rows.append({
|
||||
"issue_id": retry.issue_id,
|
||||
"issue_identifier": retry.identifier,
|
||||
"attempt": retry.attempt,
|
||||
"due_at": retry.due_at_ms,
|
||||
"error": retry.error,
|
||||
})
|
||||
|
||||
# Compute live seconds_running including active sessions
|
||||
total_seconds = self._state.codex_totals.seconds_running
|
||||
for entry in self._state.running.values():
|
||||
if entry.started_at:
|
||||
total_seconds += (now - entry.started_at).total_seconds()
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"counts": {
|
||||
"running": len(self._state.running),
|
||||
"retrying": len(self._state.retry_attempts),
|
||||
},
|
||||
"running": running_rows,
|
||||
"retrying": retry_rows,
|
||||
"codex_totals": {
|
||||
"input_tokens": self._state.codex_totals.input_tokens,
|
||||
"output_tokens": self._state.codex_totals.output_tokens,
|
||||
"total_tokens": self._state.codex_totals.total_tokens,
|
||||
"seconds_running": round(total_seconds, 1),
|
||||
},
|
||||
"rate_limits": self._state.codex_rate_limits,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Prompt builder — renders issue prompts from workflow templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, StrictUndefined, TemplateSyntaxError, UndefinedError
|
||||
|
||||
from ai_orchestrator.models import Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateParseError(Exception):
|
||||
"""Raised when the prompt template cannot be parsed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TemplateRenderError(Exception):
|
||||
"""Raised when prompt rendering fails (unknown variable/filter)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Default fallback prompt when workflow body is empty
|
||||
DEFAULT_PROMPT = "You are working on an issue from Linear."
|
||||
|
||||
|
||||
def render_prompt(
|
||||
template_str: str,
|
||||
issue: Issue,
|
||||
attempt: int | None = None,
|
||||
) -> str:
|
||||
"""Render a prompt from the workflow template and issue context.
|
||||
|
||||
Uses Jinja2 with strict undefined checking (unknown variables fail).
|
||||
|
||||
Args:
|
||||
template_str: The prompt template string from WORKFLOW.md body.
|
||||
issue: The normalized issue object.
|
||||
attempt: Retry/continuation attempt number, or None for first run.
|
||||
|
||||
Returns:
|
||||
Rendered prompt string.
|
||||
|
||||
Raises:
|
||||
TemplateParseError: If the template has syntax errors.
|
||||
TemplateRenderError: If rendering fails (unknown variable/filter).
|
||||
"""
|
||||
if not template_str:
|
||||
return DEFAULT_PROMPT
|
||||
|
||||
env = Environment(undefined=StrictUndefined)
|
||||
|
||||
try:
|
||||
template = env.from_string(template_str)
|
||||
except TemplateSyntaxError as e:
|
||||
raise TemplateParseError(f"Template syntax error: {e}") from e
|
||||
|
||||
# Build template context
|
||||
issue_dict = _issue_to_template_dict(issue)
|
||||
context: dict[str, Any] = {
|
||||
"issue": issue_dict,
|
||||
"attempt": attempt,
|
||||
}
|
||||
|
||||
try:
|
||||
return template.render(**context)
|
||||
except UndefinedError as e:
|
||||
raise TemplateRenderError(f"Unknown template variable: {e}") from e
|
||||
except Exception as e:
|
||||
raise TemplateRenderError(f"Template render error: {e}") from e
|
||||
|
||||
|
||||
def build_continuation_prompt(
|
||||
issue: Issue, turn_number: int, max_turns: int
|
||||
) -> str:
|
||||
"""Build a continuation prompt for subsequent turns.
|
||||
|
||||
Args:
|
||||
issue: The issue being worked on.
|
||||
turn_number: Current turn number.
|
||||
max_turns: Maximum allowed turns.
|
||||
|
||||
Returns:
|
||||
Continuation guidance prompt.
|
||||
"""
|
||||
return (
|
||||
f"Continue working on {issue.identifier}: {issue.title}. "
|
||||
f"This is turn {turn_number} of {max_turns}. "
|
||||
f"Review your previous work and continue from where you left off."
|
||||
)
|
||||
|
||||
|
||||
def _issue_to_template_dict(issue: Issue) -> dict[str, Any]:
|
||||
"""Convert an Issue to a template-friendly dict.
|
||||
|
||||
Converts all fields to strings/primitives for template compatibility.
|
||||
Preserves nested arrays/maps for iteration.
|
||||
"""
|
||||
d = asdict(issue)
|
||||
|
||||
# Convert datetime objects to ISO strings
|
||||
if d.get("created_at"):
|
||||
d["created_at"] = issue.created_at.isoformat() if issue.created_at else None
|
||||
if d.get("updated_at"):
|
||||
d["updated_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||
|
||||
return d
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Issue tracker client — Linear GraphQL adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import BlockerRef, Issue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# GraphQL queries for Linear
|
||||
CANDIDATE_ISSUES_QUERY = """
|
||||
query CandidateIssues($projectSlug: String!, $states: [String!]!, $after: String) {
|
||||
issues(
|
||||
filter: {
|
||||
project: { slugId: { eq: $projectSlug } }
|
||||
state: { name: { in: $states } }
|
||||
}
|
||||
first: 50
|
||||
after: $after
|
||||
orderBy: createdAt
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
priority
|
||||
state { name }
|
||||
branchName
|
||||
url
|
||||
labels { nodes { name } }
|
||||
relations(first: 50) {
|
||||
nodes {
|
||||
type
|
||||
relatedIssue {
|
||||
id
|
||||
identifier
|
||||
state { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ISSUES_BY_STATES_QUERY = """
|
||||
query IssuesByStates($projectSlug: String!, $states: [String!]!, $after: String) {
|
||||
issues(
|
||||
filter: {
|
||||
project: { slugId: { eq: $projectSlug } }
|
||||
state: { name: { in: $states } }
|
||||
}
|
||||
first: 50
|
||||
after: $after
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
state { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ISSUES_BY_IDS_QUERY = """
|
||||
query IssuesByIds($ids: [ID!]!) {
|
||||
nodes(ids: $ids) {
|
||||
... on Issue {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
state { name }
|
||||
priority
|
||||
labels { nodes { name } }
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class TrackerError(Exception):
|
||||
"""Base class for tracker errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TrackerAPIError(TrackerError):
|
||||
"""Raised on API transport failures."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TrackerGraphQLError(TrackerError):
|
||||
"""Raised when GraphQL returns errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LinearClient:
|
||||
"""Linear issue tracker client."""
|
||||
|
||||
def __init__(self, config: ServiceConfig) -> None:
|
||||
self._config = config
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=config.tracker.endpoint,
|
||||
headers={
|
||||
"Authorization": config.tracker.api_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
async def _execute_query(
|
||||
self, query: str, variables: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a GraphQL query against Linear.
|
||||
|
||||
Args:
|
||||
query: GraphQL query string.
|
||||
variables: Query variables.
|
||||
|
||||
Returns:
|
||||
Response data dict.
|
||||
|
||||
Raises:
|
||||
TrackerAPIError: On transport failures.
|
||||
TrackerGraphQLError: On GraphQL errors.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
"", json={"query": query, "variables": variables}
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"Linear API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"Linear API returned status {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "errors" in data:
|
||||
raise TrackerGraphQLError(
|
||||
f"Linear GraphQL errors: {data['errors']}"
|
||||
)
|
||||
|
||||
return data.get("data", {})
|
||||
|
||||
async def fetch_candidate_issues(self) -> list[Issue]:
|
||||
"""Fetch issues in active states for the configured project.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
all_issues: list[Issue] = []
|
||||
cursor: str | None = None
|
||||
|
||||
while True:
|
||||
variables: dict[str, Any] = {
|
||||
"projectSlug": self._config.tracker.project_slug,
|
||||
"states": self._config.tracker.active_states,
|
||||
}
|
||||
if cursor:
|
||||
variables["after"] = cursor
|
||||
|
||||
data = await self._execute_query(CANDIDATE_ISSUES_QUERY, variables)
|
||||
issues_data = data.get("issues", {})
|
||||
nodes = issues_data.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
issue = self._normalize_issue(node)
|
||||
if issue:
|
||||
all_issues.append(issue)
|
||||
|
||||
page_info = issues_data.get("pageInfo", {})
|
||||
if page_info.get("hasNextPage") and page_info.get("endCursor"):
|
||||
cursor = page_info["endCursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_issues
|
||||
|
||||
async def fetch_issues_by_states(self, states: list[str]) -> list[Issue]:
|
||||
"""Fetch issues in specified states (used for terminal cleanup).
|
||||
|
||||
Args:
|
||||
states: List of state names to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
if not states:
|
||||
return []
|
||||
|
||||
all_issues: list[Issue] = []
|
||||
cursor: str | None = None
|
||||
|
||||
while True:
|
||||
variables: dict[str, Any] = {
|
||||
"projectSlug": self._config.tracker.project_slug,
|
||||
"states": states,
|
||||
}
|
||||
if cursor:
|
||||
variables["after"] = cursor
|
||||
|
||||
data = await self._execute_query(ISSUES_BY_STATES_QUERY, variables)
|
||||
issues_data = data.get("issues", {})
|
||||
nodes = issues_data.get("nodes", [])
|
||||
|
||||
for node in nodes:
|
||||
issue = Issue(
|
||||
id=node["id"],
|
||||
identifier=node["identifier"],
|
||||
title="",
|
||||
state=node.get("state", {}).get("name", ""),
|
||||
)
|
||||
all_issues.append(issue)
|
||||
|
||||
page_info = issues_data.get("pageInfo", {})
|
||||
if page_info.get("hasNextPage") and page_info.get("endCursor"):
|
||||
cursor = page_info["endCursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_issues
|
||||
|
||||
async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]:
|
||||
"""Fetch current states for specific issue IDs (reconciliation).
|
||||
|
||||
Args:
|
||||
issue_ids: List of issue IDs to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects with current state.
|
||||
"""
|
||||
if not issue_ids:
|
||||
return []
|
||||
|
||||
data = await self._execute_query(
|
||||
ISSUES_BY_IDS_QUERY, {"ids": issue_ids}
|
||||
)
|
||||
nodes = data.get("nodes", [])
|
||||
|
||||
issues: list[Issue] = []
|
||||
for node in nodes:
|
||||
if node and "id" in node:
|
||||
issues.append(
|
||||
Issue(
|
||||
id=node["id"],
|
||||
identifier=node.get("identifier", ""),
|
||||
title=node.get("title", ""),
|
||||
state=node.get("state", {}).get("name", ""),
|
||||
priority=node.get("priority"),
|
||||
labels=[
|
||||
lbl["name"].lower()
|
||||
for lbl in (node.get("labels", {}).get("nodes", []))
|
||||
],
|
||||
updated_at=_parse_timestamp(node.get("updatedAt")),
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
def _normalize_issue(self, node: dict[str, Any]) -> Issue | None:
|
||||
"""Normalize a raw Linear issue node into an Issue model.
|
||||
|
||||
Args:
|
||||
node: Raw GraphQL issue node.
|
||||
|
||||
Returns:
|
||||
Normalized Issue or None if missing required fields.
|
||||
"""
|
||||
issue_id = node.get("id")
|
||||
identifier = node.get("identifier")
|
||||
title = node.get("title")
|
||||
state = node.get("state", {}).get("name")
|
||||
|
||||
if not all([issue_id, identifier, title, state]):
|
||||
return None
|
||||
|
||||
# Normalize labels to lowercase
|
||||
labels = [
|
||||
lbl["name"].lower()
|
||||
for lbl in (node.get("labels", {}).get("nodes", []))
|
||||
]
|
||||
|
||||
# Normalize blockers from inverse relations
|
||||
blocked_by: list[BlockerRef] = []
|
||||
for relation in node.get("relations", {}).get("nodes", []):
|
||||
if relation.get("type") == "blocks":
|
||||
related = relation.get("relatedIssue", {})
|
||||
blocked_by.append(
|
||||
BlockerRef(
|
||||
id=related.get("id"),
|
||||
identifier=related.get("identifier"),
|
||||
state=related.get("state", {}).get("name"),
|
||||
)
|
||||
)
|
||||
|
||||
return Issue(
|
||||
id=issue_id,
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
description=node.get("description"),
|
||||
priority=_parse_priority(node.get("priority")),
|
||||
state=state,
|
||||
branch_name=node.get("branchName"),
|
||||
url=node.get("url"),
|
||||
labels=labels,
|
||||
blocked_by=blocked_by,
|
||||
created_at=_parse_timestamp(node.get("createdAt")),
|
||||
updated_at=_parse_timestamp(node.get("updatedAt")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_priority(value: Any) -> int | None:
|
||||
"""Parse priority to int or None."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_timestamp(value: Any) -> datetime | None:
|
||||
"""Parse ISO-8601 timestamp string."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,211 @@
|
||||
"""OrgMyLife tracker adapter — polls your personal task board instead of Linear."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Issue
|
||||
from ai_orchestrator.tracker import TrackerError, TrackerAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrgMyLifeClient:
|
||||
"""OrgMyLife issue tracker client.
|
||||
|
||||
Connects to the OrgMyLife FastAPI backend to fetch agent-ready tasks
|
||||
and update their state when work completes.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ServiceConfig) -> None:
|
||||
self._config = config
|
||||
# OrgMyLife endpoint is stored in tracker.endpoint
|
||||
self._base_url = config.tracker.endpoint.rstrip("/")
|
||||
self._api_key = config.tracker.api_key # Optional auth token
|
||||
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
async def fetch_candidate_issues(self) -> list[Issue]:
|
||||
"""Fetch tasks marked as agent_ready in active states.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.get("/api/orchestrator/tasks")
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
issues: list[Issue] = []
|
||||
for task in tasks:
|
||||
issue = self._normalize_task(task)
|
||||
if issue:
|
||||
issues.append(issue)
|
||||
|
||||
return issues
|
||||
|
||||
async def fetch_issues_by_states(self, states: list[str]) -> list[Issue]:
|
||||
"""Fetch tasks in specified states (used for terminal cleanup).
|
||||
|
||||
Args:
|
||||
states: List of state names to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects.
|
||||
"""
|
||||
if not states:
|
||||
return []
|
||||
|
||||
states_param = ",".join(states)
|
||||
try:
|
||||
response = await self._client.get(
|
||||
"/api/orchestrator/tasks/by-states",
|
||||
params={"states": states_param},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
return [
|
||||
Issue(
|
||||
id=task["id"],
|
||||
identifier=task["identifier"],
|
||||
title=task.get("title", ""),
|
||||
state=task.get("state", ""),
|
||||
)
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
async def fetch_issue_states_by_ids(self, issue_ids: list[str]) -> list[Issue]:
|
||||
"""Fetch current states for specific task IDs (reconciliation).
|
||||
|
||||
Since OrgMyLife doesn't have a batch-by-ID endpoint, we fetch all
|
||||
orchestrator tasks and filter locally.
|
||||
|
||||
Args:
|
||||
issue_ids: List of task IDs to query.
|
||||
|
||||
Returns:
|
||||
List of normalized Issue objects with current state.
|
||||
"""
|
||||
if not issue_ids:
|
||||
return []
|
||||
|
||||
# Fetch all active + completed tasks and filter
|
||||
try:
|
||||
response = await self._client.get(
|
||||
"/api/orchestrator/tasks/by-states",
|
||||
params={"states": "open,in_progress,completed"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise TrackerAPIError(f"OrgMyLife API request failed: {e}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TrackerAPIError(
|
||||
f"OrgMyLife API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
tasks = data.get("tasks", [])
|
||||
|
||||
id_set = set(issue_ids)
|
||||
return [
|
||||
Issue(
|
||||
id=task["id"],
|
||||
identifier=task["identifier"],
|
||||
title=task.get("title", ""),
|
||||
state=task.get("state", ""),
|
||||
)
|
||||
for task in tasks
|
||||
if task["id"] in id_set
|
||||
]
|
||||
|
||||
async def update_task_state(self, task_id: str, new_state: str) -> bool:
|
||||
"""Push a state update back to OrgMyLife.
|
||||
|
||||
Args:
|
||||
task_id: The task ID (numeric string).
|
||||
new_state: New status (open, in_progress, completed).
|
||||
|
||||
Returns:
|
||||
True if update succeeded.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"/api/orchestrator/tasks/{task_id}/state",
|
||||
json={"status": new_state},
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"Failed to update task {task_id} state: {e}")
|
||||
return False
|
||||
|
||||
def _normalize_task(self, task: dict[str, Any]) -> Issue | None:
|
||||
"""Normalize an OrgMyLife task into an Issue model."""
|
||||
task_id = task.get("id")
|
||||
identifier = task.get("identifier")
|
||||
title = task.get("title")
|
||||
state = task.get("state")
|
||||
|
||||
if not all([task_id, identifier, title, state]):
|
||||
return None
|
||||
|
||||
# Map OrgMyLife states to Symphony-compatible states
|
||||
state_map = {
|
||||
"open": "Todo",
|
||||
"in_progress": "In Progress",
|
||||
"completed": "Done",
|
||||
}
|
||||
mapped_state = state_map.get(state, state)
|
||||
|
||||
created_at = None
|
||||
if task.get("created_at"):
|
||||
try:
|
||||
created_at = datetime.fromisoformat(
|
||||
task["created_at"].replace("Z", "+00:00")
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return Issue(
|
||||
id=str(task_id),
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
description=task.get("description"),
|
||||
priority=task.get("priority"),
|
||||
state=mapped_state,
|
||||
labels=task.get("labels", []),
|
||||
created_at=created_at,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Workflow file watcher — detects WORKFLOW.md changes for dynamic reload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from watchdog.events import FileModifiedEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowFileHandler(FileSystemEventHandler):
|
||||
"""Watches for changes to the WORKFLOW.md file."""
|
||||
|
||||
def __init__(self, workflow_path: Path, on_change: Callable[[], None]) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._on_change = on_change
|
||||
|
||||
def on_modified(self, event: FileModifiedEvent) -> None: # type: ignore[override]
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
modified_path = Path(str(event.src_path)).resolve()
|
||||
if modified_path == self._workflow_path:
|
||||
logger.info("WORKFLOW.md changed, triggering reload...")
|
||||
try:
|
||||
self._on_change()
|
||||
except Exception as e:
|
||||
logger.error(f"Workflow reload callback failed: {e}")
|
||||
|
||||
|
||||
class WorkflowWatcher:
|
||||
"""Manages filesystem watching for WORKFLOW.md changes."""
|
||||
|
||||
def __init__(self, workflow_path: Path, on_change: Callable[[], None]) -> None:
|
||||
self._workflow_path = workflow_path.resolve()
|
||||
self._on_change = on_change
|
||||
self._observer: Observer | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start watching for workflow file changes."""
|
||||
handler = WorkflowFileHandler(self._workflow_path, self._on_change)
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(
|
||||
handler,
|
||||
str(self._workflow_path.parent),
|
||||
recursive=False,
|
||||
)
|
||||
self._observer.daemon = True
|
||||
self._observer.start()
|
||||
logger.info(f"Watching {self._workflow_path} for changes")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the file watcher."""
|
||||
if self._observer:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=5)
|
||||
self._observer = None
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Workflow loader — reads and parses WORKFLOW.md files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ai_orchestrator.models import WorkflowDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowError(Exception):
|
||||
"""Base class for workflow-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MissingWorkflowFileError(WorkflowError):
|
||||
"""Raised when WORKFLOW.md cannot be found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowParseError(WorkflowError):
|
||||
"""Raised when WORKFLOW.md cannot be parsed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowFrontMatterNotAMapError(WorkflowError):
|
||||
"""Raised when front matter is not a YAML map."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def load_workflow(path: Path) -> WorkflowDefinition:
|
||||
"""Load and parse a WORKFLOW.md file.
|
||||
|
||||
Args:
|
||||
path: Path to the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
WorkflowDefinition with config and prompt_template.
|
||||
|
||||
Raises:
|
||||
MissingWorkflowFileError: If the file doesn't exist or can't be read.
|
||||
WorkflowParseError: If YAML parsing fails.
|
||||
WorkflowFrontMatterNotAMapError: If front matter is not a map/dict.
|
||||
"""
|
||||
if not path.exists():
|
||||
raise MissingWorkflowFileError(f"Workflow file not found: {path}")
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise MissingWorkflowFileError(f"Cannot read workflow file: {e}") from e
|
||||
|
||||
return parse_workflow(content)
|
||||
|
||||
|
||||
def parse_workflow(content: str) -> WorkflowDefinition:
|
||||
"""Parse workflow content into config and prompt template.
|
||||
|
||||
Args:
|
||||
content: Raw content of the WORKFLOW.md file.
|
||||
|
||||
Returns:
|
||||
WorkflowDefinition with config and prompt_template.
|
||||
"""
|
||||
config: dict = {}
|
||||
prompt_template: str = content
|
||||
|
||||
# Check for YAML front matter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
front_matter_raw = parts[1]
|
||||
prompt_template = parts[2]
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(front_matter_raw)
|
||||
except yaml.YAMLError as e:
|
||||
raise WorkflowParseError(f"Invalid YAML front matter: {e}") from e
|
||||
|
||||
if parsed is None:
|
||||
config = {}
|
||||
elif not isinstance(parsed, dict):
|
||||
raise WorkflowFrontMatterNotAMapError(
|
||||
f"Front matter must be a YAML map, got: {type(parsed).__name__}"
|
||||
)
|
||||
else:
|
||||
config = parsed
|
||||
|
||||
prompt_template = prompt_template.strip()
|
||||
|
||||
return WorkflowDefinition(config=config, prompt_template=prompt_template)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Workspace manager — per-issue workspace lifecycle and hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ai_orchestrator.config import ServiceConfig
|
||||
from ai_orchestrator.models import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only allow safe characters in workspace directory names
|
||||
SAFE_CHARS_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
"""Base class for workspace errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkspacePathError(WorkspaceError):
|
||||
"""Raised when workspace path is invalid or outside root."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceHookError(WorkspaceError):
|
||||
"""Raised when a workspace hook fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def sanitize_workspace_key(identifier: str) -> str:
|
||||
"""Sanitize an issue identifier for use as a workspace directory name.
|
||||
|
||||
Replaces any character not in [A-Za-z0-9._-] with underscore.
|
||||
|
||||
Args:
|
||||
identifier: The issue identifier (e.g., "ABC-123").
|
||||
|
||||
Returns:
|
||||
Sanitized workspace key.
|
||||
"""
|
||||
return SAFE_CHARS_RE.sub("_", identifier)
|
||||
|
||||
|
||||
def get_workspace_path(workspace_root: str, identifier: str) -> Path:
|
||||
"""Compute the workspace path for an issue.
|
||||
|
||||
Args:
|
||||
workspace_root: Absolute path to workspace root directory.
|
||||
identifier: Issue identifier.
|
||||
|
||||
Returns:
|
||||
Absolute path to the issue's workspace directory.
|
||||
|
||||
Raises:
|
||||
WorkspacePathError: If the computed path is outside workspace root.
|
||||
"""
|
||||
root = Path(workspace_root).resolve()
|
||||
key = sanitize_workspace_key(identifier)
|
||||
workspace_path = (root / key).resolve()
|
||||
|
||||
# Safety: ensure workspace stays inside root
|
||||
if not str(workspace_path).startswith(str(root)):
|
||||
raise WorkspacePathError(
|
||||
f"Workspace path {workspace_path} is outside root {root}"
|
||||
)
|
||||
|
||||
return workspace_path
|
||||
|
||||
|
||||
async def create_workspace(
|
||||
config: ServiceConfig, identifier: str
|
||||
) -> Workspace:
|
||||
"""Create or reuse a workspace for an issue.
|
||||
|
||||
Args:
|
||||
config: Service configuration.
|
||||
identifier: Issue identifier.
|
||||
|
||||
Returns:
|
||||
Workspace object with path and creation status.
|
||||
|
||||
Raises:
|
||||
WorkspaceError: On workspace creation failure.
|
||||
WorkspaceHookError: If after_create hook fails.
|
||||
"""
|
||||
workspace_path = get_workspace_path(config.workspace.root, identifier)
|
||||
key = sanitize_workspace_key(identifier)
|
||||
|
||||
created_now = False
|
||||
if not workspace_path.exists():
|
||||
try:
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
created_now = True
|
||||
except OSError as e:
|
||||
raise WorkspaceError(f"Failed to create workspace: {e}") from e
|
||||
elif not workspace_path.is_dir():
|
||||
# Handle case where path exists but is not a directory
|
||||
raise WorkspaceError(
|
||||
f"Workspace path exists but is not a directory: {workspace_path}"
|
||||
)
|
||||
|
||||
workspace = Workspace(
|
||||
path=str(workspace_path),
|
||||
workspace_key=key,
|
||||
created_now=created_now,
|
||||
)
|
||||
|
||||
# Run after_create hook only for newly created workspaces
|
||||
if created_now and config.hooks.after_create:
|
||||
try:
|
||||
await run_hook(
|
||||
config.hooks.after_create,
|
||||
workspace_path,
|
||||
config.hooks.timeout_ms,
|
||||
)
|
||||
except WorkspaceHookError:
|
||||
# after_create failure is fatal — remove the workspace
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(workspace_path, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
return workspace
|
||||
|
||||
|
||||
async def run_hook(
|
||||
script: str, cwd: Path, timeout_ms: int
|
||||
) -> None:
|
||||
"""Run a workspace hook script.
|
||||
|
||||
Args:
|
||||
script: Shell script to execute.
|
||||
cwd: Working directory for the script.
|
||||
timeout_ms: Timeout in milliseconds.
|
||||
|
||||
Raises:
|
||||
WorkspaceHookError: If the hook fails or times out.
|
||||
"""
|
||||
timeout_sec = timeout_ms / 1000.0
|
||||
|
||||
logger.info(f"Running hook in {cwd}")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bash", "-lc", script,
|
||||
cwd=str(cwd),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=timeout_sec
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
raise WorkspaceHookError(
|
||||
f"Hook timed out after {timeout_ms}ms"
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")[:500]
|
||||
raise WorkspaceHookError(
|
||||
f"Hook failed with exit code {process.returncode}: {stderr_text}"
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise WorkspaceHookError(f"Hook execution failed: {e}") from e
|
||||
|
||||
|
||||
async def run_hook_best_effort(
|
||||
script: str | None, cwd: Path, timeout_ms: int
|
||||
) -> None:
|
||||
"""Run a hook, logging failures without raising.
|
||||
|
||||
Args:
|
||||
script: Shell script to execute, or None to skip.
|
||||
cwd: Working directory.
|
||||
timeout_ms: Timeout in milliseconds.
|
||||
"""
|
||||
if not script:
|
||||
return
|
||||
|
||||
try:
|
||||
await run_hook(script, cwd, timeout_ms)
|
||||
except WorkspaceHookError as e:
|
||||
logger.warning(f"Hook failed (non-fatal): {e}")
|
||||
|
||||
|
||||
async def cleanup_workspace(
|
||||
config: ServiceConfig, identifier: str
|
||||
) -> None:
|
||||
"""Remove a workspace directory for a terminal issue.
|
||||
|
||||
Args:
|
||||
config: Service configuration.
|
||||
identifier: Issue identifier.
|
||||
"""
|
||||
workspace_path = get_workspace_path(config.workspace.root, identifier)
|
||||
|
||||
if not workspace_path.exists():
|
||||
return
|
||||
|
||||
# Run before_remove hook
|
||||
await run_hook_best_effort(
|
||||
config.hooks.before_remove, workspace_path, config.hooks.timeout_ms
|
||||
)
|
||||
|
||||
# Remove the workspace
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(workspace_path)
|
||||
logger.info(f"Cleaned workspace for {identifier}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove workspace for {identifier}: {e}")
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for AI-Orchestrator
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unit tests for the PAT Manager alert sender module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from scripts.pat_manager.alerter import AlertSender
|
||||
from scripts.pat_manager.errors import AlertError
|
||||
from scripts.pat_manager.models import (
|
||||
AlertChannel,
|
||||
AlertResult,
|
||||
CheckResult,
|
||||
PatEntry,
|
||||
PatStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expiring_result() -> CheckResult:
|
||||
"""A CheckResult for a Jira token expiring in 7 days."""
|
||||
return CheckResult(
|
||||
entry=PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-api-access",
|
||||
expiry_date="2025-08-01",
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_result() -> CheckResult:
|
||||
"""A CheckResult for an expired Confluence token."""
|
||||
return CheckResult(
|
||||
entry=PatEntry(
|
||||
service="Confluence",
|
||||
token_name="confluence-bot",
|
||||
expiry_date="2025-06-01",
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orgmylife_config() -> dict:
|
||||
"""Config for OrgMyLife alert channel."""
|
||||
return {
|
||||
"orgmylife_url": "https://orgmylife.app",
|
||||
"orgmylife_api_key": "test-api-key-123",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_config() -> dict:
|
||||
"""Config for email alert channel."""
|
||||
return {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": "587",
|
||||
"smtp_user": "user@example.com",
|
||||
"smtp_pass": "password123",
|
||||
"alert_email_to": "admin@example.com",
|
||||
}
|
||||
|
||||
|
||||
class TestAlertSenderInit:
|
||||
"""Tests for AlertSender initialization."""
|
||||
|
||||
def test_orgmylife_channel(self, orgmylife_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
assert sender.channel == AlertChannel.ORGMYLIFE
|
||||
assert sender.config == orgmylife_config
|
||||
|
||||
def test_email_channel(self, email_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.EMAIL, email_config)
|
||||
assert sender.channel == AlertChannel.EMAIL
|
||||
assert sender.config == email_config
|
||||
|
||||
|
||||
class TestBuildTaskTitle:
|
||||
"""Tests for task title formatting."""
|
||||
|
||||
def test_title_format(self, expiring_result: CheckResult, orgmylife_config: dict) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
title = sender._build_task_title(expiring_result)
|
||||
assert title == "[PAT Renewal] Jira - jira-api-access"
|
||||
|
||||
def test_title_contains_service_and_token(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
title = sender._build_task_title(expired_result)
|
||||
assert "Confluence" in title
|
||||
assert "confluence-bot" in title
|
||||
assert title == "[PAT Renewal] Confluence - confluence-bot"
|
||||
|
||||
|
||||
class TestBuildTaskDescription:
|
||||
"""Tests for task description formatting."""
|
||||
|
||||
def test_expiring_description(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expiring_result)
|
||||
assert "7 days" in desc
|
||||
assert "2025-08-01" in desc
|
||||
assert "Manual renewal required" in desc
|
||||
assert "Jira" in desc
|
||||
assert "jira-api-access" in desc
|
||||
|
||||
def test_expired_description(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expired_result)
|
||||
assert "0 days remaining" in desc
|
||||
assert "Token has expired" in desc
|
||||
assert "Manual renewal required" in desc
|
||||
|
||||
|
||||
class TestSendAlertOrgMyLife:
|
||||
"""Tests for OrgMyLife alert sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_successful_task_creation(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: successful task creation
|
||||
respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1, "title": "[PAT Renewal] Jira - jira-api-access"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
result = await sender.send_alert(expiring_result)
|
||||
|
||||
assert result.sent is True
|
||||
assert result.channel == AlertChannel.ORGMYLIFE
|
||||
assert result.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_skips_when_existing_task(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: existing task found
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}],
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
result = await sender.send_alert(expiring_result)
|
||||
|
||||
assert result.sent is False
|
||||
assert result.channel == AlertChannel.ORGMYLIFE
|
||||
assert result.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_raises_alert_error_on_failure(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: task creation fails
|
||||
respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(500, json={"error": "Internal Server Error"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
|
||||
assert "orgmylife" in exc_info.value.channel
|
||||
assert "500" in exc_info.value.reason
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_api_key_header_sent(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
# Mock: no existing tasks
|
||||
get_route = respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
# Mock: successful task creation
|
||||
post_route = respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
await sender.send_alert(expiring_result)
|
||||
|
||||
# Verify API key was sent
|
||||
assert get_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123"
|
||||
assert post_route.calls[0].request.headers["X-API-Key"] == "test-api-key-123"
|
||||
|
||||
|
||||
class TestCheckExistingTask:
|
||||
"""Tests for idempotent alerting via check_existing_task."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_true_when_task_exists(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}],
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_false_when_no_task(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(200, json=[])
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_returns_false_on_api_error(self, orgmylife_config: dict) -> None:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(500, json={"error": "Server Error"})
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_handles_wrapped_response(self, orgmylife_config: dict) -> None:
|
||||
"""Test handling of paginated/wrapped API response."""
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json={
|
||||
"tasks": [
|
||||
{"title": "[PAT Renewal] Jira - jira-api-access", "status": "open"}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
exists = await sender.check_existing_task("Jira", "jira-api-access", "2025-08-01")
|
||||
assert exists is True
|
||||
|
||||
|
||||
class TestSendAlertEmail:
|
||||
"""Tests for email alert sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_missing_smtp_config(
|
||||
self, expiring_result: CheckResult
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.EMAIL, {})
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
assert "email" in exc_info.value.channel
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_missing_email_to(
|
||||
self, expiring_result: CheckResult
|
||||
) -> None:
|
||||
config = {"smtp_host": "smtp.example.com"}
|
||||
sender = AlertSender(AlertChannel.EMAIL, config)
|
||||
with pytest.raises(AlertError) as exc_info:
|
||||
await sender.send_alert(expiring_result)
|
||||
assert "email" in exc_info.value.channel
|
||||
|
||||
|
||||
class TestDaysRemainingInAlert:
|
||||
"""Tests for days remaining in alert messages."""
|
||||
|
||||
def test_expired_shows_zero_days(
|
||||
self, expired_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expired_result)
|
||||
assert "0 days remaining" in desc
|
||||
|
||||
def test_expiring_shows_correct_days(
|
||||
self, expiring_result: CheckResult, orgmylife_config: dict
|
||||
) -> None:
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(expiring_result)
|
||||
assert "7 days" in desc
|
||||
|
||||
def test_none_days_remaining_treated_as_zero(self, orgmylife_config: dict) -> None:
|
||||
result = CheckResult(
|
||||
entry=PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="test-token",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=None,
|
||||
error_message=None,
|
||||
)
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, orgmylife_config)
|
||||
desc = sender._build_task_description(result)
|
||||
assert "0 days remaining" in desc
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Property-based tests for the PAT Manager alert sender module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.alerter import AlertSender
|
||||
from scripts.pat_manager.models import (
|
||||
AlertChannel,
|
||||
CheckResult,
|
||||
PatEntry,
|
||||
PatStatus,
|
||||
)
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
SERVICES = ["GitLab", "Jira", "Confluence", "OrgMyLife"]
|
||||
RENEWAL_METHODS = ["auto", "manual"]
|
||||
ALL_STATUSES = [PatStatus.HEALTHY, PatStatus.EXPIRING_SOON, PatStatus.EXPIRED, PatStatus.CHECK_FAILED]
|
||||
|
||||
# Services that require manual renewal (used for alert testing)
|
||||
manual_services = st.sampled_from(["Jira", "Confluence", "OrgMyLife"])
|
||||
|
||||
# Statuses that trigger alerts
|
||||
alert_statuses = st.sampled_from([PatStatus.EXPIRING_SOON, PatStatus.EXPIRED])
|
||||
|
||||
# Token names: non-empty strings up to 128 chars (printable, no control chars)
|
||||
token_names = st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "P", "S"), min_codepoint=32),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
# Days remaining: 0 for expired, 1-14 for expiring soon
|
||||
days_remaining_values = st.integers(min_value=0, max_value=14)
|
||||
|
||||
# Expiry dates in ISO 8601 format
|
||||
expiry_dates = st.dates(
|
||||
min_value=datetime.date(2020, 1, 1),
|
||||
max_value=datetime.date(2030, 12, 31),
|
||||
).map(lambda d: d.isoformat())
|
||||
|
||||
|
||||
def pat_entries() -> st.SearchStrategy[PatEntry]:
|
||||
"""Generate valid PatEntry instances with random services and token names."""
|
||||
return st.builds(
|
||||
PatEntry,
|
||||
service=st.sampled_from(SERVICES),
|
||||
token_name=st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "P")),
|
||||
min_size=1,
|
||||
max_size=64,
|
||||
),
|
||||
expiry_date=st.one_of(
|
||||
st.none(),
|
||||
st.dates().map(lambda d: d.isoformat()),
|
||||
),
|
||||
renewal_method=st.sampled_from(RENEWAL_METHODS),
|
||||
)
|
||||
|
||||
|
||||
# --- Additional imports for Property 9 ---
|
||||
from datetime import date
|
||||
|
||||
from scripts.pat_manager.checker import classify_by_date
|
||||
|
||||
|
||||
# --- Strategies for Property 9 ---
|
||||
|
||||
# Generate ISO 8601 date strings in range 2020-2099
|
||||
prop9_expiry_dates = st.dates(
|
||||
min_value=date(2020, 1, 1),
|
||||
max_value=date(2099, 12, 31),
|
||||
).map(lambda d: d.isoformat())
|
||||
|
||||
# Generate reference dates in range 2020-2099
|
||||
prop9_reference_dates = st.dates(
|
||||
min_value=date(2020, 1, 1),
|
||||
max_value=date(2099, 12, 31),
|
||||
)
|
||||
|
||||
|
||||
# --- Property 9 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 9: Days remaining calculation
|
||||
class TestDaysRemainingCalculation:
|
||||
"""
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
Property 9: For any expiry date and reference date, the computed
|
||||
days_remaining SHALL equal max(0, (expiry_date - reference_date).days)
|
||||
— yielding 0 for already-expired tokens and the correct positive integer
|
||||
for future dates.
|
||||
"""
|
||||
|
||||
@given(expiry_date=prop9_expiry_dates, reference_date=prop9_reference_dates)
|
||||
@settings(max_examples=100)
|
||||
def test_days_remaining_equals_max_zero_delta(
|
||||
self, expiry_date: str, reference_date: date
|
||||
) -> None:
|
||||
"""Days remaining is always max(0, (expiry - ref).days)."""
|
||||
_status, days_remaining = classify_by_date(
|
||||
expiry_date, reference_date, window=14
|
||||
)
|
||||
|
||||
parsed_expiry = date.fromisoformat(expiry_date)
|
||||
expected = max(0, (parsed_expiry - reference_date).days)
|
||||
|
||||
assert days_remaining == expected
|
||||
|
||||
|
||||
def healthy_check_results() -> st.SearchStrategy[CheckResult]:
|
||||
"""Generate CheckResult instances that are all HEALTHY."""
|
||||
return st.builds(
|
||||
CheckResult,
|
||||
entry=pat_entries(),
|
||||
status=st.just(PatStatus.HEALTHY),
|
||||
days_remaining=st.integers(min_value=15, max_value=365),
|
||||
error_message=st.none(),
|
||||
stale_warning=st.just(False),
|
||||
)
|
||||
|
||||
|
||||
def check_results_any_status() -> st.SearchStrategy[CheckResult]:
|
||||
"""Generate CheckResult instances with any valid status."""
|
||||
return st.builds(
|
||||
CheckResult,
|
||||
entry=pat_entries(),
|
||||
status=st.sampled_from(ALL_STATUSES),
|
||||
days_remaining=st.one_of(
|
||||
st.none(),
|
||||
st.integers(min_value=0, max_value=365),
|
||||
),
|
||||
error_message=st.one_of(st.none(), st.text(min_size=1, max_size=50)),
|
||||
stale_warning=st.booleans(),
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def check_results_requiring_alert(draw: st.DrawFn) -> CheckResult:
|
||||
"""Generate CheckResult objects that require alerts (manual renewal, expiring/expired)."""
|
||||
service = draw(manual_services)
|
||||
token_name = draw(token_names)
|
||||
status = draw(alert_statuses)
|
||||
days = draw(days_remaining_values)
|
||||
expiry_date = draw(expiry_dates)
|
||||
|
||||
# Align days_remaining with status
|
||||
if status == PatStatus.EXPIRED:
|
||||
days = 0
|
||||
else:
|
||||
days = max(1, days) # expiring_soon should have at least 1 day
|
||||
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="manual",
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
# --- Helper: Alert decision logic ---
|
||||
|
||||
|
||||
def needs_alert(result: CheckResult) -> bool:
|
||||
"""Determine if a CheckResult should trigger an alert.
|
||||
|
||||
A PAT needs an alert if:
|
||||
- Its status is EXPIRING_SOON or EXPIRED
|
||||
- AND its renewal_method is "manual"
|
||||
|
||||
GitLab tokens with auto renewal that failed rotation would also
|
||||
need alerts, but that is handled by the orchestrator flow (rotation
|
||||
failure falls back to alert). For this property test, we focus on
|
||||
the direct filtering logic based on status and renewal_method.
|
||||
"""
|
||||
if result.status not in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED):
|
||||
return False
|
||||
if result.entry.renewal_method == "manual":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Property 7 Tests ---
|
||||
|
||||
|
||||
# Feature: pat-renewal, Property 7: Alert triggering correctness
|
||||
class TestAlertTriggeringCorrectness:
|
||||
"""
|
||||
**Validates: Requirements 4.1, 5.4**
|
||||
|
||||
Property 7: Alert triggering correctness
|
||||
- If ALL PATs have status "healthy", THEN zero alerts SHALL be sent
|
||||
- For each manual-renewal PAT with status "expiring soon" or "expired",
|
||||
exactly one alert SHALL be triggered
|
||||
"""
|
||||
|
||||
@given(results=st.lists(healthy_check_results(), min_size=0, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_all_healthy_means_zero_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""If all PATs are healthy, zero alerts should be triggered.
|
||||
|
||||
This verifies Requirement 5.4: IF all PATs are classified as "healthy",
|
||||
THEN the PAT_Manager SHALL complete without creating OrgMyLife tasks or
|
||||
sending email alerts.
|
||||
"""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
assert len(alerts_needed) == 0, (
|
||||
f"Expected zero alerts for all-healthy set, got {len(alerts_needed)}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_one_alert_per_expiring_expired_manual_token(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Each manual-renewal PAT with status expiring/expired triggers exactly one alert.
|
||||
|
||||
This verifies Requirement 4.1: WHEN a Manual_Renewal_Token has 14 or fewer
|
||||
days until expiration or is already expired, THE PAT_Manager SHALL send an
|
||||
alert via the configured Alert_Channel.
|
||||
|
||||
The count of alerts needed must equal the count of results that are
|
||||
manual-renewal AND have status EXPIRING_SOON or EXPIRED.
|
||||
"""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
# Count expected: manual tokens with expiring_soon or expired status
|
||||
expected_count = sum(
|
||||
1
|
||||
for r in results
|
||||
if r.entry.renewal_method == "manual"
|
||||
and r.status in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED)
|
||||
)
|
||||
|
||||
assert len(alerts_needed) == expected_count, (
|
||||
f"Expected {expected_count} alerts, got {len(alerts_needed)}. "
|
||||
f"Results: {[(r.entry.renewal_method, r.status.value) for r in results]}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_healthy_tokens_never_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""No token with HEALTHY status should ever trigger an alert,
|
||||
regardless of its renewal_method."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.status != PatStatus.HEALTHY, (
|
||||
f"Healthy token should not trigger alert: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_auto_renewal_tokens_do_not_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Tokens with renewal_method "auto" should not trigger alerts
|
||||
through the standard filtering logic (they go through rotation first)."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.entry.renewal_method != "auto", (
|
||||
f"Auto-renewal token should not trigger alert via filtering: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
@given(results=st.lists(check_results_any_status(), min_size=1, max_size=20))
|
||||
@settings(max_examples=100)
|
||||
def test_check_failed_tokens_do_not_trigger_alerts(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""Tokens with CHECK_FAILED status should not trigger alerts
|
||||
(they are reported differently in the summary)."""
|
||||
alerts_needed = [r for r in results if needs_alert(r)]
|
||||
|
||||
for alert in alerts_needed:
|
||||
assert alert.status != PatStatus.CHECK_FAILED, (
|
||||
f"Check-failed token should not trigger alert: "
|
||||
f"{alert.entry.service}/{alert.entry.token_name}"
|
||||
)
|
||||
|
||||
|
||||
# --- Property 8 Tests ---
|
||||
|
||||
|
||||
# Feature: pat-renewal, Property 8: Alert message completeness
|
||||
class TestAlertMessageCompleteness:
|
||||
"""
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
Property 8: Alert message completeness
|
||||
- If the alert channel is OrgMyLife, the task title SHALL contain both the
|
||||
service name and token identifier.
|
||||
- The description SHALL contain service name, token identifier, and expiry
|
||||
information.
|
||||
"""
|
||||
|
||||
@given(result=check_results_requiring_alert())
|
||||
@settings(max_examples=100)
|
||||
def test_orgmylife_task_title_contains_service_and_token(
|
||||
self, result: CheckResult
|
||||
) -> None:
|
||||
"""OrgMyLife task title contains both service name and token identifier."""
|
||||
sender = AlertSender(
|
||||
AlertChannel.ORGMYLIFE,
|
||||
{"orgmylife_url": "https://orgmylife.app", "orgmylife_api_key": "test-key"},
|
||||
)
|
||||
|
||||
title = sender._build_task_title(result)
|
||||
|
||||
# Title SHALL contain both the service name and token identifier
|
||||
assert result.entry.service in title, (
|
||||
f"Title '{title}' does not contain service name '{result.entry.service}'"
|
||||
)
|
||||
assert result.entry.token_name in title, (
|
||||
f"Title '{title}' does not contain token name '{result.entry.token_name}'"
|
||||
)
|
||||
|
||||
@given(result=check_results_requiring_alert())
|
||||
@settings(max_examples=100)
|
||||
def test_orgmylife_task_description_contains_required_fields(
|
||||
self, result: CheckResult
|
||||
) -> None:
|
||||
"""OrgMyLife task description contains service name, token name, and days remaining info."""
|
||||
sender = AlertSender(
|
||||
AlertChannel.ORGMYLIFE,
|
||||
{"orgmylife_url": "https://orgmylife.app", "orgmylife_api_key": "test-key"},
|
||||
)
|
||||
|
||||
description = sender._build_task_description(result)
|
||||
|
||||
# Description SHALL contain service name
|
||||
assert result.entry.service in description, (
|
||||
f"Description does not contain service name '{result.entry.service}'"
|
||||
)
|
||||
# Description SHALL contain token identifier
|
||||
assert result.entry.token_name in description, (
|
||||
f"Description does not contain token name '{result.entry.token_name}'"
|
||||
)
|
||||
# Description SHALL contain days remaining information
|
||||
days = result.days_remaining if result.days_remaining is not None else 0
|
||||
if days == 0:
|
||||
assert "0 days remaining" in description, (
|
||||
f"Description does not contain '0 days remaining' for expired token"
|
||||
)
|
||||
else:
|
||||
assert str(days) in description, (
|
||||
f"Description does not contain days remaining '{days}'"
|
||||
)
|
||||
|
||||
|
||||
# --- Additional imports for Property 10 ---
|
||||
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
|
||||
# --- Property 10 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 10: Alert idempotence
|
||||
class TestAlertIdempotence:
|
||||
"""
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
Property 10: For any PAT that requires an alert, if an open OrgMyLife task
|
||||
already exists for the same service and token_name, THEN no new task SHALL
|
||||
be created. Running the alert flow twice for the same PAT state SHALL produce
|
||||
at most one task.
|
||||
"""
|
||||
|
||||
@given(
|
||||
service=manual_services,
|
||||
token_name=token_names,
|
||||
expiry_date=expiry_dates,
|
||||
status=alert_statuses,
|
||||
days_remaining=days_remaining_values,
|
||||
)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_no_duplicate_task_when_open_task_exists(
|
||||
self,
|
||||
service: str,
|
||||
token_name: str,
|
||||
expiry_date: str,
|
||||
status: PatStatus,
|
||||
days_remaining: int,
|
||||
) -> None:
|
||||
"""When an open task already exists for a PAT, no new task is created."""
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="manual",
|
||||
)
|
||||
check_result = CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
config = {
|
||||
"orgmylife_url": "https://orgmylife.app",
|
||||
"orgmylife_api_key": "test-api-key",
|
||||
}
|
||||
sender = AlertSender(AlertChannel.ORGMYLIFE, config)
|
||||
|
||||
expected_title = f"[PAT Renewal] {service} - {token_name}"
|
||||
|
||||
with respx.mock:
|
||||
# Mock GET: return a list containing a task with matching title
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json=[{"title": expected_title, "status": "open"}],
|
||||
)
|
||||
)
|
||||
# Mock POST: should NOT be called
|
||||
post_route = respx.post("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
result = await sender.send_alert(check_result)
|
||||
|
||||
# Alert was not sent (no new task created)
|
||||
assert result.sent is False
|
||||
# POST endpoint was never called (no duplicate task creation)
|
||||
assert not post_route.called
|
||||
@@ -0,0 +1,734 @@
|
||||
"""Unit tests for the PAT Manager expiry checker module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
import httpx
|
||||
|
||||
from scripts.pat_manager.checker import ExpiryChecker, classify_by_date
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus
|
||||
|
||||
|
||||
class TestClassifyByDate:
|
||||
"""Tests for the classify_by_date pure function."""
|
||||
|
||||
def test_expired_token(self) -> None:
|
||||
"""A token with expiry_date before reference_date is expired."""
|
||||
status, days = classify_by_date("2025-01-01", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.EXPIRED
|
||||
assert days == 0
|
||||
|
||||
def test_expiring_soon_on_boundary(self) -> None:
|
||||
"""A token expiring exactly on reference_date is expiring soon."""
|
||||
status, days = classify_by_date("2025-01-15", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.EXPIRING_SOON
|
||||
assert days == 0
|
||||
|
||||
def test_expiring_soon_within_window(self) -> None:
|
||||
"""A token expiring within the window is expiring soon."""
|
||||
status, days = classify_by_date("2025-01-20", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.EXPIRING_SOON
|
||||
assert days == 5
|
||||
|
||||
def test_expiring_soon_at_window_end(self) -> None:
|
||||
"""A token expiring exactly at window end is expiring soon."""
|
||||
status, days = classify_by_date("2025-01-29", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.EXPIRING_SOON
|
||||
assert days == 14
|
||||
|
||||
def test_healthy_beyond_window(self) -> None:
|
||||
"""A token expiring beyond the window is healthy."""
|
||||
status, days = classify_by_date("2025-01-30", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.HEALTHY
|
||||
assert days == 15
|
||||
|
||||
def test_healthy_far_future(self) -> None:
|
||||
"""A token expiring far in the future is healthy."""
|
||||
status, days = classify_by_date("2026-06-01", date(2025, 1, 15), window=14)
|
||||
assert status == PatStatus.HEALTHY
|
||||
assert days == 502
|
||||
|
||||
def test_custom_window(self) -> None:
|
||||
"""Classification respects custom window size."""
|
||||
# With window=7, 10 days out is healthy
|
||||
status, days = classify_by_date("2025-01-25", date(2025, 1, 15), window=7)
|
||||
assert status == PatStatus.HEALTHY
|
||||
assert days == 10
|
||||
|
||||
# With window=30, 10 days out is expiring soon
|
||||
status, days = classify_by_date("2025-01-25", date(2025, 1, 15), window=30)
|
||||
assert status == PatStatus.EXPIRING_SOON
|
||||
assert days == 10
|
||||
|
||||
|
||||
class TestExpiryCheckerInit:
|
||||
"""Tests for ExpiryChecker initialization."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""ExpiryChecker uses correct defaults."""
|
||||
checker = ExpiryChecker()
|
||||
assert checker.expiry_window_days == 14
|
||||
assert checker.timeout == 30
|
||||
assert checker.max_retries == 2
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""ExpiryChecker accepts custom configuration."""
|
||||
checker = ExpiryChecker(expiry_window_days=7, timeout=60, max_retries=3)
|
||||
assert checker.expiry_window_days == 7
|
||||
assert checker.timeout == 60
|
||||
assert checker.max_retries == 3
|
||||
|
||||
|
||||
class TestCheckAll:
|
||||
"""Tests for ExpiryChecker.check_all method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_secret_returns_check_failed(self) -> None:
|
||||
"""Entries with no matching secret get check_failed status."""
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry(
|
||||
service="GitLab",
|
||||
token_name="test-token",
|
||||
expiry_date="2025-06-01",
|
||||
renewal_method="auto",
|
||||
)
|
||||
results = await checker.check_all([entry], secrets={})
|
||||
assert len(results) == 1
|
||||
assert results[0].status == PatStatus.CHECK_FAILED
|
||||
assert results[0].days_remaining is None
|
||||
assert "No secret found" in results[0].error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_service_returns_check_failed(self) -> None:
|
||||
"""Entries with unsupported service get check_failed status."""
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry(
|
||||
service="UnknownService",
|
||||
token_name="test-token",
|
||||
expiry_date="2025-06-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
# Provide a secret that would match via the fallback empty key
|
||||
results = await checker.check_all([entry], secrets={"": "some-token"})
|
||||
assert len(results) == 1
|
||||
assert results[0].status == PatStatus.CHECK_FAILED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_check_with_mock_api(self) -> None:
|
||||
"""GitLab check with a mocked API response classifies correctly."""
|
||||
import respx
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry(
|
||||
service="GitLab",
|
||||
token_name="test-token",
|
||||
expiry_date="2025-06-01",
|
||||
renewal_method="auto",
|
||||
)
|
||||
# Mock the GitLab API to return a 401 (expired token)
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
results = await checker.check_all(
|
||||
[entry], secrets={"GITLAB_PAT": "glpat-test123"}
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].status == PatStatus.EXPIRED
|
||||
assert results[0].days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_entries_returns_empty_list(self) -> None:
|
||||
"""No entries produces no results."""
|
||||
checker = ExpiryChecker()
|
||||
results = await checker.check_all([], secrets={"GITLAB_PAT": "token"})
|
||||
assert results == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_entries_returns_one_result_per_entry(self) -> None:
|
||||
"""Each entry produces exactly one result."""
|
||||
checker = ExpiryChecker()
|
||||
entries = [
|
||||
PatEntry("GitLab", "token-1", "2025-06-01", "auto"),
|
||||
PatEntry("Jira", "token-2", "2025-07-01", "manual"),
|
||||
PatEntry("Confluence", "token-3", "2025-08-01", "manual"),
|
||||
]
|
||||
results = await checker.check_all(entries, secrets={})
|
||||
assert len(results) == 3
|
||||
# All should be check_failed since no secrets provided
|
||||
for result in results:
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
|
||||
|
||||
class TestCheckGitLab:
|
||||
"""Tests for ExpiryChecker.check_gitlab method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab token with future expiry is classified as healthy."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", future_date, "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": future_date})
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_expiring_soon_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab token expiring within window is classified as expiring soon."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
soon_date = (date.today() + timedelta(days=7)).isoformat()
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", soon_date, "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": soon_date})
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.EXPIRING_SOON
|
||||
assert result.days_remaining == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_expired_token_via_api(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab token with past expiry from API is classified as expired."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
past_date = (date.today() - timedelta(days=5)).isoformat()
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", past_date, "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": past_date})
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab API returning 401 means token is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab API returning 500 means check failed."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(500)
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert "HTTP 500" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_null_expires_at_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab token with null expires_at and no stored expiry is healthy."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", None, "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": None})
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_stale_expiry_warning(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab API succeeds but stored expiry_date has passed → expired with stale warning."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_api_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
past_stored_date = (date.today() - timedelta(days=5)).isoformat()
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("GitLab", "ci-token", past_stored_date, "auto")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": future_api_date})
|
||||
)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.stale_warning is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_timeout_retries_then_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GitLab API timing out after retries results in check_failed."""
|
||||
import asyncio
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
|
||||
checker = ExpiryChecker(max_retries=1, timeout=1)
|
||||
entry = PatEntry("GitLab", "ci-token", "2025-12-01", "auto")
|
||||
|
||||
async def noop_sleep(_: float) -> None:
|
||||
pass
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
monkeypatch.setattr("asyncio.sleep", noop_sleep)
|
||||
result = await checker.check_gitlab(entry, "glpat-test")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
|
||||
|
||||
class TestCheckJira:
|
||||
"""Tests for ExpiryChecker.check_jira method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Jira token with successful API call and future expiry is healthy."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", future_date, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(200, json={"displayName": "Test User"})
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Jira API returning 401 means token is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_no_url_env_var(self) -> None:
|
||||
"""Jira check fails when JIRA_URL is not set."""
|
||||
import os
|
||||
|
||||
# Ensure JIRA_URL is not set
|
||||
os.environ.pop("JIRA_URL", None)
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual")
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert "JIRA_URL" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Jira token with no expiry_date and successful API call is healthy."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", None, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(200, json={"displayName": "Test User"})
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_stale_expiry_warning(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Jira API succeeds but stored expiry_date has passed → expired with stale warning."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
past_date = (date.today() - timedelta(days=5)).isoformat()
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", past_date, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(200, json={"displayName": "Test User"})
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.stale_warning is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jira_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Jira API returning 500 means check failed."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(500)
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert "HTTP 500" in result.error_message
|
||||
|
||||
|
||||
class TestCheckConfluence:
|
||||
"""Tests for ExpiryChecker.check_confluence method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confluence_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Confluence token with successful API call and future expiry is healthy."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Confluence", "conf-token", future_date, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://confluence.example.com/rest/api/user/current").mock(
|
||||
return_value=httpx.Response(200, json={"displayName": "Test User"})
|
||||
)
|
||||
result = await checker.check_confluence(entry, "conf-pat-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confluence_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Confluence API returning 401 means token is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Confluence", "conf-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://confluence.example.com/rest/api/user/current").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
result = await checker.check_confluence(entry, "conf-pat-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confluence_no_url_env_var(self) -> None:
|
||||
"""Confluence check fails when CONFLUENCE_URL is not set."""
|
||||
import os
|
||||
|
||||
os.environ.pop("CONFLUENCE_URL", None)
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Confluence", "conf-token", "2025-12-01", "manual")
|
||||
result = await checker.check_confluence(entry, "conf-pat-123")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert "CONFLUENCE_URL" in result.error_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confluence_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Confluence token with no expiry_date and successful API call is healthy."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("Confluence", "conf-token", None, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://confluence.example.com/rest/api/user/current").mock(
|
||||
return_value=httpx.Response(200, json={"displayName": "Test User"})
|
||||
)
|
||||
result = await checker.check_confluence(entry, "conf-pat-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining is None
|
||||
|
||||
|
||||
class TestCheckOrgMyLife:
|
||||
"""Tests for ExpiryChecker.check_orgmylife method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_healthy_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife token with successful API call and future expiry is healthy."""
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", future_date, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_401_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife API returning 401 means token is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_403_means_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife API returning 403 means token is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(403)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_default_url(self) -> None:
|
||||
"""OrgMyLife uses default URL when env var not set."""
|
||||
import respx
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
|
||||
os.environ.pop("ORGMYLIFE_URL", None)
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", future_date, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.app/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_null_expiry_healthy(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife token with no expiry_date and successful API call is healthy."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", None, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert result.days_remaining is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_null_expiry_401_expired(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife token with no expiry_date and 401 response is expired."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", None, "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.EXPIRED
|
||||
assert result.days_remaining == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orgmylife_500_means_check_failed(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""OrgMyLife API returning 500 means check failed."""
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
|
||||
checker = ExpiryChecker()
|
||||
entry = PatEntry("OrgMyLife", "oml-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(500)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "oml-key-123")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert "HTTP 500" in result.error_message
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
"""Tests for the retry mechanism in service checks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_succeeds_on_second_attempt(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Request succeeds after first timeout, second attempt works."""
|
||||
import asyncio
|
||||
import respx
|
||||
from datetime import date, timedelta
|
||||
|
||||
future_date = (date.today() + timedelta(days=30)).isoformat()
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
async def noop_sleep(_: float) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", noop_sleep)
|
||||
|
||||
checker = ExpiryChecker(max_retries=2)
|
||||
entry = PatEntry("Jira", "jira-token", future_date, "manual")
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(request):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
return httpx.Response(200, json={"displayName": "Test User"})
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
side_effect=side_effect
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.HEALTHY
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_retries_exhausted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""All retries exhausted results in check_failed."""
|
||||
import asyncio
|
||||
import respx
|
||||
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
|
||||
async def noop_sleep(_: float) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", noop_sleep)
|
||||
|
||||
checker = ExpiryChecker(max_retries=2)
|
||||
entry = PatEntry("Jira", "jira-token", "2025-12-01", "manual")
|
||||
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
side_effect=httpx.TimeoutException("Request timed out")
|
||||
)
|
||||
result = await checker.check_jira(entry, "jira-pat-123")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Property-based tests for the PAT Manager expiry checker module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.checker import ExpiryChecker
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
# HTTP status codes that are NOT in {200-299, 401, 403}
|
||||
# These should all yield "check failed" classification.
|
||||
non_auth_error_codes = st.one_of(
|
||||
st.just(400),
|
||||
st.just(402),
|
||||
st.integers(min_value=404, max_value=499),
|
||||
st.integers(min_value=500, max_value=599),
|
||||
)
|
||||
|
||||
|
||||
# --- Property 12 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 12: Non-auth HTTP errors yield "check failed"
|
||||
class TestNonAuthHttpErrorsYieldCheckFailed:
|
||||
"""
|
||||
**Validates: Requirements 6.6**
|
||||
|
||||
Property 12: For any HTTP response status code that is not 200-299, 401,
|
||||
or 403, the classification SHALL be "check failed". This includes 4xx errors
|
||||
(other than 401/403), 5xx errors, and timeout conditions.
|
||||
"""
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_jira_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""Jira check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="Jira",
|
||||
token_name="test-jira-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"JIRA_URL": "https://jira.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=Response(status_code)
|
||||
)
|
||||
result = await checker.check_jira(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_confluence_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""Confluence check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="Confluence",
|
||||
token_name="test-confluence-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"CONFLUENCE_URL": "https://confluence.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get(
|
||||
"https://confluence.example.com/rest/api/user/current"
|
||||
).mock(return_value=Response(status_code))
|
||||
result = await checker.check_confluence(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
|
||||
@given(status_code=non_auth_error_codes)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_orgmylife_non_auth_errors_yield_check_failed(
|
||||
self, status_code: int
|
||||
) -> None:
|
||||
"""OrgMyLife check with non-auth HTTP errors produces check_failed status."""
|
||||
entry = PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="test-orgmylife-token",
|
||||
expiry_date="2025-12-01",
|
||||
renewal_method="manual",
|
||||
)
|
||||
checker = ExpiryChecker(max_retries=0)
|
||||
|
||||
with patch.dict(os.environ, {"ORGMYLIFE_URL": "https://orgmylife.example.com"}):
|
||||
with respx.mock:
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=Response(status_code)
|
||||
)
|
||||
result = await checker.check_orgmylife(entry, "fake-token")
|
||||
|
||||
assert result.status == PatStatus.CHECK_FAILED
|
||||
assert result.error_message is not None
|
||||
assert len(result.error_message) > 0
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for configuration layer."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.config import (
|
||||
ServiceConfig,
|
||||
build_config,
|
||||
expand_path,
|
||||
resolve_env_var,
|
||||
validate_dispatch_config,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveEnvVar:
|
||||
"""Tests for environment variable resolution."""
|
||||
|
||||
def test_resolves_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("MY_TOKEN", "secret123")
|
||||
assert resolve_env_var("$MY_TOKEN") == "secret123"
|
||||
|
||||
def test_missing_env_var_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("NONEXISTENT_VAR", raising=False)
|
||||
assert resolve_env_var("$NONEXISTENT_VAR") == ""
|
||||
|
||||
def test_literal_value_unchanged(self):
|
||||
assert resolve_env_var("literal_value") == "literal_value"
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert resolve_env_var(123) == 123 # type: ignore
|
||||
|
||||
|
||||
class TestExpandPath:
|
||||
"""Tests for path expansion."""
|
||||
|
||||
def test_tilde_expansion(self, tmp_path):
|
||||
result = expand_path("~/workspaces", tmp_path)
|
||||
assert str(Path.home()) in result
|
||||
|
||||
def test_relative_path_resolved(self, tmp_path):
|
||||
result = expand_path("./workspaces", tmp_path)
|
||||
assert str(tmp_path) in result
|
||||
|
||||
def test_absolute_path_unchanged(self, tmp_path):
|
||||
if os.name == "nt":
|
||||
result = expand_path("C:\\workspaces", tmp_path)
|
||||
assert "C:\\" in result
|
||||
else:
|
||||
result = expand_path("/tmp/workspaces", tmp_path)
|
||||
assert result.startswith("/tmp")
|
||||
|
||||
def test_empty_uses_default(self, tmp_path):
|
||||
result = expand_path("", tmp_path)
|
||||
assert "symphony_workspaces" in result
|
||||
|
||||
|
||||
class TestBuildConfig:
|
||||
"""Tests for building typed config from raw dict."""
|
||||
|
||||
def test_defaults_applied(self, tmp_path):
|
||||
config = build_config({}, tmp_path)
|
||||
assert config.polling.interval_ms == 30000
|
||||
assert config.agent.max_concurrent_agents == 10
|
||||
assert config.agent.max_turns == 20
|
||||
assert config.codex.command == "codex app-server"
|
||||
assert config.codex.turn_timeout_ms == 3600000
|
||||
assert config.hooks.timeout_ms == 60000
|
||||
|
||||
def test_tracker_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "test-key")
|
||||
raw = {
|
||||
"tracker": {
|
||||
"kind": "linear",
|
||||
"project_slug": "my-proj",
|
||||
"active_states": ["Ready", "Working"],
|
||||
}
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
assert config.tracker.kind == "linear"
|
||||
assert config.tracker.project_slug == "my-proj"
|
||||
assert config.tracker.api_key == "test-key"
|
||||
assert config.tracker.active_states == ["Ready", "Working"]
|
||||
assert config.tracker.endpoint == "https://api.linear.app/graphql"
|
||||
|
||||
def test_per_state_concurrency_normalization(self, tmp_path):
|
||||
raw = {
|
||||
"agent": {
|
||||
"max_concurrent_agents_by_state": {
|
||||
"In Progress": 3,
|
||||
"Todo": "invalid",
|
||||
"Review": -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
# Only valid positive entries kept, normalized to lowercase
|
||||
assert config.agent.max_concurrent_agents_by_state == {"in progress": 3}
|
||||
|
||||
|
||||
class TestValidateDispatchConfig:
|
||||
"""Tests for dispatch config validation."""
|
||||
|
||||
def test_valid_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "test-key")
|
||||
raw = {
|
||||
"tracker": {"kind": "linear", "project_slug": "proj"},
|
||||
}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert errors == []
|
||||
|
||||
def test_missing_tracker_kind(self, tmp_path):
|
||||
config = build_config({}, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("tracker.kind" in e for e in errors)
|
||||
|
||||
def test_unsupported_tracker_kind(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "key")
|
||||
raw = {"tracker": {"kind": "jira", "project_slug": "x"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("Unsupported" in e for e in errors)
|
||||
|
||||
def test_missing_api_key(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("LINEAR_API_KEY", raising=False)
|
||||
raw = {"tracker": {"kind": "linear", "project_slug": "x"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("api_key" in e for e in errors)
|
||||
|
||||
def test_missing_project_slug(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LINEAR_API_KEY", "key")
|
||||
raw = {"tracker": {"kind": "linear"}}
|
||||
config = build_config(raw, tmp_path)
|
||||
errors = validate_dispatch_config(config)
|
||||
assert any("project_slug" in e for e in errors)
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Integration tests for the PAT Manager orchestrator flow.
|
||||
|
||||
Tests the full end-to-end orchestrator with mocked external APIs using respx.
|
||||
Validates Requirements: 2.5, 2.6, 3.2, 3.4, 3.5, 4.6, 5.4, 5.5, 6.8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from nacl.public import PrivateKey
|
||||
|
||||
from scripts.pat_manager.__main__ import main, REGISTRY_PATH
|
||||
|
||||
|
||||
def _future_date(days: int = 60) -> str:
|
||||
"""Return an ISO date string `days` in the future."""
|
||||
return (date.today() + timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _expiring_date(days: int = 7) -> str:
|
||||
"""Return an ISO date string `days` in the future (within 14-day window)."""
|
||||
return (date.today() + timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _write_registry(tmp_path: Path, tokens: list[dict]) -> Path:
|
||||
"""Write a registry JSON file to tmp_path and return its path."""
|
||||
registry_path = tmp_path / "pat-registry.json"
|
||||
data = {"version": "1.0", "tokens": tokens}
|
||||
registry_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
return registry_path
|
||||
|
||||
|
||||
def _generate_keypair():
|
||||
"""Generate a NaCl keypair for mocking GitHub secret encryption."""
|
||||
private_key = PrivateKey.generate()
|
||||
public_key = private_key.public_key
|
||||
public_key_b64 = base64.b64encode(bytes(public_key)).decode("utf-8")
|
||||
return private_key, public_key, public_key_b64
|
||||
|
||||
|
||||
def _set_base_env(monkeypatch, tmp_path: Path, registry_path: Path):
|
||||
"""Set common environment variables for all integration tests."""
|
||||
monkeypatch.setattr(
|
||||
"scripts.pat_manager.__main__.REGISTRY_PATH", registry_path
|
||||
)
|
||||
monkeypatch.setenv("GITLAB_PAT", "glpat-test-token-123")
|
||||
monkeypatch.setenv("JIRA_PAT", "jira-test-token-456")
|
||||
monkeypatch.setenv("CONFLUENCE_PAT", "confluence-test-token-789")
|
||||
monkeypatch.setenv("ORGMYLIFE_API_KEY", "orgmylife-api-key-abc")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_github_token_xyz")
|
||||
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
|
||||
monkeypatch.setenv("JIRA_URL", "https://jira.example.com")
|
||||
monkeypatch.setenv("CONFLUENCE_URL", "https://confluence.example.com")
|
||||
monkeypatch.setenv("ORGMYLIFE_URL", "https://orgmylife.example.com")
|
||||
monkeypatch.setenv("ALERT_CHANNEL", "orgmylife")
|
||||
monkeypatch.setenv("GITLAB_TOKEN_ID", "12345")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "test-owner/test-repo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Happy path - all healthy → no alerts, summary only
|
||||
# Validates: Requirements 5.4, 5.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_happy_path_all_healthy(tmp_path, monkeypatch, capsys):
|
||||
"""All PATs are healthy (far-future expiry) → exit 0, no alerts sent."""
|
||||
far_future = _future_date(60)
|
||||
|
||||
tokens = [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-token",
|
||||
"expiry_date": far_future,
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-access",
|
||||
"expiry_date": far_future,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "oml-key",
|
||||
"expiry_date": far_future,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock GitLab check → healthy (expires_at far in future)
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": far_future})
|
||||
)
|
||||
|
||||
# Mock Jira check → 200 (token works)
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(200, json={"name": "user"})
|
||||
)
|
||||
|
||||
# Mock OrgMyLife check → 200 (token works)
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
|
||||
# No alert routes should be called
|
||||
alert_route = respx.post("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
assert exit_code == 0
|
||||
# No alerts should have been sent
|
||||
assert not alert_route.called
|
||||
|
||||
# Summary report should be printed
|
||||
captured = capsys.readouterr()
|
||||
assert "PAT Lifecycle Check Summary" in captured.out
|
||||
assert "ci-token" in captured.out
|
||||
assert "jira-access" in captured.out
|
||||
assert "oml-key" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: GitLab rotation flow with mocked APIs
|
||||
# Validates: Requirements 3.2, 3.4
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_gitlab_rotation_flow(tmp_path, monkeypatch, capsys):
|
||||
"""GitLab token expiring soon → rotation + secret update → registry updated."""
|
||||
expiring = _expiring_date(7)
|
||||
new_expiry = _future_date(90)
|
||||
_, _, public_key_b64 = _generate_keypair()
|
||||
|
||||
tokens = [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-token",
|
||||
"expiry_date": expiring,
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock GitLab check → expiring soon
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": expiring})
|
||||
)
|
||||
|
||||
# Mock GitLab rotation → success
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/12345/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-new-rotated-token", "expires_at": new_expiry},
|
||||
)
|
||||
)
|
||||
|
||||
# Mock GitHub public key fetch
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"key": public_key_b64, "key_id": "key-123"}
|
||||
)
|
||||
)
|
||||
|
||||
# Mock GitHub secret update → success
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/GITLAB_PAT"
|
||||
).mock(return_value=httpx.Response(204))
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
assert exit_code == 0
|
||||
|
||||
# Verify registry was updated with new expiry date
|
||||
updated_registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
assert updated_registry["tokens"][0]["expiry_date"] == new_expiry
|
||||
|
||||
# Verify rotation was logged
|
||||
captured = capsys.readouterr()
|
||||
assert "Rotated GitLab token" in captured.out
|
||||
assert new_expiry in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Rotation success + secret update failure → alert triggered
|
||||
# Validates: Requirements 3.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_rotation_success_secret_update_failure_triggers_alert(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Rotation succeeds but GitHub secret update fails → alert is triggered."""
|
||||
expiring = _expiring_date(7)
|
||||
new_expiry = _future_date(90)
|
||||
_, _, public_key_b64 = _generate_keypair()
|
||||
|
||||
tokens = [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-token",
|
||||
"expiry_date": expiring,
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock GitLab check → expiring soon
|
||||
respx.get("https://gitlab.example.com/api/v4/personal_access_tokens/self").mock(
|
||||
return_value=httpx.Response(200, json={"expires_at": expiring})
|
||||
)
|
||||
|
||||
# Mock GitLab rotation → success
|
||||
respx.post(
|
||||
"https://gitlab.example.com/api/v4/personal_access_tokens/12345/rotate"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "glpat-new-rotated-token", "expires_at": new_expiry},
|
||||
)
|
||||
)
|
||||
|
||||
# Mock GitHub public key fetch → success
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"key": public_key_b64, "key_id": "key-123"}
|
||||
)
|
||||
)
|
||||
|
||||
# Mock GitHub secret update → FAILURE (500)
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/GITLAB_PAT"
|
||||
).mock(return_value=httpx.Response(500))
|
||||
|
||||
# Mock OrgMyLife GET tasks (check existing) → no existing tasks
|
||||
orgmylife_get_route = respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
|
||||
# Mock OrgMyLife POST tasks (create alert) → success
|
||||
orgmylife_post_route = respx.post("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(201, json={"id": 42})
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
# Alert was delivered successfully, so exit code is 0
|
||||
assert exit_code == 0
|
||||
|
||||
# Verify alert was triggered (POST to OrgMyLife)
|
||||
assert orgmylife_post_route.called
|
||||
|
||||
# Verify registry was NOT updated (secret update failed)
|
||||
updated_registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
assert updated_registry["tokens"][0]["expiry_date"] == expiring
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Alert channel unreachable → non-zero exit code
|
||||
# Validates: Requirements 4.6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_alert_channel_unreachable_nonzero_exit(tmp_path, monkeypatch, capsys):
|
||||
"""Alert channel (OrgMyLife) unreachable → exit code 1."""
|
||||
expiring = _expiring_date(7)
|
||||
|
||||
tokens = [
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-access",
|
||||
"expiry_date": expiring,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock Jira check → 200 (token works, but expiry within window)
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
return_value=httpx.Response(200, json={"name": "user"})
|
||||
)
|
||||
|
||||
# Mock OrgMyLife GET tasks → connection error
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
|
||||
# Mock OrgMyLife POST tasks → connection error
|
||||
respx.post("https://orgmylife.example.com/api/tasks").mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
# Alert delivery failed → exit code 1
|
||||
assert exit_code == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: Service timeout after retries → "check failed" classification
|
||||
# Validates: Requirements 2.5
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_service_timeout_after_retries_check_failed(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Service times out on all retry attempts → classified as 'check failed'."""
|
||||
tokens = [
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-access",
|
||||
"expiry_date": _future_date(30),
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock Jira API → timeout on all attempts
|
||||
respx.get("https://jira.example.com/rest/api/2/myself").mock(
|
||||
side_effect=httpx.TimeoutException("Request timed out")
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays in retry logic
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
# "check failed" doesn't trigger alerts, so exit code is 0
|
||||
assert exit_code == 0
|
||||
|
||||
# Verify summary shows "check failed"
|
||||
captured = capsys.readouterr()
|
||||
assert "check failed" in captured.out
|
||||
assert "jira-access" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Null expiry_date classification scenarios
|
||||
# Validates: Requirements 2.6, 6.8
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_null_expiry_date_api_success_classified_healthy(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Null expiry_date + API success → classified as 'healthy'."""
|
||||
tokens = [
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "oml-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock OrgMyLife check → 200 (token works)
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(200, json=[])
|
||||
)
|
||||
|
||||
# No alert routes should be called
|
||||
alert_route = respx.post("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(201, json={"id": 1})
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
assert exit_code == 0
|
||||
assert not alert_route.called
|
||||
|
||||
# Verify summary shows "healthy"
|
||||
captured = capsys.readouterr()
|
||||
assert "healthy" in captured.out
|
||||
assert "oml-key" in captured.out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_null_expiry_date_api_401_classified_expired(
|
||||
tmp_path, monkeypatch, capsys
|
||||
):
|
||||
"""Null expiry_date + API returns 401 → classified as 'expired'."""
|
||||
tokens = [
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "oml-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
]
|
||||
registry_path = _write_registry(tmp_path, tokens)
|
||||
_set_base_env(monkeypatch, tmp_path, registry_path)
|
||||
|
||||
# Mock OrgMyLife check → 401 (token expired/revoked)
|
||||
respx.get("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(401)
|
||||
)
|
||||
|
||||
# Mock OrgMyLife alert task creation → success
|
||||
respx.post("https://orgmylife.example.com/api/tasks").mock(
|
||||
return_value=httpx.Response(201, json={"id": 99})
|
||||
)
|
||||
|
||||
# Mock asyncio.sleep to avoid delays
|
||||
monkeypatch.setattr("asyncio.sleep", AsyncMock())
|
||||
|
||||
exit_code = await main()
|
||||
|
||||
assert exit_code == 0
|
||||
|
||||
# Verify summary shows "expired"
|
||||
captured = capsys.readouterr()
|
||||
assert "expired" in captured.out
|
||||
assert "oml-key" in captured.out
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for orchestrator dispatch logic."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.models import BlockerRef, Issue, OrchestratorState, RunningEntry, LiveSession
|
||||
from ai_orchestrator.orchestrator import Orchestrator
|
||||
|
||||
|
||||
def make_issue(
|
||||
id: str = "id-1",
|
||||
identifier: str = "ABC-1",
|
||||
title: str = "Test",
|
||||
state: str = "Todo",
|
||||
priority: int | None = 1,
|
||||
created_at: datetime | None = None,
|
||||
blocked_by: list | None = None,
|
||||
) -> Issue:
|
||||
return Issue(
|
||||
id=id,
|
||||
identifier=identifier,
|
||||
title=title,
|
||||
state=state,
|
||||
priority=priority,
|
||||
created_at=created_at or datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
blocked_by=blocked_by or [],
|
||||
)
|
||||
|
||||
|
||||
class TestDispatchSorting:
|
||||
"""Tests for issue dispatch sorting."""
|
||||
|
||||
def test_priority_ascending(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(id="3", identifier="C", priority=3),
|
||||
make_issue(id="1", identifier="A", priority=1),
|
||||
make_issue(id="2", identifier="B", priority=2),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert [i.identifier for i in sorted_issues] == ["A", "B", "C"]
|
||||
|
||||
def test_null_priority_sorts_last(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(id="1", identifier="A", priority=None),
|
||||
make_issue(id="2", identifier="B", priority=2),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert sorted_issues[0].identifier == "B"
|
||||
assert sorted_issues[1].identifier == "A"
|
||||
|
||||
def test_oldest_first_tiebreaker(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("---\ntracker:\n kind: linear\n---\nprompt")
|
||||
orch = Orchestrator(workflow)
|
||||
|
||||
issues = [
|
||||
make_issue(
|
||||
id="2", identifier="B", priority=1,
|
||||
created_at=datetime(2024, 2, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
make_issue(
|
||||
id="1", identifier="A", priority=1,
|
||||
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
sorted_issues = orch._sort_for_dispatch(issues)
|
||||
assert sorted_issues[0].identifier == "A"
|
||||
|
||||
|
||||
class TestDispatchEligibility:
|
||||
"""Tests for should_dispatch logic."""
|
||||
|
||||
def setup_method(self, method, tmp_path=None):
|
||||
"""Set up a basic orchestrator for testing."""
|
||||
pass
|
||||
|
||||
def _make_orchestrator(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: fake-key
|
||||
active_states: ["Todo", "In Progress"]
|
||||
terminal_states: ["Done", "Cancelled"]
|
||||
agent:
|
||||
max_concurrent_agents: 5
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
return orch
|
||||
|
||||
def test_active_issue_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(state="Todo")
|
||||
assert orch._should_dispatch(issue) is True
|
||||
|
||||
def test_terminal_issue_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(state="Done")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_already_running_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(id="id-1", state="Todo")
|
||||
orch._state.running["id-1"] = RunningEntry(
|
||||
issue_id="id-1", identifier="ABC-1", issue=issue
|
||||
)
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_already_claimed_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(id="id-1", state="Todo")
|
||||
orch._state.claimed.add("id-1")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_todo_with_non_terminal_blocker_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(
|
||||
state="Todo",
|
||||
blocked_by=[BlockerRef(id="b1", state="In Progress")],
|
||||
)
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
def test_todo_with_terminal_blocker_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(
|
||||
state="Todo",
|
||||
blocked_by=[BlockerRef(id="b1", state="Done")],
|
||||
)
|
||||
assert orch._should_dispatch(issue) is True
|
||||
|
||||
def test_missing_required_fields_not_eligible(self, tmp_path):
|
||||
orch = self._make_orchestrator(tmp_path)
|
||||
issue = make_issue(title="", state="Todo")
|
||||
assert orch._should_dispatch(issue) is False
|
||||
|
||||
|
||||
class TestBackoffComputation:
|
||||
"""Tests for retry backoff calculation."""
|
||||
|
||||
def test_first_retry(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 1: 10000 * 2^0 = 10000
|
||||
assert orch._compute_backoff(1) == 10000
|
||||
|
||||
def test_second_retry(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 2: 10000 * 2^1 = 20000
|
||||
assert orch._compute_backoff(2) == 20000
|
||||
|
||||
def test_backoff_capped(self, tmp_path):
|
||||
workflow = tmp_path / "WORKFLOW.md"
|
||||
workflow.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: test
|
||||
api_key: key
|
||||
agent:
|
||||
max_retry_backoff_ms: 300000
|
||||
---
|
||||
prompt""")
|
||||
orch = Orchestrator(workflow)
|
||||
orch._reload_workflow()
|
||||
# attempt 10: 10000 * 2^9 = 5120000, capped at 300000
|
||||
assert orch._compute_backoff(10) == 300000
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for prompt builder."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.models import Issue
|
||||
from ai_orchestrator.prompt import (
|
||||
DEFAULT_PROMPT,
|
||||
TemplateParseError,
|
||||
TemplateRenderError,
|
||||
render_prompt,
|
||||
)
|
||||
|
||||
|
||||
def make_issue(**kwargs) -> Issue:
|
||||
"""Create a test issue with defaults."""
|
||||
defaults = {
|
||||
"id": "issue-1",
|
||||
"identifier": "ABC-123",
|
||||
"title": "Fix the bug",
|
||||
"description": "Something is broken",
|
||||
"state": "In Progress",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return Issue(**defaults)
|
||||
|
||||
|
||||
class TestRenderPrompt:
|
||||
"""Tests for prompt rendering."""
|
||||
|
||||
def test_basic_rendering(self):
|
||||
template = "Work on {{ issue.identifier }}: {{ issue.title }}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue)
|
||||
assert result == "Work on ABC-123: Fix the bug"
|
||||
|
||||
def test_attempt_variable(self):
|
||||
template = "{% if attempt %}Retry #{{ attempt }}{% endif %}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue, attempt=3)
|
||||
assert result == "Retry #3"
|
||||
|
||||
def test_attempt_none_on_first_run(self):
|
||||
template = "{% if attempt %}retry{% else %}first{% endif %}"
|
||||
issue = make_issue()
|
||||
result = render_prompt(template, issue, attempt=None)
|
||||
assert result == "first"
|
||||
|
||||
def test_empty_template_returns_default(self):
|
||||
issue = make_issue()
|
||||
result = render_prompt("", issue)
|
||||
assert result == DEFAULT_PROMPT
|
||||
|
||||
def test_unknown_variable_fails(self):
|
||||
template = "{{ unknown_var }}"
|
||||
issue = make_issue()
|
||||
with pytest.raises(TemplateRenderError):
|
||||
render_prompt(template, issue)
|
||||
|
||||
def test_issue_labels_iterable(self):
|
||||
template = "{% for label in issue.labels %}{{ label }} {% endfor %}"
|
||||
issue = make_issue(labels=["bug", "urgent"])
|
||||
result = render_prompt(template, issue)
|
||||
assert "bug" in result
|
||||
assert "urgent" in result
|
||||
|
||||
def test_issue_description_none(self):
|
||||
template = "{{ issue.description or 'No description' }}"
|
||||
issue = make_issue(description=None)
|
||||
result = render_prompt(template, issue)
|
||||
assert result == "No description"
|
||||
|
||||
def test_syntax_error_raises(self):
|
||||
template = "{% if unclosed"
|
||||
issue = make_issue()
|
||||
with pytest.raises(TemplateParseError):
|
||||
render_prompt(template, issue)
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Unit tests for the PAT Registry module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.pat_manager.errors import RegistryValidationError
|
||||
from scripts.pat_manager.models import PatEntry
|
||||
from scripts.pat_manager.registry import PatRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> PatRegistry:
|
||||
"""Create a fresh PatRegistry instance."""
|
||||
return PatRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_entry_dict() -> dict:
|
||||
"""A valid PAT entry dictionary."""
|
||||
return {
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-pipeline-token",
|
||||
"expiry_date": "2025-08-15",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_registry_data() -> dict:
|
||||
"""A valid registry JSON structure with multiple entries."""
|
||||
return {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "ci-pipeline-token",
|
||||
"expiry_date": "2025-08-15",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "jira-api-access",
|
||||
"expiry_date": "2025-07-20",
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
{
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "orchestrator-api-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestValidateEntry:
|
||||
"""Tests for PatRegistry.validate_entry()."""
|
||||
|
||||
def test_valid_gitlab_entry(self, registry: PatRegistry, valid_entry_dict: dict) -> None:
|
||||
result = registry.validate_entry(valid_entry_dict)
|
||||
assert isinstance(result, PatEntry)
|
||||
assert result.service == "GitLab"
|
||||
assert result.token_name == "ci-pipeline-token"
|
||||
assert result.expiry_date == "2025-08-15"
|
||||
assert result.renewal_method == "auto"
|
||||
|
||||
def test_valid_jira_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "jira-token",
|
||||
"expiry_date": "2025-12-31",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "Jira"
|
||||
assert result.renewal_method == "manual"
|
||||
|
||||
def test_valid_confluence_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Confluence",
|
||||
"token_name": "conf-token",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "Confluence"
|
||||
|
||||
def test_valid_orgmylife_entry(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "OrgMyLife",
|
||||
"token_name": "oml-key",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.service == "OrgMyLife"
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_null_expiry_date_accepted(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "no-expiry",
|
||||
"expiry_date": None,
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_missing_expiry_date_field_accepted(self, registry: PatRegistry) -> None:
|
||||
"""expiry_date is optional (defaults to None when missing)."""
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "no-expiry-field",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert result.expiry_date is None
|
||||
|
||||
def test_missing_service_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"token_name": "test", "renewal_method": "manual"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
|
||||
def test_missing_token_name_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"service": "GitLab", "renewal_method": "auto"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_missing_renewal_method_field(self, registry: PatRegistry) -> None:
|
||||
entry = {"service": "GitLab", "token_name": "test"}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_service_value(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitHub",
|
||||
"token_name": "test",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
|
||||
def test_empty_token_name(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_token_name_too_long(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "x" * 129,
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_token_name_max_length_accepted(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "x" * 128,
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
result = registry.validate_entry(entry)
|
||||
assert len(result.token_name) == 128
|
||||
|
||||
def test_invalid_expiry_date_format(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025/08/15",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_expiry_date_values(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-13-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_invalid_expiry_date_feb_30(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-02-30",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "expiry_date" in exc_info.value.failed_fields
|
||||
|
||||
def test_renewal_method_mismatch_gitlab(self, registry: PatRegistry) -> None:
|
||||
"""GitLab must have renewal_method 'auto'."""
|
||||
entry = {
|
||||
"service": "GitLab",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_renewal_method_mismatch_jira(self, registry: PatRegistry) -> None:
|
||||
"""Jira must have renewal_method 'manual'."""
|
||||
entry = {
|
||||
"service": "Jira",
|
||||
"token_name": "test",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert "renewal_method" in exc_info.value.failed_fields
|
||||
|
||||
def test_non_dict_entry_raises(self, registry: PatRegistry) -> None:
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.validate_entry("not a dict") # type: ignore
|
||||
|
||||
def test_multiple_failed_fields(self, registry: PatRegistry) -> None:
|
||||
entry = {
|
||||
"service": "InvalidService",
|
||||
"token_name": "",
|
||||
"renewal_method": "invalid",
|
||||
}
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.validate_entry(entry)
|
||||
assert len(exc_info.value.failed_fields) >= 2
|
||||
|
||||
|
||||
class TestLoad:
|
||||
"""Tests for PatRegistry.load()."""
|
||||
|
||||
def test_load_valid_registry(
|
||||
self, registry: PatRegistry, tmp_path: Path, valid_registry_data: dict
|
||||
) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(valid_registry_data), encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 3
|
||||
assert entries[0].service == "GitLab"
|
||||
assert entries[1].service == "Jira"
|
||||
assert entries[2].service == "OrgMyLife"
|
||||
|
||||
def test_load_empty_registry(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text('{"version": "1.0", "tokens": []}', encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert entries == []
|
||||
|
||||
def test_load_nonexistent_file(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "nonexistent.json"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
registry.load(file_path)
|
||||
|
||||
def test_load_invalid_json(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text("not valid json", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
registry.load(file_path)
|
||||
|
||||
def test_load_duplicate_entries_raises(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "same-name",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "same-name",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RegistryValidationError) as exc_info:
|
||||
registry.load(file_path)
|
||||
assert "service" in exc_info.value.failed_fields
|
||||
assert "token_name" in exc_info.value.failed_fields
|
||||
|
||||
def test_load_same_token_name_different_services(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
"""Same token_name in different services is allowed."""
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "GitLab",
|
||||
"token_name": "api-token",
|
||||
"expiry_date": "2025-01-01",
|
||||
"renewal_method": "auto",
|
||||
},
|
||||
{
|
||||
"service": "Jira",
|
||||
"token_name": "api-token",
|
||||
"expiry_date": "2025-06-01",
|
||||
"renewal_method": "manual",
|
||||
},
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 2
|
||||
|
||||
def test_load_invalid_entry_raises(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"tokens": [
|
||||
{
|
||||
"service": "InvalidService",
|
||||
"token_name": "test",
|
||||
"renewal_method": "manual",
|
||||
}
|
||||
],
|
||||
}
|
||||
file_path = tmp_path / "registry.json"
|
||||
file_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(file_path)
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for PatRegistry.save()."""
|
||||
|
||||
def test_save_entries(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
entries = [
|
||||
PatEntry(
|
||||
service="GitLab",
|
||||
token_name="test-token",
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto",
|
||||
),
|
||||
PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-token",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
]
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, entries)
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data["version"] == "1.0"
|
||||
assert len(data["tokens"]) == 2
|
||||
assert data["tokens"][0]["service"] == "GitLab"
|
||||
assert data["tokens"][1]["expiry_date"] is None
|
||||
|
||||
def test_save_empty_list(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, [])
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data == {"version": "1.0", "tokens": []}
|
||||
|
||||
def test_save_creates_parent_directories(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
file_path = tmp_path / "subdir" / "nested" / "registry.json"
|
||||
registry.save(file_path, [])
|
||||
assert file_path.exists()
|
||||
|
||||
|
||||
class TestCreateEmpty:
|
||||
"""Tests for PatRegistry.create_empty()."""
|
||||
|
||||
def test_create_empty_file(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
file_path = tmp_path / "new-registry.json"
|
||||
registry.create_empty(file_path)
|
||||
|
||||
assert file_path.exists()
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data == {"version": "1.0", "tokens": []}
|
||||
|
||||
def test_create_empty_loadable(self, registry: PatRegistry, tmp_path: Path) -> None:
|
||||
"""An empty registry created by create_empty should be loadable."""
|
||||
file_path = tmp_path / "new-registry.json"
|
||||
registry.create_empty(file_path)
|
||||
|
||||
entries = registry.load(file_path)
|
||||
assert entries == []
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""Tests for save -> load round-trip consistency."""
|
||||
|
||||
def test_round_trip_preserves_entries(
|
||||
self, registry: PatRegistry, tmp_path: Path
|
||||
) -> None:
|
||||
original_entries = [
|
||||
PatEntry(
|
||||
service="GitLab",
|
||||
token_name="ci-token",
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto",
|
||||
),
|
||||
PatEntry(
|
||||
service="Jira",
|
||||
token_name="jira-access",
|
||||
expiry_date="2025-12-31",
|
||||
renewal_method="manual",
|
||||
),
|
||||
PatEntry(
|
||||
service="OrgMyLife",
|
||||
token_name="api-key",
|
||||
expiry_date=None,
|
||||
renewal_method="manual",
|
||||
),
|
||||
]
|
||||
|
||||
file_path = tmp_path / "registry.json"
|
||||
registry.save(file_path, original_entries)
|
||||
loaded_entries = registry.load(file_path)
|
||||
|
||||
assert len(loaded_entries) == len(original_entries)
|
||||
for original, loaded in zip(original_entries, loaded_entries):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Property-based tests for the PAT Registry module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.errors import RegistryValidationError
|
||||
from scripts.pat_manager.models import PatEntry
|
||||
from scripts.pat_manager.registry import PatRegistry, VALID_SERVICES
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
# Valid service names and their required renewal methods
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
# Limit days based on month to avoid invalid dates
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
# Simplified leap year check
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate valid token names (1-128 chars, printable ASCII)."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "S"),
|
||||
whitelist_characters="-_",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
)
|
||||
)
|
||||
# Ensure no control characters or empty after strip
|
||||
assume(len(name.strip()) > 0)
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_pat_entries(draw: st.DrawFn) -> PatEntry:
|
||||
"""Generate a valid PatEntry with consistent service/renewal_method."""
|
||||
service, renewal_method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
token_name = draw(valid_token_names())
|
||||
expiry_date = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
return PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method=renewal_method,
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def unique_pat_entry_lists(draw: st.DrawFn) -> list[PatEntry]:
|
||||
"""Generate a list of valid PatEntry objects with no duplicate (service, token_name) pairs."""
|
||||
entries = draw(st.lists(valid_pat_entries(), min_size=1, max_size=10))
|
||||
# Deduplicate by (service, token_name)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique_entries: list[PatEntry] = []
|
||||
for entry in entries:
|
||||
key = (entry.service, entry.token_name)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_entries.append(entry)
|
||||
assume(len(unique_entries) >= 1)
|
||||
return unique_entries
|
||||
|
||||
|
||||
@st.composite
|
||||
def invalid_entry_dicts(draw: st.DrawFn) -> dict:
|
||||
"""Generate entry dicts that are invalid (missing fields, bad service, bad token_name, etc.)."""
|
||||
strategy_choice = draw(st.integers(min_value=0, max_value=4))
|
||||
|
||||
if strategy_choice == 0:
|
||||
# Missing required field: service
|
||||
return {
|
||||
"token_name": draw(valid_token_names()),
|
||||
"renewal_method": draw(st.sampled_from(["auto", "manual"])),
|
||||
}
|
||||
elif strategy_choice == 1:
|
||||
# Missing required field: token_name
|
||||
service, method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"renewal_method": method,
|
||||
}
|
||||
elif strategy_choice == 2:
|
||||
# Missing required field: renewal_method
|
||||
service, _ = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"token_name": draw(valid_token_names()),
|
||||
}
|
||||
elif strategy_choice == 3:
|
||||
# Invalid service name
|
||||
bad_service = draw(
|
||||
st.text(min_size=1, max_size=20).filter(
|
||||
lambda s: s not in VALID_SERVICES
|
||||
)
|
||||
)
|
||||
return {
|
||||
"service": bad_service,
|
||||
"token_name": draw(valid_token_names()),
|
||||
"renewal_method": draw(st.sampled_from(["auto", "manual"])),
|
||||
}
|
||||
else:
|
||||
# Empty token_name or too long
|
||||
bad_name = draw(
|
||||
st.one_of(
|
||||
st.just(""),
|
||||
st.text(min_size=129, max_size=200),
|
||||
)
|
||||
)
|
||||
service, method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
return {
|
||||
"service": service,
|
||||
"token_name": bad_name,
|
||||
"renewal_method": method,
|
||||
}
|
||||
|
||||
|
||||
@st.composite
|
||||
def duplicate_entry_from_existing(draw: st.DrawFn, entries: list[PatEntry]) -> dict:
|
||||
"""Generate a duplicate entry dict that has the same (service, token_name) as an existing entry."""
|
||||
assume(len(entries) > 0)
|
||||
existing = draw(st.sampled_from(entries))
|
||||
# Create a dict with same service+token_name but possibly different expiry
|
||||
expiry = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
return {
|
||||
"service": existing.service,
|
||||
"token_name": existing.token_name,
|
||||
"expiry_date": expiry,
|
||||
"renewal_method": existing.renewal_method,
|
||||
}
|
||||
|
||||
|
||||
# --- Property 1 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 1: Registry serialization round-trip
|
||||
class TestRegistrySerializationRoundTrip:
|
||||
"""
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
Property 1: For any valid list of PAT entries, serializing the registry
|
||||
to JSON and deserializing it back SHALL produce an equivalent list of
|
||||
entries with identical field values.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entries=st.lists(valid_pat_entries(), min_size=0, max_size=15).map(
|
||||
lambda entries: list({
|
||||
(e.service, e.token_name): e for e in entries
|
||||
}.values())
|
||||
)
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_save_load_round_trip(
|
||||
self, entries: list[PatEntry], tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""save(path, entries) followed by load(path) produces identical entries."""
|
||||
tmp_path = tmp_path_factory.mktemp("prop1_roundtrip")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save entries to file
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Load entries back
|
||||
loaded = registry.load(file_path)
|
||||
|
||||
# Verify same count
|
||||
assert len(loaded) == len(entries)
|
||||
|
||||
# Verify each entry has identical field values
|
||||
for original, restored in zip(entries, loaded):
|
||||
assert restored.service == original.service
|
||||
assert restored.token_name == original.token_name
|
||||
assert restored.expiry_date == original.expiry_date
|
||||
assert restored.renewal_method == original.renewal_method
|
||||
|
||||
|
||||
# --- Property 3 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 3: Invalid entries and duplicates preserve registry state
|
||||
class TestInvalidEntriesAndDuplicatesPreserveState:
|
||||
"""
|
||||
**Validates: Requirements 1.3, 1.5**
|
||||
|
||||
Property 3: For any PAT registry and any entry that is either invalid
|
||||
(fails validation) or a duplicate (same service + token_name already exists),
|
||||
attempting to add that entry SHALL leave the registry unchanged — the entry
|
||||
count and all existing entries remain identical.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entries=unique_pat_entry_lists(),
|
||||
bad_entry=invalid_entry_dicts(),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_invalid_entry_preserves_registry_state(
|
||||
self, entries: list[PatEntry], bad_entry: dict, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Loading a registry file with an appended invalid entry raises
|
||||
RegistryValidationError and the original saved file remains unchanged.
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("prop3_invalid")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save the valid registry
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Read the original file content for comparison
|
||||
original_content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
# Create a corrupted registry file with the bad entry appended
|
||||
corrupted_path = tmp_path / "corrupted.json"
|
||||
data = json.loads(original_content)
|
||||
data["tokens"].append(bad_entry)
|
||||
corrupted_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Attempting to load the corrupted file should raise RegistryValidationError
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(corrupted_path)
|
||||
|
||||
# The original file must remain unchanged
|
||||
assert file_path.read_text(encoding="utf-8") == original_content
|
||||
|
||||
# The original file is still loadable and produces the same entries
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == len(entries)
|
||||
for original, loaded in zip(entries, reloaded):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
|
||||
@given(entries=unique_pat_entry_lists())
|
||||
@settings(max_examples=100)
|
||||
def test_duplicate_entry_preserves_registry_state(
|
||||
self, entries: list[PatEntry], tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Loading a registry file with a duplicate (service, token_name) entry
|
||||
raises RegistryValidationError and the original saved file remains unchanged.
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("prop3_dup")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Save the valid registry
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Read the original file content for comparison
|
||||
original_content = file_path.read_text(encoding="utf-8")
|
||||
|
||||
# Pick an existing entry to duplicate
|
||||
existing = entries[0]
|
||||
duplicate_dict = {
|
||||
"service": existing.service,
|
||||
"token_name": existing.token_name,
|
||||
"expiry_date": "2099-12-31", # Different expiry to prove it's a new entry
|
||||
"renewal_method": existing.renewal_method,
|
||||
}
|
||||
|
||||
# Create a corrupted registry file with the duplicate appended
|
||||
corrupted_path = tmp_path / "corrupted_dup.json"
|
||||
data = json.loads(original_content)
|
||||
data["tokens"].append(duplicate_dict)
|
||||
corrupted_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Attempting to load the corrupted file should raise RegistryValidationError
|
||||
with pytest.raises(RegistryValidationError):
|
||||
registry.load(corrupted_path)
|
||||
|
||||
# The original file must remain unchanged
|
||||
assert file_path.read_text(encoding="utf-8") == original_content
|
||||
|
||||
# The original file is still loadable and produces the same entries
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == len(entries)
|
||||
for original, loaded in zip(entries, reloaded):
|
||||
assert loaded.service == original.service
|
||||
assert loaded.token_name == original.token_name
|
||||
assert loaded.expiry_date == original.expiry_date
|
||||
assert loaded.renewal_method == original.renewal_method
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for the Summary Reporter module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus, RotationResult
|
||||
from scripts.pat_manager.reporter import SummaryReporter
|
||||
|
||||
|
||||
def make_entry(service: str = "GitLab", token_name: str = "ci-token") -> PatEntry:
|
||||
"""Helper to create a PatEntry for testing."""
|
||||
return PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date="2025-08-15",
|
||||
renewal_method="auto" if service == "GitLab" else "manual",
|
||||
)
|
||||
|
||||
|
||||
def make_result(
|
||||
service: str = "GitLab",
|
||||
token_name: str = "ci-token",
|
||||
status: PatStatus = PatStatus.HEALTHY,
|
||||
days_remaining: int | None = 45,
|
||||
error_message: str | None = None,
|
||||
) -> CheckResult:
|
||||
"""Helper to create a CheckResult for testing."""
|
||||
entry = make_entry(service, token_name)
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
class TestSummaryReporter:
|
||||
"""Tests for SummaryReporter.report()."""
|
||||
|
||||
def test_report_header(self) -> None:
|
||||
"""Report starts with the expected header."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report([], [])
|
||||
assert "=== PAT Lifecycle Check Summary ===" in report
|
||||
|
||||
def test_report_contains_table_headers(self) -> None:
|
||||
"""Report contains column headers."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
report = reporter.report(results, [])
|
||||
assert "Service" in report
|
||||
assert "Token Name" in report
|
||||
assert "Status" in report
|
||||
assert "Details" in report
|
||||
|
||||
def test_report_one_healthy_pat(self) -> None:
|
||||
"""Report shows healthy PAT with days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result(days_remaining=45)]
|
||||
report = reporter.report(results, [])
|
||||
assert "GitLab" in report
|
||||
assert "ci-token" in report
|
||||
assert "healthy" in report
|
||||
assert "45 days remaining" in report
|
||||
|
||||
def test_report_expiring_soon_pat(self) -> None:
|
||||
"""Report shows expiring soon PAT with days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="Jira",
|
||||
token_name="jira-api",
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "Jira" in report
|
||||
assert "jira-api" in report
|
||||
assert "expiring soon" in report
|
||||
assert "7 days remaining" in report
|
||||
|
||||
def test_report_expired_pat(self) -> None:
|
||||
"""Report shows expired PAT with 0 days remaining."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="Confluence",
|
||||
token_name="confluence-bot",
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "Confluence" in report
|
||||
assert "confluence-bot" in report
|
||||
assert "expired" in report
|
||||
assert "0 days remaining" in report
|
||||
|
||||
def test_report_check_failed_pat(self) -> None:
|
||||
"""Report shows check failed PAT with error message."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(
|
||||
service="OrgMyLife",
|
||||
token_name="orchestrator-key",
|
||||
status=PatStatus.CHECK_FAILED,
|
||||
days_remaining=None,
|
||||
error_message="Connection timeout",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "OrgMyLife" in report
|
||||
assert "orchestrator-key" in report
|
||||
assert "check failed" in report
|
||||
assert "Connection timeout" in report
|
||||
|
||||
def test_report_multiple_pats(self) -> None:
|
||||
"""Report contains one line per PAT."""
|
||||
reporter = SummaryReporter()
|
||||
results = [
|
||||
make_result(service="GitLab", token_name="token-a", days_remaining=45),
|
||||
make_result(
|
||||
service="Jira",
|
||||
token_name="token-b",
|
||||
status=PatStatus.EXPIRING_SOON,
|
||||
days_remaining=7,
|
||||
),
|
||||
make_result(
|
||||
service="Confluence",
|
||||
token_name="token-c",
|
||||
status=PatStatus.EXPIRED,
|
||||
days_remaining=0,
|
||||
),
|
||||
]
|
||||
report = reporter.report(results, [])
|
||||
assert "token-a" in report
|
||||
assert "token-b" in report
|
||||
assert "token-c" in report
|
||||
|
||||
def test_report_no_rotation_section_when_empty(self) -> None:
|
||||
"""Report omits rotation section when no rotations occurred."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
report = reporter.report(results, [])
|
||||
assert "Rotation Results" not in report
|
||||
|
||||
def test_report_includes_rotation_section(self) -> None:
|
||||
"""Report includes rotation section with successful rotations."""
|
||||
reporter = SummaryReporter()
|
||||
entry = make_entry("GitLab", "ci-pipeline-token")
|
||||
results = [make_result()]
|
||||
rotations = [
|
||||
RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-new-value",
|
||||
new_expiry_date="2025-08-01",
|
||||
error_message=None,
|
||||
entry=entry,
|
||||
old_expiry_date="2025-07-01",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, rotations)
|
||||
assert "--- Rotation Results ---" in report
|
||||
assert "GitLab/ci-pipeline-token" in report
|
||||
assert "Rotated successfully" in report
|
||||
assert "old: 2025-07-01" in report
|
||||
assert "new: 2025-08-01" in report
|
||||
|
||||
def test_report_excludes_failed_rotations(self) -> None:
|
||||
"""Report rotation section only shows successful rotations."""
|
||||
reporter = SummaryReporter()
|
||||
results = [make_result()]
|
||||
rotations = [
|
||||
RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message="Rotation failed",
|
||||
entry=make_entry("GitLab", "failed-token"),
|
||||
old_expiry_date="2025-07-01",
|
||||
)
|
||||
]
|
||||
report = reporter.report(results, rotations)
|
||||
assert "Rotation Results" not in report
|
||||
|
||||
def test_report_empty_results(self) -> None:
|
||||
"""Report handles empty results list gracefully."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report([], [])
|
||||
assert "=== PAT Lifecycle Check Summary ===" in report
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Property-based tests for the Summary Reporter module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.models import CheckResult, PatEntry, PatStatus
|
||||
from scripts.pat_manager.reporter import SummaryReporter
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
_ALL_STATUSES = [
|
||||
PatStatus.HEALTHY,
|
||||
PatStatus.EXPIRING_SOON,
|
||||
PatStatus.EXPIRED,
|
||||
PatStatus.CHECK_FAILED,
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def simple_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate simple token names using alphanumeric + hyphens for reliable report matching."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("Ll", "Lu", "Nd"),
|
||||
whitelist_characters="-",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=64,
|
||||
)
|
||||
)
|
||||
# Ensure name starts with a letter (not hyphen) and is non-empty after strip
|
||||
assume(len(name.strip()) > 0)
|
||||
assume(name[0].isalpha())
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def check_results(draw: st.DrawFn) -> CheckResult:
|
||||
"""Generate a CheckResult with a random status and appropriate fields."""
|
||||
service, renewal_method = draw(st.sampled_from(_SERVICES_AND_METHODS))
|
||||
token_name = draw(simple_token_names())
|
||||
expiry_date = draw(st.one_of(st.none(), valid_iso_dates()))
|
||||
status = draw(st.sampled_from(_ALL_STATUSES))
|
||||
|
||||
# Generate appropriate days_remaining and error_message based on status
|
||||
if status == PatStatus.CHECK_FAILED:
|
||||
days_remaining = None
|
||||
error_message = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N", "Z")),
|
||||
min_size=3,
|
||||
max_size=50,
|
||||
)
|
||||
)
|
||||
elif status == PatStatus.EXPIRED:
|
||||
days_remaining = 0
|
||||
error_message = None
|
||||
elif status == PatStatus.EXPIRING_SOON:
|
||||
days_remaining = draw(st.integers(min_value=1, max_value=14))
|
||||
error_message = None
|
||||
else: # HEALTHY
|
||||
days_remaining = draw(st.integers(min_value=15, max_value=365))
|
||||
error_message = None
|
||||
|
||||
entry = PatEntry(
|
||||
service=service,
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method=renewal_method,
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
entry=entry,
|
||||
status=status,
|
||||
days_remaining=days_remaining,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
# --- Property 11 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 11: Summary report completeness
|
||||
class TestSummaryReportCompleteness:
|
||||
"""
|
||||
**Validates: Requirements 5.3**
|
||||
|
||||
Property 11: For any list of check results, the generated summary report
|
||||
SHALL contain one line per result, and each line SHALL include the service
|
||||
name, token identifier, and classification status.
|
||||
"""
|
||||
|
||||
@given(results=st.lists(check_results(), min_size=1, max_size=10))
|
||||
@settings(max_examples=100)
|
||||
def test_report_contains_service_token_and_status_for_each_result(
|
||||
self, results: list[CheckResult]
|
||||
) -> None:
|
||||
"""For each check result, the report contains the service name,
|
||||
token name, and status value."""
|
||||
reporter = SummaryReporter()
|
||||
report = reporter.report(results, [])
|
||||
|
||||
for result in results:
|
||||
# The report must contain the service name
|
||||
assert result.entry.service in report, (
|
||||
f"Service '{result.entry.service}' not found in report"
|
||||
)
|
||||
# The report must contain the token name
|
||||
assert result.entry.token_name in report, (
|
||||
f"Token name '{result.entry.token_name}' not found in report"
|
||||
)
|
||||
# The report must contain the status value
|
||||
assert result.status.value in report, (
|
||||
f"Status '{result.status.value}' not found in report"
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Property-based tests for the GitLab Rotator module using Hypothesis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hypothesis import given, settings, assume
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from scripts.pat_manager.models import PatEntry, RotationResult
|
||||
from scripts.pat_manager.registry import PatRegistry
|
||||
|
||||
|
||||
# --- Strategies ---
|
||||
|
||||
_SERVICES_AND_METHODS = [
|
||||
("GitLab", "auto"),
|
||||
("Jira", "manual"),
|
||||
("Confluence", "manual"),
|
||||
("OrgMyLife", "manual"),
|
||||
]
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate valid ISO 8601 date strings (YYYY-MM-DD)."""
|
||||
year = draw(st.integers(min_value=2020, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def future_iso_dates(draw: st.DrawFn) -> str:
|
||||
"""Generate future ISO 8601 date strings (always in the future)."""
|
||||
year = draw(st.integers(min_value=2026, max_value=2099))
|
||||
month = draw(st.integers(min_value=1, max_value=12))
|
||||
if month in (1, 3, 5, 7, 8, 10, 12):
|
||||
max_day = 31
|
||||
elif month in (4, 6, 9, 11):
|
||||
max_day = 30
|
||||
elif month == 2:
|
||||
if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0):
|
||||
max_day = 29
|
||||
else:
|
||||
max_day = 28
|
||||
else:
|
||||
max_day = 28
|
||||
day = draw(st.integers(min_value=1, max_value=max_day))
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_token_names(draw: st.DrawFn) -> str:
|
||||
"""Generate valid token names (1-128 chars, printable ASCII)."""
|
||||
name = draw(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "S"),
|
||||
whitelist_characters="-_",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=128,
|
||||
)
|
||||
)
|
||||
assume(len(name.strip()) > 0)
|
||||
return name
|
||||
|
||||
|
||||
@st.composite
|
||||
def gitlab_pat_entries(draw: st.DrawFn) -> PatEntry:
|
||||
"""Generate a valid GitLab PatEntry with a valid expiry_date."""
|
||||
token_name = draw(valid_token_names())
|
||||
expiry_date = draw(valid_iso_dates())
|
||||
return PatEntry(
|
||||
service="GitLab",
|
||||
token_name=token_name,
|
||||
expiry_date=expiry_date,
|
||||
renewal_method="auto",
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def successful_rotation_results(draw: st.DrawFn, original_expiry: str) -> RotationResult:
|
||||
"""Generate a successful RotationResult with a new expiry date different from the original."""
|
||||
new_expiry = draw(future_iso_dates())
|
||||
assume(new_expiry != original_expiry)
|
||||
return RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-" + draw(st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L", "N")),
|
||||
min_size=10,
|
||||
max_size=30,
|
||||
)),
|
||||
new_expiry_date=new_expiry,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
# --- Property 6 Test ---
|
||||
|
||||
# Feature: pat-renewal, Property 6: Registry update after successful rotation
|
||||
class TestRegistryUpdateAfterSuccessfulRotation:
|
||||
"""
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
Property 6: For any GitLab PAT entry and any valid rotation response
|
||||
containing a new expiry date, after successful rotation the registry
|
||||
entry's expiry_date SHALL equal the new expiry date from the rotation
|
||||
response.
|
||||
"""
|
||||
|
||||
@given(
|
||||
entry=gitlab_pat_entries(),
|
||||
new_expiry=future_iso_dates(),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_registry_entry_updated_with_new_expiry_after_rotation(
|
||||
self, entry: PatEntry, new_expiry: str, tmp_path_factory
|
||||
) -> None:
|
||||
"""After successful rotation, the registry entry's expiry_date equals
|
||||
the new expiry date from the rotation response."""
|
||||
assume(new_expiry != entry.expiry_date)
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("prop6_rotation")
|
||||
registry = PatRegistry()
|
||||
file_path = tmp_path / "registry.json"
|
||||
|
||||
# Step 1: Save a registry with the original entry
|
||||
registry.save(file_path, [entry])
|
||||
|
||||
# Step 2: Create a RotationResult with success=True and the new expiry_date
|
||||
rotation_result = RotationResult(
|
||||
success=True,
|
||||
new_token="glpat-new-rotated-token",
|
||||
new_expiry_date=new_expiry,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
# Step 3: Simulate the registry update after successful rotation
|
||||
# Load the registry, update the entry's expiry_date, save it back
|
||||
entries = registry.load(file_path)
|
||||
assert len(entries) == 1
|
||||
|
||||
# Update the entry's expiry_date to the new one from RotationResult
|
||||
entries[0].expiry_date = rotation_result.new_expiry_date
|
||||
registry.save(file_path, entries)
|
||||
|
||||
# Step 4: Reload and verify the entry's expiry_date equals the new_expiry_date
|
||||
reloaded = registry.load(file_path)
|
||||
assert len(reloaded) == 1
|
||||
assert reloaded[0].expiry_date == new_expiry
|
||||
assert reloaded[0].expiry_date == rotation_result.new_expiry_date
|
||||
|
||||
# Also verify other fields remain unchanged
|
||||
assert reloaded[0].service == entry.service
|
||||
assert reloaded[0].token_name == entry.token_name
|
||||
assert reloaded[0].renewal_method == entry.renewal_method
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for the SecretUpdater module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from nacl.public import PrivateKey
|
||||
|
||||
from scripts.pat_manager.errors import SecretUpdateError
|
||||
from scripts.pat_manager.secret_updater import SecretUpdater
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keypair():
|
||||
"""Generate a NaCl keypair for testing encryption."""
|
||||
private_key = PrivateKey.generate()
|
||||
public_key = private_key.public_key
|
||||
public_key_b64 = base64.b64encode(bytes(public_key)).decode("utf-8")
|
||||
return private_key, public_key, public_key_b64
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def updater():
|
||||
"""Create a SecretUpdater instance for testing."""
|
||||
return SecretUpdater(
|
||||
github_token="ghp_test_token_123",
|
||||
repo_owner="test-owner",
|
||||
repo_name="test-repo",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_success(updater, keypair):
|
||||
"""Test successful secret update flow."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
# Mock GET public key endpoint
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-123"},
|
||||
)
|
||||
)
|
||||
|
||||
# Mock PUT secret endpoint
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(return_value=httpx.Response(204))
|
||||
|
||||
result = await updater.update_secret("MY_SECRET", "super-secret-value")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_created_201(updater, keypair):
|
||||
"""Test secret creation returns True on 201."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-456"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/NEW_SECRET"
|
||||
).mock(return_value=httpx.Response(201))
|
||||
|
||||
result = await updater.update_secret("NEW_SECRET", "new-value")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_public_key_fetch_fails_http_error(updater):
|
||||
"""Test SecretUpdateError raised when public key fetch returns non-200."""
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(return_value=httpx.Response(403))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "public key" in str(exc_info.value).lower()
|
||||
assert "403" in str(exc_info.value)
|
||||
# Ensure token value is NOT in the error message
|
||||
assert "value" not in str(exc_info.value) or "secret value" not in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_public_key_network_error(updater):
|
||||
"""Test SecretUpdateError raised on network failure during key fetch."""
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "MY_SECRET" in str(exc_info.value)
|
||||
assert "ConnectError" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_put_fails_http_error(updater, keypair):
|
||||
"""Test SecretUpdateError raised when PUT returns unexpected status."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-789"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(return_value=httpx.Response(422))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "secret-token-value")
|
||||
|
||||
assert "422" in str(exc_info.value)
|
||||
# Ensure the actual secret value is NOT exposed in the error
|
||||
assert "secret-token-value" not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_put_network_error(updater, keypair):
|
||||
"""Test SecretUpdateError raised on network failure during PUT."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-000"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/MY_SECRET"
|
||||
).mock(side_effect=httpx.TimeoutException("Request timed out"))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("MY_SECRET", "value")
|
||||
|
||||
assert "MY_SECRET" in str(exc_info.value)
|
||||
assert "TimeoutException" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_does_not_expose_token_value(updater, keypair):
|
||||
"""Verify that error messages never contain the secret value."""
|
||||
_, _, public_key_b64 = keypair
|
||||
sensitive_value = "ghp_SUPER_SECRET_TOKEN_12345"
|
||||
|
||||
respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-sec"},
|
||||
)
|
||||
)
|
||||
|
||||
respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/TOKEN_SECRET"
|
||||
).mock(return_value=httpx.Response(500))
|
||||
|
||||
with pytest.raises(SecretUpdateError) as exc_info:
|
||||
await updater.update_secret("TOKEN_SECRET", sensitive_value)
|
||||
|
||||
error_str = str(exc_info.value)
|
||||
assert sensitive_value not in error_str
|
||||
assert "ghp_SUPER_SECRET" not in error_str
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encrypt_secret_produces_valid_output(updater, keypair):
|
||||
"""Test that _encrypt_secret produces base64-encoded encrypted data."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
encrypted = updater._encrypt_secret(public_key_b64, "test-secret")
|
||||
|
||||
# Should be valid base64
|
||||
decoded = base64.b64decode(encrypted)
|
||||
# NaCl sealed box output is always 48 bytes longer than the plaintext
|
||||
# (32 bytes ephemeral public key + 16 bytes MAC)
|
||||
assert len(decoded) == len("test-secret".encode("utf-8")) + 48
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_update_secret_sends_correct_headers(updater, keypair):
|
||||
"""Verify correct authorization headers are sent."""
|
||||
_, _, public_key_b64 = keypair
|
||||
|
||||
key_route = respx.get(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/public-key"
|
||||
).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"key": public_key_b64, "key_id": "key-id-hdr"},
|
||||
)
|
||||
)
|
||||
|
||||
put_route = respx.put(
|
||||
"https://api.github.com/repos/test-owner/test-repo/actions/secrets/HDR_SECRET"
|
||||
).mock(return_value=httpx.Response(204))
|
||||
|
||||
await updater.update_secret("HDR_SECRET", "value")
|
||||
|
||||
# Verify authorization header was sent
|
||||
assert key_route.called
|
||||
key_request = key_route.calls[0].request
|
||||
assert key_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|
||||
|
||||
assert put_route.called
|
||||
put_request = put_route.calls[0].request
|
||||
assert put_request.headers["Authorization"] == "Bearer ghp_test_token_123"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for workflow loader."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.workflow import (
|
||||
MissingWorkflowFileError,
|
||||
WorkflowFrontMatterNotAMapError,
|
||||
WorkflowParseError,
|
||||
load_workflow,
|
||||
parse_workflow,
|
||||
)
|
||||
|
||||
|
||||
class TestParseWorkflow:
|
||||
"""Tests for parse_workflow function."""
|
||||
|
||||
def test_full_workflow_with_front_matter(self):
|
||||
content = """---
|
||||
tracker:
|
||||
kind: linear
|
||||
project_slug: my-project
|
||||
polling:
|
||||
interval_ms: 15000
|
||||
---
|
||||
You are working on {{ issue.identifier }}: {{ issue.title }}
|
||||
"""
|
||||
result = parse_workflow(content)
|
||||
assert result.config["tracker"]["kind"] == "linear"
|
||||
assert result.config["tracker"]["project_slug"] == "my-project"
|
||||
assert result.config["polling"]["interval_ms"] == 15000
|
||||
assert "{{ issue.identifier }}" in result.prompt_template
|
||||
|
||||
def test_no_front_matter(self):
|
||||
content = "Just a prompt template with {{ issue.title }}"
|
||||
result = parse_workflow(content)
|
||||
assert result.config == {}
|
||||
assert result.prompt_template == content
|
||||
|
||||
def test_empty_front_matter(self):
|
||||
content = """---
|
||||
---
|
||||
Hello {{ issue.identifier }}"""
|
||||
result = parse_workflow(content)
|
||||
assert result.config == {}
|
||||
assert "Hello" in result.prompt_template
|
||||
|
||||
def test_front_matter_not_a_map(self):
|
||||
content = """---
|
||||
- item1
|
||||
- item2
|
||||
---
|
||||
prompt body"""
|
||||
with pytest.raises(WorkflowFrontMatterNotAMapError):
|
||||
parse_workflow(content)
|
||||
|
||||
def test_invalid_yaml(self):
|
||||
content = """---
|
||||
invalid: yaml: content: [
|
||||
---
|
||||
prompt"""
|
||||
with pytest.raises(WorkflowParseError):
|
||||
parse_workflow(content)
|
||||
|
||||
def test_prompt_is_trimmed(self):
|
||||
content = """---
|
||||
key: value
|
||||
---
|
||||
|
||||
Some prompt with whitespace
|
||||
|
||||
"""
|
||||
result = parse_workflow(content)
|
||||
assert result.prompt_template == "Some prompt with whitespace"
|
||||
|
||||
|
||||
class TestLoadWorkflow:
|
||||
"""Tests for load_workflow function."""
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
with pytest.raises(MissingWorkflowFileError):
|
||||
load_workflow(tmp_path / "nonexistent.md")
|
||||
|
||||
def test_valid_file(self, tmp_path):
|
||||
workflow_file = tmp_path / "WORKFLOW.md"
|
||||
workflow_file.write_text("""---
|
||||
tracker:
|
||||
kind: linear
|
||||
---
|
||||
Hello {{ issue.title }}""")
|
||||
|
||||
result = load_workflow(workflow_file)
|
||||
assert result.config["tracker"]["kind"] == "linear"
|
||||
assert "Hello" in result.prompt_template
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for workspace manager."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_orchestrator.workspace import (
|
||||
WorkspacePathError,
|
||||
get_workspace_path,
|
||||
sanitize_workspace_key,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeWorkspaceKey:
|
||||
"""Tests for workspace key sanitization."""
|
||||
|
||||
def test_simple_identifier(self):
|
||||
assert sanitize_workspace_key("ABC-123") == "ABC-123"
|
||||
|
||||
def test_special_characters_replaced(self):
|
||||
assert sanitize_workspace_key("ABC/123") == "ABC_123"
|
||||
assert sanitize_workspace_key("my project!") == "my_project_"
|
||||
|
||||
def test_dots_and_underscores_preserved(self):
|
||||
assert sanitize_workspace_key("v1.0_release") == "v1.0_release"
|
||||
|
||||
def test_unicode_replaced(self):
|
||||
assert sanitize_workspace_key("café-123") == "caf__123"
|
||||
|
||||
|
||||
class TestGetWorkspacePath:
|
||||
"""Tests for workspace path computation."""
|
||||
|
||||
def test_deterministic_path(self, tmp_path):
|
||||
root = str(tmp_path)
|
||||
path1 = get_workspace_path(root, "ABC-123")
|
||||
path2 = get_workspace_path(root, "ABC-123")
|
||||
assert path1 == path2
|
||||
|
||||
def test_path_inside_root(self, tmp_path):
|
||||
root = str(tmp_path)
|
||||
path = get_workspace_path(root, "ABC-123")
|
||||
assert str(path).startswith(str(tmp_path))
|
||||
|
||||
def test_path_traversal_blocked(self, tmp_path):
|
||||
# The sanitizer replaces .. with __ so this is safe
|
||||
root = str(tmp_path)
|
||||
path = get_workspace_path(root, "../escape")
|
||||
# Should be sanitized to __escape and stay inside root
|
||||
assert str(path).startswith(str(tmp_path))
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.env
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
*.db
|
||||
alembic/versions/__pycache__
|
||||
tests/__pycache__
|
||||
@@ -0,0 +1,34 @@
|
||||
DATABASE_URL=sqlite:///./orgmylife.db
|
||||
|
||||
# For Docker/production use PostgreSQL:
|
||||
# DATABASE_URL=postgresql://orgmylife:changeme@db:5432/orgmylife
|
||||
|
||||
# PostgreSQL password (used by docker-compose)
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# API Secret for orchestrator endpoints (leave empty for local dev)
|
||||
API_SECRET=
|
||||
|
||||
# App login credentials (for the web UI)
|
||||
APP_USERNAME=andre
|
||||
APP_PASSWORD=your_login_password
|
||||
|
||||
# Nextcloud Configuration
|
||||
NEXTCLOUD_URL=https://your-nextcloud-instance.com
|
||||
NEXTCLOUD_USERNAME=your_app_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
|
||||
# dHive Email / IMAP Configuration
|
||||
IMAP_SERVER=imap.your-email-server.com
|
||||
IMAP_PORT=993
|
||||
EMAIL_USERNAME=your@dhive-email.com
|
||||
EMAIL_PASSWORD=your_email_password
|
||||
EMAIL_WEBMAIL_URL=https://your-webmail.com
|
||||
|
||||
# Gmail Configuration (use App Password, not regular password)
|
||||
# Generate at: https://myaccount.google.com/apppasswords
|
||||
GMAIL_USERNAME=your@gmail.com
|
||||
GMAIL_PASSWORD=your_app_password
|
||||
|
||||
# NoteGraph Configuration (Send to Notes feature)
|
||||
NOTEGRAPH_TOKEN=your_notegraph_token
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Backup Dismissed Tasks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: repo-file-writes
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
backup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch dismissed task IDs from OrgMyLife
|
||||
run: |
|
||||
# Get all dismissed and completed tasks (their source_ids)
|
||||
RESPONSE=$(curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
"https://api.andreknie.de/api/tasks/dismissed")
|
||||
echo "$RESPONSE" > dismissed_tasks.json
|
||||
cat dismissed_tasks.json | head -c 200
|
||||
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add dismissed_tasks.json
|
||||
git diff --cached --quiet && echo "No changes" && exit 0
|
||||
git commit -m "chore: backup dismissed task IDs [skip ci]"
|
||||
git pull --rebase origin main || true
|
||||
git push
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Mark Issues for Review
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'DONE_ISSUES.md'
|
||||
|
||||
concurrency:
|
||||
group: repo-file-writes
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
mark-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mark issues as ready for review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const content = fs.readFileSync('DONE_ISSUES.md', 'utf8');
|
||||
const issueNumbers = [...content.matchAll(/#(\d+)/g)].map(m => parseInt(m[1]));
|
||||
|
||||
if (issueNumbers.length === 0) {
|
||||
console.log('No issue numbers found in DONE_ISSUES.md');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const num of issueNumbers) {
|
||||
try {
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: num,
|
||||
});
|
||||
|
||||
if (issue.data.state === 'open') {
|
||||
// Add review label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: num,
|
||||
labels: ['ready-for-review'],
|
||||
});
|
||||
|
||||
// Add a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: num,
|
||||
body: '✅ Implementation complete and deployed. Ready for your review.\n\nClose this issue when you\'re satisfied with the result.',
|
||||
});
|
||||
|
||||
console.log(`Marked #${num} as ready-for-review`);
|
||||
} else {
|
||||
console.log(`Issue #${num} already closed.`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error processing #${num}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the file after processing
|
||||
fs.writeFileSync('DONE_ISSUES.md', '# Done Issues\n\n_Empty — no issues to process._\n');
|
||||
|
||||
- name: Commit cleared file
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add DONE_ISSUES.md
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: clear DONE_ISSUES.md after processing [skip ci]"
|
||||
git pull --rebase origin main || true
|
||||
git push
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Collect AI Tasks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync-tasks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch agent-ready tasks from OrgMyLife
|
||||
id: fetch
|
||||
run: |
|
||||
curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
"https://api.andreknie.de/api/orchestrator/tasks" > /tmp/tasks.json
|
||||
cat /tmp/tasks.json | head -c 500
|
||||
|
||||
- name: Create GitHub Issues for new tasks
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const data = JSON.parse(fs.readFileSync('/tmp/tasks.json', 'utf8'));
|
||||
const tasks = data.tasks || [];
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No agent-ready tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIssues = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'ai-task',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existingTitles = new Set(
|
||||
existingIssues.data.map(i => i.title)
|
||||
);
|
||||
|
||||
for (const task of tasks) {
|
||||
const issueTitle = `[AI] ${task.identifier}: ${task.title}`;
|
||||
|
||||
if (existingTitles.has(issueTitle)) {
|
||||
console.log(`Skipping (already exists): ${issueTitle}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = [
|
||||
`**Task ID:** ${task.identifier}`,
|
||||
`**Priority:** ${task.priority || 'unset'}`,
|
||||
`**State:** ${task.state}`,
|
||||
task.due_date ? `**Due:** ${task.due_date}` : '',
|
||||
'',
|
||||
'## Description',
|
||||
task.description || '_No description provided._',
|
||||
'',
|
||||
'---',
|
||||
'_Auto-created from OrgMyLife task board._',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: issueTitle,
|
||||
body: body,
|
||||
labels: ['ai-task'],
|
||||
});
|
||||
|
||||
console.log(`Created issue: ${issueTitle}`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Update Task State on Issue Events
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed, labeled]
|
||||
|
||||
jobs:
|
||||
mark-review:
|
||||
if: |
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'ready-for-review' &&
|
||||
contains(github.event.issue.labels.*.name, 'ai-task')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark task as review in OrgMyLife
|
||||
run: |
|
||||
TITLE="${{ github.event.issue.title }}"
|
||||
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
|
||||
|
||||
if [ -z "$TASK_ID" ]; then
|
||||
echo "Could not extract task ID from: $TITLE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Marking task $TASK_ID as review..."
|
||||
|
||||
curl -s -X POST \
|
||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "review"}' \
|
||||
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
|
||||
|
||||
mark-done:
|
||||
if: |
|
||||
github.event.action == 'closed' &&
|
||||
contains(github.event.issue.labels.*.name, 'ai-task')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark task as completed in OrgMyLife
|
||||
run: |
|
||||
TITLE="${{ github.event.issue.title }}"
|
||||
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
|
||||
|
||||
if [ -z "$TASK_ID" ]; then
|
||||
echo "Could not extract task ID from: $TITLE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Marking task $TASK_ID as completed..."
|
||||
|
||||
curl -s -X POST \
|
||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "completed"}' \
|
||||
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
name: Deploy to VPS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Write .env file to VPS
|
||||
uses: appleboy/ssh-action@v1
|
||||
env:
|
||||
ENV_CONTENT: |
|
||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
API_SECRET=${{ secrets.ENV_API_SECRET }}
|
||||
APP_USERNAME=${{ secrets.ENV_APP_USERNAME }}
|
||||
APP_PASSWORD=${{ secrets.ENV_APP_PASSWORD }}
|
||||
NEXTCLOUD_URL=${{ secrets.ENV_NEXTCLOUD_URL }}
|
||||
NEXTCLOUD_USERNAME=${{ secrets.ENV_NEXTCLOUD_USERNAME }}
|
||||
NEXTCLOUD_PASSWORD=${{ secrets.ENV_NEXTCLOUD_PASSWORD }}
|
||||
IMAP_SERVER=${{ secrets.ENV_IMAP_SERVER }}
|
||||
IMAP_PORT=993
|
||||
EMAIL_USERNAME=${{ secrets.ENV_EMAIL_USERNAME }}
|
||||
EMAIL_PASSWORD=${{ secrets.ENV_EMAIL_PASSWORD }}
|
||||
EMAIL_WEBMAIL_URL=${{ secrets.ENV_EMAIL_WEBMAIL_URL }}
|
||||
GMAIL_USERNAME=${{ secrets.ENV_GMAIL_USERNAME }}
|
||||
GMAIL_PASSWORD=${{ secrets.ENV_GMAIL_PASSWORD }}
|
||||
with:
|
||||
host: 217.160.174.2
|
||||
username: root
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
envs: ENV_CONTENT
|
||||
script: |
|
||||
# Ensure directory exists
|
||||
if [ ! -d /opt/OrgMyLife ]; then
|
||||
cd /opt
|
||||
git clone https://github.com/DoctoDre/OrgMyLife.git || true
|
||||
fi
|
||||
|
||||
cd /opt/OrgMyLife
|
||||
git pull origin main || true
|
||||
|
||||
# Write .env from environment variable (safe for special chars)
|
||||
printf '%s\n' "$ENV_CONTENT" > .env
|
||||
|
||||
docker compose up -d --build
|
||||
echo "Deploy complete"
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Sync Issues to File
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch: {}
|
||||
issues:
|
||||
types: [opened, closed, reopened, edited, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: repo-file-writes
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Write open AI issues to file
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const issues = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
labels: 'ai-task',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let content = '# AI Tasks (auto-generated)\n\n';
|
||||
content += `_Last updated: ${new Date().toISOString()}_\n\n`;
|
||||
|
||||
if (issues.data.length === 0) {
|
||||
content += 'No open AI tasks.\n';
|
||||
} else {
|
||||
content += `${issues.data.length} open task(s):\n\n`;
|
||||
for (const issue of issues.data) {
|
||||
content += `---\n\n`;
|
||||
content += `## #${issue.number}: ${issue.title}\n\n`;
|
||||
content += `- **State:** open\n`;
|
||||
content += `- **Created:** ${issue.created_at}\n`;
|
||||
content += `- **URL:** ${issue.html_url}\n\n`;
|
||||
content += `### Description\n\n`;
|
||||
content += `${issue.body || '_No description._'}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync('AI_TASKS.md', content);
|
||||
console.log(`Wrote ${issues.data.length} issues to AI_TASKS.md`);
|
||||
|
||||
- name: Commit and push if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add AI_TASKS.md
|
||||
git diff --cached --quiet && echo "No changes" && exit 0
|
||||
git commit -m "chore: update AI_TASKS.md [skip ci]"
|
||||
git pull --rebase origin main || true
|
||||
git push
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Sync OrgMyLife Plan to Projects Board
|
||||
|
||||
on:
|
||||
push:
|
||||
paths: ['PLAN.md']
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse open tasks and sync to Projects board
|
||||
run: |
|
||||
REPO_NAME="OrgMyLife"
|
||||
|
||||
TASKS="[]"
|
||||
if [ -f "PLAN.md" ]; then
|
||||
ITEMS=$(grep -E '^\s*- \[ \]' PLAN.md | sed 's/.*- \[ \] //' | head -50)
|
||||
if [ -n "$ITEMS" ]; then
|
||||
TASKS=$(echo "$ITEMS" | jq -R -s 'split("\n") | map(select(length > 0)) | map({"title": ., "status": "backlog"})')
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Found tasks: $TASKS"
|
||||
|
||||
PAYLOAD=$(jq -n --arg repo "$REPO_NAME" --argjson tasks "$TASKS" '{"repo": $repo, "tasks": $tasks}')
|
||||
|
||||
curl -s -X POST \
|
||||
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
|
||||
-H "X-API-Key: ${{ secrets.ORGMYLIFE_API_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"https://api.andreknie.de/api/projects/sync"
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
inclusion: auto
|
||||
---
|
||||
|
||||
# OrgMyLife Standards
|
||||
|
||||
## Quality Gates (before every commit)
|
||||
|
||||
1. `gitleaks detect --source . --no-git` — no secrets in code
|
||||
2. `ruff check .` — linting passes (if configured)
|
||||
3. `pytest` — all tests pass
|
||||
4. No hardcoded secrets in source
|
||||
|
||||
## Conventional Commits
|
||||
|
||||
```
|
||||
feat(sync): add Gmail label filtering
|
||||
fix(api): handle empty task list gracefully
|
||||
test(digest): add test for overdue calculation
|
||||
docs(spec): update component descriptions
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- pytest for unit and integration tests
|
||||
- httpx TestClient for API endpoint tests
|
||||
- Given-When-Then structure
|
||||
- 80% coverage target for new code
|
||||
|
||||
## Autonomous Mode
|
||||
|
||||
- Never push directly to main — use feature branches
|
||||
- Create PRs on GitHub targeting main
|
||||
- If stuck after 3 attempts: stop and document
|
||||
- Report: `PROGRESS: X%` / `HELP: description`
|
||||
|
||||
## Architecture
|
||||
|
||||
- FastAPI + SQLAlchemy + PostgreSQL
|
||||
- Alembic for migrations
|
||||
- MCP server for Kiro integration
|
||||
- Sync services: Nextcloud (CalDAV/ToDo), Gmail (IMAP), Backlog.md
|
||||
|
||||
## Deployment
|
||||
|
||||
- Docker on VPS (https://api.andreknie.de)
|
||||
- `docker compose up -d --build` for deploy
|
||||
- .env file on VPS for secrets (not in repo)
|
||||
@@ -0,0 +1,122 @@
|
||||
# AGENTS.md
|
||||
|
||||
---
|
||||
|
||||
## Coding rules
|
||||
|
||||
1. Read `SPEC.md` first and treat it as source of truth ("the spec"). If `SPEC.md` does not exist, **stop and ask**.
|
||||
2. Follow the user prompt exactly; do not omit explicitly requested steps.
|
||||
3. **Avoid over-engineering.** Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused:
|
||||
- Scope: Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability.
|
||||
- Documentation: Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.
|
||||
- Defensive coding: Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs).
|
||||
- Abstractions: Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task.
|
||||
- Custom coding: Don't reinvent the wheel, use the functions the framework provides you with. If you think that using framework is not possible, consult the relevant online or MCP docs to make sure. If you still cannot find a solution within the framework, **stop and ask**.
|
||||
4. Do not hard-code values or create solutions that only work for specific test inputs; implement the actual logic that solves the problem generally.
|
||||
5. The resulting code must be robust, maintainable, and extendable.
|
||||
6. The resulting code must not contradict the spec.
|
||||
7. **Before writing code**, post a short and concise "Plan + spec mapping" summary and **ask for approval to proceed**. Do not implement until you receive the go-ahead.
|
||||
8. Documentation Rules:
|
||||
- filenames are always capitalized.
|
||||
- describes the **current state only**. Do not include change/history phrasing.
|
||||
- must always be coherent end-to-end never have stale or conflicting info.
|
||||
9. Always use repo virtual environment (unless there is none).
|
||||
|
||||
---
|
||||
|
||||
## Documentation MCP policy
|
||||
|
||||
- We run ONE docs MCP server: `docs` (multi-library).
|
||||
|
||||
### Library selection
|
||||
- For any docs query call (everything except `list_libraries`), you MUST set `library`.
|
||||
- If the prompt or repo context involves a specific product/project unambiguously, use that as `library` (e.g. `library="haystack"`).
|
||||
- If more than one library could plausibly match, call `list_libraries` and select the best match (do not invent/guess library ids).
|
||||
- If the task spans multiple products, run separate docs calls per library and label results by library.
|
||||
|
||||
### Version selection
|
||||
- If the prompt or repo context involves a version or range (e.g. `2.25`, `2.x`), call `find_version(library=..., targetVersion=...)` and record the returned `version`.
|
||||
- Use that resolved `version` for all subsequent calls for that library.
|
||||
- If `find_version` cannot resolve unambiguously, **stop and ask**.
|
||||
- If no version is mentioned, omit `version` (defaults to latest indexed for that library).
|
||||
|
||||
- We also run the `github` MCP server. Consult it always for code that is in public github instead of searching at the disk or cloning the repos locally.
|
||||
|
||||
---
|
||||
## Base branch rule
|
||||
|
||||
**BASE_BRANCH = the branch that new work branches are created from and PRs target.**
|
||||
|
||||
Default:
|
||||
- If the user did not explicitly specify a base/target branch, set:
|
||||
- `BASE_BRANCH=$(git branch --show-current)` (from the user's main checkout at session start)
|
||||
|
||||
Override:
|
||||
- If the user explicitly specifies a base/target branch in the prompt, use that as `BASE_BRANCH`.
|
||||
|
||||
Remote base ref:
|
||||
- If `origin/$BASE_BRANCH` exists, use that as `BASE_REF`, else use `$BASE_BRANCH`:
|
||||
- `git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH" && BASE_REF="origin/$BASE_BRANCH" || BASE_REF="$BASE_BRANCH"`
|
||||
|
||||
Notes:
|
||||
- This supports long-lived feature integration branches and stacked PRs.
|
||||
- The base branch may itself be another PR branch (for stacking); set BASE_BRANCH accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Before work
|
||||
1. Determine base branch (unless user specified it explicitly):
|
||||
- `BASE_BRANCH=$(git branch --show-current)`
|
||||
2. Fetch latest refs:
|
||||
- `git fetch origin --prune`
|
||||
3. Update base branch (fast-forward only) if it has an `origin/` tracking ref:
|
||||
- `git switch "$BASE_BRANCH"`
|
||||
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git pull --ff-only origin "$BASE_BRANCH"; fi`
|
||||
- If the pull fails for any reason, stop and ask.
|
||||
4. Read `SPEC.md` + `README.md` once per session. Re-read only if changed/unsure:
|
||||
- `git log -1 --oneline -- SPEC.md README.md`
|
||||
5. If deviating from `SPEC.md`: **stop and ask**.
|
||||
6. Create a new local work branch (once per session) **from BASE_BRANCH**. **ALWAYS push to the branch you created.**
|
||||
- `git switch -c "agent/<topic>-YYYYMMDD-<shortid>"`
|
||||
|
||||
## Tests
|
||||
- Run tests only if the prompt requests or `SPEC.md` requires them; run all requested tests.
|
||||
- If any cannot be run: **stop and ask**.
|
||||
|
||||
## After work
|
||||
1. Make sure you did not miss requested tests.
|
||||
2. If you are addressing a **remote github issue** with
|
||||
- "to-do" checklist: for **each** item, make sure your implementation is **fully complete** and:
|
||||
- if yes, check it off
|
||||
- if not, finish implementation and recheck.
|
||||
- "acceptance" checklist: for **each** item, make the acceptance criterion is **completely fulfilled** and:
|
||||
- if yes, check it off
|
||||
- if not, implement missing code and recheck.
|
||||
3. Update all relevant documentation appropriately.
|
||||
4. **Before making a commit**: Stop and ask user to `/review` → `Review uncommited changes`.
|
||||
5. Commit **only after** user confirms code review passed.
|
||||
6. Open a PR or use already opened PR
|
||||
- **use your created branch**.
|
||||
- **Target `BASE_BRANCH`**.
|
||||
- PR text requirements:
|
||||
- commands in backticks
|
||||
- real newlines (ANSI-C quoting/heredoc)
|
||||
- if addressing a **remote github issue**, reference it so that github can close automatically after merging.
|
||||
7. **Only after** PR is merged:
|
||||
- switch back to base branch and sync it
|
||||
- `git switch "$BASE_BRANCH"`
|
||||
- `git fetch origin --prune`
|
||||
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git reset --hard "origin/$BASE_BRANCH"; fi`
|
||||
- If the reset fails, **stop and ask**.
|
||||
- delete the local merged work branch
|
||||
|
||||
---
|
||||
|
||||
## Required completion checklist (final response)
|
||||
- [ ] Base/target branch used correctly (BASE_BRANCH)
|
||||
- [ ] **all** Coding rules followed
|
||||
- [ ] All requested tests run (commands + results), or blocked → asked
|
||||
- [ ] Documentation updated appropriately: **list updated files**
|
||||
- [ ] PR opened (summary + command log) targeting BASE_BRANCH
|
||||
- [ ] After merge (if applicable): base branch synced; merged branch deleted
|
||||
---
|
||||
@@ -0,0 +1,125 @@
|
||||
# AI Tasks (auto-generated)
|
||||
|
||||
_Last updated: 2026-05-21T04:53:17.650Z_
|
||||
|
||||
6 open task(s):
|
||||
|
||||
---
|
||||
|
||||
## #23: [AI] OML-460: Silverbullet frontend
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-15T19:59:08Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/23
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-460
|
||||
**Priority:** 2
|
||||
**State:** open
|
||||
**Due:** 2026-05-17T00:00:00+00:00
|
||||
## Description
|
||||
Ich brauche eine Suchfunktion, eine schnelle neue Notiz-Funktion, eine übersichtliche Darstellung der Notizen mit zeitlicher suche
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
---
|
||||
|
||||
## #22: [AI] OML-459: Call list entries bearbeitbar machen
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-15T18:01:57Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/22
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-459
|
||||
**Priority:** 4
|
||||
**State:** open
|
||||
**Due:** 2026-05-31T00:00:00+00:00
|
||||
## Description
|
||||
Calls nachträglich mit Informationen anreichern wäre sehr praktisch.
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
---
|
||||
|
||||
## #21: [AI] OML-458: Tasks als Basis für notes ermöglichen
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-15T18:01:57Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/21
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-458
|
||||
**Priority:** 3
|
||||
**State:** open
|
||||
**Due:** 2026-05-18T00:00:00+00:00
|
||||
## Description
|
||||
Neuer knopf im Taskboard an notes senden. Möglichkeit finden Duplikate zu vermeiden.
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
---
|
||||
|
||||
## #20: [AI] OML-457: Übertrage das aktuelle lernen aus den Agenten automatisch in notegraph
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-15T18:01:56Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/20
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-457
|
||||
**Priority:** 3
|
||||
**State:** open
|
||||
**Due:** 2026-05-18T00:00:00+00:00
|
||||
## Description
|
||||
Überlege, wie alle Agenten dazu enabled werden gewonnenes wissen in die notegraph zu bekommen. Die wesentlichen Specs und alles an knowledge sollte darin enthalten sein und beim Aufräumen auch dort abgelegt werden.
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
---
|
||||
|
||||
## #19: [AI] OML-394: Jury-Auswertungstabelle und Bewertungsbogen
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-14T09:14:38Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/19
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-394
|
||||
**Priority:** 4
|
||||
**State:** open
|
||||
## Description
|
||||
Lieber André,
|
||||
|
||||
wie besprochen Bewertungsbogen und Auswertungstabelle für die Jurysitzung.
|
||||
|
||||
Die Jurynamen und Top11 sind bereits eingetragen.
|
||||
|
||||
Melde dich jederzeit, wenn du noch etwas brauchst. Wäre cool, wenn du eine digitale Lösung schaffst. Sonst sind wir in diesem Jahr nochmal per Stift unt
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
---
|
||||
|
||||
## #18: [AI] OML-423: Supabase von kiq project entfernen
|
||||
|
||||
- **State:** open
|
||||
- **Created:** 2026-05-14T09:14:38Z
|
||||
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/18
|
||||
|
||||
### Description
|
||||
|
||||
**Task ID:** OML-423
|
||||
**Priority:** 2
|
||||
**State:** open
|
||||
**Due:** 2026-05-17T00:00:00+00:00
|
||||
## Description
|
||||
_No description provided._
|
||||
---
|
||||
_Auto-created from OrgMyLife task board._
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# ARCHITECTURE.md
|
||||
|
||||
## System Architecture
|
||||
|
||||
**Product:** OrgMyLife
|
||||
**Goal:** A self-sufficient personal coordination system aggregating life domains (calendars, emails, notes, tasks) with a tidy UI, prioritizing local control over external dependencies.
|
||||
|
||||
## Technology Stack
|
||||
- **Backend:** Python (e.g., FastAPI) - Chosen for self-sufficiency and ease of future AI/LLM integration.
|
||||
- **Database:** PostgreSQL (self-hosted/local).
|
||||
- **Frontend:** Standard web technologies (HTML/CSS/JS) - Focus on a tidy, clean, functional structure before implementing fancy designs.
|
||||
|
||||
## Integration Strategy
|
||||
Data aggregation will be implemented in the following order:
|
||||
1. **Nextcloud:** Initial proof of concept for calendar and notes aggregation.
|
||||
2. **Gmail:** Email aggregation and initial task identification.
|
||||
3. **Outlook:** Handled last due to specialized security and enterprise protection requirements.
|
||||
@@ -0,0 +1,27 @@
|
||||
| ToDo | Details | Ansprechpartner | Quelle | Deadline | Priorität | Umfang |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| Bewerbung OFK2 DB Regio | | | signal | 05.05.26 | hoch | 1h |
|
||||
| Angebot RWK | Thomas Fabich | Zettel/Gespräch | | 03.05.26 | hoch | |
|
||||
| CV Rotari | Josef | Email | | 03.05.26 | hoch | |
|
||||
| Rechnung Uni Kassel | Manu | | | 03.05.26 | hoch | |
|
||||
| Rechnung Schlachthof | | | | 03.05.26 | Mittel | |
|
||||
| Termine nächste Wochen klären | | | | 03.05.26 | hoch | |
|
||||
| Folien Digitalisierungsforum | | | | 03.05.26 | sehr hoch | |
|
||||
| Projekte UKS Anlegen | | | | zeitnah | | |
|
||||
| BCIS Anfragen Sales | | | | naechste Woche | | |
|
||||
| Mit Andrea weiteres Vorgehen klären | Andrea Schlachthof | | | naechste Woche | | |
|
||||
| Steuerberater bezahlen | | | | asap | | |
|
||||
| Podcast aufnehmen | Lasse | | | 03.05.26 | terminiert | |
|
||||
| KIQ Pitchdeck aufbauen | | | | asap | | |
|
||||
| KIQ Termin mit Deck 4 machen | | | | asap | | |
|
||||
| dHive Homepage | | | | asap | | |
|
||||
| Termin mit Alex über Zukunft machen | | | | Mai 26 | | |
|
||||
| Dhive Zukunft durchdenken | | | | asap | | |
|
||||
| Hautscreening Termin machen | | | | 2026 | | |
|
||||
| Augenarzt Termin machen | | | | Juni/Juli | | |
|
||||
| Vitamin D testen lassen | | | | Juni/Juli | | |
|
||||
| Orga Setup klären, am besten Haupteingangskanal Email | eigene Email adresse? | | | asap | | |
|
||||
| Zahnreinigung vereinbaren | | | | 2026 | | |
|
||||
| KanBan Board für Thanasis aufbauen | | | | Mai 26 | | |
|
||||
| Habil Plan machen | | | | Sommer 26 | | |
|
||||
| Buch Plan machen (KI, New Work (#Einfachbahn), Science Fiction) | | | | Sommer 26 | | |
|
||||
@@ -0,0 +1,24 @@
|
||||
# COMPONENTS.md
|
||||
|
||||
## Project Components
|
||||
*This document tracks all specific components used in the project.*
|
||||
|
||||
> **Rule:** Components are only added to this list once they have been actively included and utilized in the codebase.
|
||||
|
||||
### Backend Core
|
||||
- **FastAPI**: Main web framework for the API backend.
|
||||
- **Uvicorn**: ASGI web server implementation.
|
||||
- **SQLAlchemy**: SQL toolkit and Object-Relational Mapper (ORM).
|
||||
- **Alembic**: Database migration tool for SQLAlchemy.
|
||||
- **Psycopg2**: PostgreSQL database adapter for Python.
|
||||
- **caldav**: Library for CalDAV client functionality (Nextcloud sync).
|
||||
- **python-multipart**: Form data parsing for login page.
|
||||
- **python-dotenv**: Environment variable loading from .env files.
|
||||
|
||||
### Infrastructure
|
||||
- **Docker**: Containerization for app and database.
|
||||
- **PostgreSQL 16**: Primary database (Docker container with persistent volume).
|
||||
- **Caddy**: Reverse proxy with automatic HTTPS (Let's Encrypt).
|
||||
|
||||
### Frontend
|
||||
- **Vanilla HTML/CSS/JS**: No framework, clean and lightweight.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Deployment Guide — OrgMyLife on a VPS
|
||||
|
||||
## Architecture (Multi-Machine)
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────────────────┐
|
||||
│ Your Laptop │ │ VPS (e.g., IONOS) │
|
||||
│ ───────────── │ │ ────────────────── │
|
||||
│ AI-Orchestrator│────▶│ OrgMyLife (Docker) │
|
||||
│ (polls API) │ │ PostgreSQL (Docker) │
|
||||
│ │ │ https://api.knie.email │
|
||||
└─────────────────┘ └──────────────────────────────┘
|
||||
│ │
|
||||
│ │ syncs with
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌──────────────────────────────┐
|
||||
│ Other machines │ │ Nextcloud (existing) │
|
||||
│ (API clients) │ │ Calendar + ToDo persistence │
|
||||
└─────────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
## Option 1: IONOS VPS (Recommended — 3-5€/month)
|
||||
|
||||
### 1. Get a VPS
|
||||
|
||||
- Go to IONOS Cloud or VPS section
|
||||
- Pick the smallest Linux VPS (1 vCPU, 1GB RAM is enough)
|
||||
- Choose Ubuntu 22.04 or Debian 12
|
||||
- Note the IP address
|
||||
|
||||
### 2. Point your domain
|
||||
|
||||
Since you own `andreknie.de`, add a DNS record:
|
||||
- `A` record: `api.andreknie.de` → your VPS IP (`217.160.174.2`)
|
||||
|
||||
### 3. SSH into the VPS and install Docker
|
||||
|
||||
```bash
|
||||
ssh root@YOUR_VPS_IP
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Install docker-compose
|
||||
apt install docker-compose-plugin
|
||||
```
|
||||
|
||||
### 4. Deploy OrgMyLife
|
||||
|
||||
```bash
|
||||
# Clone your repo
|
||||
git clone https://github.com/DoctoDre/OrgMyLife.git
|
||||
cd OrgMyLife
|
||||
|
||||
# Create .env with your real credentials
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
# Fill in:
|
||||
# POSTGRES_PASSWORD=<strong random password>
|
||||
# API_SECRET=<strong random token for orchestrator auth>
|
||||
# NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD
|
||||
# IMAP_SERVER, EMAIL_USERNAME, EMAIL_PASSWORD
|
||||
|
||||
# Start everything
|
||||
docker compose up -d
|
||||
|
||||
# Check it's running
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
### 5. Add HTTPS with Caddy (reverse proxy)
|
||||
|
||||
```bash
|
||||
# Install Caddy
|
||||
apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
apt update && apt install caddy
|
||||
```
|
||||
|
||||
Create `/etc/caddy/Caddyfile`:
|
||||
```
|
||||
api.andreknie.de {
|
||||
reverse_proxy localhost:8000
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl restart caddy
|
||||
```
|
||||
|
||||
Caddy automatically gets a Let's Encrypt certificate. Your API is now at `https://api.andreknie.de`.
|
||||
|
||||
### 6. Configure AI-Orchestrator on your machines
|
||||
|
||||
Update `WORKFLOW.md` on any machine:
|
||||
```yaml
|
||||
tracker:
|
||||
kind: orgmylife
|
||||
endpoint: https://api.andreknie.de
|
||||
api_key: $ORGMYLIFE_API_SECRET
|
||||
```
|
||||
|
||||
Set the env var:
|
||||
```bash
|
||||
export ORGMYLIFE_API_SECRET=<same token you put in .env on the VPS>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Run Locally with Docker (No Server Needed)
|
||||
|
||||
If you just want multi-machine via your home network or Tailscale:
|
||||
|
||||
```bash
|
||||
cd OrgMyLife
|
||||
|
||||
# Add to your .env:
|
||||
# POSTGRES_PASSWORD=localdev
|
||||
# API_SECRET= (empty = no auth, fine for local)
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Access from other machines on your network at `http://YOUR_IP:8000`.
|
||||
|
||||
For access outside your network without a VPS, use **Tailscale** (free):
|
||||
- Install Tailscale on all your machines
|
||||
- Access OrgMyLife at `http://your-tailscale-ip:8000`
|
||||
|
||||
---
|
||||
|
||||
## Option 3: IONOS Managed Domain Only (No VPS)
|
||||
|
||||
If you really don't want a VPS yet, you can use a free tier service:
|
||||
|
||||
- **Railway.app** — free tier, deploy from GitHub, auto-HTTPS
|
||||
- **Render.com** — free tier with PostgreSQL
|
||||
- **Fly.io** — free tier, deploy Docker containers
|
||||
|
||||
These all support Docker and can connect to your `knie.email` domain via CNAME records.
|
||||
|
||||
---
|
||||
|
||||
## Nextcloud as Persistence Layer
|
||||
|
||||
Regardless of where OrgMyLife runs, Nextcloud stays the durable backend:
|
||||
|
||||
1. OrgMyLife syncs tasks FROM Nextcloud (import)
|
||||
2. OrgMyLife syncs changes BACK to Nextcloud (two-way)
|
||||
3. If OrgMyLife goes down, your tasks are still in Nextcloud
|
||||
4. If you rebuild OrgMyLife, just re-sync from Nextcloud
|
||||
|
||||
This means Nextcloud is your backup and your mobile access (Nextcloud Tasks app on phone).
|
||||
|
||||
---
|
||||
|
||||
## Periodic Sync (Cron)
|
||||
|
||||
Add automatic syncing so OrgMyLife stays fresh:
|
||||
|
||||
```bash
|
||||
# Add to crontab on the VPS (or as a Docker healthcheck)
|
||||
# Sync every 5 minutes
|
||||
*/5 * * * * curl -s -X POST http://localhost:8000/api/sync/nextcloud-todo
|
||||
*/5 * * * * curl -s -X POST http://localhost:8000/api/sync/email
|
||||
```
|
||||
|
||||
Or add a background scheduler inside the app (future enhancement).
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong `POSTGRES_PASSWORD` (use `openssl rand -hex 32`)
|
||||
- [ ] Strong `API_SECRET` (use `openssl rand -hex 32`)
|
||||
- [ ] HTTPS enabled (Caddy handles this automatically)
|
||||
- [ ] Firewall: only ports 80, 443, 22 open on VPS
|
||||
- [ ] Don't expose PostgreSQL port (5432) to the internet
|
||||
- [ ] Keep `.env` out of git (already in .gitignore)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Done Issues
|
||||
|
||||
_Empty — no issues to process._
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for psycopg2 and lxml
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directory for SQLite (if used)
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Expose the API port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD python -c "import httpx; r = httpx.get('http://localhost:8000/api/health'); assert r.status_code == 200"
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# PLAN.md
|
||||
|
||||
## Current Phase: Live & Operational
|
||||
|
||||
### Completed ✅
|
||||
- [x] Python backend (FastAPI) + PostgreSQL (Docker)
|
||||
- [x] Frontend (HTML/CSS/JS) with Dashboard, Tasks, Digest, Retro, Projects tabs
|
||||
- [x] Nextcloud Calendar sync (CalDAV, Berlin timezone)
|
||||
- [x] Nextcloud ToDo sync (two-way, own calendars only, respects local status)
|
||||
- [x] dHive Email/IMAP sync (IONOS, archive to Archiv/2026, delete to Papierkorb)
|
||||
- [x] Gmail sync (App Password, archive/delete both directions)
|
||||
- [x] Auto-sync every 5 minutes (background scheduler)
|
||||
- [x] Auto-dismiss emails archived/deleted externally
|
||||
- [x] Session-based login (7-day, DB-persistent, survives deploys)
|
||||
- [x] Task sorting (priority, due date, created, manual)
|
||||
- [x] Task filtering (by origin, by agent_ready)
|
||||
- [x] Delete (✕ dismiss/archive), Done (✓ complete/archive), Purge (🗑 trash/remove)
|
||||
- [x] Move to top/bottom arrows
|
||||
- [x] Due-date editing in UI
|
||||
- [x] Email sender shown below title
|
||||
- [x] Origin labels with clickable links to source app
|
||||
- [x] Prioritization Engine (rule-based suggestions on dashboard)
|
||||
- [x] Daily Digest page (overdue, due today, top priorities, schedule)
|
||||
- [x] Weekly Retrospective page (completed vs created, backlog health)
|
||||
- [x] Projects Kanban board (6 columns, multi-repo sync)
|
||||
- [x] AI-Orchestrator with OrgMyLife tracker adapter
|
||||
- [x] GitHub Actions auto-deploy (push → VPS)
|
||||
- [x] AI task pipeline (🤖 → GitHub Issue → implement → review → close)
|
||||
- [x] Dismissed tasks backup (survives VPS resets)
|
||||
- [x] Multi-repo project sync (PLAN.md → Projects board)
|
||||
- [x] Confluence Bot standalone repo
|
||||
- [x] Mobile-responsive layout
|
||||
- [x] Input validation and security fixes
|
||||
- [x] Implement Call List feature (📞 tab, phone number extraction, daily digest integration)
|
||||
|
||||
### Short-Term Goals (Next Session)
|
||||
- [ ] Fix Git Action sync error (race condition on DONE_ISSUES.md)
|
||||
- [ ] Verify Gmail archive works end-to-end after latest fix
|
||||
- [ ] Add DB GitLab (project-audit) sync via GitLab CI
|
||||
|
||||
### Medium-Term Goals
|
||||
- [ ] Add conflict resolution for two-way sync
|
||||
- [ ] Add test coverage (sync services, API endpoints)
|
||||
- [ ] Implement task labels/tags system
|
||||
- [ ] Add Prometheus metrics / structured JSON logging
|
||||
|
||||
### Long-Term Goals
|
||||
- [ ] Integrate Outlook (enterprise security constraints)
|
||||
- [ ] Implement Knowledge Graph / Notes Organization
|
||||
- [ ] Develop Availability Sharing for external booking
|
||||
- [ ] Support multi-user roles (family, colleagues)
|
||||
@@ -0,0 +1,10 @@
|
||||
# RULES.md
|
||||
|
||||
## Foundational Rules
|
||||
1. **No documents outside the folder:** All project-related documents and files must reside strictly within this repository folder.
|
||||
2. **Keep documentation concise:** Documentation should be brief, clear, and to the point.
|
||||
3. **Single source of truth:** Use single, dedicated documents for specific types of information to avoid fragmentation and redundancy.
|
||||
- Use `ARCHITECTURE.md` for all architectural information.
|
||||
- Use `PLAN.md` for ongoing steps, which must be revised after each PR.
|
||||
- Use `COMPONENTS.md` to track all specific components used in the project.
|
||||
4. **Document Components After Inclusion:** Only add components to `COMPONENTS.md` once you have actually included them in the code.
|
||||
@@ -0,0 +1,355 @@
|
||||
# SPEC.md
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
**Product Name:** OrgMyLife
|
||||
**Stakeholder Role:** Developer and primary user
|
||||
**Product Type:** New product
|
||||
|
||||
**Goal (one sentence):**
|
||||
Create a personal coordination system that aggregates life domains, proposes prioritized actions, and reduces mental load while increasing quality of life.
|
||||
|
||||
**Business Context:**
|
||||
The stakeholder manages multiple professional roles (DB, dHive), personal life, and commitments across fragmented tools (calendars, emails, notes, task systems), leading to coordination overhead and missed prioritization.
|
||||
|
||||
**Primary Target Users:**
|
||||
|
||||
* Initially: single primary user (self)
|
||||
* Later: family members and dHive colleagues
|
||||
|
||||
**Core Functional Scope Summary:**
|
||||
The system consolidates calendars, emails, notes, and task sources, identifies actionable items, proposes prioritization, and interacts with the user through daily digests and weekly retrospectives. It supports user-confirmed execution of actions such as scheduling, task creation, and restructuring of information.
|
||||
|
||||
**In Scope:**
|
||||
|
||||
* Aggregation of multiple calendars, email systems, and note sources
|
||||
* Central task identification and management
|
||||
* Suggestion-driven prioritization
|
||||
* Daily and weekly guided interactions
|
||||
* User-confirmed execution of planning actions
|
||||
* Visibility into key planning data (tasks, deadlines, conflicts, priorities)
|
||||
|
||||
**Out of Scope:**
|
||||
|
||||
* Fully autonomous decision-making without user confirmation
|
||||
* Technical implementation details
|
||||
* Final definition of tool-specific integration rules (to be defined later)
|
||||
|
||||
**Main Open Questions:**
|
||||
|
||||
* Role-specific differences for future users (family, colleagues)
|
||||
* Exact definition of task lifecycle and statuses
|
||||
* Level of automation for time tracking analysis
|
||||
* Boundaries of knowledge graph usage and structure
|
||||
* Handling of sensitive/private calendar data across contexts
|
||||
|
||||
**Main Acceptance Indicators:**
|
||||
|
||||
* Reduced perceived mental load
|
||||
* Increased feeling of control
|
||||
* Regular engagement with daily and weekly flows
|
||||
* Fewer forgotten tasks and missed priorities
|
||||
* Measurable increase in time for sport and personal life
|
||||
|
||||
---
|
||||
|
||||
## 2. Goal
|
||||
|
||||
**One-sentence goal:**
|
||||
Reduce coordination overhead by centralizing, structuring, and actively guiding personal and professional planning.
|
||||
|
||||
**Detailed goal:**
|
||||
OrgMyLife aims to unify fragmented information sources (calendars, emails, notes, task systems) into a single coordination layer that not only aggregates data but actively assists in prioritization and execution. The system should act as a proactive assistant that continuously surfaces relevant actions, identifies conflicts, and guides the user through structured planning and reflection routines.
|
||||
|
||||
**Business value:**
|
||||
|
||||
* Increased productivity through better prioritization
|
||||
* Reduced manual coordination effort
|
||||
* Improved work-life balance
|
||||
* Better utilization of time and energy
|
||||
|
||||
**Intended outcome:**
|
||||
Users experience clarity, control, and reduced cognitive load while maintaining or increasing output quality and personal well-being.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stakeholder Context
|
||||
|
||||
* **Respondent role:** Developer and primary user
|
||||
* **Perspective:** Individual productivity and life management
|
||||
* **Context:** Multiple jobs (DB, dHive), family life, sports, volunteering
|
||||
* **Scope:** This specification reflects a single stakeholder interview
|
||||
|
||||
---
|
||||
|
||||
## 4. Target Users
|
||||
|
||||
### Primary User
|
||||
|
||||
* Individual managing multiple life domains
|
||||
* Needs centralized visibility and guidance
|
||||
* Experiences fragmentation and overload
|
||||
|
||||
### Future Users
|
||||
|
||||
* Family members
|
||||
* dHive colleagues
|
||||
|
||||
### Needs
|
||||
|
||||
* Clear prioritization
|
||||
* Reliable overview of commitments
|
||||
* Reduced manual coordination
|
||||
* Guided planning routines
|
||||
|
||||
### Pain Points
|
||||
|
||||
* Fragmented tools and data sources
|
||||
* Missing synchronization
|
||||
* Forgotten tasks
|
||||
* Lack of centralized task management
|
||||
* No structured prioritization process
|
||||
* Insufficient planning for personal time (e.g., sports)
|
||||
|
||||
### Differences
|
||||
|
||||
* Primary user: full control and configuration
|
||||
* Future users: likely limited visibility and permissions (to be defined)
|
||||
|
||||
---
|
||||
|
||||
## 5. Current Situation / Current Process
|
||||
|
||||
### Current Setup
|
||||
|
||||
* Multiple calendars: Outlook (DB), Google (private/family), Nextcloud (dHive)
|
||||
* Multiple email systems: Outlook 365, Gmail, Nextcloud, GMX
|
||||
* Notes across various systems (including Confluence, Jira, loose notes)
|
||||
* Mattermost used without structured task management
|
||||
* Separate time tracking application
|
||||
|
||||
### Workarounds
|
||||
|
||||
* Manual tracking and coordination
|
||||
* Ad-hoc prioritization
|
||||
* Informal note-taking
|
||||
|
||||
### Main Gaps
|
||||
|
||||
* Incomplete or broken synchronization
|
||||
* Lack of central task list
|
||||
* Missing buffer times
|
||||
* No systematic prioritization
|
||||
* No analysis of time tracking data
|
||||
* High manual effort
|
||||
|
||||
### Failure Points
|
||||
|
||||
* Tasks forgotten
|
||||
* Deadlines missed or recognized too late
|
||||
* Conflicting or invisible calendar entries
|
||||
* Under-planning of personal priorities (e.g., sports)
|
||||
|
||||
---
|
||||
|
||||
## 6. Functional Scope
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
1. **Aggregation Layer**
|
||||
|
||||
* Combine multiple calendars, email systems, and note sources
|
||||
* Provide unified visibility
|
||||
|
||||
2. **Task Identification**
|
||||
|
||||
* Extract tasks from emails and notes
|
||||
* Consolidate into a central task system
|
||||
|
||||
3. **Prioritization Support**
|
||||
|
||||
* Suggest prioritization based on:
|
||||
|
||||
* urgency
|
||||
* importance hierarchy (family > DB/dHive critical > customer potential > personal interest)
|
||||
* energy level
|
||||
|
||||
4. **Planning Assistance**
|
||||
|
||||
* Suggest scheduling (e.g., sport, tasks, buffer times)
|
||||
* Detect conflicts and gaps
|
||||
|
||||
5. **Interactive Guidance**
|
||||
|
||||
* Daily digest:
|
||||
|
||||
* overview of the day
|
||||
* new important items
|
||||
* Weekly retrospective:
|
||||
|
||||
* structured reflection
|
||||
* prioritization review
|
||||
|
||||
6. **Action Execution (User-confirmed)**
|
||||
|
||||
* Create/modify calendar events
|
||||
* Create and manage tasks
|
||||
* Block time slots
|
||||
* Send invitations
|
||||
* Organize notes and maintain knowledge graph
|
||||
|
||||
7. **Availability Sharing**
|
||||
|
||||
* Provide selected free time slots for external booking
|
||||
|
||||
8. **Transparency Layer**
|
||||
|
||||
* Show:
|
||||
|
||||
* full calendar overview
|
||||
* open tasks
|
||||
* top 5 priorities
|
||||
* deadlines
|
||||
* conflicts
|
||||
* origin of suggestions
|
||||
|
||||
---
|
||||
|
||||
## 7. Business Rules
|
||||
|
||||
* The system must not act autonomously without user confirmation
|
||||
* All suggested actions require explicit approval
|
||||
* Exceptions: explicitly commanded actions may be executed directly
|
||||
* All rules must be visible and modifiable by the user
|
||||
* The system should actively ask clarifying questions when ambiguity exists
|
||||
* Prioritization follows defined hierarchy but allows subjective override
|
||||
* The system must explain its suggestions (traceability of origin)
|
||||
|
||||
---
|
||||
|
||||
## 8. Business Objects / Functional Data Objects
|
||||
|
||||
* **Task**
|
||||
|
||||
* Represents actionable work item
|
||||
* Has priority, status, origin
|
||||
|
||||
* **Calendar Event**
|
||||
|
||||
* Represents scheduled commitment
|
||||
* May originate from different systems
|
||||
|
||||
* **Time Slot**
|
||||
|
||||
* Represents available or blocked time
|
||||
|
||||
* **Suggestion**
|
||||
|
||||
* System-generated proposal (e.g., reschedule, prioritize)
|
||||
* Includes explanation/source
|
||||
|
||||
* **Reminder**
|
||||
|
||||
* Unscheduled but relevant item
|
||||
|
||||
* **Note / Knowledge Object**
|
||||
|
||||
* Structured or unstructured information
|
||||
* Connected via knowledge graph
|
||||
|
||||
* **Priority Item (Top 5)**
|
||||
|
||||
* High-level focus topics
|
||||
|
||||
---
|
||||
|
||||
## 9. In Scope
|
||||
|
||||
* Multi-source aggregation
|
||||
* Central task management
|
||||
* Suggestion-based prioritization
|
||||
* Daily and weekly interaction formats
|
||||
* User-controlled execution of actions
|
||||
* Conflict detection and visibility
|
||||
* Knowledge organization support
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope
|
||||
|
||||
* Fully autonomous system behavior
|
||||
* Technical integration details
|
||||
* Final definition of all external system rules
|
||||
* Detailed UI design specifications
|
||||
|
||||
---
|
||||
|
||||
## 11. Requirements
|
||||
|
||||
1. The system must aggregate multiple calendars into a unified view.
|
||||
2. The system must aggregate inputs from multiple email systems.
|
||||
3. The system must identify potential tasks from incoming information.
|
||||
4. The system must provide a central task list.
|
||||
5. The system must suggest prioritization of tasks based on defined criteria.
|
||||
6. The system must allow user override of all prioritization decisions.
|
||||
7. The system must provide a daily digest of relevant items.
|
||||
8. The system must conduct a weekly retrospective in an interactive format.
|
||||
9. The system must propose scheduling actions (e.g., tasks, sports, buffers).
|
||||
10. The system must detect and highlight conflicts across calendars.
|
||||
11. The system must display key information (tasks, deadlines, priorities, conflicts).
|
||||
12. The system must explain the origin of suggestions.
|
||||
13. The system must require user confirmation before executing actions.
|
||||
14. The system must allow execution of user-approved actions (e.g., scheduling, task creation).
|
||||
15. The system must support availability sharing for external booking.
|
||||
16. The system must organize notes and maintain a knowledge structure.
|
||||
17. The system must ask clarifying questions when data is ambiguous.
|
||||
18. The system must allow rules to be viewed and modified.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions / Items to Clarify
|
||||
|
||||
* How should task statuses and lifecycle be defined?
|
||||
* What level of automation is expected for time tracking analysis?
|
||||
* How should different user roles (family, colleagues) interact with the system?
|
||||
* What data privacy boundaries exist between contexts (work vs private)?
|
||||
* How detailed should the knowledge graph be?
|
||||
* How should duplicate or conflicting tasks be resolved?
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks and Ambiguities
|
||||
|
||||
* Ambiguity in prioritization logic (subjective vs rule-based)
|
||||
* Undefined role model for future multi-user usage
|
||||
* Potential overload from too many suggestions or questions
|
||||
* Integration limitations affecting completeness of data
|
||||
* Risk of user distrust if suggestions are not transparent or accurate
|
||||
|
||||
---
|
||||
|
||||
## 14. Acceptance Perspective
|
||||
|
||||
The product is successful if:
|
||||
|
||||
* The user feels in control of their commitments
|
||||
* The system is used daily and weekly as intended
|
||||
* Fewer tasks are forgotten or delayed
|
||||
* Planning effort is significantly reduced
|
||||
* The user gains measurable time for personal priorities (e.g., sports, family)
|
||||
* The backlog no longer creates mental stress
|
||||
|
||||
---
|
||||
|
||||
## 15. Glossary
|
||||
|
||||
| Original Term | English Explanation | Notes |
|
||||
| ----------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Priorisierung | Prioritization | Based on subjective and rule-based factors |
|
||||
| Bauchgefühl | Gut feeling | Subjective decision influence |
|
||||
| Knowledge Graph | Structured knowledge network | Used for notes and relationships |
|
||||
| Retro | Retrospective | Weekly reflection format |
|
||||
| Mental Load | Cognitive burden | Key problem to reduce |
|
||||
| Backlogjonglage | Managing backlog overload | Informal term for task overload |
|
||||
| Zeiterfassungsapp | Time tracking application | Currently underutilized |
|
||||
| Mattermost | Team communication tool | Used without structured task management |
|
||||
@@ -0,0 +1,22 @@
|
||||
# STARTFRESH.md
|
||||
|
||||
## General Approach to New Projects
|
||||
|
||||
This document serves as a template and set of guiding principles for starting any new project, capturing the preferred methodology and structural rules.
|
||||
|
||||
### 1. Design & Architecture Philosophy
|
||||
- **Self-Sufficiency:** Minimize external dependencies. Prefer tech stacks that offer local control and self-hosting capabilities (e.g., Python backend, PostgreSQL database).
|
||||
- **Tidy First, Fancy Later:** Focus initially on clean structures, functional layouts, and standard technologies. Complex visuals and design polish should only be added once the foundational logic is proven.
|
||||
|
||||
### 2. Repository Rules
|
||||
- **Containment:** No documents outside the folder. All project-related documents, notes, and files must reside strictly within the repository.
|
||||
- **Conciseness:** Keep documentation brief, clear, and to the point.
|
||||
- **Single Source of Truth:** Use single, dedicated documents for specific types of information to avoid fragmentation.
|
||||
|
||||
### 3. Required Foundational Documents
|
||||
Every new project should initialize with the following document structure:
|
||||
|
||||
* **`RULES.md`**: To declare foundational repository and workflow rules.
|
||||
* **`ARCHITECTURE.md`**: To collect all architectural decisions, technology stack choices, and integration strategies.
|
||||
* **`PLAN.md`**: To track ongoing steps, categorized by short, medium, and long-term goals. *Must be revised after each PR.*
|
||||
* **`COMPONENTS.md`**: To track specific components used in the project. *Rule: Only add components to this document once they have been actively included and utilized in the codebase.*
|
||||
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,83 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from app.models import Base
|
||||
from app.db.session import DATABASE_URL
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Add agent_ready and labels columns to tasks
|
||||
|
||||
Revision ID: 003
|
||||
Revises:
|
||||
Create Date: 2026-05-04
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '003'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tasks', sa.Column('agent_ready', sa.Boolean(), nullable=True, server_default='false'))
|
||||
op.add_column('tasks', sa.Column('labels', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tasks', 'labels')
|
||||
op.drop_column('tasks', 'agent_ready')
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Add source_url column to tasks
|
||||
|
||||
Revision ID: 004
|
||||
Create Date: 2026-05-06
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tasks', sa.Column('source_url', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tasks', 'source_url')
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add call_items table
|
||||
|
||||
Revision ID: 005
|
||||
Create Date: 2026-05-10
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '005'
|
||||
down_revision = '004'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('call_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('person', sa.String(200), nullable=False),
|
||||
sa.Column('reason', sa.String(500), nullable=False),
|
||||
sa.Column('phone_number', sa.String(30), nullable=True),
|
||||
sa.Column('original_task_id', sa.Integer(), nullable=True),
|
||||
sa.Column('priority', sa.Integer(), server_default='3'),
|
||||
sa.Column('status', sa.String(), server_default="'pending'"),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
|
||||
sa.ForeignKeyConstraint(['original_task_id'], ['tasks.id']),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_call_items_id'), 'call_items', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_call_items_status'), 'call_items', ['status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_call_items_status'), table_name='call_items')
|
||||
op.drop_index(op.f('ix_call_items_id'), table_name='call_items')
|
||||
op.drop_table('call_items')
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Add hidden_until column to tasks table
|
||||
|
||||
Revision ID: 006
|
||||
Create Date: 2026-05-13
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '006'
|
||||
down_revision = '005'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tasks', sa.Column('hidden_until', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tasks', 'hidden_until')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add conflict resolution columns to tasks table
|
||||
|
||||
Revision ID: 007
|
||||
Create Date: 2026-05-20
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tasks', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('tasks', sa.Column('has_conflict', sa.Boolean(), server_default='0', nullable=True))
|
||||
op.add_column('tasks', sa.Column('conflict_data', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tasks', 'conflict_data')
|
||||
op.drop_column('tasks', 'has_conflict')
|
||||
op.drop_column('tasks', 'last_synced_at')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_position_to_tasks
|
||||
|
||||
Revision ID: 0ce1353d9433
|
||||
Revises: 9d39809cb00a
|
||||
Create Date: 2026-05-03 11:45:56.407583
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0ce1353d9433'
|
||||
down_revision: Union[str, Sequence[str], None] = '9d39809cb00a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('tasks', sa.Column('position', sa.Integer(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('tasks', 'position')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add_source_id_to_tasks
|
||||
|
||||
Revision ID: 28a023783522
|
||||
Revises: 0ce1353d9433
|
||||
Create Date: 2026-05-03 21:42:57.678532
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '28a023783522'
|
||||
down_revision: Union[str, Sequence[str], None] = '0ce1353d9433'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('tasks', sa.Column('source_id', sa.String(), nullable=True))
|
||||
op.create_index(op.f('ix_tasks_source_id'), 'tasks', ['source_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_tasks_source_id'), table_name='tasks')
|
||||
op.drop_column('tasks', 'source_id')
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Initial DB
|
||||
|
||||
Revision ID: 9d39809cb00a
|
||||
Revises:
|
||||
Create Date: 2026-04-29 23:49:47.777582
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9d39809cb00a'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('end_time', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('source', sa.String(), nullable=False),
|
||||
sa.Column('is_conflict', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_events_title'), 'events', ['title'], unique=False)
|
||||
op.create_table('suggestions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('content', sa.String(), nullable=False),
|
||||
sa.Column('explanation', sa.Text(), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_suggestions_id'), 'suggestions', ['id'], unique=False)
|
||||
op.create_table('tasks',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('priority', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('origin', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('due_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tasks_title'), 'tasks', ['title'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_tasks_title'), table_name='tasks')
|
||||
op.drop_index(op.f('ix_tasks_id'), table_name='tasks')
|
||||
op.drop_table('tasks')
|
||||
op.drop_index(op.f('ix_suggestions_id'), table_name='suggestions')
|
||||
op.drop_table('suggestions')
|
||||
op.drop_index(op.f('ix_events_title'), table_name='events')
|
||||
op.drop_index(op.f('ix_events_id'), table_name='events')
|
||||
op.drop_table('events')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./orgmylife.db")
|
||||
|
||||
connect_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency for FastAPI endpoints
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user