All files / src/entities/workitems reorder.ts

100% Statements 62/62
100% Branches 36/36
100% Functions 8/8
100% Lines 48/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 15825x   25x 25x 25x                                                                   100x 75x   100x         75x             25x 25x 25x             25x                                       25x                                 40x 38x         38x       20x 2x       16x 16x 4x   4x 104x 104x 103x 103x 102x   2x       25x 21x 21x 21x 21x   21x 21x 21x 20x 19x 19x 16x 16x             13x 13x 13x 13x 1x 12x 10x    
import { gql } from 'graphql-tag';
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { ConnectionManager } from '../../services/ConnectionManager';
import { getGitLabApiUrlFromContext } from '../../oauth/token-context';
import {
  cleanWorkItemResponse,
  extractSimpleId,
  toGid,
  type GitLabWorkItem,
} from '../../utils/idConversion';
import type { GraphQLClient } from '../../graphql/client';
import type { ReorderWorkItemInput } from './schema';
 
interface Boards {
  nodes: Array<{ id: string } | null>;
}
interface Project {
  id: string;
  fullPath: string;
  boards?: Boards | null;
  group: { fullPath: string } | null;
}
interface Item {
  id: string;
  iid: string;
  project: Project | null;
}
interface Context {
  target: Item | null;
  before?: Item | null;
  after?: Item | null;
}
type Variables = { id: string; beforeId?: string; afterId?: string };
 
// Only constant aliases enter these documents. Compile each bounded shape once, not per call.
function contextQuery(
  neighbours: readonly ('before' | 'after')[],
): TypedDocumentNode<Context, Variables> {
  const definitions = neighbours.map((name) => `$${name}Id: WorkItemID!`).join(', ');
  const fields = neighbours
    .map(
      (name) => `${name}: workItem(id: $${name}Id) {
    id iid project { id fullPath group { fullPath } }
  }`,
    )
    .join('\n');
  return gql`query ReorderWorkItemContext($id: WorkItemID!, ${definitions}) {
    target: workItem(id: $id) {
      id iid project { id fullPath boards(first: 1) { nodes { id } } group { fullPath } }
    }
    ${fields}
  }`;
}
const BEFORE = contextQuery(['before']);
const AFTER = contextQuery(['after']);
const BETWEEN = contextQuery(['before', 'after']);
 
const GROUP_BOARD: TypedDocumentNode<
  {
    group: { boards: Boards | null; parent: { fullPath: string } | null } | null;
  },
  { path: string }
> = gql`
  query ReorderGroupBoard($path: ID!) {
    group(fullPath: $path) {
      boards(first: 1) {
        nodes {
          id
        }
      }
      parent {
        fullPath
      }
    }
  }
`;
 
const MOVE: TypedDocumentNode<
  {
    issueMoveList: { issue: GitLabWorkItem | null; errors: string[] } | null;
  },
  { input: Record<string, string> }
> = gql`
  mutation ReorderIssueCard($input: IssueMoveListInput!) {
    issueMoveList(input: $input) {
      issue {
        id
        iid
        title
        state
        webUrl
        relativePosition
      }
      errors
    }
  }
`;
 
function projectOf(item: Item | null | undefined): Project {
  if (!item?.project) throw new Error('Issue is unavailable or is not a project work item');
  return item.project;
}
 
/** GitLab 16.0 Issue.relative_positioning_parent_projects: root group, or one personal project. */
function positioningScope(project: Project): string {
  return project.group ? project.fullPath.slice(0, project.fullPath.indexOf('/')) : project.id;
}
 
function checkNeighbour(item: Item | null | undefined, scope: string): void {
  if (positioningScope(projectOf(item)) !== scope)
    throw new Error('Neighbour is outside the issue manual-ordering namespace');
}
 
async function findBoard(client: GraphQLClient, project: Project): Promise<string> {
  const own = project.boards?.nodes[0];
  if (own) return own.id;
  let path = project.group?.fullPath;
  // Bound traversals even if an inconsistent upstream response contains a parent cycle.
  for (let requests = 0; path !== undefined && requests < 100; requests++) {
    const { group } = await client.request(GROUP_BOARD, { path });
    if (!group) throw new Error('Board discovery could not read the issue namespace');
    const board = group.boards?.nodes[0];
    if (board) return board.id;
    path = group.parent?.fullPath;
  }
  throw new Error('No accessible issue board found within the board discovery limit');
}
 
/** Reordering is issue-relative, shared by boards; an accessible related board supplies context. */
export async function reorderWorkItem(input: ReorderWorkItemInput): Promise<GitLabWorkItem> {
  const client = ConnectionManager.getInstance().getClient(getGitLabApiUrlFromContext());
  const variables: Variables = { id: toGid(input.id, 'WorkItem') };
  if (input.beforeId !== undefined) variables.beforeId = toGid(input.beforeId, 'WorkItem');
  if (input.afterId !== undefined) variables.afterId = toGid(input.afterId, 'WorkItem');
  const document =
    input.beforeId === undefined ? AFTER : input.afterId === undefined ? BEFORE : BETWEEN;
  const context = await client.request(document, variables);
  if (!context.target) throw new Error('Issue is unavailable');
  const project = projectOf(context.target);
  const scope = positioningScope(project);
  if (input.beforeId !== undefined) checkNeighbour(context.before, scope);
  if (input.afterId !== undefined) checkNeighbour(context.after, scope);
  const mutation: Record<string, string> = {
    boardId: await findBoard(client, project),
    projectPath: project.fullPath,
    iid: context.target.iid,
  };
  // GitLab's moveAfterId names the issue AFTER the moved card, not the card to move after.
  // v16.0.0-ee/app/services/boards/base_item_move_service.rb:105-110.
  if (input.beforeId !== undefined) mutation.moveAfterId = extractSimpleId(input.beforeId);
  if (input.afterId !== undefined) mutation.moveBeforeId = extractSimpleId(input.afterId);
  const response = await client.request(MOVE, { input: mutation });
  if (response.issueMoveList?.errors.length)
    throw new Error(`GitLab GraphQL errors: ${response.issueMoveList.errors.join(', ')}`);
  if (!response.issueMoveList?.issue) throw new Error('Issue reorder failed: no issue returned');
  return cleanWorkItemResponse(response.issueMoveList.issue);
}