mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
Plugin tag ids (#7582)
* feat(plugin-api): add tag ids to plugin field mapping * feat(plugin-sync): handle tag ids in plugin sync adapter * feat(plugin-sync): handle tag ids in plugin issue provider adapter * feat(plugin-sync): add tags to two way sync * fix(plugin-sync): add deep equality comparison for arrays * refactor(plugin-sync): extract sortTagLabels utility and refactor sync adapters * fix(two-way-sync): trigger sync on addTagToTask action * fix(plugin-sync): harden tag synchronization * fix(plugin-sync): preserve provider-owned baselines --------- Co-authored-by: johannesjo <johannes.millan@gmail.com>
This commit is contained in:
parent
6f5e174b54
commit
1204380198
19 changed files with 1274 additions and 96 deletions
|
|
@ -8,6 +8,8 @@ export interface PluginSearchResult {
|
|||
url?: string;
|
||||
status?: string;
|
||||
assignee?: string;
|
||||
/** Labels/tags used by tagIds field mappings during initial import */
|
||||
labels?: string[];
|
||||
/** Event start timestamp (ms) - required for agenda view */
|
||||
start?: number;
|
||||
/** Precise due-with-time timestamp (ms) for timed events. When set, the task is
|
||||
|
|
@ -19,6 +21,8 @@ export interface PluginSearchResult {
|
|||
isAllDay?: boolean;
|
||||
/** Event description / body text */
|
||||
description?: string;
|
||||
/** Provider-specific fields used by issue display and field mappings */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PluginIssue {
|
||||
|
|
@ -62,7 +66,19 @@ export interface PluginCommentsConfig {
|
|||
export type PluginSyncDirection = 'off' | 'pullOnly' | 'pushOnly' | 'both';
|
||||
|
||||
export interface PluginFieldMapping {
|
||||
taskField: 'isDone' | 'title' | 'notes' | 'dueDay' | 'dueWithTime' | 'timeEstimate';
|
||||
/**
|
||||
* `tagIds` maps Super Productivity tags by title/label, not by internal tag id:
|
||||
* - `toIssueValue` receives a sorted string[] of local tag titles.
|
||||
* - `toTaskValue` must return a string[] of tag titles/labels to match or create locally.
|
||||
*/
|
||||
taskField:
|
||||
| 'isDone'
|
||||
| 'title'
|
||||
| 'notes'
|
||||
| 'dueDay'
|
||||
| 'dueWithTime'
|
||||
| 'timeEstimate'
|
||||
| 'tagIds';
|
||||
issueField: string;
|
||||
defaultDirection: PluginSyncDirection;
|
||||
/** Task fields to clear when this field is set (e.g. dueWithTime and dueDay are mutually exclusive) */
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ import { IssueLog } from '../../../core/log';
|
|||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginBridgeService } from '../../../plugins/plugin-bridge.service';
|
||||
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
|
||||
import { OAuthFlowConfig } from '@super-productivity/plugin-api';
|
||||
import { OAuthFlowConfig, PluginSyncDirection } from '@super-productivity/plugin-api';
|
||||
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
|
||||
import { TrelloAdditionalCfgComponent } from '../providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component';
|
||||
// ClickUp is now a plugin — no built-in config component needed
|
||||
|
|
@ -506,6 +506,26 @@ export class DialogEditIssueProviderComponent {
|
|||
migrated['readCalendarIds'] = [migrated['calendarId'] as string];
|
||||
migrated['writeCalendarId'] = migrated['writeCalendarId'] || migrated['calendarId'];
|
||||
}
|
||||
const defaultPluginConfig = this._getDefaultPluginConfig();
|
||||
const defaultTwoWaySync = defaultPluginConfig['twoWaySync'] as
|
||||
| Record<string, PluginSyncDirection>
|
||||
| undefined;
|
||||
if (defaultTwoWaySync) {
|
||||
const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
|
||||
const isPushSupported = !!provider?.definition.updateIssue;
|
||||
const currentTwoWaySync =
|
||||
(migrated['twoWaySync'] as Record<string, PluginSyncDirection> | undefined) ?? {};
|
||||
const normalizedCurrentTwoWaySync = Object.fromEntries(
|
||||
Object.entries(currentTwoWaySync).map(([field, direction]) => [
|
||||
field,
|
||||
this._normalizeSyncDirectionForCapabilities(direction, isPushSupported),
|
||||
]),
|
||||
);
|
||||
migrated['twoWaySync'] = {
|
||||
...defaultTwoWaySync,
|
||||
...normalizedCurrentTwoWaySync,
|
||||
};
|
||||
}
|
||||
return { ...model, pluginConfig: migrated } as Partial<IssueProvider>;
|
||||
}
|
||||
|
||||
|
|
@ -513,17 +533,32 @@ export class DialogEditIssueProviderComponent {
|
|||
if (!this._pluginRegistry.hasProvider(this.issueProviderKey)) {
|
||||
return {};
|
||||
}
|
||||
const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
|
||||
const isPushSupported = !!provider?.definition.updateIssue;
|
||||
const fieldMappings = this._pluginRegistry.getFieldMappings(this.issueProviderKey);
|
||||
if (!fieldMappings?.length) {
|
||||
return {};
|
||||
}
|
||||
const twoWaySync: Record<string, string> = {};
|
||||
for (const m of fieldMappings) {
|
||||
twoWaySync[m.taskField] = m.defaultDirection;
|
||||
twoWaySync[m.taskField] = this._normalizeSyncDirectionForCapabilities(
|
||||
m.defaultDirection as PluginSyncDirection,
|
||||
isPushSupported,
|
||||
);
|
||||
}
|
||||
return { twoWaySync };
|
||||
}
|
||||
|
||||
private _normalizeSyncDirectionForCapabilities(
|
||||
direction: PluginSyncDirection,
|
||||
isPushSupported: boolean,
|
||||
): PluginSyncDirection {
|
||||
if (!isPushSupported && (direction === 'pushOnly' || direction === 'both')) {
|
||||
return 'pullOnly';
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
|
||||
private _getPluginFormSection(): ConfigFormSection<IssueIntegrationCfg> | undefined {
|
||||
const pluginKey = this.issueProviderKey;
|
||||
const configFields = this._pluginRegistry.getConfigFields(pluginKey);
|
||||
|
|
@ -626,11 +661,17 @@ export class DialogEditIssueProviderComponent {
|
|||
pluginKey: IssueProviderKey,
|
||||
fieldMappings: { taskField: string; issueField: string; defaultDirection: string }[],
|
||||
): unknown {
|
||||
const provider = this._pluginRegistry.getProvider(pluginKey);
|
||||
const isPushSupported = !!provider?.definition.updateIssue;
|
||||
const syncDirectionOptions = [
|
||||
{ value: 'off', label: T.F.ISSUE.TWO_WAY_SYNC.OFF },
|
||||
{ value: 'pullOnly', label: T.F.ISSUE.TWO_WAY_SYNC.PULL_ONLY },
|
||||
{ value: 'pushOnly', label: T.F.ISSUE.TWO_WAY_SYNC.PUSH_ONLY },
|
||||
{ value: 'both', label: T.F.ISSUE.TWO_WAY_SYNC.BOTH },
|
||||
...(isPushSupported
|
||||
? [
|
||||
{ value: 'pushOnly', label: T.F.ISSUE.TWO_WAY_SYNC.PUSH_ONLY },
|
||||
{ value: 'both', label: T.F.ISSUE.TWO_WAY_SYNC.BOTH },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const TASK_FIELD_LABELS: Record<string, string> = {
|
||||
isDone: T.F.ISSUE.TWO_WAY_SYNC.STATUS,
|
||||
|
|
@ -639,6 +680,7 @@ export class DialogEditIssueProviderComponent {
|
|||
dueDay: T.F.ISSUE.TWO_WAY_SYNC.DUE_DAY,
|
||||
dueWithTime: T.F.ISSUE.TWO_WAY_SYNC.DUE_WITH_TIME,
|
||||
timeEstimate: T.F.ISSUE.TWO_WAY_SYNC.TIME_ESTIMATE,
|
||||
tagIds: T.F.ISSUE.TWO_WAY_SYNC.TAGS,
|
||||
};
|
||||
const syncFields: any[] = fieldMappings.map((m) => ({
|
||||
key: ('pluginConfig.twoWaySync.' + m.taskField) as keyof IssueIntegrationCfg,
|
||||
|
|
@ -648,7 +690,6 @@ export class DialogEditIssueProviderComponent {
|
|||
options: syncDirectionOptions,
|
||||
},
|
||||
}));
|
||||
const provider = this._pluginRegistry.getProvider(pluginKey);
|
||||
if (provider?.definition.createIssue) {
|
||||
syncFields.push({
|
||||
key: 'pluginConfig.isAutoCreateIssues',
|
||||
|
|
@ -663,6 +704,16 @@ export class DialogEditIssueProviderComponent {
|
|||
},
|
||||
});
|
||||
}
|
||||
if (provider?.definition.fieldMappings?.some((m) => m.taskField === 'tagIds')) {
|
||||
syncFields.push({
|
||||
key: 'pluginConfig.isAutoCreateTags',
|
||||
type: 'checkbox',
|
||||
props: {
|
||||
label: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS,
|
||||
description: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS_DESCRIPTION,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: 'collapsible',
|
||||
props: { label: T.F.ISSUE.TWO_WAY_SYNC.SECTION },
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import { GiteaCommonInterfacesService } from './providers/gitea/gitea-common-int
|
|||
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
|
||||
import { LinearCommonInterfacesService } from './providers/linear/linear-common-interfaces.service';
|
||||
import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service';
|
||||
import { PluginIssueProviderAdapterService } from '../../plugins/issue-provider/plugin-issue-provider-adapter.service';
|
||||
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
|
||||
describe('IssueService', () => {
|
||||
let service: IssueService;
|
||||
|
|
@ -40,6 +42,8 @@ describe('IssueService', () => {
|
|||
let translateServiceSpy: jasmine.SpyObj<TranslateService>;
|
||||
let globalProgressBarServiceSpy: jasmine.SpyObj<GlobalProgressBarService>;
|
||||
let navigateToTaskServiceSpy: jasmine.SpyObj<NavigateToTaskService>;
|
||||
let pluginAdapterSpy: jasmine.SpyObj<PluginIssueProviderAdapterService>;
|
||||
let pluginRegistrySpy: jasmine.SpyObj<PluginIssueProviderRegistryService>;
|
||||
|
||||
const createMockTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
|
|
@ -91,7 +95,8 @@ describe('IssueService', () => {
|
|||
calendarIntegrationServiceSpy = jasmine.createSpyObj('CalendarIntegrationService', [
|
||||
'skipCalendarEvent',
|
||||
]);
|
||||
storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']);
|
||||
storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch', 'pipe']);
|
||||
storeSpy.pipe.and.returnValue(of([]));
|
||||
translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']);
|
||||
globalProgressBarServiceSpy = jasmine.createSpyObj('GlobalProgressBarService', [
|
||||
'countUp',
|
||||
|
|
@ -100,6 +105,18 @@ describe('IssueService', () => {
|
|||
navigateToTaskServiceSpy = jasmine.createSpyObj('NavigateToTaskService', [
|
||||
'navigate',
|
||||
]);
|
||||
pluginAdapterSpy = jasmine.createSpyObj('PluginIssueProviderAdapterService', [
|
||||
'getAddTaskData',
|
||||
'getAddTaskDataForCfg',
|
||||
]);
|
||||
pluginRegistrySpy = jasmine.createSpyObj('PluginIssueProviderRegistryService', [
|
||||
'hasProvider',
|
||||
'getIcon',
|
||||
'getName',
|
||||
'getIssueStrings',
|
||||
'getPollIntervalMs',
|
||||
]);
|
||||
pluginRegistrySpy.hasProvider.and.returnValue(false);
|
||||
|
||||
// Default mock return values - use 'as any' to bypass strict type checking
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
|
||||
|
|
@ -153,6 +170,8 @@ describe('IssueService', () => {
|
|||
provide: CalendarCommonInterfacesService,
|
||||
useValue: mockCommonInterfaceService,
|
||||
},
|
||||
{ provide: PluginIssueProviderAdapterService, useValue: pluginAdapterSpy },
|
||||
{ provide: PluginIssueProviderRegistryService, useValue: pluginRegistrySpy },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(IssueService);
|
||||
|
|
@ -343,6 +362,82 @@ describe('IssueService', () => {
|
|||
expect(taskData.tagIds).toEqual(['tag-1', 'tag-2']);
|
||||
});
|
||||
|
||||
it('should merge provider tagIds with default tags', async () => {
|
||||
setupForNewTask();
|
||||
(service.ISSUE_SERVICE_MAP['JIRA'] as any).getAddTaskData = () => ({
|
||||
title: 'Test Jira Issue',
|
||||
tagIds: ['remote-tag'],
|
||||
});
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
|
||||
of({
|
||||
defaultProjectId: 'proj-1',
|
||||
defaultTagIds: ['default-tag'],
|
||||
} as any),
|
||||
);
|
||||
Object.defineProperty(workContextServiceSpy, 'activeWorkContextType', {
|
||||
get: () => WorkContextType.PROJECT,
|
||||
});
|
||||
Object.defineProperty(workContextServiceSpy, 'activeWorkContextId', {
|
||||
get: () => 'proj-1',
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: jiraIssue as any,
|
||||
issueProviderId: 'jira-provider-1',
|
||||
issueProviderKey: 'JIRA',
|
||||
});
|
||||
|
||||
const addCall = taskServiceSpy.add.calls.mostRecent();
|
||||
const taskData = addCall.args[2] as Partial<Task>;
|
||||
expect(taskData.tagIds).toEqual(['default-tag', 'remote-tag']);
|
||||
});
|
||||
|
||||
it('should use plugin add task data with cfg so mapped tags are imported', async () => {
|
||||
const pluginIssue = {
|
||||
id: 'PLUGIN-1',
|
||||
title: 'Plugin Issue',
|
||||
labels: ['bug'],
|
||||
};
|
||||
pluginRegistrySpy.hasProvider.and.callFake((key) => key === 'plugin:test');
|
||||
pluginAdapterSpy.getAddTaskDataForCfg.and.returnValue({
|
||||
title: 'Plugin Issue',
|
||||
tagIds: ['remote-tag'],
|
||||
issueLastSyncedValues: { labels: ['bug'] },
|
||||
});
|
||||
taskServiceSpy.checkForTaskWithIssueEverywhere.and.resolveTo(null);
|
||||
taskServiceSpy.add.and.returnValue('new-task-id');
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
|
||||
of({
|
||||
id: 'plugin-provider-1',
|
||||
issueProviderKey: 'plugin:test',
|
||||
defaultProjectId: 'proj-1',
|
||||
defaultTagIds: ['default-tag'],
|
||||
pluginConfig: { twoWaySync: { tagIds: 'both' } },
|
||||
} as any),
|
||||
);
|
||||
Object.defineProperty(workContextServiceSpy, 'activeWorkContextType', {
|
||||
get: () => WorkContextType.PROJECT,
|
||||
});
|
||||
Object.defineProperty(workContextServiceSpy, 'activeWorkContextId', {
|
||||
get: () => 'proj-1',
|
||||
});
|
||||
|
||||
await service.addTaskFromIssue({
|
||||
issueDataReduced: pluginIssue as any,
|
||||
issueProviderId: 'plugin-provider-1',
|
||||
issueProviderKey: 'plugin:test' as any,
|
||||
});
|
||||
|
||||
expect(pluginAdapterSpy.getAddTaskDataForCfg).toHaveBeenCalledWith(
|
||||
pluginIssue as any,
|
||||
jasmine.objectContaining({ issueProviderKey: 'plugin:test' }),
|
||||
);
|
||||
const addCall = taskServiceSpy.add.calls.mostRecent();
|
||||
const taskData = addCall.args[2] as Partial<Task>;
|
||||
expect(taskData.tagIds).toEqual(['default-tag', 'remote-tag']);
|
||||
expect(taskData.issueLastSyncedValues).toEqual({ labels: ['bug'] });
|
||||
});
|
||||
|
||||
it('should set defaultNote when provider adapter does not set notes', async () => {
|
||||
setupForNewTask();
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(
|
||||
|
|
|
|||
|
|
@ -509,20 +509,21 @@ export class IssueService {
|
|||
};
|
||||
}
|
||||
|
||||
const providerCfg = await firstValueFrom(
|
||||
this._issueProviderService.getCfgOnce$(issueProviderId, issueProviderKey),
|
||||
);
|
||||
|
||||
const {
|
||||
title = null,
|
||||
related_to,
|
||||
...additionalFromProviderIssueService
|
||||
} = this._getAddTaskData(issueProviderKey, issueDataReduced);
|
||||
} = this._getAddTaskData(issueProviderKey, issueDataReduced, providerCfg);
|
||||
IssueLog.log({
|
||||
related_to,
|
||||
additionalKeys: Object.keys(additionalFromProviderIssueService),
|
||||
});
|
||||
|
||||
const getTaskDefaults = async (): Promise<Partial<TaskCopy>> => {
|
||||
const providerCfg = await this._issueProviderService
|
||||
.getCfgOnce$(issueProviderId, issueProviderKey)
|
||||
.toPromise();
|
||||
const getTaskDefaults = (): Partial<TaskCopy> => {
|
||||
const defaultProjectId = providerCfg.defaultProjectId;
|
||||
const defaultTagIds = (providerCfg.defaultTagIds || []).filter(
|
||||
(id) => id !== TODAY_TAG.id,
|
||||
|
|
@ -563,6 +564,13 @@ export class IssueService {
|
|||
}
|
||||
};
|
||||
|
||||
const taskDefaults = getTaskDefaults();
|
||||
const providerTagIds = (additionalFromProviderIssueService as Partial<TaskCopy>)
|
||||
.tagIds;
|
||||
if (Array.isArray(providerTagIds)) {
|
||||
taskDefaults.tagIds = unique([...(taskDefaults.tagIds ?? []), ...providerTagIds]);
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
issueType: issueProviderKey,
|
||||
issueProviderId: issueProviderId,
|
||||
|
|
@ -574,7 +582,7 @@ export class IssueService {
|
|||
// shows up in the Today tab, defeating the point of the backlog.
|
||||
...(isAddToBacklog ? {} : { dueDay: getDbDateStr() }),
|
||||
...additionalFromProviderIssueService,
|
||||
...(await getTaskDefaults()),
|
||||
...taskDefaults,
|
||||
...additional,
|
||||
};
|
||||
|
||||
|
|
@ -860,12 +868,19 @@ export class IssueService {
|
|||
private _getAddTaskData(
|
||||
issueProviderKey: IssueProviderKey,
|
||||
issueReduced: IssueDataReduced,
|
||||
cfg?: unknown,
|
||||
): IssueTask {
|
||||
const service = this._getService(issueProviderKey);
|
||||
if (!service?.getAddTaskData) {
|
||||
throw new Error('Issue method not available');
|
||||
}
|
||||
const r = service.getAddTaskData(issueReduced);
|
||||
const serviceWithCfg = service as IssueServiceInterface & {
|
||||
getAddTaskDataForCfg?: (issueData: IssueDataReduced, cfg: unknown) => IssueTask;
|
||||
};
|
||||
const r =
|
||||
cfg && serviceWithCfg.getAddTaskDataForCfg
|
||||
? serviceWithCfg.getAddTaskDataForCfg(issueReduced, cfg)
|
||||
: service.getAddTaskData(issueReduced);
|
||||
typia.assert<IssueTask>(r);
|
||||
return r;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,4 +405,55 @@ describe('computePushDecisions', () => {
|
|||
reason: 'provider changed (provider wins)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should push tagIds when fresh and last synced arrays have same contents (different references)', () => {
|
||||
const tagIdsMapping: FieldMapping = {
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => (v as string[])?.slice().sort(),
|
||||
toTaskValue: (v: unknown) => v,
|
||||
};
|
||||
const syncConfig: FieldSyncConfig = { tagIds: 'both' };
|
||||
const freshLabels = ['bug', 'feature'];
|
||||
const lastLabels = ['bug', 'feature'];
|
||||
const decisions = computePushDecisions(
|
||||
{ tagIds: ['feature', 'bug'] },
|
||||
[tagIdsMapping],
|
||||
syncConfig,
|
||||
{ labels: freshLabels },
|
||||
{ labels: lastLabels },
|
||||
ctx,
|
||||
);
|
||||
|
||||
const labelDecision = decisions.find((d) => d.field === 'labels');
|
||||
expect(labelDecision?.action).toBe('push');
|
||||
expect(labelDecision?.issueValue).toEqual(['bug', 'feature']);
|
||||
});
|
||||
|
||||
it('should skip tagIds when fresh and last synced arrays have different contents', () => {
|
||||
const tagIdsMapping: FieldMapping = {
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => (v as string[])?.slice().sort(),
|
||||
toTaskValue: (v: unknown) => v,
|
||||
};
|
||||
const syncConfig: FieldSyncConfig = { tagIds: 'both' };
|
||||
const decisions = computePushDecisions(
|
||||
{ tagIds: ['tag-1'] },
|
||||
[tagIdsMapping],
|
||||
syncConfig,
|
||||
{ labels: ['bug', 'feature'] },
|
||||
{ labels: ['bug'] },
|
||||
ctx,
|
||||
);
|
||||
|
||||
const labelDecision = decisions.find((d) => d.field === 'labels');
|
||||
expect(labelDecision).toEqual({
|
||||
field: 'labels',
|
||||
action: 'skip',
|
||||
reason: 'provider changed (provider wins)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ export interface PushDecision {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
const valuesEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
return a === b;
|
||||
};
|
||||
|
||||
export const computePushDecisions = (
|
||||
changedTaskFields: Record<string, unknown>,
|
||||
fieldMappings: FieldMapping[],
|
||||
|
|
@ -49,7 +56,12 @@ export const computePushDecisions = (
|
|||
continue;
|
||||
}
|
||||
|
||||
if (freshIssueValues[mapping.issueField] !== lastSyncedValues[mapping.issueField]) {
|
||||
if (
|
||||
!valuesEqual(
|
||||
freshIssueValues[mapping.issueField],
|
||||
lastSyncedValues[mapping.issueField],
|
||||
)
|
||||
) {
|
||||
decisions.push({
|
||||
field: mapping.issueField,
|
||||
action: 'skip',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { IssueSyncAdapter } from './issue-sync-adapter.interface';
|
|||
import { IssueProvider } from '../issue.model';
|
||||
import { WorkContextType } from '../../work-context/work-context.model';
|
||||
import { DeletedTaskIssueSidecarService } from './deleted-task-issue-sidecar.service';
|
||||
import { deleteTag } from '../../tag/store/tag.actions';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
|
||||
describe('IssueTwoWaySyncEffects', () => {
|
||||
let effects: IssueTwoWaySyncEffects;
|
||||
|
|
@ -95,6 +97,14 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
toTaskValue: (val: unknown) => val,
|
||||
};
|
||||
|
||||
const tagIdsFieldMapping: FieldMapping = {
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (val: unknown) => val,
|
||||
toTaskValue: (val: unknown) => val,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
actions$ = new Subject<any>();
|
||||
|
||||
|
|
@ -179,6 +189,157 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
|
||||
it('should not advance baseline when provider changed and nothing was pushed', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
.createSpy('getFieldMappings')
|
||||
.and.returnValue([isDoneFieldMapping]),
|
||||
getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}),
|
||||
fetchIssue: jasmine
|
||||
.createSpy('fetchIssue')
|
||||
.and.resolveTo({ status: 'COMPLETED' }),
|
||||
extractSyncValues: jasmine
|
||||
.createSpy('extractSyncValues')
|
||||
.and.returnValue({ status: 'COMPLETED' }),
|
||||
});
|
||||
adapterRegistry.register('TEST_PROVIDER', adapter);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-1',
|
||||
issueType: 'TEST_PROVIDER' as any,
|
||||
issueId: 'issue-1',
|
||||
issueProviderId: 'provider-1',
|
||||
issueLastSyncedValues: { status: 'NEEDS-ACTION' },
|
||||
isDone: true,
|
||||
});
|
||||
|
||||
taskServiceSpy.getByIdOnce$.and.returnValue(of(task));
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider()));
|
||||
|
||||
effects.pushFieldsOnTaskUpdate$.subscribe();
|
||||
|
||||
actions$.next(
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: 'task-1', changes: { isDone: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
tick();
|
||||
|
||||
expect(adapter.pushChanges).not.toHaveBeenCalled();
|
||||
expect(taskServiceSpy.update).not.toHaveBeenCalled();
|
||||
|
||||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
|
||||
it('should not create undefined baselines for missing fresh values', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
.createSpy('getFieldMappings')
|
||||
.and.returnValue([isDoneFieldMapping, dueDayFieldMapping]),
|
||||
getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}),
|
||||
fetchIssue: jasmine
|
||||
.createSpy('fetchIssue')
|
||||
.and.resolveTo({ status: 'NEEDS-ACTION' }),
|
||||
extractSyncValues: jasmine
|
||||
.createSpy('extractSyncValues')
|
||||
.and.returnValue({ status: 'NEEDS-ACTION' }),
|
||||
});
|
||||
adapterRegistry.register('TEST_PROVIDER', adapter);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-1',
|
||||
issueType: 'TEST_PROVIDER' as any,
|
||||
issueId: 'issue-1',
|
||||
issueProviderId: 'provider-1',
|
||||
issueLastSyncedValues: { status: 'NEEDS-ACTION' },
|
||||
isDone: true,
|
||||
});
|
||||
|
||||
taskServiceSpy.getByIdOnce$.and.returnValue(of(task));
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider()));
|
||||
|
||||
effects.pushFieldsOnTaskUpdate$.subscribe();
|
||||
|
||||
actions$.next(
|
||||
TaskSharedActions.updateTask({
|
||||
task: { id: 'task-1', changes: { isDone: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
tick();
|
||||
|
||||
const updateChanges = taskServiceSpy.update.calls.mostRecent()
|
||||
.args[1] as Partial<Task>;
|
||||
expect(updateChanges.issueLastSyncedValues).toEqual({ status: 'COMPLETED' });
|
||||
expect('dtstart' in updateChanges.issueLastSyncedValues!).toBeFalse();
|
||||
|
||||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
|
||||
it('should leave issueLastUpdated untouched when another changed field must be pulled', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
.createSpy('getFieldMappings')
|
||||
.and.returnValue([isDoneFieldMapping, dueDayFieldMapping]),
|
||||
getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}),
|
||||
fetchIssue: jasmine.createSpy('fetchIssue').and.resolveTo({
|
||||
status: 'NEEDS-ACTION',
|
||||
dtstart: '2026-03-22',
|
||||
}),
|
||||
extractSyncValues: jasmine.createSpy('extractSyncValues').and.returnValue({
|
||||
status: 'NEEDS-ACTION',
|
||||
dtstart: '2026-03-22',
|
||||
}),
|
||||
});
|
||||
adapterRegistry.register('TEST_PROVIDER', adapter);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-1',
|
||||
issueType: 'TEST_PROVIDER' as any,
|
||||
issueId: 'issue-1',
|
||||
issueProviderId: 'provider-1',
|
||||
issueLastSyncedValues: {
|
||||
status: 'NEEDS-ACTION',
|
||||
dtstart: '2026-03-19',
|
||||
},
|
||||
isDone: true,
|
||||
dueDay: '2026-03-21',
|
||||
issueLastUpdated: 123,
|
||||
});
|
||||
|
||||
taskServiceSpy.getByIdOnce$.and.returnValue(of(task));
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider()));
|
||||
|
||||
effects.pushFieldsOnTaskUpdate$.subscribe();
|
||||
|
||||
actions$.next(
|
||||
TaskSharedActions.updateTask({
|
||||
task: {
|
||||
id: 'task-1',
|
||||
changes: { isDone: true, dueDay: '2026-03-21' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
tick();
|
||||
|
||||
expect(adapter.pushChanges).toHaveBeenCalledWith(
|
||||
'issue-1',
|
||||
{ status: 'COMPLETED' },
|
||||
jasmine.any(Object),
|
||||
);
|
||||
const updateChanges = taskServiceSpy.update.calls.mostRecent()
|
||||
.args[1] as Partial<Task>;
|
||||
expect(updateChanges.issueLastSyncedValues).toEqual({
|
||||
status: 'COMPLETED',
|
||||
dtstart: '2026-03-19',
|
||||
});
|
||||
expect('issueLastUpdated' in updateChanges).toBeFalse();
|
||||
|
||||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
|
||||
it('should skip push when no issueType on task', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
|
|
@ -548,6 +709,79 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
describe('pushTagChangesAfterTagDelete$', () => {
|
||||
it('should push updated labels for linked tasks affected by deleted tag titles', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
.createSpy('getFieldMappings')
|
||||
.and.returnValue([tagIdsFieldMapping]),
|
||||
getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}),
|
||||
fetchIssue: jasmine
|
||||
.createSpy('fetchIssue')
|
||||
.and.resolveTo({ labels: ['bug', 'feature'] }),
|
||||
extractSyncValues: jasmine
|
||||
.createSpy('extractSyncValues')
|
||||
.and.returnValue({ labels: ['bug', 'feature'] }),
|
||||
});
|
||||
adapterRegistry.register('TEST_PROVIDER', adapter);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-1',
|
||||
issueType: 'TEST_PROVIDER' as any,
|
||||
issueId: 'issue-1',
|
||||
issueProviderId: 'provider-1',
|
||||
tagIds: ['feature'],
|
||||
issueLastSyncedValues: { labels: ['bug', 'feature'] },
|
||||
});
|
||||
|
||||
store.overrideSelector(selectAllTasks, [task]);
|
||||
taskServiceSpy.getByIdOnce$.and.returnValue(of(task));
|
||||
issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider()));
|
||||
|
||||
effects.pushTagChangesAfterTagDelete$.subscribe();
|
||||
|
||||
actions$.next(deleteTag({ id: 'tag-bug', deletedTagTitles: ['bug'] }));
|
||||
tick();
|
||||
|
||||
expect(adapter.pushChanges).toHaveBeenCalledWith(
|
||||
'issue-1',
|
||||
{ labels: ['feature'] },
|
||||
jasmine.any(Object),
|
||||
);
|
||||
|
||||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
|
||||
it('should not push labels for tasks unaffected by deleted tag titles', fakeAsync(() => {
|
||||
const adapter = createMockAdapter({
|
||||
getFieldMappings: jasmine
|
||||
.createSpy('getFieldMappings')
|
||||
.and.returnValue([tagIdsFieldMapping]),
|
||||
});
|
||||
adapterRegistry.register('TEST_PROVIDER', adapter);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-1',
|
||||
issueType: 'TEST_PROVIDER' as any,
|
||||
issueId: 'issue-1',
|
||||
issueProviderId: 'provider-1',
|
||||
tagIds: ['feature'],
|
||||
issueLastSyncedValues: { labels: ['feature'] },
|
||||
});
|
||||
|
||||
store.overrideSelector(selectAllTasks, [task]);
|
||||
|
||||
effects.pushTagChangesAfterTagDelete$.subscribe();
|
||||
|
||||
actions$.next(deleteTag({ id: 'tag-bug', deletedTagTitles: ['bug'] }));
|
||||
tick();
|
||||
|
||||
expect(adapter.pushChanges).not.toHaveBeenCalled();
|
||||
|
||||
adapterRegistry.unregister('TEST_PROVIDER');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('deleteIssueOnTaskDelete$', () => {
|
||||
it('should call adapter.deleteIssue when task with issue is deleted', fakeAsync(() => {
|
||||
const deleteIssueSpy = jasmine.createSpy('deleteIssue').and.resolveTo(undefined);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import { catchError, concatMap, filter, map } from 'rxjs/operators';
|
|||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
import { IssueProviderService } from '../issue-provider.service';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
|
||||
import { computePushDecisions } from './compute-push-decisions';
|
||||
import { FieldMapping, FieldSyncConfig } from './issue-sync.model';
|
||||
import { IssueSyncAdapter } from './issue-sync-adapter.interface';
|
||||
import { IssueProvider, IssueProviderKey } from '../issue.model';
|
||||
import { IssueLog } from '../../../core/log';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
|
|
@ -24,7 +26,11 @@ import { selectEnabledIssueProviders } from '../store/issue-provider.selectors';
|
|||
import { getErrorTxt } from '../../../util/get-error-text';
|
||||
import { T } from '../../../t.const';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
|
||||
import { TagService } from '../../tag/tag.service';
|
||||
import { createPluginSyncAdapter } from '../../../plugins/issue-provider/plugin-sync-adapter.service';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { deleteTag, deleteTags } from '../../tag/store/tag.actions';
|
||||
|
||||
const SYNCABLE_TASK_FIELDS: ReadonlySet<string> = new Set([
|
||||
'isDone',
|
||||
|
|
@ -33,8 +39,17 @@ const SYNCABLE_TASK_FIELDS: ReadonlySet<string> = new Set([
|
|||
'dueWithTime',
|
||||
'dueDay',
|
||||
'timeEstimate',
|
||||
'tagIds',
|
||||
]);
|
||||
|
||||
const toSortedStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.slice()
|
||||
.sort()
|
||||
: [];
|
||||
|
||||
// Lookup map to extract taskId and changes from each action type,
|
||||
// replacing chained if/else with manual casts.
|
||||
const ACTION_EXTRACTORS: Record<
|
||||
|
|
@ -71,6 +86,10 @@ const ACTION_EXTRACTORS: Record<
|
|||
changes: a.task.changes as Partial<Task>,
|
||||
};
|
||||
},
|
||||
[TaskSharedActions.addTagToTask.type]: (action) => {
|
||||
const a = action as ReturnType<typeof TaskSharedActions.addTagToTask>;
|
||||
return { taskId: a.taskId, changes: { tagIds: undefined } };
|
||||
},
|
||||
[PlannerActions.planTaskForDay.type]: (action) => {
|
||||
const a = action as ReturnType<typeof PlannerActions.planTaskForDay>;
|
||||
return { taskId: a.task.id, changes: { dueDay: a.day } };
|
||||
|
|
@ -91,6 +110,8 @@ export class IssueTwoWaySyncEffects {
|
|||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService);
|
||||
private readonly _pluginRegistry = inject(PluginIssueProviderRegistryService);
|
||||
private readonly _pluginHttp = inject(PluginHttpService);
|
||||
private readonly _tagService = inject(TagService);
|
||||
private _syncOriginatedTaskIds = new Set<string>();
|
||||
private static readonly _MAX_SYNC_ORIGINATED_IDS = 1000;
|
||||
|
||||
|
|
@ -108,6 +129,7 @@ export class IssueTwoWaySyncEffects {
|
|||
TaskSharedActions.scheduleTaskWithTime,
|
||||
TaskSharedActions.reScheduleTaskWithTime,
|
||||
TaskSharedActions.unscheduleTask,
|
||||
TaskSharedActions.addTagToTask,
|
||||
PlannerActions.planTaskForDay,
|
||||
PlannerActions.transferTask,
|
||||
),
|
||||
|
|
@ -139,7 +161,7 @@ export class IssueTwoWaySyncEffects {
|
|||
) {
|
||||
return false;
|
||||
}
|
||||
return this._adapterRegistry.has(fullTask.issueType);
|
||||
return !!this._getAdapter(fullTask.issueType);
|
||||
}),
|
||||
concatMap(({ fullTask, changes }) =>
|
||||
this._pushChanges$(fullTask, changes).pipe(
|
||||
|
|
@ -158,6 +180,49 @@ export class IssueTwoWaySyncEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
pushTagChangesAfterTagDelete$: Observable<unknown> = createEffect(
|
||||
() =>
|
||||
this._actions$.pipe(
|
||||
ofType(deleteTag, deleteTags),
|
||||
map((action) => action.deletedTagTitles ?? []),
|
||||
filter((deletedTagTitles) => deletedTagTitles.length > 0),
|
||||
concatMap((deletedTagTitles) =>
|
||||
this._store.select(selectAllTasks).pipe(
|
||||
first(),
|
||||
map((tasks) =>
|
||||
tasks.filter((task) =>
|
||||
this._isTaskAffectedByDeletedTagTitles(task, deletedTagTitles),
|
||||
),
|
||||
),
|
||||
concatMap((tasks) => from(tasks)),
|
||||
),
|
||||
),
|
||||
filter((task) => {
|
||||
if (this._syncOriginatedTaskIds.delete(task.id)) {
|
||||
return false;
|
||||
}
|
||||
if (!task.issueType || !task.issueProviderId || !task.issueId) {
|
||||
return false;
|
||||
}
|
||||
return !!this._getAdapter(task.issueType);
|
||||
}),
|
||||
concatMap((task) =>
|
||||
this._pushChanges$(task, { tagIds: task.tagIds }).pipe(
|
||||
catchError((err) => {
|
||||
IssueLog.err('Two-way sync tag delete push failed', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.ISSUE.S.TWO_WAY_SYNC_PUSH_FAILED,
|
||||
translateParams: { errorMsg: getErrorTxt(err) },
|
||||
});
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
deleteIssueOnTaskDelete$: Observable<unknown> = createEffect(
|
||||
() =>
|
||||
this._actions$.pipe(
|
||||
|
|
@ -165,7 +230,7 @@ export class IssueTwoWaySyncEffects {
|
|||
filter(
|
||||
({ task }) => !!task.issueId && !!task.issueType && !!task.issueProviderId,
|
||||
),
|
||||
filter(({ task }) => this._adapterRegistry.has(task.issueType!)),
|
||||
filter(({ task }) => !!this._getAdapter(task.issueType!)),
|
||||
concatMap(({ task }) => this._deleteRemoteIssue$(task)),
|
||||
),
|
||||
{ dispatch: false },
|
||||
|
|
@ -181,7 +246,7 @@ export class IssueTwoWaySyncEffects {
|
|||
return EMPTY;
|
||||
}
|
||||
return from(issueInfos).pipe(
|
||||
filter((info) => this._adapterRegistry.has(info.issueType)),
|
||||
filter((info) => !!this._getAdapter(info.issueType)),
|
||||
concatMap((info) => this._deleteRemoteIssue$(info)),
|
||||
);
|
||||
}),
|
||||
|
|
@ -207,7 +272,7 @@ export class IssueTwoWaySyncEffects {
|
|||
),
|
||||
filter((provider): provider is IssueProvider => !!provider),
|
||||
concatMap((provider) => {
|
||||
const adapter = this._adapterRegistry.get(provider.issueProviderKey);
|
||||
const adapter = this._getAdapter(provider.issueProviderKey);
|
||||
if (!adapter?.createIssue) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
|
@ -336,6 +401,71 @@ export class IssueTwoWaySyncEffects {
|
|||
return false;
|
||||
}
|
||||
|
||||
private _isTaskAffectedByDeletedTagTitles(
|
||||
task: Task,
|
||||
deletedTagTitles: string[],
|
||||
): boolean {
|
||||
if (!task.issueType || !task.issueId || !task.issueLastSyncedValues) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const adapter = this._getAdapter(task.issueType);
|
||||
if (!adapter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deletedTitleSet = new Set(deletedTagTitles);
|
||||
const parsed = parseInt(task.issueId, 10);
|
||||
const ctx = {
|
||||
issueId: task.issueId,
|
||||
issueNumber: Number.isNaN(parsed) ? undefined : parsed,
|
||||
};
|
||||
|
||||
for (const mapping of adapter.getFieldMappings()) {
|
||||
if (mapping.taskField !== 'tagIds') {
|
||||
continue;
|
||||
}
|
||||
const lastValue = task.issueLastSyncedValues[mapping.issueField];
|
||||
const labels = toSortedStringArray(
|
||||
lastValue != null ? mapping.toTaskValue(lastValue, ctx) : [],
|
||||
);
|
||||
if (labels.some((label) => deletedTitleSet.has(label))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getAdapter(issueType: string): IssueSyncAdapter<unknown> | undefined {
|
||||
const existing = this._adapterRegistry.get(issueType);
|
||||
if (existing) return existing;
|
||||
|
||||
const provider = this._pluginRegistry.getProvider(issueType);
|
||||
const definition = provider?.definition;
|
||||
if (
|
||||
!provider ||
|
||||
!definition ||
|
||||
(!definition.createIssue &&
|
||||
!definition.deleteIssue &&
|
||||
!(definition.fieldMappings?.length && definition.updateIssue))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
(getHeadersFn) =>
|
||||
this._pluginHttp.createHttpHelper(getHeadersFn, {
|
||||
allowPrivateNetwork: provider.allowPrivateNetwork,
|
||||
}),
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
this._adapterRegistry.register(issueType, adapter);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
private _deleteRemoteIssue$(info: DeletedTaskIssueInfo | Task): Observable<unknown> {
|
||||
if (!info.issueType || !info.issueProviderId || !info.issueId) {
|
||||
return EMPTY;
|
||||
|
|
@ -379,7 +509,7 @@ export class IssueTwoWaySyncEffects {
|
|||
const issueType = task.issueType as IssueProviderKey;
|
||||
const issueProviderId = task.issueProviderId;
|
||||
const issueId = task.issueId;
|
||||
const adapter = this._adapterRegistry.get(issueType);
|
||||
const adapter = this._getAdapter(issueType);
|
||||
if (!adapter) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
|
@ -440,36 +570,43 @@ export class IssueTwoWaySyncEffects {
|
|||
const didPush = Object.keys(toPush).length > 0;
|
||||
if (didPush) {
|
||||
await adapter.pushChanges(issueId, toPush, cfg);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update lastSyncedValues for ALL tracked fields from the fresh issue,
|
||||
// overriding with pushed values for fields we just pushed.
|
||||
const updatedSyncValues: Record<string, unknown> = {};
|
||||
for (const mapping of fieldMappings) {
|
||||
const pushDecision = decisions.find(
|
||||
(d) => d.field === mapping.issueField && d.action === 'push',
|
||||
);
|
||||
updatedSyncValues[mapping.issueField] = pushDecision
|
||||
? pushDecision.issueValue
|
||||
: freshValues[mapping.issueField];
|
||||
const pushedDecisions = decisions.filter((d) => d.action === 'push');
|
||||
const hasProviderOwnedSkip = decisions.some(
|
||||
(d) =>
|
||||
d.action === 'skip' &&
|
||||
(d.reason === 'provider changed (provider wins)' ||
|
||||
d.reason === 'no baseline (first sync)'),
|
||||
);
|
||||
|
||||
// Only advance baselines for fields we actually wrote. Fresh provider
|
||||
// values for skipped or unrelated fields still need the polling path to
|
||||
// pull them into the task before they become the new baseline.
|
||||
const updatedSyncValues: Record<string, unknown> = { ...lastSyncedValues };
|
||||
for (const pushDecision of pushedDecisions) {
|
||||
updatedSyncValues[pushDecision.field] = pushDecision.issueValue;
|
||||
}
|
||||
|
||||
// After push, re-fetch the issue to get the provider's updated marker
|
||||
// (e.g. CalDAV etag changes on write). Fall back to Date.now() for
|
||||
// providers that don't implement getIssueLastUpdated.
|
||||
let issueLastUpdated = Date.now();
|
||||
if (didPush && adapter.getIssueLastUpdated) {
|
||||
if (didPush && !hasProviderOwnedSkip && adapter.getIssueLastUpdated) {
|
||||
const postPushIssue = await adapter.fetchIssue(issueId, cfg);
|
||||
issueLastUpdated = adapter.getIssueLastUpdated(postPushIssue);
|
||||
}
|
||||
|
||||
// Update sync values and issueLastUpdated to prevent poll from
|
||||
// treating our own push as an external update
|
||||
// treating our own push as an external update. If any changed field was
|
||||
// provider-owned, keep the old issueLastUpdated so polling can pull it.
|
||||
this._trackSyncOriginatedTask(task.id);
|
||||
try {
|
||||
this._taskService.update(task.id, {
|
||||
issueLastSyncedValues: updatedSyncValues,
|
||||
issueLastUpdated,
|
||||
...(hasProviderOwnedSkip ? {} : { issueLastUpdated }),
|
||||
});
|
||||
} catch (e) {
|
||||
this._syncOriginatedTaskIds.delete(task.id);
|
||||
|
|
|
|||
|
|
@ -28,19 +28,22 @@ export const updateTag = createAction(
|
|||
}),
|
||||
);
|
||||
|
||||
export const deleteTag = createAction('[Tag] Delete Tag', (tagProps: { id: string }) => ({
|
||||
...tagProps,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TAG',
|
||||
entityId: tagProps.id,
|
||||
opType: OpType.Delete,
|
||||
} satisfies PersistentActionMeta,
|
||||
}));
|
||||
export const deleteTag = createAction(
|
||||
'[Tag] Delete Tag',
|
||||
(tagProps: { id: string; deletedTagTitles?: string[] }) => ({
|
||||
...tagProps,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TAG',
|
||||
entityId: tagProps.id,
|
||||
opType: OpType.Delete,
|
||||
} satisfies PersistentActionMeta,
|
||||
}),
|
||||
);
|
||||
|
||||
export const deleteTags = createAction(
|
||||
'[Tag] Delete multiple Tags',
|
||||
(tagProps: { ids: string[] }) => ({
|
||||
(tagProps: { ids: string[]; deletedTagTitles?: string[] }) => ({
|
||||
...tagProps,
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ describe('TagService', () => {
|
|||
|
||||
service.deleteTag('tag-1');
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(deleteTag({ id: 'tag-1' }));
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: deleteTag.type, id: 'tag-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +165,9 @@ describe('TagService', () => {
|
|||
|
||||
service.removeTag('tag-2');
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(deleteTag({ id: 'tag-2' }));
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: deleteTag.type, id: 'tag-2' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -173,7 +177,12 @@ describe('TagService', () => {
|
|||
|
||||
service.deleteTags(['tag-1', 'tag-2']);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(deleteTags({ ids: ['tag-1', 'tag-2'] }));
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
type: deleteTags.type,
|
||||
ids: ['tag-1', 'tag-2'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ export class TagService {
|
|||
}
|
||||
|
||||
deleteTag(id: string): void {
|
||||
this._store$.dispatch(deleteTag({ id }));
|
||||
this._store$.dispatch(
|
||||
deleteTag({ id, deletedTagTitles: this._getTagTitlesByIds([id]) }),
|
||||
);
|
||||
}
|
||||
|
||||
removeTag(id: string): void {
|
||||
this._store$.dispatch(deleteTag({ id }));
|
||||
this.deleteTag(id);
|
||||
}
|
||||
|
||||
updateColor(id: string, color: string): void {
|
||||
|
|
@ -75,7 +77,9 @@ export class TagService {
|
|||
}
|
||||
|
||||
deleteTags(ids: string[]): void {
|
||||
this._store$.dispatch(deleteTags({ ids }));
|
||||
this._store$.dispatch(
|
||||
deleteTags({ ids, deletedTagTitles: this._getTagTitlesByIds(ids) }),
|
||||
);
|
||||
}
|
||||
|
||||
updateTag(id: string, changes: Partial<Tag>): void {
|
||||
|
|
@ -103,4 +107,15 @@ export class TagService {
|
|||
action: addTag({ tag: newTag }),
|
||||
};
|
||||
}
|
||||
|
||||
private _getTagTitlesByIds(ids: string[]): string[] {
|
||||
const idSet = new Set(ids);
|
||||
try {
|
||||
return this.tags()
|
||||
.filter((tag) => idSet.has(tag.id))
|
||||
.map((tag) => tag.title);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Task } from '../../features/tasks/task.model';
|
|||
import { TaskService } from '../../features/tasks/task.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
|
||||
describe('PluginIssueProviderAdapterService', () => {
|
||||
let service: PluginIssueProviderAdapterService;
|
||||
|
|
@ -26,6 +27,7 @@ describe('PluginIssueProviderAdapterService', () => {
|
|||
let storeSpy: jasmine.SpyObj<Store>;
|
||||
let snackSpy: jasmine.SpyObj<SnackService>;
|
||||
let taskServiceSpy: jasmine.SpyObj<TaskService>;
|
||||
let tagServiceSpy: jasmine.SpyObj<TagService>;
|
||||
|
||||
const PLUGIN_KEY = 'plugin:test-plugin';
|
||||
const PROVIDER_ID = 'provider-123';
|
||||
|
|
@ -86,13 +88,20 @@ describe('PluginIssueProviderAdapterService', () => {
|
|||
'getAvailableProviders',
|
||||
]);
|
||||
pluginHttpSpy = jasmine.createSpyObj('PluginHttpService', ['createHttpHelper']);
|
||||
storeSpy = jasmine.createSpyObj('Store', ['select']);
|
||||
storeSpy = jasmine.createSpyObj('Store', ['select', 'pipe']);
|
||||
|
||||
snackSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
taskServiceSpy = jasmine.createSpyObj('TaskService', ['removeMultipleTasks']);
|
||||
tagServiceSpy = jasmine.createSpyObj('TagService', [
|
||||
'tagsNoMyDayAndNoList',
|
||||
'addTag',
|
||||
]);
|
||||
pluginHttpSpy.createHttpHelper.and.returnValue(mockHttpHelper);
|
||||
storeSpy.select.and.returnValue(of(mockPluginCfg));
|
||||
storeSpy.pipe.and.returnValue(of([]));
|
||||
registrySpy.hasProvider.and.returnValue(true);
|
||||
tagServiceSpy.tagsNoMyDayAndNoList.and.returnValue([]);
|
||||
tagServiceSpy.addTag.and.returnValue('new-tag-id');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
|
@ -102,6 +111,7 @@ describe('PluginIssueProviderAdapterService', () => {
|
|||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: SnackService, useValue: snackSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: TagService, useValue: tagServiceSpy },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -363,6 +373,43 @@ describe('PluginIssueProviderAdapterService', () => {
|
|||
expect((result as any).dueDay).toBeDefined();
|
||||
expect((result as any).dueWithTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should apply tag mappings and seed sync values when cfg is available', () => {
|
||||
const issueData = {
|
||||
id: 'ISS-10',
|
||||
title: 'Tagged issue',
|
||||
labels: ['bug'],
|
||||
lastUpdated: 2000,
|
||||
} as PluginSearchResult;
|
||||
const provider = createMockProvider({
|
||||
fieldMappings: [
|
||||
{
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => v,
|
||||
toTaskValue: (v: unknown) => v,
|
||||
},
|
||||
],
|
||||
extractSyncValues: undefined,
|
||||
});
|
||||
registrySpy.getProvider.and.returnValue(provider);
|
||||
tagServiceSpy.tagsNoMyDayAndNoList.and.returnValue([
|
||||
{ id: 'tag-bug', title: 'bug' } as any,
|
||||
]);
|
||||
|
||||
const result = service.getAddTaskDataForCfg(issueData, {
|
||||
...mockPluginCfg,
|
||||
pluginConfig: {
|
||||
...mockPluginConfig,
|
||||
twoWaySync: { tagIds: 'both' },
|
||||
},
|
||||
} as IssueProviderPluginType);
|
||||
|
||||
expect(result.tagIds).toEqual(['tag-bug']);
|
||||
expect(result.issueLastSyncedValues).toEqual({ labels: ['bug'] });
|
||||
expect(result.issueLastUpdated).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFreshDataForIssueTask', () => {
|
||||
|
|
@ -733,6 +780,102 @@ describe('PluginIssueProviderAdapterService', () => {
|
|||
expect(result!.taskChanges['dueWithTime' as keyof Task]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use issue field fallback for tagIds when extractSyncValues is absent', async () => {
|
||||
const freshIssue: PluginIssue = {
|
||||
id: 'ISS-1',
|
||||
title: 'Tagged',
|
||||
lastUpdated: 2000,
|
||||
labels: ['bug'],
|
||||
};
|
||||
const provider = createMockProvider({
|
||||
getById: jasmine.createSpy('getById').and.resolveTo(freshIssue),
|
||||
fieldMappings: [
|
||||
{
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => v,
|
||||
toTaskValue: (v: unknown) => v,
|
||||
},
|
||||
],
|
||||
extractSyncValues: undefined,
|
||||
});
|
||||
registrySpy.getProvider.and.returnValue(provider);
|
||||
|
||||
const cfgWithSync = {
|
||||
...mockPluginCfg,
|
||||
pluginConfig: {
|
||||
...mockPluginConfig,
|
||||
twoWaySync: { tagIds: 'both' },
|
||||
},
|
||||
} as IssueProviderPluginType;
|
||||
storeSpy.select.and.returnValue(of(cfgWithSync));
|
||||
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
issueId: 'ISS-1',
|
||||
issueProviderId: PROVIDER_ID,
|
||||
issueLastUpdated: 1000,
|
||||
tagIds: ['local-bug-tag'],
|
||||
issueLastSyncedValues: { labels: ['bug'] },
|
||||
} as unknown as Task;
|
||||
|
||||
const result = await service.getFreshDataForIssueTask(task);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.taskChanges['tagIds' as keyof Task]).toBeUndefined();
|
||||
expect(result!.taskChanges.issueLastSyncedValues).toEqual({ labels: ['bug'] });
|
||||
});
|
||||
|
||||
it('should use issue field fallback for tagIds when extractSyncValues omits labels', async () => {
|
||||
const freshIssue: PluginIssue = {
|
||||
id: 'ISS-1',
|
||||
title: 'Tagged',
|
||||
lastUpdated: 2000,
|
||||
labels: ['bug'],
|
||||
};
|
||||
const provider = createMockProvider({
|
||||
getById: jasmine.createSpy('getById').and.resolveTo(freshIssue),
|
||||
fieldMappings: [
|
||||
{
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => v,
|
||||
toTaskValue: (v: unknown) => v,
|
||||
},
|
||||
],
|
||||
extractSyncValues: jasmine
|
||||
.createSpy('extractSyncValues')
|
||||
.and.returnValue({ title: 'Tagged' }),
|
||||
});
|
||||
registrySpy.getProvider.and.returnValue(provider);
|
||||
|
||||
const cfgWithSync = {
|
||||
...mockPluginCfg,
|
||||
pluginConfig: {
|
||||
...mockPluginConfig,
|
||||
twoWaySync: { tagIds: 'both' },
|
||||
},
|
||||
} as IssueProviderPluginType;
|
||||
storeSpy.select.and.returnValue(of(cfgWithSync));
|
||||
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
issueId: 'ISS-1',
|
||||
issueProviderId: PROVIDER_ID,
|
||||
issueLastUpdated: 1000,
|
||||
tagIds: ['local-bug-tag'],
|
||||
issueLastSyncedValues: { labels: ['bug'] },
|
||||
} as unknown as Task;
|
||||
|
||||
const result = await service.getFreshDataForIssueTask(task);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.taskChanges['tagIds' as keyof Task]).toBeUndefined();
|
||||
expect(result!.taskChanges.issueLastSyncedValues).toEqual({ labels: ['bug'] });
|
||||
});
|
||||
|
||||
it('should pass issueNumber from the fresh issue into ctx for toTaskValue', async () => {
|
||||
const freshIssue: PluginIssue = {
|
||||
id: 'ISS-1',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { selectIssueProviderById } from '../../features/issue/store/issue-provid
|
|||
import { firstValueFrom } from 'rxjs';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { TaskService } from '../../features/tasks/task.service';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
import { sortTagLabels } from './plugin-tag-utils';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
|
|
@ -28,6 +30,7 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
private _store = inject(Store);
|
||||
private _snackService = inject(SnackService);
|
||||
private _taskService = inject(TaskService);
|
||||
private _tagService = inject(TagService);
|
||||
|
||||
// Not meaningful for a multi-plugin adapter, but required by interface
|
||||
pollInterval = 0;
|
||||
|
|
@ -108,6 +111,28 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
return this._buildBaseIssueTask(issueData as PluginIssue);
|
||||
}
|
||||
|
||||
getAddTaskDataForCfg(issueData: IssueDataReduced, cfg: unknown): IssueTask {
|
||||
const pluginCfg = this._asPluginCfg(cfg);
|
||||
if (!pluginCfg) {
|
||||
return this.getAddTaskData(issueData);
|
||||
}
|
||||
|
||||
const provider = this._registry.getProvider(pluginCfg.issueProviderKey);
|
||||
if (!provider) {
|
||||
return this.getAddTaskData(issueData);
|
||||
}
|
||||
|
||||
const issue = issueData as PluginIssue;
|
||||
const syncValues = this._extractSyncValues(issue, provider);
|
||||
const tagIds = this._extractInitialTagIds(provider, syncValues, pluginCfg, issue);
|
||||
|
||||
return {
|
||||
...this._getAddTaskDataForProvider(issue, provider, syncValues),
|
||||
...(tagIds ? { tagIds } : {}),
|
||||
issueLastSyncedValues: syncValues,
|
||||
};
|
||||
}
|
||||
|
||||
async searchIssues(
|
||||
searchTerm: string,
|
||||
issueProviderId: string,
|
||||
|
|
@ -184,18 +209,17 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
issue.lastUpdated != null && issue.lastUpdated > (task.issueLastUpdated || 0);
|
||||
if (isUpdated) {
|
||||
// Compute sync values once and pass through to avoid redundant calls
|
||||
const issueLastSyncedValues =
|
||||
resolved.provider.definition.extractSyncValues?.(issue);
|
||||
const issueLastSyncedValues = this._extractSyncValues(issue, resolved.provider);
|
||||
const addTaskData = this._getAddTaskDataForProvider(
|
||||
issue,
|
||||
resolved.provider,
|
||||
issueLastSyncedValues ?? {},
|
||||
issueLastSyncedValues,
|
||||
);
|
||||
|
||||
// Apply field mappings to pull changes from issue to task
|
||||
const fieldChanges = this._applyFieldMappingPull(
|
||||
resolved.provider,
|
||||
issueLastSyncedValues ?? {},
|
||||
issueLastSyncedValues,
|
||||
task,
|
||||
cfg,
|
||||
issue,
|
||||
|
|
@ -206,7 +230,7 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
...addTaskData,
|
||||
...fieldChanges,
|
||||
issueWasUpdated: true,
|
||||
...(issueLastSyncedValues ? { issueLastSyncedValues } : {}),
|
||||
issueLastSyncedValues,
|
||||
},
|
||||
issue,
|
||||
issueTitle: issue.title,
|
||||
|
|
@ -274,7 +298,7 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
|
||||
// --- Private helpers ---
|
||||
|
||||
private _asPluginCfg(cfg: IssueIntegrationCfg): IssueProviderPluginType | undefined {
|
||||
private _asPluginCfg(cfg: unknown): IssueProviderPluginType | undefined {
|
||||
const candidate = cfg as unknown as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate['pluginId'] !== 'string' ||
|
||||
|
|
@ -325,6 +349,35 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
return typeof n === 'number' ? n : undefined;
|
||||
}
|
||||
|
||||
private _extractSyncValues(
|
||||
issue: PluginIssue,
|
||||
provider: RegisteredPluginIssueProvider,
|
||||
): Record<string, unknown> {
|
||||
const fieldMappings = provider.definition.fieldMappings;
|
||||
if (!fieldMappings?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = provider.definition.extractSyncValues
|
||||
? provider.definition.extractSyncValues(issue)
|
||||
: (issue as Record<string, unknown>);
|
||||
const issueRecord = issue as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of fieldMappings) {
|
||||
const value = Object.prototype.hasOwnProperty.call(data, mapping.issueField)
|
||||
? data[mapping.issueField]
|
||||
: issueRecord[mapping.issueField];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[mapping.issueField] =
|
||||
mapping.taskField === 'tagIds' ? sortTagLabels(value) : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private _extractTaskFieldsFromIssueWithSyncValues(
|
||||
issue: PluginIssue,
|
||||
provider: RegisteredPluginIssueProvider,
|
||||
|
|
@ -344,6 +397,9 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
if (issueValue == null) {
|
||||
continue;
|
||||
}
|
||||
if (mapping.taskField === 'tagIds') {
|
||||
continue;
|
||||
}
|
||||
const taskValue = mapping.toTaskValue(issueValue, ctx);
|
||||
if (taskValue != null) {
|
||||
result[mapping.taskField] = taskValue;
|
||||
|
|
@ -422,6 +478,58 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
}
|
||||
}
|
||||
|
||||
private _mapLabelsToTagIds(labels: string[], shouldCreate: boolean): string[] {
|
||||
const allTags = this._tagService.tagsNoMyDayAndNoList();
|
||||
const tagIds: string[] = [];
|
||||
for (const label of labels) {
|
||||
const existing = allTags.find((t) => t.title === label);
|
||||
if (existing) {
|
||||
tagIds.push(existing.id);
|
||||
} else if (shouldCreate) {
|
||||
tagIds.push(this._tagService.addTag({ title: label }));
|
||||
}
|
||||
}
|
||||
return tagIds;
|
||||
}
|
||||
|
||||
private _extractInitialTagIds(
|
||||
provider: RegisteredPluginIssueProvider,
|
||||
syncValues: Record<string, unknown>,
|
||||
cfg: IssueProviderPluginType,
|
||||
issue: PluginIssue,
|
||||
): string[] | undefined {
|
||||
const fieldMappings = provider.definition.fieldMappings;
|
||||
if (!fieldMappings?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const twoWaySync = (cfg.pluginConfig?.['twoWaySync'] as Record<string, string>) ?? {};
|
||||
const ctx = {
|
||||
issueId: issue.id,
|
||||
issueNumber: this._getIssueNumber(issue),
|
||||
};
|
||||
const isAutoCreateTags = !!(cfg.pluginConfig as Record<string, unknown>)?.[
|
||||
'isAutoCreateTags'
|
||||
];
|
||||
|
||||
for (const mapping of fieldMappings) {
|
||||
const dir = twoWaySync[mapping.taskField] ?? mapping.defaultDirection;
|
||||
if (mapping.taskField !== 'tagIds' || (dir !== 'pullOnly' && dir !== 'both')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const issueValue = syncValues[mapping.issueField];
|
||||
if (issueValue == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labels = sortTagLabels(mapping.toTaskValue(issueValue, ctx));
|
||||
return this._mapLabelsToTagIds(labels, isAutoCreateTags);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _applyFieldMappingPull(
|
||||
provider: RegisteredPluginIssueProvider,
|
||||
freshSyncValues: Record<string, unknown>,
|
||||
|
|
@ -451,6 +559,27 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface
|
|||
const freshValue = freshSyncValues[mapping.issueField];
|
||||
const lastValue = lastSyncedValues[mapping.issueField];
|
||||
|
||||
if (mapping.taskField === 'tagIds') {
|
||||
const toLabels = (v: unknown): string[] => {
|
||||
const taskValue = v != null ? mapping.toTaskValue(v, ctx) : [];
|
||||
return sortTagLabels(taskValue);
|
||||
};
|
||||
const labels = toLabels(freshValue);
|
||||
const lastLabels = toLabels(lastValue);
|
||||
if (
|
||||
labels.length === lastLabels.length &&
|
||||
labels.every((l, i) => l === lastLabels[i])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isAutoCreateTags = !!(cfg.pluginConfig as Record<string, unknown>)?.[
|
||||
'isAutoCreateTags'
|
||||
];
|
||||
const tagIds = this._mapLabelsToTagIds(labels, isAutoCreateTags);
|
||||
changes.tagIds = tagIds;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only pull if the issue value actually changed since last sync
|
||||
if (freshValue === lastValue) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
PluginHttp,
|
||||
} from './plugin-issue-provider.model';
|
||||
import { IssueProviderPluginType } from '../../features/issue/issue.model';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
|
||||
const MOCK_FIELD_MAPPINGS: PluginFieldMapping[] = [
|
||||
{
|
||||
|
|
@ -23,6 +24,14 @@ const MOCK_FIELD_MAPPINGS: PluginFieldMapping[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const MOCK_TAG_IDS_FIELD_MAPPING: PluginFieldMapping = {
|
||||
taskField: 'tagIds',
|
||||
issueField: 'labels',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => v,
|
||||
toTaskValue: (v: unknown) => v,
|
||||
};
|
||||
|
||||
const createMockDefinition = (
|
||||
overrides: Partial<IssueProviderPluginDefinition> = {},
|
||||
): IssueProviderPluginDefinition => ({
|
||||
|
|
@ -76,9 +85,18 @@ const mockHttpHelper: PluginHttp = {
|
|||
request: jasmine.createSpy('request'),
|
||||
};
|
||||
|
||||
const mockTagService = {
|
||||
tags: () => [],
|
||||
addTag: jasmine.createSpy('addTag').and.returnValue('new-tag-id'),
|
||||
} as unknown as TagService;
|
||||
|
||||
describe('createPluginSyncAdapter', () => {
|
||||
it('should return field mappings from plugin definition', () => {
|
||||
const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition(),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
|
||||
|
|
@ -89,7 +107,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
});
|
||||
|
||||
it('should return sync config from pluginConfig.twoWaySync', () => {
|
||||
const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition(),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const syncConfig = adapter.getSyncConfig(MOCK_CFG);
|
||||
|
||||
|
|
@ -101,8 +123,39 @@ describe('createPluginSyncAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should downgrade push directions when updateIssue is absent', () => {
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition({
|
||||
updateIssue: undefined,
|
||||
fieldMappings: [
|
||||
{
|
||||
taskField: 'isDone',
|
||||
issueField: 'state',
|
||||
defaultDirection: 'both',
|
||||
toIssueValue: (v: unknown) => (v ? 'closed' : 'open'),
|
||||
toTaskValue: (v: unknown) => v === 'closed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
expect(adapter.getFieldMappings()[0].defaultDirection).toBe('pullOnly');
|
||||
expect(
|
||||
adapter.getSyncConfig({
|
||||
...MOCK_CFG,
|
||||
pluginConfig: { twoWaySync: { isDone: 'pushOnly' } },
|
||||
}).isDone,
|
||||
).toBe('pullOnly');
|
||||
});
|
||||
|
||||
it('should return empty sync config when twoWaySync is absent', () => {
|
||||
const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition(),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const cfgWithoutSync = {
|
||||
...MOCK_CFG,
|
||||
|
|
@ -114,7 +167,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should fetch issue via definition.getById', async () => {
|
||||
const definition = createMockDefinition();
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const result = await adapter.fetchIssue('1', MOCK_CFG);
|
||||
|
||||
|
|
@ -128,7 +185,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should push changes via definition.updateIssue', async () => {
|
||||
const definition = createMockDefinition();
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
await adapter.pushChanges('1', { state: 'closed' }, MOCK_CFG);
|
||||
|
||||
|
|
@ -142,7 +203,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should throw when pushing without updateIssue', async () => {
|
||||
const definition = createMockDefinition({ updateIssue: undefined });
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
await expectAsync(
|
||||
adapter.pushChanges('1', { state: 'closed' }, MOCK_CFG),
|
||||
|
|
@ -151,7 +216,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should extract sync values via definition.extractSyncValues', () => {
|
||||
const definition = createMockDefinition();
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const issue = { state: 'open', title: 'test', extra: 'ignored' };
|
||||
const result = adapter.extractSyncValues(issue);
|
||||
|
|
@ -162,7 +231,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should fall back to field-based extraction when extractSyncValues is absent', () => {
|
||||
const definition = createMockDefinition({ extractSyncValues: undefined });
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const issue = { state: 'open', title: 'test', extra: 'ignored' };
|
||||
const result = adapter.extractSyncValues(issue);
|
||||
|
|
@ -172,7 +245,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should create issue via definition.createIssue', async () => {
|
||||
const definition = createMockDefinition();
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const result = await adapter.createIssue!('new issue', MOCK_CFG);
|
||||
|
||||
|
|
@ -187,7 +264,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should throw when creating without createIssue', async () => {
|
||||
const definition = createMockDefinition({ createIssue: undefined });
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
await expectAsync(adapter.createIssue!('new issue', MOCK_CFG)).toBeRejectedWithError(
|
||||
/does not implement createIssue/,
|
||||
|
|
@ -195,7 +276,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
});
|
||||
|
||||
it('should convert field mapping direction functions correctly', () => {
|
||||
const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition(),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
const isDoneMapping = mappings[0];
|
||||
|
|
@ -221,6 +306,7 @@ describe('createPluginSyncAdapter', () => {
|
|||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition({ fieldMappings: mappingsWithExclusive }),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
|
|
@ -231,7 +317,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
});
|
||||
|
||||
it('should not set mutuallyExclusive when not provided in plugin mapping', () => {
|
||||
const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
createMockDefinition(),
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
|
||||
|
|
@ -243,7 +333,11 @@ describe('createPluginSyncAdapter', () => {
|
|||
.createSpy('deleteIssue')
|
||||
.and.returnValue(Promise.resolve());
|
||||
const definition = createMockDefinition({ deleteIssue: deleteIssueSpy });
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
await adapter.deleteIssue!('99', MOCK_CFG);
|
||||
|
||||
|
|
@ -256,8 +350,104 @@ describe('createPluginSyncAdapter', () => {
|
|||
|
||||
it('should set deleteIssue to undefined when definition does not implement it', () => {
|
||||
const definition = createMockDefinition({ deleteIssue: undefined });
|
||||
const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper);
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
mockTagService,
|
||||
);
|
||||
|
||||
expect(adapter.deleteIssue).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('tagIds mapping', () => {
|
||||
const tagIdA = 'tag-a';
|
||||
const tagIdB = 'tag-b';
|
||||
const tagServiceWithTags = {
|
||||
tags: () => [
|
||||
{ id: tagIdA, title: 'bug' },
|
||||
{ id: tagIdB, title: 'feature' },
|
||||
],
|
||||
addTag: jasmine.createSpy('addTag'),
|
||||
} as unknown as TagService;
|
||||
|
||||
it('should convert tagIds to sorted label array via toIssueValue', () => {
|
||||
const definition = createMockDefinition({
|
||||
fieldMappings: [...MOCK_FIELD_MAPPINGS, MOCK_TAG_IDS_FIELD_MAPPING],
|
||||
});
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
tagServiceWithTags,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
const tagMapping = mappings.find((m) => m.taskField === 'tagIds');
|
||||
|
||||
expect(tagMapping).toBeDefined();
|
||||
const result = tagMapping!.toIssueValue([tagIdB, tagIdA], {
|
||||
issueId: '1',
|
||||
}) as string[];
|
||||
expect(result).toEqual(['bug', 'feature']);
|
||||
});
|
||||
|
||||
it('should fall back to raw tagId as label when tag is not found locally', () => {
|
||||
const tagServiceNoTags = {
|
||||
tags: () => [],
|
||||
addTag: jasmine.createSpy('addTag'),
|
||||
} as unknown as TagService;
|
||||
|
||||
const definition = createMockDefinition({
|
||||
fieldMappings: [...MOCK_FIELD_MAPPINGS, MOCK_TAG_IDS_FIELD_MAPPING],
|
||||
});
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
tagServiceNoTags,
|
||||
);
|
||||
|
||||
const mappings = adapter.getFieldMappings();
|
||||
const tagMapping = mappings.find((m) => m.taskField === 'tagIds');
|
||||
|
||||
const result = tagMapping!.toIssueValue(['unknown-tag-id'], {
|
||||
issueId: '1',
|
||||
}) as string[];
|
||||
expect(result).toEqual(['unknown-tag-id']);
|
||||
});
|
||||
|
||||
it('should sort label arrays in extractSyncValues for consistent diffing', () => {
|
||||
const definition = createMockDefinition({
|
||||
fieldMappings: [...MOCK_FIELD_MAPPINGS, MOCK_TAG_IDS_FIELD_MAPPING],
|
||||
extractSyncValues: undefined,
|
||||
});
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
tagServiceWithTags,
|
||||
);
|
||||
|
||||
const issue = { state: 'open', title: 'test', labels: ['feature', 'bug'] };
|
||||
const result = adapter.extractSyncValues(issue);
|
||||
|
||||
expect(result['labels']).toEqual(['bug', 'feature']);
|
||||
});
|
||||
|
||||
it('should fall back to the raw issue field when extractSyncValues omits labels', () => {
|
||||
const definition = createMockDefinition({
|
||||
fieldMappings: [...MOCK_FIELD_MAPPINGS, MOCK_TAG_IDS_FIELD_MAPPING],
|
||||
extractSyncValues: jasmine
|
||||
.createSpy('extractSyncValues')
|
||||
.and.returnValue({ state: 'open', title: 'test' }),
|
||||
});
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
() => mockHttpHelper,
|
||||
tagServiceWithTags,
|
||||
);
|
||||
|
||||
const issue = { state: 'open', title: 'test', labels: ['feature', 'bug'] };
|
||||
const result = adapter.extractSyncValues(issue);
|
||||
|
||||
expect(result['labels']).toEqual(['bug', 'feature']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { IssueSyncAdapter } from '../../features/issue/two-way-sync/issue-sync-a
|
|||
import {
|
||||
FieldMapping,
|
||||
FieldSyncConfig,
|
||||
SyncDirection,
|
||||
} from '../../features/issue/two-way-sync/issue-sync.model';
|
||||
import { IssueProviderPluginType } from '../../features/issue/issue.model';
|
||||
import {
|
||||
|
|
@ -10,30 +11,78 @@ import {
|
|||
PluginHttp,
|
||||
} from './plugin-issue-provider.model';
|
||||
import { Task } from '../../features/tasks/task.model';
|
||||
import { TagService } from '../../features/tag/tag.service';
|
||||
import { sortTagLabels } from './plugin-tag-utils';
|
||||
|
||||
const convertMapping = (pm: PluginFieldMapping): FieldMapping => ({
|
||||
taskField: pm.taskField,
|
||||
issueField: pm.issueField,
|
||||
defaultDirection: pm.defaultDirection,
|
||||
toIssueValue: pm.toIssueValue,
|
||||
toTaskValue: pm.toTaskValue,
|
||||
...(pm.mutuallyExclusive
|
||||
? { mutuallyExclusive: pm.mutuallyExclusive as (keyof Task)[] }
|
||||
: {}),
|
||||
});
|
||||
const normalizeSyncDirectionForCapabilities = (
|
||||
direction: SyncDirection,
|
||||
isPushSupported: boolean,
|
||||
): SyncDirection =>
|
||||
!isPushSupported && (direction === 'pushOnly' || direction === 'both')
|
||||
? 'pullOnly'
|
||||
: direction;
|
||||
|
||||
const convertMapping = (
|
||||
pm: PluginFieldMapping,
|
||||
tagService: TagService,
|
||||
isPushSupported: boolean,
|
||||
): FieldMapping => {
|
||||
if (pm.taskField === 'tagIds') {
|
||||
return {
|
||||
taskField: pm.taskField,
|
||||
issueField: pm.issueField,
|
||||
defaultDirection: normalizeSyncDirectionForCapabilities(
|
||||
pm.defaultDirection,
|
||||
isPushSupported,
|
||||
),
|
||||
toIssueValue: (taskValue: unknown, ctx): unknown => {
|
||||
const tagIds = (taskValue as string[]) || [];
|
||||
const allTags = tagService.tags();
|
||||
const labels = tagIds
|
||||
.map((id) => allTags.find((t) => t.id === id)?.title || id)
|
||||
.sort();
|
||||
return pm.toIssueValue(labels, ctx);
|
||||
},
|
||||
toTaskValue: pm.toTaskValue,
|
||||
...(pm.mutuallyExclusive
|
||||
? { mutuallyExclusive: pm.mutuallyExclusive as (keyof Task)[] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
taskField: pm.taskField,
|
||||
issueField: pm.issueField,
|
||||
defaultDirection: normalizeSyncDirectionForCapabilities(
|
||||
pm.defaultDirection,
|
||||
isPushSupported,
|
||||
),
|
||||
toIssueValue: pm.toIssueValue,
|
||||
toTaskValue: pm.toTaskValue,
|
||||
...(pm.mutuallyExclusive
|
||||
? { mutuallyExclusive: pm.mutuallyExclusive as (keyof Task)[] }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an IssueSyncAdapter for a specific plugin issue provider.
|
||||
* One instance is created per plugin that declares fieldMappings + updateIssue.
|
||||
* One instance is created per plugin that declares issue side effects.
|
||||
*/
|
||||
export const createPluginSyncAdapter = (
|
||||
definition: IssueProviderPluginDefinition,
|
||||
createHttpHelper: (
|
||||
getHeaders: () => Record<string, string> | Promise<Record<string, string>>,
|
||||
) => PluginHttp,
|
||||
tagService: TagService,
|
||||
): IssueSyncAdapter<IssueProviderPluginType> => {
|
||||
const fieldMappings: FieldMapping[] = (definition.fieldMappings ?? []).map(
|
||||
convertMapping,
|
||||
const isPushSupported = !!definition.updateIssue;
|
||||
const fieldMappings: FieldMapping[] = (definition.fieldMappings ?? []).map((pm) =>
|
||||
convertMapping(pm, tagService, isPushSupported),
|
||||
);
|
||||
|
||||
const tagIssueFields = new Set(
|
||||
fieldMappings.filter((m) => m.taskField === 'tagIds').map((m) => m.issueField),
|
||||
);
|
||||
|
||||
const createHttp = (cfg: IssueProviderPluginType): PluginHttp =>
|
||||
|
|
@ -54,7 +103,10 @@ export const createPluginSyncAdapter = (
|
|||
for (const m of fieldMappings) {
|
||||
const direction = twoWay[m.taskField];
|
||||
if (direction && VALID_DIRECTIONS.has(direction)) {
|
||||
result[m.taskField] = direction as FieldSyncConfig[keyof FieldSyncConfig];
|
||||
result[m.taskField] = normalizeSyncDirectionForCapabilities(
|
||||
direction as SyncDirection,
|
||||
isPushSupported,
|
||||
) as FieldSyncConfig[keyof FieldSyncConfig];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
@ -82,16 +134,27 @@ export const createPluginSyncAdapter = (
|
|||
},
|
||||
|
||||
extractSyncValues: (issue: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (definition.extractSyncValues) {
|
||||
return definition.extractSyncValues(
|
||||
issue as Parameters<
|
||||
NonNullable<IssueProviderPluginDefinition['extractSyncValues']>
|
||||
>[0],
|
||||
);
|
||||
}
|
||||
const data = definition.extractSyncValues
|
||||
? definition.extractSyncValues(
|
||||
issue as Parameters<
|
||||
NonNullable<IssueProviderPluginDefinition['extractSyncValues']>
|
||||
>[0],
|
||||
)
|
||||
: issue;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const m of fieldMappings) {
|
||||
result[m.issueField] = issue[m.issueField];
|
||||
const value = Object.prototype.hasOwnProperty.call(data, m.issueField)
|
||||
? data[m.issueField]
|
||||
: issue[m.issueField];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (tagIssueFields.has(m.issueField)) {
|
||||
result[m.issueField] = sortTagLabels(value);
|
||||
} else {
|
||||
result[m.issueField] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
|
|
|||
2
src/app/plugins/issue-provider/plugin-tag-utils.ts
Normal file
2
src/app/plugins/issue-provider/plugin-tag-utils.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const sortTagLabels = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? (value as string[]).slice().sort() : [];
|
||||
|
|
@ -343,12 +343,19 @@ export class PluginBridgeService implements OnDestroy {
|
|||
return;
|
||||
}
|
||||
|
||||
// Register sync adapter if plugin supports two-way sync
|
||||
if (definition.fieldMappings?.length && definition.updateIssue) {
|
||||
// Register adapter when plugin supports any issue side effects. Push support
|
||||
// still requires updateIssue; create/delete can work without it.
|
||||
if (
|
||||
definition.createIssue ||
|
||||
definition.deleteIssue ||
|
||||
(definition.fieldMappings?.length && definition.updateIssue)
|
||||
) {
|
||||
const registered = this._pluginIssueProviderRegistry.getProvider(registeredKey);
|
||||
const httpOpts = { allowPrivateNetwork: registered?.allowPrivateNetwork };
|
||||
const adapter = createPluginSyncAdapter(definition, (getHeaders) =>
|
||||
this._pluginHttpService.createHttpHelper(getHeaders, httpOpts),
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
(getHeaders) => this._pluginHttpService.createHttpHelper(getHeaders, httpOpts),
|
||||
this._tagService,
|
||||
);
|
||||
this._syncAdapterRegistry.register(registeredKey, adapter);
|
||||
PluginLog.log(
|
||||
|
|
|
|||
|
|
@ -490,6 +490,8 @@ const T = {
|
|||
AUTO_CREATE_ISSUES: 'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES',
|
||||
AUTO_CREATE_ISSUES_DESCRIPTION:
|
||||
'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES_DESCRIPTION',
|
||||
AUTO_CREATE_TAGS: 'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS',
|
||||
AUTO_CREATE_TAGS_DESCRIPTION: 'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS_DESCRIPTION',
|
||||
BOTH: 'F.ISSUE.TWO_WAY_SYNC.BOTH',
|
||||
DUE_DAY: 'F.ISSUE.TWO_WAY_SYNC.DUE_DAY',
|
||||
DUE_WITH_TIME: 'F.ISSUE.TWO_WAY_SYNC.DUE_WITH_TIME',
|
||||
|
|
@ -499,6 +501,7 @@ const T = {
|
|||
PUSH_ONLY: 'F.ISSUE.TWO_WAY_SYNC.PUSH_ONLY',
|
||||
SECTION: 'F.ISSUE.TWO_WAY_SYNC.SECTION',
|
||||
STATUS: 'F.ISSUE.TWO_WAY_SYNC.STATUS',
|
||||
TAGS: 'F.ISSUE.TWO_WAY_SYNC.TAGS',
|
||||
TIME_ESTIMATE: 'F.ISSUE.TWO_WAY_SYNC.TIME_ESTIMATE',
|
||||
TITLE: 'F.ISSUE.TWO_WAY_SYNC.TITLE',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -488,6 +488,8 @@
|
|||
"TWO_WAY_SYNC": {
|
||||
"AUTO_CREATE_ISSUES": "Auto-create issues for new tasks",
|
||||
"AUTO_CREATE_ISSUES_DESCRIPTION": "Automatically creates an issue when you add a new top-level task to the default project. Requires a default project to be set.",
|
||||
"AUTO_CREATE_TAGS": "Auto-create tags for synced labels",
|
||||
"AUTO_CREATE_TAGS_DESCRIPTION": "Automatically creates local tags when unknown labels are pulled from the remote provider.",
|
||||
"BOTH": "Both (bidirectional)",
|
||||
"DUE_DAY": "Due Day",
|
||||
"DUE_WITH_TIME": "Due Date/Time",
|
||||
|
|
@ -497,6 +499,7 @@
|
|||
"PUSH_ONLY": "Push only",
|
||||
"SECTION": "Two-Way Sync (experimental)",
|
||||
"STATUS": "Status sync direction",
|
||||
"TAGS": "Tags / Categories sync direction",
|
||||
"TIME_ESTIMATE": "Time Estimate",
|
||||
"TITLE": "Title sync direction"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue