@remogram/core 0.1.0-beta.3 → 0.1.0-beta.4
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/auth-classes.js +6 -4
- package/caps.js +45 -0
- package/check-pagination.js +94 -17
- package/config-schema.js +2 -0
- package/contracts/envelope.js +12 -2
- package/contracts/errors.js +10 -2
- package/contracts/forge-error-fields.js +97 -0
- package/contracts/observer-fact-inventory.js +2 -5
- package/contracts/semantic-diff-facts.js +11 -2
- package/cr-inventory.js +19 -12
- package/cr-open.js +33 -0
- package/git-local.js +12 -0
- package/index.js +51 -2
- package/merge-blockers.js +19 -1
- package/merge-plan.js +34 -0
- package/open-pull-list.js +256 -0
- package/package.json +1 -1
- package/path-allowlist.js +63 -0
- package/write-config.js +34 -0
package/auth-classes.js
CHANGED
|
@@ -17,6 +17,7 @@ export const API_PROVIDER_COMMAND_AUTH = {
|
|
|
17
17
|
pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
|
|
18
18
|
merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
|
|
19
19
|
sync_plan: AUTH_CLASS.GIT_ONLY,
|
|
20
|
+
cr_open: AUTH_CLASS.TOKEN_REQUIRED,
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export function commandCapability(name, { implemented = true } = {}) {
|
|
@@ -27,10 +28,11 @@ export function commandCapability(name, { implemented = true } = {}) {
|
|
|
27
28
|
return { name, implemented, auth_class };
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function apiProviderCommands() {
|
|
31
|
-
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
export function apiProviderCommands({ writeCommandsImplemented = false } = {}) {
|
|
32
|
+
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
|
|
33
|
+
const implemented = name === 'cr_open' ? writeCommandsImplemented : true;
|
|
34
|
+
return commandCapability(name, { implemented });
|
|
35
|
+
});
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export function stubProviderCommands() {
|
package/caps.js
CHANGED
|
@@ -6,6 +6,11 @@ export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
|
|
|
6
6
|
export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
|
|
7
7
|
export const MAX_CHECK_STATUS_PAGES = 50;
|
|
8
8
|
|
|
9
|
+
/** Gitea open-pull list page size for idempotency scan and inventory list bounds. */
|
|
10
|
+
export const DEFAULT_OPEN_PULL_LIST_PAGE_SIZE = 100;
|
|
11
|
+
/** Max pages scanned before cr open idempotency fails closed (decoupled from check-status pagination). */
|
|
12
|
+
export const MAX_OPEN_PULL_IDEMPOTENCY_PAGES = 50;
|
|
13
|
+
|
|
9
14
|
export function getEffectiveIngestMaxBytes() {
|
|
10
15
|
const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
|
|
11
16
|
if (raw == null || raw === '') {
|
|
@@ -48,6 +53,46 @@ export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, source
|
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/** Structured idempotency scan facts for provider capabilities (cr open). */
|
|
57
|
+
export function idempotencyScanCapabilityFacts() {
|
|
58
|
+
return {
|
|
59
|
+
idempotency_scan: {
|
|
60
|
+
max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
61
|
+
page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
62
|
+
ingest_backoff: 'halve_until_fit',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
|
|
68
|
+
export function openPullListCapabilityFacts({
|
|
69
|
+
totalCountSource = null,
|
|
70
|
+
totalCountHeader = null,
|
|
71
|
+
sliceSortNotes = null,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const compliantMaxItems = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
|
|
74
|
+
return {
|
|
75
|
+
open_pull_list: {
|
|
76
|
+
max_pages: MAX_CHECK_STATUS_PAGES,
|
|
77
|
+
page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
78
|
+
ingest_backoff: 'halve_until_fit',
|
|
79
|
+
compliant_max_items: compliantMaxItems,
|
|
80
|
+
truncation_packet_field: 'list_truncated',
|
|
81
|
+
incomplete_error_code: 'inventory_list_incomplete',
|
|
82
|
+
default_slice_sort: 'number_asc',
|
|
83
|
+
supported_slice_sorts: [
|
|
84
|
+
'number_asc',
|
|
85
|
+
'number_desc',
|
|
86
|
+
'recent_update',
|
|
87
|
+
'recent_created',
|
|
88
|
+
],
|
|
89
|
+
...(totalCountSource ? { total_count_source: totalCountSource } : {}),
|
|
90
|
+
...(totalCountHeader ? { total_count_header: totalCountHeader } : {}),
|
|
91
|
+
...(sliceSortNotes ? { slice_sort_notes: sliceSortNotes } : {}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
51
96
|
export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
|
|
52
97
|
if (!text) return { text: '', truncated: false, bytes: 0 };
|
|
53
98
|
const buf = Buffer.from(text, 'utf8');
|
package/check-pagination.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
2
|
import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
|
|
3
|
+
import { resolvePaginatedEntryCount } from './open-pull-list.js';
|
|
3
4
|
|
|
4
5
|
function isOversizedIngestError(err) {
|
|
5
6
|
return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
|
|
@@ -65,6 +66,21 @@ export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit)
|
|
|
65
66
|
return { items, usedLimit };
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* When a full page lands on maxPages, probe one item on the next page to distinguish
|
|
71
|
+
* end-of-list from truncation.
|
|
72
|
+
* @template T
|
|
73
|
+
* @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
|
|
74
|
+
* @param {number} page
|
|
75
|
+
* @param {number} maxPages
|
|
76
|
+
* @returns {Promise<boolean>} true when list is truncated (more items exist)
|
|
77
|
+
*/
|
|
78
|
+
async function probeNextPageHasItems(fetchPage, page, maxPages) {
|
|
79
|
+
if (page > maxPages) return true;
|
|
80
|
+
const { items: probeItems } = await fetchPageWithIngestBackoff(fetchPage, page + 1, 1);
|
|
81
|
+
return probeItems.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/**
|
|
69
85
|
* Offset/limit check-status pagination with ingest-cap backoff.
|
|
70
86
|
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
|
|
@@ -99,8 +115,8 @@ export async function paginateCheckStatusPages({
|
|
|
99
115
|
/**
|
|
100
116
|
* Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
|
|
101
117
|
* listLimit bounds request size per page; callers slice returned items when enforcing a hard cap.
|
|
102
|
-
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean }} opts
|
|
103
|
-
* @returns {Promise<{ items: unknown[], list_truncated: boolean }>}
|
|
118
|
+
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean, retainMax?: number | null, trustedEntryCount?: number | null, seededFirstPage?: { items: unknown[], usedLimit: number } | null, startPage?: number, suppressFinalPageProbe?: boolean }} opts
|
|
119
|
+
* @returns {Promise<{ items: unknown[], list_truncated: boolean, walked_count: number, entry_count?: number }>}
|
|
104
120
|
*/
|
|
105
121
|
export async function paginateOffsetListPages({
|
|
106
122
|
fetchPage,
|
|
@@ -108,31 +124,92 @@ export async function paginateOffsetListPages({
|
|
|
108
124
|
listLimit = null,
|
|
109
125
|
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
110
126
|
maxPagesTruncatesWithLimit = false,
|
|
127
|
+
retainMax = null,
|
|
128
|
+
trustedEntryCount = null,
|
|
129
|
+
seededFirstPage = null,
|
|
130
|
+
startPage = 1,
|
|
131
|
+
suppressFinalPageProbe = false,
|
|
111
132
|
}) {
|
|
112
133
|
const all = [];
|
|
134
|
+
let entryCount = 0;
|
|
113
135
|
let listTruncated = false;
|
|
114
136
|
let activeLimit = pageSize;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
|
|
138
|
+
async function afterPage(page, items, usedLimit) {
|
|
139
|
+
entryCount += items.length;
|
|
140
|
+
if (retainMax != null) {
|
|
141
|
+
const space = Math.max(retainMax - all.length, 0);
|
|
142
|
+
if (space > 0) all.push(...items.slice(0, space));
|
|
143
|
+
} else {
|
|
144
|
+
all.push(...items);
|
|
145
|
+
}
|
|
146
|
+
if (items.length < usedLimit) {
|
|
147
|
+
if (
|
|
148
|
+
trustedEntryCount != null &&
|
|
149
|
+
trustedEntryCount > entryCount &&
|
|
150
|
+
page === 1 &&
|
|
151
|
+
page < maxPages
|
|
152
|
+
) {
|
|
153
|
+
return false;
|
|
127
154
|
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (listLimit != null && all.length >= listLimit) {
|
|
158
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (listLimit != null) {
|
|
128
162
|
if (maxPagesTruncatesWithLimit && page === maxPages) {
|
|
129
163
|
listTruncated = true;
|
|
130
|
-
|
|
164
|
+
return true;
|
|
131
165
|
}
|
|
132
166
|
} else if (page === maxPages) {
|
|
133
|
-
|
|
167
|
+
if (suppressFinalPageProbe) {
|
|
168
|
+
listTruncated = items.length >= usedLimit;
|
|
169
|
+
} else {
|
|
170
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let page = startPage;
|
|
178
|
+
if (seededFirstPage && startPage === 1) {
|
|
179
|
+
const { items, usedLimit } = seededFirstPage;
|
|
180
|
+
activeLimit = usedLimit;
|
|
181
|
+
if (await afterPage(1, items, usedLimit)) {
|
|
182
|
+
return {
|
|
183
|
+
items: all,
|
|
184
|
+
list_truncated: listTruncated,
|
|
185
|
+
walked_count: entryCount,
|
|
186
|
+
...(retainMax != null || trustedEntryCount != null
|
|
187
|
+
? { entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount) }
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
page = 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (; page <= maxPages; page += 1) {
|
|
195
|
+
const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
|
|
196
|
+
if (listLimit != null && remaining === 0) break;
|
|
197
|
+
const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
|
|
198
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
|
|
199
|
+
activeLimit = usedLimit;
|
|
200
|
+
if (await afterPage(page, items, usedLimit)) {
|
|
134
201
|
break;
|
|
135
202
|
}
|
|
136
203
|
}
|
|
137
|
-
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
items: all,
|
|
207
|
+
list_truncated: listTruncated,
|
|
208
|
+
walked_count: entryCount,
|
|
209
|
+
...(retainMax != null || trustedEntryCount != null
|
|
210
|
+
? {
|
|
211
|
+
entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount),
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
};
|
|
138
215
|
}
|
package/config-schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { writeCommandSchema } from './write-config.js';
|
|
2
3
|
|
|
3
4
|
const providerSchema = z.enum([
|
|
4
5
|
'gitea-api',
|
|
@@ -23,6 +24,7 @@ export const configSchema = z
|
|
|
23
24
|
owner: repoSegmentSchema,
|
|
24
25
|
repo: repoSegmentSchema,
|
|
25
26
|
baseUrl: z.string().url().optional(),
|
|
27
|
+
write_commands: z.array(writeCommandSchema).optional(),
|
|
26
28
|
})
|
|
27
29
|
.strict();
|
|
28
30
|
|
package/contracts/envelope.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeField } from '../caps.js';
|
|
2
|
+
import { normalizeForgeErrorFields } from './forge-error-fields.js';
|
|
2
3
|
|
|
3
4
|
export const SCHEMA_VERSION = 1;
|
|
4
5
|
|
|
@@ -12,6 +13,7 @@ export const PACKET_TYPES = {
|
|
|
12
13
|
PROVIDER_CAPABILITIES: 'provider_capabilities',
|
|
13
14
|
PROVIDER_DOCTOR: 'provider_doctor',
|
|
14
15
|
FORGE_ERROR: 'forge_error',
|
|
16
|
+
CHANGE_REQUEST_OPENED: 'change_request_opened',
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
@@ -29,7 +31,7 @@ function assertNoForbiddenKeys(value) {
|
|
|
29
31
|
}
|
|
30
32
|
for (const [key, nested] of Object.entries(value)) {
|
|
31
33
|
if (FORBIDDEN_PACKET_KEYS.has(key)) {
|
|
32
|
-
throw new Error(`Forbidden
|
|
34
|
+
throw new Error(`Forbidden workflow/planning-tool key in remogram output: ${key}`);
|
|
33
35
|
}
|
|
34
36
|
assertNoForbiddenKeys(nested);
|
|
35
37
|
}
|
|
@@ -53,13 +55,21 @@ export function forgePacket(type, context, body = {}, error = null) {
|
|
|
53
55
|
packet.error_code = error.code;
|
|
54
56
|
packet.error_message = sanitizeField(error.message);
|
|
55
57
|
if (error.status != null) packet.error_status = error.status;
|
|
58
|
+
if (error.fields != null && typeof error.fields === 'object') {
|
|
59
|
+
assertNoForbiddenKeys(error.fields);
|
|
60
|
+
const trustedFields = normalizeForgeErrorFields(error.code, error.fields);
|
|
61
|
+
if (trustedFields != null) {
|
|
62
|
+
Object.assign(packet, trustedFields);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
return packet;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
|
|
62
|
-
|
|
71
|
+
const body = error?.fields != null && typeof error.fields === 'object' ? error.fields : {};
|
|
72
|
+
return forgePacket(type, context, body, error);
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
export function unknownForgeContext() {
|
package/contracts/errors.js
CHANGED
|
@@ -13,8 +13,16 @@ export const ERROR_CODES = {
|
|
|
13
13
|
API_ERROR: 'api_error',
|
|
14
14
|
PR_NOT_OPEN: 'pr_not_open',
|
|
15
15
|
REMOTE_INFER_FAILED: 'remote_infer_failed',
|
|
16
|
+
WRITE_NOT_CONFIGURED: 'write_not_configured',
|
|
17
|
+
IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
|
|
18
|
+
INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
|
|
16
19
|
};
|
|
17
20
|
|
|
18
|
-
export function forgeError(code, message, status = null) {
|
|
19
|
-
return {
|
|
21
|
+
export function forgeError(code, message, status = null, fields = null) {
|
|
22
|
+
return {
|
|
23
|
+
code,
|
|
24
|
+
message,
|
|
25
|
+
...(status != null ? { status } : {}),
|
|
26
|
+
...(fields != null && typeof fields === 'object' ? { fields } : {}),
|
|
27
|
+
};
|
|
20
28
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ERROR_CODES } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** Top-level packet keys forge error fields must never override. */
|
|
4
|
+
const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
|
|
5
|
+
'type',
|
|
6
|
+
'schema_version',
|
|
7
|
+
'provider_id',
|
|
8
|
+
'remote_name',
|
|
9
|
+
'repo_id',
|
|
10
|
+
'observed_at',
|
|
11
|
+
'ok',
|
|
12
|
+
'error_code',
|
|
13
|
+
'error_message',
|
|
14
|
+
'error_status',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Trusted body fields allowed per forge error code. */
|
|
18
|
+
export const FORGE_ERROR_FIELD_ALLOWLIST = Object.freeze({
|
|
19
|
+
[ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE]: ['idempotency_scan'],
|
|
20
|
+
[ERROR_CODES.INVENTORY_LIST_INCOMPLETE]: ['inventory_list'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function assertPositiveInteger(name, value) {
|
|
24
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
25
|
+
throw new Error(`Invalid forge error field ${name}: must be a positive integer`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateIdempotencyScan(scan) {
|
|
30
|
+
if (scan == null || typeof scan !== 'object' || Array.isArray(scan)) {
|
|
31
|
+
throw new Error('Invalid forge error field idempotency_scan: must be an object');
|
|
32
|
+
}
|
|
33
|
+
const keys = Object.keys(scan).sort();
|
|
34
|
+
const expected = ['max_pages', 'page_size', 'pages'];
|
|
35
|
+
if (keys.length !== expected.length || !expected.every((k) => keys.includes(k))) {
|
|
36
|
+
throw new Error('Invalid forge error field idempotency_scan: unexpected keys');
|
|
37
|
+
}
|
|
38
|
+
assertPositiveInteger('idempotency_scan.pages', scan.pages);
|
|
39
|
+
assertPositiveInteger('idempotency_scan.max_pages', scan.max_pages);
|
|
40
|
+
assertPositiveInteger('idempotency_scan.page_size', scan.page_size);
|
|
41
|
+
return { idempotency_scan: scan };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateInventoryList(list) {
|
|
45
|
+
if (list == null || typeof list !== 'object' || Array.isArray(list)) {
|
|
46
|
+
throw new Error('Invalid forge error field inventory_list: must be an object');
|
|
47
|
+
}
|
|
48
|
+
const keys = Object.keys(list).sort();
|
|
49
|
+
if (keys.length !== 1 || keys[0] !== 'entry_count') {
|
|
50
|
+
throw new Error('Invalid forge error field inventory_list: unexpected keys');
|
|
51
|
+
}
|
|
52
|
+
assertPositiveInteger('inventory_list.entry_count', list.entry_count);
|
|
53
|
+
return { inventory_list: list };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate and normalize trusted forge_error body fields before packet merge.
|
|
58
|
+
* @param {string} code
|
|
59
|
+
* @param {Record<string, unknown> | null | undefined} fields
|
|
60
|
+
* @returns {Record<string, unknown> | null}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeForgeErrorFields(code, fields) {
|
|
63
|
+
if (fields == null) return null;
|
|
64
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
|
65
|
+
throw new Error('Invalid forge error fields: must be an object');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keys = Object.keys(fields);
|
|
69
|
+
if (keys.length === 0) return null;
|
|
70
|
+
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (FORBIDDEN_ERROR_FIELD_KEYS.has(key)) {
|
|
73
|
+
throw new Error(`Forge error fields cannot override packet field ${key}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const allowlist = FORGE_ERROR_FIELD_ALLOWLIST[code];
|
|
78
|
+
if (!allowlist) {
|
|
79
|
+
throw new Error(`Forge error code ${code} does not allow trusted fields`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
if (!allowlist.includes(key)) {
|
|
84
|
+
throw new Error(`Forge error field ${key} is not allowed for code ${code}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (fields.idempotency_scan != null) {
|
|
89
|
+
return validateIdempotencyScan(fields.idempotency_scan);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (fields.inventory_list != null) {
|
|
93
|
+
return validateInventoryList(fields.inventory_list);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return fields;
|
|
97
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remogram fact requirements for
|
|
3
|
-
* Observer proto today captures remogram repo status only;
|
|
2
|
+
* Remogram fact requirements for observer and semantic-diff consumer snapshots.
|
|
3
|
+
* Observer proto today captures remogram repo status only; downstream consumers
|
|
4
4
|
* may compose additional read-only fact packets listed here.
|
|
5
|
-
*
|
|
6
|
-
* @see ../topogram/tools/branch-workcycle/observer-snapshot.sh
|
|
7
|
-
* @see topo/sdlc/acceptance_criteria/semantic_diff_fact_inventory.tg ac_semantic_diff_observer_facts
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
7
|
import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
|
|
@@ -22,6 +22,9 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
|
|
|
22
22
|
'doctor',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
/** v1 write surface (Gitea cr open only in first slice). */
|
|
26
|
+
export const V1_WRITE_COMMANDS = Object.freeze(['cr open']);
|
|
27
|
+
|
|
25
28
|
/**
|
|
26
29
|
* Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
|
|
27
30
|
* All use schema_version 1 envelope discipline via forgePacket.
|
|
@@ -55,11 +58,14 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
|
|
|
55
58
|
truncated: true,
|
|
56
59
|
list_truncated: true,
|
|
57
60
|
checks_truncated: true,
|
|
61
|
+
slice_sort: true,
|
|
58
62
|
entry_count: true,
|
|
59
63
|
mergeability_confidence: true,
|
|
60
64
|
write_support: true,
|
|
61
65
|
diverged: true,
|
|
62
66
|
auth_present: true,
|
|
67
|
+
reused_existing: true,
|
|
68
|
+
idempotency_scan: true,
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
/**
|
|
@@ -74,6 +80,7 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
|
74
80
|
merge_plan: ['blockers[].message', 'blockers[].context'],
|
|
75
81
|
sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
|
|
76
82
|
ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
|
|
83
|
+
change_request_opened: ['url', 'title', 'head', 'base'],
|
|
77
84
|
cr_inventory_slice: [
|
|
78
85
|
'entries[].url',
|
|
79
86
|
'entries[].title',
|
|
@@ -88,7 +95,7 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
|
88
95
|
],
|
|
89
96
|
});
|
|
90
97
|
|
|
91
|
-
/** Keys that must never appear in remogram output (
|
|
98
|
+
/** Keys that must never appear in remogram output (external planning/SDLC workflow concepts). */
|
|
92
99
|
export { FORBIDDEN_PACKET_KEYS };
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -108,6 +115,8 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
|
|
|
108
115
|
/** true when list cap applied (entry_count > limit), not missing entries */
|
|
109
116
|
truncated: 'boolean',
|
|
110
117
|
list_truncated: 'boolean',
|
|
118
|
+
/** normalized slice sort preset applied to open-list resolution */
|
|
119
|
+
slice_sort: 'string',
|
|
111
120
|
entries_skipped:
|
|
112
121
|
'array<{ pr_number: number, error_code: pr_not_open | api_error | oversized_raw_output | ... }> optional',
|
|
113
122
|
slice_ref: 'string optional',
|
|
@@ -116,7 +125,7 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
|
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
127
|
* Build a fact-inventory packet body through the standard envelope gate.
|
|
119
|
-
* Throws if body contains forbidden
|
|
128
|
+
* Throws if body contains forbidden workflow/planning-tool keys.
|
|
120
129
|
*/
|
|
121
130
|
export function forgeFactInventoryPacket(type, context, body = {}, error = null) {
|
|
122
131
|
if (!Object.values(FACT_INVENTORY_PACKET_TYPES).includes(type)) {
|
package/cr-inventory.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { sanitizeField } from './caps.js';
|
|
2
2
|
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
3
|
import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
6
|
+
normalizeCrInventorySort,
|
|
7
|
+
} from './open-pull-list.js';
|
|
4
8
|
import { staleHeadDetails } from './pr-head-reconcile.js';
|
|
5
9
|
|
|
6
10
|
export const DEFAULT_CR_INVENTORY_LIMIT = 50;
|
|
11
|
+
/** Default bound when `--limit` is omitted (keeps forge ingest under cap on large repos). */
|
|
12
|
+
export const DEFAULT_CR_INVENTORY_SAFE_LIMIT = 3;
|
|
7
13
|
|
|
8
14
|
export function normalizeCrInventoryLimit(value) {
|
|
9
|
-
if (value == null || value === '') return
|
|
15
|
+
if (value == null || value === '') return DEFAULT_CR_INVENTORY_SAFE_LIMIT;
|
|
10
16
|
const n = Number(value);
|
|
11
17
|
if (!Number.isInteger(n) || n <= 0) {
|
|
12
18
|
throw Object.assign(new Error('Invalid cr inventory limit'), {
|
|
@@ -16,12 +22,11 @@ export function normalizeCrInventoryLimit(value) {
|
|
|
16
22
|
return n;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
async function resolveOpenPullList(provider, ctx,
|
|
20
|
-
const listOpts = { limit };
|
|
25
|
+
async function resolveOpenPullList(provider, ctx, entryLimit, sliceSort) {
|
|
21
26
|
if (typeof provider.listOpenPullsWithMeta === 'function') {
|
|
22
|
-
return provider.listOpenPullsWithMeta(ctx,
|
|
27
|
+
return provider.listOpenPullsWithMeta(ctx, { retain_max: entryLimit, sort: sliceSort });
|
|
23
28
|
}
|
|
24
|
-
const numbers = await provider.listOpenPulls(ctx,
|
|
29
|
+
const numbers = await provider.listOpenPulls(ctx, {});
|
|
25
30
|
return { numbers, list_truncated: false };
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -71,16 +76,17 @@ export function buildCrInventoryEntry(ctx, view, checks) {
|
|
|
71
76
|
* Aggregate open change requests into a semantic-diff-oriented inventory slice.
|
|
72
77
|
* @param {object} ctx forge context
|
|
73
78
|
* @param {object} provider must expose listOpenPulls, prView, prChecks
|
|
74
|
-
* @param {{ slice_ref?: string, limit?: number }} [opts]
|
|
79
|
+
* @param {{ slice_ref?: string, limit?: number, sort?: string }} [opts]
|
|
75
80
|
*/
|
|
76
81
|
export async function crInventory(ctx, provider, opts = {}) {
|
|
77
82
|
const limit = normalizeCrInventoryLimit(opts.limit);
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
const sliceSort = normalizeCrInventorySort(opts.sort);
|
|
84
|
+
const {
|
|
85
|
+
numbers,
|
|
86
|
+
list_truncated: listTruncated,
|
|
87
|
+
entry_count: providerEntryCount,
|
|
88
|
+
} = await resolveOpenPullList(provider, ctx, limit, sliceSort);
|
|
89
|
+
const entryCount = providerEntryCount ?? numbers.length;
|
|
84
90
|
const selected = numbers.slice(0, limit);
|
|
85
91
|
const entries = [];
|
|
86
92
|
const entries_skipped = [];
|
|
@@ -106,6 +112,7 @@ export async function crInventory(ctx, provider, opts = {}) {
|
|
|
106
112
|
entry_count: entryCount,
|
|
107
113
|
truncated: entryCount > selected.length,
|
|
108
114
|
list_truncated: listTruncated,
|
|
115
|
+
slice_sort: sliceSort ?? DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
109
116
|
...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
|
|
110
117
|
};
|
|
111
118
|
}
|
package/cr-open.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sanitizeField, sanitizeUrl } from './caps.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
|
|
4
|
+
/** Normalize Gitea pull create response into change_request_opened body fields. */
|
|
5
|
+
export function buildChangeRequestOpenedBody(
|
|
6
|
+
pull,
|
|
7
|
+
{ head, base, title },
|
|
8
|
+
{ reusedExisting = false } = {},
|
|
9
|
+
) {
|
|
10
|
+
const prNumber = Number(pull?.number);
|
|
11
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
12
|
+
throw Object.assign(new Error('Provider returned invalid pull number'), {
|
|
13
|
+
forgeError: forgeError(
|
|
14
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
15
|
+
'Provider returned invalid pull number',
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const resolvedTitle = reusedExisting
|
|
20
|
+
? sanitizeField(pull?.title ?? title)
|
|
21
|
+
: sanitizeField(title ?? pull?.title);
|
|
22
|
+
const body = {
|
|
23
|
+
pr_number: prNumber,
|
|
24
|
+
url: sanitizeUrl(pull.html_url ?? pull.url),
|
|
25
|
+
head: sanitizeField(head),
|
|
26
|
+
base: sanitizeField(base),
|
|
27
|
+
title: resolvedTitle,
|
|
28
|
+
};
|
|
29
|
+
if (reusedExisting) {
|
|
30
|
+
body.reused_existing = true;
|
|
31
|
+
}
|
|
32
|
+
return body;
|
|
33
|
+
}
|
package/git-local.js
CHANGED
|
@@ -43,3 +43,15 @@ export function gitAheadBehind(cwd, base, head) {
|
|
|
43
43
|
return { ahead_by: null, behind_by: null };
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export function gitDiffNameOnly(cwd, base, head) {
|
|
48
|
+
assertGitRef(base, 'base');
|
|
49
|
+
assertGitRef(head, 'head');
|
|
50
|
+
try {
|
|
51
|
+
const out = gitExec(cwd, ['diff', '--name-only', base, head]);
|
|
52
|
+
if (!out) return [];
|
|
53
|
+
return out.split('\n').filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/index.js
CHANGED
|
@@ -16,6 +16,10 @@ export {
|
|
|
16
16
|
allObserverEligibleCommands,
|
|
17
17
|
} from './contracts/observer-fact-inventory.js';
|
|
18
18
|
export { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
19
|
+
export {
|
|
20
|
+
FORGE_ERROR_FIELD_ALLOWLIST,
|
|
21
|
+
normalizeForgeErrorFields,
|
|
22
|
+
} from './contracts/forge-error-fields.js';
|
|
19
23
|
export {
|
|
20
24
|
capText,
|
|
21
25
|
sanitizeField,
|
|
@@ -26,9 +30,13 @@ export {
|
|
|
26
30
|
FORGE_INGEST_MAX_BYTES_ENV,
|
|
27
31
|
DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
28
32
|
MAX_CHECK_STATUS_PAGES,
|
|
33
|
+
DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
34
|
+
MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
29
35
|
getEffectiveIngestMaxBytes,
|
|
30
36
|
forgeIngestCapabilityFacts,
|
|
31
37
|
checkPaginationCapabilityFacts,
|
|
38
|
+
idempotencyScanCapabilityFacts,
|
|
39
|
+
openPullListCapabilityFacts,
|
|
32
40
|
} from './caps.js';
|
|
33
41
|
export {
|
|
34
42
|
paginateCheckStatusPages,
|
|
@@ -39,10 +47,51 @@ export {
|
|
|
39
47
|
withLimitParam,
|
|
40
48
|
} from './check-pagination.js';
|
|
41
49
|
export { assertGitRef, assertGitRemote } from './git-args.js';
|
|
42
|
-
export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot } from './git-local.js';
|
|
50
|
+
export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot, gitDiffNameOnly } from './git-local.js';
|
|
43
51
|
export { buildRefInventoryBody, refsInventory } from './ref-inventory.js';
|
|
44
|
-
export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
|
|
52
|
+
export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, DEFAULT_CR_INVENTORY_SAFE_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
|
|
53
|
+
export {
|
|
54
|
+
CR_INVENTORY_SLICE_SORTS,
|
|
55
|
+
DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
56
|
+
normalizeCrInventorySort,
|
|
57
|
+
parseTotalCountHeader,
|
|
58
|
+
isCrInventoryFastPathEligible,
|
|
59
|
+
forgeOrderAuthoritative,
|
|
60
|
+
validateFastPathPageLength,
|
|
61
|
+
isNumberSortFastPathEligible,
|
|
62
|
+
resolvePaginatedEntryCount,
|
|
63
|
+
resolveListTruncatedWithTrustedTotal,
|
|
64
|
+
isRecentCreatedFastPathEligible,
|
|
65
|
+
giteaRecentCreatedTailPage,
|
|
66
|
+
isNumberSortFullCollectRequired,
|
|
67
|
+
prepareGiteaOpenPullPageItems,
|
|
68
|
+
orderOpenPullNumbers,
|
|
69
|
+
buildOpenPullListMeta,
|
|
70
|
+
giteaOpenPullSortQuery,
|
|
71
|
+
gitlabOpenPullSortQuery,
|
|
72
|
+
githubOpenPullSortQuery,
|
|
73
|
+
appendSortQuery,
|
|
74
|
+
} from './open-pull-list.js';
|
|
75
|
+
export { buildChangeRequestOpenedBody } from './cr-open.js';
|
|
76
|
+
export {
|
|
77
|
+
WRITE_COMMAND_IDS,
|
|
78
|
+
CONFIGURED_WRITE_COMMANDS,
|
|
79
|
+
writeCommandSchema,
|
|
80
|
+
assertWriteCommandConfigured,
|
|
81
|
+
isWriteCommandConfigured,
|
|
82
|
+
} from './write-config.js';
|
|
45
83
|
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
84
|
+
export {
|
|
85
|
+
resolveMergePlanPathScope,
|
|
86
|
+
buildMergePlanBody,
|
|
87
|
+
buildMergePlanBodyFromFacts,
|
|
88
|
+
} from './merge-plan.js';
|
|
89
|
+
export {
|
|
90
|
+
matchPathAllowlist,
|
|
91
|
+
isPathAllowed,
|
|
92
|
+
pathsOutsideAllowlist,
|
|
93
|
+
allPathsAllowed,
|
|
94
|
+
} from './path-allowlist.js';
|
|
46
95
|
export {
|
|
47
96
|
localHeadShaForPr,
|
|
48
97
|
staleHeadDetails,
|
package/merge-blockers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { allPathsAllowed } from './path-allowlist.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Derive merge blockers from already-fetched PR view and checks facts.
|
|
3
5
|
* Shared by merge plan and cr inventory aggregation.
|
|
@@ -6,7 +8,12 @@ export function isOpenPrState(state) {
|
|
|
6
8
|
return String(state ?? '').toLowerCase() === 'open';
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} view
|
|
13
|
+
* @param {object} checks
|
|
14
|
+
* @param {{ allowed_paths?: string[], changed_paths?: string[] | null }} [pathScope]
|
|
15
|
+
*/
|
|
16
|
+
export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
|
|
10
17
|
const blockers = [];
|
|
11
18
|
if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
|
|
12
19
|
if (!isOpenPrState(view.state)) blockers.push('pr_not_open');
|
|
@@ -14,5 +21,16 @@ export function mergeBlockersFromFacts(view, checks) {
|
|
|
14
21
|
if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
|
|
15
22
|
if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
|
|
16
23
|
if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
|
|
24
|
+
|
|
25
|
+
const allowedPaths = pathScope.allowed_paths;
|
|
26
|
+
if (Array.isArray(allowedPaths) && allowedPaths.length > 0) {
|
|
27
|
+
const changedPaths = pathScope.changed_paths;
|
|
28
|
+
if (changedPaths == null) {
|
|
29
|
+
blockers.push('changed_paths_unavailable');
|
|
30
|
+
} else if (!allPathsAllowed(allowedPaths, changedPaths)) {
|
|
31
|
+
blockers.push('path_scope_violation');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
return blockers;
|
|
18
36
|
}
|
package/merge-plan.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { gitDiffNameOnly } from './git-local.js';
|
|
2
|
+
import { mergeBlockersFromFacts } from './merge-blockers.js';
|
|
3
|
+
|
|
4
|
+
function normalizeAllowedPaths(allowedPaths) {
|
|
5
|
+
if (!Array.isArray(allowedPaths)) return null;
|
|
6
|
+
const normalized = allowedPaths.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
7
|
+
return normalized.length > 0 ? normalized : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveMergePlanPathScope(ctx, view, opts = {}) {
|
|
11
|
+
const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
|
|
12
|
+
if (!allowedPaths) {
|
|
13
|
+
return { allowed_paths: null, changed_paths: null };
|
|
14
|
+
}
|
|
15
|
+
if (!view.base_sha || !view.head_sha) {
|
|
16
|
+
return { allowed_paths: allowedPaths, changed_paths: null };
|
|
17
|
+
}
|
|
18
|
+
const changedPaths = gitDiffNameOnly(ctx.cwd, view.base_sha, view.head_sha);
|
|
19
|
+
return { allowed_paths: allowedPaths, changed_paths: changedPaths };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
23
|
+
return {
|
|
24
|
+
pr_number: view.pr_number,
|
|
25
|
+
mergeability: view.mergeability,
|
|
26
|
+
checks_conclusion: checks.check_conclusion,
|
|
27
|
+
blockers: mergeBlockersFromFacts(view, checks, pathScope),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildMergePlanBodyFromFacts(ctx, view, checks, opts = {}) {
|
|
32
|
+
const pathScope = resolveMergePlanPathScope(ctx, view, opts);
|
|
33
|
+
return buildMergePlanBody(view, checks, pathScope);
|
|
34
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
2
|
+
|
|
3
|
+
/** Normalized slice sort keys exposed on CLI/MCP and success packets. */
|
|
4
|
+
export const CR_INVENTORY_SLICE_SORTS = Object.freeze([
|
|
5
|
+
'number_asc',
|
|
6
|
+
'number_desc',
|
|
7
|
+
'recent_update',
|
|
8
|
+
'recent_created',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CR_INVENTORY_SLICE_SORT = 'number_asc';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {unknown} value
|
|
15
|
+
* @returns {typeof CR_INVENTORY_SLICE_SORTS[number]}
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeCrInventorySort(value) {
|
|
18
|
+
if (value == null || value === '') return DEFAULT_CR_INVENTORY_SLICE_SORT;
|
|
19
|
+
const sort = String(value).trim().toLowerCase();
|
|
20
|
+
if (!CR_INVENTORY_SLICE_SORTS.includes(sort)) {
|
|
21
|
+
throw Object.assign(new Error('Invalid cr inventory sort'), {
|
|
22
|
+
forgeError: forgeError(
|
|
23
|
+
ERROR_CODES.INVALID_ARGS,
|
|
24
|
+
`--sort must be one of: ${CR_INVENTORY_SLICE_SORTS.join(', ')}`,
|
|
25
|
+
),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return sort;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse forge total-count response header as a positive integer within sanity bounds.
|
|
33
|
+
* @param {Headers | Record<string, string> | null | undefined} headers
|
|
34
|
+
* @param {string} headerName
|
|
35
|
+
* @param {{ maxTrusted: number }} opts
|
|
36
|
+
* @returns {number | null}
|
|
37
|
+
*/
|
|
38
|
+
export function parseTotalCountHeader(headers, headerName, { maxTrusted }) {
|
|
39
|
+
if (headers == null || maxTrusted <= 0) return null;
|
|
40
|
+
const read =
|
|
41
|
+
typeof headers.get === 'function'
|
|
42
|
+
? (name) => headers.get(name)
|
|
43
|
+
: (name) => headers[name] ?? headers[String(name).toLowerCase()];
|
|
44
|
+
const raw = read(headerName) ?? read(String(headerName).toLowerCase());
|
|
45
|
+
if (raw == null || raw === '') return null;
|
|
46
|
+
const n = Number.parseInt(String(raw).trim(), 10);
|
|
47
|
+
if (!Number.isFinite(n) || n <= 0 || n > maxTrusted) return null;
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fast path applies to cr inventory retain_max slices, not idempotency scans.
|
|
53
|
+
* @param {{ retain_max?: number | null, limit?: number | null }} opts
|
|
54
|
+
*/
|
|
55
|
+
export function isCrInventoryFastPathEligible(opts = {}) {
|
|
56
|
+
return (
|
|
57
|
+
opts.retain_max != null &&
|
|
58
|
+
Number.isInteger(Number(opts.retain_max)) &&
|
|
59
|
+
Number(opts.retain_max) > 0
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* When forge order is authoritative, skip client-side number reordering.
|
|
65
|
+
* @param {string} sliceSort
|
|
66
|
+
*/
|
|
67
|
+
export function forgeOrderAuthoritative(sliceSort) {
|
|
68
|
+
return sliceSort === 'recent_update' || sliceSort === 'recent_created';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fast path requires the first page to contain the expected item count.
|
|
73
|
+
* @param {number} totalCount
|
|
74
|
+
* @param {number} requestLimit
|
|
75
|
+
* @param {number} bodyLength
|
|
76
|
+
*/
|
|
77
|
+
export function validateFastPathPageLength(totalCount, requestLimit, bodyLength) {
|
|
78
|
+
const expected = Math.min(totalCount, requestLimit);
|
|
79
|
+
return bodyLength === expected;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Number sorts need full local reorder; skip fast path when total exceeds retain_max.
|
|
84
|
+
* @param {number} totalCount
|
|
85
|
+
* @param {number} retainMax
|
|
86
|
+
* @param {string} sliceSort
|
|
87
|
+
*/
|
|
88
|
+
export function isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) {
|
|
89
|
+
if (sliceSort !== 'number_asc' && sliceSort !== 'number_desc') return true;
|
|
90
|
+
return totalCount <= retainMax;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prefer forge header/search total over summed pagination lengths on fallback.
|
|
95
|
+
* @param {number | null | undefined} trustedTotal
|
|
96
|
+
* @param {number} summedCount
|
|
97
|
+
*/
|
|
98
|
+
export function resolvePaginatedEntryCount(trustedTotal, summedCount) {
|
|
99
|
+
if (trustedTotal != null && Number.isInteger(trustedTotal) && trustedTotal > 0) {
|
|
100
|
+
return trustedTotal;
|
|
101
|
+
}
|
|
102
|
+
return summedCount;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* When forge total is trusted but pagination walked fewer items, mark truncated.
|
|
107
|
+
* @param {{ listTruncated: boolean, trustedTotalCount?: number | null, walkedCount: number, fullCollect?: boolean }} opts
|
|
108
|
+
*/
|
|
109
|
+
export function resolveListTruncatedWithTrustedTotal({
|
|
110
|
+
listTruncated,
|
|
111
|
+
trustedTotalCount,
|
|
112
|
+
walkedCount,
|
|
113
|
+
fullCollect = false,
|
|
114
|
+
}) {
|
|
115
|
+
if (
|
|
116
|
+
trustedTotalCount != null &&
|
|
117
|
+
Number.isInteger(trustedTotalCount) &&
|
|
118
|
+
trustedTotalCount > 0 &&
|
|
119
|
+
walkedCount < trustedTotalCount
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return listTruncated;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gitea recent_created uses oldest sort; fast path only when total fits retain_max.
|
|
128
|
+
* @param {number} totalCount
|
|
129
|
+
* @param {number} retainMax
|
|
130
|
+
* @param {string} sliceSort
|
|
131
|
+
* @param {string} providerId
|
|
132
|
+
*/
|
|
133
|
+
export function isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, providerId) {
|
|
134
|
+
if (sliceSort !== 'recent_created' || providerId !== 'gitea-api') return true;
|
|
135
|
+
return totalCount <= retainMax;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Last page index for Gitea sort=oldest when fetching globally newest-created slice.
|
|
140
|
+
* @param {number} totalCount
|
|
141
|
+
* @param {number} pageSize
|
|
142
|
+
*/
|
|
143
|
+
export function giteaRecentCreatedTailPage(totalCount, pageSize) {
|
|
144
|
+
if (!Number.isInteger(totalCount) || totalCount <= 0) return 1;
|
|
145
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0) return 1;
|
|
146
|
+
return Math.max(1, Math.ceil(totalCount / pageSize));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Number sorts need full local reorder when total exceeds retain_max.
|
|
151
|
+
* @param {number} totalCount
|
|
152
|
+
* @param {number} retainMax
|
|
153
|
+
* @param {string} sliceSort
|
|
154
|
+
*/
|
|
155
|
+
export function isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort) {
|
|
156
|
+
if (forgeOrderAuthoritative(sliceSort)) return false;
|
|
157
|
+
return !isNumberSortFastPathEligible(totalCount, retainMax, sliceSort);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gitea sort=oldest returns oldest-first; recent_created needs newest-first.
|
|
162
|
+
* @param {unknown[]} items
|
|
163
|
+
* @param {string} sliceSort
|
|
164
|
+
*/
|
|
165
|
+
export function prepareGiteaOpenPullPageItems(items, sliceSort) {
|
|
166
|
+
if (sliceSort !== 'recent_created' || !Array.isArray(items)) return items;
|
|
167
|
+
return items.slice().reverse();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {unknown[]} items
|
|
172
|
+
* @param {(item: unknown) => number | null | undefined} getNumber
|
|
173
|
+
* @param {string} sliceSort
|
|
174
|
+
* @returns {number[]}
|
|
175
|
+
*/
|
|
176
|
+
export function orderOpenPullNumbers(items, getNumber, sliceSort) {
|
|
177
|
+
const numbers = items
|
|
178
|
+
.map(getNumber)
|
|
179
|
+
.filter((number) => Number.isInteger(number));
|
|
180
|
+
if (forgeOrderAuthoritative(sliceSort)) return numbers;
|
|
181
|
+
numbers.sort((a, b) => (sliceSort === 'number_desc' ? b - a : a - b));
|
|
182
|
+
return numbers;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ totalCount?: number | null, numbers: number[], listTruncated: boolean, sliceSort?: string }} meta
|
|
187
|
+
*/
|
|
188
|
+
export function buildOpenPullListMeta({ totalCount, numbers, listTruncated, sliceSort }) {
|
|
189
|
+
return {
|
|
190
|
+
numbers,
|
|
191
|
+
list_truncated: listTruncated,
|
|
192
|
+
...(totalCount != null ? { entry_count: totalCount } : {}),
|
|
193
|
+
...(sliceSort ? { slice_sort: sliceSort } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Gitea query params for normalized slice sort.
|
|
199
|
+
* @param {string} sliceSort
|
|
200
|
+
*/
|
|
201
|
+
export function giteaOpenPullSortQuery(sliceSort) {
|
|
202
|
+
switch (sliceSort) {
|
|
203
|
+
case 'recent_update':
|
|
204
|
+
return { sort: 'recentupdate' };
|
|
205
|
+
case 'recent_created':
|
|
206
|
+
return { sort: 'oldest' };
|
|
207
|
+
default:
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* GitLab query params for normalized slice sort.
|
|
214
|
+
* @param {string} sliceSort
|
|
215
|
+
*/
|
|
216
|
+
export function gitlabOpenPullSortQuery(sliceSort) {
|
|
217
|
+
switch (sliceSort) {
|
|
218
|
+
case 'recent_update':
|
|
219
|
+
return { order_by: 'updated_at', sort: 'desc' };
|
|
220
|
+
case 'recent_created':
|
|
221
|
+
return { order_by: 'created_at', sort: 'desc' };
|
|
222
|
+
case 'number_desc':
|
|
223
|
+
return { order_by: 'created_at', sort: 'desc' };
|
|
224
|
+
default:
|
|
225
|
+
return { order_by: 'created_at', sort: 'asc' };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* GitHub query params for normalized slice sort on list pulls.
|
|
231
|
+
* @param {string} sliceSort
|
|
232
|
+
*/
|
|
233
|
+
export function githubOpenPullSortQuery(sliceSort) {
|
|
234
|
+
switch (sliceSort) {
|
|
235
|
+
case 'recent_update':
|
|
236
|
+
return { sort: 'updated', direction: 'desc' };
|
|
237
|
+
case 'recent_created':
|
|
238
|
+
return { sort: 'created', direction: 'desc' };
|
|
239
|
+
case 'number_desc':
|
|
240
|
+
return { sort: 'created', direction: 'desc' };
|
|
241
|
+
default:
|
|
242
|
+
return { sort: 'created', direction: 'asc' };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Append URLSearchParams for forge sort query entries.
|
|
248
|
+
* @param {string} path
|
|
249
|
+
* @param {Record<string, string>} query
|
|
250
|
+
*/
|
|
251
|
+
export function appendSortQuery(path, query) {
|
|
252
|
+
if (!query || Object.keys(query).length === 0) return path;
|
|
253
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
254
|
+
const params = new URLSearchParams(query);
|
|
255
|
+
return `${path}${sep}${params.toString()}`;
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob allowlist matching for autonomous Observer auto-merge scope checks.
|
|
3
|
+
* Supports `**`, `*`, and literal path segments (e.g. README.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function globToRegExp(glob) {
|
|
7
|
+
let pattern = '';
|
|
8
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
9
|
+
const ch = glob[i];
|
|
10
|
+
if (ch === '*' && glob[i + 1] === '*') {
|
|
11
|
+
pattern += '.*';
|
|
12
|
+
i += 1;
|
|
13
|
+
if (glob[i + 1] === '/') i += 1;
|
|
14
|
+
} else if (ch === '*') {
|
|
15
|
+
pattern += '[^/]*';
|
|
16
|
+
} else if (/[+?^${}()|[\]\\]/.test(ch)) {
|
|
17
|
+
pattern += `\\${ch}`;
|
|
18
|
+
} else {
|
|
19
|
+
pattern += ch;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return new RegExp(`^${pattern}$`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} glob
|
|
27
|
+
* @param {string} filePath
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function matchPathAllowlist(glob, filePath) {
|
|
31
|
+
if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
33
|
+
return globToRegExp(glob).test(normalized);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} allowedPaths
|
|
38
|
+
* @param {string} filePath
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isPathAllowed(allowedPaths, filePath) {
|
|
42
|
+
if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
|
|
43
|
+
return allowedPaths.some((glob) => matchPathAllowlist(glob, filePath));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string[]} allowedPaths
|
|
48
|
+
* @param {string[]} changedPaths
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function pathsOutsideAllowlist(allowedPaths, changedPaths) {
|
|
52
|
+
if (!Array.isArray(changedPaths)) return [];
|
|
53
|
+
return changedPaths.filter((filePath) => !isPathAllowed(allowedPaths, filePath));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string[]} allowedPaths
|
|
58
|
+
* @param {string[]} changedPaths
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function allPathsAllowed(allowedPaths, changedPaths) {
|
|
62
|
+
return pathsOutsideAllowlist(allowedPaths, changedPaths).length === 0;
|
|
63
|
+
}
|
package/write-config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
|
|
4
|
+
/** Canonical v1 consumer write command ids (schema + gate single source). */
|
|
5
|
+
export const WRITE_COMMAND_IDS = Object.freeze(['cr_open']);
|
|
6
|
+
|
|
7
|
+
export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
|
|
8
|
+
|
|
9
|
+
/** @deprecated use WRITE_COMMAND_IDS */
|
|
10
|
+
export const CONFIGURED_WRITE_COMMANDS = WRITE_COMMAND_IDS;
|
|
11
|
+
|
|
12
|
+
export function isWriteCommandConfigured(config, commandName) {
|
|
13
|
+
if (!writeCommandSchema.safeParse(commandName).success) return false;
|
|
14
|
+
const allowed = config?.write_commands;
|
|
15
|
+
return Array.isArray(allowed) && allowed.includes(commandName);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertWriteCommandConfigured(config, commandName) {
|
|
19
|
+
if (!writeCommandSchema.safeParse(commandName).success) {
|
|
20
|
+
throw Object.assign(new Error(`Unknown write command: ${commandName}`), {
|
|
21
|
+
forgeError: forgeError(
|
|
22
|
+
ERROR_CODES.INVALID_ARGS,
|
|
23
|
+
`Unknown write command "${commandName}"; supported: ${WRITE_COMMAND_IDS.join(', ')}`,
|
|
24
|
+
),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (isWriteCommandConfigured(config, commandName)) return;
|
|
28
|
+
throw Object.assign(new Error(`Write command not configured: ${commandName}`), {
|
|
29
|
+
forgeError: forgeError(
|
|
30
|
+
ERROR_CODES.WRITE_NOT_CONFIGURED,
|
|
31
|
+
`Add "${commandName}" to write_commands in .remogram.json to enable this command`,
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
}
|