@remogram/core 0.1.0-beta.8 → 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 +3 -0
- package/change-request-merge-execute.js +12 -3
- package/check-diagnostics.js +92 -0
- package/check-pagination.js +1 -0
- package/config-schema.js +6 -0
- package/contracts/envelope.js +1 -0
- package/contracts/errors.js +1 -0
- package/contracts/semantic-diff-facts.js +12 -1
- package/cr-inventory-cursor.js +64 -0
- package/cr-inventory.js +26 -8
- package/cr-open.js +6 -1
- package/forge-changes-cursor.js +88 -0
- package/idempotency.js +69 -0
- package/index.js +41 -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/provider-health.js +93 -0
- package/resolve.js +2 -0
- package/status-set.js +6 -1
- package/write-config.js +1 -1
- package/write-readiness.js +76 -0
package/auth-classes.js
CHANGED
|
@@ -18,6 +18,7 @@ export const API_PROVIDER_COMMAND_AUTH = {
|
|
|
18
18
|
merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
|
|
19
19
|
sync_plan: AUTH_CLASS.GIT_ONLY,
|
|
20
20
|
cr_open: AUTH_CLASS.TOKEN_REQUIRED,
|
|
21
|
+
issue_open: AUTH_CLASS.TOKEN_REQUIRED,
|
|
21
22
|
status_set: AUTH_CLASS.TOKEN_REQUIRED,
|
|
22
23
|
whoami: AUTH_CLASS.TOKEN_REQUIRED,
|
|
23
24
|
branch_protection: AUTH_CLASS.TOKEN_REQUIRED,
|
|
@@ -37,6 +38,7 @@ export function commandCapability(name, { implemented = true } = {}) {
|
|
|
37
38
|
|
|
38
39
|
export function apiProviderCommands({
|
|
39
40
|
writeCommandsImplemented = false,
|
|
41
|
+
issueOpenImplemented = false,
|
|
40
42
|
statusSetImplemented = false,
|
|
41
43
|
branchProtectionImplemented = false,
|
|
42
44
|
crFilesImplemented = false,
|
|
@@ -47,6 +49,7 @@ export function apiProviderCommands({
|
|
|
47
49
|
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
|
|
48
50
|
let implemented = true;
|
|
49
51
|
if (name === 'cr_open') implemented = writeCommandsImplemented;
|
|
52
|
+
if (name === 'issue_open') implemented = issueOpenImplemented;
|
|
50
53
|
if (name === 'status_set') implemented = statusSetImplemented;
|
|
51
54
|
if (name === 'branch_protection') implemented = branchProtectionImplemented;
|
|
52
55
|
if (name === 'cr_files') implemented = crFilesImplemented;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sanitizeField } from './caps.js';
|
|
2
2
|
import { mergeBlockersFromFacts } from './merge-blockers.js';
|
|
3
|
+
import { mergePolicyAuditFacts } from './merge-policy.js';
|
|
3
4
|
|
|
4
5
|
const SHA40 = /^[0-9a-f]{40}$/i;
|
|
5
6
|
|
|
@@ -27,9 +28,16 @@ export function assertExpectedSha(value, flagName) {
|
|
|
27
28
|
return normalized;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function buildMergeExecuteBeforeFacts(
|
|
31
|
+
export function buildMergeExecuteBeforeFacts(
|
|
32
|
+
view,
|
|
33
|
+
checks,
|
|
34
|
+
mergePlanBody,
|
|
35
|
+
forgeHeadRefSha = null,
|
|
36
|
+
mergePolicy = null,
|
|
37
|
+
) {
|
|
31
38
|
const viewFacts = mergeExecuteViewFacts(view);
|
|
32
39
|
const checksFacts = mergeExecuteChecksFacts(checks);
|
|
40
|
+
const audit = mergePolicyAuditFacts(mergePolicy);
|
|
33
41
|
return {
|
|
34
42
|
base_sha: viewFacts.targetSha ?? null,
|
|
35
43
|
head_sha: viewFacts.sourceSha ?? null,
|
|
@@ -39,6 +47,7 @@ export function buildMergeExecuteBeforeFacts(view, checks, mergePlanBody, forgeH
|
|
|
39
47
|
checks_conclusion: checks.check_conclusion ?? 'unknown',
|
|
40
48
|
checks_truncated: checks.checks_truncated === true,
|
|
41
49
|
merge_plan_blockers: Array.isArray(mergePlanBody.blockers) ? [...mergePlanBody.blockers] : [],
|
|
50
|
+
...(audit ? { merge_policy: audit } : {}),
|
|
42
51
|
};
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -51,7 +60,7 @@ export function collectMergeExecuteBlockers(
|
|
|
51
60
|
checks,
|
|
52
61
|
mergePlanBody,
|
|
53
62
|
expected,
|
|
54
|
-
{ forgeHeadRefSha } = {},
|
|
63
|
+
{ forgeHeadRefSha, mergePolicy = {} } = {},
|
|
55
64
|
) {
|
|
56
65
|
const viewFacts = mergeExecuteViewFacts(view);
|
|
57
66
|
const checksFacts = mergeExecuteChecksFacts(checks);
|
|
@@ -69,7 +78,7 @@ export function collectMergeExecuteBlockers(
|
|
|
69
78
|
if (checksHead && forgeHead && checksHead !== forgeHead) blockers.push('checks_forge_head_mismatch');
|
|
70
79
|
if (forgeHead && forgeHead !== expected.headSha) blockers.push('head_ref_moved');
|
|
71
80
|
|
|
72
|
-
const planBlockers = mergeBlockersFromFacts(view, checks, {});
|
|
81
|
+
const planBlockers = mergeBlockersFromFacts(view, checks, {}, mergePolicy);
|
|
73
82
|
for (const blocker of planBlockers) {
|
|
74
83
|
if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
75
84
|
}
|
|
@@ -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
|
+
}
|
package/check-pagination.js
CHANGED
package/config-schema.js
CHANGED
|
@@ -25,6 +25,12 @@ export const configSchema = z
|
|
|
25
25
|
repo: repoSegmentSchema,
|
|
26
26
|
baseUrl: z.string().url().optional(),
|
|
27
27
|
write_commands: z.array(writeCommandSchema).optional(),
|
|
28
|
+
merge_policy: z
|
|
29
|
+
.object({
|
|
30
|
+
allow_missing_checks: z.boolean().optional(),
|
|
31
|
+
allow_pending_checks: z.boolean().optional(),
|
|
32
|
+
})
|
|
33
|
+
.optional(),
|
|
28
34
|
})
|
|
29
35
|
.strict();
|
|
30
36
|
|
package/contracts/envelope.js
CHANGED
|
@@ -14,6 +14,7 @@ export const PACKET_TYPES = {
|
|
|
14
14
|
PROVIDER_DOCTOR: 'provider_doctor',
|
|
15
15
|
FORGE_ERROR: 'forge_error',
|
|
16
16
|
CHANGE_REQUEST_OPENED: 'change_request_opened',
|
|
17
|
+
ISSUE_OPENED: 'issue_opened',
|
|
17
18
|
COMMIT_STATUS_SET: 'commit_status_set',
|
|
18
19
|
PROVIDER_IDENTITY: 'provider_identity',
|
|
19
20
|
BRANCH_PROTECTION: 'branch_protection',
|
package/contracts/errors.js
CHANGED
|
@@ -15,6 +15,7 @@ export const ERROR_CODES = {
|
|
|
15
15
|
REMOTE_INFER_FAILED: 'remote_infer_failed',
|
|
16
16
|
WRITE_NOT_CONFIGURED: 'write_not_configured',
|
|
17
17
|
IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
|
|
18
|
+
IDEMPOTENCY_CONFLICT: 'idempotency_conflict',
|
|
18
19
|
INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
|
|
19
20
|
MERGE_BLOCKED: 'merge_blocked',
|
|
20
21
|
MERGE_ENDPOINT_FAILED: 'merge_endpoint_failed',
|
|
@@ -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', 'merge execute']);
|
|
31
|
+
export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set', 'merge execute', 'issue open']);
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
|
|
@@ -75,6 +75,9 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
|
|
|
75
75
|
auth_present: true,
|
|
76
76
|
can_write: true,
|
|
77
77
|
reused_existing: true,
|
|
78
|
+
created: true,
|
|
79
|
+
idempotency_fingerprint: true,
|
|
80
|
+
ambiguous_after_write: true,
|
|
78
81
|
idempotency_scan: true,
|
|
79
82
|
});
|
|
80
83
|
|
|
@@ -135,6 +138,14 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
|
|
|
135
138
|
/** true when list cap applied (entry_count > limit), not missing entries */
|
|
136
139
|
truncated: 'boolean',
|
|
137
140
|
list_truncated: 'boolean',
|
|
141
|
+
/** true when more pages remain via next_cursor */
|
|
142
|
+
has_more: 'boolean',
|
|
143
|
+
/** true when open-list enumeration is complete for slice_sort */
|
|
144
|
+
complete: 'boolean',
|
|
145
|
+
/** opaque cursor for the next page when has_more is true */
|
|
146
|
+
next_cursor: 'string optional',
|
|
147
|
+
/** numbers consumed from open-list ordering including skipped entries */
|
|
148
|
+
entry_count_observed: 'number',
|
|
138
149
|
/** normalized slice sort preset applied to open-list resolution */
|
|
139
150
|
slice_sort: 'string',
|
|
140
151
|
entries_skipped:
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
2
|
+
import { normalizeCrInventorySort } from './open-pull-list.js';
|
|
3
|
+
|
|
4
|
+
export const CR_INVENTORY_CURSOR_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ sort: string, offset: number }} state
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function encodeCrInventoryCursor(state) {
|
|
11
|
+
const sort = normalizeCrInventorySort(state.sort);
|
|
12
|
+
const offset = Number(state.offset);
|
|
13
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
14
|
+
throw Object.assign(new Error('Invalid cursor offset'), {
|
|
15
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'cursor offset must be a non-negative integer'),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const payload = JSON.stringify({ v: CR_INVENTORY_CURSOR_VERSION, sort, offset });
|
|
19
|
+
return Buffer.from(payload, 'utf8').toString('base64url');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} raw
|
|
24
|
+
* @param {{ sort?: string | null }} [opts]
|
|
25
|
+
* @returns {{ sort: string, offset: number }}
|
|
26
|
+
*/
|
|
27
|
+
export function decodeCrInventoryCursor(raw, opts = {}) {
|
|
28
|
+
if (raw == null || raw === '') {
|
|
29
|
+
throw Object.assign(new Error('Missing cursor'), {
|
|
30
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor must not be empty when provided'),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
let decoded;
|
|
34
|
+
try {
|
|
35
|
+
decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
throw Object.assign(new Error('Invalid cursor'), {
|
|
38
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor is malformed or not base64url JSON'),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (decoded?.v !== CR_INVENTORY_CURSOR_VERSION) {
|
|
42
|
+
throw Object.assign(new Error('Invalid cursor version'), {
|
|
43
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor version is unsupported'),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const sort = normalizeCrInventorySort(decoded.sort);
|
|
47
|
+
const offset = Number(decoded.offset);
|
|
48
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
49
|
+
throw Object.assign(new Error('Invalid cursor offset'), {
|
|
50
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor offset must be a non-negative integer'),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const requestedSort =
|
|
54
|
+
opts.sort == null || opts.sort === '' ? null : normalizeCrInventorySort(opts.sort);
|
|
55
|
+
if (requestedSort != null && requestedSort !== sort) {
|
|
56
|
+
throw Object.assign(new Error('Cursor sort mismatch'), {
|
|
57
|
+
forgeError: forgeError(
|
|
58
|
+
ERROR_CODES.INVALID_ARGS,
|
|
59
|
+
'--sort must match the cursor slice_sort when both are provided',
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { sort, offset };
|
|
64
|
+
}
|
package/cr-inventory.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sanitizeField } from './caps.js';
|
|
2
2
|
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
import { decodeCrInventoryCursor, encodeCrInventoryCursor } from './cr-inventory-cursor.js';
|
|
3
4
|
import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
@@ -22,9 +23,9 @@ export function normalizeCrInventoryLimit(value) {
|
|
|
22
23
|
return n;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
async function resolveOpenPullList(provider, ctx,
|
|
26
|
+
async function resolveOpenPullList(provider, ctx, listWindow, sliceSort) {
|
|
26
27
|
if (typeof provider.listOpenPullsWithMeta === 'function') {
|
|
27
|
-
return provider.listOpenPullsWithMeta(ctx, { retain_max:
|
|
28
|
+
return provider.listOpenPullsWithMeta(ctx, { retain_max: listWindow, sort: sliceSort });
|
|
28
29
|
}
|
|
29
30
|
const numbers = await provider.listOpenPulls(ctx, {});
|
|
30
31
|
return { numbers, list_truncated: false };
|
|
@@ -64,7 +65,7 @@ export function buildCrInventoryEntry(ctx, view, checks) {
|
|
|
64
65
|
mergeability: view.mergeability,
|
|
65
66
|
checks_conclusion: checks.check_conclusion,
|
|
66
67
|
checks_truncated: checks.checks_truncated === true,
|
|
67
|
-
blockers: mergeBlockersFromFacts(view, checks),
|
|
68
|
+
blockers: mergeBlockersFromFacts(view, checks, {}, ctx.mergePolicy ?? {}),
|
|
68
69
|
head_reconcile: buildHeadReconcile(ctx, view),
|
|
69
70
|
};
|
|
70
71
|
if (view.forge_target_sha) entry.forge_target_sha = view.forge_target_sha;
|
|
@@ -76,18 +77,26 @@ export function buildCrInventoryEntry(ctx, view, checks) {
|
|
|
76
77
|
* Aggregate open change requests into a semantic-diff-oriented inventory slice.
|
|
77
78
|
* @param {object} ctx forge context
|
|
78
79
|
* @param {object} provider must expose listOpenPulls, prView, prChecks
|
|
79
|
-
* @param {{ slice_ref?: string, limit?: number, sort?: string }} [opts]
|
|
80
|
+
* @param {{ slice_ref?: string, limit?: number, sort?: string, cursor?: string }} [opts]
|
|
80
81
|
*/
|
|
81
82
|
export async function crInventory(ctx, provider, opts = {}) {
|
|
82
83
|
const limit = normalizeCrInventoryLimit(opts.limit);
|
|
83
|
-
|
|
84
|
+
let sliceSort = normalizeCrInventorySort(opts.sort);
|
|
85
|
+
let offset = 0;
|
|
86
|
+
if (opts.cursor != null && opts.cursor !== '') {
|
|
87
|
+
const decoded = decodeCrInventoryCursor(opts.cursor, { sort: opts.sort });
|
|
88
|
+
offset = decoded.offset;
|
|
89
|
+
sliceSort = decoded.sort;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const listWindow = offset + limit;
|
|
84
93
|
const {
|
|
85
94
|
numbers,
|
|
86
95
|
list_truncated: listTruncated,
|
|
87
96
|
entry_count: providerEntryCount,
|
|
88
|
-
} = await resolveOpenPullList(provider, ctx,
|
|
97
|
+
} = await resolveOpenPullList(provider, ctx, listWindow, sliceSort);
|
|
89
98
|
const entryCount = providerEntryCount ?? numbers.length;
|
|
90
|
-
const selected = numbers.slice(
|
|
99
|
+
const selected = numbers.slice(offset, offset + limit);
|
|
91
100
|
const entries = [];
|
|
92
101
|
const entries_skipped = [];
|
|
93
102
|
for (const number of selected) {
|
|
@@ -106,13 +115,22 @@ export async function crInventory(ctx, provider, opts = {}) {
|
|
|
106
115
|
});
|
|
107
116
|
}
|
|
108
117
|
}
|
|
118
|
+
|
|
119
|
+
const observedEnd = offset + selected.length;
|
|
120
|
+
const hasMore = listTruncated || observedEnd < entryCount;
|
|
121
|
+
const complete = !hasMore;
|
|
122
|
+
|
|
109
123
|
return {
|
|
110
124
|
entries,
|
|
111
125
|
...(entries_skipped.length ? { entries_skipped } : {}),
|
|
112
126
|
entry_count: entryCount,
|
|
113
|
-
|
|
127
|
+
entry_count_observed: observedEnd,
|
|
128
|
+
truncated: entryCount > observedEnd || listTruncated,
|
|
114
129
|
list_truncated: listTruncated,
|
|
115
130
|
slice_sort: sliceSort ?? DEFAULT_CR_INVENTORY_SLICE_SORT,
|
|
131
|
+
has_more: hasMore,
|
|
132
|
+
complete,
|
|
133
|
+
...(hasMore ? { next_cursor: encodeCrInventoryCursor({ sort: sliceSort, offset: observedEnd }) } : {}),
|
|
116
134
|
...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
|
|
117
135
|
};
|
|
118
136
|
}
|
package/cr-open.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
|
5
5
|
export function buildChangeRequestOpenedBody(
|
|
6
6
|
pull,
|
|
7
7
|
{ head, base, title },
|
|
8
|
-
{ reusedExisting = false } = {},
|
|
8
|
+
{ reusedExisting = false, idempotencyFields = null } = {},
|
|
9
9
|
) {
|
|
10
10
|
const prNumber = Number(pull?.number);
|
|
11
11
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
@@ -28,6 +28,11 @@ export function buildChangeRequestOpenedBody(
|
|
|
28
28
|
};
|
|
29
29
|
if (reusedExisting) {
|
|
30
30
|
body.reused_existing = true;
|
|
31
|
+
} else {
|
|
32
|
+
body.created = true;
|
|
33
|
+
}
|
|
34
|
+
if (idempotencyFields && typeof idempotencyFields === 'object') {
|
|
35
|
+
Object.assign(body, idempotencyFields);
|
|
31
36
|
}
|
|
32
37
|
return body;
|
|
33
38
|
}
|
|
@@ -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/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,7 @@ 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';
|
|
78
84
|
export {
|
|
79
85
|
assertExpectedSha,
|
|
80
86
|
mergeExecuteViewFacts,
|
|
@@ -115,6 +121,11 @@ export {
|
|
|
115
121
|
MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
|
|
116
122
|
MAX_BRANCH_PROTECTION_RULES,
|
|
117
123
|
} from './branch-protection.js';
|
|
124
|
+
export {
|
|
125
|
+
enrichCheckStatus,
|
|
126
|
+
buildCheckDiagnostics,
|
|
127
|
+
buildPrChecksBody,
|
|
128
|
+
} from './check-diagnostics.js';
|
|
118
129
|
export {
|
|
119
130
|
buildCrFilesBody,
|
|
120
131
|
buildCrFilesFromGiteaFiles,
|
|
@@ -137,6 +148,13 @@ export {
|
|
|
137
148
|
MAX_FORGE_CHANGES_EVENTS,
|
|
138
149
|
FORGE_CHANGE_EVENT_KINDS,
|
|
139
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';
|
|
140
158
|
export {
|
|
141
159
|
WRITE_COMMAND_IDS,
|
|
142
160
|
CONFIGURED_WRITE_COMMANDS,
|
|
@@ -145,6 +163,29 @@ export {
|
|
|
145
163
|
writeNotConfiguredMessage,
|
|
146
164
|
isWriteCommandConfigured,
|
|
147
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';
|
|
148
189
|
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
149
190
|
export {
|
|
150
191
|
applyForgePathScopeForMergePlan,
|
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
|
@@ -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/resolve.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { normalizedForgeOrigin } from './forge-identity.js';
|
|
7
|
+
import { resolveMergePolicy } from './merge-policy.js';
|
|
7
8
|
import { assertGitRemote } from './git-args.js';
|
|
8
9
|
import { gitRepoRoot } from './git-local.js';
|
|
9
10
|
|
|
@@ -150,5 +151,6 @@ export function forgeContext(loaded) {
|
|
|
150
151
|
config,
|
|
151
152
|
cwd: loaded.cwd,
|
|
152
153
|
parsed,
|
|
154
|
+
mergePolicy: resolveMergePolicy(config),
|
|
153
155
|
};
|
|
154
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
|
}
|
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', 'merge']);
|
|
5
|
+
export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set', 'merge', 'issue_open']);
|
|
6
6
|
|
|
7
7
|
export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
|
|
8
8
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { isWriteCommandConfigured } from './write-config.js';
|
|
2
|
+
import { API_PROVIDER_COMMAND_AUTH, AUTH_CLASS } from './auth-classes.js';
|
|
3
|
+
|
|
4
|
+
const WRITE_ID_TO_AUTH_COMMAND = Object.freeze({
|
|
5
|
+
merge: 'merge_execute',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function authCommandForWriteId(commandId) {
|
|
9
|
+
return WRITE_ID_TO_AUTH_COMMAND[commandId] ?? commandId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function commandNeedsToken(commandId) {
|
|
13
|
+
return API_PROVIDER_COMMAND_AUTH[authCommandForWriteId(commandId)] === AUTH_CLASS.TOKEN_REQUIRED;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function providerImplementsWrite(commandId, providerCapabilities) {
|
|
17
|
+
const authCommand = authCommandForWriteId(commandId);
|
|
18
|
+
const command = providerCapabilities?.commands?.find(
|
|
19
|
+
(entry) => entry.name === commandId || entry.name === authCommand,
|
|
20
|
+
);
|
|
21
|
+
if (command) return command.implemented !== false;
|
|
22
|
+
return (providerCapabilities?.write_commands || []).includes(commandId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildNextConfigSnippet(commandId, configuredWrites) {
|
|
26
|
+
const next = [...new Set([...(configuredWrites || []), commandId])].sort((a, b) => a.localeCompare(b));
|
|
27
|
+
return `"write_commands": ${JSON.stringify(next)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} config
|
|
32
|
+
* @param {object | null} providerCapabilities
|
|
33
|
+
* @param {{ authPresent?: boolean }} [opts]
|
|
34
|
+
*/
|
|
35
|
+
export function buildWriteReadiness(config, providerCapabilities, opts = {}) {
|
|
36
|
+
const authPresent = opts.authPresent === true;
|
|
37
|
+
const providerWrites = [...new Set((providerCapabilities?.write_commands || []).filter(Boolean))].sort(
|
|
38
|
+
(a, b) => a.localeCompare(b),
|
|
39
|
+
);
|
|
40
|
+
const configuredWrites = [...new Set((Array.isArray(config?.write_commands) ? config.write_commands : []).filter(Boolean))].sort(
|
|
41
|
+
(a, b) => a.localeCompare(b),
|
|
42
|
+
);
|
|
43
|
+
const commandIds = [...new Set([...providerWrites, ...configuredWrites])].sort((a, b) => a.localeCompare(b));
|
|
44
|
+
|
|
45
|
+
const commands = commandIds.map((id) => {
|
|
46
|
+
const provider_supported = providerWrites.includes(id) && providerImplementsWrite(id, providerCapabilities);
|
|
47
|
+
const configured = isWriteCommandConfigured(config, id);
|
|
48
|
+
const auth_present = commandNeedsToken(id) ? authPresent : true;
|
|
49
|
+
const ready = provider_supported && configured && auth_present;
|
|
50
|
+
const entry = {
|
|
51
|
+
id,
|
|
52
|
+
provider_supported,
|
|
53
|
+
configured,
|
|
54
|
+
auth_present,
|
|
55
|
+
ready,
|
|
56
|
+
};
|
|
57
|
+
if (provider_supported && !configured) {
|
|
58
|
+
entry.next_config_snippet = buildNextConfigSnippet(id, configuredWrites);
|
|
59
|
+
}
|
|
60
|
+
return entry;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
configured_write_commands: configuredWrites,
|
|
65
|
+
provider_write_commands: providerWrites,
|
|
66
|
+
commands,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when any provider-supported write is not ready (config or auth). */
|
|
71
|
+
export function writeReadinessHasWarnings(writeConfig) {
|
|
72
|
+
if (!writeConfig?.commands?.length) return false;
|
|
73
|
+
return writeConfig.commands.some(
|
|
74
|
+
(entry) => entry.provider_supported === true && entry.ready !== true,
|
|
75
|
+
);
|
|
76
|
+
}
|