From cb53bb97ff52e36a7d305c1292e2aaf80f828c15 Mon Sep 17 00:00:00 2001 From: felix bear Date: Mon, 8 Jun 2026 19:07:03 +0900 Subject: [PATCH] fix(jira): skip transition for target status #5940 (#8102) Co-authored-by: cocojojo5213 --- .../providers/jira/jira-issue.effects.spec.ts | 211 ++++++++++++++++++ .../providers/jira/jira-issue.effects.ts | 7 +- 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/app/features/issue/providers/jira/jira-issue.effects.spec.ts diff --git a/src/app/features/issue/providers/jira/jira-issue.effects.spec.ts b/src/app/features/issue/providers/jira/jira-issue.effects.spec.ts new file mode 100644 index 0000000000..934892827d --- /dev/null +++ b/src/app/features/issue/providers/jira/jira-issue.effects.spec.ts @@ -0,0 +1,211 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { of, Subject } from 'rxjs'; +import { SnackService } from '../../../../core/snack/snack.service'; +import { TaskSharedActions } from '../../../../root-store/meta/task-shared.actions'; +import { LOCAL_ACTIONS } from '../../../../util/local-actions.token'; +import { IssueLocalState, IssueProviderJira } from '../../issue.model'; +import { IssueProviderService } from '../../issue-provider.service'; +import { IssueService } from '../../issue.service'; +import { JIRA_TYPE } from '../../issue.const'; +import { JiraOriginalStatus, JiraOriginalTransition } from './jira-api-responses'; +import { JiraApiService } from './jira-api.service'; +import { DEFAULT_JIRA_CFG } from './jira.const'; +import { JiraIssueEffects } from './jira-issue.effects'; +import { JiraIssueReduced } from './jira-issue.model'; +import { DEFAULT_TASK, Task } from '../../../tasks/task.model'; +import { TaskService } from '../../../tasks/task.service'; + +describe('JiraIssueEffects', () => { + let actions$: Subject; + let jiraApiService: jasmine.SpyObj; + let issueProviderService: jasmine.SpyObj; + let issueService: jasmine.SpyObj; + let snackService: jasmine.SpyObj; + let taskService: jasmine.SpyObj; + let jiraCfg: IssueProviderJira; + let task: Task; + + const createStatus = (id: string, name: string): JiraOriginalStatus => ({ + self: `https://jira.example.com/status/${id}`, + id, + name, + description: '', + iconUrl: '', + statusCategory: { + self: `https://jira.example.com/statuscategory/${id}`, + id, + key: name.toLowerCase().replace(/\s+/g, '-'), + colorName: 'blue-gray', + name, + }, + }); + + const createTransition = ( + id: string, + name: string, + targetStatus: JiraOriginalStatus, + ): JiraOriginalTransition => ({ + id, + name, + to: { + ...targetStatus, + statusCategory: { + ...targetStatus.statusCategory, + id: Number(targetStatus.id), + }, + }, + hasScreen: false, + isGlobal: false, + isInitial: false, + isConditional: false, + fields: {}, + }); + + const createIssue = (status: JiraOriginalStatus): JiraIssueReduced => ({ + key: 'SP-1', + id: 'issue-1', + summary: 'Jira issue', + components: [], + timeestimate: 0, + timespent: 0, + description: null, + updated: '2026-06-08T00:00:00.000+0000', + status, + attachments: [], + assignee: null, + comments: [], + }); + + const emitDoneUpdate = (): void => { + actions$.next( + TaskSharedActions.updateTask({ + task: { id: task.id, changes: { isDone: true } }, + }), + ); + }; + + const setupTransition = ( + transition: JiraOriginalTransition, + issue: JiraIssueReduced, + ): void => { + jiraCfg = { + ...jiraCfg, + transitionConfig: { + ...jiraCfg.transitionConfig, + [IssueLocalState.DONE]: transition, + }, + }; + + issueProviderService.getCfgOnce$.and.returnValue(of(jiraCfg)); + jiraApiService.getReducedIssueById$.and.returnValue(of(issue)); + jiraApiService.transitionIssue$.and.returnValue(of({})); + issueService.refreshIssueTask.and.returnValue(Promise.resolve()); + }; + + beforeEach(() => { + actions$ = new Subject(); + jiraApiService = jasmine.createSpyObj('JiraApiService', [ + 'getCurrentUser$', + 'getReducedIssueById$', + 'transitionIssue$', + 'updateAssignee$', + ]); + issueProviderService = jasmine.createSpyObj( + 'IssueProviderService', + ['getCfgOnce$'], + ); + issueService = jasmine.createSpyObj('IssueService', [ + 'refreshIssueTask', + ]); + snackService = jasmine.createSpyObj('SnackService', ['open']); + taskService = jasmine.createSpyObj('TaskService', ['getByIdOnce$']); + + task = { + ...DEFAULT_TASK, + id: 'task-1', + projectId: 'project-1', + issueType: JIRA_TYPE, + issueId: 'issue-1', + issueProviderId: 'jira-provider-1', + }; + jiraCfg = { + ...DEFAULT_JIRA_CFG, + id: 'jira-provider-1', + issueProviderKey: 'JIRA', + isEnabled: true, + isTransitionIssuesEnabled: true, + }; + + taskService.getByIdOnce$.and.returnValue(of(task)); + + TestBed.configureTestingModule({ + providers: [ + JiraIssueEffects, + provideMockActions(() => actions$), + provideMockStore(), + { provide: JiraApiService, useValue: jiraApiService }, + { provide: IssueProviderService, useValue: issueProviderService }, + { provide: IssueService, useValue: issueService }, + { provide: SnackService, useValue: snackService }, + { provide: TaskService, useValue: taskService }, + { + provide: MatDialog, + useValue: jasmine.createSpyObj('MatDialog', ['open']), + }, + { provide: LOCAL_ACTIONS, useValue: actions$ }, + ], + }); + }); + + it('does not transition when the issue is already in the transition target status', fakeAsync(() => { + const targetStatus = createStatus('3', 'Work in Progress'); + const transition = createTransition('11', 'Start progress', targetStatus); + setupTransition(transition, createIssue(targetStatus)); + + TestBed.inject(JiraIssueEffects).checkForDoneTransition$.subscribe(); + emitDoneUpdate(); + tick(); + + expect(jiraApiService.getReducedIssueById$).toHaveBeenCalledWith('issue-1', jiraCfg); + expect(jiraApiService.transitionIssue$).not.toHaveBeenCalled(); + expect(issueService.refreshIssueTask).not.toHaveBeenCalled(); + expect(snackService.open).not.toHaveBeenCalled(); + })); + + it('matches the target status by id when the status name differs', fakeAsync(() => { + const targetStatus = createStatus('3', 'Done'); + const localizedStatus = createStatus('3', 'Erledigt'); + const transition = createTransition('21', 'Resolve Issue', targetStatus); + setupTransition(transition, createIssue(localizedStatus)); + + TestBed.inject(JiraIssueEffects).checkForDoneTransition$.subscribe(); + emitDoneUpdate(); + tick(); + + expect(jiraApiService.transitionIssue$).not.toHaveBeenCalled(); + expect(issueService.refreshIssueTask).not.toHaveBeenCalled(); + expect(snackService.open).not.toHaveBeenCalled(); + })); + + it('transitions and refreshes the task when the issue is not in the target status', fakeAsync(() => { + const targetStatus = createStatus('3', 'Done'); + const transition = createTransition('21', 'Resolve Issue', targetStatus); + setupTransition(transition, createIssue(createStatus('1', 'To Do'))); + + TestBed.inject(JiraIssueEffects).checkForDoneTransition$.subscribe(); + emitDoneUpdate(); + tick(); + + expect(jiraApiService.transitionIssue$).toHaveBeenCalledWith( + 'issue-1', + '21', + jiraCfg, + ); + expect(snackService.open).toHaveBeenCalled(); + expect(issueService.refreshIssueTask).toHaveBeenCalledWith(task, false, false); + })); +}); diff --git a/src/app/features/issue/providers/jira/jira-issue.effects.ts b/src/app/features/issue/providers/jira/jira-issue.effects.ts index 2d07a928fe..3190bcca9e 100644 --- a/src/app/features/issue/providers/jira/jira-issue.effects.ts +++ b/src/app/features/issue/providers/jira/jira-issue.effects.ts @@ -316,7 +316,12 @@ export class JiraIssueEffects { return this._jiraApiService.getReducedIssueById$(task.issueId, jiraCfg).pipe( concatMap((issue) => { - if (!issue.status || issue.status.name !== chosenTransition.name) { + const isAlreadyInTargetStatus = + issue.status && + (issue.status.id === chosenTransition.to.id || + issue.status.name === chosenTransition.to.name); + + if (!isAlreadyInTargetStatus) { return this._jiraApiService .transitionIssue$(issue.id, chosenTransition.id, jiraCfg) .pipe(