@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.
Files changed (45) hide show
  1. package/auth-classes.js +73 -0
  2. package/branch-protection.js +155 -0
  3. package/caps.js +185 -6
  4. package/change-request-merge-execute.js +148 -0
  5. package/check-diagnostics.js +92 -0
  6. package/check-pagination.js +216 -0
  7. package/config-schema.js +21 -0
  8. package/contracts/envelope.js +26 -2
  9. package/contracts/errors.js +14 -2
  10. package/contracts/forge-error-fields.js +124 -0
  11. package/contracts/observer-fact-inventory.js +64 -0
  12. package/contracts/semantic-diff-facts.js +168 -0
  13. package/cr-comments.js +93 -0
  14. package/cr-files.js +62 -0
  15. package/cr-inventory-cursor.js +64 -0
  16. package/cr-inventory.js +136 -0
  17. package/cr-open.js +38 -0
  18. package/effective-write-policy.js +68 -0
  19. package/forge-changes-cursor.js +88 -0
  20. package/forge-changes.js +181 -0
  21. package/forge-identity.js +42 -0
  22. package/git-args.js +19 -0
  23. package/git-local.js +22 -0
  24. package/http.js +83 -4
  25. package/idempotency.js +69 -0
  26. package/index.js +266 -4
  27. package/issue-open.js +50 -0
  28. package/merge-blockers.js +68 -0
  29. package/merge-plan-forge.js +63 -0
  30. package/merge-plan.js +82 -0
  31. package/merge-policy.js +55 -0
  32. package/open-pull-list.js +256 -0
  33. package/operator-config.js +260 -0
  34. package/package.json +1 -1
  35. package/path-allowlist.js +114 -0
  36. package/pr-head-reconcile.js +38 -0
  37. package/provider-health.js +93 -0
  38. package/ref-inventory.js +98 -0
  39. package/resolve.js +53 -4
  40. package/status-set.js +92 -0
  41. package/stub-provider.js +11 -8
  42. package/whoami.js +114 -0
  43. package/write-config.js +63 -0
  44. package/write-field-policy.js +93 -0
  45. package/write-readiness.js +91 -0
package/index.js CHANGED
@@ -1,9 +1,254 @@
1
1
  export { SCHEMA_VERSION, PACKET_TYPES, forgePacket, forgeErrorPacket, unknownForgeContext, FORBIDDEN_PACKET_KEYS } from './contracts/envelope.js';
2
+ export {
3
+ V1_READ_PLAN_COMMANDS,
4
+ FACT_INVENTORY_PACKET_TYPES,
5
+ TRUSTED_ENVELOPE_FIELDS,
6
+ TRUSTED_NORMALIZED_BODY_FIELDS,
7
+ FORGE_SOURCED_STRING_LEAVES,
8
+ FACT_INVENTORY_BODY_SHAPES,
9
+ forgeFactInventoryPacket,
10
+ } from './contracts/semantic-diff-facts.js';
11
+ export {
12
+ OBSERVER_REMOGRAM_COMMANDS,
13
+ OBSERVER_FACT_INVENTORY_PACKETS,
14
+ observerProtoRemogramCommands,
15
+ semanticDiffFactCommands,
16
+ allObserverEligibleCommands,
17
+ } from './contracts/observer-fact-inventory.js';
2
18
  export { ERROR_CODES, forgeError } from './contracts/errors.js';
