@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.
- package/auth-classes.js +6 -0
- package/change-request-merge-execute.js +148 -0
- package/check-diagnostics.js +92 -0
- package/check-pagination.js +1 -0
- package/config-schema.js +6 -0
- package/contracts/envelope.js +8 -0
- package/contracts/errors.js +3 -0
- package/contracts/forge-error-fields.js +1 -0
- package/contracts/semantic-diff-facts.js +24 -12
- package/cr-inventory-cursor.js +64 -0
- package/cr-inventory.js +33 -15
- package/cr-open.js +6 -1
- package/forge-changes-cursor.js +88 -0
- package/forge-changes.js +2 -2
- package/forge-identity.js +42 -0
- package/idempotency.js +69 -0
- package/index.js +53 -0
- package/issue-open.js +50 -0
- package/merge-blockers.js +28 -3
- package/merge-plan-forge.js +2 -1
- package/merge-plan.js +9 -3
- package/merge-policy.js +55 -0
- package/package.json +1 -1
- package/pr-head-reconcile.js +3 -3
- package/provider-health.js +93 -0
- package/ref-inventory.js +2 -2
- package/resolve.js +4 -0
- package/status-set.js +6 -1
- package/write-config.js +1 -1
- package/write-readiness.js +76 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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'
|
|
23
|
-
|
|
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) {
|
package/merge-plan-forge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
const policy = opts.merge_policy ?? {};
|
|
81
|
+
return buildMergePlanBody(view, checks, pathScope, policy);
|
|
76
82
|
}
|
package/merge-policy.js
ADDED
|
@@ -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
package/pr-head-reconcile.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
}
|