super-productivity/packages/plugin-dev/azure-devops-issue-provider/src/plugin.ts
Johannes Millan 933b274afe
feat(plugins): migrate Azure DevOps issue provider to a plugin (#8509)
* fix(plainspace): don't tint claim-list link icon with accent color

* feat(plugins): migrate Azure DevOps issue provider to a plugin

Move the built-in Azure DevOps provider out of core into a bundled plugin
(packages/plugin-dev/azure-devops-issue-provider), mirroring the prior
GitHub/ClickUp/Gitea/Linear/Trello migrations.

- Read-only provider: WIQL search + backlog import (scope all / created-by-me
  / assigned-to-me), two-step work-item fetch, getById with description.
- PAT auth via HTTP Basic (empty username); allowPrivateNetwork enabled so
  self-hosted Azure DevOps Server hosts keep working.
- getIssueLink builds the work-item URL from host + project + id without a
  request; isDone maps (pullOnly) from Closed/Done/Removed/Resolved states.
- issue-provider.reducer migrates legacy AZURE_DEVOPS providers to plugin
  shape on load (project listed first so the tooltip/initials keep showing
  the project); auto-enable already covers migrated keys generically.
- AZURE_DEVOPS moves from BuiltInIssueProviderKey to MigratedIssueProviderKey;
  the synced issueProviderKey union member is preserved for forward-compat.
- i18n: ported the translated F.AZURE_DEVOPS.FORM.* config labels (de/en/uk
  localized, English fallback elsewhere) and shared issue-content display
  labels to all 28 app locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(plugins): tidy Azure DevOps plugin issue field mapping

Post-review cleanup of the field mappers (no behavior change for the
common case, and closer to the Linear/Gitea reference plugins):

- mapReduced no longer emits a redundant `state` field; the typed `status`
  already drives the search-list done indicator (is-issue-done), and dropping
  `state` keeps isDone unset on add-from-search — matching the built-in, which
  never set isDone in getAddTaskData.
- mapIssue keeps `state` as the single canonical status field (used by
  issueDisplay, the isDone fieldMapping and extractSyncValues) and no longer
  duplicates it as `status`; the display row now points at `state`.
- Compute the work-item summary once per mapper instead of twice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(plugins): remove dead Azure DevOps form i18n keys after plugin migration

The built-in Azure DevOps config form was deleted in the plugin migration,
so the F.AZURE_DEVOPS.FORM.* keys in t.const.ts and the app locale files
(en/de/uk) are now unreferenced. The plugin ships its own bundled i18n.
Removing them matches the GitHub/Trello/Linear/ClickUp migration cleanup.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:06:45 +02:00

344 lines
11 KiB
TypeScript

import type {
IssueProviderPluginDefinition,
PluginFieldMapping,
PluginHttp,
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_VERSION = '6.0';
const DEFAULT_WORK_ITEM_LIMIT = 50;
const MAX_WORK_ITEM_LIMIT = 200;
// Work item fields fetched for the list views (kept identical to the built-in
// provider so behavior is unchanged after migration). getById fetches the full
// work item (no field filter) so it additionally returns System.Description.
const WORK_ITEM_FIELDS = [
'System.Id',
'System.Title',
'System.WorkItemType',
'System.State',
'Microsoft.VSTS.Common.Priority',
'System.CreatedDate',
'System.ChangedDate',
'System.AssignedTo',
'Microsoft.VSTS.Scheduling.DueDate',
'Microsoft.VSTS.Scheduling.TargetDate',
'Microsoft.VSTS.Scheduling.StartDate',
].join(',');
// Azure DevOps "done" state categories. The backlog query excludes
// Closed/Done/Removed; Resolved is the Agile resolved-but-not-closed state.
const DONE_STATES = ['closed', 'done', 'removed', 'resolved'];
type AzureScope = 'all' | 'created-by-me' | 'assigned-to-me';
interface AzureDevOpsConfig {
host?: string;
organization?: string;
project?: string;
token?: string;
scope?: AzureScope;
autoImportLimit?: number | string;
}
// Azure DevOps work item fields use dotted names like 'System.Title'.
type AzureWorkItemFields = Record<string, unknown> & {
// eslint-disable-next-line @typescript-eslint/naming-convention
'System.AssignedTo'?: { displayName?: string };
};
interface AzureWorkItem {
id: number;
fields: AzureWorkItemFields;
_links?: { html?: { href?: string } };
}
interface AzureWorkItemsResponse {
value: AzureWorkItem[];
}
interface AzureWiqlResponse {
workItems?: { id: number }[];
}
const t = (key: string, params?: Record<string, string | number>): string => {
try {
return PluginAPI.translate(key, params);
} catch {
return key;
}
};
const toMs = (date: unknown): number => {
const str = typeof date === 'string' ? date : '';
return str ? new Date(str).getTime() : 0;
};
// Azure DevOps Server (on-prem) uses a custom host; cloud falls back to the
// dev.azure.com/<org> URL the built-in provider built from `organization`.
const getBaseUrl = (cfg: AzureDevOpsConfig): string => {
const host = cfg.host || `https://dev.azure.com/${cfg.organization || ''}`;
return host.replace(/\/$/, '');
};
// WIQL has no parameter binding, so single quotes in user input are doubled to
// prevent breaking out of the string literal (matches the built-in provider).
const escapeWiql = (value: string | undefined): string =>
(value || '').replace(/'/g, "''");
const clampLimit = (cfg: AzureDevOpsConfig): number => {
const raw = Number(cfg.autoImportLimit);
const limit = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WORK_ITEM_LIMIT;
return Math.min(Math.max(Math.floor(limit), 1), MAX_WORK_ITEM_LIMIT);
};
const summaryOf = (item: AzureWorkItem): string =>
`${item.fields['System.WorkItemType']} ${item.id}: ${item.fields['System.Title']}`;
const dueOf = (fields: AzureWorkItemFields): string =>
(fields['Microsoft.VSTS.Scheduling.DueDate'] as string | undefined) ||
(fields['Microsoft.VSTS.Scheduling.TargetDate'] as string | undefined) ||
(fields['Microsoft.VSTS.Scheduling.StartDate'] as string | undefined) ||
'';
const mapReduced = (item: AzureWorkItem): PluginSearchResult => {
const fields = item.fields;
const summary = summaryOf(item);
const due = dueOf(fields);
return {
id: String(item.id),
title: summary,
url: item._links?.html?.href,
// is-issue-done reads `status` to strike through done items in the search
// list. add-task leaves isDone unset (no `state` here), matching built-in.
status: String(fields['System.State'] || ''),
// Extended fields for display + add-task data.
summary,
assignee: fields['System.AssignedTo']?.displayName,
lastUpdated: toMs(fields['System.ChangedDate']),
priority: fields['Microsoft.VSTS.Common.Priority'] as number | undefined,
due,
// Built-in seeded dueWithTime from the work item's due/target/start date.
...(due ? { dueWithTime: new Date(due).getTime() } : {}),
};
};
const mapIssue = (item: AzureWorkItem): PluginIssue => {
const fields = item.fields;
const summary = summaryOf(item);
const due = dueOf(fields);
return {
id: String(item.id),
title: summary,
body: (fields['System.Description'] as string) || '',
url: item._links?.html?.href,
// `state` is the canonical status field — drives issueDisplay, fieldMappings
// (isDone) and extractSyncValues.
state: String(fields['System.State'] || ''),
lastUpdated: toMs(fields['System.ChangedDate']),
assignee: fields['System.AssignedTo']?.displayName,
// Extended fields for display.
summary,
priority: fields['Microsoft.VSTS.Common.Priority'] as number | undefined,
due,
};
};
// Run a WIQL query (ids only), then batch-fetch the work item details — the two
// steps the Azure DevOps REST API requires (matches the built-in provider).
const runWiqlAndFetch = async (
cfg: AzureDevOpsConfig,
http: PluginHttp,
query: string,
limit: number,
): Promise<AzureWorkItem[]> => {
const baseUrl = getBaseUrl(cfg);
const wiql = await http.post<AzureWiqlResponse>(
`${baseUrl}/${cfg.project}/_apis/wit/wiql?api-version=${API_VERSION}`,
{ query },
);
const refs = wiql?.workItems || [];
if (!refs.length) {
return [];
}
const ids = refs.map((r) => r.id).slice(0, limit);
const res = await http.get<AzureWorkItemsResponse>(
`${baseUrl}/${cfg.project}/_apis/wit/workitems`,
{
params: {
ids: ids.join(','),
fields: WORK_ITEM_FIELDS,
// eslint-disable-next-line @typescript-eslint/naming-convention
'api-version': API_VERSION,
},
},
);
return res?.value || [];
};
PluginAPI.registerIssueProvider({
configFields: [
{
key: 'host',
type: 'input',
label: t('CFG.HOST'),
description: t('CFG.HOST_DESC'),
required: true,
},
{
key: 'project',
type: 'input',
label: t('CFG.PROJECT'),
required: true,
},
{
key: 'token',
type: 'password',
label: t('CFG.TOKEN'),
required: true,
},
{
key: 'scope',
type: 'select',
label: t('CFG.SCOPE'),
advanced: true,
options: [
{ value: 'all', label: t('CFG.SCOPE_ALL') },
{ value: 'created-by-me', label: t('CFG.SCOPE_CREATED') },
{ value: 'assigned-to-me', label: t('CFG.SCOPE_ASSIGNED') },
],
},
{
key: 'autoImportLimit',
type: 'input',
label: t('CFG.AUTO_IMPORT_LIMIT'),
description: t('CFG.AUTO_IMPORT_LIMIT_DESC', { max: MAX_WORK_ITEM_LIMIT }),
advanced: true,
},
],
getHeaders(config: Record<string, unknown>): Record<string, string> {
const cfg = config as unknown as AzureDevOpsConfig;
// Azure DevOps PAT auth: HTTP Basic with an empty username and the PAT as
// the password. Content-Type is set globally for the WIQL POST body.
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`:${cfg.token || ''}`)}`,
};
},
async searchIssues(
searchTerm: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
const cfg = config as unknown as AzureDevOpsConfig;
const term = escapeWiql(searchTerm);
const project = escapeWiql(cfg.project);
let query =
`Select [System.Id] From WorkItems Where [System.Title] Contains '${term}' ` +
`AND [System.TeamProject] = '${project}'`;
if (/^\d+$/.test(term)) {
query =
`Select [System.Id] From WorkItems Where ([System.Title] Contains '${term}' ` +
`OR [System.Id] = ${term}) AND [System.TeamProject] = '${project}'`;
}
const items = await runWiqlAndFetch(cfg, http, query, DEFAULT_WORK_ITEM_LIMIT);
return items.map(mapReduced);
},
async getById(
issueId: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginIssue> {
const cfg = config as unknown as AzureDevOpsConfig;
const item = await http.get<AzureWorkItem>(
`${getBaseUrl(cfg)}/${cfg.project}/_apis/wit/workitems/${issueId}?api-version=${API_VERSION}`,
);
return mapIssue(item);
},
// Azure DevOps work item URLs are derivable from host + project + id, so the
// link is built without a request (the html href the API returns for an item).
getIssueLink(issueId: string, config: Record<string, unknown>): string {
const cfg = config as unknown as AzureDevOpsConfig;
const baseUrl = getBaseUrl(cfg);
if (!baseUrl || !cfg.project) {
return '';
}
return `${baseUrl}/${cfg.project}/_workitems/edit/${issueId}`;
},
async testConnection(
config: Record<string, unknown>,
http: PluginHttp,
): Promise<boolean> {
const cfg = config as unknown as AzureDevOpsConfig;
try {
await http.get(`${getBaseUrl(cfg)}/_apis/connectionData?api-version=5.1-preview`);
return true;
} catch {
return false;
}
},
async getNewIssuesForBacklog(
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
const cfg = config as unknown as AzureDevOpsConfig;
const project = escapeWiql(cfg.project);
// Default to the built-in's 'assigned-to-me' when scope is unset (the plugin
// form has no default-value mechanism for selects).
const scope: AzureScope = cfg.scope || 'assigned-to-me';
let query =
`Select [System.Id] From WorkItems Where [System.TeamProject] = '${project}' ` +
`AND [System.State] <> 'Closed' AND [System.State] <> 'Done' AND [System.State] <> 'Removed'`;
if (scope === 'assigned-to-me') {
query += ` AND [System.AssignedTo] = @Me`;
} else if (scope === 'created-by-me') {
query += ` AND [System.CreatedBy] = @Me`;
}
const items = await runWiqlAndFetch(cfg, http, query, clampLimit(cfg));
return items.map(mapReduced);
},
issueDisplay: [
{ field: 'summary', label: t('DISPLAY.SUMMARY'), type: 'link', linkField: 'url' },
{ field: 'state', label: t('DISPLAY.STATUS'), type: 'text', hideEmpty: true },
{ field: 'assignee', label: t('DISPLAY.ASSIGNEE'), type: 'text', hideEmpty: true },
{ field: 'due', label: t('DISPLAY.DUE_DATE'), type: 'text', hideEmpty: true },
{ field: 'priority', label: t('DISPLAY.PRIORITY'), type: 'text', hideEmpty: true },
{ field: 'body', label: t('DISPLAY.DESCRIPTION'), type: 'markdown' },
],
// Read-only provider: pull-only mapping drives remote-update detection only.
fieldMappings: [
{
taskField: 'isDone',
issueField: 'state',
defaultDirection: 'pullOnly',
toIssueValue: (taskValue: unknown): string => (taskValue ? 'Closed' : 'Active'),
toTaskValue: (issueValue: unknown): boolean =>
DONE_STATES.includes(String(issueValue).toLowerCase()),
},
] satisfies PluginFieldMapping[],
extractSyncValues(issue: PluginIssue): Record<string, unknown> {
return {
state: issue.state,
title: issue.title,
body: issue.body,
};
},
} satisfies IssueProviderPluginDefinition as IssueProviderPluginDefinition);