HuggingClaw-MissionControl / src /lib /github-sync-engine.ts
nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
/**
* 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 }
}