mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* fix(plugins/github): correct issue-number prefix on imported tasks
The title field mapping mishandled issue imports two ways:
- Search/backlog results carry no `number`, so ctx.issueNumber was
undefined and titles got a literal "#undefined " prefix. Fall back
to ctx.issueId (equals String(issue.number) for GitHub).
- mapSearchResult already display-prefixes the title with "#<n> ", so
re-applying it yielded "#<n> #<n> ...". Short syntax then treated the
non-leading "#<n>" as a tag and stripped it. toTaskValue is now
idempotent so the prefix lands exactly once.
* fix(tasks): ignore numeric #tags in issue task titles
An issue task's leading "#<number>" is the issue id, and issue titles
routinely reference other issues ("fixes #1234"). Short syntax parsed
those non-leading "#<n>" tokens as tags, prompted to create them, and
stripped them from the title. Extend the existing numeric-at-start
guard to also block numeric "#<n>" anywhere on issue tasks. Non-numeric
tags ("#mytag") still parse, so manual tagging of issue tasks via the
title keeps working.
411 lines
12 KiB
TypeScript
411 lines
12 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_BASE = 'https://api.github.com';
|
|
|
|
interface GithubConfig {
|
|
repo: string;
|
|
token?: string;
|
|
filterUsername?: string;
|
|
backlogQuery?: string;
|
|
includePullRequests?: boolean;
|
|
}
|
|
|
|
interface GithubUser {
|
|
login: string;
|
|
avatar_url: string;
|
|
html_url: string;
|
|
}
|
|
|
|
interface GithubLabel {
|
|
name: string;
|
|
description?: string;
|
|
color?: string;
|
|
}
|
|
|
|
interface GithubMilestone {
|
|
title: string;
|
|
number: number;
|
|
state: string;
|
|
}
|
|
|
|
interface GithubIssueResponse {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
html_url: string;
|
|
url: string;
|
|
state: string;
|
|
locked: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
closed_at: string | null;
|
|
comments: number;
|
|
user: GithubUser | null;
|
|
assignee: GithubUser | null;
|
|
labels: (string | GithubLabel)[];
|
|
milestone: GithubMilestone | null;
|
|
pull_request?: { html_url: string };
|
|
}
|
|
|
|
interface GithubSearchResponse {
|
|
items: GithubIssueResponse[];
|
|
}
|
|
|
|
interface GithubCommentResponse {
|
|
user: GithubUser | null;
|
|
body: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
html_url: string;
|
|
author_association: string;
|
|
}
|
|
|
|
const parseRepo = (config: GithubConfig): { owner: string; repo: string } => {
|
|
const raw = (config.repo || '').trim();
|
|
const parts = raw.split('/');
|
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
throw new Error(`Invalid repo format "${raw}". Expected "owner/repo".`);
|
|
}
|
|
return { owner: parts[0], repo: parts[1] };
|
|
};
|
|
|
|
const mapSearchResult = (issue: GithubIssueResponse): PluginSearchResult => ({
|
|
id: String(issue.number),
|
|
title: `#${issue.number} ${issue.title}`,
|
|
url: issue.html_url,
|
|
status: issue.state,
|
|
assignee: issue.assignee?.login,
|
|
});
|
|
|
|
const t = (key: string): string => {
|
|
try {
|
|
return PluginAPI.translate(key);
|
|
} catch {
|
|
return key;
|
|
}
|
|
};
|
|
|
|
const isAuthOrNotFoundError = (err: unknown): boolean => {
|
|
if (typeof err === 'object' && err !== null && 'status' in err) {
|
|
const status = (err as { status: unknown }).status;
|
|
return status === 401 || status === 403 || status === 404;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// GitHub's search API requires parentheses percent-encoded, but
|
|
// encodeURIComponent leaves them intact (they're unreserved per RFC 3986).
|
|
// Unencoded parens cause HTTP 422 on queries like "(author:@me OR assignee:@me)".
|
|
// See https://github.com/super-productivity/super-productivity/issues/4913
|
|
const encodeGithubQuery = (query: string): string =>
|
|
encodeURIComponent(query).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
|
|
PluginAPI.registerIssueProvider({
|
|
configFields: [
|
|
{
|
|
key: 'repo',
|
|
type: 'input',
|
|
label: t('CFG.REPO'),
|
|
required: true,
|
|
},
|
|
{
|
|
key: 'token',
|
|
type: 'password',
|
|
label: t('CFG.TOKEN'),
|
|
required: false,
|
|
},
|
|
{
|
|
key: 'tokenHelp',
|
|
type: 'link',
|
|
label: t('CFG.HOW_TO_GET_TOKEN'),
|
|
url: 'https://github.com/super-productivity/super-productivity/blob/master/docs/github-access-token-instructions.md',
|
|
},
|
|
{
|
|
key: 'filterUsername',
|
|
type: 'input',
|
|
label: t('CFG.FILTER_USERNAME'),
|
|
required: false,
|
|
advanced: true,
|
|
},
|
|
{
|
|
key: 'backlogQuery',
|
|
type: 'input',
|
|
label: t('CFG.BACKLOG_QUERY'),
|
|
required: false,
|
|
advanced: true,
|
|
},
|
|
{
|
|
key: 'includePullRequests',
|
|
type: 'checkbox' as const,
|
|
label: t('CFG.INCLUDE_PULL_REQUESTS'),
|
|
advanced: true,
|
|
},
|
|
],
|
|
|
|
getHeaders(config: Record<string, unknown>): Record<string, string> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/vnd.github.v3+json',
|
|
};
|
|
if (cfg.token) {
|
|
headers['Authorization'] = `token ${cfg.token}`;
|
|
}
|
|
return headers;
|
|
},
|
|
|
|
async searchIssues(
|
|
searchTerm: string,
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<PluginSearchResult[]> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
const { owner, repo } = parseRepo(cfg);
|
|
// Ensure we only search issues (not PRs) unless the user opted in via
|
|
// config or explicitly typed an is: filter in their search term.
|
|
const hasTypeFilter =
|
|
searchTerm.includes('is:issue') || searchTerm.includes('is:pull-request');
|
|
const typeFilter = hasTypeFilter || cfg.includePullRequests ? '' : ' is:issue';
|
|
const q = encodeGithubQuery(`repo:${owner}/${repo}${typeFilter} ${searchTerm}`);
|
|
const url = `${API_BASE}/search/issues?q=${q}&per_page=50&advanced_search=true`;
|
|
const response = await http.get<GithubSearchResponse>(url);
|
|
return (response.items || []).map(mapSearchResult);
|
|
},
|
|
|
|
async getById(
|
|
issueId: string,
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<PluginIssue> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
const { owner, repo } = parseRepo(cfg);
|
|
const issueUrl = `${API_BASE}/repos/${owner}/${repo}/issues/${issueId}`;
|
|
const issue = await http.get<GithubIssueResponse>(issueUrl);
|
|
|
|
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
|
|
|
const result: PluginIssue = {
|
|
// Core fields used by the adapter
|
|
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,
|
|
labels,
|
|
comments: [],
|
|
|
|
// Extended fields for richer display
|
|
number: issue.number,
|
|
summary: `#${issue.number} ${issue.title}`,
|
|
creator: issue.user?.login,
|
|
creatorAvatarUrl: issue.user?.avatar_url,
|
|
assigneeUrl: issue.assignee?.html_url,
|
|
milestone: issue.milestone?.title,
|
|
locked: issue.locked,
|
|
isPullRequest: !!issue.pull_request,
|
|
pullRequestUrl: issue.pull_request?.html_url,
|
|
createdAt: new Date(issue.created_at).getTime(),
|
|
closedAt: issue.closed_at ? new Date(issue.closed_at).getTime() : undefined,
|
|
};
|
|
|
|
// Fetch comments if any exist
|
|
if (issue.comments > 0) {
|
|
const commentsData = await http.get<GithubCommentResponse[]>(
|
|
`${issueUrl}/comments?per_page=100`,
|
|
);
|
|
|
|
result.comments = commentsData.map((c) => ({
|
|
author: c.user?.login || 'unknown',
|
|
body: c.body || '',
|
|
created: new Date(c.created_at).getTime(),
|
|
avatarUrl: c.user?.avatar_url,
|
|
}));
|
|
|
|
// Smart lastUpdated: filter out own comments for update detection
|
|
// (matches built-in GitHub provider's filterUsernameForIssueUpdates)
|
|
if (cfg.filterUsername) {
|
|
const otherComments = commentsData.filter(
|
|
(c) => c.user?.login?.toLowerCase() !== cfg.filterUsername!.toLowerCase(),
|
|
);
|
|
if (otherComments.length > 0) {
|
|
const latestOtherComment = Math.max(
|
|
...otherComments.map((c) => new Date(c.created_at).getTime()),
|
|
);
|
|
result.lastUpdated = Math.max(result.lastUpdated!, latestOtherComment);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
getIssueLink(issueId: string, config: Record<string, unknown>): string {
|
|
const { owner, repo } = parseRepo(config as unknown as GithubConfig);
|
|
return `https://github.com/${owner}/${repo}/issues/${issueId}`;
|
|
},
|
|
|
|
async testConnection(
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<boolean> {
|
|
const { owner, repo } = parseRepo(config as unknown as GithubConfig);
|
|
try {
|
|
await http.get(`${API_BASE}/repos/${owner}/${repo}`);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
async getNewIssuesForBacklog(
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<PluginSearchResult[]> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
const { owner, repo } = parseRepo(cfg);
|
|
// `assignee:@me` requires an authenticated request; without a token the
|
|
// GitHub Search API returns 422. Fall back to a token-less default so
|
|
// public-repo auto-import works without credentials.
|
|
const query =
|
|
cfg.backlogQuery ||
|
|
(cfg.token ? 'sort:updated state:open assignee:@me' : 'sort:updated state:open');
|
|
const typeFilter = cfg.includePullRequests ? '' : ' is:issue';
|
|
const q = encodeGithubQuery(`repo:${owner}/${repo}${typeFilter} ${query}`);
|
|
const url = `${API_BASE}/search/issues?q=${q}&per_page=50&advanced_search=true`;
|
|
const response = await http.get<GithubSearchResponse>(url);
|
|
return (response.items || []).map(mapSearchResult);
|
|
},
|
|
|
|
issueDisplay: [
|
|
{ field: 'summary', label: t('DISPLAY.SUMMARY'), type: 'link', linkField: 'url' },
|
|
{ field: 'state', label: t('DISPLAY.STATE'), type: 'text' },
|
|
{ field: 'assignee', label: t('DISPLAY.ASSIGNEE'), type: 'text', hideEmpty: true },
|
|
{ field: 'labels', label: t('DISPLAY.LABELS'), type: 'list', hideEmpty: true },
|
|
{ field: 'milestone', label: t('DISPLAY.MILESTONE'), type: 'text', hideEmpty: true },
|
|
{ field: 'creator', label: t('DISPLAY.CREATOR'), type: 'text', hideEmpty: true },
|
|
{ field: 'body', label: t('DISPLAY.DESCRIPTION'), type: 'markdown' },
|
|
],
|
|
|
|
commentsConfig: {
|
|
authorField: 'author',
|
|
bodyField: 'body',
|
|
createdField: 'created',
|
|
avatarField: 'avatarUrl',
|
|
},
|
|
|
|
fieldMappings: [
|
|
{
|
|
taskField: 'isDone',
|
|
issueField: 'state',
|
|
defaultDirection: 'pullOnly',
|
|
toIssueValue: (taskValue: unknown): string => (taskValue ? 'closed' : 'open'),
|
|
toTaskValue: (issueValue: unknown): boolean => issueValue === 'closed',
|
|
},
|
|
{
|
|
taskField: 'title',
|
|
issueField: 'title',
|
|
defaultDirection: 'pullOnly',
|
|
toIssueValue: (
|
|
taskValue: unknown,
|
|
ctx: { issueId: string; issueNumber?: number },
|
|
): string => {
|
|
const num = ctx.issueNumber ?? ctx.issueId;
|
|
const str = taskValue as string;
|
|
const prefix = `#${num} `;
|
|
return str.startsWith(prefix) ? str.slice(prefix.length) : str;
|
|
},
|
|
toTaskValue: (
|
|
issueValue: unknown,
|
|
ctx: { issueId: string; issueNumber?: number },
|
|
): string => {
|
|
const num = ctx.issueNumber ?? ctx.issueId;
|
|
const str = issueValue as string;
|
|
const prefix = `#${num} `;
|
|
return str.startsWith(prefix) ? str : `${prefix}${str}`;
|
|
},
|
|
},
|
|
{
|
|
taskField: 'notes',
|
|
issueField: 'body',
|
|
defaultDirection: 'off',
|
|
toIssueValue: (taskValue: unknown): string => (taskValue as string) ?? '',
|
|
toTaskValue: (issueValue: unknown): string => (issueValue as string) ?? '',
|
|
},
|
|
] satisfies PluginFieldMapping[],
|
|
|
|
async updateIssue(
|
|
id: string,
|
|
changes: Record<string, unknown>,
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<void> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
if (!cfg.token) {
|
|
throw new Error(t('ERRORS.TOKEN_REQUIRED'));
|
|
}
|
|
const { owner, repo } = parseRepo(cfg);
|
|
try {
|
|
await http.patch(`${API_BASE}/repos/${owner}/${repo}/issues/${id}`, changes);
|
|
} catch (e) {
|
|
throw isAuthOrNotFoundError(e)
|
|
? new Error(t('ERRORS.INSUFFICIENT_PERMISSIONS'))
|
|
: e;
|
|
}
|
|
},
|
|
|
|
async createIssue(
|
|
title: string,
|
|
config: Record<string, unknown>,
|
|
http: PluginHttp,
|
|
): Promise<{ issueId: string; issueNumber: number; issueData: PluginIssue }> {
|
|
const cfg = config as unknown as GithubConfig;
|
|
if (!cfg.token) {
|
|
throw new Error(t('ERRORS.TOKEN_REQUIRED'));
|
|
}
|
|
const { owner, repo } = parseRepo(cfg);
|
|
let response: GithubIssueResponse;
|
|
try {
|
|
response = await http.post<GithubIssueResponse>(
|
|
`${API_BASE}/repos/${owner}/${repo}/issues`,
|
|
{ title },
|
|
);
|
|
} catch (e) {
|
|
throw isAuthOrNotFoundError(e)
|
|
? new Error(t('ERRORS.INSUFFICIENT_PERMISSIONS'))
|
|
: e;
|
|
}
|
|
return {
|
|
issueId: String(response.number),
|
|
issueNumber: response.number,
|
|
issueData: {
|
|
id: String(response.number),
|
|
title: response.title,
|
|
body: response.body || '',
|
|
url: response.html_url,
|
|
state: response.state,
|
|
lastUpdated: new Date(response.updated_at).getTime(),
|
|
},
|
|
};
|
|
},
|
|
|
|
extractSyncValues(issue: PluginIssue): Record<string, unknown> {
|
|
return {
|
|
state: issue.state,
|
|
title: issue.title,
|
|
body: issue.body,
|
|
};
|
|
},
|
|
});
|