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:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# The API_SECRET from your OrgMyLife .env on the VPS
ORGMYLIFE_API_SECRET=your_api_secret_here
+21
View File
@@ -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"
+32
View File
@@ -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"
+38
View File
@@ -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
+110
View File
@@ -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
---
+14
View File
@@ -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"]
+33
View File
@@ -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.
+110
View File
@@ -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
+96
View File
@@ -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 |
+115
View File
@@ -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
+70
View File
@@ -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.
+13
View File
@@ -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"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.0",
"tokens": []
}
+56
View File
@@ -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}")
+1
View File
@@ -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
+138
View File
@@ -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))
+10
View File
@@ -0,0 +1,10 @@
.git
.env
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
*.db
alembic/versions/__pycache__
tests/__pycache__
+34
View File
@@ -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
+37
View File
@@ -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
+81
View File
@@ -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
+80
View File
@@ -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}`);
}
+57
View File
@@ -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
View File
@@ -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"
+6
View File
@@ -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)
+122
View File
@@ -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
---
+125
View File
@@ -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._
+17
View File
@@ -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.
+27
View File
@@ -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 | | |
+24
View File
@@ -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.
+180
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
# Done Issues
_Empty — no issues to process._
+31
View File
@@ -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"]
+51
View File
@@ -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)
+10
View File
@@ -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.
+355
View File
@@ -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 |
+22
View File
@@ -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.*
+149
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+83
View File
@@ -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()
+28
View File
@@ -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 ###
+25
View File
@@ -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