3
- export { capText, sanitizeField, sanitizeUrl, readStreamCapped, DEFAULT_MAX_BYTES, DEFAULT_FIELD_MAX_BYTES } from './caps.js';
4
- export { assertGitRef, assertGitRemote } from './git-args.js';
5
- export { gitRevParse, gitCurrentBranch, gitAheadBehind } from './git-local.js';
19
+ export {
20
+ FORGE_ERROR_FIELD_ALLOWLIST,
21
+ normalizeForgeErrorFields,
22
+ } from './contracts/forge-error-fields.js';
23
+ export {
24
+ capText,
25
+ sanitizeField,
26
+ sanitizeReadField,
27
+ sanitizeWriteBody,
28
+ sanitizeWriteTitle,
29
+ sanitizeUrl,
30
+ readStreamCapped,
31
+ DEFAULT_MAX_BYTES,
32
+ DEFAULT_FIELD_MAX_BYTES,
33
+ FORGE_INGEST_MAX_BYTES_ENV,
34
+ MAX_FORGE_INGEST_ENV_BYTES,
35
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
36
+ MAX_CHECK_STATUS_PAGES,
37
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
38
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
39
+ getEffectiveIngestMaxBytes,
40
+ forgeIngestCapabilityFacts,
41
+ checkPaginationCapabilityFacts,
42
+ idempotencyScanCapabilityFacts,
43
+ statusSetIdempotencyScanCapabilityFacts,
44
+ openPullListCapabilityFacts,
45
+ } from './caps.js';
46
+ export {
47
+ WRITE_FIELD_MAX_BYTES_ENV,
48
+ MAX_WRITE_FIELD_ENV_BYTES,
49
+ resolveEffectiveWriteFieldPolicy,
50
+ forgeWriteFieldCapabilityFacts,
51
+ getEffectiveWriteFieldMaxBytesFromEnv,
52
+ parseForgeWritePolicyBlock,
53
+ } from './write-field-policy.js';
54
+ export {
55
+ paginateCheckStatusPages,
56
+ paginateOffsetListPages,
57
+ fetchWithIngestPageBackoff,
58
+ fetchPageWithIngestBackoff,
59
+ withPerPageParam,
60
+ withLimitParam,
61
+ } from './check-pagination.js';
62
+ export { assertGitRef, assertGitRemote, assertCrOpenBranchRef } from './git-args.js';
63
+ export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot, gitDiffNameOnly } from './git-local.js';
64
+ export { buildRefInventoryBody, refsInventory } from './ref-inventory.js';
65
+ export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, DEFAULT_CR_INVENTORY_SAFE_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
66
+ export {
67
+ CR_INVENTORY_CURSOR_VERSION,
68
+ decodeCrInventoryCursor,
69
+ encodeCrInventoryCursor,
70
+ } from './cr-inventory-cursor.js';
71
+ export {
72
+ CR_INVENTORY_SLICE_SORTS,
73
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
74
+ normalizeCrInventorySort,
75
+ parseTotalCountHeader,
76
+ isCrInventoryFastPathEligible,
77
+ forgeOrderAuthoritative,
78
+ validateFastPathPageLength,
79
+ isNumberSortFastPathEligible,
80
+ resolvePaginatedEntryCount,
81
+ resolveListTruncatedWithTrustedTotal,
82
+ isRecentCreatedFastPathEligible,
83
+ giteaRecentCreatedTailPage,
84
+ isNumberSortFullCollectRequired,
85
+ prepareGiteaOpenPullPageItems,
86
+ orderOpenPullNumbers,
87
+ buildOpenPullListMeta,
88
+ giteaOpenPullSortQuery,
89
+ gitlabOpenPullSortQuery,
90
+ githubOpenPullSortQuery,
91
+ appendSortQuery,
92
+ } from './open-pull-list.js';
93
+ export { buildChangeRequestOpenedBody } from './cr-open.js';
94
+ export { buildIssueOpenedBody, parseIssueOpenArgs } from './issue-open.js';
95
+ export {
96
+ assertExpectedSha,
97
+ mergeExecuteViewFacts,
98
+ mergeExecuteChecksFacts,
99
+ buildMergeExecuteBeforeFacts,
100
+ collectMergeExecuteBlockers,
101
+ buildCrMergeBlockedBody,
102
+ buildCrMergedBody,
103
+ buildMergeExecuteAfterFacts,
104
+ buildMergeExecuteMergeFacts,
105
+ } from './change-request-merge-execute.js';
106
+ export {
107
+ STATUS_SET_STATES,
108
+ assertCommitSha,
109
+ normalizeStatusSetState,
110
+ parseStatusSetArgs,
111
+ buildCommitStatusSetBody,
112
+ } from './status-set.js';
113
+ export {
114
+ buildProviderIdentityBody,
115
+ buildProviderIdentityFromGiteaUser,
116
+ parseGitHubOAuthScopes,
117
+ githubCanWriteFromScopes,
118
+ buildProviderIdentityFromGitHubUser,
119
+ normalizeGitLabCanWrite,
120
+ parseGitLabPatSelfSignals,
121
+ buildProviderIdentityFromGitLabUser,
122
+ normalizeGiteaCanWrite,
123
+ unimplementedTokenScopeSignal,
124
+ unimplementedTokenExpirySignal,
125
+ } from './whoami.js';
126
+ export {
127
+ buildBranchProtectionBody,
128
+ buildBranchProtectionFromGiteaProtection,
129
+ buildBranchProtectionFromGitHubProtection,
130
+ buildBranchProtectionFromGitLabProtection,
131
+ unimplementedApprovalsRequiredSignal,
132
+ MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
133
+ MAX_BRANCH_PROTECTION_RULES,
134
+ } from './branch-protection.js';
135
+ export {
136
+ enrichCheckStatus,
137
+ buildCheckDiagnostics,
138
+ buildPrChecksBody,
139
+ } from './check-diagnostics.js';
140
+ export {
141
+ buildCrFilesBody,
142
+ buildCrFilesFromGiteaFiles,
143
+ buildCrFilesFromGitLabChanges,
144
+ MAX_CR_FILES_PATHS,
145
+ } from './cr-files.js';
146
+ export {
147
+ buildCrCommentsBody,
148
+ buildCrCommentsFromGiteaComments,
149
+ buildCrCommentsFromGitLabDiscussions,
150
+ normalizeCrComment,
151
+ MAX_CR_COMMENTS,
152
+ } from './cr-comments.js';
153
+ export {
154
+ parseSinceObservedAt,
155
+ buildForgeChangesBody,
156
+ buildForgeChangesFromGiteaPulls,
157
+ buildChecksConclusionObservedEvent,
158
+ appendForgeChangeEvents,
159
+ MAX_FORGE_CHANGES_EVENTS,
160
+ FORGE_CHANGE_EVENT_KINDS,
161
+ } from './forge-changes.js';
162
+ export {
163
+ encodeForgeChangesCursor,
164
+ decodeForgeChangesCursor,
165
+ paginateForgeChangesBody,
166
+ FORGE_CHANGES_CURSOR_VERSION,
167
+ DEFAULT_FORGE_CHANGES_PAGE_SIZE,
168
+ } from './forge-changes-cursor.js';
169
+ export {
170
+ WRITE_COMMAND_IDS,
171
+ CONFIGURED_WRITE_COMMANDS,
172
+ writeCommandSchema,
173
+ assertWriteCommandConfigured,
174
+ writeNotConfiguredMessage,
175
+ isWriteCommandConfigured,
176
+ buildOperatorConfigSnippet,
177
+ buildRepoConfigSnippet,
178
+ } from './write-config.js';
179
+ export {
180
+ resolveEffectiveWritePolicy,
181
+ isWriteCommandAllowed,
182
+ normalizeWritePolicyInput,
183
+ writeSourceForCommand,
184
+ } from './effective-write-policy.js';
185
+ export {
186
+ REMOGRAM_OPERATOR_CONFIG_ENV,
187
+ MAX_OPERATOR_CONFIG_BYTES,
188
+ operatorConfigSchema,
189
+ defaultOperatorConfigPath,
190
+ discoverOperatorConfigPath,
191
+ parseOperatorConfigFile,
192
+ loadOperatorConfigFile,
193
+ assertOperatorBindMatches,
194
+ loadOperatorConfig,
195
+ } from './operator-config.js';
196
+ export {
197
+ buildWriteReadiness,
198
+ writeReadinessHasWarnings,
199
+ } from './write-readiness.js';
200
+ export {
201
+ buildApiReachabilityCheck,
202
+ classifyReachabilityFailure,
203
+ LIVE_REACHABILITY_TIMEOUT_MS,
204
+ } from './provider-health.js';
205
+ export {
206
+ bindIdempotencyScope,
207
+ idempotencyFingerprint,
208
+ idempotencyPacketFields,
209
+ normalizeIdempotencyKey,
210
+ resetIdempotencyScopeBindings,
211
+ } from './idempotency.js';
212
+ export {
213
+ resolveMergePolicy,
214
+ parseTruthyEnv,
215
+ mergePolicyAuditFacts,
216
+ ALLOW_MISSING_CHECKS_ENV,
217
+ ALLOW_PENDING_CHECKS_ENV,
218
+ } from './merge-policy.js';
219
+ export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
220
+ export {
221
+ applyForgePathScopeForMergePlan,
222
+ isCrFilesScopeComplete,
223
+ resolveMergePlanPathScope,
224
+ buildMergePlanBody,
225
+ buildMergePlanBodyFromFacts,
226
+ normalizeAllowedPaths,
227
+ } from './merge-plan.js';
228
+ export {
229
+ isMergePlanForgeScopeRethrowError,
230
+ MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES,
231
+ resolveMergePlanOptsWithForgePaths,
232
+ buildMergePlanFromProviderFacts,
233
+ } from './merge-plan-forge.js';
234
+ export {
235
+ matchPathAllowlist,
236
+ isPathAllowed,
237
+ pathsOutsideAllowlist,
238
+ allPathsAllowed,
239
+ normalizeRepoRelativePath,
240
+ normalizeChangedPathList,
241
+ } from './path-allowlist.js';
242
+ export {
243
+ localHeadShaForPr,
244
+ staleHeadDetails,
245
+ staleHeadForgeError,
246
+ staleHeadForgeError as staleHeadError,
247
+ STALE_HEAD_MESSAGE,
248
+ throwIfStaleHeadByNumber,
249
+ } from './pr-head-reconcile.js';
6
250
  export { parseConfigFile, configSchema } from './config-schema.js';
