Spaces:
Sleeping
Sleeping
| /** | |
| * GitHub Sync Engine — bidirectional sync between MC tasks and GitHub issues. | |
| * Uses proper DB columns (github_repo, github_issue_number, github_synced_at) | |
| * instead of metadata JSON for matching. | |
| */ | |
| import { getDatabase, db_helpers } from '@/lib/db' | |
| import { logger } from '@/lib/logger' | |
| import { | |
| fetchIssues, | |
| fetchIssue, | |
| updateIssue, | |
| createIssue, | |
| ensureLabels, | |
| type GitHubIssue, | |
| } from '@/lib/github' | |
| import { | |
| ALL_MC_LABELS, | |
| ALL_STATUS_LABEL_NAMES, | |
| ALL_PRIORITY_LABEL_NAMES, | |
| statusToLabel, | |
| labelToStatus, | |
| priorityToLabel, | |
| labelToPriority, | |
| type TaskStatus, | |
| type TaskPriority, | |
| } from '@/lib/github-label-map' | |
| /** | |
| * Idempotently create all MC labels on a GitHub repo. | |
| */ | |
| export async function initializeLabels(repo: string): Promise<void> { | |
| await ensureLabels(repo, ALL_MC_LABELS) | |
| logger.info({ repo }, 'GitHub labels initialized') | |
| } | |
| /** | |
| * Push a single MC task to GitHub (create or update issue). | |
| */ | |
| export async function pushTaskToGitHub( | |
| task: { | |
| id: number | |
| title: string | |
| description?: string | null | |
| status: string | |
| priority: string | |
| github_issue_number?: number | null | |
| github_repo?: string | null | |
| workspace_id?: number | |
| }, | |
| project: { | |
| id: number | |
| github_repo?: string | null | |
| github_sync_enabled?: number | null | |
| } | |
| ): Promise<void> { | |
| const repo = task.github_repo || project.github_repo | |
| if (!repo) return | |
| const db = getDatabase() | |
| const now = Math.floor(Date.now() / 1000) | |
| const statusLabel = statusToLabel(task.status as TaskStatus) | |
| const priorityLabel = priorityToLabel(task.priority as TaskPriority) | |
| const state: 'open' | 'closed' = task.status === 'done' ? 'closed' : 'open' | |
| if (task.github_issue_number) { | |
| // Update existing issue | |
| let existingIssue: GitHubIssue | |
| try { | |
| existingIssue = await fetchIssue(repo, task.github_issue_number) | |
| } catch (err) { | |
| logger.error({ err, repo, issue: task.github_issue_number }, 'Failed to fetch issue for update') | |
| return | |
| } | |
| // Keep non-MC labels, replace MC labels with current values | |
| const nonMcLabels = existingIssue.labels | |
| .map(l => l.name) | |
| .filter(name => !ALL_STATUS_LABEL_NAMES.includes(name) && !ALL_PRIORITY_LABEL_NAMES.includes(name)) | |
| const labels = [...nonMcLabels, statusLabel.name, priorityLabel.name] | |
| await updateIssue(repo, task.github_issue_number, { | |
| title: task.title, | |
| body: task.description || '', | |
| state, | |
| labels, | |
| }) | |
| // Mark synced to prevent ping-pong | |
| db.prepare(` | |
| UPDATE tasks SET github_synced_at = ? WHERE id = ? | |
| `).run(now, task.id) | |
| logger.info({ repo, issue: task.github_issue_number }, 'Pushed task update to GitHub') | |
| } else if (project.github_sync_enabled) { | |
| // Create new issue | |
| const labels = [statusLabel.name, priorityLabel.name] | |
| const created = await createIssue(repo, { | |
| title: task.title, | |
| body: task.description || undefined, | |
| labels, | |
| }) | |
| // Store the issue number and repo on the task | |
| db.prepare(` | |
| UPDATE tasks | |
| SET github_issue_number = ?, github_repo = ?, github_synced_at = ? | |
| WHERE id = ? | |
| `).run(created.number, repo, now, task.id) | |
| logger.info({ repo, issue: created.number, taskId: task.id }, 'Created GitHub issue for task') | |
| } | |
| } | |
| /** | |
| * Pull issues from GitHub and sync into MC tasks for a project. | |
| */ | |
| export async function pullFromGitHub( | |
| project: { | |
| id: number | |
| github_repo?: string | null | |
| github_sync_enabled?: number | null | |
| github_default_branch?: string | null | |
| }, | |
| workspaceId: number | |
| ): Promise<{ pulled: number; pushed: number }> { | |
| const repo = project.github_repo | |
| if (!repo || !project.github_sync_enabled) { | |
| return { pulled: 0, pushed: 0 } | |
| } | |
| const db = getDatabase() | |
| const now = Math.floor(Date.now() / 1000) | |
| let pulled = 0 | |
| let pushed = 0 | |
| // Find last sync time for this project | |
| const lastSync = db.prepare(` | |
| SELECT last_synced_at FROM github_syncs | |
| WHERE project_id = ? AND workspace_id = ? | |
| ORDER BY created_at DESC LIMIT 1 | |
| `).get(project.id, workspaceId) as { last_synced_at: number } | undefined | |
| const sinceDate = lastSync | |
| ? new Date(lastSync.last_synced_at * 1000).toISOString() | |
| : undefined | |
| // Fetch all issues updated since last sync | |
| let issues: GitHubIssue[] | |
| try { | |
| issues = await fetchIssues(repo, { | |
| state: 'all', | |
| since: sinceDate, | |
| per_page: 100, | |
| }) | |
| } catch (err) { | |
| logger.error({ err, repo }, 'Failed to fetch issues from GitHub') | |
| // Record failed sync | |
| db.prepare(` | |
| INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, error, project_id, changes_pushed, changes_pulled, workspace_id) | |
| VALUES (?, ?, 0, 'inbound', 'error', ?, ?, 0, 0, ?) | |
| `).run(repo, now, (err as Error).message, project.id, workspaceId) | |
| return { pulled: 0, pushed: 0 } | |
| } | |
| for (const issue of issues) { | |
| try { | |
| // Match to existing task via DB columns | |
| const existingTask = db.prepare(` | |
| SELECT * FROM tasks | |
| WHERE github_repo = ? AND github_issue_number = ? AND workspace_id = ? | |
| `).get(repo, issue.number, workspaceId) as any | undefined | |
| const issueUpdatedAt = Math.floor(new Date(issue.updated_at).getTime() / 1000) | |
| const labelNames = issue.labels.map(l => l.name) | |
| if (!existingTask) { | |
| // New issue — create MC task | |
| const status = issue.state === 'closed' ? 'done' : (labelToStatus( | |
| labelNames.find(l => ALL_STATUS_LABEL_NAMES.includes(l)) || '' | |
| ) || 'inbox') | |
| const priority = labelToPriority(labelNames) | |
| const tags = labelNames.filter(l => !ALL_STATUS_LABEL_NAMES.includes(l) && !ALL_PRIORITY_LABEL_NAMES.includes(l)) | |
| db.prepare(` | |
| INSERT INTO tasks ( | |
| title, description, status, priority, created_by, | |
| created_at, updated_at, tags, metadata, | |
| github_issue_number, github_repo, github_synced_at, | |
| project_id, workspace_id | |
| ) VALUES (?, ?, ?, ?, 'github-sync', ?, ?, ?, '{}', ?, ?, ?, ?, ?) | |
| `).run( | |
| issue.title, | |
| issue.body || '', | |
| status, | |
| priority, | |
| now, now, | |
| JSON.stringify(tags), | |
| issue.number, repo, now, | |
| project.id, workspaceId | |
| ) | |
| pulled++ | |
| db_helpers.logActivity( | |
| 'task_created', 'task', 0, 'github-sync', | |
| `Synced from GitHub: ${repo}#${issue.number}`, | |
| { github_issue: issue.number, github_repo: repo }, | |
| workspaceId | |
| ) | |
| } else { | |
| // Existing task — anti-ping-pong: skip if task was just pushed | |
| if (existingTask.github_synced_at && Math.abs(existingTask.github_synced_at - issueUpdatedAt) < 10) { | |
| continue | |
| } | |
| // Only update if GitHub is newer | |
| if (issueUpdatedAt <= existingTask.updated_at) { | |
| continue | |
| } | |
| const status = issue.state === 'closed' ? 'done' : (labelToStatus( | |
| labelNames.find(l => ALL_STATUS_LABEL_NAMES.includes(l)) || '' | |
| ) || existingTask.status) | |
| const priority = labelToPriority(labelNames) | |
| db.prepare(` | |
| UPDATE tasks | |
| SET title = ?, description = ?, status = ?, priority = ?, | |
| github_synced_at = ?, updated_at = ? | |
| WHERE id = ? AND workspace_id = ? | |
| `).run( | |
| issue.title, | |
| issue.body || '', | |
| status, | |
| priority, | |
| now, now, | |
| existingTask.id, workspaceId | |
| ) | |
| pulled++ | |
| db_helpers.logActivity( | |
| 'task_updated', 'task', existingTask.id, 'github-sync', | |
| `Updated from GitHub: ${repo}#${issue.number}`, | |
| { github_issue: issue.number, github_repo: repo }, | |
| workspaceId | |
| ) | |
| } | |
| } catch (err) { | |
| logger.error({ err, issue: issue.number, repo }, 'Failed to sync GitHub issue') | |
| } | |
| } | |
| // Record sync | |
| db.prepare(` | |
| INSERT INTO github_syncs (repo, last_synced_at, issue_count, sync_direction, status, project_id, changes_pushed, changes_pulled, workspace_id) | |
| VALUES (?, ?, ?, 'inbound', 'success', ?, ?, ?, ?) | |
| `).run(repo, now, pulled, project.id, pushed, pulled, workspaceId) | |
| logger.info({ repo, pulled, pushed, projectId: project.id }, 'GitHub sync completed') | |
| return { pulled, pushed } | |
| } | |