Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
85e07cf
fix(integrations): enforce current task ownership
tinsever Sep 29, 2026
e04e85e
style(integrations): format scoped gitea close handler
tinsever Sep 29, 2026
8787420
fix(integrations): preserve resources and fetch comments before locki…
tinsever Sep 29, 2026
394cd41
fix(tasks): invalidate moved resources in the initiating window
tinsever Sep 29, 2026
ca29c96
fix: address verified concurrency and migration review findings
tinsever Sep 29, 2026
71e2a3e
fix: preserve live resources across concurrent moves and cleanup
tinsever Sep 29, 2026
f2e2527
fix: address verified delivery and concurrency review findings
tinsever Sep 29, 2026
088a8f1
fix: preserve manual links for destinations without integrations
tinsever Sep 29, 2026
f37b776
merge: preserve current upstream PR matching and reopen behavior
tinsever Sep 29, 2026
6104cd0
Revalidate imported issue links after provider reads
tinsever Sep 30, 2026
2dd66f6
fix(integrations): guard pull request transitions against task moves
tinsever Sep 30, 2026
802c5bd
fix(integrations): revalidate links across webhook and import continu…
tinsever Sep 30, 2026
ca6e4f7
fix(integrations): publish live task updates after committed provider…
tinsever Sep 30, 2026
cf2b030
fix(integrations): refresh imported task details after committed pages
tinsever Sep 30, 2026
b62c486
fix(integrations): publish complete GitHub task creation data
tinsever Sep 30, 2026
53c75ea
fix(integrations): announce committed PR links and reimported resources
tinsever Sep 30, 2026
2fca490
fix(integrations): keep branch link and status changes in one ownersh…
tinsever Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 109 additions & 65 deletions apps/api/src/gitea-integration/controllers/import-gitea-issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
import db from "../../database";
import {
activityTable,
externalLinkTable,
integrationTable,
labelTable,
projectTable,
Expand All @@ -14,6 +15,7 @@ import { isKaneoComment } from "../../plugins/gitea/utils/comment-origin";
import {
createGiteaClient,
type GiteaIssue,
type GiteaComment,
type GiteaLabel,
type GiteaPullRequest,
} from "../../plugins/gitea/utils/gitea-api";
Expand All @@ -29,6 +31,12 @@ import {
import { formatTaskDescriptionFromIssue } from "../../plugins/github/utils/format";
import { claimTaskNumber } from "../../task/controllers/claim-task-numbers";

import {
type IntegrationDatabase,
linkedTaskScope,
withIntegrationTask,
} from "../../plugins/github/services/integration-task-scope";

type ImportResult = {
imported: number;
updated: number;
Expand Down Expand Up @@ -209,71 +217,104 @@ async function importSingleIssue(
const priority = extractIssuePriority(adaptedLabels);
const status = extractIssueStatus(adaptedLabels);

const comments = await fetchIssueComments(issue.number, config, client);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck issue links after fetching comments

When an issue is initially unlinked and its opened webhook creates a task while this potentially paginated provider request is pending, existingLink remains null and the importer proceeds down the creation branch without rechecking. Because external_link has no uniqueness constraint for an integration/resource/external ID, both paths can commit separate tasks and links for the same provider issue. Serialize creation with the webhook or recheck under a shared lock after the fetch; the GitLab notes path at import-gitlab-issues.ts:196 has the same race.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this creation race, including in the pre-PR main implementation: it checks for an issue link before creation but does not recheck under a creation lock or enforce an external-resource uniqueness constraint. Moving comment/notes requests outside ownership transactions widens an existing window; the current ownership guards still prevent writes crossing project/workspace boundaries. Coordinating importer and opened-webhook creation is separate provider-creation idempotency work, alongside the previously reported remote-orphan compensation followup. I am reporting it without expanding this ownership PR into that lifecycle redesign.


if (existingLink) {
const updateData: Record<string, unknown> = {
title: issue.title,
description: formatTaskDescriptionFromIssue(issue.body),
};
const result = await withIntegrationTask(
existingLink.taskId,
{ id: integrationId, projectId, project: { workspaceId } },
Comment thread
tinsever marked this conversation as resolved.
async (database) => {
const [linked] = await database
.select({ id: externalLinkTable.id })
.from(externalLinkTable)
.where(
and(
eq(externalLinkTable.id, existingLink.id),
eq(externalLinkTable.taskId, existingLink.taskId),
eq(externalLinkTable.integrationId, integrationId),
),
)
.for("update");
if (!linked) return "skipped" as const;

if (priority) updateData.priority = priority;
if (status) updateData.status = status;
const updateData: Record<string, unknown> = {
title: issue.title,
description: formatTaskDescriptionFromIssue(issue.body),
};

await db
.update(taskTable)
.set(updateData)
.where(eq(taskTable.id, existingLink.taskId));
if (priority) updateData.priority = priority;
if (status) updateData.status = status;

await importLabelsForTask(labels, existingLink.taskId, workspaceId);
await database
.update(taskTable)
.set(updateData)
.where(linkedTaskScope(existingLink.taskId, projectId));

await importCommentsForTask(
issue.number,
existingLink.taskId,
config,
client,
);
await importLabelsForTask(
labels,
existingLink.taskId,
workspaceId,
database,
);

return "updated";
await importCommentsForTask(comments, existingLink.taskId, database);

return "updated" as const;
},
);
return result ?? "skipped";
}

const createdTask = await db.transaction(async (tx) => {
const nextNumber = await claimTaskNumber(projectId, tx);
const createdTask = await withIntegrationTask(
null,
{ id: integrationId, projectId, project: { workspaceId } },
async (tx) => {
const nextNumber = await claimTaskNumber(projectId, tx);

const taskValues: typeof taskTable.$inferInsert = {
projectId,
userId: null,
title: issue.title,
description: formatTaskDescriptionFromIssue(issue.body),
status: status || "to-do",
priority: priority ?? "low",
number: nextNumber,
};
const taskValues: typeof taskTable.$inferInsert = {
projectId,
userId: null,
title: issue.title,
description: formatTaskDescriptionFromIssue(issue.body),
status: status || "to-do",
priority: priority ?? "low",
number: nextNumber,
};

const [created] = await tx.insert(taskTable).values(taskValues).returning();
const [created] = await tx
.insert(taskTable)
.values(taskValues)
.returning();

if (!created) {
throw new Error("Failed to create task");
}
if (!created) {
throw new Error("Failed to create task");
}

return created;
});
await createExternalLink(
{
taskId: created.id,
integrationId,
resourceType: "issue",
externalId: issue.number.toString(),
url: issue.html_url,
title: issue.title,
metadata: {
state: issue.state,
createdFrom: "gitea-import",
author: issue.user?.login ?? issue.user?.username,
},
},
tx,
);

await createExternalLink({
taskId: createdTask.id,
integrationId,
resourceType: "issue",
externalId: issue.number.toString(),
url: issue.html_url,
title: issue.title,
metadata: {
state: issue.state,
createdFrom: "gitea-import",
author: issue.user?.login ?? issue.user?.username,
},
});
await importLabelsForTask(labels, created.id, workspaceId, tx);

await importLabelsForTask(labels, createdTask.id, workspaceId);
await importCommentsForTask(comments, created.id, tx);

await importCommentsForTask(issue.number, createdTask.id, config, client);
return created;
},
);
if (!createdTask) return "skipped";

await publishEvent("task.created", {
...createdTask,
Expand All @@ -293,6 +334,7 @@ async function importLabelsForTask(
issueLabels: GiteaIssue["labels"],
taskId: string,
workspaceId: string,
database: IntegrationDatabase = db,
): Promise<void> {
const nonSystemLabels = (issueLabels ?? [])
.map((label) => {
Expand All @@ -316,7 +358,7 @@ async function importLabelsForTask(
const expectedNames = nonSystemLabels.map((label) => label.name);

if (expectedNames.length > 0) {
await db
await database
.delete(labelTable)
.where(
and(
Expand All @@ -325,10 +367,10 @@ async function importLabelsForTask(
),
);
} else {
await db.delete(labelTable).where(eq(labelTable.taskId, taskId));
await database.delete(labelTable).where(eq(labelTable.taskId, taskId));
}

const existingLabelsOnTask = await db.query.labelTable.findMany({
const existingLabelsOnTask = await database.query.labelTable.findMany({
where:
expectedNames.length > 0
? and(
Expand All @@ -347,7 +389,7 @@ async function importLabelsForTask(
continue;
}

const existingWorkspaceLabel = await db.query.labelTable.findFirst({
const existingWorkspaceLabel = await database.query.labelTable.findFirst({
where: and(
eq(labelTable.workspaceId, workspaceId),
eq(labelTable.name, labelData.name),
Expand All @@ -356,7 +398,7 @@ async function importLabelsForTask(

const colorToUse = existingWorkspaceLabel?.color || labelData.color;

await db
await database
.insert(labelTable)
.values({
name: labelData.name,
Expand All @@ -370,18 +412,12 @@ async function importLabelsForTask(
}
}

async function importCommentsForTask(
async function fetchIssueComments(
issueNumber: number,
taskId: string,
config: GiteaConfig,
client: ReturnType<typeof createGiteaClient>,
): Promise<void> {
const allComments: Array<{
id: number;
body: string;
html_url: string;
user?: { login?: string; username?: string; avatar_url?: string } | null;
}> = [];
): Promise<GiteaComment[]> {
const allComments: GiteaComment[] = [];
let page = 1;

while (true) {
Expand All @@ -401,13 +437,21 @@ async function importCommentsForTask(
page++;
}

return allComments;
}

async function importCommentsForTask(
allComments: GiteaComment[],
taskId: string,
database: IntegrationDatabase,
): Promise<void> {
for (const comment of allComments) {
const username = comment.user?.login ?? comment.user?.username ?? "";
if (username.endsWith("[bot]") || isKaneoComment(comment.body)) {
continue;
}

await db
await database
.insert(activityTable)
.values({
taskId,
Expand Down
Loading