@remogram/core 0.1.0-beta.6 → 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
  }
@@ -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
@@ -75,6 +75,17 @@ export {
75
75
  appendSortQuery,
76
76
  } from './open-pull-list.js';
77
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';
78
89
  export {
79
90
  STATUS_SET_STATES,
80
91
  assertCommitSha,
@@ -166,6 +177,7 @@ export {
166
177
  throwIfStaleHeadByNumber,
167
178
  } from './pr-head-reconcile.js';
168
179
  export { parseConfigFile, configSchema } from './config-schema.js';
180
+ export { normalizedForgeOrigin } from './forge-identity.js';
169
181
  export {
170
182
  findConfigPath,
171
183
  loadConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.6",
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",
@@ -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