diff --git a/packages/plugin-api/src/issue-provider-types.ts b/packages/plugin-api/src/issue-provider-types.ts index 11c9e41134..b93775d51f 100644 --- a/packages/plugin-api/src/issue-provider-types.ts +++ b/packages/plugin-api/src/issue-provider-types.ts @@ -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) */ diff --git a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts index e7493ad92b..602e1de2b4 100644 --- a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts +++ b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts @@ -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 + | undefined; + if (defaultTwoWaySync) { + const provider = this._pluginRegistry.getProvider(this.issueProviderKey); + const isPushSupported = !!provider?.definition.updateIssue; + const currentTwoWaySync = + (migrated['twoWaySync'] as Record | 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; } @@ -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 = {}; 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 | 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 = { 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 }, diff --git a/src/app/features/issue/issue.service.spec.ts b/src/app/features/issue/issue.service.spec.ts index 14d65005b1..1cfb90c709 100644 --- a/src/app/features/issue/issue.service.spec.ts +++ b/src/app/features/issue/issue.service.spec.ts @@ -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; let globalProgressBarServiceSpy: jasmine.SpyObj; let navigateToTaskServiceSpy: jasmine.SpyObj; + let pluginAdapterSpy: jasmine.SpyObj; + let pluginRegistrySpy: jasmine.SpyObj; const createMockTask = (overrides: Partial = {}): 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; + 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; + 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( diff --git a/src/app/features/issue/issue.service.ts b/src/app/features/issue/issue.service.ts index 525f308d5e..4af1f46f66 100644 --- a/src/app/features/issue/issue.service.ts +++ b/src/app/features/issue/issue.service.ts @@ -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> => { - const providerCfg = await this._issueProviderService - .getCfgOnce$(issueProviderId, issueProviderKey) - .toPromise(); + const getTaskDefaults = (): Partial => { 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) + .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(r); return r; } diff --git a/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts b/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts index 0db633e09e..187f055d68 100644 --- a/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts +++ b/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts @@ -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)', + }); + }); }); diff --git a/src/app/features/issue/two-way-sync/compute-push-decisions.ts b/src/app/features/issue/two-way-sync/compute-push-decisions.ts index 9a41e2e0e3..7e90d5f190 100644 --- a/src/app/features/issue/two-way-sync/compute-push-decisions.ts +++ b/src/app/features/issue/two-way-sync/compute-push-decisions.ts @@ -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, 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', diff --git a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts index 1a8ab03017..29a1be2953 100644 --- a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts +++ b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts @@ -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(); @@ -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; + 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; + 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); diff --git a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts index d0a9e0a493..5e1e0d75c3 100644 --- a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts +++ b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts @@ -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 = new Set([ 'isDone', @@ -33,8 +39,17 @@ const SYNCABLE_TASK_FIELDS: ReadonlySet = 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, }; }, + [TaskSharedActions.addTagToTask.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.taskId, changes: { tagIds: undefined } }; + }, [PlannerActions.planTaskForDay.type]: (action) => { const a = action as ReturnType; 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(); 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 = 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 = 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 | 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 { 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 = {}; - 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 = { ...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); diff --git a/src/app/features/tag/store/tag.actions.ts b/src/app/features/tag/store/tag.actions.ts index 72b97033ae..902d703d4a 100644 --- a/src/app/features/tag/store/tag.actions.ts +++ b/src/app/features/tag/store/tag.actions.ts @@ -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, diff --git a/src/app/features/tag/tag.service.spec.ts b/src/app/features/tag/tag.service.spec.ts index 9f30c31a54..8112f9d4a3 100644 --- a/src/app/features/tag/tag.service.spec.ts +++ b/src/app/features/tag/tag.service.spec.ts @@ -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'], + }), + ); }); }); diff --git a/src/app/features/tag/tag.service.ts b/src/app/features/tag/tag.service.ts index 4dc19dfadb..f60861e4c9 100644 --- a/src/app/features/tag/tag.service.ts +++ b/src/app/features/tag/tag.service.ts @@ -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): 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 []; + } + } } diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts index d52a851b3d..d77940f278 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts @@ -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; let snackSpy: jasmine.SpyObj; let taskServiceSpy: jasmine.SpyObj; + let tagServiceSpy: jasmine.SpyObj; 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', diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts index 1daf9e7015..d2c4ce1ed0 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts @@ -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; 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 { + const fieldMappings = provider.definition.fieldMappings; + if (!fieldMappings?.length) { + return {}; + } + + const data = provider.definition.extractSyncValues + ? provider.definition.extractSyncValues(issue) + : (issue as Record); + const issueRecord = issue as Record; + const result: Record = {}; + + 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, + cfg: IssueProviderPluginType, + issue: PluginIssue, + ): string[] | undefined { + const fieldMappings = provider.definition.fieldMappings; + if (!fieldMappings?.length) { + return undefined; + } + + const twoWaySync = (cfg.pluginConfig?.['twoWaySync'] as Record) ?? {}; + const ctx = { + issueId: issue.id, + issueNumber: this._getIssueNumber(issue), + }; + const isAutoCreateTags = !!(cfg.pluginConfig as Record)?.[ + '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, @@ -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)?.[ + '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; diff --git a/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts b/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts index d19d43ee97..4c9a515824 100644 --- a/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts +++ b/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts @@ -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 => ({ @@ -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']); + }); + }); }); diff --git a/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts b/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts index bce91f7904..9fe9219cec 100644 --- a/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts +++ b/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts @@ -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 | Promise>, ) => PluginHttp, + tagService: TagService, ): IssueSyncAdapter => { - 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): Record => { - if (definition.extractSyncValues) { - return definition.extractSyncValues( - issue as Parameters< - NonNullable - >[0], - ); - } + const data = definition.extractSyncValues + ? definition.extractSyncValues( + issue as Parameters< + NonNullable + >[0], + ) + : issue; + const result: Record = {}; 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; }, diff --git a/src/app/plugins/issue-provider/plugin-tag-utils.ts b/src/app/plugins/issue-provider/plugin-tag-utils.ts new file mode 100644 index 0000000000..77111673bc --- /dev/null +++ b/src/app/plugins/issue-provider/plugin-tag-utils.ts @@ -0,0 +1,2 @@ +export const sortTagLabels = (value: unknown): string[] => + Array.isArray(value) ? (value as string[]).slice().sort() : []; diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index 0e0e6c8b74..10722fd33b 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -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( diff --git a/src/app/t.const.ts b/src/app/t.const.ts index b915e97a5b..a95e5061e0 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index a9a747f388..803f80aaca 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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" },