chore: resolve no-explicit-any lint warnings in plugins (#12237)

* 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>
This commit is contained in:
Tom Moor
2026-05-01 08:29:58 -04:00
committed by GitHub
parent e2c28f4b9f
commit 1f097b0fdd
21 changed files with 202 additions and 98 deletions
+8 -3
View File
@@ -1,6 +1,8 @@
import { useCallback, useRef } from "react";
import { getCookie, removeCookie, setCookie } from "tiny-cookie";
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState";
import usePersistedState, {
setPersistedState,
} from "~/hooks/usePersistedState";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isAllowedLoginRedirect } from "~/utils/urls";
@@ -39,9 +41,12 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
*/
export function useTrackLastVisitedPath(currentPath: string): void {
const prevPathRef = useRef<string>();
// Update localStorage directly if path has changed
if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) {
if (
prevPathRef.current !== currentPath &&
isAllowedLoginRedirect(currentPath)
) {
prevPathRef.current = currentPath;
setPersistedState("lastVisitedPath", currentPath);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for share management operations.
*
*
* @param targetShare - the share to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if share is null.
*/
+1 -1
View File
@@ -24,7 +24,7 @@ import {
/**
* Hook that constructs the action menu for user management operations.
*
*
* @param targetUser - the user to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if user is null.
*/
+72 -25
View File
@@ -5,7 +5,26 @@ import { Integration, IntegrationAuthentication } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitLab } from "./gitlab";
import { sequelize } from "@server/storage/database";
import { Op } from "sequelize";
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() {
@@ -64,7 +83,8 @@ export class GitLabIssueProvider extends BaseIssueProvider {
headers: Record<string, unknown>;
}) {
const hookId = headers["x-gitlab-webhook-uuid"] as string;
const eventName = payload.event_name as string;
const typedPayload = payload as GitLabWebhookPayload;
const eventName = typedPayload.event_name;
if (!eventName) {
Logger.warn(
@@ -77,28 +97,28 @@ export class GitLabIssueProvider extends BaseIssueProvider {
case "project_update":
case "project_transfer":
case "project_rename":
await this.updateProject(payload);
await this.updateProject(typedPayload);
break;
case "repository_update":
await this.createProject(payload);
await this.createProject(typedPayload);
break;
case "project_destroy":
await this.destroyProject(payload);
await this.destroyProject(typedPayload);
break;
case "group_rename":
case "user_rename":
await this.updateNamespace(payload);
await this.updateNamespace(typedPayload);
break;
case "user_destroy":
case "group_destroy":
await this.destroyNamespace(payload);
await this.destroyNamespace(typedPayload);
break;
default:
break;
}
}
private async updateNamespace(payload: Record<string, any>) {
private async updateNamespace(payload: GitLabWebhookPayload) {
const name = payload.old_full_path ?? payload.old_username;
const where = {
service: IntegrationService.GitLab,
@@ -119,6 +139,12 @@ export class GitLabIssueProvider extends BaseIssueProvider {
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) {
@@ -126,7 +152,7 @@ export class GitLabIssueProvider extends BaseIssueProvider {
...source,
owner: {
id: payload.group_id || source.owner.id,
name: payload.full_path ?? payload.username,
name: newName,
},
};
}
@@ -139,19 +165,29 @@ export class GitLabIssueProvider extends BaseIssueProvider {
});
}
private async destroyNamespace(payload: Record<string, any>) {
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: any = {
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) {
whereCondition["settings.gitlab.installation.account.id"] =
payload.user_id;
} else if (payload.full_path) {
whereCondition[Op.and] = sequelize.literal(
`"issueSources"::jsonb @> :jsonCondition`
);
if (!payload.user_id && payload.full_path) {
replacements = {
jsonCondition: JSON.stringify([{ owner: { name: payload.full_path } }]),
};
@@ -187,7 +223,7 @@ export class GitLabIssueProvider extends BaseIssueProvider {
});
}
private async destroyProject(payload: Record<string, any>) {
private async destroyProject(payload: GitLabWebhookPayload) {
await sequelize.transaction(async (transaction) => {
const integrations = await Integration.findAll({
where: {
@@ -223,14 +259,20 @@ export class GitLabIssueProvider extends BaseIssueProvider {
});
}
private async createProject(payload: Record<string, any>) {
const createEvent = payload.changes.some((p: { before: string }) =>
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: {
@@ -245,7 +287,6 @@ export class GitLabIssueProvider extends BaseIssueProvider {
return;
}
const project = payload.project;
const owner = {
id: "", // namespace.id is not provided in this webhook payload
name: project.path_with_namespace.split("/").slice(0, -1).join("/"),
@@ -264,7 +305,13 @@ export class GitLabIssueProvider extends BaseIssueProvider {
});
}
private async updateProject(payload: Record<string, any>) {
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: {
@@ -293,8 +340,8 @@ export class GitLabIssueProvider extends BaseIssueProvider {
);
if (source) {
source.name = payload.name;
source.owner.name = payload.path_with_namespace
source.name = newName;
source.owner.name = pathWithNamespace
.split("/")
.slice(0, -1)
.join("/");
+8 -1
View File
@@ -46,7 +46,14 @@ class Iframely {
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
}
const parsedData = data as Record<string, any>;
const parsedData = data as {
url: string;
meta: { title: string; description: string; site: string };
links: {
thumbnail?: { href: string }[];
icon?: { href: string }[];
};
};
return {
type: UnfurlResourceType.URL,
+8 -2
View File
@@ -1,7 +1,13 @@
import type { Request } from "express";
import { HttpsProxyAgent } from "https-proxy-agent";
import type OAuth2Strategy from "passport-oauth2";
import { Strategy } from "passport-oauth2";
interface AuthenticateOptions {
originalQuery?: Request["query"];
[key: string]: unknown;
}
export class OIDCStrategy extends Strategy {
constructor(
options: OAuth2Strategy.StrategyOptionsWithRequest,
@@ -15,12 +21,12 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: any, options: any) {
authenticate(req: Request, options: AuthenticateOptions) {
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: any) {
authorizationParams(options: AuthenticateOptions) {
return {
...options.originalQuery,
...super.authorizationParams?.(options),
+8 -3
View File
@@ -1,4 +1,5 @@
import { startRegistration } from "@simplewebauthn/browser";
import type { JSONObject } from "@shared/types";
import { observer } from "mobx-react";
import { KeyIcon, PlusIcon } from "outline-icons";
import * as React from "react";
@@ -66,9 +67,13 @@ function PasskeysSettings() {
}
);
const attResp = await startRegistration(resp.data);
await client.post("/passkeys.verifyRegistration", attResp as any, {
baseUrl: "/auth",
});
await client.post(
"/passkeys.verifyRegistration",
attResp as unknown as JSONObject,
{
baseUrl: "/auth",
}
);
toast.success(t("Passkey added successfully"));
await loadPasskeys();
} catch (err) {
+14 -13
View File
@@ -8,19 +8,20 @@ describe("getExpectedOrigin", () => {
hostname: string;
host: string;
forwardedPort?: string;
}): APIContext => ({
protocol: options.protocol,
request: {
hostname: options.hostname,
host: options.host,
get: (header: string) => {
if (header === "X-Forwarded-Port" && options.forwardedPort) {
return options.forwardedPort;
}
return undefined;
},
} as unknown,
}) as unknown as APIContext;
}): APIContext =>
({
protocol: options.protocol,
request: {
hostname: options.hostname,
host: options.host,
get: (header: string) => {
if (header === "X-Forwarded-Port" && options.forwardedPort) {
return options.forwardedPort;
}
return undefined;
},
} as unknown,
}) as unknown as APIContext;
it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => {
const ctx = createMockContext({
@@ -236,13 +236,13 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
where,
limit,
offset,
}) as any as Promise<RankedDocument[]>;
}) as unknown as Promise<RankedDocument[]>;
const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count
replacements: findOptions.replacements,
where,
}) as any as Promise<number>;
}) as unknown as Promise<number>;
const [results, count] = await Promise.all([resultsQuery, countQuery]);
// Final query to get associated document data
@@ -428,7 +428,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
where,
limit,
offset,
})) as any as RankedDocument[];
})) as unknown as RankedDocument[];
const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count
@@ -436,7 +436,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
include,
replacements: findOptions.replacements,
where,
}) as any as Promise<number>;
}) as unknown as Promise<number>;
// Final query to get associated document data
const [documents, count] = await Promise.all([
+2 -1
View File
@@ -238,7 +238,8 @@ router.post(
return;
}
const { results, total } = await SearchProviderManager.getProvider().searchForUser(user, options);
const { results, total } =
await SearchProviderManager.getProvider().searchForUser(user, options);
await SearchQuery.create({
userId: user ? user.id : null,
+9 -3
View File
@@ -1,4 +1,4 @@
import querystring from "node:querystring";
import querystring, { type ParsedUrlQueryInput } from "node:querystring";
import { InvalidRequestError } from "@server/errors";
import fetch from "@server/utils/fetch";
import { SlackUtils } from "../shared/SlackUtils";
@@ -13,7 +13,10 @@ const SLACK_API_URL = "https://slack.com/api";
* @param body - the request body containing token and other parameters.
* @returns the parsed JSON response from Slack.
*/
export async function post(endpoint: string, body: Record<string, any>) {
export async function post(
endpoint: string,
body: { token: string } & Record<string, unknown>
) {
let data;
const { token, ...bodyWithoutToken } = body;
@@ -44,7 +47,10 @@ export async function post(endpoint: string, body: Record<string, any>) {
* @param body - the request parameters.
* @returns the parsed JSON response from Slack.
*/
export async function request(endpoint: string, body: Record<string, any>) {
export async function request(
endpoint: string,
body: { client_id?: string; client_secret?: string } & ParsedUrlQueryInput
) {
let data;
const { client_id, client_secret, ...params } = body;
+1 -1
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState(
teamId: string,
type: IntegrationType,
data?: Record<string, any>
data?: Record<string, unknown>
) {
return JSON.stringify({ type, teamId, ...data });
}
+23 -6
View File
@@ -16,7 +16,10 @@ describe("errors", () => {
describe("User input errors", () => {
const userInputErrors = [
{ name: "AuthenticationError", fn: errors.AuthenticationError },
{ name: "InvalidAuthenticationError", fn: errors.InvalidAuthenticationError },
{
name: "InvalidAuthenticationError",
fn: errors.InvalidAuthenticationError,
},
{ name: "AuthorizationError", fn: errors.AuthorizationError },
{ name: "CSRFError", fn: errors.CSRFError },
{ name: "RateLimitExceededError", fn: errors.RateLimitExceededError },
@@ -33,12 +36,24 @@ describe("errors", () => {
{ name: "FileImportError", fn: errors.FileImportError },
{ name: "OAuthStateMismatchError", fn: errors.OAuthStateMismatchError },
{ name: "TeamPendingDeletionError", fn: errors.TeamPendingDeletionError },
{ name: "EmailAuthenticationRequiredError", fn: errors.EmailAuthenticationRequiredError },
{
name: "EmailAuthenticationRequiredError",
fn: errors.EmailAuthenticationRequiredError,
},
{ name: "MicrosoftGraphError", fn: errors.MicrosoftGraphError },
{ name: "TeamDomainRequiredError", fn: errors.TeamDomainRequiredError },
{ name: "GmailAccountCreationError", fn: errors.GmailAccountCreationError },
{ name: "OIDCMalformedUserInfoError", fn: errors.OIDCMalformedUserInfoError },
{ name: "AuthenticationProviderDisabledError", fn: errors.AuthenticationProviderDisabledError },
{
name: "GmailAccountCreationError",
fn: errors.GmailAccountCreationError,
},
{
name: "OIDCMalformedUserInfoError",
fn: errors.OIDCMalformedUserInfoError,
},
{
name: "AuthenticationProviderDisabledError",
fn: errors.AuthenticationProviderDisabledError,
},
{ name: "UnprocessableEntityError", fn: errors.UnprocessableEntityError },
{ name: "ClientClosedRequestError", fn: errors.ClientClosedRequestError },
];
@@ -53,7 +68,9 @@ describe("errors", () => {
describe("UserSuspendedError", () => {
it("should not be marked for Sentry reporting", () => {
const error = errors.UserSuspendedError({ adminEmail: "test@example.com" });
const error = errors.UserSuspendedError({
adminEmail: "test@example.com",
});
expect(error.isReportable).toBe(false);
});
});
@@ -26,6 +26,8 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
});
// Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(
RedisPrefixHelper.getUnfurlKey(integration.teamId)
);
}
}
@@ -27,7 +27,9 @@ export default class IntegrationDeletedProcessor extends BaseProcessor {
// Clear the cache of unfurled data for the team as it may be stale now.
if (integration.type === IntegrationType.Embed) {
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId));
await CacheHelper.clearData(
RedisPrefixHelper.getUnfurlKey(integration.teamId)
);
}
await integration.destroy({ force: true });
+5 -1
View File
@@ -74,7 +74,11 @@ router.post(
offset,
limit,
}),
SearchProviderManager.getProvider().searchCollectionsForUser(actor, { query, offset, limit }),
SearchProviderManager.getProvider().searchCollectionsForUser(actor, {
query,
offset,
limit,
}),
]);
ctx.body = {
+1 -1
View File
@@ -341,7 +341,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
content,
shortcutIcon:
publicBranding && team?.avatarUrl
? (await team.publicAvatarUrl()) ?? undefined
? ((await team.publicAvatarUrl()) ?? undefined)
: undefined,
analytics,
isShare: true,
+1 -5
View File
@@ -19,11 +19,7 @@ describe("getVersionInfo", () => {
it("should return version info when Docker Hub is accessible", async () => {
fetchMock.mockResponseOnce(
JSON.stringify({
results: [
{ name: "0.81.0" },
{ name: "0.80.0" },
{ name: "0.79.0" },
],
results: [{ name: "0.81.0" }, { name: "0.80.0" }, { name: "0.79.0" }],
next: null,
})
);
@@ -5,9 +5,9 @@ describe("getDocumentHighlightColors", () => {
it("returns empty array when no highlights exist", () => {
const testDoc = doc([p("Plain text without highlights")]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toEqual([]);
});
@@ -15,18 +15,18 @@ describe("getDocumentHighlightColors", () => {
// Create text with highlight marks
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const text1 = schema.text("Highlighted text 1", [highlightMark1]);
const text2 = schema.text(" and more ", [highlightMark2]);
const text3 = schema.text("and again", [highlightMark1]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1, text2, text3])
schema.nodes.paragraph.create(null, [text1, text2, text3]),
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(2);
expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A");
@@ -34,18 +34,18 @@ describe("getDocumentHighlightColors", () => {
it("deduplicates colors used multiple times", () => {
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const text1 = schema.text("First highlight", [highlightMark]);
const text2 = schema.text("Second highlight", [highlightMark]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2])
schema.nodes.paragraph.create(null, [text2]),
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B");
});
@@ -54,20 +54,20 @@ describe("getDocumentHighlightColors", () => {
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const highlightMark3 = schema.marks.highlight.create({ color: "#FA551E" });
const text1 = schema.text("First paragraph", [highlightMark1]);
const text2 = schema.text("Second paragraph", [highlightMark2]);
const text3 = schema.text("Third paragraph", [highlightMark3]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2]),
schema.nodes.paragraph.create(null, [text3])
schema.nodes.paragraph.create(null, [text3]),
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(3);
expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A");
@@ -77,17 +77,17 @@ describe("getDocumentHighlightColors", () => {
it("ignores text with other marks but no highlight", () => {
const boldMark = schema.marks.strong.create();
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const boldText = schema.text("Bold text", [boldMark]);
const highlightedText = schema.text("Highlighted text", [highlightMark]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [boldText, highlightedText])
schema.nodes.paragraph.create(null, [boldText, highlightedText]),
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B");
});
@@ -11,7 +11,9 @@ export function getDocumentHighlightColors(state: EditorState): string[] {
state.doc.descendants((node) => {
if (node.isText) {
const highlightMark = node.marks.find((mark) => mark.type.name === "highlight");
const highlightMark = node.marks.find(
(mark) => mark.type.name === "highlight"
);
if (highlightMark?.attrs.color) {
colors.add(highlightMark.attrs.color);
}
+5 -2
View File
@@ -54,14 +54,17 @@ export default function markdownItAlphaLists(md: MarkdownIt): void {
// Post-process tokens to add the listStyle attribute
md.core.ruler.after("block", "alpha_lists_postprocess", (state) => {
if (!state.env.alphaListMarkers || state.env.alphaListMarkers.length === 0) {
if (
!state.env.alphaListMarkers ||
state.env.alphaListMarkers.length === 0
) {
return;
}
const markers = state.env.alphaListMarkers;
// Build a map of line numbers to markers for more reliable matching
const lineToMarkerMap = new Map<number, typeof markers[0]>();
const lineToMarkerMap = new Map<number, (typeof markers)[0]>();
for (const marker of markers) {
lineToMarkerMap.set(marker.lineIndex, marker);
}