kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add task title and description change events to GitHub integration

+516 -4
+101
apps/api/src/plugins/github/events/task-description-changed.ts
··· 1 + import type { PluginContext, TaskDescriptionChangedEvent } from "../../types"; 2 + import type { GitHubConfig } from "../config"; 3 + import { 4 + findExternalLinksByTask, 5 + updateExternalLink, 6 + } from "../services/link-manager"; 7 + import { formatIssueBody } from "../utils/format"; 8 + import { getGithubApp, getInstallationIdForRepo } from "../utils/github-app"; 9 + 10 + export async function handleTaskDescriptionChanged( 11 + event: TaskDescriptionChangedEvent, 12 + context: PluginContext, 13 + ): Promise<void> { 14 + const githubApp = getGithubApp(); 15 + if (!githubApp) { 16 + return; 17 + } 18 + 19 + const config = context.config as GitHubConfig; 20 + const { repositoryOwner, repositoryName } = config; 21 + 22 + try { 23 + const links = await findExternalLinksByTask(event.taskId); 24 + const issueLink = links.find( 25 + (link) => 26 + link.integrationId === context.integrationId && 27 + link.resourceType === "issue", 28 + ); 29 + 30 + if (!issueLink) { 31 + return; 32 + } 33 + 34 + const metadata = issueLink.metadata ? JSON.parse(issueLink.metadata) : {}; 35 + 36 + // LOOP PREVENTION: Check if this update originated from GitHub 37 + const lastDescSync = metadata.lastSync?.description; 38 + const newDescNormalized = event.newDescription || ""; 39 + 40 + if (lastDescSync) { 41 + // Skip if value unchanged and last sync was from GitHub 42 + if ( 43 + lastDescSync.value === newDescNormalized && 44 + lastDescSync.source === "github" 45 + ) { 46 + console.log("Skipping description sync - already synced from GitHub"); 47 + return; 48 + } 49 + 50 + // Skip if recent sync (within 2 seconds) to prevent rapid loops 51 + const timeSinceLastSync = 52 + Date.now() - new Date(lastDescSync.timestamp).getTime(); 53 + if (timeSinceLastSync < 2000) { 54 + console.log( 55 + `Skipping description sync - recent sync detected (${timeSinceLastSync}ms ago)`, 56 + ); 57 + return; 58 + } 59 + } 60 + 61 + let installationId = config.installationId; 62 + if (!installationId) { 63 + installationId = await getInstallationIdForRepo( 64 + repositoryOwner, 65 + repositoryName, 66 + ); 67 + } 68 + 69 + const octokit = await githubApp.getInstallationOctokit(installationId); 70 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 71 + 72 + // Format description with task ID footer 73 + const formattedBody = formatIssueBody(event.newDescription, event.taskId); 74 + 75 + await octokit.rest.issues.update({ 76 + owner: repositoryOwner, 77 + repo: repositoryName, 78 + issue_number: issueNumber, 79 + body: formattedBody, 80 + }); 81 + 82 + // Update metadata to track this sync 83 + await updateExternalLink(issueLink.id, { 84 + metadata: { 85 + ...metadata, 86 + lastSync: { 87 + ...metadata.lastSync, 88 + description: { 89 + timestamp: new Date().toISOString(), 90 + source: "kaneo", 91 + value: newDescNormalized, 92 + }, 93 + }, 94 + }, 95 + }); 96 + 97 + console.log(`Synced task description to GitHub issue #${issueNumber}`); 98 + } catch (error) { 99 + console.error("Failed to update GitHub issue description:", error); 100 + } 101 + }
+96
apps/api/src/plugins/github/events/task-title-changed.ts
··· 1 + import type { PluginContext, TaskTitleChangedEvent } from "../../types"; 2 + import type { GitHubConfig } from "../config"; 3 + import { 4 + findExternalLinksByTask, 5 + updateExternalLink, 6 + } from "../services/link-manager"; 7 + import { getGithubApp, getInstallationIdForRepo } from "../utils/github-app"; 8 + 9 + export async function handleTaskTitleChanged( 10 + event: TaskTitleChangedEvent, 11 + context: PluginContext, 12 + ): Promise<void> { 13 + const githubApp = getGithubApp(); 14 + if (!githubApp) { 15 + return; 16 + } 17 + 18 + const config = context.config as GitHubConfig; 19 + const { repositoryOwner, repositoryName } = config; 20 + 21 + try { 22 + const links = await findExternalLinksByTask(event.taskId); 23 + const issueLink = links.find( 24 + (link) => 25 + link.integrationId === context.integrationId && 26 + link.resourceType === "issue", 27 + ); 28 + 29 + if (!issueLink) { 30 + return; 31 + } 32 + 33 + const metadata = issueLink.metadata ? JSON.parse(issueLink.metadata) : {}; 34 + 35 + // LOOP PREVENTION: Check if this update originated from GitHub 36 + const lastTitleSync = metadata.lastSync?.title; 37 + if (lastTitleSync) { 38 + // Skip if value unchanged and last sync was from GitHub 39 + if ( 40 + lastTitleSync.value === event.newTitle && 41 + lastTitleSync.source === "github" 42 + ) { 43 + console.log("Skipping title sync - already synced from GitHub"); 44 + return; 45 + } 46 + 47 + // Skip if recent sync (within 2 seconds) to prevent rapid loops 48 + const timeSinceLastSync = 49 + Date.now() - new Date(lastTitleSync.timestamp).getTime(); 50 + if (timeSinceLastSync < 2000) { 51 + console.log( 52 + `Skipping title sync - recent sync detected (${timeSinceLastSync}ms ago)`, 53 + ); 54 + return; 55 + } 56 + } 57 + 58 + let installationId = config.installationId; 59 + if (!installationId) { 60 + installationId = await getInstallationIdForRepo( 61 + repositoryOwner, 62 + repositoryName, 63 + ); 64 + } 65 + 66 + const octokit = await githubApp.getInstallationOctokit(installationId); 67 + const issueNumber = Number.parseInt(issueLink.externalId, 10); 68 + 69 + await octokit.rest.issues.update({ 70 + owner: repositoryOwner, 71 + repo: repositoryName, 72 + issue_number: issueNumber, 73 + title: event.newTitle, 74 + }); 75 + 76 + // Update metadata to track this sync 77 + await updateExternalLink(issueLink.id, { 78 + title: event.newTitle, 79 + metadata: { 80 + ...metadata, 81 + lastSync: { 82 + ...metadata.lastSync, 83 + title: { 84 + timestamp: new Date().toISOString(), 85 + source: "kaneo", 86 + value: event.newTitle, 87 + }, 88 + }, 89 + }, 90 + }); 91 + 92 + console.log(`Synced task title to GitHub issue #${issueNumber}`); 93 + } catch (error) { 94 + console.error("Failed to update GitHub issue title:", error); 95 + } 96 + }
+4
apps/api/src/plugins/github/index.ts
··· 1 1 import type { IntegrationPlugin } from "../types"; 2 2 import { validateGitHubConfig } from "./config"; 3 3 import { handleTaskCreated } from "./events/task-created"; 4 + import { handleTaskDescriptionChanged } from "./events/task-description-changed"; 4 5 import { handleTaskPriorityChanged } from "./events/task-priority-changed"; 5 6 import { handleTaskStatusChanged } from "./events/task-status-changed"; 7 + import { handleTaskTitleChanged } from "./events/task-title-changed"; 6 8 import { setupWebhookHandlers } from "./webhook-handler"; 7 9 8 10 export const githubPlugin: IntegrationPlugin = { ··· 11 13 onTaskCreated: handleTaskCreated, 12 14 onTaskStatusChanged: handleTaskStatusChanged, 13 15 onTaskPriorityChanged: handleTaskPriorityChanged, 16 + onTaskTitleChanged: handleTaskTitleChanged, 17 + onTaskDescriptionChanged: handleTaskDescriptionChanged, 14 18 validateConfig: validateGitHubConfig, 15 19 }; 16 20
+5
apps/api/src/plugins/github/webhook-handler.ts
··· 1 1 import { getGithubApp } from "./utils/github-app"; 2 2 import { handleIssueClosed } from "./webhooks/issue-closed"; 3 3 import { handleIssueCommentCreated } from "./webhooks/issue-comment-created"; 4 + import { handleIssueEdited } from "./webhooks/issue-edited"; 4 5 import { handleIssueLabeled } from "./webhooks/issue-labeled"; 5 6 import { handleIssueOpened } from "./webhooks/issue-opened"; 6 7 import { handleLabelCreated } from "./webhooks/label-created"; ··· 70 71 await handleIssueLabeled( 71 72 payload as Parameters<typeof handleIssueLabeled>[0], 72 73 ); 74 + }); 75 + 76 + githubApp.webhooks.on("issues.edited", async ({ payload }) => { 77 + await handleIssueEdited(payload as Parameters<typeof handleIssueEdited>[0]); 73 78 }); 74 79 75 80 githubApp.webhooks.on("push", async ({ payload }) => {
+184
apps/api/src/plugins/github/webhooks/issue-edited.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import db from "../../../database"; 3 + import { taskTable } from "../../../database/schema"; 4 + import { findExternalLink, updateExternalLink } from "../services/link-manager"; 5 + import { findIntegrationByRepo } from "../services/task-service"; 6 + import { formatTaskDescriptionFromIssue } from "../utils/format"; 7 + 8 + type IssueEditedPayload = { 9 + action: string; 10 + issue: { 11 + number: number; 12 + title: string; 13 + body: string | null; 14 + html_url: string; 15 + }; 16 + changes?: { 17 + title?: { 18 + from: string; 19 + }; 20 + body?: { 21 + from: string; 22 + }; 23 + }; 24 + repository: { 25 + owner: { login: string }; 26 + name: string; 27 + full_name: string; 28 + }; 29 + }; 30 + 31 + export async function handleIssueEdited(payload: IssueEditedPayload) { 32 + const { issue, repository, changes } = payload; 33 + 34 + // Early exit if no relevant changes 35 + if (!changes?.title && !changes?.body) { 36 + console.log( 37 + `Issue #${issue.number} edited but no title/body changes detected`, 38 + ); 39 + return; 40 + } 41 + 42 + const integration = await findIntegrationByRepo( 43 + repository.owner.login, 44 + repository.name, 45 + ); 46 + 47 + if (!integration) { 48 + console.log( 49 + `No integration found for ${repository.owner.login}/${repository.name}`, 50 + ); 51 + return; 52 + } 53 + 54 + const externalLink = await findExternalLink( 55 + integration.id, 56 + "issue", 57 + issue.number.toString(), 58 + ); 59 + 60 + if (!externalLink) { 61 + console.log(`No linked task found for issue #${issue.number}`); 62 + return; 63 + } 64 + 65 + const task = await db.query.taskTable.findFirst({ 66 + where: eq(taskTable.id, externalLink.taskId), 67 + }); 68 + 69 + if (!task) { 70 + console.error(`Task ${externalLink.taskId} not found`); 71 + return; 72 + } 73 + 74 + const metadata = externalLink.metadata 75 + ? JSON.parse(externalLink.metadata) 76 + : {}; 77 + 78 + const updateData: Record<string, unknown> = {}; 79 + const updatedMetadata = { ...metadata }; 80 + 81 + // Initialize lastSync if not present 82 + if (!updatedMetadata.lastSync) { 83 + updatedMetadata.lastSync = {}; 84 + } 85 + 86 + // Handle title change 87 + if (changes.title) { 88 + const lastTitleSync = metadata.lastSync?.title; 89 + 90 + // LOOP PREVENTION 91 + let shouldUpdateTitle = true; 92 + 93 + if (lastTitleSync) { 94 + // Skip if this value was just synced from Kaneo 95 + if ( 96 + lastTitleSync.value === issue.title && 97 + lastTitleSync.source === "kaneo" 98 + ) { 99 + console.log("Skipping title update - already synced from Kaneo"); 100 + shouldUpdateTitle = false; 101 + } 102 + 103 + // Skip if recent sync (within 2 seconds) 104 + const timeSinceLastSync = 105 + Date.now() - new Date(lastTitleSync.timestamp).getTime(); 106 + if (timeSinceLastSync < 2000 && shouldUpdateTitle) { 107 + console.log( 108 + `Skipping title update - recent sync detected (${timeSinceLastSync}ms ago)`, 109 + ); 110 + shouldUpdateTitle = false; 111 + } 112 + } 113 + 114 + if (shouldUpdateTitle) { 115 + updateData.title = issue.title; 116 + updatedMetadata.lastSync.title = { 117 + timestamp: new Date().toISOString(), 118 + source: "github", 119 + value: issue.title, 120 + }; 121 + console.log( 122 + `Updating task title from GitHub: "${changes.title.from}" → "${issue.title}"`, 123 + ); 124 + } 125 + } 126 + 127 + // Handle description change 128 + if (changes.body) { 129 + const lastDescSync = metadata.lastSync?.description; 130 + const formattedDescription = formatTaskDescriptionFromIssue(issue.body); 131 + 132 + // LOOP PREVENTION 133 + let shouldUpdateDescription = true; 134 + 135 + if (lastDescSync) { 136 + // Skip if this value was just synced from Kaneo 137 + if ( 138 + lastDescSync.value === formattedDescription && 139 + lastDescSync.source === "kaneo" 140 + ) { 141 + console.log("Skipping description update - already synced from Kaneo"); 142 + shouldUpdateDescription = false; 143 + } 144 + 145 + // Skip if recent sync (within 2 seconds) 146 + const timeSinceLastSync = 147 + Date.now() - new Date(lastDescSync.timestamp).getTime(); 148 + if (timeSinceLastSync < 2000 && shouldUpdateDescription) { 149 + console.log( 150 + `Skipping description update - recent sync detected (${timeSinceLastSync}ms ago)`, 151 + ); 152 + shouldUpdateDescription = false; 153 + } 154 + } 155 + 156 + if (shouldUpdateDescription) { 157 + updateData.description = formattedDescription; 158 + updatedMetadata.lastSync.description = { 159 + timestamp: new Date().toISOString(), 160 + source: "github", 161 + value: formattedDescription, 162 + }; 163 + console.log("Updating task description from GitHub"); 164 + } 165 + } 166 + 167 + // Apply updates if any 168 + if (Object.keys(updateData).length > 0) { 169 + await db.update(taskTable).set(updateData).where(eq(taskTable.id, task.id)); 170 + 171 + await updateExternalLink(externalLink.id, { 172 + title: issue.title, 173 + metadata: updatedMetadata, 174 + }); 175 + 176 + console.log( 177 + `Synced ${Object.keys(updateData).join(", ")} from GitHub issue #${issue.number} to task ${task.id}`, 178 + ); 179 + } else { 180 + console.log( 181 + `No updates needed for task ${task.id} from issue #${issue.number}`, 182 + ); 183 + } 184 + }
+78
apps/api/src/plugins/registry.ts
··· 6 6 IntegrationPlugin, 7 7 PluginContext, 8 8 TaskCreatedEvent, 9 + TaskDescriptionChangedEvent, 9 10 TaskPriorityChangedEvent, 10 11 TaskStatusChangedEvent, 12 + TaskTitleChangedEvent, 11 13 } from "./types"; 12 14 13 15 const plugins = new Map<string, IntegrationPlugin>(); ··· 84 86 }); 85 87 }); 86 88 89 + subscribeToEvent<{ 90 + taskId: string; 91 + userId: string | null; 92 + oldTitle: string; 93 + newTitle: string; 94 + projectId: string; 95 + }>("task.title_changed", async (data) => { 96 + await broadcastTaskTitleChanged({ 97 + taskId: data.taskId, 98 + projectId: data.projectId, 99 + userId: data.userId, 100 + oldTitle: data.oldTitle, 101 + newTitle: data.newTitle, 102 + }); 103 + }); 104 + 105 + subscribeToEvent<{ 106 + taskId: string; 107 + userId: string | null; 108 + oldDescription: string | null; 109 + newDescription: string | null; 110 + projectId: string; 111 + }>("task.description_changed", async (data) => { 112 + await broadcastTaskDescriptionChanged({ 113 + taskId: data.taskId, 114 + projectId: data.projectId, 115 + userId: data.userId, 116 + oldDescription: data.oldDescription, 117 + newDescription: data.newDescription, 118 + }); 119 + }); 120 + 87 121 eventSubscriptionsInitialized = true; 88 122 console.log("✓ Plugin event subscriptions initialized"); 89 123 } ··· 182 216 } 183 217 } 184 218 } 219 + 220 + export async function broadcastTaskTitleChanged( 221 + event: TaskTitleChangedEvent, 222 + ): Promise<void> { 223 + const integrations = await getActiveIntegrations(event.projectId); 224 + 225 + for (const integration of integrations) { 226 + const plugin = getPlugin(integration.type); 227 + if (!plugin?.onTaskTitleChanged) continue; 228 + 229 + const context = createContext(integration); 230 + 231 + try { 232 + await plugin.onTaskTitleChanged(event, context); 233 + } catch (error) { 234 + console.error( 235 + `Plugin ${plugin.type} error on task.title_changed:`, 236 + error, 237 + ); 238 + } 239 + } 240 + } 241 + 242 + export async function broadcastTaskDescriptionChanged( 243 + event: TaskDescriptionChangedEvent, 244 + ): Promise<void> { 245 + const integrations = await getActiveIntegrations(event.projectId); 246 + 247 + for (const integration of integrations) { 248 + const plugin = getPlugin(integration.type); 249 + if (!plugin?.onTaskDescriptionChanged) continue; 250 + 251 + const context = createContext(integration); 252 + 253 + try { 254 + await plugin.onTaskDescriptionChanged(event, context); 255 + } catch (error) { 256 + console.error( 257 + `Plugin ${plugin.type} error on task.description_changed:`, 258 + error, 259 + ); 260 + } 261 + } 262 + }
+21 -1
apps/api/src/plugins/types.ts
··· 33 33 title: string; 34 34 }; 35 35 36 + export type TaskTitleChangedEvent = { 37 + taskId: string; 38 + projectId: string; 39 + userId: string | null; 40 + oldTitle: string; 41 + newTitle: string; 42 + }; 43 + 44 + export type TaskDescriptionChangedEvent = { 45 + taskId: string; 46 + projectId: string; 47 + userId: string | null; 48 + oldDescription: string | null; 49 + newDescription: string | null; 50 + }; 51 + 36 52 export type TaskEvent = 37 53 | TaskCreatedEvent 38 54 | TaskStatusChangedEvent 39 - | TaskPriorityChangedEvent; 55 + | TaskPriorityChangedEvent 56 + | TaskTitleChangedEvent 57 + | TaskDescriptionChangedEvent; 40 58 41 59 export type ExternalMetadata = { 42 60 type: "issue" | "pull_request" | "branch"; ··· 74 92 onTaskCreated?: TaskEventHandler<TaskCreatedEvent>; 75 93 onTaskStatusChanged?: TaskEventHandler<TaskStatusChangedEvent>; 76 94 onTaskPriorityChanged?: TaskEventHandler<TaskPriorityChangedEvent>; 95 + onTaskTitleChanged?: TaskEventHandler<TaskTitleChangedEvent>; 96 + onTaskDescriptionChanged?: TaskEventHandler<TaskDescriptionChangedEvent>; 77 97 78 98 handleWebhook?: WebhookHandler; 79 99 getTaskMetadata?: MetadataProvider;
+27 -3
apps/api/src/task/index.ts
··· 1 + import { eq } from "drizzle-orm"; 1 2 import { Hono } from "hono"; 3 + import { HTTPException } from "hono/http-exception"; 2 4 import { describeRoute, resolver, validator } from "hono-openapi"; 3 5 import * as v from "valibot"; 4 6 import { auth } from "../auth"; 7 + import db from "../database"; 8 + import { taskTable } from "../database/schema"; 5 9 import { publishEvent } from "../events"; 6 10 import { taskSchema } from "../schemas"; 7 11 import { workspaceAccess } from "../utils/workspace-access-middleware"; ··· 476 480 const { title } = c.req.valid("json"); 477 481 const user = c.get("userId"); 478 482 483 + // Fetch task BEFORE update to get old title 484 + const existingTask = await db.query.taskTable.findFirst({ 485 + where: eq(taskTable.id, id), 486 + }); 487 + 488 + if (!existingTask) { 489 + throw new HTTPException(404, { message: "Task not found" }); 490 + } 491 + 479 492 const task = await updateTaskTitle({ id, title }); 480 493 481 494 await publishEvent("task.title_changed", { 482 495 taskId: task.id, 496 + projectId: task.projectId, 483 497 userId: user, 484 - oldTitle: task.title, 498 + oldTitle: existingTask.title, 485 499 newTitle: title, 486 - title: task.title, 487 500 type: "title_changed", 488 501 }); 489 502 ··· 514 527 const { description } = c.req.valid("json"); 515 528 const user = c.get("userId"); 516 529 530 + // Fetch task BEFORE update to get old description 531 + const existingTask = await db.query.taskTable.findFirst({ 532 + where: eq(taskTable.id, id), 533 + }); 534 + 535 + if (!existingTask) { 536 + throw new HTTPException(404, { message: "Task not found" }); 537 + } 538 + 517 539 const task = await updateTaskDescription({ id, description }); 518 540 519 541 await publishEvent("task.description_changed", { 520 542 taskId: task.id, 543 + projectId: task.projectId, 521 544 userId: user, 522 - title: task.title, 545 + oldDescription: existingTask.description, 546 + newDescription: description, 523 547 type: "description_changed", 524 548 }); 525 549