All files / src/configuration scope-projects.ts

100% Statements 38/38
95% Branches 19/20
100% Functions 11/11
100% Lines 30/30

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          21x           21x                                                       21x 1132x 3x 3x 2x                       21x   21x       53x 53x 3x 3x   53x               98x 72x 83x             21x         1130x 1129x 1129x 1127x 1127x 53x 68x   53x     98x       21x 76x   287x        
/**
 * Projects a scope names by path, read one by one where GitLab offers no listing of them:
 * project listings and project searches answered from the scope's own projects.
 */
 
import { parseGitLabApiError } from '../utils/error-handler';
import type { RunTool } from './scoped-execution';
 
type Args = Record<string, unknown>;
 
/** GitLab's simple project representation (BasicProjectDetails), returned unless simple=false. */
const SIMPLE_PROJECT_FIELDS: ReadonlySet<string> = new Set([
  'id',
  'description',
  'name',
  'name_with_namespace',
  'path',
  'path_with_namespace',
  'created_at',
  'default_branch',
  'tag_list',
  'topics',
  'ssh_url_to_repo',
  'http_url_to_repo',
  'web_url',
  'readme_url',
  'forks_count',
  'license_url',
  'license',
  'avatar_url',
  'star_count',
  'last_activity_at',
  'visibility',
  'namespace',
  'custom_attributes',
  'repository_storage',
]);
 
/** One project of the scope, or undefined when GitLab has no such project (404). */
export function readScopeProject(run: RunTool, project_id: string): Promise<unknown> {
  return run('browse_projects', { action: 'get', project_id }).catch((error: unknown) => {
    const message = error instanceof Error ? error.message : String(error);
    if (parseGitLabApiError(message)?.status === 404) return undefined;
    throw error;
  });
}
 
/**
 * How GitLab matches the text: a listing (`ProjectsFinder` search) against the project's own
 * path and name, ignoring terms under three characters; a search of projects
 * (`Gitlab::SearchResults#projects`) against its full path and full name, with every term.
 */
export type ProjectTextMatch = 'listing' | 'search';
 
/** Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING */
const MIN_PARTIAL_TERM = 3;
/** Gitlab::SQL::Pattern::REGEX_QUOTED_TERM */
const QUOTED_TERM = /(?<=^| )"[^"]+"(?= |$)/g;
 
/** Gitlab::SQL::Pattern.split_query_to_search_terms: words, then quoted phrases. */
function searchTerms(query: string): string[] {
  const quoted: string[] = [];
  const rest = query.replace(QUOTED_TERM, (term) => {
    quoted.push(term.slice(1, -1));
    return '';
  });
  return [...rest.split(' ').filter(Boolean), ...quoted];
}
 
/**
 * Gitlab::SQL::Pattern#fuzzy_arel_match on one field: every kept term appears in it, case
 * aside; with no term kept, the field equals the whole query.
 */
function matchesField(value: unknown, query: string, terms: string[]): boolean {
  if (typeof value !== 'string') return false;
  const field = value.toLowerCase();
  return terms.length > 0 ? terms.every((term) => field.includes(term)) : field === query;
}
 
/**
 * Whether a project answers a listing or search: not archived, and one of its fields matches
 * the search as GitLab matches it (the terms all in the same field).
 */
export function matchesScopeProject(
  project: unknown,
  search: unknown,
  match: ProjectTextMatch,
): boolean {
  if (project === undefined) return false;
  const fields = project as Args;
  if (fields.archived === true) return false;
  const query = typeof search === 'string' ? search.trim().replace(/\s+/g, ' ').toLowerCase() : '';
  if (!query) return true;
  const minLength = match === 'listing' ? MIN_PARTIAL_TERM : 0;
  const terms = searchTerms(query).filter((term) => term.length >= minLength);
  const values =
    match === 'listing'
      ? [fields.path, fields.name, fields.description]
      : [fields.path_with_namespace, fields.name_with_namespace, fields.description];
  return values.some((value) => matchesField(value, query, terms));
}
 
/** A project read in full, in the simple representation a listing or search answers with. */
export function simpleProject(project: unknown): unknown {
  return Object.fromEntries(
    Object.entries(project as Record<string, unknown>).filter(([key]) =>
      SIMPLE_PROJECT_FIELDS.has(key),
    ),
  );
}