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.

fix(gitea): map upstream errors, tighten sync, and fix bulk labels

- Map GiteaApiError from verifyGiteaToken/getRepo to HTTPException
- Skip inactive integrations in repository conflict check
- Only treat AbortError as timeout when our timer fired
- Instantiate Gitea client in addLabelsToIssueGitea
- Sync label colors on Gitea label_updated webhooks (batch by color)
- Skip issue reopen sync when webhook timestamp matches recent outbound state sync
- Set lastOutboundStateSyncAt on outbound issue create and status-driven updates
- Match bulk addLabel existence check to (taskId, name) unique constraint

+80 -18
+17 -6
apps/api/src/gitea-integration/controllers/create-gitea-integration.ts
··· 11 11 } from "../../plugins/gitea/config"; 12 12 import { 13 13 createGiteaClient, 14 + GiteaApiError, 14 15 verifyGiteaToken, 15 16 } from "../../plugins/gitea/utils/gitea-api"; 16 17 ··· 63 64 }); 64 65 } 65 66 66 - await verifyGiteaToken(normalizedBase, resolvedToken); 67 + try { 68 + await verifyGiteaToken(normalizedBase, resolvedToken); 67 69 68 - const client = createGiteaClient({ 69 - baseUrl: normalizedBase, 70 - accessToken: resolvedToken, 71 - }); 72 - await client.getRepo(repositoryOwner, repositoryName); 70 + const client = createGiteaClient({ 71 + baseUrl: normalizedBase, 72 + accessToken: resolvedToken, 73 + }); 74 + await client.getRepo(repositoryOwner, repositoryName); 75 + } catch (error) { 76 + if (error instanceof GiteaApiError) { 77 + throw new HTTPException(error.status || 400, { message: error.message }); 78 + } 79 + throw error; 80 + } 73 81 74 82 const allGitea = await db.query.integrationTable.findMany({ 75 83 where: eq(integrationTable.type, "gitea"), ··· 77 85 78 86 for (const integration of allGitea) { 79 87 if (integration.projectId === projectId) { 88 + continue; 89 + } 90 + if (!integration.isActive) { 80 91 continue; 81 92 } 82 93 try {
+1
apps/api/src/plugins/gitea/events/task-created.ts
··· 54 54 metadata: { 55 55 state: createdIssue.state, 56 56 createdFrom: "kaneo", 57 + lastOutboundStateSyncAt: Date.now(), 57 58 }, 58 59 }); 59 60
+2
apps/api/src/plugins/gitea/events/task-status-changed.ts
··· 48 48 metadata: { 49 49 ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), 50 50 state: "closed", 51 + lastOutboundStateSyncAt: Date.now(), 51 52 }, 52 53 }); 53 54 } else if (event.oldStatus === "done" && event.newStatus !== "done") { ··· 59 60 metadata: { 60 61 ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), 61 62 state: "open", 63 + lastOutboundStateSyncAt: Date.now(), 62 64 }, 63 65 }); 64 66 }
+12 -8
apps/api/src/plugins/gitea/utils/gitea-api.ts
··· 69 69 const url = `${root}/api/v1${path.startsWith("/") ? path : `/${path}`}`; 70 70 71 71 const controller = new AbortController(); 72 - const timeoutId = setTimeout( 73 - () => controller.abort(), 74 - GITEA_FETCH_TIMEOUT_MS, 75 - ); 72 + let timedOut = false; 73 + const timeoutId = setTimeout(() => { 74 + timedOut = true; 75 + controller.abort(); 76 + }, GITEA_FETCH_TIMEOUT_MS); 76 77 if (init?.signal) { 77 78 if (init.signal.aborted) { 78 79 controller.abort(); ··· 95 96 }); 96 97 } catch (error) { 97 98 if (error instanceof Error && error.name === "AbortError") { 98 - throw new GiteaApiError( 99 - `Gitea request timed out after ${GITEA_FETCH_TIMEOUT_MS}ms`, 100 - 408, 101 - ); 99 + if (timedOut) { 100 + throw new GiteaApiError( 101 + `Gitea request timed out after ${GITEA_FETCH_TIMEOUT_MS}ms`, 102 + 408, 103 + ); 104 + } 105 + throw error; 102 106 } 103 107 throw error; 104 108 } finally {
+2
apps/api/src/plugins/gitea/utils/labels.ts
··· 82 82 83 83 if (ids.length === 0) return; 84 84 85 + const client = createGiteaClient(config); 86 + 85 87 try { 86 88 await client.addLabelsToIssue( 87 89 config.repositoryOwner,
+26 -1
apps/api/src/plugins/gitea/webhooks/issue-labeled.ts
··· 47 47 return out; 48 48 } 49 49 50 + function normalizedGiteaLabelColor(g: { color?: string }): string { 51 + return g.color ? `#${g.color.replace(/^#/, "")}` : "#6B7280"; 52 + } 53 + 50 54 async function syncGiteaLabelsToTask( 51 55 taskId: string, 52 56 workspaceId: string, ··· 61 65 .filter((g) => !existingRows.some((row) => row.name === g.name)) 62 66 .map((g) => ({ 63 67 name: g.name, 64 - color: g.color ? `#${g.color.replace(/^#/, "")}` : "#6B7280", 68 + color: normalizedGiteaLabelColor(g), 65 69 taskId, 66 70 workspaceId, 67 71 })); 72 + 73 + const colorToIds = new Map<string, string[]>(); 74 + for (const g of giteaLabels) { 75 + if (isSystemLabelName(g.name)) continue; 76 + const row = existingRows.find((r) => r.name === g.name); 77 + if (!row) continue; 78 + const want = normalizedGiteaLabelColor(g); 79 + const have = row.color ? `#${row.color.replace(/^#/, "")}` : "#6B7280"; 80 + if (have === want) continue; 81 + const list = colorToIds.get(want) ?? []; 82 + list.push(row.id); 83 + colorToIds.set(want, list); 84 + } 85 + 86 + for (const [color, ids] of colorToIds) { 87 + if (ids.length === 0) continue; 88 + await db 89 + .update(labelTable) 90 + .set({ color }) 91 + .where(inArray(labelTable.id, ids)); 92 + } 68 93 69 94 if (labelsToInsert.length > 0) { 70 95 await db
+20 -2
apps/api/src/plugins/gitea/webhooks/issue-reopened.ts
··· 10 10 import { resolveTargetStatus } from "../utils/resolve-column"; 11 11 import { baseUrlFromRepositoryHtmlUrl } from "../utils/webhook-repo"; 12 12 13 + /** Skip reopen sync when it likely echoes our own outbound state update (webhook vs API). */ 14 + const OUTBOUND_STATE_ECHO_WINDOW_MS = 5000; 15 + 16 + function parseIssueUpdatedAtMs(issue: { updated_at?: string }): number | null { 17 + const raw = issue.updated_at; 18 + if (!raw || typeof raw !== "string") return null; 19 + const t = Date.parse(raw); 20 + return Number.isNaN(t) ? null : t; 21 + } 22 + 13 23 type IssueReopenedPayload = { 14 24 action: string; 15 25 issue: { ··· 17 27 title: string; 18 28 html_url: string; 19 29 state: string; 30 + updated_at?: string; 20 31 }; 21 32 repository: { 22 33 owner: { login?: string; username?: string }; ··· 80 91 } 81 92 } 82 93 83 - if (existingMetadata.createdFrom === "kaneo") { 84 - continue; 94 + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; 95 + if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) { 96 + const eventMs = parseIssueUpdatedAtMs(issue); 97 + if ( 98 + eventMs !== null && 99 + Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS 100 + ) { 101 + continue; 102 + } 85 103 } 86 104 87 105 const targetStatus = await resolveTargetStatus(
-1
apps/api/src/task/controllers/bulk-update-tasks.ts
··· 167 167 const existingAssignment = await db.query.labelTable.findFirst({ 168 168 where: and( 169 169 eq(labelTable.name, label.name), 170 - eq(labelTable.color, label.color), 171 170 eq(labelTable.taskId, task.id), 172 171 ), 173 172 });