@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
@@ -0,0 +1,73 @@
1
+ /** Per-command auth requirements for structured provider capabilities. */
2
+ export const AUTH_CLASS = {
3
+ NONE: 'none',
4
+ GIT_ONLY: 'git_only',
5
+ TOKEN_REQUIRED: 'token_required',
6
+ };
7
+
8
+ const AUTH_CLASS_VALUES = new Set(Object.values(AUTH_CLASS));
9
+
10
+ /** Runtime auth requirements for fully implemented API providers. */
11
+ export const API_PROVIDER_COMMAND_AUTH = {
12
+ repo_status: AUTH_CLASS.NONE,
13
+ ref_compare: AUTH_CLASS.GIT_ONLY,
14
+ ref_inventory: AUTH_CLASS.GIT_ONLY,
15
+ cr_inventory: AUTH_CLASS.TOKEN_REQUIRED,
16
+ pr_status: AUTH_CLASS.TOKEN_REQUIRED,
17
+ pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
18
+ merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
19
+ sync_plan: AUTH_CLASS.GIT_ONLY,
20
+ cr_open: AUTH_CLASS.TOKEN_REQUIRED,
21
+ issue_open: AUTH_CLASS.TOKEN_REQUIRED,
22
+ status_set: AUTH_CLASS.TOKEN_REQUIRED,
23
+ whoami: AUTH_CLASS.TOKEN_REQUIRED,
24
+ branch_protection: AUTH_CLASS.TOKEN_REQUIRED,
25
+ cr_files: AUTH_CLASS.TOKEN_REQUIRED,
26
+ cr_comments: AUTH_CLASS.TOKEN_REQUIRED,
27
+ forge_changes: AUTH_CLASS.TOKEN_REQUIRED,
28
+ merge_execute: AUTH_CLASS.TOKEN_REQUIRED,
29
+ };
30
+
31
+ export function commandCapability(name, { implemented = true } = {}) {
32
+ const auth_class = API_PROVIDER_COMMAND_AUTH[name];
33
+ if (!auth_class) {
34
+ throw new Error(`Unknown command: ${name}`);
35
+ }
36
+ return { name, implemented, auth_class };
37
+ }
38
+
39
+ export function apiProviderCommands({
40
+ writeCommandsImplemented = false,
41
+ issueOpenImplemented = false,
42
+ statusSetImplemented = false,
43
+ branchProtectionImplemented = false,
44
+ crFilesImplemented = false,
45
+ crCommentsImplemented = false,
46
+ forgeChangesImplemented = false,
47
+ mergeExecuteImplemented = false,
48
+ } = {}) {
49
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
50
+ let implemented = true;
51
+ if (name === 'cr_open') implemented = writeCommandsImplemented;
52
+ if (name === 'issue_open') implemented = issueOpenImplemented;
53
+ if (name === 'status_set') implemented = statusSetImplemented;
54
+ if (name === 'branch_protection') implemented = branchProtectionImplemented;
55
+ if (name === 'cr_files') implemented = crFilesImplemented;
56
+ if (name === 'cr_comments') implemented = crCommentsImplemented;
57
+ if (name === 'forge_changes') implemented = forgeChangesImplemented;
58
+ if (name === 'merge_execute') implemented = mergeExecuteImplemented;
59
+ return commandCapability(name, { implemented });
60
+ });
61
+ }
62
+
63
+ export function stubProviderCommands() {
64
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
65
+ commandCapability(name, { implemented: false }),
66
+ );
67
+ }
68
+
69
+ export function assertAuthClass(value) {
70
+ if (!AUTH_CLASS_VALUES.has(value)) {
71
+ throw new Error(`Invalid auth_class: ${value}`);
72
+ }
73
+ }
@@ -0,0 +1,155 @@
1
+ import { sanitizeField } from './caps.js';
2
+
3
+ export const MAX_BRANCH_PROTECTION_STATUS_CONTEXTS = 64;
4
+ export const MAX_BRANCH_PROTECTION_RULES = 32;
5
+
6
+ /** Gitea exposes required_approvals on branch protection; omit when unavailable. */
7
+ export function unimplementedApprovalsRequiredSignal() {
8
+ return { implemented: false, count: null };
9
+ }
10
+
11
+ function sanitizeStringList(values, maxItems) {
12
+ if (!Array.isArray(values)) return [];
13
+ const out = [];
14
+ for (const value of values) {
15
+ if (out.length >= maxItems) break;
16
+ const sanitized = sanitizeField(value);
17
+ if (sanitized) out.push(sanitized);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function normalizeApprovalsRequired(signal) {
23
+ if (signal == null || typeof signal !== 'object') {
24
+ return unimplementedApprovalsRequiredSignal();
25
+ }
26
+ if (signal.implemented === false) {
27
+ return { implemented: false, count: null };
28
+ }
29
+ if (signal.implemented === true) {
30
+ if (signal.count == null) {
31
+ return { implemented: true, count: null };
32
+ }
33
+ const count = Number(signal.count);
34
+ if (!Number.isFinite(count) || count < 0) {
35
+ return { implemented: true, count: null };
36
+ }
37
+ return { implemented: true, count: Math.floor(count) };
38
+ }
39
+ return unimplementedApprovalsRequiredSignal();
40
+ }
41
+
42
+ export function buildBranchProtectionBody({
43
+ branch_ref,
44
+ required_status_contexts,
45
+ protected_branch_rules,
46
+ approvals_required,
47
+ }) {
48
+ const rules = (Array.isArray(protected_branch_rules) ? protected_branch_rules : [])
49
+ .slice(0, MAX_BRANCH_PROTECTION_RULES)
50
+ .map((rule) => {
51
+ const name =
52
+ rule != null && typeof rule === 'object'
53
+ ? sanitizeField(rule.name)
54
+ : sanitizeField(rule);
55
+ return name ? { name } : null;
56
+ })
57
+ .filter(Boolean);
58
+
59
+ return {
60
+ branch_ref: sanitizeField(branch_ref),
61
+ required_status_contexts: sanitizeStringList(
62
+ required_status_contexts,
63
+ MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
64
+ ),
65
+ protected_branch_rules: rules,
66
+ approvals_required: normalizeApprovalsRequired(approvals_required),
67
+ };
68
+ }
69
+
70
+ export function buildBranchProtectionFromGitLabProtection(
71
+ branchRef,
72
+ { protectedBranch = null, approvalRules = [] } = {},
73
+ ) {
74
+ if (protectedBranch == null) {
75
+ return buildBranchProtectionBody({
76
+ branch_ref: branchRef,
77
+ required_status_contexts: [],
78
+ protected_branch_rules: [],
79
+ approvals_required: unimplementedApprovalsRequiredSignal(),
80
+ });
81
+ }
82
+ const ruleName = sanitizeField(protectedBranch.name ?? branchRef);
83
+ let approvals_required = unimplementedApprovalsRequiredSignal();
84
+ if (Array.isArray(approvalRules) && approvalRules.length > 0) {
85
+ const counts = approvalRules
86
+ .map((rule) => Number(rule.approvals_required))
87
+ .filter((count) => Number.isFinite(count) && count >= 0);
88
+ if (counts.length > 0) {
89
+ approvals_required = { implemented: true, count: Math.max(...counts) };
90
+ }
91
+ }
92
+ return buildBranchProtectionBody({
93
+ branch_ref: branchRef,
94
+ required_status_contexts: [],
95
+ protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
96
+ approvals_required,
97
+ });
98
+ }
99
+
100
+ export function buildBranchProtectionFromGitHubProtection(branchRef, protectionPayload) {
101
+ if (protectionPayload == null) {
102
+ return buildBranchProtectionBody({
103
+ branch_ref: branchRef,
104
+ required_status_contexts: [],
105
+ protected_branch_rules: [],
106
+ approvals_required: unimplementedApprovalsRequiredSignal(),
107
+ });
108
+ }
109
+ const payload =
110
+ protectionPayload != null && typeof protectionPayload === 'object' ? protectionPayload : {};
111
+ const required_status_contexts = sanitizeStringList(
112
+ payload.required_status_checks?.contexts,
113
+ MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
114
+ );
115
+ let approvals_required = unimplementedApprovalsRequiredSignal();
116
+ const reviews = payload.required_pull_request_reviews;
117
+ if (reviews != null && typeof reviews === 'object' && 'required_approving_review_count' in reviews) {
118
+ const count = Number(reviews.required_approving_review_count);
119
+ if (Number.isFinite(count) && count >= 0) {
120
+ approvals_required = { implemented: true, count: Math.floor(count) };
121
+ }
122
+ }
123
+ const ruleName = sanitizeField(branchRef);
124
+ return buildBranchProtectionBody({
125
+ branch_ref: branchRef,
126
+ required_status_contexts,
127
+ protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
128
+ approvals_required,
129
+ });
130
+ }
131
+
132
+ export function buildBranchProtectionFromGiteaProtection(branchRef, protectionPayload) {
133
+ const payload =
134
+ protectionPayload != null && typeof protectionPayload === 'object' ? protectionPayload : {};
135
+ const ruleName = sanitizeField(payload.branch_name ?? payload.rule_name ?? branchRef);
136
+ const required_status_contexts =
137
+ payload.enable_status_check === false
138
+ ? []
139
+ : sanitizeStringList(payload.status_check_contexts, MAX_BRANCH_PROTECTION_STATUS_CONTEXTS);
140
+
141
+ let approvals_required = unimplementedApprovalsRequiredSignal();
142
+ if ('required_approvals' in payload) {
143
+ const count = Number(payload.required_approvals);
144
+ if (Number.isFinite(count) && count >= 0) {
145
+ approvals_required = { implemented: true, count: Math.floor(count) };
146
+ }
147
+ }
148
+
149
+ return buildBranchProtectionBody({
150
+ branch_ref: branchRef,
151
+ required_status_contexts,
152
+ protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
153
+ approvals_required,
154
+ });
155
+ }
package/caps.js CHANGED
@@ -1,10 +1,122 @@
1
1
  export const DEFAULT_MAX_BYTES = 8192;
2
2
  export const DEFAULT_FIELD_MAX_BYTES = 512;
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;
6
+
7
+ /** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
8
+ export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
9
+ export const MAX_CHECK_STATUS_PAGES = 50;
10
+
11
+ /** Gitea open-pull list page size for idempotency scan and inventory list bounds. */
12
+ export const DEFAULT_OPEN_PULL_LIST_PAGE_SIZE = 100;
13
+ /** Max pages scanned before cr open idempotency fails closed (decoupled from check-status pagination). */
14
+ export const MAX_OPEN_PULL_IDEMPOTENCY_PAGES = 50;
15
+
16
+ export function getEffectiveIngestMaxBytes() {
17
+ const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
18
+ if (raw == null || raw === '') {
19
+ return { bytes: DEFAULT_MAX_BYTES, envOverride: false };
20
+ }
21
+ const parsed = Number.parseInt(String(raw), 10);
22
+ if (!Number.isFinite(parsed) || parsed <= 0) {
23
+ return { bytes: DEFAULT_MAX_BYTES, envOverride: false, invalidEnv: true };
24
+ }
25
+ if (parsed > MAX_FORGE_INGEST_ENV_BYTES) {
26
+ return { bytes: MAX_FORGE_INGEST_ENV_BYTES, envOverride: true, clamped: true };
27
+ }
28
+ return { bytes: parsed, envOverride: true };
29
+ }
30
+
31
+ /** Facts for provider capabilities packets (forge ingest policy). */
32
+ export function forgeIngestCapabilityFacts() {
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
+ };
39
+ }
40
+
41
+ /**
42
+ * Structured check-list pagination facts for provider capabilities.
43
+ * @param {{ strategy: 'offset_limit' | 'link_header', pageSizeParam: 'limit' | 'per_page' | null, sourceCount?: number }} opts
44
+ */
45
+ export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, sourceCount = 1 }) {
46
+ const perSource = DEFAULT_CHECK_STATUS_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
47
+ return {
48
+ check_pagination: {
49
+ strategy,
50
+ page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
51
+ max_pages: MAX_CHECK_STATUS_PAGES,
52
+ page_size_param: pageSizeParam,
53
+ ingest_backoff: 'halve_until_fit',
54
+ on_page_cap: 'set_checks_truncated',
55
+ compliant_max_items_per_source: perSource,
56
+ check_source_count: sourceCount,
57
+ truncation_combination:
58
+ sourceCount > 1 ? 'any_source_truncated' : 'single_source',
59
+ compliant_max_items_total: perSource * sourceCount,
60
+ truncation_packet_field: 'checks_truncated',
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Structured idempotency scan facts for provider capabilities (cr open). */
66
+ export function idempotencyScanCapabilityFacts() {
67
+ return {
68
+ idempotency_scan: {
69
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
70
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
71
+ ingest_backoff: 'halve_until_fit',
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Idempotency scan facts for status set (commit-status list pagination). */
77
+ export function statusSetIdempotencyScanCapabilityFacts() {
78
+ return {
79
+ idempotency_scan: {
80
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
81
+ page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
82
+ ingest_backoff: 'halve_until_fit',
83
+ },
84
+ };
85
+ }
86
+
87
+ /** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
88
+ export function openPullListCapabilityFacts({
89
+ totalCountSource = null,
90
+ totalCountHeader = null,
91
+ sliceSortNotes = null,
92
+ } = {}) {
93
+ const compliantMaxItems = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
94
+ return {
95
+ open_pull_list: {
96
+ max_pages: MAX_CHECK_STATUS_PAGES,
97
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
98
+ ingest_backoff: 'halve_until_fit',
99
+ compliant_max_items: compliantMaxItems,
100
+ truncation_packet_field: 'list_truncated',
101
+ incomplete_error_code: 'inventory_list_incomplete',
102
+ default_slice_sort: 'number_asc',
103
+ supported_slice_sorts: [
104
+ 'number_asc',
105
+ 'number_desc',
106
+ 'recent_update',
107
+ 'recent_created',
108
+ ],
109
+ ...(totalCountSource ? { total_count_source: totalCountSource } : {}),
110
+ ...(totalCountHeader ? { total_count_header: totalCountHeader } : {}),
111
+ ...(sliceSortNotes ? { slice_sort_notes: sliceSortNotes } : {}),
112
+ },
113
+ };
114
+ }
3
115
 
4
116
  export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
5
117
  if (!text) return { text: '', truncated: false, bytes: 0 };
6
118
  const buf = Buffer.from(text, 'utf8');
7
- if (buf.length <= maxBytes) {
119
+ if (maxBytes == null || buf.length <= maxBytes) {
8
120
  return { text, truncated: false, bytes: buf.length };
9
121
  }
10
122
  let end = maxBytes;
@@ -13,13 +125,78 @@ export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
13
125
  return { text: slice, truncated: true, bytes: end };
14
126
  }
15
127
 
16
- export function sanitizeField(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
17
- if (value == null) return null;
18
- const singleLine = String(value)
128
+ function redactSecretPatterns(text) {
129
+ return text
130
+ .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
131
+ .replace(/\bghp_[A-Za-z0-9]+\b/g, '[REDACTED]')
132
+ .replace(/\bgho_[A-Za-z0-9]+\b/g, '[REDACTED]')
133
+ .replace(/\bghs_[A-Za-z0-9._-]{36,}/g, '[REDACTED]')
134
+ .replace(/\bghs_[A-Za-z0-9_-]+\b/g, '[REDACTED]')
135
+ .replace(/\bglpat-[A-Za-z0-9_-]+\b/g, '[REDACTED]')
136
+ .replace(/\b(GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|GITEA_TOKEN)\b/gi, '[REDACTED]');
137
+ }
138
+
139
+ function stripControlChars(text, { preserveNewlines = false } = {}) {
140
+ if (preserveNewlines) {
141
+ return String(text)
142
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ' ')
143
+ .replace(/\r\n/g, '\n')
144
+ .replace(/\r/g, '\n');
145
+ }
146
+ return String(text)
19
147
  .replace(/[\x00-\x1f\x7f]/g, ' ')
20
148
  .replace(/\r?\n/g, ' ')
21
149
  .trim();
22
- return capText(singleLine, maxBytes).text;
150
+ }
151
+
152
+ function applyFieldCap(text, maxBytes) {
153
+ if (maxBytes == null) return text;
154
+ return capText(text, maxBytes).text;
155
+ }
156
+
157
+ /**
158
+ * Read/packet sanitization — always capped at DEFAULT_FIELD_MAX_BYTES; newlines collapsed.
159
+ */
160
+ export function sanitizeReadField(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
161
+ if (value == null) return null;
162
+ const singleLine = stripControlChars(value, { preserveNewlines: false });
163
+ const redacted = redactSecretPatterns(singleLine);
164
+ return applyFieldCap(redacted, maxBytes);
165
+ }
166
+
167
+ /** @deprecated Use sanitizeReadField for read paths; alias preserved for compatibility. */
168
+ export function sanitizeField(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
169
+ return sanitizeReadField(value, maxBytes);
170
+ }
171
+
172
+ /**
173
+ * Forge write body sanitization — secret redaction always on; newlines preserved; cap configurable.
174
+ * @param {string | null | undefined} value
175
+ * @param {{ fieldMaxBytes?: number | null } | null} writeFieldPolicy
176
+ */
177
+ export function sanitizeWriteBody(value, writeFieldPolicy = null) {
178
+ if (value == null) return null;
179
+ const maxBytes = writeFieldPolicy?.uncapped
180
+ ? null
181
+ : (writeFieldPolicy?.fieldMaxBytes ?? DEFAULT_FIELD_MAX_BYTES);
182
+ const cleaned = stripControlChars(value, { preserveNewlines: true }).trim();
183
+ const redacted = redactSecretPatterns(cleaned);
184
+ return applyFieldCap(redacted, maxBytes);
185
+ }
186
+
187
+ /**
188
+ * Forge write title/short field — redact + cap; single-line normalization.
189
+ * @param {string | null | undefined} value
190
+ * @param {{ fieldMaxBytes?: number | null, uncapped?: boolean } | null} writeFieldPolicy
191
+ */
192
+ export function sanitizeWriteTitle(value, writeFieldPolicy = null) {
193
+ if (value == null) return null;
194
+ const maxBytes = writeFieldPolicy?.uncapped
195
+ ? null
196
+ : (writeFieldPolicy?.fieldMaxBytes ?? DEFAULT_FIELD_MAX_BYTES);
197
+ const singleLine = stripControlChars(value, { preserveNewlines: false });
198
+ const redacted = redactSecretPatterns(singleLine);
199
+ return applyFieldCap(redacted, maxBytes);
23
200
  }
24
201
 
25
202
  export function sanitizeUrl(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
@@ -27,7 +204,9 @@ export function sanitizeUrl(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
27
204
  try {
28
205
  const u = new URL(String(value));
29
206
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
30
- return sanitizeField(u.href, maxBytes);
207
+ u.username = '';
208
+ u.password = '';
209
+ return sanitizeReadField(u.href, maxBytes);
31
210
  } catch {
32
211
  return null;
33
212
  }
@@ -0,0 +1,148 @@
1
+ import { sanitizeField } from './caps.js';
2
+ import { mergeBlockersFromFacts } from './merge-blockers.js';
3
+ import { mergePolicyAuditFacts } from './merge-policy.js';
4
+
5
+ const SHA40 = /^[0-9a-f]{40}$/i;
6
+
7
+ export function mergeExecuteViewFacts(view) {
8
+ return {
9
+ sourceBranchRef: view.forge_source_branch_ref ?? view.head_ref ?? null,
10
+ sourceSha: view.forge_source_sha ?? view.head_sha ?? null,
11
+ targetSha: view.forge_target_sha ?? view.base_sha ?? null,
12
+ };
13
+ }
14
+
15
+ export function mergeExecuteChecksFacts(checks) {
16
+ return {
17
+ sourceSha: checks.forge_source_sha ?? checks.head_sha ?? null,
18
+ };
19
+ }
20
+
21
+ export function assertExpectedSha(value, flagName) {
22
+ const normalized = String(value ?? '').trim().toLowerCase();
23
+ if (!SHA40.test(normalized)) {
24
+ throw Object.assign(new Error(`Invalid ${flagName}`), {
25
+ invalidArgs: `${flagName} must be a 40-character git SHA`,
26
+ });
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ export function buildMergeExecuteBeforeFacts(
32
+ view,
33
+ checks,
34
+ mergePlanBody,
35
+ forgeHeadRefSha = null,
36
+ mergePolicy = null,
37
+ ) {
38
+ const viewFacts = mergeExecuteViewFacts(view);
39
+ const checksFacts = mergeExecuteChecksFacts(checks);
40
+ const audit = mergePolicyAuditFacts(mergePolicy);
41
+ return {
42
+ base_sha: viewFacts.targetSha ?? null,
43
+ head_sha: viewFacts.sourceSha ?? null,
44
+ checks_head_sha: checksFacts.sourceSha ?? null,
45
+ forge_head_ref_sha: forgeHeadRefSha ?? null,
46
+ mergeability: view.mergeability ?? 'unknown',
47
+ checks_conclusion: checks.check_conclusion ?? 'unknown',
48
+ checks_truncated: checks.checks_truncated === true,
49
+ merge_plan_blockers: Array.isArray(mergePlanBody.blockers) ? [...mergePlanBody.blockers] : [],
50
+ ...(audit ? { merge_policy: audit } : {}),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Collect merge-execute blockers before forge mutation.
56
+ * @returns {string[]} normalized blocker ids
57
+ */
58
+ export function collectMergeExecuteBlockers(
59
+ view,
60
+ checks,
61
+ mergePlanBody,
62
+ expected,
63
+ { forgeHeadRefSha, mergePolicy = {} } = {},
64
+ ) {
65
+ const viewFacts = mergeExecuteViewFacts(view);
66
+ const checksFacts = mergeExecuteChecksFacts(checks);
67
+ const blockers = [];
68
+ const baseSha = viewFacts.targetSha ? String(viewFacts.targetSha).toLowerCase() : null;
69
+ const headSha = viewFacts.sourceSha ? String(viewFacts.sourceSha).toLowerCase() : null;
70
+
71
+ if (baseSha && expected.baseSha !== baseSha) blockers.push('base_sha_mismatch');
72
+ if (headSha && expected.headSha !== headSha) blockers.push('head_sha_mismatch');
73
+
74
+ const forgeHead = forgeHeadRefSha ? String(forgeHeadRefSha).toLowerCase() : null;
75
+ const checksHead = checksFacts.sourceSha ? String(checksFacts.sourceSha).toLowerCase() : null;
76
+ if (headSha && checksHead && headSha !== checksHead) blockers.push('checks_head_sha_mismatch');
77
+ if (headSha && forgeHead && headSha !== forgeHead) blockers.push('forge_pr_head_mismatch');
78
+ if (checksHead && forgeHead && checksHead !== forgeHead) blockers.push('checks_forge_head_mismatch');
79
+ if (forgeHead && forgeHead !== expected.headSha) blockers.push('head_ref_moved');
80
+
81
+ const planBlockers = mergeBlockersFromFacts(view, checks, {}, mergePolicy);
82
+ for (const blocker of planBlockers) {
83
+ if (!blockers.includes(blocker)) blockers.push(blocker);
84
+ }
85
+
86
+ if (view.mergeability !== 'clean') {
87
+ if (view.mergeability === 'conflicted' && !blockers.includes('merge_conflict')) {
88
+ blockers.push('merge_conflict');
89
+ } else if (view.mergeability !== 'conflicted' && !blockers.includes('mergeability_not_clean')) {
90
+ blockers.push('mergeability_not_clean');
91
+ }
92
+ }
93
+
94
+ return blockers;
95
+ }
96
+
97
+ export function buildCrMergeBlockedBody({
98
+ prNumber,
99
+ expected,
100
+ before,
101
+ blockers,
102
+ }) {
103
+ return {
104
+ change_request: { number: prNumber },
105
+ expected: {
106
+ base_sha: expected.baseSha,
107
+ head_sha: expected.headSha,
108
+ },
109
+ before,
110
+ blockers,
111
+ };
112
+ }
113
+
114
+ export function buildCrMergedBody({
115
+ prNumber,
116
+ expected,
117
+ before,
118
+ merge,
119
+ after,
120
+ }) {
121
+ return {
122
+ change_request: { number: prNumber, state: 'merged' },
123
+ expected: {
124
+ base_sha: expected.baseSha,
125
+ head_sha: expected.headSha,
126
+ },
127
+ before,
128
+ merge,
129
+ after,
130
+ };
131
+ }
132
+
133
+ export function buildMergeExecuteAfterFacts(view, mergeResult = {}) {
134
+ const viewFacts = mergeExecuteViewFacts(view);
135
+ return {
136
+ state: 'merged',
137
+ base_sha: mergeResult.base_sha ?? viewFacts.targetSha ?? null,
138
+ head_sha: viewFacts.sourceSha ?? null,
139
+ };
140
+ }
141
+
142
+ export function buildMergeExecuteMergeFacts(method, providerResult = {}) {
143
+ return {
144
+ method: sanitizeField(method),
145
+ commit_sha: providerResult.commit_sha ?? null,
146
+ provider_status: providerResult.provider_status ?? null,
147
+ };
148
+ }
@@ -0,0 +1,92 @@
1
+ import { sanitizeField } from './caps.js';
2
+
3
+ function uniqueSorted(values) {
4
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
5
+ }
6
+
7
+ /**
8
+ * @param {object} entry
9
+ * @param {{ headSha?: string | null, requiredContexts?: Set<string> }} opts
10
+ */
11
+ export function enrichCheckStatus(entry, opts = {}) {
12
+ const headSha = opts.headSha ?? null;
13
+ const context = sanitizeField(entry?.context);
14
+ const rawSha = entry?.sha != null ? sanitizeField(entry.sha) : headSha;
15
+ const sha = rawSha || null;
16
+ const stale = Boolean(headSha && sha && sha !== headSha);
17
+ const required = context ? opts.requiredContexts?.has(context) === true : false;
18
+ return {
19
+ context,
20
+ state: entry?.state ?? 'unknown',
21
+ ...(sha ? { sha } : {}),
22
+ required,
23
+ source: sanitizeField(entry?.source) || 'commit_status',
24
+ ...(entry?.target_url ? { target_url: sanitizeField(entry.target_url) } : {}),
25
+ ...(entry?.description ? { description: sanitizeField(entry.description) } : {}),
26
+ ...(stale ? { stale: true } : {}),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * @param {object[]} statuses
32
+ * @param {{ headSha?: string | null, requiredContexts?: string[] }} opts
33
+ */
34
+ export function buildCheckDiagnostics(statuses, opts = {}) {
35
+ const headSha = opts.headSha ?? null;
36
+ const requiredContexts = uniqueSorted(
37
+ Array.isArray(opts.requiredContexts) ? opts.requiredContexts.map((c) => sanitizeField(c)) : [],
38
+ );
39
+ const requiredSet = new Set(requiredContexts);
40
+ const enriched = (Array.isArray(statuses) ? statuses : []).map((entry) =>
41
+ enrichCheckStatus(entry, { headSha, requiredContexts: requiredSet }),
42
+ );
43
+ const byContext = new Map();
44
+ for (const status of enriched) {
45
+ if (status.context) byContext.set(status.context, status);
46
+ }
47
+
48
+ const failed_contexts = [];
49
+ const pending_contexts = [];
50
+ const stale_contexts = [];
51
+ for (const status of enriched) {
52
+ if (!status.context) continue;
53
+ if (status.stale === true) stale_contexts.push(status.context);
54
+ if (status.state === 'failure') failed_contexts.push(status.context);
55
+ if (status.state === 'pending') pending_contexts.push(status.context);
56
+ }
57
+
58
+ const missing_required_contexts = [];
59
+ for (const context of requiredContexts) {
60
+ if (!byContext.has(context)) missing_required_contexts.push(context);
61
+ }
62
+
63
+ return {
64
+ statuses: enriched,
65
+ required_contexts: requiredContexts,
66
+ missing_required_contexts: uniqueSorted(missing_required_contexts),
67
+ failed_contexts: uniqueSorted(failed_contexts),
68
+ pending_contexts: uniqueSorted(pending_contexts),
69
+ stale_contexts: uniqueSorted(stale_contexts),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * @param {{ forge_source_sha: string, check_conclusion: string, checks_truncated?: boolean, statuses?: object[], required_contexts?: string[] }} body
75
+ */
76
+ export function buildPrChecksBody(body) {
77
+ const diagnostics = buildCheckDiagnostics(body.statuses ?? [], {
78
+ headSha: body.forge_source_sha,
79
+ requiredContexts: body.required_contexts ?? [],
80
+ });
81
+ return {
82
+ forge_source_sha: body.forge_source_sha,
83
+ check_conclusion: body.check_conclusion,
84
+ checks_truncated: body.checks_truncated === true,
85
+ statuses: diagnostics.statuses,
86
+ required_contexts: diagnostics.required_contexts,
87
+ missing_required_contexts: diagnostics.missing_required_contexts,
88
+ failed_contexts: diagnostics.failed_contexts,
89
+ pending_contexts: diagnostics.pending_contexts,
90
+ stale_contexts: diagnostics.stale_contexts,
91
+ };
92
+ }