@remogram/core 0.1.0-beta.2 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { sanitizeField } from './caps.js';
3
+ import { gitAheadBehind, gitCurrentBranch, gitRevParse } from './git-local.js';
4
+
5
+ const GIT_TIMEOUT_MS = 10_000;
6
+
7
+ function gitExec(cwd, args) {
8
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
9
+ }
10
+
11
+ function listRefs(cwd) {
12
+ try {
13
+ const out = gitExec(cwd, [
14
+ 'for-each-ref',
15
+ '--format=%(refname:short)|%(objectname)|%(refname)',
16
+ 'refs/heads',
17
+ 'refs/remotes',
18
+ ]);
19
+ if (!out) return [];
20
+ return out.split('\n').filter(Boolean).map((line) => {
21
+ const [name, sha, fullRef] = line.split('|');
22
+ const kind = fullRef.startsWith('refs/heads/') ? 'branch' : 'remote';
23
+ return { name, sha, kind };
24
+ });
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function resolveDefaultRef(cwd, remoteName) {
31
+ try {
32
+ const sym = gitExec(cwd, ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`]);
33
+ const prefix = `refs/remotes/${remoteName}/`;
34
+ if (sym.startsWith(prefix)) {
35
+ return sym.slice(prefix.length);
36
+ }
37
+ return sym.replace(/^refs\/remotes\/[^/]+\//, '');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function buildAncestryHints(cwd, defaultRef, refs) {
44
+ if (!defaultRef) return [];
45
+ const defaultEntry = refs.find((r) => r.name === defaultRef || r.name === `origin/${defaultRef}`);
46
+ const headBranch = gitCurrentBranch(cwd);
47
+ if (!headBranch || headBranch === 'HEAD') return [];
48
+ const headEntry = refs.find((r) => r.name === headBranch);
49
+ if (!defaultEntry?.sha || !headEntry?.sha || defaultEntry.sha === headEntry.sha) return [];
50
+
51
+ const counts = gitAheadBehind(cwd, defaultEntry.sha, headEntry.sha);
52
+ if (counts.ahead_by == null && counts.behind_by == null) return [];
53
+
54
+ return [
55
+ {
56
+ base_ref: sanitizeField(defaultRef),
57
+ head_ref: sanitizeField(headBranch),
58
+ ahead_by: counts.ahead_by,
59
+ behind_by: counts.behind_by,
60
+ },
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * Build provider-neutral ref inventory body from local git.
66
+ * @param {string} cwd repository working directory (git root after config load)
67
+ * @param {string} [remoteName]
68
+ */
69
+ export function buildRefInventoryBody(cwd, remoteName = 'origin') {
70
+ const refs = listRefs(cwd).map((ref) => ({
71
+ name: sanitizeField(ref.name),
72
+ sha: ref.sha,
73
+ kind: ref.kind,
74
+ is_default: false,
75
+ }));
76
+
77
+ const defaultRef = resolveDefaultRef(cwd, remoteName);
78
+ if (defaultRef) {
79
+ for (const ref of refs) {
80
+ if (ref.name === defaultRef || ref.name === `${remoteName}/${defaultRef}`) {
81
+ ref.is_default = true;
82
+ }
83
+ }
84
+ }
85
+
86
+ const ancestry_hints = buildAncestryHints(cwd, defaultRef, refs);
87
+
88
+ return {
89
+ refs,
90
+ ...(defaultRef ? { default_ref: sanitizeField(defaultRef) } : {}),
91
+ ...(ancestry_hints.length > 0 ? { ancestry_hints } : {}),
92
+ };
93
+ }
94
+
95
+ export async function refsInventory(ctx) {
96
+ const remoteName = ctx.config.remote || 'origin';
97
+ return buildRefInventoryBody(ctx.cwd, remoteName);
98
+ }
package/stub-provider.js CHANGED
@@ -23,6 +23,8 @@ export function createStubProvider(id) {
23
23
  providerCapabilities,
24
24
  repoStatus: unsupported,
25
25
  refsCompare: unsupported,
26
+ refsInventory: unsupported,
27
+ crInventory: unsupported,
26
28
  prView: unsupported,
27
29
  prChecks: unsupported,
28
30
  mergePlan: unsupported,
@@ -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
+ }