super-productivity/packages/plugin-dev/gitea-issue-provider/src/plugin.ts
Johannes Millan 4c869c2e15
feat(plugins): migrate Gitea and Linear issue providers to plugins (#8204)
* feat(plugins): migrate Gitea and Linear issue providers to plugins

* feat(plugins): localize Gitea issue provider config form

Port the existing Gitea translations (F.GITEA.* + shared issue-content keys)
into the plugin's own i18n so non-English users keep localized config labels
after the plugin migration. Generated for all 28 app locales.

* docs(plugins): note Linear teamId/projectId filter is now active

The built-in Linear provider defined teamId/projectId config fields but never
passed them to the search, so they were inert. The plugin honors them as the
field labels promise; document this as a deliberate behavior change.

* test(issue): fix unknown casts in issue-provider migration spec

The spec compiled clean under the app tsconfig (which excludes *.spec.ts)
but failed CI's tsconfig.spec.json build: casting state.entities[id]
(IssueProvider | undefined) straight to Record<string, unknown> is a
TS2352 non-overlap error, and .toBe(already) on a bare literal is a
TS2345. Add the intermediate 'as unknown' so the spec type-checks.

* ci(lighthouse): raise total resource-count budget to 260

Migrating the Gitea and Linear issue providers to bundled plugins adds
two more entries to BUNDLED_PLUGIN_PATHS. Each bundled plugin is
discovered at startup, fetching its manifest.json plus icon.svg, which
pushed the Lighthouse total request count to 251, one over the previous
250 budget. The increase is an inherent, accepted consequence of the
built-in -> bundled-plugin migration (same as github/clickup); bump the
budget to 260 to reflect the new baseline with modest headroom.
2026-06-09 15:03:20 +02:00

371 lines
11 KiB
TypeScript

import type {
IssueProviderPluginDefinition,
PluginFieldMapping,
PluginHttp,
PluginHttpOptions,
PluginIssue,
PluginSearchResult,
} from '@super-productivity/plugin-api';
declare const PluginAPI: {
registerIssueProvider(definition: IssueProviderPluginDefinition): void;
translate(key: string, params?: Record<string, string | number>): string;
};
const API_SUFFIX = 'api';
const API_VERSION = 'v1';
// Scope option values — must stay identical to the built-in provider's stored
// config so existing (migrated) providers keep working.
const SCOPE_CREATED_BY_ME = 'created-by-me';
const SCOPE_ASSIGNED_TO_ME = 'assigned-to-me';
interface GiteaConfig {
host?: string;
token?: string;
repoFullname?: string;
scope?: string;
filterLabels?: string;
excludeLabels?: string;
}
interface GiteaUser {
avatar_url: string;
id: number;
username: string;
login: string;
full_name: string;
}
interface GiteaLabel {
id: number;
name: string;
color: string;
}
interface GiteaRepositoryReduced {
id: number;
full_name: string;
}
interface GiteaIssue {
id: number;
number: number;
url: string;
html_url: string;
title: string;
body: string;
state: string;
labels: GiteaLabel[];
user: GiteaUser | null;
assignee: GiteaUser | null;
assignees: GiteaUser[] | null;
comments: number;
created_at: string;
updated_at: string;
closed_at: string | null;
repository: GiteaRepositoryReduced | null;
}
interface GiteaComment {
id: number;
body: string;
created_at: string;
user: GiteaUser | null;
}
const t = (key: string): string => {
try {
return PluginAPI.translate(key);
} catch {
return key;
}
};
const baseUrl = (cfg: GiteaConfig): string => {
const host = (cfg.host || '').replace(/\/+$/, '');
if (!host) {
throw new Error('Gitea host is not configured.');
}
return `${host}/${API_SUFFIX}/${API_VERSION}`;
};
// Auth is sent as `Authorization: token <token>` (a Gitea-supported scheme),
// which keeps the token out of request URLs / logs.
const giteaHeaders = (cfg: GiteaConfig): Record<string, string> => {
const headers: Record<string, string> = { accept: 'application/json' };
if (cfg.token) {
headers['Authorization'] = `token ${cfg.token}`;
}
return headers;
};
const parseLabelList = (raw: string | undefined): string[] =>
(raw ?? '')
.split(',')
.map((l) => l.trim())
.filter((l) => l.length > 0);
// Gitea/Forgejo's two issue endpoints historically disagree on whether a
// `labels=a,b` query means AND or OR (see go-gitea/gitea#33509). We always
// filter labels client-side so behavior is consistent regardless of server.
const hasAllLabels = (issue: GiteaIssue, required: readonly string[]): boolean => {
if (required.length === 0) {
return true;
}
const names = new Set((issue.labels ?? []).map((l) => l.name));
return required.every((name) => names.has(name));
};
const isIssueIncludedByLabels = (
issue: GiteaIssue,
excluded: readonly string[],
): boolean => {
if (excluded.length === 0) {
return true;
}
const names = new Set((issue.labels ?? []).map((l) => l.name));
return !excluded.some((name) => names.has(name));
};
const issueAssignees = (issue: GiteaIssue): string[] =>
(issue.assignees ?? [])
.map((a) => a.login || a.username)
.filter((name): name is string => !!name);
const mapSearchResult = (issue: GiteaIssue): PluginSearchResult => ({
// Gitea tracks issues by their per-repo `number`, not the global `id`.
id: String(issue.number),
title: `#${issue.number} ${issue.title}`,
url: issue.html_url,
status: issue.state,
assignee: issue.assignee?.login || issue.assignee?.username,
labels: (issue.labels ?? []).map((l) => l.name),
});
PluginAPI.registerIssueProvider({
configFields: [
{
key: 'host',
type: 'input',
label: t('CFG.HOST'),
required: true,
},
{
key: 'token',
type: 'password',
label: t('CFG.TOKEN'),
required: true,
},
{
key: 'tokenHelp',
type: 'link',
label: t('CFG.HOW_TO_GET_TOKEN'),
url: 'https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens',
},
{
key: 'repoFullname',
type: 'input',
label: t('CFG.REPO_FULL_NAME'),
required: true,
},
{
key: 'scope',
type: 'select',
label: t('CFG.SCOPE'),
required: true,
options: [
{ value: 'all', label: t('CFG.SCOPE_ALL') },
{ value: SCOPE_CREATED_BY_ME, label: t('CFG.SCOPE_CREATED') },
{ value: SCOPE_ASSIGNED_TO_ME, label: t('CFG.SCOPE_ASSIGNED') },
],
},
{
key: 'filterLabels',
type: 'input',
label: t('CFG.FILTER_LABELS'),
advanced: true,
},
{
key: 'excludeLabels',
type: 'input',
label: t('CFG.EXCLUDE_LABELS'),
advanced: true,
},
],
getHeaders(config: Record<string, unknown>): Record<string, string> {
return giteaHeaders(config as unknown as GiteaConfig);
},
async searchIssues(
searchTerm: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
const cfg = config as unknown as GiteaConfig;
const base = baseUrl(cfg);
const includedLabels = parseLabelList(cfg.filterLabels);
const excludedLabels = parseLabelList(cfg.excludeLabels);
// `priority_repo_id` is the only reliable way to scope the global issue
// search to the configured repository, so look it up first.
const repo = await http.get<GiteaRepositoryReduced>(
`${base}/repos/${cfg.repoFullname}`,
);
const params: Record<string, string> = {
limit: '100',
state: 'open',
q: searchTerm,
};
if (repo?.id) {
params['priority_repo_id'] = String(repo.id);
}
if (cfg.scope === SCOPE_CREATED_BY_ME) {
params['created'] = 'true';
} else if (cfg.scope === SCOPE_ASSIGNED_TO_ME) {
params['assigned'] = 'true';
}
if (includedLabels.length > 0) {
params['labels'] = includedLabels.join(',');
}
const issues =
(await http.get<GiteaIssue[]>(`${base}/repos/issues/search`, { params })) || [];
return issues
.filter((issue) => issue.repository?.full_name === cfg.repoFullname)
.filter((issue) => hasAllLabels(issue, includedLabels))
.filter((issue) => isIssueIncludedByLabels(issue, excludedLabels))
.map(mapSearchResult);
},
async getById(
issueId: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginIssue> {
const cfg = config as unknown as GiteaConfig;
const base = baseUrl(cfg);
const issueUrl = `${base}/repos/${cfg.repoFullname}/issues/${issueId}`;
const issue = await http.get<GiteaIssue>(issueUrl);
const result: PluginIssue = {
id: String(issue.number),
title: issue.title,
body: issue.body || '',
url: issue.html_url,
state: issue.state,
lastUpdated: new Date(issue.updated_at).getTime(),
assignee: issue.assignee?.login || issue.assignee?.username,
labels: (issue.labels ?? []).map((l) => l.name),
comments: [],
// Extended fields for richer display
number: issue.number,
summary: `#${issue.number} ${issue.title}`,
assignees: issueAssignees(issue),
creator: issue.user?.login || issue.user?.username,
creatorAvatarUrl: issue.user?.avatar_url,
createdAt: new Date(issue.created_at).getTime(),
closedAt: issue.closed_at ? new Date(issue.closed_at).getTime() : undefined,
};
if (issue.comments > 0) {
const commentsData = await http.get<GiteaComment[]>(`${issueUrl}/comments`);
result.comments = (commentsData || []).map((c) => ({
author: c.user?.login || c.user?.username || 'unknown',
body: c.body || '',
created: new Date(c.created_at).getTime(),
avatarUrl: c.user?.avatar_url,
}));
}
return result;
},
getIssueLink(issueId: string, config: Record<string, unknown>): string {
const cfg = config as unknown as GiteaConfig;
const host = (cfg.host || '').replace(/\/+$/, '');
return `${host}/${cfg.repoFullname}/issues/${issueId}`;
},
async testConnection(
config: Record<string, unknown>,
http: PluginHttp,
): Promise<boolean> {
const cfg = config as unknown as GiteaConfig;
try {
await http.get(`${baseUrl(cfg)}/repos/${cfg.repoFullname}`);
return true;
} catch {
return false;
}
},
async getNewIssuesForBacklog(
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
const cfg = config as unknown as GiteaConfig;
const base = baseUrl(cfg);
const includedLabels = parseLabelList(cfg.filterLabels);
const excludedLabels = parseLabelList(cfg.excludeLabels);
const params: Record<string, string> = { limit: '100', state: 'open' };
if (cfg.scope === SCOPE_CREATED_BY_ME || cfg.scope === SCOPE_ASSIGNED_TO_ME) {
const user = await http.get<GiteaUser>(`${base}/user`);
if (cfg.scope === SCOPE_CREATED_BY_ME) {
params['created_by'] = user.username;
} else {
params['assigned_by'] = user.username;
}
}
if (includedLabels.length > 0) {
params['labels'] = includedLabels.join(',');
}
const opts: PluginHttpOptions = { params };
const issues =
(await http.get<GiteaIssue[]>(`${base}/repos/${cfg.repoFullname}/issues`, opts)) ||
[];
return issues
.filter((issue) => hasAllLabels(issue, includedLabels))
.filter((issue) => isIssueIncludedByLabels(issue, excludedLabels))
.map(mapSearchResult);
},
issueDisplay: [
{ field: 'summary', label: t('DISPLAY.SUMMARY'), type: 'link', linkField: 'url' },
{ field: 'state', label: t('DISPLAY.STATE'), type: 'text' },
{ field: 'assignees', label: t('DISPLAY.ASSIGNEE'), type: 'list', hideEmpty: true },
{ field: 'labels', label: t('DISPLAY.LABELS'), type: 'list', hideEmpty: true },
{ field: 'body', label: t('DISPLAY.DESCRIPTION'), type: 'markdown' },
],
commentsConfig: {
authorField: 'author',
bodyField: 'body',
createdField: 'created',
avatarField: 'avatarUrl',
},
// Read-only provider: pull-only mappings drive remote-update detection only.
fieldMappings: [
{
taskField: 'isDone',
issueField: 'state',
defaultDirection: 'pullOnly',
toIssueValue: (taskValue: unknown): string => (taskValue ? 'closed' : 'open'),
toTaskValue: (issueValue: unknown): boolean => issueValue === 'closed',
},
] satisfies PluginFieldMapping[],
extractSyncValues(issue: PluginIssue): Record<string, unknown> {
return {
state: issue.state,
title: issue.title,
body: issue.body,
};
},
});