fix(team-tasks): resolve display task references
This commit is contained in:
parent
cb17bcfdb5
commit
5b1fccdbed
8 changed files with 597 additions and 38 deletions
|
|
@ -677,19 +677,60 @@ export class TeamGraphAdapter {
|
|||
): void {
|
||||
const taskStateById = new Map<
|
||||
string,
|
||||
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
||||
Pick<
|
||||
TeamGraphData['tasks'][number],
|
||||
'id' | 'displayId' | 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||
>
|
||||
>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
const taskIdsByCanonicalReference = new Map<string, Set<string>>();
|
||||
const taskIdsByDisplayReference = new Map<string, Set<string>>();
|
||||
const memberColorByName = new Map<string, string>();
|
||||
const addTaskReference = (
|
||||
references: Map<string, Set<string>>,
|
||||
reference: string | undefined,
|
||||
taskId: string
|
||||
): void => {
|
||||
const normalized = reference?.trim().replace(/^#/, '');
|
||||
if (!normalized) return;
|
||||
const taskIds = references.get(normalized) ?? new Set<string>();
|
||||
taskIds.add(taskId);
|
||||
references.set(normalized, taskIds);
|
||||
};
|
||||
const resolveTaskReference = (reference: string): string | null => {
|
||||
const normalized = reference.trim().replace(/^#/, '');
|
||||
if (!normalized) return null;
|
||||
const canonicalTaskIds = taskIdsByCanonicalReference.get(normalized);
|
||||
if (canonicalTaskIds?.size === 1) {
|
||||
return [...canonicalTaskIds][0]!;
|
||||
}
|
||||
if (canonicalTaskIds && canonicalTaskIds.size > 1) {
|
||||
return null;
|
||||
}
|
||||
const displayTaskIds = taskIdsByDisplayReference.get(normalized);
|
||||
return displayTaskIds?.size === 1 ? [...displayTaskIds][0]! : null;
|
||||
};
|
||||
const formatTaskReference = (reference: string): string => {
|
||||
const taskId = resolveTaskReference(reference);
|
||||
if (taskId) {
|
||||
return taskDisplayIds.get(taskId) ?? `#${taskId.slice(0, 6)}`;
|
||||
}
|
||||
const trimmed = reference.trim();
|
||||
return trimmed.startsWith('#') ? trimmed : `#${trimmed.slice(0, 6)}`;
|
||||
};
|
||||
|
||||
for (const t of data.tasks) {
|
||||
taskStateById.set(t.id, {
|
||||
id: t.id,
|
||||
...(t.displayId ? { displayId: t.displayId } : {}),
|
||||
status: t.status,
|
||||
...(t.reviewState ? { reviewState: t.reviewState } : {}),
|
||||
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
|
||||
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
|
||||
});
|
||||
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||
addTaskReference(taskIdsByCanonicalReference, t.id, t.id);
|
||||
addTaskReference(taskIdsByDisplayReference, t.displayId, t.id);
|
||||
}
|
||||
for (const member of data.members) {
|
||||
if (member.color) {
|
||||
|
|
@ -726,10 +767,10 @@ export class TeamGraphAdapter {
|
|||
: TeamGraphAdapter.#mapReviewState(task.reviewState);
|
||||
|
||||
const blockedByDisplayIds = task.blockedBy?.length
|
||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||
? task.blockedBy.map(formatTaskReference)
|
||||
: undefined;
|
||||
const blocksDisplayIds = task.blocks?.length
|
||||
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||
? task.blocks.map(formatTaskReference)
|
||||
: undefined;
|
||||
|
||||
const totalCommentCount = task.comments?.length ?? 0;
|
||||
|
|
@ -799,7 +840,10 @@ export class TeamGraphAdapter {
|
|||
targetTaskIds: Set<string>;
|
||||
}
|
||||
>();
|
||||
const addBlockingRelation = (blockerId: string, blockedId: string): void => {
|
||||
const addBlockingRelation = (blockerRef: string, blockedRef: string): void => {
|
||||
const blockerId = resolveTaskReference(blockerRef);
|
||||
const blockedId = resolveTaskReference(blockedRef);
|
||||
if (!blockerId || !blockedId) return;
|
||||
if (blockerId === blockedId) return;
|
||||
const rawRelationKey = `${blockerId}->${blockedId}`;
|
||||
if (seenBlockingRelations.has(rawRelationKey)) return;
|
||||
|
|
@ -842,7 +886,9 @@ export class TeamGraphAdapter {
|
|||
|
||||
if (!visibleTaskIds.has(task.id)) continue;
|
||||
|
||||
for (const relatedId of task.related ?? []) {
|
||||
for (const relatedRef of task.related ?? []) {
|
||||
const relatedId = resolveTaskReference(relatedRef);
|
||||
if (!relatedId) continue;
|
||||
if (!visibleTaskIds.has(relatedId)) continue;
|
||||
const key =
|
||||
task.id.localeCompare(relatedId) <= 0
|
||||
|
|
|
|||
|
|
@ -90,11 +90,47 @@ function buildBaseItem(
|
|||
};
|
||||
}
|
||||
|
||||
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): string[] {
|
||||
const keys = [task.id, task.displayId]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
||||
interface TaskReferenceIndex {
|
||||
canonical: Map<string, Set<MemberWorkSyncTaskLike>>;
|
||||
display: Map<string, Set<MemberWorkSyncTaskLike>>;
|
||||
}
|
||||
|
||||
function addTaskReference(
|
||||
index: Map<string, Set<MemberWorkSyncTaskLike>>,
|
||||
reference: string | undefined,
|
||||
task: MemberWorkSyncTaskLike
|
||||
): void {
|
||||
const normalized = reference?.trim().replace(/^#/, '');
|
||||
if (!normalized) return;
|
||||
const matches = index.get(normalized) ?? new Set<MemberWorkSyncTaskLike>();
|
||||
matches.add(task);
|
||||
index.set(normalized, matches);
|
||||
}
|
||||
|
||||
function buildTaskReferenceIndex(tasks: MemberWorkSyncTaskLike[]): TaskReferenceIndex {
|
||||
const index = new Map<string, Set<MemberWorkSyncTaskLike>>();
|
||||
const display = new Map<string, Set<MemberWorkSyncTaskLike>>();
|
||||
for (const task of tasks) {
|
||||
addTaskReference(index, task.id, task);
|
||||
addTaskReference(display, task.displayId, task);
|
||||
}
|
||||
return { canonical: index, display };
|
||||
}
|
||||
|
||||
function findUniqueReferencedTask(
|
||||
tasksByReference: TaskReferenceIndex,
|
||||
reference: string
|
||||
): MemberWorkSyncTaskLike | null {
|
||||
const normalized = reference.trim().replace(/^#/, '');
|
||||
const canonicalMatches = tasksByReference.canonical.get(normalized);
|
||||
if (canonicalMatches?.size === 1) {
|
||||
return [...canonicalMatches][0]!;
|
||||
}
|
||||
if (canonicalMatches && canonicalMatches.size > 1) {
|
||||
return null;
|
||||
}
|
||||
const matches = tasksByReference.display.get(normalized);
|
||||
return matches?.size === 1 ? [...matches][0]! : null;
|
||||
}
|
||||
|
||||
export function buildActionableWorkAgenda(
|
||||
|
|
@ -104,9 +140,7 @@ export function buildActionableWorkAgenda(
|
|||
const diagnostics: string[] = [];
|
||||
const activeMemberNames = getActiveMemberNames(input.members);
|
||||
const activeLeadName = getActiveLeadName(input.members);
|
||||
const tasksByReference = new Map(
|
||||
input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const))
|
||||
);
|
||||
const tasksByReference = buildTaskReferenceIndex(input.tasks);
|
||||
|
||||
if (!memberName || isReservedMemberName(memberName)) {
|
||||
diagnostics.push('member_invalid_or_reserved');
|
||||
|
|
@ -135,7 +169,7 @@ export function buildActionableWorkAgenda(
|
|||
const brokenDependencyIds: string[] = [];
|
||||
const waitingDependencyIds: string[] = [];
|
||||
for (const dependencyId of blockedBy) {
|
||||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
||||
const dependency = findUniqueReferencedTask(tasksByReference, dependencyId);
|
||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||
brokenDependencyIds.push(dependencyId);
|
||||
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
||||
|
|
|
|||
|
|
@ -32,22 +32,65 @@ function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
|||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function taskMatchesId(task: TeamTask, taskId: string): boolean {
|
||||
const normalized = taskId.trim().replace(/^#/, '');
|
||||
interface TaskReferenceIndex {
|
||||
canonical: Map<string, Set<TeamTask>>;
|
||||
display: Map<string, Set<TeamTask>>;
|
||||
}
|
||||
|
||||
function addTaskReference(
|
||||
index: Map<string, Set<TeamTask>>,
|
||||
reference: string | undefined,
|
||||
task: TeamTask
|
||||
): void {
|
||||
const normalized = reference?.trim().replace(/^#/, '');
|
||||
if (!normalized) return;
|
||||
const matches = index.get(normalized) ?? new Set<TeamTask>();
|
||||
matches.add(task);
|
||||
index.set(normalized, matches);
|
||||
}
|
||||
|
||||
function buildTaskReferenceIndex(tasks: TeamTask[]): TaskReferenceIndex {
|
||||
const canonical = new Map<string, Set<TeamTask>>();
|
||||
const display = new Map<string, Set<TeamTask>>();
|
||||
for (const task of tasks) {
|
||||
addTaskReference(canonical, task.id, task);
|
||||
addTaskReference(display, task.displayId, task);
|
||||
}
|
||||
return { canonical, display };
|
||||
}
|
||||
|
||||
function getTaskReferenceMatches(
|
||||
tasksByReference: TaskReferenceIndex,
|
||||
reference: string
|
||||
): ReadonlySet<TeamTask> | null {
|
||||
const normalized = reference.trim().replace(/^#/, '');
|
||||
return (
|
||||
task.id === taskId ||
|
||||
task.id === normalized ||
|
||||
task.displayId === taskId ||
|
||||
task.displayId === normalized ||
|
||||
task.displayId === `#${normalized}`
|
||||
tasksByReference.canonical.get(normalized) ?? tasksByReference.display.get(normalized) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] {
|
||||
const keys = [task.id, task.displayId]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
||||
function taskReferenceIncludesTask(
|
||||
tasksByReference: TaskReferenceIndex,
|
||||
reference: string,
|
||||
task: TeamTask
|
||||
): boolean {
|
||||
return getTaskReferenceMatches(tasksByReference, reference)?.has(task) === true;
|
||||
}
|
||||
|
||||
function taskReferenceIsMissingOrDeleted(
|
||||
tasksByReference: TaskReferenceIndex,
|
||||
reference: string
|
||||
): boolean {
|
||||
const matches = getTaskReferenceMatches(tasksByReference, reference);
|
||||
if (!matches || matches.size === 0) {
|
||||
return true;
|
||||
}
|
||||
return [...matches].every(isDeletedTask);
|
||||
}
|
||||
|
||||
function findTasksByReference(tasksByReference: TaskReferenceIndex, reference: string): TeamTask[] {
|
||||
const matches = getTaskReferenceMatches(tasksByReference, reference);
|
||||
return matches ? [...matches] : [];
|
||||
}
|
||||
|
||||
function normalizedTaskReferences(values: readonly string[] | undefined): string[] {
|
||||
|
|
@ -124,14 +167,23 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
}
|
||||
};
|
||||
|
||||
const task = tasks.find((candidate) => taskMatchesId(candidate, taskId));
|
||||
if (!task) {
|
||||
const tasksByReference = buildTaskReferenceIndex(tasks);
|
||||
const matchingTasks = findTasksByReference(tasksByReference, taskId);
|
||||
if (matchingTasks.length === 0) {
|
||||
return {
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_not_found'],
|
||||
};
|
||||
}
|
||||
if (matchingTasks.length > 1) {
|
||||
return {
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_reference_ambiguous'],
|
||||
};
|
||||
}
|
||||
const task = matchingTasks[0]!;
|
||||
|
||||
addMember(task.owner);
|
||||
|
||||
|
|
@ -178,14 +230,8 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
addLead();
|
||||
}
|
||||
|
||||
const tasksByReference = new Map(
|
||||
tasks.flatMap((candidate) =>
|
||||
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
|
||||
)
|
||||
);
|
||||
const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => {
|
||||
const dependency = tasksByReference.get(dependencyId);
|
||||
return !dependency || isDeletedTask(dependency);
|
||||
return taskReferenceIsMissingOrDeleted(tasksByReference, dependencyId);
|
||||
});
|
||||
if (brokenDependencies.length > 0) {
|
||||
addLead();
|
||||
|
|
@ -204,8 +250,8 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
continue;
|
||||
}
|
||||
if (
|
||||
normalizedTaskReferences(candidate.blockedBy).some(
|
||||
(dependencyId) => tasksByReference.get(dependencyId) === task
|
||||
normalizedTaskReferences(candidate.blockedBy).some((dependencyId) =>
|
||||
taskReferenceIncludesTask(tasksByReference, dependencyId, task)
|
||||
)
|
||||
) {
|
||||
addMember(candidate.owner);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export interface TeamTaskStateLike {
|
||||
id?: string | null;
|
||||
displayId?: string | null;
|
||||
status: string;
|
||||
reviewState?: string | null;
|
||||
kanbanColumn?: string | null;
|
||||
|
|
@ -126,6 +128,55 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea
|
|||
return getCachedTeamTaskState(task).finishedForDependency;
|
||||
}
|
||||
|
||||
function normalizeTaskReference(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().replace(/^#/, '') : '';
|
||||
}
|
||||
|
||||
function findTaskStateByReference(
|
||||
taskStateById: ReadonlyMap<string, TeamTaskStateLike>,
|
||||
taskId: string
|
||||
): TeamTaskStateLike | null {
|
||||
const normalized = normalizeTaskReference(taskId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const direct =
|
||||
taskStateById.get(taskId) ??
|
||||
taskStateById.get(normalized) ??
|
||||
taskStateById.get(`#${normalized}`);
|
||||
if (direct && (!direct.id || normalizeTaskReference(direct.id) === normalized)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
let idMatched: TeamTaskStateLike | null = null;
|
||||
for (const task of taskStateById.values()) {
|
||||
if (normalizeTaskReference(task.id) === normalized) {
|
||||
if (idMatched && idMatched !== task) {
|
||||
return null;
|
||||
}
|
||||
idMatched = task;
|
||||
}
|
||||
}
|
||||
if (idMatched) {
|
||||
return idMatched;
|
||||
}
|
||||
|
||||
let displayMatched: TeamTaskStateLike | null = null;
|
||||
for (const [key, task] of taskStateById) {
|
||||
if (
|
||||
normalizeTaskReference(key) === normalized ||
|
||||
normalizeTaskReference(task.displayId) === normalized
|
||||
) {
|
||||
if (displayMatched && displayMatched !== task) {
|
||||
return null;
|
||||
}
|
||||
displayMatched = task;
|
||||
}
|
||||
}
|
||||
return displayMatched;
|
||||
}
|
||||
|
||||
export function isTeamTaskBlockedByUnfinishedDependency(
|
||||
task: TeamTaskBlockerLike,
|
||||
taskStateById: ReadonlyMap<string, TeamTaskStateLike>
|
||||
|
|
@ -137,7 +188,7 @@ export function isTeamTaskBlockedByUnfinishedDependency(
|
|||
}
|
||||
|
||||
return blockedBy.some((taskId) => {
|
||||
const blocker = taskStateById.get(taskId);
|
||||
const blocker = findTaskStateByReference(taskStateById, taskId);
|
||||
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -709,6 +709,78 @@ describe('buildActionableWorkAgenda', () => {
|
|||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not unblock owner work when a display-id dependency is ambiguous', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-active-dep',
|
||||
displayId: '#33333333',
|
||||
subject: 'Active dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-completed-dep',
|
||||
displayId: '#33333333',
|
||||
subject: 'Duplicate completed dependency',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-dependent',
|
||||
subject: 'Ambiguous dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
blockedBy: ['33333333'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers canonical task ids over colliding display ids for dependencies', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-dep',
|
||||
displayId: '#33333333',
|
||||
subject: 'Completed dependency',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-collision',
|
||||
displayId: '#task-dep',
|
||||
subject: 'Display collision',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-dependent',
|
||||
subject: 'Canonical dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
blockedBy: ['task-dep'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
|
||||
['task-dependent', 'owned_in_progress_task'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects lead-owned oversight for lead clarification and broken dependencies', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
|
|
|
|||
|
|
@ -129,6 +129,116 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('targets dependent owners when a display-id dependency is ambiguous but includes the changed task', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#11111111',
|
||||
subject: 'Changed dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-duplicate',
|
||||
displayId: '#11111111',
|
||||
subject: 'Duplicate display id',
|
||||
status: 'completed',
|
||||
owner: 'zoe',
|
||||
},
|
||||
{
|
||||
id: 'task-b',
|
||||
subject: 'Depends on ambiguous display id',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: [' 11111111 '],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom', 'zoe']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({
|
||||
memberNames: ['alice', 'tom'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back team-wide when the changed task reference is ambiguous', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#11111111',
|
||||
subject: 'First duplicate',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-b',
|
||||
displayId: '#11111111',
|
||||
subject: 'Second duplicate',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: '#11111111' })).resolves.toEqual({
|
||||
memberNames: [],
|
||||
fallbackTeamWide: true,
|
||||
diagnostics: ['task_reference_ambiguous'],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers canonical task ids over colliding display ids', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#11111111',
|
||||
subject: 'Changed dependency',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'task-collision',
|
||||
displayId: '#task-a',
|
||||
subject: 'Display collision',
|
||||
status: 'in_progress',
|
||||
owner: 'zoe',
|
||||
},
|
||||
{
|
||||
id: 'task-b',
|
||||
subject: 'Depends on canonical id',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: ['task-a'],
|
||||
},
|
||||
];
|
||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||
taskReader: { getTasks: vi.fn(async () => tasks) },
|
||||
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
|
||||
activeMemberSource: {
|
||||
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom', 'zoe']),
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({
|
||||
memberNames: ['alice', 'tom'],
|
||||
fallbackTeamWide: false,
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not target owners of already approved dependent tasks', async () => {
|
||||
const tasks: TeamTask[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -142,6 +142,121 @@ describe('isTeamTaskBlockedByUnfinishedDependency', () => {
|
|||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves blocker references by display id and #display id', () => {
|
||||
const taskStateById = new Map([
|
||||
[
|
||||
'task-completed',
|
||||
{
|
||||
id: 'task-completed',
|
||||
displayId: 'abc12345',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
[
|
||||
'task-approved',
|
||||
{
|
||||
id: 'task-approved',
|
||||
displayId: 'def67890',
|
||||
status: 'in_progress',
|
||||
kanbanColumn: 'approved',
|
||||
},
|
||||
],
|
||||
[
|
||||
'task-active',
|
||||
{
|
||||
id: 'task-active',
|
||||
displayId: 'fedcba98',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
isTeamTaskBlockedByUnfinishedDependency(
|
||||
{ blockedBy: ['abc12345', '#def67890'] },
|
||||
taskStateById
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['#fedcba98'] }, taskStateById)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for ambiguous display id blocker references', () => {
|
||||
const taskStateById = new Map([
|
||||
[
|
||||
'task-completed',
|
||||
{
|
||||
id: 'task-completed',
|
||||
displayId: 'abc12345',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
[
|
||||
'task-active',
|
||||
{
|
||||
id: 'task-active',
|
||||
displayId: 'abc12345',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['#abc12345'] }, taskStateById)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when a direct map key match is an ambiguous display id', () => {
|
||||
const taskStateById = new Map([
|
||||
[
|
||||
'abc12345',
|
||||
{
|
||||
id: 'task-completed',
|
||||
displayId: 'abc12345',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
[
|
||||
'task-active',
|
||||
{
|
||||
id: 'task-active',
|
||||
displayId: 'abc12345',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['abc12345'] }, taskStateById)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers canonical id matches over colliding display ids', () => {
|
||||
const taskStateById = new Map([
|
||||
[
|
||||
'task-completed',
|
||||
{
|
||||
id: 'task-completed',
|
||||
displayId: 'abc12345',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
[
|
||||
'task-active',
|
||||
{
|
||||
id: 'task-active',
|
||||
displayId: 'task-completed',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(
|
||||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['task-completed'] }, taskStateById)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamTaskWorkflowColumn', () => {
|
||||
|
|
|
|||
|
|
@ -2112,6 +2112,91 @@ describe('TeamGraphAdapter particles', () => {
|
|||
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves blocking edges and labels from display id references', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData({
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#A1',
|
||||
subject: 'Blocker',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
{
|
||||
id: 'task-b',
|
||||
displayId: '#B1',
|
||||
subject: 'Blocked task',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
blockedBy: ['#A1'],
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
],
|
||||
}),
|
||||
'my-team'
|
||||
);
|
||||
|
||||
expect(findNode(graph, 'task:my-team:task-b')).toMatchObject({
|
||||
isBlocked: true,
|
||||
blockedByDisplayIds: ['#A1'],
|
||||
});
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'task:my-team:task-a',
|
||||
target: 'task:my-team:task-b',
|
||||
type: 'blocking',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers canonical task ids over colliding display id references', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData({
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: '#A1',
|
||||
subject: 'Canonical blocker',
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
{
|
||||
id: 'task-collision',
|
||||
displayId: '#task-a',
|
||||
subject: 'Display id collision',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
{
|
||||
id: 'task-b',
|
||||
displayId: '#B1',
|
||||
subject: 'Blocked task',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
blockedBy: ['task-a'],
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
],
|
||||
}),
|
||||
'my-team'
|
||||
);
|
||||
|
||||
expect(findNode(graph, 'task:my-team:task-b')?.isBlocked).toBe(false);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'task:my-team:task-a',
|
||||
target: 'task:my-team:task-b',
|
||||
type: 'blocking',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(
|
||||
|
|
|
|||
Loading…
Reference in a new issue