@remogram/core 0.1.0-beta.5 → 0.1.0-beta.8

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 CHANGED
@@ -24,6 +24,7 @@ export const API_PROVIDER_COMMAND_AUTH = {
24
24
  cr_files: AUTH_CLASS.TOKEN_REQUIRED,
25
25
  cr_comments: AUTH_CLASS.TOKEN_REQUIRED,
26
26
  forge_changes: AUTH_CLASS.TOKEN_REQUIRED,
27
+ merge_execute: AUTH_CLASS.TOKEN_REQUIRED,
27
28
  };
28
29
 
29
30
  export function commandCapability(name, { implemented = true } = {}) {
@@ -41,6 +42,7 @@ export function apiProviderCommands({
41
42
  crFilesImplemented = false,
42
43
  crCommentsImplemented = false,
43
44
  forgeChangesImplemented = false,
45
+ mergeExecuteImplemented = false,
44
46
  } = {}) {
45
47
  return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
46
48
  let implemented = true;
@@ -50,6 +52,7 @@ export function apiProviderCommands({
50
52
  if (name === 'cr_files') implemented = crFilesImplemented;
51
53
  if (name === 'cr_comments') implemented = crCommentsImplemented;
52
54
  if (name === 'forge_changes') implemented = forgeChangesImplemented;
55
+ if (name === 'merge_execute') implemented = mergeExecuteImplemented;
53
56
  return commandCapability(name, { implemented });
54
57
  });
55
58
  }
package/caps.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export const DEFAULT_MAX_BYTES = 8192;
2
2
  export const DEFAULT_FIELD_MAX_BYTES = 512;
3
3
  export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
4
+ /** Upper bound for undocumented REMOGRAM_FORGE_INGEST_MAX_BYTES debug override. */
5
+ export const MAX_FORGE_INGEST_ENV_BYTES = 65536;
4
6
 
5
7
  /** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
6
8
  export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
@@ -20,13 +22,20 @@ export function getEffectiveIngestMaxBytes() {
20
22
  if (!Number.isFinite(parsed) || parsed <= 0) {
21
23
  return { bytes: DEFAULT_MAX_BYTES, envOverride: false, invalidEnv: true };
22
24
  }
25
+ if (parsed > MAX_FORGE_INGEST_ENV_BYTES) {
26
+ return { bytes: MAX_FORGE_INGEST_ENV_BYTES, envOverride: true, clamped: true };
27
+ }
23
28
  return { bytes: parsed, envOverride: true };
24
29
  }
25
30
 
26
31
  /** Facts for provider capabilities packets (forge ingest policy). */
27
32
  export function forgeIngestCapabilityFacts() {
28
- const { bytes } = getEffectiveIngestMaxBytes();
29
- return { forge_ingest_cap_bytes: bytes };
33
+ const { bytes, envOverride, clamped } = getEffectiveIngestMaxBytes();
34
+ return {
35
+ forge_ingest_cap_bytes: bytes,
36
+ ...(envOverride ? { forge_ingest_env_override: true } : {}),
37
+ ...(clamped ? { forge_ingest_cap_clamped: true } : {}),
38
+ };
30
39
  }
31
40
 
32
41
  /**
@@ -0,0 +1,139 @@
1
+ import { sanitizeField } from './caps.js';
2
+ import { mergeBlockersFromFacts } from './merge-blockers.js';
3
+
4
+ const SHA40 = /^[0-9a-f]{40}$/i;
5
+
6
+ export function mergeExecuteViewFacts(view) {
7
+ return {
8
+ sourceBranchRef: view.forge_source_branch_ref ?? view.head_ref ?? null,
9
+ sourceSha: view.forge_source_sha ?? view.head_sha ?? null,
10
+ targetSha: view.forge_target_sha ?? view.base_sha ?? null,
11
+ };
12
+ }
13
+
14
+ export function mergeExecuteChecksFacts(checks) {
15
+ return {
16
+ sourceSha: checks.forge_source_sha ?? checks.head_sha ?? null,
17
+ };
18
+ }
19
+
20
+ export function assertExpectedSha(value, flagName) {
21
+ const normalized = String(value ?? '').trim().toLowerCase();
22
+ if (!SHA40.test(normalized)) {
23
+ throw Object.assign(new Error(`Invalid ${flagName}`), {
24
+ invalidArgs: `${flagName} must be a 40-character git SHA`,
25
+ });
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ export function buildMergeExecuteBeforeFacts(view, checks, mergePlanBody, forgeHeadRefSha = null) {
31
+ const viewFacts = mergeExecuteViewFacts(view);
32
+ const checksFacts = mergeExecuteChecksFacts(checks);
33
+ return {
34
+ base_sha: viewFacts.targetSha ?? null,
35
+ head_sha: viewFacts.sourceSha ?? null,
36
+ checks_head_sha: checksFacts.sourceSha ?? null,
37
+ forge_head_ref_sha: forgeHeadRefSha ?? null,
38
+ mergeability: view.mergeability ?? 'unknown',
39
+ checks_conclusion: checks.check_conclusion ?? 'unknown',
40
+ checks_truncated: checks.checks_truncated === true,
41
+ merge_plan_blockers: Array.isArray(mergePlanBody.blockers) ? [...mergePlanBody.blockers] : [],
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Collect merge-execute blockers before forge mutation.
47
+ * @returns {string[]} normalized blocker ids
48
+ */
49
+ export function collectMergeExecuteBlockers(
50
+ view,
51
+ checks,
52
+ mergePlanBody,
53
+ expected,
54
+ { forgeHeadRefSha } = {},
55
+ ) {
56
+ const viewFacts = mergeExecuteViewFacts(view);
57
+ const checksFacts = mergeExecuteChecksFacts(checks);
58
+ const blockers = [];
59
+ const baseSha = viewFacts.targetSha ? String(viewFacts.targetSha).toLowerCase() : null;
60
+ const headSha = viewFacts.sourceSha ? String(viewFacts.sourceSha).toLowerCase() : null;
61
+
62
+ if (baseSha && expected.baseSha !== baseSha) blockers.push('base_sha_mismatch');
63
+ if (headSha && expected.headSha !== headSha) blockers.push('head_sha_mismatch');
64
+
65
+ const forgeHead = forgeHeadRefSha ? String(forgeHeadRefSha).toLowerCase() : null;
66
+ const checksHead = checksFacts.sourceSha ? String(checksFacts.sourceSha).toLowerCase() : null;
67
+ if (headSha && checksHead && headSha !== checksHead) blockers.push('checks_head_sha_mismatch');
68
+ if (headSha && forgeHead && headSha !== forgeHead) blockers.push('forge_pr_head_mismatch');
69
+ if (checksHead && forgeHead && checksHead !== forgeHead) blockers.push('checks_forge_head_mismatch');
70
+ if (forgeHead && forgeHead !== expected.headSha) blockers.push('head_ref_moved');
71
+
72
+ const planBlockers = mergeBlockersFromFacts(view, checks, {});
73
+ for (const blocker of planBlockers) {
74
+ if (!blockers.includes(blocker)) blockers.push(blocker);
75
+ }
76
+
77
+ if (view.mergeability !== 'clean') {
78
+ if (view.mergeability === 'conflicted' && !blockers.includes('merge_conflict')) {
79
+ blockers.push('merge_conflict');
80
+ } else if (view.mergeability !== 'conflicted' && !blockers.includes('mergeability_not_clean')) {
81
+ blockers.push('mergeability_not_clean');
82
+ }
83
+ }
84
+
85
+ return blockers;
86
+ }
87
+
88
+ export function buildCrMergeBlockedBody({
89
+ prNumber,
90
+ expected,
91
+ before,
92
+ blockers,
93
+ }) {
94
+ return {
95
+ change_request: { number: prNumber },
96
+ expected: {
97
+ base_sha: expected.baseSha,
98
+ head_sha: expected.headSha,
99
+ },
100
+ before,
101
+ blockers,
102
+ };
103
+ }
104
+
105
+ export function buildCrMergedBody({
106
+ prNumber,
107
+ expected,
108
+ before,
109
+ merge,
110
+ after,
111
+ }) {
112
+ return {
113
+ change_request: { number: prNumber, state: 'merged' },
114
+ expected: {
115
+ base_sha: expected.baseSha,
116
+ head_sha: expected.headSha,
117
+ },
118
+ before,
119
+ merge,
120
+ after,
121
+ };
122
+ }
123
+
124
+ export function buildMergeExecuteAfterFacts(view, mergeResult = {}) {
125
+ const viewFacts = mergeExecuteViewFacts(view);
126
+ return {
127
+ state: 'merged',
128
+ base_sha: mergeResult.base_sha ?? viewFacts.targetSha ?? null,
129
+ head_sha: viewFacts.sourceSha ?? null,
130
+ };
131
+ }
132
+
133
+ export function buildMergeExecuteMergeFacts(method, providerResult = {}) {
134
+ return {
135
+ method: sanitizeField(method),
136
+ commit_sha: providerResult.commit_sha ?? null,
137
+ provider_status: providerResult.provider_status ?? null,
138
+ };
139
+ }
@@ -20,6 +20,8 @@ export const PACKET_TYPES = {
20
20
  CR_FILES: 'cr_files',
21
21
  CR_COMMENTS: 'cr_comments',
22
22
  FORGE_CHANGES: 'forge_changes',
23
+ CR_MERGED: 'cr_merged',
24
+ CR_MERGE_BLOCKED: 'cr_merge_blocked',
23
25
  };
24
26
 
25
27
  export const FORBIDDEN_PACKET_KEYS = new Set([
@@ -57,6 +59,11 @@ export function forgePacket(type, context, body = {}, error = null) {
57
59
  ok: error == null,
58
60
  };
59
61
 
62
+ delete packet.base_url;
63
+ if (context.baseUrl) {
64
+ packet.base_url = context.baseUrl;
65
+ }
66
+
60
67
  if (error) {
61
68
  packet.error_code = error.code;
62
69
  packet.error_message = sanitizeField(error.message);
@@ -16,6 +16,8 @@ export const ERROR_CODES = {
16
16
  WRITE_NOT_CONFIGURED: 'write_not_configured',
17
17
  IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
18
18
  INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
19
+ MERGE_BLOCKED: 'merge_blocked',
20
+ MERGE_ENDPOINT_FAILED: 'merge_endpoint_failed',
19
21
  };
20
22
 
21
23
  export function forgeError(code, message, status = null, fields = null) {
@@ -7,6 +7,7 @@ const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
7
7
  'provider_id',
8
8
  'remote_name',
9
9
  'repo_id',
10
+ 'base_url',
10
11
  'observed_at',
11
12
  'ok',
12
13
  'error_code',
@@ -28,7 +28,7 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
28
28
  ]);
29
29
 
30
30
  /** v1 write surface (Gitea cr open and status set in first slices). */
31
- export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set']);
31
+ export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set', 'merge execute']);
32
32
 
33
33
  /**
34
34
  * Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
@@ -46,6 +46,7 @@ export const TRUSTED_ENVELOPE_FIELDS = Object.freeze([
46
46
  'provider_id',
47
47
  'remote_name',
48
48
  'repo_id',
49
+ 'base_url',
49
50
  'observed_at',
50
51
  'ok',
51
52
  ]);
@@ -83,9 +84,9 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
83
84
  */
84
85
  export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
85
86
  repo_status: ['default_branch', 'auth_env', 'capabilities'],
86
- ref_compare: ['base_ref', 'head_ref', 'base_sha', 'head_sha'],
87
- pr_status: ['url', 'title', 'base_ref', 'head_ref', 'base_sha', 'head_sha'],
88
- pr_checks: ['head_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
87
+ ref_compare: ['compare_base_ref', 'compare_head_ref', 'compare_base_sha', 'compare_head_sha'],
88
+ pr_status: ['url', 'title', 'forge_target_branch_ref', 'forge_source_branch_ref', 'forge_target_sha', 'forge_source_sha'],
89
+ pr_checks: ['forge_source_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
89
90
  merge_plan: ['blockers[].message', 'blockers[].context'],
90
91
  sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
91
92
  ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
@@ -99,16 +100,16 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
99
100
  ],
100
101
  cr_files: ['changed_paths[]'],
101
102
  cr_comments: ['comments[].id', 'comments[].author', 'comments[].path', 'comments[].body'],
102
- forge_changes: ['events[].title', 'events[].url', 'events[].head_sha'],
103
+ forge_changes: ['events[].title', 'events[].url', 'events[].forge_source_sha'],
103
104
  cr_inventory_slice: [
104
105
  'entries[].url',
105
106
  'entries[].title',
106
- 'entries[].base_ref',
107
- 'entries[].head_ref',
108
- 'entries[].base_sha',
109
- 'entries[].head_sha',
107
+ 'entries[].forge_target_branch_ref',
108
+ 'entries[].forge_source_branch_ref',
109
+ 'entries[].forge_target_sha',
110
+ 'entries[].forge_source_sha',
110
111
  'entries[].head_reconcile.local_head_sha',
111
- 'entries[].head_reconcile.head_sha',
112
+ 'entries[].head_reconcile.forge_source_sha',
112
113
  'entries[].checks[].context',
113
114
  'entries[].checks[].description',
114
115
  ],
@@ -125,11 +126,11 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
125
126
  [FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY]: {
126
127
  refs: 'array<{ name: string, sha: string, kind?: string, is_default?: boolean }>',
127
128
  default_ref: 'string optional',
128
- ancestry_hints: 'array<{ base_ref: string, head_ref: string, ahead_by?: number, behind_by?: number }> optional',
129
+ ancestry_hints: 'array<{ compare_base_ref: string, compare_head_ref: string, ahead_by?: number, behind_by?: number }> optional',
129
130
  },
130
131
  [FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE]: {
131
132
  entries:
132
- 'array<{ pr_number: number, url?: string, title?: string, state?: string, base_ref?: string, head_ref?: string, base_sha?: string, head_sha?: string, mergeability?: string, checks_conclusion?: string, checks_truncated?: boolean, blockers?: array, head_reconcile?: { stale: boolean, local_head_sha?: string, head_sha?: string } }>',
133
+ 'array<{ pr_number: number, url?: string, title?: string, state?: string, forge_target_branch_ref?: string, forge_source_branch_ref?: string, forge_target_sha?: string, forge_source_sha?: string, mergeability?: string, checks_conclusion?: string, checks_truncated?: boolean, blockers?: array, head_reconcile?: { stale: boolean, local_head_sha?: string, forge_source_sha?: string } }>',
133
134
  entry_count: 'number',
134
135
  /** true when list cap applied (entry_count > limit), not missing entries */
135
136
  truncated: 'boolean',
package/cr-inventory.js CHANGED
@@ -39,14 +39,14 @@ export function buildHeadReconcile(ctx, view) {
39
39
  const details = staleHeadDetails(
40
40
  ctx.cwd,
41
41
  ctx.config?.remote ?? ctx.remoteName,
42
- view.head_ref,
43
- view.head_sha,
42
+ view.forge_source_branch_ref,
43
+ view.forge_source_sha,
44
44
  );
45
45
  if (!details) return { stale: false };
46
46
  return {
47
47
  stale: true,
48
48
  local_head_sha: details.local_head_sha,
49
- head_sha: details.head_sha,
49
+ forge_source_sha: details.forge_source_sha,
50
50
  };
51
51
  }
52
52
 
@@ -59,16 +59,16 @@ export function buildCrInventoryEntry(ctx, view, checks) {
59
59
  url: view.url,
60
60
  title: view.title,
61
61
  state: view.state,
62
- base_ref: view.base_ref,
63
- head_ref: view.head_ref,
62
+ forge_target_branch_ref: view.forge_target_branch_ref,
63
+ forge_source_branch_ref: view.forge_source_branch_ref,
64
64
  mergeability: view.mergeability,
65
65
  checks_conclusion: checks.check_conclusion,
66
66
  checks_truncated: checks.checks_truncated === true,
67
67
  blockers: mergeBlockersFromFacts(view, checks),
68
68
  head_reconcile: buildHeadReconcile(ctx, view),
69
69
  };
70
- if (view.base_sha) entry.base_sha = view.base_sha;
71
- if (view.head_sha) entry.head_sha = view.head_sha;
70
+ if (view.forge_target_sha) entry.forge_target_sha = view.forge_target_sha;
71
+ if (view.forge_source_sha) entry.forge_source_sha = view.forge_source_sha;
72
72
  return entry;
73
73
  }
74
74
 
package/forge-changes.js CHANGED
@@ -72,7 +72,7 @@ export function buildChecksConclusionObservedEvent(prNumber, checksBody) {
72
72
  return {
73
73
  kind: FORGE_CHANGE_EVENT_KINDS.CHECKS_CONCLUSION_OBSERVED,
74
74
  pr_number: Math.floor(Number(prNumber)),
75
- head_sha: sanitizeField(checksBody?.head_sha ?? '') || null,
75
+ forge_source_sha: sanitizeField(checksBody?.forge_source_sha ?? '') || null,
76
76
  check_conclusion: sanitizeField(checksBody?.check_conclusion ?? '') || 'unknown',
77
77
  checks_truncated: Boolean(checksBody?.checks_truncated ?? false),
78
78
  };
@@ -171,7 +171,7 @@ export function buildForgeChangesFromGiteaPulls(sinceIso, pullsArray, opts = {})
171
171
  kind: FORGE_CHANGE_EVENT_KINDS.HEAD_SHA_MOVED,
172
172
  ...base,
173
173
  state: 'open',
174
- head_sha: sanitizeField(pull.head?.sha ?? '') || null,
174
+ forge_source_sha: sanitizeField(pull.head?.sha ?? '') || null,
175
175
  updated_at: normalizeIsoTimestamp(updatedAt),
176
176
  });
177
177
  }
@@ -0,0 +1,42 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+
3
+ /**
4
+ * Canonical forge origin (scheme + host + port) from configured baseUrl.
5
+ * Config-derived trusted identity — not forge-sourced.
6
+ *
7
+ * @param {{ baseUrl?: string }} config
8
+ * @returns {string | null}
9
+ */
10
+ export function normalizedForgeOrigin(config) {
11
+ if (!config?.baseUrl) return null;
12
+ let url;
13
+ try {
14
+ url = new URL(config.baseUrl);
15
+ } catch {
16
+ throw Object.assign(new Error('Invalid baseUrl'), {
17
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'Invalid baseUrl in .remogram.json'),
18
+ });
19
+ }
20
+ if (url.username || url.password) {
21
+ throw Object.assign(new Error('baseUrl must not contain userinfo'), {
22
+ forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, 'baseUrl must not contain userinfo'),
23
+ });
24
+ }
25
+ if (url.pathname && url.pathname !== '/') {
26
+ throw Object.assign(new Error('baseUrl must be a forge origin'), {
27
+ forgeError: forgeError(
28
+ ERROR_CODES.CONFIG_INVALID,
29
+ 'baseUrl must be a forge origin without a path',
30
+ ),
31
+ });
32
+ }
33
+ if (url.search || url.hash) {
34
+ throw Object.assign(new Error('baseUrl must be a forge origin'), {
35
+ forgeError: forgeError(
36
+ ERROR_CODES.CONFIG_INVALID,
37
+ 'baseUrl must be a forge origin without query or fragment',
38
+ ),
39
+ });
40
+ }
41
+ return url.origin;
42
+ }
package/index.js CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  DEFAULT_MAX_BYTES,
29
29
  DEFAULT_FIELD_MAX_BYTES,
