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:
Paul Tirk 2026-05-15 01:36:07 +02:00 committed by GitHub
parent 6f5e174b54
commit 1204380198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1274 additions and 96 deletions

View file

@ -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) */

View file

@ -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 },

View file

@ -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(

View file

@ -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;
}

View file

@ -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)',
});
});
});

View file

@ -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',

View file

@ -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);

View file

@ -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);

View file

@ -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,

View file

@ -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'],
}),
);
});
});

View file

@ -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 [];
}
}
}

View file

@ -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',

View file

@ -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;

View file

@ -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']);
});
});
});

View file

@ -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;
},

View file

@ -0,0 +1,2 @@
export const sortTagLabels = (value: unknown): string[] =>
Array.isArray(value) ? (value as string[]).slice().sort() : [];

View file

@ -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(

View file

@ -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',
},

View file

@ -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"
},