From 04fa58d3a32f614aead3bd3b4ef1f5484a8cd767 Mon Sep 17 00:00:00 2001 From: 0xtechdean <β€œdean@othentic.xyz”> Date: Mon, 12 Jan 2026 13:21:18 +0200 Subject: [PATCH 1/2] [AG-10] Add branch-per-task workflow with PR creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add git-service.ts for branch/PR management via GitHub API - Update Task interface with branch, prUrl, prNumber, prStatus fields - Create branches when tasks start (pattern: task/{id}-{slug}) - Auto-create PRs when tasks complete - Add GitHub webhook handler for PR merge events - Add "PR Review" column to Kanban board - Show PR badge and branch name on task cards - Show PR info in task detail view - Branch cleanup after PR merge Acceptance Criteria: - [x] Agent creates branch when starting task - [x] Branch follows naming convention with task ID - [x] Agent creates PR when completing task - [x] PR description includes task context - [x] PR link is stored on task record - [x] Task status updates when PR is merged - [x] Dashboard shows PR status on tasks - [x] Branches are cleaned up after merge πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- public/index.html | 83 +++++++- src/controllers/tasks.controller.ts | 58 +++++- src/git-service.ts | 310 ++++++++++++++++++++++++++++ src/index.ts | 98 +++++++++ src/orchestrator.ts | 1 + src/taskdb.ts | 30 ++- 6 files changed, 568 insertions(+), 12 deletions(-) create mode 100644 src/git-service.ts diff --git a/public/index.html b/public/index.html index 59939e6..ac4960c 100644 --- a/public/index.html +++ b/public/index.html @@ -357,6 +357,7 @@ .column-title .dot.backlog { background: var(--text-muted); } .column-title .dot.ready { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); } .column-title .dot.in_progress { background: var(--accent-yellow); box-shadow: 0 0 8px var(--accent-yellow); } + .column-title .dot.pr_created { background: #9b59b6; box-shadow: 0 0 8px #9b59b6; } .column-title .dot.done { background: var(--accent-green); box-shadow: 0 0 8px var(--accent-green); } .column-count { @@ -430,6 +431,43 @@ .task-owner { color: var(--accent-blue); } + /* AG-10: PR and branch styles */ + .task-pr { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(255,255,255,0.1); + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .pr-badge { + font-size: 10px; + padding: 0.125rem 0.375rem; + border-radius: 3px; + cursor: pointer; + font-weight: 500; + } + + .pr-open { background: #238636; color: #fff; } + .pr-approved { background: #8957e5; color: #fff; } + .pr-merged { background: #a371f7; color: #fff; } + .pr-closed { background: #da3633; color: #fff; } + + .branch-name { + font-size: 9px; + color: var(--text-muted); + background: rgba(255,255,255,0.05); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: 'JetBrains Mono', monospace; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .add-task-btn { width: 100%; padding: 0.5rem; @@ -1416,6 +1454,13 @@
+
+
+
PR REVIEW
+ 0 +
+
+
DONE
@@ -1456,11 +1501,20 @@

COMPLETED: -
+ +
+
@@ -2035,7 +2089,7 @@ const filteredTasks = applyFilters(tasks); updateFilterCount(filteredTasks.length, tasks.length); - const statuses = ['backlog', 'ready', 'in_progress', 'done']; + const statuses = ['backlog', 'ready', 'in_progress', 'pr_created', 'done']; statuses.forEach(status => { const container = document.getElementById(`tasks-${status}`); const statusTasks = filteredTasks.filter(t => t.status === status); @@ -2059,6 +2113,12 @@ ${task.owner || '-'} ${task.output ? 'βœ“' : ''} + ${task.prUrl ? ` +
+ PR #${task.prNumber || ''} + ${task.branch ? `${task.branch}` : ''} +
+ ` : (task.branch ? `
${task.branch}
` : '')} `).join(''); }); @@ -2163,6 +2223,27 @@ document.getElementById('detail-completed').textContent = formatDate(task.completedAt); document.getElementById('detail-description').textContent = task.description || 'No description provided.'; + // AG-10: PR and branch info + const branchRow = document.getElementById('detail-branch-row'); + const prRow = document.getElementById('detail-pr-row'); + + if (task.branch) { + branchRow.style.display = 'flex'; + document.getElementById('detail-branch').textContent = task.branch; + } else { + branchRow.style.display = 'none'; + } + + if (task.prUrl) { + prRow.style.display = 'flex'; + const prEl = document.getElementById('detail-pr'); + prEl.innerHTML = ` + PR #${task.prNumber || ''} + `; + } else { + prRow.style.display = 'none'; + } + const outputEl = document.getElementById('detail-output'); outputEl.innerHTML = task.output ? `${escapeHtml(task.output)}` diff --git a/src/controllers/tasks.controller.ts b/src/controllers/tasks.controller.ts index b6df191..6f6a2a5 100644 --- a/src/controllers/tasks.controller.ts +++ b/src/controllers/tasks.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Patch, Path, Post, Query, Response, Rout import { Task, taskDb } from '../taskdb'; import { ErrorResponse } from '../types/api'; import { getOrchestrator } from './orchestration.controller'; +import { gitService } from '../git-service'; interface CreateTaskRequest { title: string; @@ -13,12 +14,17 @@ interface CreateTaskRequest { interface UpdateTaskRequest { title?: string; description?: string; - status?: 'backlog' | 'ready' | 'in_progress' | 'done'; + status?: 'backlog' | 'ready' | 'in_progress' | 'pr_created' | 'done'; owner?: string; priority?: 'P0' | 'P1' | 'P2'; output?: string; startedAt?: string; completedAt?: string; + // Git workflow fields (AG-10) + branch?: string; + prUrl?: string; + prNumber?: number; + prStatus?: 'open' | 'approved' | 'merged' | 'closed'; } @Route('api') @@ -90,10 +96,23 @@ export class TasksController extends Controller { if (body.status === 'ready' && currentTask?.status !== 'ready' && task.owner) { console.log(`[TasksController] Task ${taskId} moved to ready - triggering ${task.owner} agent`); - // Update status to in_progress immediately + // AG-10: Create a branch for this task + let branchName: string | undefined; + if (gitService.isConfigured()) { + const branchResult = await gitService.createBranch(taskId, task.title); + if (branchResult.success && branchResult.branch) { + branchName = branchResult.branch; + console.log(`[TasksController] Created branch: ${branchName}`); + } else { + console.warn(`[TasksController] Branch creation failed: ${branchResult.error}`); + } + } + + // Update status to in_progress with branch info await taskDb.updateTask(taskId, { status: 'in_progress', - startedAt: new Date().toISOString() + startedAt: new Date().toISOString(), + branch: branchName, }); // Run agent asynchronously (don't wait for completion) @@ -102,9 +121,38 @@ export class TasksController extends Controller { orchestrator.runAgent( task.owner, `${task.title}${task.description ? `: ${task.description}` : ''}`, - { taskId } + { taskId, branch: branchName } ).then(async (result) => { - // Mark task as done when agent completes + // AG-10: Create PR when agent completes (if git is configured) + if (branchName && gitService.isConfigured()) { + // Commit any changes made by agent + await gitService.createCommit(taskId, `Complete: ${task.title}`); + + // Create PR + const prResult = await gitService.createPR( + taskId, + task.title, + task.description || result.substring(0, 500), + branchName + ); + + if (prResult.success && prResult.prUrl && prResult.prNumber) { + // Update task with PR info and set status to pr_created + await taskDb.updateTask(taskId, { + status: 'pr_created', + output: result.substring(0, 10000), + prUrl: prResult.prUrl, + prNumber: prResult.prNumber, + prStatus: 'open', + }); + console.log(`[TasksController] Task ${taskId} PR created: ${prResult.prUrl}`); + return; + } else { + console.warn(`[TasksController] PR creation failed: ${prResult.error}`); + } + } + + // Fallback: Mark task as done if no PR workflow await taskDb.updateTask(taskId, { status: 'done', output: result.substring(0, 10000), diff --git a/src/git-service.ts b/src/git-service.ts new file mode 100644 index 0000000..39e237a --- /dev/null +++ b/src/git-service.ts @@ -0,0 +1,310 @@ +/** + * Git Service (AG-10) + * Handles branch creation, PR management, and GitHub API integration + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +// GitHub repo configuration +const GITHUB_OWNER = process.env.GITHUB_OWNER || 'Othentic-Labs'; +const GITHUB_REPO = process.env.GITHUB_REPO || 'ai-team'; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const DEFAULT_BASE_BRANCH = process.env.DEFAULT_BASE_BRANCH || 'master'; + +interface CreateBranchResult { + success: boolean; + branch?: string; + error?: string; +} + +interface CreatePRResult { + success: boolean; + prUrl?: string; + prNumber?: number; + error?: string; +} + +interface PRInfo { + number: number; + state: 'open' | 'closed'; + merged: boolean; + title: string; + url: string; +} + +export class GitService { + private repoPath: string; + + constructor(repoPath?: string) { + this.repoPath = repoPath || process.env.REPO_PATH || process.cwd(); + } + + /** + * Generate a branch name from task ID and title + * Pattern: task/{task-id}-{short-description} + */ + generateBranchName(taskId: string, title: string): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 30); + return `task/${taskId}-${slug}`; + } + + /** + * Create a new branch for a task + */ + async createBranch(taskId: string, title: string): Promise { + const branchName = this.generateBranchName(taskId, title); + + try { + // Fetch latest from origin + await execAsync(`git fetch origin ${DEFAULT_BASE_BRANCH}`, { cwd: this.repoPath }); + + // Create and checkout the new branch from base + await execAsync(`git checkout -b ${branchName} origin/${DEFAULT_BASE_BRANCH}`, { cwd: this.repoPath }); + + console.log(`[GitService] Created branch: ${branchName}`); + return { success: true, branch: branchName }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[GitService] Failed to create branch: ${errorMsg}`); + + // Check if branch already exists + if (errorMsg.includes('already exists')) { + try { + await execAsync(`git checkout ${branchName}`, { cwd: this.repoPath }); + return { success: true, branch: branchName }; + } catch { + return { success: false, error: `Branch exists but checkout failed: ${errorMsg}` }; + } + } + + return { success: false, error: errorMsg }; + } + } + + /** + * Push branch to remote + */ + async pushBranch(branchName: string): Promise<{ success: boolean; error?: string }> { + try { + await execAsync(`git push -u origin ${branchName}`, { cwd: this.repoPath }); + console.log(`[GitService] Pushed branch: ${branchName}`); + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[GitService] Failed to push branch: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + } + + /** + * Create a commit with task ID in message + */ + async createCommit(taskId: string, message: string): Promise<{ success: boolean; error?: string }> { + try { + // Stage all changes + await execAsync('git add -A', { cwd: this.repoPath }); + + // Create commit with task ID prefix + const commitMessage = `[${taskId}] ${message}`; + await execAsync(`git commit -m "${commitMessage}"`, { cwd: this.repoPath }); + + console.log(`[GitService] Created commit: ${commitMessage}`); + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // "nothing to commit" is not really an error + if (errorMsg.includes('nothing to commit')) { + return { success: true }; + } + + console.error(`[GitService] Failed to create commit: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + } + + /** + * Create a Pull Request via GitHub API + */ + async createPR( + taskId: string, + title: string, + description: string, + branchName: string + ): Promise { + if (!GITHUB_TOKEN) { + return { success: false, error: 'GITHUB_TOKEN not configured' }; + } + + try { + // Push branch first + const pushResult = await this.pushBranch(branchName); + if (!pushResult.success) { + return { success: false, error: pushResult.error }; + } + + // Create PR via GitHub API + const prTitle = `[${taskId}] ${title}`; + const prBody = `## Summary +${description} + +## Task +- **ID:** ${taskId} +- **Title:** ${title} + +## Changes + + +--- +πŸ€– Generated by AI Agent`; + + const response = await fetch(`https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/pulls`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + 'Accept': 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: prTitle, + body: prBody, + head: branchName, + base: DEFAULT_BASE_BRANCH, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMsg = (errorData as { message?: string }).message || `HTTP ${response.status}`; + console.error(`[GitService] GitHub API error: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + + const data = await response.json() as { number: number; html_url: string }; + console.log(`[GitService] Created PR #${data.number}: ${data.html_url}`); + + return { + success: true, + prNumber: data.number, + prUrl: data.html_url, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[GitService] Failed to create PR: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + } + + /** + * Get PR status from GitHub + */ + async getPRStatus(prNumber: number): Promise { + if (!GITHUB_TOKEN) { + return null; + } + + try { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/pulls/${prNumber}`, + { + headers: { + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + 'Accept': 'application/vnd.github.v3+json', + }, + } + ); + + if (!response.ok) { + return null; + } + + const data = await response.json() as { + number: number; + state: 'open' | 'closed'; + merged: boolean; + title: string; + html_url: string; + }; + + return { + number: data.number, + state: data.state, + merged: data.merged, + title: data.title, + url: data.html_url, + }; + } catch (error) { + console.error(`[GitService] Failed to get PR status:`, error); + return null; + } + } + + /** + * Delete a branch (cleanup after merge) + */ + async deleteBranch(branchName: string): Promise<{ success: boolean; error?: string }> { + if (!GITHUB_TOKEN) { + return { success: false, error: 'GITHUB_TOKEN not configured' }; + } + + try { + // Delete remote branch + const response = await fetch( + `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/git/refs/heads/${branchName}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + 'Accept': 'application/vnd.github.v3+json', + }, + } + ); + + if (!response.ok && response.status !== 404) { + return { success: false, error: `HTTP ${response.status}` }; + } + + // Also try to delete local branch + try { + await execAsync(`git branch -D ${branchName}`, { cwd: this.repoPath }); + } catch { + // Ignore local deletion errors + } + + console.log(`[GitService] Deleted branch: ${branchName}`); + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[GitService] Failed to delete branch: ${errorMsg}`); + return { success: false, error: errorMsg }; + } + } + + /** + * Get current branch name + */ + async getCurrentBranch(): Promise { + try { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: this.repoPath }); + return stdout.trim(); + } catch { + return null; + } + } + + /** + * Check if GitHub token is configured + */ + isConfigured(): boolean { + return !!GITHUB_TOKEN; + } +} + +export const gitService = new GitService(); diff --git a/src/index.ts b/src/index.ts index 5c25347..3b9bf30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1589,6 +1589,104 @@ app.get('/api/slack/info', async (req, res) => { }); }); +// ============== GitHub Webhook (AG-10) ============== +import { gitService } from './git-service'; + +// GitHub webhook for PR events +app.post('/api/github/webhook', express.json(), async (req, res) => { + const event = req.headers['x-github-event']; + const payload = req.body; + + console.log(`[GitHub Webhook] Received event: ${event}`); + + // Verify webhook secret (optional but recommended) + const secret = process.env.GITHUB_WEBHOOK_SECRET; + if (secret) { + const crypto = await import('crypto'); + const signature = req.headers['x-hub-signature-256'] as string; + const hmac = crypto.createHmac('sha256', secret); + const digest = 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + if (signature !== digest) { + console.warn('[GitHub Webhook] Invalid signature'); + return res.status(401).json({ error: 'Invalid signature' }); + } + } + + // Handle pull_request events + if (event === 'pull_request') { + const action = payload.action; + const pr = payload.pull_request; + const prNumber = pr?.number; + + console.log(`[GitHub Webhook] PR #${prNumber} action: ${action}`); + + // Extract task ID from branch name (format: task/{taskId}-{slug}) + const branchName = pr?.head?.ref; + const taskIdMatch = branchName?.match(/^task\/([a-z0-9]+)-/); + const taskId = taskIdMatch?.[1]; + + if (!taskId) { + console.log(`[GitHub Webhook] No task ID found in branch: ${branchName}`); + return res.json({ ok: true, message: 'Not a task branch' }); + } + + // Update task based on PR action + if (action === 'closed' && pr?.merged) { + // PR was merged - mark task as done and cleanup branch + console.log(`[GitHub Webhook] PR #${prNumber} merged for task ${taskId}`); + + await taskDb.updateTask(taskId, { + status: 'done', + prStatus: 'merged', + completedAt: new Date().toISOString(), + }); + + // Cleanup: delete the branch + if (branchName) { + await gitService.deleteBranch(branchName); + } + + // Post to Slack if channel exists + const channelMapping = await taskDb.getChannelMapping(`task-*-${taskId}`); + if (channelMapping) { + await slackService.postMessage( + channelMapping.taskId, // This is actually channelId in this context + `βœ… *PR Merged!*\n\nPR #${prNumber} has been merged. Task ${taskId} is now complete.` + ); + } + } else if (action === 'closed' && !pr?.merged) { + // PR was closed without merging + await taskDb.updateTask(taskId, { + prStatus: 'closed', + }); + } else if (action === 'review_requested' || (action === 'submitted' && payload.review?.state === 'approved')) { + // PR was approved + await taskDb.updateTask(taskId, { + prStatus: 'approved', + }); + } + } + + res.json({ ok: true }); +}); + +// Get GitHub integration info +app.get('/api/github/info', (req, res) => { + res.json({ + configured: gitService.isConfigured(), + webhookEndpoint: '/api/github/webhook', + owner: process.env.GITHUB_OWNER || 'Othentic-Labs', + repo: process.env.GITHUB_REPO || 'ai-team', + instructions: [ + '1. Go to your repo Settings > Webhooks', + '2. Add webhook with URL: https://YOUR_DOMAIN/api/github/webhook', + '3. Content type: application/json', + '4. Events: Pull requests', + '5. (Optional) Add GITHUB_WEBHOOK_SECRET env var for security', + ], + }); +}); + // ============== File Viewer Routes ============== // View a file's contents (for sharing file links) diff --git a/src/orchestrator.ts b/src/orchestrator.ts index aa0957f..d880163 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -24,6 +24,7 @@ interface AgentContext { slackChannelId?: string; slackUserId?: string; isSlackReply?: boolean; + branch?: string; // AG-10: Git branch name for this task } export class AgentOrchestrator { diff --git a/src/taskdb.ts b/src/taskdb.ts index 7a49a3a..f9bb0c6 100644 --- a/src/taskdb.ts +++ b/src/taskdb.ts @@ -10,7 +10,7 @@ export interface Task { id: string; title: string; description?: string; - status: 'backlog' | 'ready' | 'in_progress' | 'done'; + status: 'backlog' | 'ready' | 'in_progress' | 'pr_created' | 'done'; owner?: string; priority?: 'P0' | 'P1' | 'P2'; output?: string; @@ -18,6 +18,11 @@ export interface Task { completedAt?: string; createdAt: string; updatedAt: string; + // Git workflow fields (AG-10) + branch?: string; + prUrl?: string; + prNumber?: number; + prStatus?: 'open' | 'approved' | 'merged' | 'closed'; } export interface Project { @@ -119,6 +124,11 @@ class TaskDatabase { ALTER TABLE tasks ADD COLUMN IF NOT EXISTS output TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS started_at TIMESTAMP; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS completed_at TIMESTAMP; + -- AG-10: Git workflow columns + ALTER TABLE tasks ADD COLUMN IF NOT EXISTS branch VARCHAR(255); + ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr_url VARCHAR(500); + ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr_number INTEGER; + ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr_status VARCHAR(20); EXCEPTION WHEN OTHERS THEN NULL; END $$; `); @@ -314,7 +324,7 @@ class TaskDatabase { async getTask(id: string): Promise { if (this.usePostgres && this.pg) { const result = await this.pg.query( - 'SELECT id, title, description, status, owner, priority, output, started_at, completed_at, created_at, updated_at FROM tasks WHERE id = $1', + 'SELECT id, title, description, status, owner, priority, output, started_at, completed_at, created_at, updated_at, branch, pr_url, pr_number, pr_status FROM tasks WHERE id = $1', [id] ); if (result.rows.length === 0) return undefined; @@ -331,6 +341,10 @@ class TaskDatabase { completedAt: row.completed_at?.toISOString(), createdAt: row.created_at?.toISOString() || new Date().toISOString(), updatedAt: row.updated_at?.toISOString() || new Date().toISOString(), + branch: row.branch, + prUrl: row.pr_url, + prNumber: row.pr_number, + prStatus: row.pr_status, }; } else if (this.redis) { const data = await this.redis.get(this.taskKey(id)); @@ -343,7 +357,7 @@ class TaskDatabase { let tasks: Task[] = []; if (this.usePostgres && this.pg) { - let query = 'SELECT id, title, description, status, owner, priority, output, started_at, completed_at, created_at, updated_at FROM tasks WHERE project_id = $1'; + let query = 'SELECT id, title, description, status, owner, priority, output, started_at, completed_at, created_at, updated_at, branch, pr_url, pr_number, pr_status FROM tasks WHERE project_id = $1'; const params: string[] = [projectId]; if (status) { @@ -364,6 +378,10 @@ class TaskDatabase { completedAt: row.completed_at?.toISOString(), createdAt: row.created_at?.toISOString() || new Date().toISOString(), updatedAt: row.updated_at?.toISOString() || new Date().toISOString(), + branch: row.branch, + prUrl: row.pr_url, + prNumber: row.pr_number, + prStatus: row.pr_status, })); } else if (this.redis) { const ids = await this.redis.smembers(this.projectTasksKey(projectId)); @@ -396,7 +414,7 @@ class TaskDatabase { }); } - async updateTask(id: string, updates: Partial>): Promise { + async updateTask(id: string, updates: Partial>): Promise { const task = await this.getTask(id); if (!task) return undefined; @@ -408,8 +426,8 @@ class TaskDatabase { if (this.usePostgres && this.pg) { await this.pg.query( - `UPDATE tasks SET title = $1, description = $2, status = $3, owner = $4, priority = $5, output = $6, started_at = $7, completed_at = $8, updated_at = $9 WHERE id = $10`, - [updated.title, updated.description, updated.status, updated.owner, updated.priority, updated.output, updated.startedAt, updated.completedAt, updated.updatedAt, id] + `UPDATE tasks SET title = $1, description = $2, status = $3, owner = $4, priority = $5, output = $6, started_at = $7, completed_at = $8, updated_at = $9, branch = $10, pr_url = $11, pr_number = $12, pr_status = $13 WHERE id = $14`, + [updated.title, updated.description, updated.status, updated.owner, updated.priority, updated.output, updated.startedAt, updated.completedAt, updated.updatedAt, updated.branch, updated.prUrl, updated.prNumber, updated.prStatus, id] ); } else if (this.redis) { await this.redis.set(this.taskKey(id), JSON.stringify(updated)); From 7e976bc5984bcf66709e15bcfc754b87515b3de8 Mon Sep 17 00:00:00 2001 From: 0xtechdean <β€œdean@othentic.xyz”> Date: Mon, 12 Jan 2026 13:26:22 +0200 Subject: [PATCH 2/2] docs: Add Git Branch Workflow documentation to README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add feature bullet point for Git Branch Workflow - Add GitHub environment variables to configuration table - Add comprehensive Git Branch Workflow section with: - How it works overview - GitHub setup instructions - Webhook configuration - Branch naming convention - PR template details - Task status flow - Dashboard features - Update Task Workflow section with PR Review step πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 60b9297..1a79dfa 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A self-improving multi-agent system that coordinates AI agents to work on tasks - **Role-Based Permissions**: Manager, Specialist, and Support roles with different capabilities - **Release Stuck Tasks**: One-click release for tasks stuck in progress - **Slack Integration**: Creates dedicated channels for each task, enabling real-time communication +- **Git Branch Workflow**: Automatic branch creation per task with PR generation for code review ## Architecture @@ -115,6 +116,10 @@ Open http://localhost:3000 to access the Kanban dashboard. | `PORT` | No | Server port (default: 3000) | | `SLACK_BOT_TOKEN` | No | Slack Bot Token for task channels | | `SLACK_CHANNEL_ID` | No | Default Slack channel for notifications | +| `GITHUB_TOKEN` | No | GitHub personal access token for PR creation | +| `GITHUB_OWNER` | No | GitHub repository owner (default: Othentic-Labs) | +| `GITHUB_REPO` | No | GitHub repository name (default: ai-team) | +| `GITHUB_WEBHOOK_SECRET` | No | Secret for webhook signature verification | ### Agent Definitions @@ -299,13 +304,90 @@ Status: In Progress Use this channel to communicate with the agent about this task. ``` +## Git Branch Workflow + +When GitHub is configured, agents work on isolated branches and create PRs for code review: + +### How It Works + +1. **Branch Creation**: When an agent starts a task, a branch is created: `task/{taskId}-{slug}` +2. **Isolated Work**: Agent commits changes to the task branch, not main/master +3. **PR Creation**: When task completes, a PR is automatically created +4. **Human Review**: Team reviews the PR and merges when ready +5. **Auto-Cleanup**: After merge, the task branch is automatically deleted + +### Setting Up GitHub + +1. Create a Personal Access Token at https://github.com/settings/tokens +2. Required scopes: `repo` (full control of private repositories) +3. Set environment variables: + ```bash + GITHUB_TOKEN=ghp_xxxxxxxxxxxx + GITHUB_OWNER=your-org # default: Othentic-Labs + GITHUB_REPO=your-repo # default: ai-team + ``` + +### GitHub Webhook (Optional) + +For automatic task status updates when PRs are merged: + +1. Go to your repo Settings β†’ Webhooks +2. Add webhook: + - URL: `https://your-domain.com/api/github/webhook` + - Content type: `application/json` + - Events: `Pull requests` +3. (Optional) Set `GITHUB_WEBHOOK_SECRET` for signature verification + +### Branch Naming Convention + +``` +task/{taskId}-{slug} + +Examples: +- task/abc123-implement-user-auth +- task/xyz789-fix-login-bug +- task/def456-add-dashboard-chart +``` + +### PR Template + +PRs are created with: +- Title: `[{taskId}] {task title}` +- Body: Task description + auto-generated summary +- Base branch: `master` (configurable via `DEFAULT_BASE_BRANCH`) + +### Task Status Flow + +| Status | Description | +|--------|-------------| +| `backlog` | Task not started | +| `ready` | Ready to work on | +| `in_progress` | Agent working (branch exists) | +| `pr_created` | PR open for review | +| `done` | PR merged | + +### Dashboard Features + +- **PR Review Column**: Tasks awaiting review in dedicated Kanban column +- **PR Badge**: Shows PR number and status on task cards +- **Branch Display**: Shows branch name on task cards +- **PR Links**: Click to open PR in GitHub + ## Task Workflow 1. Tasks start in **Backlog** 2. Move to **Ready** when unblocked -3. Agent picks up and moves to **In Progress** (Slack channel created) -4. Agent completes and moves to **Done** (Slack channel updated) -5. PM evaluates and creates follow-up tasks +3. Agent picks up and moves to **In Progress** (Slack channel + Git branch created) +4. Agent completes and creates PR, moves to **PR Review** +5. Human reviews and merges PR β†’ moves to **Done** +6. PM evaluates and creates follow-up tasks + +``` +Backlog β†’ Ready β†’ In Progress β†’ PR Review β†’ Done + ↓ ↓ + Branch created PR created + #task-agent-id task/{id}-{slug} +``` ## Deployment