251
+ export { normalizedForgeOrigin } from './forge-identity.js';
7
252
  export {
8
253
  findConfigPath,
9
254
  loadConfig,
@@ -13,5 +258,22 @@ export {
13
258
  assertConfigMatchesRemote,
14
259
  assertForgeReady,
15
260
  forgeContext,
261
+ resolveWritePolicyForForge,
262
+ prepareForgeContext,
16
263
  } from './resolve.js';
17
- export { fetchWithTimeout, fetchJson, fetchTextCapped } from './http.js';
264
+ export {
265
+ fetchWithTimeout,
266
+ fetchJson,
267
+ fetchJsonWithMeta,
268
+ parseLinkHeader,
269
+ isTrustedPaginationUrl,
270
+ fetchTextCapped,
271
+ } from './http.js';
272
+ export {
273
+ AUTH_CLASS,
274
+ API_PROVIDER_COMMAND_AUTH,
275
+ commandCapability,
276
+ apiProviderCommands,
277
+ stubProviderCommands,
278
+ assertAuthClass,
279
+ } from './auth-classes.js';
package/issue-open.js ADDED
@@ -0,0 +1,50 @@
1
+ import { sanitizeReadField, sanitizeWriteBody, sanitizeWriteTitle, sanitizeUrl } from './caps.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+
4
+ /** Normalize Gitea issue create response into issue_opened body fields. */
5
+ export function buildIssueOpenedBody(
6
+ issue,
7
+ { title },
8
+ { reusedExisting = false, idempotencyFields = null } = {},
9
+ ) {
10
+ const issueNumber = Number(issue?.number);
11
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
12
+ throw Object.assign(new Error('Provider returned invalid issue number'), {
13
+ forgeError: forgeError(
14
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
15
+ 'Provider returned invalid issue number',
16
+ ),
17
+ });
18
+ }
19
+ const resolvedTitle = reusedExisting
20
+ ? sanitizeReadField(issue?.title ?? title)
21
+ : sanitizeReadField(title ?? issue?.title);
22
+ const body = {
23
+ issue_number: issueNumber,
24
+ url: sanitizeUrl(issue.html_url ?? issue.url),
25
+ state: sanitizeReadField(String(issue?.state ?? 'open').toLowerCase()),
26
+ title: resolvedTitle,
27
+ };
28
+ if (reusedExisting) {
29
+ body.reused_existing = true;
30
+ } else {
31
+ body.created = true;
32
+ }
33
+ if (idempotencyFields && typeof idempotencyFields === 'object') {
34
+ Object.assign(body, idempotencyFields);
35
+ }
36
+ return body;
37
+ }
38
+
39
+ export function parseIssueOpenArgs({ title, body: issueBody }, writeFieldPolicy = null) {
40
+ if (title == null || String(title).trim() === '') {
41
+ throw Object.assign(new Error('--title required'), {
42
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--title required for issue open'),
43
+ });
44
+ }
45
+ const parsed = { title: sanitizeWriteTitle(String(title), writeFieldPolicy) };
46
+ if (issueBody != null && String(issueBody).trim() !== '') {
47
+ parsed.body = sanitizeWriteBody(String(issueBody), writeFieldPolicy);
48
+ }
49
+ return parsed;
50
+ }
@@ -0,0 +1,68 @@
1
+ import { allPathsAllowed, normalizeChangedPathList } from './path-allowlist.js';
2
+
3
+ function uniqueSorted(values) {
4
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
5
+ }
6
+
7
+ function diagnosticBlockers(checks) {
8
+ const blockers = [];
9
+ const missing = checks.missing_required_contexts;
10
+ if (Array.isArray(missing) && missing.length > 0) blockers.push('required_checks_missing');
11
+ const stale = checks.stale_contexts;
12
+ if (Array.isArray(stale) && stale.length > 0) blockers.push('stale_status_context');
13
+ const required = new Set(
14
+ Array.isArray(checks.required_contexts) ? checks.required_contexts.filter(Boolean) : [],
15
+ );
16
+ if (required.size > 0 && Array.isArray(checks.pending_contexts)) {
17
+ const pendingRequired = checks.pending_contexts.filter((context) => required.has(context));
18
+ if (pendingRequired.length > 0) blockers.push('required_checks_pending');
19
+ }
20
+ return uniqueSorted(blockers);
21
+ }
22
+
23
+ /**
24
+ * Derive merge blockers from already-fetched PR view and checks facts.
25
+ * Shared by merge plan and cr inventory aggregation.
26
+ */
27
+ export function isOpenPrState(state) {
28
+ return String(state ?? '').toLowerCase() === 'open';
29
+ }
30
+
31
+ /**
32
+ * @param {object} view
33
+ * @param {object} checks
34
+ * @param {{ allowed_paths?: string[], changed_paths?: string[] | null }} [pathScope]
35
+ */
36
+ export function mergeBlockersFromFacts(view, checks, pathScope = {}, policy = {}) {
37
+ const blockers = [];
38
+ if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
39
+ if (!isOpenPrState(view.state)) blockers.push('pr_not_open');
40
+ if (checks.checks_truncated === true) blockers.push('checks_incomplete');
41
+ if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
42
+ if (checks.check_conclusion === 'missing' && !policy.allow_missing_checks) {
43
+ blockers.push('checks_missing');
44
+ }
45
+ if (checks.check_conclusion === 'pending' && !policy.allow_pending_checks) {
46
+ blockers.push('checks_pending');
47
+ }
48
+ blockers.push(...diagnosticBlockers(checks));
49
+
50
+ const allowedPaths = pathScope.allowed_paths;
51
+ if (Array.isArray(allowedPaths) && allowedPaths.length > 0) {
52
+ const changedPaths = pathScope.changed_paths;
53
+ if (changedPaths == null) {
54
+ blockers.push('changed_paths_unavailable');
55
+ } else if (Array.isArray(changedPaths)) {
56
+ const normalizedPaths = normalizeChangedPathList(changedPaths);
57
+ if (normalizedPaths == null) {
58
+ blockers.push('changed_paths_unavailable');
59
+ } else if (!allPathsAllowed(allowedPaths, normalizedPaths)) {
60
+ blockers.push('path_scope_violation');
61
+ }
62
+ } else {
63
+ blockers.push('changed_paths_unavailable');
64
+ }
65
+ }
66
+
67
+ return blockers;
68
+ }
@@ -0,0 +1,63 @@
1
+ import { ERROR_CODES } from './contracts/errors.js';
2
+ import {
3
+ applyForgePathScopeForMergePlan,
4
+ buildMergePlanBodyFromFacts,
5
+ normalizeAllowedPaths,
6
+ } from './merge-plan.js';
7
+
8
+ /** Errors that must propagate from crFiles during merge-plan path scope — not mapped to changed_paths_unavailable. */
9
+ export const MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES = new Set([
10
+ ERROR_CODES.UNAUTHENTICATED_PROVIDER,
11
+ ERROR_CODES.INVALID_ARGS,
12
+ ERROR_CODES.UNTRUSTED_BASE_URL,
13
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
14
+ ERROR_CODES.CONFIG_INVALID,
15
+ ERROR_CODES.OVERSIZED_RAW_OUTPUT,
16
+ ERROR_CODES.CONFIG_NOT_FOUND,
17
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
18
+ ERROR_CODES.STALE_HEAD,
19
+ ERROR_CODES.MISSING_REF,
20
+ ERROR_CODES.PR_NOT_OPEN,
21
+ ERROR_CODES.REMOTE_INFER_FAILED,
22
+ ]);
23
+
24
+ export function isMergePlanForgeScopeRethrowError(err) {
25
+ const code = err?.forgeError?.code;
26
+ return typeof code === 'string' && MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES.has(code);
27
+ }
28
+
29
+ export { normalizeAllowedPaths } from './merge-plan.js';
30
+
31
+ /**
32
+ * Resolve merge plan opts with forge cr_files when an allowlist is configured.
33
+ * @param {object} opts
34
+ * @param {() => Promise<object>} crFilesFn
35
+ */
36
+ export async function resolveMergePlanOptsWithForgePaths(opts, crFilesFn) {
37
+ if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
38
+ try {
39
+ const crFilesBody = await crFilesFn();
40
+ return applyForgePathScopeForMergePlan(opts, crFilesBody);
41
+ } catch (err) {
42
+ if (isMergePlanForgeScopeRethrowError(err)) throw err;
43
+ // Intentional mask bucket: transient api_error → changed_paths_unavailable.
44
+ // write_not_configured / idempotency codes are not emitted by crFiles today.
45
+ return applyForgePathScopeForMergePlan(opts, null);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Build merge_plan body from provider deps (shared tri-provider orchestration).
51
+ * @param {object} ctx
52
+ * @param {object} opts
53
+ * @param {{ prView: Function, prChecks: Function, crFiles: Function }} deps
54
+ */
55
+ export async function buildMergePlanFromProviderFacts(ctx, opts, deps) {
56
+ const view = await deps.prView(ctx, opts);
57
+ const checks = await deps.prChecks(ctx, { number: view.pr_number });
58
+ const mergeOpts = await resolveMergePlanOptsWithForgePaths(opts, () =>
59
+ deps.crFiles(ctx, { number: view.pr_number }),
60
+ );
61
+ const mergePolicy = opts.merge_policy ?? ctx.mergePolicy ?? {};
62
+ return buildMergePlanBodyFromFacts(view, checks, { ...mergeOpts, merge_policy: mergePolicy });
63
+ }
package/merge-plan.js ADDED
@@ -0,0 +1,82 @@
1
+ import { mergeBlockersFromFacts } from './merge-blockers.js';
2
+ import { normalizeChangedPathList } from './path-allowlist.js';
3
+
4
+ function allowlistGlobHasDotDotSegment(glob) {
5
+ if (typeof glob !== 'string') return false;
6
+ const normalized = glob.replace(/\\/g, '/');
7
+ if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
8
+ return true;
9
+ }
10
+ return normalized.split('/').some((segment) => segment === '..');
11
+ }
12
+
13
+ export function normalizeAllowedPaths(allowedPaths) {
14
+ if (!Array.isArray(allowedPaths)) return null;
15
+ const normalized = allowedPaths
16
+ .filter((entry) => typeof entry === 'string')
17
+ .map((entry) => entry.trim())
18
+ .filter((entry) => entry.length > 0 && !allowlistGlobHasDotDotSegment(entry));
19
+ return normalized.length > 0 ? normalized : null;
20
+ }
21
+
22
+ /** True when cr_files body is complete enough for merge-plan path scope. */
23
+ export function isCrFilesScopeComplete(crFilesBody) {
24
+ if (!crFilesBody || crFilesBody.paths_truncated) return false;
25
+ const changedPaths = crFilesBody.changed_paths;
26
+ if (!Array.isArray(changedPaths)) return false;
27
+ const pathCount = Number.isFinite(Number(crFilesBody.path_count))
28
+ ? Math.floor(Number(crFilesBody.path_count))
29
+ : changedPaths.length;
30
+ if (pathCount > 0 && changedPaths.length === 0) return false;
31
+ if (pathCount > changedPaths.length) return false;
32
+ if (changedPaths.length > pathCount) return false;
33
+ return true;
34
+ }
35
+
36
+ /** Apply forge cr_files facts to merge plan opts when an allowlist is configured. */
37
+ export function applyForgePathScopeForMergePlan(opts, crFilesBody) {
38
+ if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
39
+ if (!isCrFilesScopeComplete(crFilesBody)) {
40
+ return { ...opts, changed_paths: null };
41
+ }
42
+ const normalizedPaths = normalizeChangedPathList(crFilesBody.changed_paths);
43
+ if (normalizedPaths == null) {
44
+ return { ...opts, changed_paths: null };
45
+ }
46
+ return { ...opts, changed_paths: normalizedPaths };
47
+ }
48
+
49
+ export function resolveMergePlanPathScope(opts = {}) {
50
+ const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
51
+ if (!allowedPaths) {
52
+ return { allowed_paths: null, changed_paths: null };
53
+ }
54
+ if (Array.isArray(opts.changed_paths)) {
55
+ const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
56
+ return {
57
+ allowed_paths: allowedPaths,
58
+ changed_paths: normalizedPaths,
59
+ };
60
+ }
61
+ return { allowed_paths: allowedPaths, changed_paths: null };
62
+ }
63
+
64
+ export function buildMergePlanBody(view, checks, pathScope = {}, policy = {}) {
65
+ return {
66
+ pr_number: view.pr_number,
67
+ mergeability: view.mergeability,
68
+ checks_conclusion: checks.check_conclusion,
69
+ required_contexts: checks.required_contexts ?? [],
70
+ missing_required_contexts: checks.missing_required_contexts ?? [],
71
+ failed_contexts: checks.failed_contexts ?? [],
72
+ pending_contexts: checks.pending_contexts ?? [],
73
+ stale_contexts: checks.stale_contexts ?? [],
74
+ blockers: mergeBlockersFromFacts(view, checks, pathScope, policy),
75
+ };
76
+ }
77
+
78
+ export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
79
+ const pathScope = resolveMergePlanPathScope(opts);
80
+ const policy = opts.merge_policy ?? {};
81
+ return buildMergePlanBody(view, checks, pathScope, policy);
82
+ }
@@ -0,0 +1,55 @@
1
+ export const ALLOW_MISSING_CHECKS_ENV = 'REMOGRAM_ALLOW_MISSING_CHECKS';
2
+ export const ALLOW_PENDING_CHECKS_ENV = 'REMOGRAM_ALLOW_PENDING_CHECKS';
3
+
4
+ /** @returns {boolean | null} true/false when recognized; null when unset/invalid */
5
+ export function parseTruthyEnv(value) {
6
+ const normalized = String(value ?? '').trim().toLowerCase();
7
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
8
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
9
+ return null;
10
+ }
11
+
12
+ function resolvePolicyFlag(configValue, envName) {
13
+ const envRaw = process.env[envName];
14
+ if (envRaw != null && envRaw !== '') {
15
+ const parsed = parseTruthyEnv(envRaw);
16
+ if (parsed === true) {
17
+ return { value: true, source: 'env' };
18
+ }
19
+ }
20
+ if (configValue === true) {
21
+ return { value: true, source: 'config' };
22
+ }
23
+ return { value: false, source: 'default' };
24
+ }
25
+
26
+ /**
27
+ * Resolved merge policy for blocker derivation (forge-facts layer only).
28
+ * @param {object | null | undefined} config consumer .remogram.json
29
+ */
30
+ export function resolveMergePolicy(config) {
31
+ const filePolicy = config?.merge_policy ?? {};
32
+ const missing = resolvePolicyFlag(filePolicy.allow_missing_checks, ALLOW_MISSING_CHECKS_ENV);
33
+ const pending = resolvePolicyFlag(filePolicy.allow_pending_checks, ALLOW_PENDING_CHECKS_ENV);
34
+ return {
35
+ allow_missing_checks: missing.value,
36
+ allow_pending_checks: pending.value,
37
+ source: {
38
+ allow_missing_checks: missing.source,
39
+ allow_pending_checks: pending.source,
40
+ },
41
+ };
42
+ }
43
+
44
+ /** Observational snapshot for merge execute before facts. */
45
+ export function mergePolicyAuditFacts(mergePolicy) {
46
+ if (!mergePolicy) return null;
47
+ return {
48
+ allow_missing_checks: mergePolicy.allow_missing_checks === true,
49
+ allow_pending_checks: mergePolicy.allow_pending_checks === true,
50
+ source: mergePolicy.source ?? {
51
+ allow_missing_checks: 'default',
52
+ allow_pending_checks: 'default',
53
+ },
54
+ };
55
+ }