@remogram/core 0.1.0-beta.3 → 0.1.0-beta.5
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 +25 -4
- package/branch-protection.js +155 -0
- package/caps.js +58 -0
- package/check-pagination.js +94 -17
- package/config-schema.js +2 -0
- package/contracts/envelope.js +18 -2
- package/contracts/errors.js +10 -2
- package/contracts/forge-error-fields.js +97 -0
- package/contracts/observer-fact-inventory.js +7 -5
- package/contracts/semantic-diff-facts.js +30 -2
- package/cr-comments.js +93 -0
- package/cr-files.js +62 -0
- package/cr-inventory.js +19 -12
- package/cr-open.js +33 -0
- package/forge-changes.js +181 -0
- package/git-local.js +12 -0
- package/index.js +104 -2
- package/merge-blockers.js +19 -1
- package/merge-plan.js +37 -0
- package/open-pull-list.js +256 -0
- package/package.json +1 -1
- package/path-allowlist.js +63 -0
- package/status-set.js +87 -0
- package/stub-provider.js +7 -0
- package/whoami.js +114 -0
- package/write-config.js +43 -0
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,37 @@
|
|
|
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 (Array.isArray(opts.changed_paths)) {
|
|
16
|
+
return { allowed_paths: allowedPaths, changed_paths: opts.changed_paths };
|
|
17
|
+
}
|
|
18
|
+
if (!view.base_sha || !view.head_sha) {
|
|
19
|
+
return { allowed_paths: allowedPaths, changed_paths: null };
|
|
20
|
+
}
|
|
21
|
+
const changedPaths = gitDiffNameOnly(ctx.cwd, view.base_sha, view.head_sha);
|
|
22
|
+
return { allowed_paths: allowedPaths, changed_paths: changedPaths };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
26
|
+
return {
|
|
27
|
+
pr_number: view.pr_number,
|
|
28
|
+
mergeability: view.mergeability,
|
|
29
|
+
checks_conclusion: checks.check_conclusion,
|
|
30
|
+
blockers: mergeBlockersFromFacts(view, checks, pathScope),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildMergePlanBodyFromFacts(ctx, view, checks, opts = {}) {
|
|
35
|
+
const pathScope = resolveMergePlanPathScope(ctx, view, opts);
|
|
36
|
+
return buildMergePlanBody(view, checks, pathScope);
|
|
37
|
+
}
|
|
@@ -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/status-set.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { sanitizeField, sanitizeUrl } from './caps.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
|
|
4
|
+
/** Supported commit status states (Gitea/GitHub parity). */
|
|
5
|
+
export const STATUS_SET_STATES = Object.freeze(['pending', 'success', 'failure', 'error']);
|
|
6
|
+
|
|
7
|
+
const STATUS_SET_STATE_SET = new Set(STATUS_SET_STATES);
|
|
8
|
+
|
|
9
|
+
const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/;
|
|
10
|
+
|
|
11
|
+
export function assertCommitSha(sha, label = 'sha') {
|
|
12
|
+
const value = String(sha ?? '').trim();
|
|
13
|
+
if (!FULL_SHA_RE.test(value)) {
|
|
14
|
+
throw Object.assign(new Error(`Invalid ${label}`), {
|
|
15
|
+
forgeError: forgeError(
|
|
16
|
+
ERROR_CODES.INVALID_ARGS,
|
|
17
|
+
`${label} must be a 40-character hexadecimal commit SHA`,
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return value.toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeStatusSetState(state) {
|
|
25
|
+
const normalized = String(state ?? '').toLowerCase();
|
|
26
|
+
if (STATUS_SET_STATE_SET.has(normalized)) return normalized;
|
|
27
|
+
if (normalized === 'pass') return 'success';
|
|
28
|
+
if (normalized === 'fail') return 'failure';
|
|
29
|
+
throw Object.assign(new Error('Invalid status state'), {
|
|
30
|
+
forgeError: forgeError(
|
|
31
|
+
ERROR_CODES.INVALID_ARGS,
|
|
32
|
+
`state must be one of: ${STATUS_SET_STATES.join(', ')}`,
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseStatusSetArgs({ sha, context, state, target_url, description }) {
|
|
38
|
+
const parsedSha = assertCommitSha(sha, '--sha');
|
|
39
|
+
if (context == null || String(context).trim() === '') {
|
|
40
|
+
throw Object.assign(new Error('--context required'), {
|
|
41
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--context required for status set'),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (state == null || String(state).trim() === '') {
|
|
45
|
+
throw Object.assign(new Error('--state required'), {
|
|
46
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--state required for status set'),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const parsed = {
|
|
50
|
+
sha: parsedSha,
|
|
51
|
+
context: sanitizeField(String(context)),
|
|
52
|
+
state: normalizeStatusSetState(state),
|
|
53
|
+
};
|
|
54
|
+
if (target_url != null && String(target_url).trim() !== '') {
|
|
55
|
+
parsed.target_url = sanitizeUrl(String(target_url));
|
|
56
|
+
}
|
|
57
|
+
if (description != null && String(description).trim() !== '') {
|
|
58
|
+
parsed.description = sanitizeField(String(description));
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalize forge commit status POST/GET response into commit_status_set body fields. */
|
|
64
|
+
export function buildCommitStatusSetBody(
|
|
65
|
+
response,
|
|
66
|
+
args,
|
|
67
|
+
{ reusedExisting = false } = {},
|
|
68
|
+
) {
|
|
69
|
+
const state = normalizeStatusSetState(response?.status ?? response?.state ?? args.state);
|
|
70
|
+
const body = {
|
|
71
|
+
sha: args.sha,
|
|
72
|
+
context: sanitizeField(args.context),
|
|
73
|
+
state,
|
|
74
|
+
};
|
|
75
|
+
const description = response?.description ?? args.description;
|
|
76
|
+
if (description != null && String(description).trim() !== '') {
|
|
77
|
+
body.description = sanitizeField(String(description));
|
|
78
|
+
}
|
|
79
|
+
const targetUrl = response?.target_url ?? args.target_url;
|
|
80
|
+
if (targetUrl != null && String(targetUrl).trim() !== '') {
|
|
81
|
+
body.target_url = sanitizeUrl(String(targetUrl));
|
|
82
|
+
}
|
|
83
|
+
if (reusedExisting) {
|
|
84
|
+
body.reused_existing = true;
|
|
85
|
+
}
|
|
86
|
+
return body;
|
|
87
|
+
}
|
package/stub-provider.js
CHANGED
|
@@ -29,5 +29,12 @@ export function createStubProvider(id) {
|
|
|
29
29
|
prChecks: unsupported,
|
|
30
30
|
mergePlan: unsupported,
|
|
31
31
|
syncPlan: unsupported,
|
|
32
|
+
whoami: unsupported,
|
|
33
|
+
branchProtection: unsupported,
|
|
34
|
+
crFiles: unsupported,
|
|
35
|
+
crComments: unsupported,
|
|
36
|
+
forgeChanges: unsupported,
|
|
37
|
+
crOpen: unsupported,
|
|
38
|
+
statusSet: unsupported,
|
|
32
39
|
};
|
|
33
40
|
}
|
package/whoami.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
|
|
3
|
+
/** Gitea does not expose OAuth scope or token expiry on GET /user. */
|
|
4
|
+
export function unimplementedTokenScopeSignal() {
|
|
5
|
+
return { implemented: false, scopes: null };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function unimplementedTokenExpirySignal() {
|
|
9
|
+
return { implemented: false, expires_at: null };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Restricted Gitea users are read-only; others may write per forge policy. */
|
|
13
|
+
export function normalizeGiteaCanWrite(user) {
|
|
14
|
+
if (user == null || typeof user !== 'object') return false;
|
|
15
|
+
if (user.restricted === true) return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildProviderIdentityBody({
|
|
20
|
+
login,
|
|
21
|
+
can_write,
|
|
22
|
+
token_scope_signal,
|
|
23
|
+
token_expiry_signal,
|
|
24
|
+
}) {
|
|
25
|
+
return {
|
|
26
|
+
login: sanitizeField(login),
|
|
27
|
+
can_write: Boolean(can_write),
|
|
28
|
+
token_scope_signal,
|
|
29
|
+
token_expiry_signal,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildProviderIdentityFromGiteaUser(user) {
|
|
34
|
+
return buildProviderIdentityBody({
|
|
35
|
+
login: user?.login ?? '',
|
|
36
|
+
can_write: normalizeGiteaCanWrite(user),
|
|
37
|
+
token_scope_signal: unimplementedTokenScopeSignal(),
|
|
38
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseGitHubOAuthScopes(headerValue) {
|
|
43
|
+
if (headerValue == null || String(headerValue).trim() === '') {
|
|
44
|
+
return unimplementedTokenScopeSignal();
|
|
45
|
+
}
|
|
46
|
+
const scopes = String(headerValue)
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((scope) => sanitizeField(scope.trim()))
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
if (scopes.length === 0) {
|
|
51
|
+
return unimplementedTokenScopeSignal();
|
|
52
|
+
}
|
|
53
|
+
return { implemented: true, scopes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function githubCanWriteFromScopes(tokenScopeSignal) {
|
|
57
|
+
if (!tokenScopeSignal?.implemented || !Array.isArray(tokenScopeSignal.scopes)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return tokenScopeSignal.scopes.some(
|
|
61
|
+
(scope) => scope === 'repo' || scope === 'public_repo' || scope.startsWith('repo:'),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildProviderIdentityFromGitHubUser(user, oauthScopesHeader) {
|
|
66
|
+
const token_scope_signal = parseGitHubOAuthScopes(oauthScopesHeader);
|
|
67
|
+
return buildProviderIdentityBody({
|
|
68
|
+
login: user?.login ?? '',
|
|
69
|
+
can_write: githubCanWriteFromScopes(token_scope_signal),
|
|
70
|
+
token_scope_signal,
|
|
71
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeGitLabCanWrite(user) {
|
|
76
|
+
if (user == null || typeof user !== 'object') return false;
|
|
77
|
+
if (user.state != null && user.state !== 'active') return false;
|
|
78
|
+
if (user.can_create_project === false) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseGitLabPatSelfSignals(patSelf) {
|
|
83
|
+
if (patSelf == null || typeof patSelf !== 'object') {
|
|
84
|
+
return {
|
|
85
|
+
token_scope_signal: unimplementedTokenScopeSignal(),
|
|
86
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let token_scope_signal = unimplementedTokenScopeSignal();
|
|
90
|
+
if (Array.isArray(patSelf.scopes)) {
|
|
91
|
+
const scopes = patSelf.scopes.map((scope) => sanitizeField(String(scope))).filter(Boolean);
|
|
92
|
+
if (scopes.length > 0) {
|
|
93
|
+
token_scope_signal = { implemented: true, scopes };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const token_expiry_signal =
|
|
97
|
+
'expires_at' in patSelf
|
|
98
|
+
? {
|
|
99
|
+
implemented: true,
|
|
100
|
+
expires_at: patSelf.expires_at == null ? null : sanitizeField(String(patSelf.expires_at)),
|
|
101
|
+
}
|
|
102
|
+
: unimplementedTokenExpirySignal();
|
|
103
|
+
return { token_scope_signal, token_expiry_signal };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildProviderIdentityFromGitLabUser(user, patSelf) {
|
|
107
|
+
const { token_scope_signal, token_expiry_signal } = parseGitLabPatSelfSignals(patSelf);
|
|
108
|
+
return buildProviderIdentityBody({
|
|
109
|
+
login: user?.username ?? user?.login ?? '',
|
|
110
|
+
can_write: normalizeGitLabCanWrite(user),
|
|
111
|
+
token_scope_signal,
|
|
112
|
+
token_expiry_signal,
|
|
113
|
+
});
|
|
114
|
+
}
|
package/write-config.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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', 'status_set']);
|
|
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
|
+
/** Consumer-facing message when a write command is not opted in via write_commands. */
|
|
19
|
+
export function writeNotConfiguredMessage(commandName) {
|
|
20
|
+
return (
|
|
21
|
+
`Command "${commandName}" is not in write_commands; add it to .remogram.json `
|
|
22
|
+
+ 'for Remogram CLI/MCP writes, or use your forge/CI tooling outside Remogram '
|
|
23
|
+
+ '(read commands still work)'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertWriteCommandConfigured(config, commandName) {
|
|
28
|
+
if (!writeCommandSchema.safeParse(commandName).success) {
|
|
29
|
+
throw Object.assign(new Error(`Unknown write command: ${commandName}`), {
|
|
30
|
+
forgeError: forgeError(
|
|
31
|
+
ERROR_CODES.INVALID_ARGS,
|
|
32
|
+
`Unknown write command "${commandName}"; supported: ${WRITE_COMMAND_IDS.join(', ')}`,
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (isWriteCommandConfigured(config, commandName)) return;
|
|
37
|
+
throw Object.assign(new Error(`Write command not configured: ${commandName}`), {
|
|
38
|
+
forgeError: forgeError(
|
|
39
|
+
ERROR_CODES.WRITE_NOT_CONFIGURED,
|
|
40
|
+
writeNotConfiguredMessage(commandName),
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
}
|