@remogram/core 0.1.0-beta.0 → 0.1.0-beta.10
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 +73 -0
- package/branch-protection.js +155 -0
- package/caps.js +185 -6
- package/change-request-merge-execute.js +148 -0
- package/check-diagnostics.js +92 -0
- package/check-pagination.js +216 -0
- package/config-schema.js +21 -0
- package/contracts/envelope.js +26 -2
- package/contracts/errors.js +14 -2
- package/contracts/forge-error-fields.js +124 -0
- package/contracts/observer-fact-inventory.js +64 -0
- package/contracts/semantic-diff-facts.js +168 -0
- package/cr-comments.js +93 -0
- package/cr-files.js +62 -0
- package/cr-inventory-cursor.js +64 -0
- package/cr-inventory.js +136 -0
- package/cr-open.js +38 -0
- package/effective-write-policy.js +68 -0
- package/forge-changes-cursor.js +88 -0
- package/forge-changes.js +181 -0
- package/forge-identity.js +42 -0
- package/git-args.js +19 -0
- package/git-local.js +22 -0
- package/http.js +83 -4
- package/idempotency.js +69 -0
- package/index.js +266 -4
- package/issue-open.js +50 -0
- package/merge-blockers.js +68 -0
- package/merge-plan-forge.js +63 -0
- package/merge-plan.js +82 -0
- package/merge-policy.js +55 -0
- package/open-pull-list.js +256 -0
- package/operator-config.js +260 -0
- package/package.json +1 -1
- package/path-allowlist.js +114 -0
- package/pr-head-reconcile.js +38 -0
- package/provider-health.js +93 -0
- package/ref-inventory.js +98 -0
- package/resolve.js +53 -4
- package/status-set.js +92 -0
- package/stub-provider.js +11 -8
- package/whoami.js +114 -0
- package/write-config.js +63 -0
- package/write-field-policy.js +93 -0
- package/write-readiness.js +91 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { writeCommandSchema } from './write-config.js';
|
|
6
|
+
import { forgeWritePolicySchema } from './config-schema.js';
|
|
7
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
8
|
+
import { normalizedForgeOrigin } from './forge-identity.js';
|
|
9
|
+
|
|
10
|
+
export const REMOGRAM_OPERATOR_CONFIG_ENV = 'REMOGRAM_OPERATOR_CONFIG';
|
|
11
|
+
export const MAX_OPERATOR_CONFIG_BYTES = 8192;
|
|
12
|
+
|
|
13
|
+
const FORBIDDEN_CONFIG_KEYS = new Set(['token', 'password', 'secret', 'api_key', 'apiKey']);
|
|
14
|
+
|
|
15
|
+
const repoSegmentSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.refine((s) => !/[/%]/.test(s) && !s.includes('..') && !s.includes('/'), {
|
|
19
|
+
message: 'owner/repo must not contain /, .., or %',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const operatorBindSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
provider: z.enum(['gitea-api', 'github-api', 'gitlab-api', 'gitea-tea', 'github-gh']),
|
|
25
|
+
remote: z.string().min(1),
|
|
26
|
+
owner: repoSegmentSchema,
|
|
27
|
+
repo: repoSegmentSchema,
|
|
28
|
+
baseUrl: z.string().url().optional(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
|
|
32
|
+
export const operatorConfigSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
version: z.literal('1'),
|
|
35
|
+
bind: operatorBindSchema,
|
|
36
|
+
write_commands: z.array(writeCommandSchema).min(1),
|
|
37
|
+
forge_write_policy: forgeWritePolicySchema.optional(),
|
|
38
|
+
})
|
|
39
|
+
.strict();
|
|
40
|
+
|
|
41
|
+
function xdgConfigHome() {
|
|
42
|
+
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultOperatorConfigPath(forgeContext) {
|
|
46
|
+
const { config, parsed } = forgeContext;
|
|
47
|
+
const owner = parsed?.owner ?? config.owner;
|
|
48
|
+
const repo = parsed?.repo ?? config.repo;
|
|
49
|
+
const safeOwner = String(owner).replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
50
|
+
const safeRepo = String(repo).replace(/[^a-zA-Z0-9._-]+/g, '_');
|
|
51
|
+
const filename = `${config.provider}-${safeOwner}-${safeRepo}.json`;
|
|
52
|
+
return join(xdgConfigHome(), 'remogram', 'operator', filename);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function discoverOperatorConfigPath(options = {}) {
|
|
56
|
+
const cliPath = options.cliPath ?? options.operatorConfigPath ?? null;
|
|
57
|
+
if (cliPath) {
|
|
58
|
+
return { path: cliPath, discovered_via: 'cli_flag' };
|
|
59
|
+
}
|
|
60
|
+
const envPath = process.env[REMOGRAM_OPERATOR_CONFIG_ENV];
|
|
61
|
+
if (envPath) {
|
|
62
|
+
return { path: envPath, discovered_via: 'env' };
|
|
63
|
+
}
|
|
64
|
+
if (options.forgeContext) {
|
|
65
|
+
const defaultPath = defaultOperatorConfigPath(options.forgeContext);
|
|
66
|
+
if (existsSync(defaultPath)) {
|
|
67
|
+
return { path: defaultPath, discovered_via: 'xdg_default' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { path: null, discovered_via: 'none' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertForbiddenKeys(obj, pathPrefix = '') {
|
|
74
|
+
if (!obj || typeof obj !== 'object') return;
|
|
75
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
76
|
+
if (FORBIDDEN_CONFIG_KEYS.has(key)) {
|
|
77
|
+
throw new Error(`Forbidden key "${pathPrefix}${key}" in operator config`);
|
|
78
|
+
}
|
|
79
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
80
|
+
assertForbiddenKeys(value, `${pathPrefix}${key}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertPathSafeToRead(configPath) {
|
|
86
|
+
if (!configPath || typeof configPath !== 'string') {
|
|
87
|
+
throw new Error('Operator config path is missing');
|
|
88
|
+
}
|
|
89
|
+
if (configPath.includes('\0')) {
|
|
90
|
+
throw new Error('Operator config path contains invalid characters');
|
|
91
|
+
}
|
|
92
|
+
const expanded = configPath.startsWith('~') ? join(homedir(), configPath.slice(1)) : configPath;
|
|
93
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
94
|
+
if (!existsSync(absolute)) {
|
|
95
|
+
throw new Error(`Operator config not found: ${configPath}`);
|
|
96
|
+
}
|
|
97
|
+
let stat;
|
|
98
|
+
try {
|
|
99
|
+
stat = statSync(absolute);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`Operator config is not readable: ${configPath}`);
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
throw new Error(`Operator config is not a regular file: ${configPath}`);
|
|
105
|
+
}
|
|
106
|
+
if (stat.size > MAX_OPERATOR_CONFIG_BYTES) {
|
|
107
|
+
throw new Error(`Operator config exceeds ${MAX_OPERATOR_CONFIG_BYTES} bytes`);
|
|
108
|
+
}
|
|
109
|
+
if ((stat.mode & 0o002) !== 0) {
|
|
110
|
+
throw new Error('Operator config is world-writable');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
realpathSync(absolute);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(`Operator config path could not be resolved: ${configPath}`);
|
|
116
|
+
}
|
|
117
|
+
return absolute;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseOperatorConfigFile(raw) {
|
|
121
|
+
let parsed;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(raw);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error('Invalid JSON in operator config');
|
|
126
|
+
}
|
|
127
|
+
assertForbiddenKeys(parsed);
|
|
128
|
+
return operatorConfigSchema.parse(parsed);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function loadOperatorConfigFile(configPath) {
|
|
132
|
+
const absolute = assertPathSafeToRead(configPath);
|
|
133
|
+
const raw = readFileSync(absolute, 'utf8');
|
|
134
|
+
const config = parseOperatorConfigFile(raw);
|
|
135
|
+
return { path: absolute, config };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function assertOperatorBindMatches(operatorConfig, forgeContext, meta = {}) {
|
|
139
|
+
const bind = operatorConfig.bind;
|
|
140
|
+
const { config, parsed } = forgeContext;
|
|
141
|
+
const owner = parsed?.owner ?? config.owner;
|
|
142
|
+
const repo = parsed?.repo ?? config.repo;
|
|
143
|
+
const ctxOrigin = forgeContext.baseUrl ?? normalizedForgeOrigin(config);
|
|
144
|
+
|
|
145
|
+
if (bind.provider !== config.provider) {
|
|
146
|
+
throw bindMismatch({
|
|
147
|
+
field: 'provider',
|
|
148
|
+
expected: config.provider,
|
|
149
|
+
actual: bind.provider,
|
|
150
|
+
message: `operator bind provider ${bind.provider} does not match repo config ${config.provider}`,
|
|
151
|
+
meta,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (bind.remote !== config.remote) {
|
|
155
|
+
throw bindMismatch({
|
|
156
|
+
field: 'remote',
|
|
157
|
+
expected: config.remote,
|
|
158
|
+
actual: bind.remote,
|
|
159
|
+
message: `operator bind remote ${bind.remote} does not match repo config ${config.remote}`,
|
|
160
|
+
meta,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (bind.owner !== owner || bind.repo !== repo) {
|
|
164
|
+
throw bindMismatch({
|
|
165
|
+
field: 'repo',
|
|
166
|
+
expected: `${owner}/${repo}`,
|
|
167
|
+
actual: `${bind.owner}/${bind.repo}`,
|
|
168
|
+
message: `operator bind repo ${bind.owner}/${bind.repo} does not match forge identity ${owner}/${repo}`,
|
|
169
|
+
meta,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (bind.baseUrl) {
|
|
173
|
+
const bindOrigin = normalizedForgeOrigin({ baseUrl: bind.baseUrl });
|
|
174
|
+
if (bindOrigin !== ctxOrigin) {
|
|
175
|
+
throw bindMismatch({
|
|
176
|
+
field: 'baseUrl',
|
|
177
|
+
expected: ctxOrigin,
|
|
178
|
+
actual: bindOrigin,
|
|
179
|
+
message: `operator bind baseUrl ${bind.baseUrl} does not match forge baseUrl ${ctxOrigin}`,
|
|
180
|
+
meta,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function bindRemediation(meta = {}) {
|
|
187
|
+
const via = meta.discovered_via ?? 'unknown';
|
|
188
|
+
const pathHint = meta.path ? ` at ${meta.path}` : '';
|
|
189
|
+
if (via === 'env') {
|
|
190
|
+
return (
|
|
191
|
+
`Check REMOGRAM_OPERATOR_CONFIG${pathHint}: update bind to match this repo's .remogram.json `
|
|
192
|
+
+ 'or unset the env var if the overlay is stale.'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (via === 'xdg_default') {
|
|
196
|
+
return (
|
|
197
|
+
`Update the XDG operator overlay${pathHint} bind block to match this repo's .remogram.json `
|
|
198
|
+
+ 'or remove the file if it targets a different repository.'
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
`Update operator config bind${pathHint} to match this repo's .remogram.json `
|
|
203
|
+
+ '(provider, remote, owner, repo, baseUrl).'
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function bindMismatch({ field, expected, actual, message, meta = {} }) {
|
|
208
|
+
const remediation = bindRemediation(meta);
|
|
209
|
+
const fullMessage = `${message}. ${remediation}`;
|
|
210
|
+
return Object.assign(new Error(fullMessage), {
|
|
211
|
+
forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, fullMessage, null, {
|
|
212
|
+
reason: 'operator_bind_mismatch',
|
|
213
|
+
field,
|
|
214
|
+
expected,
|
|
215
|
+
actual,
|
|
216
|
+
discovered_via: meta.discovered_via ?? null,
|
|
217
|
+
operator_config_path: meta.path ?? null,
|
|
218
|
+
remediation,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {{ cliPath?: string | null, forgeContext: object }} options
|
|
225
|
+
* @returns {{ config: object | null, meta: object, error?: object }}
|
|
226
|
+
*/
|
|
227
|
+
export function loadOperatorConfig(options = {}) {
|
|
228
|
+
const discovery = discoverOperatorConfigPath(options);
|
|
229
|
+
const meta = {
|
|
230
|
+
discovered_via: discovery.discovered_via,
|
|
231
|
+
path: discovery.path ? sanitizePathForEmit(discovery.path) : null,
|
|
232
|
+
bind_ok: null,
|
|
233
|
+
};
|
|
234
|
+
if (!discovery.path) {
|
|
235
|
+
return { config: null, meta, error: null };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const loaded = loadOperatorConfigFile(discovery.path);
|
|
239
|
+
assertOperatorBindMatches(loaded.config, options.forgeContext, meta);
|
|
240
|
+
meta.path = sanitizePathForEmit(loaded.path);
|
|
241
|
+
meta.bind_ok = true;
|
|
242
|
+
return { config: loaded.config, meta, error: null };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
meta.bind_ok = false;
|
|
245
|
+
const forgeErr = err.forgeError || forgeError(ERROR_CODES.CONFIG_INVALID, err.message);
|
|
246
|
+
return {
|
|
247
|
+
config: null,
|
|
248
|
+
meta,
|
|
249
|
+
error: forgeErr,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sanitizePathForEmit(configPath) {
|
|
255
|
+
const home = homedir();
|
|
256
|
+
if (configPath.startsWith(home)) {
|
|
257
|
+
return `~${configPath.slice(home.length)}`;
|
|
258
|
+
}
|
|
259
|
+
return configPath;
|
|
260
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Collapse `.` / `..` segments; reject absolute paths and repo-root escape.
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
* @returns {string|null}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRepoRelativePath(filePath) {
|
|
12
|
+
if (typeof filePath !== 'string') return null;
|
|
13
|
+
let path = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
14
|
+
if (path === '') return null;
|
|
15
|
+
if (path.startsWith('/')) return null;
|
|
16
|
+
if (path === '..' || path.startsWith('../')) return null;
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (part === '' || part === '.') continue;
|
|
21
|
+
if (part === '..') {
|
|
22
|
+
if (out.length > 0) out.pop();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(part);
|
|
26
|
+
}
|
|
27
|
+
return out.join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function changedPathHasDotDotSegment(filePath) {
|
|
31
|
+
if (typeof filePath !== 'string') return false;
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
33
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return normalized.split('/').some((segment) => segment === '..');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a forge changed-path list for allowlist scope; null if any path is unnormalizable.
|
|
41
|
+
* @param {unknown} changedPaths
|
|
42
|
+
* @returns {string[]|null}
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeChangedPathList(changedPaths) {
|
|
45
|
+
if (!Array.isArray(changedPaths)) return null;
|
|
46
|
+
const normalized = [];
|
|
47
|
+
for (const filePath of changedPaths) {
|
|
48
|
+
if (changedPathHasDotDotSegment(filePath)) return null;
|
|
49
|
+
const repoPath = normalizeRepoRelativePath(filePath);
|
|
50
|
+
if (repoPath == null) return null;
|
|
51
|
+
normalized.push(repoPath);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function globToRegExp(glob) {
|
|
57
|
+
let pattern = '';
|
|
58
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
59
|
+
const ch = glob[i];
|
|
60
|
+
if (ch === '*' && glob[i + 1] === '*') {
|
|
61
|
+
pattern += '.*';
|
|
62
|
+
i += 1;
|
|
63
|
+
if (glob[i + 1] === '/') i += 1;
|
|
64
|
+
} else if (ch === '*') {
|
|
65
|
+
pattern += '[^/]*';
|
|
66
|
+
} else if (/[+?^${}()|[\]\\]/.test(ch)) {
|
|
67
|
+
pattern += `\\${ch}`;
|
|
68
|
+
} else {
|
|
69
|
+
pattern += ch;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return new RegExp(`^${pattern}$`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} glob
|
|
77
|
+
* @param {string} filePath
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function matchPathAllowlist(glob, filePath) {
|
|
81
|
+
if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
|
|
82
|
+
const normalized = normalizeRepoRelativePath(filePath);
|
|
83
|
+
if (normalized == null) return false;
|
|
84
|
+
return globToRegExp(glob).test(normalized);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string[]} allowedPaths
|
|
89
|
+
* @param {string} filePath
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function isPathAllowed(allowedPaths, filePath) {
|
|
93
|
+
if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
|
|
94
|
+
return allowedPaths.some((glob) => matchPathAllowlist(glob, filePath));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string[]} allowedPaths
|
|
99
|
+
* @param {string[]} changedPaths
|
|
100
|
+
* @returns {string[]}
|
|
101
|
+
*/
|
|
102
|
+
export function pathsOutsideAllowlist(allowedPaths, changedPaths) {
|
|
103
|
+
if (!Array.isArray(changedPaths)) return [];
|
|
104
|
+
return changedPaths.filter((filePath) => !isPathAllowed(allowedPaths, filePath));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string[]} allowedPaths
|
|
109
|
+
* @param {string[]} changedPaths
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function allPathsAllowed(allowedPaths, changedPaths) {
|
|
113
|
+
return pathsOutsideAllowlist(allowedPaths, changedPaths).length === 0;
|
|
114
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { assertGitRemote } from './git-args.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
import { gitRevParse } from './git-local.js';
|
|
4
|
+
|
|
5
|
+
export const STALE_HEAD_MESSAGE =
|
|
6
|
+
'Forge PR head SHA diverges from locally resolved git; fetch or refresh before trusting forge_source_sha';
|
|
7
|
+
|
|
8
|
+
export function localHeadShaForPr(cwd, remoteName, headRef) {
|
|
9
|
+
if (!headRef) return null;
|
|
10
|
+
assertGitRemote(remoteName, 'remote');
|
|
11
|
+
const trackingRef = `${remoteName}/${headRef}`;
|
|
12
|
+
return gitRevParse(cwd, trackingRef) ?? gitRevParse(cwd, headRef);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function staleHeadDetails(cwd, remoteName, headRef, forgeHeadSha) {
|
|
16
|
+
if (!headRef || !forgeHeadSha) return null;
|
|
17
|
+
const localHeadSha = localHeadShaForPr(cwd, remoteName, headRef);
|
|
18
|
+
if (!localHeadSha) return null;
|
|
19
|
+
if (localHeadSha.toLowerCase() === String(forgeHeadSha).toLowerCase()) return null;
|
|
20
|
+
return {
|
|
21
|
+
forge_source_branch_ref: headRef,
|
|
22
|
+
forge_source_sha: forgeHeadSha,
|
|
23
|
+
local_head_sha: localHeadSha,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function staleHeadForgeError() {
|
|
28
|
+
return forgeError(ERROR_CODES.STALE_HEAD, STALE_HEAD_MESSAGE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function throwIfStaleHeadByNumber(ctx, packetType, body, headRef, forgeHeadSha) {
|
|
32
|
+
const details = staleHeadDetails(ctx.cwd, ctx.config?.remote ?? ctx.remoteName, headRef, forgeHeadSha);
|
|
33
|
+
if (!details) return;
|
|
34
|
+
const err = new Error(STALE_HEAD_MESSAGE);
|
|
35
|
+
err.forgeError = staleHeadForgeError();
|
|
36
|
+
err.staleHeadPacket = { type: packetType, body: { ...body, ...details } };
|
|
37
|
+
throw err;
|
|
38
|
+
}
|