@remogram/provider-gitlab-api 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +375 -6
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
gitAheadBehind,
|
|
11
11
|
refsInventory,
|
|
12
12
|
crInventory,
|
|
13
|
-
|
|
13
|
+
buildMergePlanFromProviderFacts,
|
|
14
14
|
ERROR_CODES,
|
|
15
15
|
forgeError,
|
|
16
16
|
forgeIngestCapabilityFacts,
|
|
@@ -37,6 +37,21 @@ import {
|
|
|
37
37
|
buildOpenPullListMeta,
|
|
38
38
|
gitlabOpenPullSortQuery,
|
|
39
39
|
appendSortQuery,
|
|
40
|
+
buildProviderIdentityFromGitLabUser,
|
|
41
|
+
buildBranchProtectionFromGitLabProtection,
|
|
42
|
+
buildCrFilesFromGitLabChanges,
|
|
43
|
+
buildCrCommentsBody,
|
|
44
|
+
buildCrCommentsFromGitLabDiscussions,
|
|
45
|
+
parseSinceObservedAt,
|
|
46
|
+
buildForgeChangesFromGiteaPulls,
|
|
47
|
+
buildChecksConclusionObservedEvent,
|
|
48
|
+
appendForgeChangeEvents,
|
|
49
|
+
parseStatusSetArgs,
|
|
50
|
+
buildCommitStatusSetBody,
|
|
51
|
+
normalizeStatusSetState,
|
|
52
|
+
MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
53
|
+
statusSetIdempotencyScanCapabilityFacts,
|
|
54
|
+
assertWriteCommandConfigured,
|
|
40
55
|
} from '@remogram/core';
|
|
41
56
|
|
|
42
57
|
const PUBLIC_GITLAB_HOST = 'gitlab.com';
|
|
@@ -50,8 +65,16 @@ const AUTH_CAPABILITIES = [
|
|
|
50
65
|
'pr_checks',
|
|
51
66
|
'merge_plan',
|
|
52
67
|
'sync_plan',
|
|
68
|
+
'status_set',
|
|
69
|
+
'whoami',
|
|
53
70
|
];
|
|
54
|
-
const STRUCTURED_COMMANDS = apiProviderCommands(
|
|
71
|
+
const STRUCTURED_COMMANDS = apiProviderCommands({
|
|
72
|
+
branchProtectionImplemented: true,
|
|
73
|
+
crFilesImplemented: true,
|
|
74
|
+
crCommentsImplemented: true,
|
|
75
|
+
forgeChangesImplemented: true,
|
|
76
|
+
statusSetImplemented: true,
|
|
77
|
+
});
|
|
55
78
|
|
|
56
79
|
export function gitlabToken() {
|
|
57
80
|
return process.env.GITLAB_TOKEN || null;
|
|
@@ -180,8 +203,10 @@ export function providerCapabilities() {
|
|
|
180
203
|
mergeability_confidence: 'derived',
|
|
181
204
|
host_binding: 'verified_remote_host',
|
|
182
205
|
pagination: 'supported',
|
|
183
|
-
write_support:
|
|
206
|
+
write_support: true,
|
|
207
|
+
write_commands: ['status_set'],
|
|
184
208
|
...forgeIngestCapabilityFacts(),
|
|
209
|
+
...statusSetIdempotencyScanCapabilityFacts(),
|
|
185
210
|
...checkPaginationCapabilityFacts({
|
|
186
211
|
strategy: 'offset_limit',
|
|
187
212
|
pageSizeParam: 'per_page',
|
|
@@ -341,9 +366,162 @@ export async function prChecks(ctx, opts) {
|
|
|
341
366
|
}
|
|
342
367
|
|
|
343
368
|
export async function mergePlan(ctx, opts) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
369
|
+
return buildMergePlanFromProviderFacts(ctx, opts, { prView, prChecks, crFiles });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function crFiles(ctx, { number }) {
|
|
373
|
+
requireToken();
|
|
374
|
+
if (number == null) {
|
|
375
|
+
throw Object.assign(new Error('--number required'), {
|
|
376
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR changed paths'),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const data = await gitlabFetch(
|
|
380
|
+
ctx.config,
|
|
381
|
+
ctx.parsed,
|
|
382
|
+
projectApiPath(ctx.config, 'merge_requests', number, 'changes'),
|
|
383
|
+
);
|
|
384
|
+
return buildCrFilesFromGitLabChanges(number, data?.changes);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function crComments(ctx, { number }) {
|
|
388
|
+
requireToken();
|
|
389
|
+
if (number == null) {
|
|
390
|
+
throw Object.assign(new Error('--number required'), {
|
|
391
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR review comments'),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const path = projectApiPath(ctx.config, 'merge_requests', number, 'discussions');
|
|
395
|
+
const pageSep = path.includes('?') ? '&' : '?';
|
|
396
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
397
|
+
const allDiscussions = [];
|
|
398
|
+
let listTruncated = false;
|
|
399
|
+
let entryCount = 0;
|
|
400
|
+
|
|
401
|
+
for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
|
|
402
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
403
|
+
async ({ page: pageNum, limit }) => {
|
|
404
|
+
const body = await gitlabFetch(
|
|
405
|
+
ctx.config,
|
|
406
|
+
ctx.parsed,
|
|
407
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
408
|
+
);
|
|
409
|
+
if (!Array.isArray(body)) {
|
|
410
|
+
throw Object.assign(new Error('Provider returned non-array MR discussions list'), {
|
|
411
|
+
forgeError: forgeError(
|
|
412
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
413
|
+
'Provider returned non-array MR discussions list',
|
|
414
|
+
),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return body;
|
|
418
|
+
},
|
|
419
|
+
page,
|
|
420
|
+
activeLimit,
|
|
421
|
+
);
|
|
422
|
+
activeLimit = usedLimit;
|
|
423
|
+
for (const discussion of items) {
|
|
424
|
+
const notes = Array.isArray(discussion?.notes) ? discussion.notes : [];
|
|
425
|
+
for (const note of notes) {
|
|
426
|
+
if (note?.system === true) continue;
|
|
427
|
+
entryCount += 1;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
allDiscussions.push(...items);
|
|
431
|
+
if (items.length < usedLimit) break;
|
|
432
|
+
if (page === MAX_CHECK_STATUS_PAGES) {
|
|
433
|
+
listTruncated = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const body = buildCrCommentsFromGitLabDiscussions(number, allDiscussions);
|
|
438
|
+
if (listTruncated) {
|
|
439
|
+
return buildCrCommentsBody({
|
|
440
|
+
pr_number: body.pr_number,
|
|
441
|
+
comments: body.comments,
|
|
442
|
+
comments_truncated: true,
|
|
443
|
+
comment_count: entryCount,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return body;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function gitlabMergeRequestAsPull(mr) {
|
|
450
|
+
if (mr == null || mr.iid == null) return null;
|
|
451
|
+
let state = 'unknown';
|
|
452
|
+
if (mr.state === 'opened') state = 'open';
|
|
453
|
+
else if (mr.state === 'merged' || mr.state === 'closed') state = 'closed';
|
|
454
|
+
return {
|
|
455
|
+
number: mr.iid,
|
|
456
|
+
title: mr.title,
|
|
457
|
+
html_url: mr.web_url,
|
|
458
|
+
state,
|
|
459
|
+
created_at: mr.created_at,
|
|
460
|
+
updated_at: mr.updated_at,
|
|
461
|
+
closed_at: mr.closed_at,
|
|
462
|
+
merged_at: mr.merged_at,
|
|
463
|
+
head: { sha: mr.sha ?? mr.diff_refs?.head_sha ?? null },
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export async function forgeChanges(ctx, { since }) {
|
|
468
|
+
requireToken();
|
|
469
|
+
const sinceIso = parseSinceObservedAt(since);
|
|
470
|
+
const path = `${projectApiPath(ctx.config, 'merge_requests')}?state=all&order_by=updated_at&sort=desc`;
|
|
471
|
+
const pageSep = '&';
|
|
472
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
473
|
+
const allMrs = [];
|
|
474
|
+
let listTruncated = false;
|
|
475
|
+
|
|
476
|
+
for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
|
|
477
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
478
|
+
async ({ page: pageNum, limit }) => {
|
|
479
|
+
const body = await gitlabFetch(
|
|
480
|
+
ctx.config,
|
|
481
|
+
ctx.parsed,
|
|
482
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
483
|
+
);
|
|
484
|
+
if (!Array.isArray(body)) {
|
|
485
|
+
throw Object.assign(new Error('Provider returned non-array merge request list'), {
|
|
486
|
+
forgeError: forgeError(
|
|
487
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
488
|
+
'Provider returned non-array merge request list',
|
|
489
|
+
),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return body;
|
|
493
|
+
},
|
|
494
|
+
page,
|
|
495
|
+
activeLimit,
|
|
496
|
+
);
|
|
497
|
+
activeLimit = usedLimit;
|
|
498
|
+
allMrs.push(...items);
|
|
499
|
+
if (items.length < usedLimit) break;
|
|
500
|
+
if (page === MAX_CHECK_STATUS_PAGES) {
|
|
501
|
+
listTruncated = true;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const pulls = allMrs.map(gitlabMergeRequestAsPull).filter(Boolean);
|
|
506
|
+
let body = buildForgeChangesFromGiteaPulls(sinceIso, pulls, { listTruncated });
|
|
507
|
+
const checkNumbers = new Set();
|
|
508
|
+
for (const event of body.events) {
|
|
509
|
+
if (event.kind === 'pr_opened' || event.kind === 'head_sha_moved') {
|
|
510
|
+
checkNumbers.add(event.pr_number);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const checkEvents = [];
|
|
515
|
+
for (const number of checkNumbers) {
|
|
516
|
+
const checks = await prChecks(ctx, { number });
|
|
517
|
+
checkEvents.push(buildChecksConclusionObservedEvent(number, checks));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (checkEvents.length > 0) {
|
|
521
|
+
body = appendForgeChangeEvents(body, checkEvents, { listTruncated });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return body;
|
|
347
525
|
}
|
|
348
526
|
|
|
349
527
|
const GITLAB_OPEN_PULL_COMPLIANT_MAX =
|
|
@@ -533,6 +711,191 @@ export async function syncPlan(ctx, remoteName = 'origin') {
|
|
|
533
711
|
};
|
|
534
712
|
}
|
|
535
713
|
|
|
714
|
+
async function fetchGitLabPatSelf(ctx) {
|
|
715
|
+
try {
|
|
716
|
+
return await gitlabFetch(ctx.config, ctx.parsed, '/personal_access_tokens/self');
|
|
717
|
+
} catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export async function whoami(ctx) {
|
|
723
|
+
requireToken();
|
|
724
|
+
const user = await gitlabFetch(ctx.config, ctx.parsed, '/user');
|
|
725
|
+
const patSelf = await fetchGitLabPatSelf(ctx);
|
|
726
|
+
return buildProviderIdentityFromGitLabUser(user, patSelf);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function approvalRulesForBranch(rules, branchRef) {
|
|
730
|
+
if (!Array.isArray(rules)) return [];
|
|
731
|
+
return rules.filter((rule) => {
|
|
732
|
+
const branches = rule?.protected_branches;
|
|
733
|
+
if (!Array.isArray(branches)) return false;
|
|
734
|
+
return branches.some((branch) => branch?.name === branchRef);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export async function branchProtection(ctx, { branchRef }) {
|
|
739
|
+
assertGitRef(branchRef, '--branch-ref');
|
|
740
|
+
requireToken();
|
|
741
|
+
let protectedBranch = null;
|
|
742
|
+
try {
|
|
743
|
+
protectedBranch = await gitlabFetch(
|
|
744
|
+
ctx.config,
|
|
745
|
+
ctx.parsed,
|
|
746
|
+
projectApiPath(ctx.config, 'protected_branches', branchRef),
|
|
747
|
+
);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
if (err?.status !== 404) throw err;
|
|
750
|
+
}
|
|
751
|
+
let approvalRules = [];
|
|
752
|
+
if (protectedBranch != null) {
|
|
753
|
+
try {
|
|
754
|
+
const allRules = await gitlabFetch(
|
|
755
|
+
ctx.config,
|
|
756
|
+
ctx.parsed,
|
|
757
|
+
projectApiPath(ctx.config, 'approval_rules'),
|
|
758
|
+
);
|
|
759
|
+
approvalRules = approvalRulesForBranch(allRules, branchRef);
|
|
760
|
+
} catch {
|
|
761
|
+
approvalRules = [];
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return buildBranchProtectionFromGitLabProtection(branchRef, { protectedBranch, approvalRules });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function gitlabStatusRecordOrder(a, b) {
|
|
768
|
+
const aUpdated = Date.parse(a.updated_at ?? a.created_at ?? '') || 0;
|
|
769
|
+
const bUpdated = Date.parse(b.updated_at ?? b.created_at ?? '') || 0;
|
|
770
|
+
if (aUpdated !== bUpdated) return aUpdated - bUpdated;
|
|
771
|
+
const aId = Number(a.id) || 0;
|
|
772
|
+
const bId = Number(b.id) || 0;
|
|
773
|
+
return aId - bId;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function gitlabStatusAsRemogramState(status) {
|
|
777
|
+
const normalized = String(status ?? '').toLowerCase();
|
|
778
|
+
if (normalized === 'failed' || normalized === 'canceled') return 'failure';
|
|
779
|
+
if (
|
|
780
|
+
normalized === 'running'
|
|
781
|
+
|| normalized === 'created'
|
|
782
|
+
|| normalized === 'waiting_for_resource'
|
|
783
|
+
|| normalized === 'preparing'
|
|
784
|
+
) {
|
|
785
|
+
return 'pending';
|
|
786
|
+
}
|
|
787
|
+
return normalizeStatusSetState(normalized);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function remogramStateToGitlabPostState(state) {
|
|
791
|
+
if (state === 'failure' || state === 'error') return 'failed';
|
|
792
|
+
return state;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
|
|
796
|
+
return forgeError(
|
|
797
|
+
ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
|
|
798
|
+
'Cannot prove no commit status exists for sha+context within scan limit; retry or set manually',
|
|
799
|
+
null,
|
|
800
|
+
{
|
|
801
|
+
idempotency_scan: {
|
|
802
|
+
pages: pagesScanned,
|
|
803
|
+
max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
804
|
+
page_size: pageSizeUsed,
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Paginated commit-status scan for idempotent status set; fail-closed when scan cap prevents proof of absence. */
|
|
811
|
+
export async function findCommitStatusByContext(ctx, sha, context) {
|
|
812
|
+
requireToken();
|
|
813
|
+
const path = projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses');
|
|
814
|
+
const pageSep = path.includes('?') ? '&' : '?';
|
|
815
|
+
let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
|
|
816
|
+
let bestMatch = null;
|
|
817
|
+
|
|
818
|
+
for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
|
|
819
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(
|
|
820
|
+
async ({ page: pageNum, limit }) => {
|
|
821
|
+
const body = await gitlabFetch(
|
|
822
|
+
ctx.config,
|
|
823
|
+
ctx.parsed,
|
|
824
|
+
`${path}${pageSep}per_page=${limit}&page=${pageNum}`,
|
|
825
|
+
);
|
|
826
|
+
if (!Array.isArray(body)) {
|
|
827
|
+
throw Object.assign(new Error('Provider returned non-array commit status list'), {
|
|
828
|
+
forgeError: forgeError(
|
|
829
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
830
|
+
'Provider returned non-array commit status list',
|
|
831
|
+
),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return body;
|
|
835
|
+
},
|
|
836
|
+
page,
|
|
837
|
+
activeLimit,
|
|
838
|
+
);
|
|
839
|
+
activeLimit = usedLimit;
|
|
840
|
+
|
|
841
|
+
for (const record of items) {
|
|
842
|
+
if (record?.name !== context) continue;
|
|
843
|
+
if (!bestMatch || gitlabStatusRecordOrder(record, bestMatch) > 0) {
|
|
844
|
+
bestMatch = record;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (items.length < usedLimit) return bestMatch;
|
|
849
|
+
if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
|
|
850
|
+
throw Object.assign(new Error('Commit status idempotency scan incomplete'), {
|
|
851
|
+
forgeError: statusSetIdempotencyScanIncompleteError(page, usedLimit),
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return bestMatch;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export async function statusSet(ctx, args) {
|
|
859
|
+
assertWriteCommandConfigured(ctx.config, 'status_set');
|
|
860
|
+
const parsed = parseStatusSetArgs(args);
|
|
861
|
+
const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
|
|
862
|
+
if (existing) {
|
|
863
|
+
const requestedGitlabState = remogramStateToGitlabPostState(parsed.state);
|
|
864
|
+
const existingGitlabState = String(existing.status ?? existing.state ?? '').toLowerCase();
|
|
865
|
+
if (existingGitlabState === requestedGitlabState) {
|
|
866
|
+
const remogramState = gitlabStatusAsRemogramState(existing.status ?? existing.state);
|
|
867
|
+
return buildCommitStatusSetBody(
|
|
868
|
+
{ ...existing, status: remogramState },
|
|
869
|
+
parsed,
|
|
870
|
+
{ reusedExisting: true },
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const payload = {
|
|
875
|
+
state: remogramStateToGitlabPostState(parsed.state),
|
|
876
|
+
name: parsed.context,
|
|
877
|
+
};
|
|
878
|
+
if (parsed.description != null) payload.description = parsed.description;
|
|
879
|
+
if (parsed.target_url != null) payload.target_url = parsed.target_url;
|
|
880
|
+
const response = await gitlabFetch(
|
|
881
|
+
ctx.config,
|
|
882
|
+
ctx.parsed,
|
|
883
|
+
projectApiPath(ctx.config, 'statuses', parsed.sha),
|
|
884
|
+
{
|
|
885
|
+
method: 'POST',
|
|
886
|
+
headers: { 'Content-Type': 'application/json' },
|
|
887
|
+
body: JSON.stringify(payload),
|
|
888
|
+
},
|
|
889
|
+
);
|
|
890
|
+
return buildCommitStatusSetBody(
|
|
891
|
+
{
|
|
892
|
+
...response,
|
|
893
|
+
status: gitlabStatusAsRemogramState(response?.status ?? response?.state ?? parsed.state),
|
|
894
|
+
},
|
|
895
|
+
parsed,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
536
899
|
export const provider = {
|
|
537
900
|
id: 'gitlab-api',
|
|
538
901
|
providerCapabilities,
|
|
@@ -545,4 +908,10 @@ export const provider = {
|
|
|
545
908
|
prChecks,
|
|
546
909
|
mergePlan,
|
|
547
910
|
syncPlan,
|
|
911
|
+
whoami,
|
|
912
|
+
branchProtection,
|
|
913
|
+
crFiles,
|
|
914
|
+
crComments,
|
|
915
|
+
forgeChanges,
|
|
916
|
+
statusSet,
|
|
548
917
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remogram/provider-gitlab-api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.6",
|
|
4
4
|
"description": "GitLab API provider for remogram",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,6 @@
|
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@remogram/core": "0.1.0-beta.
|
|
25
|
+
"@remogram/core": "0.1.0-beta.6"
|
|
26
26
|
}
|
|
27
27
|
}
|