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 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | 20x 20x 20x 20x 20x 20x 27x 17x 11x 9x 13x 3x 1x 20x 17x 17x 17x 16x 16x 16x 14x 14x 2x 14x 14x 14x 2x 14x 16x 16x 16x 16x 31x 16x 16x 16x 40x 40x 39x 39x 39x 37x 37x 1134x 1134x 1133x 1133x 1133x 37x 37x 12x 39x 23x 8x 24x 24x 24x 34x 33x 33x 33x 31x 31x 11x 21x 15x 14x 14x 14x 13x 13x 20x 25x 25x 23x 2213x 2213x 2197x 2197x 16x 11x 20x 2213x 1241x 23x 1193x 1193x 1193x 1109x 1205x | /**
* Project and merge request listings that name no project or group, under a scope of several
* targets: each target's own listing, merged without repeats, ordered as GitLab orders the
* listing and paged after the merge. The instance-wide feed is not read, as its first pages
* may hold nothing of the scope.
*/
import type { ScopeConfig } from '../profiles/types';
import type { ScopeEnforcer } from '../profiles/scope-enforcer';
import type { RunTool } from './scoped-execution';
import {
composedBounds,
composedPage,
MAX_REQUESTS,
PAGE_SIZE,
type Budget,
} from './composed-feeds';
import {
filteredReadArgs,
groupProjectsArgs,
needsInstanceProjectListing,
scopedResult,
scopeTargets,
targetMergeRequestsArgs,
type Target,
} from './scope-targets';
import { matchesScopeProject, readScopeProject, simpleProject } from './scope-projects';
type Args = Record<string, unknown>;
type Row = Record<string, unknown>;
type Read = { rows: Row[]; bounded: boolean } | { answer: unknown };
export type Ordering = { field: string; direction: number };
/** Project listing options a project of the scope answers itself, read on its own. */
const PROJECT_TARGET_OPTIONS: ReadonlySet<string> = new Set([
'action',
'search',
'page',
'per_page',
'simple',
'order_by',
'sort',
]);
/**
* The fields each listing is ordered by that its rows carry, so the merge can order them as
* one listing would; similarity and priority are GitLab's own ranking within one listing.
*/
const MERGEABLE_ORDER: Readonly<Record<string, ReadonlySet<string>>> = {
browse_projects: new Set(['id', 'name', 'path', 'created_at', 'updated_at', 'last_activity_at']),
browse_merge_requests: new Set(['created_at', 'updated_at', 'title']),
};
/**
* Whether a targetless listing is composed from the scope's targets. A project of the scope
* is read on its own, which answers only the options it can apply itself; with others the
* listing stays the filtered instance-wide one.
*/
export function isComposedListing(tool: string, requested: Args, scope: ScopeConfig): boolean {
if (requested.action !== 'list' || requested.project_id !== undefined) return false;
if (tool === 'browse_merge_requests') return requested.group_id === undefined;
if (tool !== 'browse_projects' || requested.group_id !== undefined) return false;
if (needsInstanceProjectListing(requested)) return false;
if (Object.keys(requested).every((key) => PROJECT_TARGET_OPTIONS.has(key))) return true;
for (const target of scopeTargets(scope)) if ('project' in target) return false;
return true;
}
/** A targetless project or merge request listing composed from the scope's targets. */
export async function multiTargetListing(
run: RunTool,
tool: string,
requested: Args,
scope: ScopeConfig,
enforcer: ScopeEnforcer,
): Promise<unknown> {
const what = tool === 'browse_projects' ? 'projects' : 'merge requests';
const bounds = composedBounds(requested, what);
if ('answer' in bounds) return bounds.answer;
const { page: _page, per_page: _perPage, ...filters } = requested;
const read = await readTargets(run, tool, filters, scope, enforcer, bounds.wanted);
if ('answer' in read) return read.answer;
const reasons: string[] = [];
if (read.bounded)
reasons.push(
`The ${what} of the scope are read with at most ${MAX_REQUESTS} requests; rows of unread projects or groups may belong on this page.`,
);
const ordering = listingOrder(tool, requested);
const ordered = ordering ? orderRows(read.rows, ordering) : undefined;
if (!ordered && read.contributing > 1)
reasons.push(
ordering
? `GitLab omitted the ${ordering.field} of some ${what}; they keep target order.`
: `The ${String(requested.order_by)} order is GitLab's own within one listing and cannot be merged across projects and groups; ${what} keep target order.`,
);
return composedPage(ordered ?? read.rows, bounds.start, bounds.wanted, reasons);
}
/**
* Each target's rows in target order, without repeats, within one request bound; the number
* of targets that added rows. A target named twice is read once.
*/
async function readTargets(
run: RunTool,
tool: string,
filters: Args,
scope: ScopeConfig,
enforcer: ScopeEnforcer,
wanted: number,
): Promise<{ rows: Row[]; bounded: boolean; contributing: number } | { answer: unknown }> {
const rows: Row[] = [];
const seen = new Set<unknown>();
const targets = new Set<string>();
const budget: Budget = { requests: 0 };
// A group's listing also returns projects shared into it, and a group's merge requests
// those of subgroups a scope without them leaves out. Rows are read in a view that tells
// their project and kept in the one the caller asked for.
const keep = (found: Row[]): Row[] => scopedResult(tool, filters, found, enforcer) as Row[];
const read = filteredReadArgs(tool, filters);
let contributing = 0;
// Targets are read in turn: they share one request budget.
for (const target of scopeTargets(scope)) {
const key = JSON.stringify(target);
if (targets.has(key)) continue;
targets.add(key);
const answer = await readTarget(run, tool, read, target, wanted, budget, keep);
if ('answer' in answer) return answer;
let added = 0;
for (const row of answer.rows) {
const id = row.id ?? JSON.stringify(row);
if (seen.has(id)) continue;
seen.add(id);
rows.push(row);
added++;
}
if (added > 0) contributing++;
if (answer.bounded) return { rows, bounded: true, contributing };
}
return { rows, bounded: false, contributing };
}
/**
* One target's rows of the scope, read until it holds `wanted` of them or ends: the first
* `wanted` rows of the merge include at most `wanted` from each target.
*/
async function readTarget(
run: RunTool,
tool: string,
filters: Args,
target: Target,
wanted: number,
budget: Budget,
keep: (rows: Row[]) => Row[],
): Promise<Read> {
let args: Args;
if (tool !== 'browse_projects') args = targetMergeRequestsArgs(filters, target);
else if ('project' in target) return readProjectTarget(run, target.project, filters, budget);
else args = groupProjectsArgs(filters, target);
const rows: Row[] = [];
let page = 1;
// Pages are read one after another: whether the next one is needed depends on this one.
while (rows.length < wanted) {
if (budget.requests === MAX_REQUESTS) return { rows, bounded: true };
budget.requests++;
const answer = await run(tool, { ...args, page, per_page: PAGE_SIZE });
if (!Array.isArray(answer)) return { answer };
rows.push(...keep(answer as Row[]));
if (answer.length < PAGE_SIZE) break;
page++;
}
return { rows, bounded: false };
}
/** A project of the scope as a listing of projects shows it: itself when it matches. */
async function readProjectTarget(
run: RunTool,
project: string,
filters: Args,
budget: Budget,
): Promise<Read> {
if (budget.requests === MAX_REQUESTS) return { rows: [], bounded: true };
budget.requests++;
const found = await readScopeProject(run, project);
if (!matchesScopeProject(found, filters.search, 'listing')) return { rows: [], bounded: false };
// A listing answers with the simple representation unless simple=false, as GitLab does.
const row = (filters.simple === false ? found : simpleProject(found)) as Row;
return { rows: [row], bounded: false };
}
/**
* The order of the listing, newest created first unless the call names one (lib/api/projects.rb,
* lib/api/helpers/merge_requests_helpers.rb), or undefined for a ranking within one listing.
*/
export function listingOrder(tool: string, requested: Args): Ordering | undefined {
const field = typeof requested.order_by === 'string' ? requested.order_by : 'created_at';
if (!MERGEABLE_ORDER[tool]?.has(field)) return undefined;
return { field, direction: requested.sort === 'asc' ? 1 : -1 };
}
/** A row's value for ordering: times as instants, IDs as numbers, anything else as text. */
function orderValue(row: Row, field: string): number | string | undefined {
const value = row[field];
if (field.endsWith('_at')) {
const time = typeof value === 'string' ? Date.parse(value) : Number.NaN;
return Number.isFinite(time) ? time : undefined;
}
if (field === 'id') return typeof value === 'number' ? value : undefined;
return typeof value === 'string' ? value : undefined;
}
/** The rows in the given order, equal values by ID the same way; undefined when one lacks it. */
export function orderRows(rows: Row[], { field, direction }: Ordering): Row[] | undefined {
const keyed = rows.map((row) => ({
row,
value: orderValue(row, field),
id: typeof row.id === 'number' ? row.id : 0,
}));
if (keyed.some(({ value }) => value === undefined)) return undefined;
keyed.sort((a, b) => {
const left = a.value as number | string,
right = b.value as number | string;
if (left !== right) return (left < right ? -1 : 1) * direction;
return (a.id - b.id) * direction;
});
return keyed.map(({ row }) => row);
}
|