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 {
|
): void {
|
||||||
const taskStateById = new Map<
|
const taskStateById = new Map<
|
||||||
string,
|
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 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 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) {
|
for (const t of data.tasks) {
|
||||||
taskStateById.set(t.id, {
|
taskStateById.set(t.id, {
|
||||||
|
id: t.id,
|
||||||
|
...(t.displayId ? { displayId: t.displayId } : {}),
|
||||||
status: t.status,
|
status: t.status,
|
||||||
...(t.reviewState ? { reviewState: t.reviewState } : {}),
|
...(t.reviewState ? { reviewState: t.reviewState } : {}),
|
||||||
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
|
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
|
||||||
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
|
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
|
||||||
});
|
});
|
||||||
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
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) {
|
for (const member of data.members) {
|
||||||
if (member.color) {
|
if (member.color) {
|
||||||
|
|
@ -726,10 +767,10 @@ export class TeamGraphAdapter {
|
||||||
: TeamGraphAdapter.#mapReviewState(task.reviewState);
|
: TeamGraphAdapter.#mapReviewState(task.reviewState);
|
||||||
|
|
||||||
const blockedByDisplayIds = task.blockedBy?.length
|
const blockedByDisplayIds = task.blockedBy?.length
|
||||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blockedBy.map(formatTaskReference)
|
||||||
: undefined;
|
: undefined;
|
||||||
const blocksDisplayIds = task.blocks?.length
|
const blocksDisplayIds = task.blocks?.length
|
||||||
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blocks.map(formatTaskReference)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const totalCommentCount = task.comments?.length ?? 0;
|
const totalCommentCount = task.comments?.length ?? 0;
|
||||||
|
|
@ -799,7 +840,10 @@ export class TeamGraphAdapter {
|
||||||
targetTaskIds: Set<string>;
|
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;
|
if (blockerId === blockedId) return;
|
||||||
const rawRelationKey = `${blockerId}->${blockedId}`;
|
const rawRelationKey = `${blockerId}->${blockedId}`;
|
||||||
if (seenBlockingRelations.has(rawRelationKey)) return;
|
if (seenBlockingRelations.has(rawRelationKey)) return;
|
||||||
|
|
@ -842,7 +886,9 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
if (!visibleTaskIds.has(task.id)) continue;
|
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;
|
if (!visibleTaskIds.has(relatedId)) continue;
|
||||||
const key =
|
const key =
|
||||||
task.id.localeCompare(relatedId) <= 0
|
task.id.localeCompare(relatedId) <= 0
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,47 @@ function buildBaseItem(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): string[] {
|
interface TaskReferenceIndex {
|
||||||
const keys = [task.id, task.displayId]
|
canonical: Map<string, Set<MemberWorkSyncTaskLike>>;
|
||||||
.map((value) => value?.trim())
|
display: Map<string, Set<MemberWorkSyncTaskLike>>;
|
||||||
.filter((value): value is string => Boolean(value));
|
}
|
||||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
|
||||||
|
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(
|
export function buildActionableWorkAgenda(
|
||||||
|
|
@ -104,9 +140,7 @@ export function buildActionableWorkAgenda(
|
||||||
const diagnostics: string[] = [];
|
const diagnostics: string[] = [];
|
||||||
const activeMemberNames = getActiveMemberNames(input.members);
|
const activeMemberNames = getActiveMemberNames(input.members);
|
||||||
const activeLeadName = getActiveLeadName(input.members);
|
const activeLeadName = getActiveLeadName(input.members);
|
||||||
const tasksByReference = new Map(
|
const tasksByReference = buildTaskReferenceIndex(input.tasks);
|
||||||
input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!memberName || isReservedMemberName(memberName)) {
|
if (!memberName || isReservedMemberName(memberName)) {
|
||||||
diagnostics.push('member_invalid_or_reserved');
|
diagnostics.push('member_invalid_or_reserved');
|
||||||
|
|
@ -135,7 +169,7 @@ export function buildActionableWorkAgenda(
|
||||||
const brokenDependencyIds: string[] = [];
|
const brokenDependencyIds: string[] = [];
|
||||||
const waitingDependencyIds: string[] = [];
|
const waitingDependencyIds: string[] = [];
|
||||||
for (const dependencyId of blockedBy) {
|
for (const dependencyId of blockedBy) {
|
||||||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
const dependency = findUniqueReferencedTask(tasksByReference, dependencyId);
|
||||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||||
brokenDependencyIds.push(dependencyId);
|
brokenDependencyIds.push(dependencyId);
|
||||||
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
||||||
|
|
|
||||||
|
|
@ -32,22 +32,65 @@ function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskMatchesId(task: TeamTask, taskId: string): boolean {
|
interface TaskReferenceIndex {
|
||||||
const normalized = taskId.trim().replace(/^#/, '');
|
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 (
|
return (
|
||||||
task.id === taskId ||
|
tasksByReference.canonical.get(normalized) ?? tasksByReference.display.get(normalized) ?? null
|
||||||
task.id === normalized ||
|
|
||||||
task.displayId === taskId ||
|
|
||||||
task.displayId === normalized ||
|
|
||||||
task.displayId === `#${normalized}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] {
|
function taskReferenceIncludesTask(
|
||||||
const keys = [task.id, task.displayId]
|
tasksByReference: TaskReferenceIndex,
|
||||||
.map((value) => value?.trim())
|
reference: string,
|
||||||
.filter((value): value is string => Boolean(value));
|
task: TeamTask
|
||||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
): 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[] {
|
function normalizedTaskReferences(values: readonly string[] | undefined): string[] {
|
||||||
|
|
@ -124,14 +167,23 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const task = tasks.find((candidate) => taskMatchesId(candidate, taskId));
|
const tasksByReference = buildTaskReferenceIndex(tasks);
|
||||||
if (!task) {
|
const matchingTasks = findTasksByReference(tasksByReference, taskId);
|
||||||
|
if (matchingTasks.length === 0) {
|
||||||
return {
|
return {
|
||||||
memberNames: [],
|
memberNames: [],
|
||||||
fallbackTeamWide: true,
|
fallbackTeamWide: true,
|
||||||
diagnostics: ['task_not_found'],
|
diagnostics: ['task_not_found'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (matchingTasks.length > 1) {
|
||||||
|
return {
|
||||||
|
memberNames: [],
|
||||||
|
fallbackTeamWide: true,
|
||||||
|
diagnostics: ['task_reference_ambiguous'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const task = matchingTasks[0]!;
|
||||||
|
|
||||||
addMember(task.owner);
|
addMember(task.owner);
|
||||||
|
|
||||||
|
|
@ -178,14 +230,8 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
addLead();
|
addLead();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasksByReference = new Map(
|
|
||||||
tasks.flatMap((candidate) =>
|
|
||||||
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => {
|
const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => {
|
||||||
const dependency = tasksByReference.get(dependencyId);
|
return taskReferenceIsMissingOrDeleted(tasksByReference, dependencyId);
|
||||||
return !dependency || isDeletedTask(dependency);
|
|
||||||
});
|
});
|
||||||
if (brokenDependencies.length > 0) {
|
if (brokenDependencies.length > 0) {
|
||||||
addLead();
|
addLead();
|
||||||
|
|
@ -204,8 +250,8 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
normalizedTaskReferences(candidate.blockedBy).some(
|
normalizedTaskReferences(candidate.blockedBy).some((dependencyId) =>
|
||||||
(dependencyId) => tasksByReference.get(dependencyId) === task
|
taskReferenceIncludesTask(tasksByReference, dependencyId, task)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
addMember(candidate.owner);
|
addMember(candidate.owner);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export interface TeamTaskStateLike {
|
export interface TeamTaskStateLike {
|
||||||
|
id?: string | null;
|
||||||
|
displayId?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
reviewState?: string | null;
|
reviewState?: string | null;
|
||||||
kanbanColumn?: string | null;
|
kanbanColumn?: string | null;
|
||||||
|
|
@ -126,6 +128,55 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea
|
||||||
return getCachedTeamTaskState(task).finishedForDependency;
|
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(
|
export function isTeamTaskBlockedByUnfinishedDependency(
|
||||||
task: TeamTaskBlockerLike,
|
task: TeamTaskBlockerLike,
|
||||||
taskStateById: ReadonlyMap<string, TeamTaskStateLike>
|
taskStateById: ReadonlyMap<string, TeamTaskStateLike>
|
||||||
|
|
@ -137,7 +188,7 @@ export function isTeamTaskBlockedByUnfinishedDependency(
|
||||||
}
|
}
|
||||||
|
|
||||||
return blockedBy.some((taskId) => {
|
return blockedBy.some((taskId) => {
|
||||||
const blocker = taskStateById.get(taskId);
|
const blocker = findTaskStateByReference(taskStateById, taskId);
|
||||||
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -709,6 +709,78 @@ describe('buildActionableWorkAgenda', () => {
|
||||||
expect(agenda.items).toEqual([]);
|
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', () => {
|
it('projects lead-owned oversight for lead clarification and broken dependencies', () => {
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
teamName: 'team-a',
|
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 () => {
|
it('does not target owners of already approved dependent tasks', async () => {
|
||||||
const tasks: TeamTask[] = [
|
const tasks: TeamTask[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,121 @@ describe('isTeamTaskBlockedByUnfinishedDependency', () => {
|
||||||
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById)
|
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById)
|
||||||
).toBe(true);
|
).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', () => {
|
describe('getTeamTaskWorkflowColumn', () => {
|
||||||
|
|
|
||||||
|
|
@ -2112,6 +2112,91 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false);
|
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', () => {
|
it('aggregates blocking edges through overflow stacks so hidden blockers stay visible', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
const graph = adapter.adapt(
|
const graph = adapter.adapt(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue