feat: initial commit — Billo Release Agent (LangGraph)

LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
This commit is contained in:
Yaojia Wang
2026-03-24 17:38:23 +01:00
commit f5c2733cfb
104 changed files with 19721 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
"""PR deduplication service.
Queries the agent_threads table to find which PRs from a given list have
not yet been processed. This prevents the PR poller from re-triggering
graph runs for PRs that already have an existing thread.
"""
from release_agent.models.pr import PRInfo
_QUERY = """
SELECT pr_id, repo_name
FROM agent_threads
WHERE (pr_id, repo_name) IN (
SELECT unnest(%s::text[]), unnest(%s::text[])
)
"""
async def find_unprocessed_prs(pool, prs: list[PRInfo]) -> list[PRInfo]:
"""Return the subset of prs that have no existing agent_threads record.
A PR is considered already-processed if there exists a row in agent_threads
with matching (pr_id, repo_name) pair. The SQL uses unnest to enforce
pair-wise matching (not independent ANY on each column).
Args:
pool: Async psycopg connection pool.
prs: List of PRInfo objects to check.
Returns:
A new list containing only PRs with no existing thread.
Original list is not mutated.
"""
if not prs:
return []
pr_ids = [p.pr_id for p in prs]
repo_names = [p.repo_name for p in prs]
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.execute(_QUERY, (pr_ids, repo_names))
rows = await cur.fetchall()
processed = {(str(row[0]), str(row[1])) for row in rows}
return [p for p in prs if (p.pr_id, p.repo_name) not in processed]