30
30
  FORGE_INGEST_MAX_BYTES_ENV,
31
+ MAX_FORGE_INGEST_ENV_BYTES,
31
32
  DEFAULT_CHECK_STATUS_PAGE_SIZE,
32
33
  MAX_CHECK_STATUS_PAGES,
33
34
  DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
@@ -74,6 +75,17 @@ export {
74
75
  appendSortQuery,
75
76
  } from './open-pull-list.js';
76
77
  export { buildChangeRequestOpenedBody } from './cr-open.js';
78
+ export {
79
+ assertExpectedSha,
80
+ mergeExecuteViewFacts,
81
+ mergeExecuteChecksFacts,
82
+ buildMergeExecuteBeforeFacts,
83
+ collectMergeExecuteBlockers,
84
+ buildCrMergeBlockedBody,
85
+ buildCrMergedBody,
86
+ buildMergeExecuteAfterFacts,
87
+ buildMergeExecuteMergeFacts,
88
+ } from './change-request-merge-execute.js';
77
89
  export {
78
90
  STATUS_SET_STATES,
79
91
  assertCommitSha,
@@ -135,15 +147,26 @@ export {
135
147
  } from './write-config.js';
136
148
  export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
137
149
  export {
150
+ applyForgePathScopeForMergePlan,
151
+ isCrFilesScopeComplete,
138
152
  resolveMergePlanPathScope,
139
153
  buildMergePlanBody,
140
154
  buildMergePlanBodyFromFacts,
155
+ normalizeAllowedPaths,
141
156
  } from './merge-plan.js';
157
+ export {
158
+ isMergePlanForgeScopeRethrowError,
159
+ MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES,
160
+ resolveMergePlanOptsWithForgePaths,
161
+ buildMergePlanFromProviderFacts,
162
+ } from './merge-plan-forge.js';
142
163
  export {
143
164
  matchPathAllowlist,
144
165
  isPathAllowed,
145
166
  pathsOutsideAllowlist,
146
167
  allPathsAllowed,
168
+ normalizeRepoRelativePath,
169
+ normalizeChangedPathList,
147
170
  } from './path-allowlist.js';
148
171
  export {
149
172
  localHeadShaForPr,
@@ -154,6 +177,7 @@ export {
154
177
  throwIfStaleHeadByNumber,
155
178
  } from './pr-head-reconcile.js';
156
179
  export { parseConfigFile, configSchema } from './config-schema.js';
180
+ export { normalizedForgeOrigin } from './forge-identity.js';
157
181
  export {
158
182
  findConfigPath,
159
183
  loadConfig,
package/merge-blockers.js CHANGED
@@ -1,4 +1,4 @@
1
- import { allPathsAllowed } from './path-allowlist.js';
1
+ import { allPathsAllowed, normalizeChangedPathList } from './path-allowlist.js';
2
2
 
3
3
  /**
4
4
  * Derive merge blockers from already-fetched PR view and checks facts.
@@ -27,8 +27,15 @@ export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
27
27
  const changedPaths = pathScope.changed_paths;
28
28
  if (changedPaths == null) {
29
29
  blockers.push('changed_paths_unavailable');
30
- } else if (!allPathsAllowed(allowedPaths, changedPaths)) {
31
- blockers.push('path_scope_violation');
30
+ } else if (Array.isArray(changedPaths)) {
31
+ const normalizedPaths = normalizeChangedPathList(changedPaths);
32
+ if (normalizedPaths == null) {
33
+ blockers.push('changed_paths_unavailable');
34
+ } else if (!allPathsAllowed(allowedPaths, normalizedPaths)) {
35
+ blockers.push('path_scope_violation');
36
+ }
37
+ } else {
38
+ blockers.push('changed_paths_unavailable');
32
39
  }
33
40
  }
34
41
 
@@ -0,0 +1,62 @@
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
+ return buildMergePlanBodyFromFacts(view, checks, mergeOpts);
62
+ }
package/merge-plan.js CHANGED
@@ -1,25 +1,64 @@
1
- import { gitDiffNameOnly } from './git-local.js';
2
1
  import { mergeBlockersFromFacts } from './merge-blockers.js';
2
+ import { normalizeChangedPathList } from './path-allowlist.js';
3
3
 
4
- function normalizeAllowedPaths(allowedPaths) {
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) {
5
14
  if (!Array.isArray(allowedPaths)) return null;
6
- const normalized = allowedPaths.filter((entry) => typeof entry === 'string' && entry.length > 0);
15
+ const normalized = allowedPaths
16
+ .filter((entry) => typeof entry === 'string')
17
+ .map((entry) => entry.trim())
18
+ .filter((entry) => entry.length > 0 && !allowlistGlobHasDotDotSegment(entry));
7
19
  return normalized.length > 0 ? normalized : null;
8
20
  }
9
21
 
10
- export function resolveMergePlanPathScope(ctx, view, opts = {}) {
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 = {}) {
11
50
  const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
12
51
  if (!allowedPaths) {
13
52
  return { allowed_paths: null, changed_paths: null };
14
53
  }
15
54
  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 };
55
+ const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
56
+ return {
57
+ allowed_paths: allowedPaths,
58
+ changed_paths: normalizedPaths,
59
+ };
20
60
  }
21
- const changedPaths = gitDiffNameOnly(ctx.cwd, view.base_sha, view.head_sha);
22
- return { allowed_paths: allowedPaths, changed_paths: changedPaths };
61
+ return { allowed_paths: allowedPaths, changed_paths: null };
23
62
  }
24
63
 
25
64
  export function buildMergePlanBody(view, checks, pathScope = {}) {
@@ -31,7 +70,7 @@ export function buildMergePlanBody(view, checks, pathScope = {}) {
31
70
  };
32
71
  }
33
72
 
34
- export function buildMergePlanBodyFromFacts(ctx, view, checks, opts = {}) {
35
- const pathScope = resolveMergePlanPathScope(ctx, view, opts);
73
+ export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
74
+ const pathScope = resolveMergePlanPathScope(opts);
36
75
  return buildMergePlanBody(view, checks, pathScope);
37
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.8",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/path-allowlist.js CHANGED
@@ -3,6 +3,56 @@
3
3
  * Supports `**`, `*`, and literal path segments (e.g. README.md).
4
4
  */
5
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
+
6
56
  function globToRegExp(glob) {
7
57
  let pattern = '';
8
58
  for (let i = 0; i < glob.length; i += 1) {
@@ -29,7 +79,8 @@ function globToRegExp(glob) {
29
79
  */
30
80
  export function matchPathAllowlist(glob, filePath) {
31
81
  if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
32
- const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
82
+ const normalized = normalizeRepoRelativePath(filePath);
83
+ if (normalized == null) return false;
33
84
  return globToRegExp(glob).test(normalized);
34
85
  }
35
86
 
@@ -3,7 +3,7 @@ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
3
  import { gitRevParse } from './git-local.js';
4
4
 
5
5
  export const STALE_HEAD_MESSAGE =
6
- 'Forge PR head SHA diverges from locally resolved git; fetch or refresh before trusting head_sha';
6
+ 'Forge PR head SHA diverges from locally resolved git; fetch or refresh before trusting forge_source_sha';
7
7
 
8
8
  export function localHeadShaForPr(cwd, remoteName, headRef) {
9
9
  if (!headRef) return null;
@@ -18,8 +18,8 @@ export function staleHeadDetails(cwd, remoteName, headRef, forgeHeadSha) {
18
18
  if (!localHeadSha) return null;
19
19
  if (localHeadSha.toLowerCase() === String(forgeHeadSha).toLowerCase()) return null;
20
20
  return {
21
- head_ref: headRef,
22
- head_sha: forgeHeadSha,
21
+ forge_source_branch_ref: headRef,
22
+ forge_source_sha: forgeHeadSha,
23
23
  local_head_sha: localHeadSha,
24
24
  };
25
25
  }
package/ref-inventory.js CHANGED
@@ -53,8 +53,8 @@ function buildAncestryHints(cwd, defaultRef, refs) {
53
53
 
54
54
  return [
55
55
  {
56
- base_ref: sanitizeField(defaultRef),
57
- head_ref: sanitizeField(headBranch),
56
+ compare_base_ref: sanitizeField(defaultRef),
57
+ compare_head_ref: sanitizeField(headBranch),
58
58
  ahead_by: counts.ahead_by,
59
59
  behind_by: counts.behind_by,
60
60
  },
package/resolve.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { parseConfigFile } from './config-schema.js';
5
5
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
6
+ import { normalizedForgeOrigin } from './forge-identity.js';
6
7
  import { assertGitRemote } from './git-args.js';
7
8
  import { gitRepoRoot } from './git-local.js';
8
9
 
@@ -145,6 +146,7 @@ export function forgeContext(loaded) {
145
146
  providerId: config.provider,
146
147
  remoteName: config.remote,
147
148
  repoId: `${parsed.owner}/${parsed.repo}`,
149
+ baseUrl: normalizedForgeOrigin(config),
148
150
  config,
149
151
  cwd: loaded.cwd,
150
152
  parsed,
package/write-config.js CHANGED
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
3
 
4
4
  /** Canonical v1 consumer write command ids (schema + gate single source). */
5
- export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set']);
5
+ export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set', 'merge']);
6
6
 
7
7
  export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
8
8