@remogram/core 0.1.0-beta.6 → 0.1.0-beta.9

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,88 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+ import { parseSinceObservedAt } from './forge-changes.js';
3
+
4
+ export const FORGE_CHANGES_CURSOR_VERSION = 1;
5
+ export const DEFAULT_FORGE_CHANGES_PAGE_SIZE = 64;
6
+
7
+ /**
8
+ * @param {{ since: string, offset: number }} state
9
+ * @returns {string}
10
+ */
11
+ export function encodeForgeChangesCursor(state) {
12
+ const since = parseSinceObservedAt(state.since);
13
+ const offset = Number(state.offset);
14
+ if (!Number.isInteger(offset) || offset < 0) {
15
+ throw Object.assign(new Error('Invalid forge changes cursor offset'), {
16
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'cursor offset must be a non-negative integer'),
17
+ });
18
+ }
19
+ const payload = JSON.stringify({ v: FORGE_CHANGES_CURSOR_VERSION, since, offset });
20
+ return Buffer.from(payload, 'utf8').toString('base64url');
21
+ }
22
+
23
+ /**
24
+ * @param {unknown} raw
25
+ * @param {{ since?: string }} [opts]
26
+ * @returns {{ since: string, offset: number }}
27
+ */
28
+ export function decodeForgeChangesCursor(raw, opts = {}) {
29
+ if (raw == null || raw === '') {
30
+ throw Object.assign(new Error('Missing forge changes cursor'), {
31
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor must not be empty when provided'),
32
+ });
33
+ }
34
+ let decoded;
35
+ try {
36
+ decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
37
+ } catch {
38
+ throw Object.assign(new Error('Invalid forge changes cursor'), {
39
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor is malformed or not base64url JSON'),
40
+ });
41
+ }
42
+ if (decoded?.v !== FORGE_CHANGES_CURSOR_VERSION) {
43
+ throw Object.assign(new Error('Invalid forge changes cursor version'), {
44
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor version is unsupported'),
45
+ });
46
+ }
47
+ const since = parseSinceObservedAt(decoded.since);
48
+ const offset = Number(decoded.offset);
49
+ if (!Number.isInteger(offset) || offset < 0) {
50
+ throw Object.assign(new Error('Invalid forge changes cursor offset'), {
51
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor offset must be a non-negative integer'),
52
+ });
53
+ }
54
+ if (opts.since != null && opts.since !== '') {
55
+ const requestedSince = parseSinceObservedAt(opts.since);
56
+ if (requestedSince !== since) {
57
+ throw Object.assign(new Error('Forge changes cursor since mismatch'), {
58
+ forgeError: forgeError(
59
+ ERROR_CODES.INVALID_ARGS,
60
+ '--since must match the cursor since when both are provided',
61
+ ),
62
+ });
63
+ }
64
+ }
65
+ return { since, offset };
66
+ }
67
+
68
+ /**
69
+ * @param {object} body
70
+ * @param {{ offset?: number, limit?: number }} [opts]
71
+ */
72
+ export function paginateForgeChangesBody(body, opts = {}) {
73
+ const offset = opts.offset ?? 0;
74
+ const limit = opts.limit ?? DEFAULT_FORGE_CHANGES_PAGE_SIZE;
75
+ const allEvents = Array.isArray(body.events) ? body.events : [];
76
+ const pageEvents = allEvents.slice(offset, offset + limit);
77
+ const observedEnd = offset + pageEvents.length;
78
+ const hasMore = observedEnd < allEvents.length || body.events_truncated === true;
79
+ return {
80
+ ...body,
81
+ events: pageEvents,
82
+ has_more: hasMore,
83
+ complete: !hasMore,
84
+ ...(hasMore
85
+ ? { next_cursor: encodeForgeChangesCursor({ since: body.since, offset: observedEnd }) }
86
+ : {}),
87
+ };
88
+ }
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/idempotency.js ADDED
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sanitizeField } from './caps.js';
3
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
4
+
5
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
+
7
+ /** @type {Map<string, string>} */
8
+ const scopeBindings = new Map();
9
+
10
+ export function resetIdempotencyScopeBindings() {
11
+ scopeBindings.clear();
12
+ }
13
+
14
+ export function normalizeIdempotencyKey(value) {
15
+ if (value == null || value === '') return null;
16
+ const key = sanitizeField(String(value).trim());
17
+ if (!key || !KEY_PATTERN.test(key)) {
18
+ throw Object.assign(new Error('Invalid idempotency key'), {
19
+ forgeError: forgeError(
20
+ ERROR_CODES.INVALID_ARGS,
21
+ 'idempotency key must be 1-128 characters from [A-Za-z0-9._:-]',
22
+ ),
23
+ });
24
+ }
25
+ return key;
26
+ }
27
+
28
+ export function idempotencyFingerprint(key, scopeParts = []) {
29
+ const normalizedKey = normalizeIdempotencyKey(key);
30
+ if (!normalizedKey) return null;
31
+ const scope = scopeParts.map((part) => sanitizeField(String(part ?? ''))).join('\0');
32
+ return createHash('sha256').update(`${normalizedKey}\0${scope}`).digest('hex').slice(0, 16);
33
+ }
34
+
35
+ export function idempotencyScopeKey(repoId, key) {
36
+ const normalizedKey = normalizeIdempotencyKey(key);
37
+ if (!normalizedKey) return null;
38
+ return `${sanitizeField(repoId)}:${normalizedKey}`;
39
+ }
40
+
41
+ export function bindIdempotencyScope(repoId, key, scopeParts) {
42
+ const normalizedKey = normalizeIdempotencyKey(key);
43
+ if (!normalizedKey) return null;
44
+ const bindingKey = idempotencyScopeKey(repoId, normalizedKey);
45
+ const serialized = JSON.stringify(scopeParts.map((part) => sanitizeField(String(part ?? ''))));
46
+ const prior = scopeBindings.get(bindingKey);
47
+ if (prior && prior !== serialized) {
48
+ throw Object.assign(new Error('Idempotency key scope conflict'), {
49
+ forgeError: forgeError(
50
+ ERROR_CODES.IDEMPOTENCY_CONFLICT,
51
+ 'idempotency key was already used with a different write scope',
52
+ ),
53
+ });
54
+ }
55
+ scopeBindings.set(bindingKey, serialized);
56
+ return idempotencyFingerprint(normalizedKey, scopeParts);
57
+ }
58
+
59
+ export function idempotencyPacketFields(fingerprint, { reusedExisting = false, ambiguousAfterWrite = false } = {}) {
60
+ const fields = {};
61
+ if (fingerprint) fields.idempotency_fingerprint = fingerprint;
62
+ if (reusedExisting) {
63
+ fields.reused_existing = true;
64
+ } else {
65
+ fields.created = true;
66
+ }
67
+ if (ambiguousAfterWrite) fields.ambiguous_after_write = true;
68
+ return fields;
69
+ }
package/index.js CHANGED
@@ -52,6 +52,11 @@ export { assertGitRef, assertGitRemote } from './git-args.js';
52
52
  export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot, gitDiffNameOnly } from './git-local.js';
