mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
1f097b0fdd
* chore: resolve no-explicit-any lint warnings in plugins Replaces uses of `any` in the plugins directory with concrete types, `unknown`, or structured type assertions, addressing the remaining typescript-eslint(no-explicit-any) warnings flagged by oxlint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: address review feedback in GitLabIssueProvider Drop trailing semicolon from log string and add early return in `destroyNamespace` when neither `user_id` nor `full_path` is present to avoid an unnecessary full-scan transaction. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import type { IssueSource } from "@shared/schema";
|
|
import { IntegrationService, type IntegrationType } from "@shared/types";
|
|
import Logger from "@server/logging/Logger";
|
|
import { Integration, IntegrationAuthentication } from "@server/models";
|
|
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
|
|
import { GitLab } from "./gitlab";
|
|
import { sequelize } from "@server/storage/database";
|
|
import { Op, type WhereOptions } from "sequelize";
|
|
|
|
interface GitLabWebhookPayload {
|
|
event_name?: string;
|
|
old_full_path?: string;
|
|
old_username?: string;
|
|
full_path?: string;
|
|
username?: string;
|
|
group_id?: string;
|
|
user_id?: string;
|
|
project_id?: string | number;
|
|
project_namespace_id?: string | number;
|
|
name?: string;
|
|
path_with_namespace?: string;
|
|
changes?: { before: string }[];
|
|
project?: {
|
|
name: string;
|
|
path_with_namespace: string;
|
|
};
|
|
}
|
|
|
|
export class GitLabIssueProvider extends BaseIssueProvider {
|
|
constructor() {
|
|
super(IntegrationService.GitLab);
|
|
}
|
|
|
|
async fetchSources(
|
|
integration: Integration<IntegrationType.Embed>
|
|
): Promise<IssueSource[]> {
|
|
await integration.reload({
|
|
include: [
|
|
{
|
|
model: IntegrationAuthentication,
|
|
as: "authentication",
|
|
required: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!integration.authentication) {
|
|
Logger.warn("GitLab integration without authentication");
|
|
return [];
|
|
}
|
|
|
|
const sources: IssueSource[] = [];
|
|
|
|
try {
|
|
const projects = await GitLab.getProjects({
|
|
accessToken: integration.authentication.token,
|
|
teamId: integration.teamId,
|
|
});
|
|
|
|
sources.push(
|
|
...projects.map<IssueSource>((project) => ({
|
|
id: String(project.id),
|
|
name: project.name,
|
|
owner: {
|
|
id: String(project.namespace.id),
|
|
name: project.namespace.full_path,
|
|
},
|
|
service: IntegrationService.GitLab,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
Logger.warn("Failed to fetch projects from GitLab", err);
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
async handleWebhook({
|
|
payload,
|
|
headers,
|
|
}: {
|
|
payload: Record<string, unknown>;
|
|
headers: Record<string, unknown>;
|
|
}) {
|
|
const hookId = headers["x-gitlab-webhook-uuid"] as string;
|
|
const typedPayload = payload as GitLabWebhookPayload;
|
|
const eventName = typedPayload.event_name;
|
|
|
|
if (!eventName) {
|
|
Logger.warn(
|
|
`Received GitLab webhook without event name; hookId: ${hookId}, eventName: ${eventName}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
switch (eventName) {
|
|
case "project_update":
|
|
case "project_transfer":
|
|
case "project_rename":
|
|
await this.updateProject(typedPayload);
|
|
break;
|
|
case "repository_update":
|
|
await this.createProject(typedPayload);
|
|
break;
|
|
case "project_destroy":
|
|
await this.destroyProject(typedPayload);
|
|
break;
|
|
case "group_rename":
|
|
case "user_rename":
|
|
await this.updateNamespace(typedPayload);
|
|
break;
|
|
case "user_destroy":
|
|
case "group_destroy":
|
|
await this.destroyNamespace(typedPayload);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async updateNamespace(payload: GitLabWebhookPayload) {
|
|
const name = payload.old_full_path ?? payload.old_username;
|
|
const where = {
|
|
service: IntegrationService.GitLab,
|
|
[Op.and]: sequelize.literal(`"issueSources"::jsonb @> :jsonCondition`),
|
|
};
|
|
const jsonCondition = JSON.stringify([{ owner: { name } }]);
|
|
|
|
await sequelize.transaction(async (transaction) => {
|
|
const integration = (await Integration.findOne({
|
|
where,
|
|
replacements: { jsonCondition },
|
|
lock: transaction.LOCK.UPDATE,
|
|
transaction,
|
|
})) as Integration<IntegrationType.Embed>;
|
|
|
|
if (!integration) {
|
|
Logger.warn(`GitLab namespace_update event without integration;`);
|
|
return;
|
|
}
|
|
|
|
const newName = payload.full_path ?? payload.username;
|
|
if (!newName) {
|
|
Logger.warn(`GitLab namespace_update event without new name`);
|
|
return;
|
|
}
|
|
|
|
const sources = integration.issueSources ?? [];
|
|
const updatedSources = sources.map((source) => {
|
|
if (source.owner.name === name) {
|
|
return {
|
|
...source,
|
|
owner: {
|
|
id: payload.group_id || source.owner.id,
|
|
name: newName,
|
|
},
|
|
};
|
|
}
|
|
return source;
|
|
});
|
|
|
|
integration.issueSources = updatedSources;
|
|
integration.changed("issueSources", true);
|
|
await integration.save({ transaction });
|
|
});
|
|
}
|
|
|
|
private async destroyNamespace(payload: GitLabWebhookPayload) {
|
|
if (!payload.user_id && !payload.full_path) {
|
|
Logger.warn(
|
|
`GitLab namespace_destroy event without user_id or full_path`
|
|
);
|
|
return;
|
|
}
|
|
|
|
let replacements = {};
|
|
const whereCondition: WhereOptions = {
|
|
service: IntegrationService.GitLab,
|
|
...(payload.user_id && {
|
|
"settings.gitlab.installation.account.id": payload.user_id,
|
|
}),
|
|
...(!payload.user_id &&
|
|
payload.full_path && {
|
|
[Op.and]: sequelize.literal(
|
|
`"issueSources"::jsonb @> :jsonCondition`
|
|
),
|
|
}),
|
|
};
|
|
|
|
if (!payload.user_id && payload.full_path) {
|
|
replacements = {
|
|
jsonCondition: JSON.stringify([{ owner: { name: payload.full_path } }]),
|
|
};
|
|
}
|
|
|
|
await sequelize.transaction(async (transaction) => {
|
|
const integrations = (await Integration.findAll({
|
|
where: whereCondition,
|
|
replacements,
|
|
lock: transaction.LOCK.UPDATE,
|
|
transaction,
|
|
})) as Integration<IntegrationType.Embed>[];
|
|
|
|
if (!integrations.length) {
|
|
Logger.warn(`GitLab namespace_destroy event without integration;`);
|
|
return;
|
|
}
|
|
|
|
for (const integration of integrations) {
|
|
if (payload.full_path) {
|
|
const sources =
|
|
integration.issueSources?.filter(
|
|
(source) => payload.full_path !== source.owner.name
|
|
) ?? [];
|
|
|
|
integration.issueSources = sources;
|
|
integration.changed("issueSources", true);
|
|
await integration.save({ transaction });
|
|
} else if (payload.user_id) {
|
|
await integration.destroy();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private async destroyProject(payload: GitLabWebhookPayload) {
|
|
await sequelize.transaction(async (transaction) => {
|
|
const integrations = await Integration.findAll({
|
|
where: {
|
|
service: IntegrationService.GitLab,
|
|
[Op.and]: sequelize.where(
|
|
sequelize.literal(`"issueSources"::jsonb @> :projectJson`),
|
|
Op.eq,
|
|
true
|
|
),
|
|
},
|
|
replacements: {
|
|
projectJson: JSON.stringify([{ id: String(payload.project_id) }]),
|
|
},
|
|
lock: transaction.LOCK.UPDATE,
|
|
transaction,
|
|
});
|
|
|
|
if (!integrations.length) {
|
|
Logger.warn(`GitLab project_destroy event without integration;`);
|
|
return;
|
|
}
|
|
|
|
for (const integration of integrations) {
|
|
const sources =
|
|
integration.issueSources?.filter(
|
|
(source) => String(payload.project_id) !== source.id
|
|
) ?? [];
|
|
|
|
integration.issueSources = sources;
|
|
integration.changed("issueSources", true);
|
|
await integration.save({ transaction });
|
|
}
|
|
});
|
|
}
|
|
|
|
private async createProject(payload: GitLabWebhookPayload) {
|
|
const createEvent = payload.changes?.some((p: { before: string }) =>
|
|
/^0{40}$/.test(p.before)
|
|
);
|
|
|
|
if (!createEvent) {
|
|
return;
|
|
}
|
|
|
|
const project = payload.project;
|
|
if (!project || !payload.project_id) {
|
|
return;
|
|
}
|
|
|
|
await sequelize.transaction(async (transaction) => {
|
|
const integration = (await Integration.findOne({
|
|
where: {
|
|
service: IntegrationService.GitLab,
|
|
"settings.gitlab.installation.account.id": payload.user_id,
|
|
},
|
|
lock: transaction.LOCK.UPDATE,
|
|
})) as Integration<IntegrationType.Embed>;
|
|
|
|
if (!integration) {
|
|
Logger.warn(`GitLab project_create event without integration;`);
|
|
return;
|
|
}
|
|
|
|
const owner = {
|
|
id: "", // namespace.id is not provided in this webhook payload
|
|
name: project.path_with_namespace.split("/").slice(0, -1).join("/"),
|
|
};
|
|
const sources = integration.issueSources ?? [];
|
|
sources.push({
|
|
id: String(payload.project_id),
|
|
name: project.name,
|
|
service: IntegrationService.GitLab,
|
|
owner,
|
|
});
|
|
|
|
integration.issueSources = sources;
|
|
integration.changed("issueSources", true);
|
|
await integration.save({ transaction });
|
|
});
|
|
}
|
|
|
|
private async updateProject(payload: GitLabWebhookPayload) {
|
|
if (!payload.name || !payload.path_with_namespace) {
|
|
return;
|
|
}
|
|
const newName = payload.name;
|
|
const pathWithNamespace = payload.path_with_namespace;
|
|
|
|
await sequelize.transaction(async (transaction) => {
|
|
const integrations = await Integration.findAll({
|
|
where: {
|
|
service: IntegrationService.GitLab,
|
|
[Op.and]: sequelize.where(
|
|
sequelize.literal(`"issueSources"::jsonb @> :projectJson`),
|
|
Op.eq,
|
|
true
|
|
),
|
|
},
|
|
replacements: {
|
|
projectJson: JSON.stringify([{ id: String(payload.project_id) }]),
|
|
},
|
|
lock: transaction.LOCK.UPDATE,
|
|
transaction,
|
|
});
|
|
|
|
if (!integrations.length) {
|
|
Logger.warn(`GitLab project_update event without integration;`);
|
|
return;
|
|
}
|
|
|
|
for (const integration of integrations) {
|
|
const source = integration.issueSources?.find(
|
|
(s) => s.id === String(payload.project_id)
|
|
);
|
|
|
|
if (source) {
|
|
source.name = newName;
|
|
source.owner.name = pathWithNamespace
|
|
.split("/")
|
|
.slice(0, -1)
|
|
.join("/");
|
|
source.owner.id = String(payload.project_namespace_id);
|
|
integration.changed("issueSources", true);
|
|
await integration.save({ transaction });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|