53
53
  export { buildRefInventoryBody, refsInventory } from './ref-inventory.js';
54
54
  export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, DEFAULT_CR_INVENTORY_SAFE_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
55
+ export {
56
+ CR_INVENTORY_CURSOR_VERSION,
57
+ decodeCrInventoryCursor,
58
+ encodeCrInventoryCursor,
59
+ } from './cr-inventory-cursor.js';
55
60
  export {
56
61
  CR_INVENTORY_SLICE_SORTS,
57
62
  DEFAULT_CR_INVENTORY_SLICE_SORT,
@@ -75,6 +80,18 @@ export {
75
80
  appendSortQuery,
76
81
  } from './open-pull-list.js';
77
82
  export { buildChangeRequestOpenedBody } from './cr-open.js';
83
+ export { buildIssueOpenedBody, parseIssueOpenArgs } from './issue-open.js';
84
+ export {
85
+ assertExpectedSha,
86
+ mergeExecuteViewFacts,
87
+ mergeExecuteChecksFacts,
88
+ buildMergeExecuteBeforeFacts,
89
+ collectMergeExecuteBlockers,
90
+ buildCrMergeBlockedBody,
91
+ buildCrMergedBody,
92
+ buildMergeExecuteAfterFacts,
93
+ buildMergeExecuteMergeFacts,
94
+ } from './change-request-merge-execute.js';
78
95
  export {
79
96
  STATUS_SET_STATES,
80
97
  assertCommitSha,
@@ -104,6 +121,11 @@ export {
104
121
  MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
105
122
  MAX_BRANCH_PROTECTION_RULES,
106
123
  } from './branch-protection.js';
124
+ export {
125
+ enrichCheckStatus,
126
+ buildCheckDiagnostics,
127
+ buildPrChecksBody,
128
+ } from './check-diagnostics.js';
107
129
  export {
108
130
  buildCrFilesBody,
109
131
  buildCrFilesFromGiteaFiles,
@@ -126,6 +148,13 @@ export {
126
148
  MAX_FORGE_CHANGES_EVENTS,
127
149
  FORGE_CHANGE_EVENT_KINDS,
128
150
  } from './forge-changes.js';
151
+ export {
152
+ encodeForgeChangesCursor,
153
+ decodeForgeChangesCursor,
154
+ paginateForgeChangesBody,
155
+ FORGE_CHANGES_CURSOR_VERSION,
156
+ DEFAULT_FORGE_CHANGES_PAGE_SIZE,
157
+ } from './forge-changes-cursor.js';
129
158
  export {
130
159
  WRITE_COMMAND_IDS,
131
160
  CONFIGURED_WRITE_COMMANDS,
@@ -134,6 +163,29 @@ export {
134
163
  writeNotConfiguredMessage,
135
164
  isWriteCommandConfigured,
136
165
  } from './write-config.js';
166
+ export {
167
+ buildWriteReadiness,
168
+ writeReadinessHasWarnings,
169
+ } from './write-readiness.js';
170
+ export {
171
+ buildApiReachabilityCheck,
172
+ classifyReachabilityFailure,
173
+ LIVE_REACHABILITY_TIMEOUT_MS,
174
+ } from './provider-health.js';
175
+ export {
176
+ bindIdempotencyScope,
177
+ idempotencyFingerprint,
178
+ idempotencyPacketFields,
179
+ normalizeIdempotencyKey,
180
+ resetIdempotencyScopeBindings,
181
+ } from './idempotency.js';
182
+ export {
183
+ resolveMergePolicy,
184
+ parseTruthyEnv,
185
+ mergePolicyAuditFacts,
186
+ ALLOW_MISSING_CHECKS_ENV,
187
+ ALLOW_PENDING_CHECKS_ENV,
188
+ } from './merge-policy.js';
137
189
  export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
138
190
  export {
139
191
  applyForgePathScopeForMergePlan,
@@ -166,6 +218,7 @@ export {
166
218
  throwIfStaleHeadByNumber,
167
219
  } from './pr-head-reconcile.js';
168
220
  export { parseConfigFile, configSchema } from './config-schema.js';
221
+ export { normalizedForgeOrigin } from './forge-identity.js';
169
222
  export {
170
223
  findConfigPath,
171
224
  loadConfig,
package/issue-open.js ADDED
@@ -0,0 +1,50 @@
1
+ import { sanitizeField, 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
+ ? sanitizeField(issue?.title ?? title)
21
+ : sanitizeField(title ?? issue?.title);
22
+ const body = {
23
+ issue_number: issueNumber,
24
+ url: sanitizeUrl(issue.html_url ?? issue.url),
25
+ state: sanitizeField(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 }) {
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: sanitizeField(String(title)) };
46
+ if (issueBody != null && String(issueBody).trim() !== '') {
47
+ parsed.body = sanitizeField(String(issueBody));
48
+ }
49
+ return parsed;
50
+ }
package/merge-blockers.js CHANGED
@@ -1,5 +1,25 @@
1
1
  import { allPathsAllowed, normalizeChangedPathList } from './path-allowlist.js';
2
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
+
3
23
  /**
4
24
  * Derive merge blockers from already-fetched PR view and checks facts.
5
25
  * Shared by merge plan and cr inventory aggregation.
@@ -13,14 +33,19 @@ export function isOpenPrState(state) {
13
33
  * @param {object} checks
14
34
  * @param {{ allowed_paths?: string[], changed_paths?: string[] | null }} [pathScope]
15
35
  */
16
- export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
36
+ export function mergeBlockersFromFacts(view, checks, pathScope = {}, policy = {}) {
17
37
  const blockers = [];
18
38
  if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
19
39
  if (!isOpenPrState(view.state)) blockers.push('pr_not_open');
20
40
  if (checks.checks_truncated === true) blockers.push('checks_incomplete');
21
41
  if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
22
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
23
- if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
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));
24
49
 
25
50
  const allowedPaths = pathScope.allowed_paths;
26
51
  if (Array.isArray(allowedPaths) && allowedPaths.length > 0) {
@@ -58,5 +58,6 @@ export async function buildMergePlanFromProviderFacts(ctx, opts, deps) {
58
58
  const mergeOpts = await resolveMergePlanOptsWithForgePaths(opts, () =>
59
59
  deps.crFiles(ctx, { number: view.pr_number }),
60
60
  );
61
- return buildMergePlanBodyFromFacts(view, checks, mergeOpts);
61
+ const mergePolicy = opts.merge_policy ?? ctx.mergePolicy ?? {};
62
+ return buildMergePlanBodyFromFacts(view, checks, { ...mergeOpts, merge_policy: mergePolicy });
62
63
  }
package/merge-plan.js CHANGED
@@ -61,16 +61,22 @@ export function resolveMergePlanPathScope(opts = {}) {
61
61
  return { allowed_paths: allowedPaths, changed_paths: null };
62
62
  }
63
63
 
64
- export function buildMergePlanBody(view, checks, pathScope = {}) {
64
+ export function buildMergePlanBody(view, checks, pathScope = {}, policy = {}) {
65
65
  return {
66
66
  pr_number: view.pr_number,
67
67
  mergeability: view.mergeability,
68
68
  checks_conclusion: checks.check_conclusion,
69
- blockers: mergeBlockersFromFacts(view, checks, pathScope),
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),
70
75
  };
71
76
  }
72
77
 
73
78
  export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
74
79
  const pathScope = resolveMergePlanPathScope(opts);
75
- return buildMergePlanBody(view, checks, pathScope);
80
+ const policy = opts.merge_policy ?? {};
81
+ return buildMergePlanBody(view, checks, pathScope, policy);
76
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
+ }
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.9",
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
  }
@@ -0,0 +1,93 @@
1
+ import { ERROR_CODES } from './contracts/errors.js';
2
+ import { sanitizeField } from './caps.js';
3
+
4
+ const LIVE_REACHABILITY_TIMEOUT_MS = 5000;
5
+
6
+ export { LIVE_REACHABILITY_TIMEOUT_MS };
7
+
8
+ export function classifyReachabilityFailure(err) {
9
+ const code = err?.forgeError?.code;
10
+ const status = err?.status ?? err?.forgeError?.status ?? null;
11
+ let causeCode = null;
12
+ for (let current = err; current; current = current.cause) {
13
+ if (current.code) {
14
+ causeCode = current.code;
15
+ break;
16
+ }
17
+ }
18
+ const message = String(err?.forgeError?.message ?? err?.message ?? '');
19
+
20
+ if (code === ERROR_CODES.UNAUTHENTICATED_PROVIDER) return 'auth_missing';
21
+ if (code === ERROR_CODES.OVERSIZED_RAW_OUTPUT) return 'oversized_raw_output';
22
+ if (status === 401 || /401/.test(message)) return 'http_401';
23
+ if (status === 404 || /not found/i.test(message)) return 'repo_not_found';
24
+ if (status >= 300 && status < 400) return 'redirect_rejected';
25
+ if (/redirect rejected/i.test(message)) return 'redirect_rejected';
26
+ if (causeCode === 'ECONNREFUSED') return 'connection_refused';
27
+ if (causeCode === 'ENOTFOUND' || causeCode === 'EAI_AGAIN') return 'network_unreachable';
28
+ if (causeCode === 'ECONNRESET' || causeCode === 'ENETUNREACH') return 'network_unreachable';
29
+ if (err?.name === 'TimeoutError' || causeCode === 'ETIMEDOUT' || /timed out/i.test(message)) {
30
+ return 'timeout';
31
+ }
32
+ return 'network_unreachable';
33
+ }
34
+
35
+ export function doctorReachabilityCheck(name, status, message, details = null) {
36
+ return {
37
+ name,
38
+ status,
39
+ message: sanitizeField(message),
40
+ ...(details ? { details } : {}),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * @param {object} ctx
46
+ * @param {object | undefined} provider
47
+ * @param {{ live?: boolean, prerequisitesPass?: boolean }} [opts]
48
+ */
49
+ export async function buildApiReachabilityCheck(ctx, provider, opts = {}) {
50
+ const { live = false, prerequisitesPass = false } = opts;
51
+ if (!live) {
52
+ return doctorReachabilityCheck(
53
+ 'api_reachability',
54
+ 'skipped',
55
+ 'Live API reachability is not checked by default',
56
+ );
57
+ }
58
+ if (!prerequisitesPass) {
59
+ return doctorReachabilityCheck(
60
+ 'api_reachability',
61
+ 'fail',
62
+ 'Live reachability requires valid config and trusted host binding',
63
+ { failure_kind: 'prerequisites_failed' },
64
+ );
65
+ }
66
+ if (typeof provider?.apiReachability !== 'function') {
67
+ return doctorReachabilityCheck(
68
+ 'api_reachability',
69
+ 'warn',
70
+ 'Provider does not implement live API reachability',
71
+ { failure_kind: 'not_implemented' },
72
+ );
73
+ }
74
+ try {
75
+ const facts = await provider.apiReachability(ctx);
76
+ return doctorReachabilityCheck(
77
+ 'api_reachability',
78
+ 'pass',
79
+ 'Forge API is reachable',
80
+ { repo_accessible: true, ...(facts && typeof facts === 'object' ? facts : {}) },
81
+ );
82
+ } catch (err) {
83
+ return doctorReachabilityCheck(
84
+ 'api_reachability',
85
+ 'fail',
86
+ err.forgeError?.message || err.message || 'Forge API reachability check failed',
87
+ {
88
+ failure_kind: classifyReachabilityFailure(err),
89
+ ...(err.status != null ? { http_status: err.status } : {}),
90
+ },
91
+ );
92
+ }
93
+ }
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,8 @@ 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';
7
+ import { resolveMergePolicy } from './merge-policy.js';
6
8
  import { assertGitRemote } from './git-args.js';
7
9
  import { gitRepoRoot } from './git-local.js';
8
10
 
@@ -145,8 +147,10 @@ export function forgeContext(loaded) {
145
147
  providerId: config.provider,
146
148
  remoteName: config.remote,
147
149
  repoId: `${parsed.owner}/${parsed.repo}`,
150
+ baseUrl: normalizedForgeOrigin(config),
148
151
  config,
149
152
  cwd: loaded.cwd,
150
153
  parsed,
154
+ mergePolicy: resolveMergePolicy(config),
151
155
  };
152
156
  }
package/status-set.js CHANGED
@@ -64,7 +64,7 @@ export function parseStatusSetArgs({ sha, context, state, target_url, descriptio
64
64
  export function buildCommitStatusSetBody(
65
65
  response,
66
66
  args,
67
- { reusedExisting = false } = {},
67
+ { reusedExisting = false, idempotencyFields = null } = {},
68
68
  ) {
69
69
  const state = normalizeStatusSetState(response?.status ?? response?.state ?? args.state);
70
70
  const body = {
@@ -82,6 +82,11 @@ export function buildCommitStatusSetBody(
82
82
  }
83
83
  if (reusedExisting) {
84
84
  body.reused_existing = true;
85
+ } else {
86
+ body.created = true;
87
+ }
88
+ if (idempotencyFields && typeof idempotencyFields === 'object') {
89
+ Object.assign(body, idempotencyFields);
85
90
  }
86
91
  return body;
87
92
  }