@remogram/core 0.1.0-beta.5 → 0.1.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth-classes.js +3 -0
- package/caps.js +11 -2
- package/change-request-merge-execute.js +139 -0
- package/contracts/envelope.js +7 -0
- package/contracts/errors.js +2 -0
- package/contracts/forge-error-fields.js +1 -0
- package/contracts/semantic-diff-facts.js +13 -12
- package/cr-inventory.js +7 -7
- package/forge-changes.js +2 -2
- package/forge-identity.js +42 -0
- package/index.js +24 -0
- package/merge-blockers.js +10 -3
- package/merge-plan-forge.js +62 -0
- package/merge-plan.js +51 -12
- package/package.json +1 -1
- package/path-allowlist.js +52 -1
- package/pr-head-reconcile.js +3 -3
- package/ref-inventory.js +2 -2
- package/resolve.js +2 -0
- package/write-config.js +1 -1
package/auth-classes.js
CHANGED
|
@@ -24,6 +24,7 @@ export const API_PROVIDER_COMMAND_AUTH = {
|
|
|
24
24
|
cr_files: AUTH_CLASS.TOKEN_REQUIRED,
|
|
25
25
|
cr_comments: AUTH_CLASS.TOKEN_REQUIRED,
|
|
26
26
|
forge_changes: AUTH_CLASS.TOKEN_REQUIRED,
|
|
27
|
+
merge_execute: AUTH_CLASS.TOKEN_REQUIRED,
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
export function commandCapability(name, { implemented = true } = {}) {
|
|
@@ -41,6 +42,7 @@ export function apiProviderCommands({
|
|
|
41
42
|
crFilesImplemented = false,
|
|
42
43
|
crCommentsImplemented = false,
|
|
43
44
|
forgeChangesImplemented = false,
|
|
45
|
+
mergeExecuteImplemented = false,
|
|
44
46
|
} = {}) {
|
|
45
47
|
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
|
|
46
48
|
let implemented = true;
|
|
@@ -50,6 +52,7 @@ export function apiProviderCommands({
|
|
|
50
52
|
if (name === 'cr_files') implemented = crFilesImplemented;
|
|
51
53
|
if (name === 'cr_comments') implemented = crCommentsImplemented;
|
|
52
54
|
if (name === 'forge_changes') implemented = forgeChangesImplemented;
|
|
55
|
+
if (name === 'merge_execute') implemented = mergeExecuteImplemented;
|
|
53
56
|
return commandCapability(name, { implemented });
|
|
54
57
|
});
|
|
55
58
|
}
|
package/caps.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const DEFAULT_MAX_BYTES = 8192;
|
|
2
2
|
export const DEFAULT_FIELD_MAX_BYTES = 512;
|
|
3
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;
|
|
4
6
|
|
|
5
7
|
/** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
|
|
6
8
|
export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
|
|
@@ -20,13 +22,20 @@ export function getEffectiveIngestMaxBytes() {
|
|
|
20
22
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
21
23
|
return { bytes: DEFAULT_MAX_BYTES, envOverride: false, invalidEnv: true };
|
|
22
24
|
}
|
|
25
|
+
if (parsed > MAX_FORGE_INGEST_ENV_BYTES) {
|
|
26
|
+
return { bytes: MAX_FORGE_INGEST_ENV_BYTES, envOverride: true, clamped: true };
|
|
27
|
+
}
|
|
23
28
|
return { bytes: parsed, envOverride: true };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/** Facts for provider capabilities packets (forge ingest policy). */
|
|
27
32
|
export function forgeIngestCapabilityFacts() {
|
|
28
|
-
const { bytes } = getEffectiveIngestMaxBytes();
|
|
29
|
-
return {
|
|
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
|
+
};
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
import { mergeBlockersFromFacts } from './merge-blockers.js';
|
|
3
|
+
|
|
4
|
+
const SHA40 = /^[0-9a-f]{40}$/i;
|
|
5
|
+
|
|
6
|
+
export function mergeExecuteViewFacts(view) {
|
|
7
|
+
return {
|
|
8
|
+
sourceBranchRef: view.forge_source_branch_ref ?? view.head_ref ?? null,
|
|
9
|
+
sourceSha: view.forge_source_sha ?? view.head_sha ?? null,
|
|
10
|
+
targetSha: view.forge_target_sha ?? view.base_sha ?? null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mergeExecuteChecksFacts(checks) {
|
|
15
|
+
return {
|
|
16
|
+
sourceSha: checks.forge_source_sha ?? checks.head_sha ?? null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function assertExpectedSha(value, flagName) {
|
|
21
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
22
|
+
if (!SHA40.test(normalized)) {
|
|
23
|
+
throw Object.assign(new Error(`Invalid ${flagName}`), {
|
|
24
|
+
invalidArgs: `${flagName} must be a 40-character git SHA`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMergeExecuteBeforeFacts(view, checks, mergePlanBody, forgeHeadRefSha = null) {
|
|
31
|
+
const viewFacts = mergeExecuteViewFacts(view);
|
|
32
|
+
const checksFacts = mergeExecuteChecksFacts(checks);
|
|
33
|
+
return {
|
|
34
|
+
base_sha: viewFacts.targetSha ?? null,
|
|
35
|
+
head_sha: viewFacts.sourceSha ?? null,
|
|
36
|
+
checks_head_sha: checksFacts.sourceSha ?? null,
|
|
37
|
+
forge_head_ref_sha: forgeHeadRefSha ?? null,
|
|
38
|
+
mergeability: view.mergeability ?? 'unknown',
|
|
39
|
+
checks_conclusion: checks.check_conclusion ?? 'unknown',
|
|
40
|
+
checks_truncated: checks.checks_truncated === true,
|
|
41
|
+
merge_plan_blockers: Array.isArray(mergePlanBody.blockers) ? [...mergePlanBody.blockers] : [],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Collect merge-execute blockers before forge mutation.
|
|
47
|
+
* @returns {string[]} normalized blocker ids
|
|
48
|
+
*/
|
|
49
|
+
export function collectMergeExecuteBlockers(
|
|
50
|
+
view,
|
|
51
|
+
checks,
|
|
52
|
+
mergePlanBody,
|
|
53
|
+
expected,
|
|
54
|
+
{ forgeHeadRefSha } = {},
|
|
55
|
+
) {
|
|
56
|
+
const viewFacts = mergeExecuteViewFacts(view);
|
|
57
|
+
const checksFacts = mergeExecuteChecksFacts(checks);
|
|
58
|
+
const blockers = [];
|
|
59
|
+
const baseSha = viewFacts.targetSha ? String(viewFacts.targetSha).toLowerCase() : null;
|
|
60
|
+
const headSha = viewFacts.sourceSha ? String(viewFacts.sourceSha).toLowerCase() : null;
|
|
61
|
+
|
|
62
|
+
if (baseSha && expected.baseSha !== baseSha) blockers.push('base_sha_mismatch');
|
|
63
|
+
if (headSha && expected.headSha !== headSha) blockers.push('head_sha_mismatch');
|
|
64
|
+
|
|
65
|
+
const forgeHead = forgeHeadRefSha ? String(forgeHeadRefSha).toLowerCase() : null;
|
|
66
|
+
const checksHead = checksFacts.sourceSha ? String(checksFacts.sourceSha).toLowerCase() : null;
|
|
67
|
+
if (headSha && checksHead && headSha !== checksHead) blockers.push('checks_head_sha_mismatch');
|
|
68
|
+
if (headSha && forgeHead && headSha !== forgeHead) blockers.push('forge_pr_head_mismatch');
|
|
69
|
+
if (checksHead && forgeHead && checksHead !== forgeHead) blockers.push('checks_forge_head_mismatch');
|
|
70
|
+
if (forgeHead && forgeHead !== expected.headSha) blockers.push('head_ref_moved');
|
|
71
|
+
|
|
72
|
+
const planBlockers = mergeBlockersFromFacts(view, checks, {});
|
|
73
|
+
for (const blocker of planBlockers) {
|
|
74
|
+
if (!blockers.includes(blocker)) blockers.push(blocker);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (view.mergeability !== 'clean') {
|
|
78
|
+
if (view.mergeability === 'conflicted' && !blockers.includes('merge_conflict')) {
|
|
79
|
+
blockers.push('merge_conflict');
|
|
80
|
+
} else if (view.mergeability !== 'conflicted' && !blockers.includes('mergeability_not_clean')) {
|
|
81
|
+
blockers.push('mergeability_not_clean');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return blockers;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildCrMergeBlockedBody({
|
|
89
|
+
prNumber,
|
|
90
|
+
expected,
|
|
91
|
+
before,
|
|
92
|
+
blockers,
|
|
93
|
+
}) {
|
|
94
|
+
return {
|
|
95
|
+
change_request: { number: prNumber },
|
|
96
|
+
expected: {
|
|
97
|
+
base_sha: expected.baseSha,
|
|
98
|
+
head_sha: expected.headSha,
|
|
99
|
+
},
|
|
100
|
+
before,
|
|
101
|
+
blockers,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildCrMergedBody({
|
|
106
|
+
prNumber,
|
|
107
|
+
expected,
|
|
108
|
+
before,
|
|
109
|
+
merge,
|
|
110
|
+
after,
|
|
111
|
+
}) {
|
|
112
|
+
return {
|
|
113
|
+
change_request: { number: prNumber, state: 'merged' },
|
|
114
|
+
expected: {
|
|
115
|
+
base_sha: expected.baseSha,
|
|
116
|
+
head_sha: expected.headSha,
|
|
117
|
+
},
|
|
118
|
+
before,
|
|
119
|
+
merge,
|
|
120
|
+
after,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildMergeExecuteAfterFacts(view, mergeResult = {}) {
|
|
125
|
+
const viewFacts = mergeExecuteViewFacts(view);
|
|
126
|
+
return {
|
|
127
|
+
state: 'merged',
|
|
128
|
+
base_sha: mergeResult.base_sha ?? viewFacts.targetSha ?? null,
|
|
129
|
+
head_sha: viewFacts.sourceSha ?? null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function buildMergeExecuteMergeFacts(method, providerResult = {}) {
|
|
134
|
+
return {
|
|
135
|
+
method: sanitizeField(method),
|
|
136
|
+
commit_sha: providerResult.commit_sha ?? null,
|
|
137
|
+
provider_status: providerResult.provider_status ?? null,
|
|
138
|
+
};
|
|
139
|
+
}
|
package/contracts/envelope.js
CHANGED
|
@@ -20,6 +20,8 @@ export const PACKET_TYPES = {
|
|
|
20
20
|
CR_FILES: 'cr_files',
|
|
21
21
|
CR_COMMENTS: 'cr_comments',
|
|
22
22
|
FORGE_CHANGES: 'forge_changes',
|
|
23
|
+
CR_MERGED: 'cr_merged',
|
|
24
|
+
CR_MERGE_BLOCKED: 'cr_merge_blocked',
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
@@ -57,6 +59,11 @@ export function forgePacket(type, context, body = {}, error = null) {
|
|
|
57
59
|
ok: error == null,
|
|
58
60
|
};
|
|
59
61
|
|
|
62
|
+
delete packet.base_url;
|
|
63
|
+
if (context.baseUrl) {
|
|
64
|
+
packet.base_url = context.baseUrl;
|
|
65
|
+
}
|
|
66
|
+
|
|
60
67
|
if (error) {
|
|
61
68
|
packet.error_code = error.code;
|
|
62
69
|
packet.error_message = sanitizeField(error.message);
|
package/contracts/errors.js
CHANGED
|
@@ -16,6 +16,8 @@ export const ERROR_CODES = {
|
|
|
16
16
|
WRITE_NOT_CONFIGURED: 'write_not_configured',
|
|
17
17
|
IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
|
|
18
18
|
INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
|
|
19
|
+
MERGE_BLOCKED: 'merge_blocked',
|
|
20
|
+
MERGE_ENDPOINT_FAILED: 'merge_endpoint_failed',
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export function forgeError(code, message, status = null, fields = null) {
|
|
@@ -28,7 +28,7 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
|
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
/** v1 write surface (Gitea cr open and status set in first slices). */
|
|
31
|
-
export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set']);
|
|
31
|
+
export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set', 'merge execute']);
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
|
|
@@ -46,6 +46,7 @@ export const TRUSTED_ENVELOPE_FIELDS = Object.freeze([
|
|
|
46
46
|
'provider_id',
|
|
47
47
|
'remote_name',
|
|
48
48
|
'repo_id',
|
|
49
|
+
'base_url',
|
|
49
50
|
'observed_at',
|
|
50
51
|
'ok',
|
|
51
52
|
]);
|
|
@@ -83,9 +84,9 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
|
|
|
83
84
|
*/
|
|
84
85
|
export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
85
86
|
repo_status: ['default_branch', 'auth_env', 'capabilities'],
|
|
86
|
-
ref_compare: ['
|
|
87
|
-
pr_status: ['url', 'title', '
|
|
88
|
-
pr_checks: ['
|
|
87
|
+
ref_compare: ['compare_base_ref', 'compare_head_ref', 'compare_base_sha', 'compare_head_sha'],
|
|
88
|
+
pr_status: ['url', 'title', 'forge_target_branch_ref', 'forge_source_branch_ref', 'forge_target_sha', 'forge_source_sha'],
|
|
89
|
+
pr_checks: ['forge_source_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
|
|
89
90
|
merge_plan: ['blockers[].message', 'blockers[].context'],
|
|
90
91
|
sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
|
|
91
92
|
ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
|
|
@@ -99,16 +100,16 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
|
99
100
|
],
|
|
100
101
|
cr_files: ['changed_paths[]'],
|
|
101
102
|
cr_comments: ['comments[].id', 'comments[].author', 'comments[].path', 'comments[].body'],
|
|
102
|
-
forge_changes: ['events[].title', 'events[].url', 'events[].
|
|
103
|
+
forge_changes: ['events[].title', 'events[].url', 'events[].forge_source_sha'],
|
|
103
104
|
cr_inventory_slice: [
|
|
104
105
|
'entries[].url',
|
|
105
106
|
'entries[].title',
|
|
106
|
-
'entries[].
|
|
107
|
-
'entries[].
|
|
108
|
-
'entries[].
|
|
109
|
-
'entries[].
|
|
107
|
+
'entries[].forge_target_branch_ref',
|
|
108
|
+
'entries[].forge_source_branch_ref',
|
|
109
|
+
'entries[].forge_target_sha',
|
|
110
|
+
'entries[].forge_source_sha',
|
|
110
111
|
'entries[].head_reconcile.local_head_sha',
|
|
111
|
-
'entries[].head_reconcile.
|
|
112
|
+
'entries[].head_reconcile.forge_source_sha',
|
|
112
113
|
'entries[].checks[].context',
|
|
113
114
|
'entries[].checks[].description',
|
|
114
115
|
],
|
|
@@ -125,11 +126,11 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
|
|
|
125
126
|
[FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY]: {
|
|
126
127
|
refs: 'array<{ name: string, sha: string, kind?: string, is_default?: boolean }>',
|
|
127
128
|
default_ref: 'string optional',
|
|
128
|
-
ancestry_hints: 'array<{
|
|
129
|
+
ancestry_hints: 'array<{ compare_base_ref: string, compare_head_ref: string, ahead_by?: number, behind_by?: number }> optional',
|
|
129
130
|
},
|
|
130
131
|
[FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE]: {
|
|
131
132
|
entries:
|
|
132
|
-
'array<{ pr_number: number, url?: string, title?: string, state?: string,
|
|
133
|
+
'array<{ pr_number: number, url?: string, title?: string, state?: string, forge_target_branch_ref?: string, forge_source_branch_ref?: string, forge_target_sha?: string, forge_source_sha?: string, mergeability?: string, checks_conclusion?: string, checks_truncated?: boolean, blockers?: array, head_reconcile?: { stale: boolean, local_head_sha?: string, forge_source_sha?: string } }>',
|
|
133
134
|
entry_count: 'number',
|
|
134
135
|
/** true when list cap applied (entry_count > limit), not missing entries */
|
|
135
136
|
truncated: 'boolean',
|
package/cr-inventory.js
CHANGED
|
@@ -39,14 +39,14 @@ export function buildHeadReconcile(ctx, view) {
|
|
|
39
39
|
const details = staleHeadDetails(
|
|
40
40
|
ctx.cwd,
|
|
41
41
|
ctx.config?.remote ?? ctx.remoteName,
|
|
42
|
-
view.
|
|
43
|
-
view.
|
|
42
|
+
view.forge_source_branch_ref,
|
|
43
|
+
view.forge_source_sha,
|
|
44
44
|
);
|
|
45
45
|
if (!details) return { stale: false };
|
|
46
46
|
return {
|
|
47
47
|
stale: true,
|
|
48
48
|
local_head_sha: details.local_head_sha,
|
|
49
|
-
|
|
49
|
+
forge_source_sha: details.forge_source_sha,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -59,16 +59,16 @@ export function buildCrInventoryEntry(ctx, view, checks) {
|
|
|
59
59
|
url: view.url,
|
|
60
60
|
title: view.title,
|
|
61
61
|
state: view.state,
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
forge_target_branch_ref: view.forge_target_branch_ref,
|
|
63
|
+
forge_source_branch_ref: view.forge_source_branch_ref,
|
|
64
64
|
mergeability: view.mergeability,
|
|
65
65
|
checks_conclusion: checks.check_conclusion,
|
|
66
66
|
checks_truncated: checks.checks_truncated === true,
|
|
67
67
|
blockers: mergeBlockersFromFacts(view, checks),
|
|
68
68
|
head_reconcile: buildHeadReconcile(ctx, view),
|
|
69
69
|
};
|
|
70
|
-
if (view.
|
|
71
|
-
if (view.
|
|
70
|
+
if (view.forge_target_sha) entry.forge_target_sha = view.forge_target_sha;
|
|
71
|
+
if (view.forge_source_sha) entry.forge_source_sha = view.forge_source_sha;
|
|
72
72
|
return entry;
|
|
73
73
|
}
|
|
74
74
|
|
package/forge-changes.js
CHANGED
|
@@ -72,7 +72,7 @@ export function buildChecksConclusionObservedEvent(prNumber, checksBody) {
|
|
|
72
72
|
return {
|
|
73
73
|
kind: FORGE_CHANGE_EVENT_KINDS.CHECKS_CONCLUSION_OBSERVED,
|
|
74
74
|
pr_number: Math.floor(Number(prNumber)),
|
|
75
|
-
|
|
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/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
DEFAULT_MAX_BYTES,
|
|
29
29
|
DEFAULT_FIELD_MAX_BYTES,
|
|
30
30
|
FORGE_INGEST_MAX_BYTES_ENV,
|
|
31
|
+
MAX_FORGE_INGEST_ENV_BYTES,
|
|
31
32
|
DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
32
33
|
MAX_CHECK_STATUS_PAGES,
|
|
33
34
|
DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
@@ -74,6 +75,17 @@ export {
|
|
|
74
75
|
appendSortQuery,
|
|
75
76
|
} from './open-pull-list.js';
|
|
76
77
|
export { buildChangeRequestOpenedBody } from './cr-open.js';
|
|
78
|
+
export {
|
|
79
|
+
assertExpectedSha,
|
|
80
|
+
mergeExecuteViewFacts,
|
|
81
|
+
mergeExecuteChecksFacts,
|
|
82
|
+
buildMergeExecuteBeforeFacts,
|
|
83
|
+
collectMergeExecuteBlockers,
|
|
84
|
+
buildCrMergeBlockedBody,
|
|
85
|
+
buildCrMergedBody,
|
|
86
|
+
buildMergeExecuteAfterFacts,
|
|
87
|
+
buildMergeExecuteMergeFacts,
|
|
88
|
+
} from './change-request-merge-execute.js';
|
|
77
89
|
export {
|
|
78
90
|
STATUS_SET_STATES,
|
|
79
91
|
assertCommitSha,
|
|
@@ -135,15 +147,26 @@ export {
|
|
|
135
147
|
} from './write-config.js';
|
|
136
148
|
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
137
149
|
export {
|
|
150
|
+
applyForgePathScopeForMergePlan,
|
|
151
|
+
isCrFilesScopeComplete,
|
|
138
152
|
resolveMergePlanPathScope,
|
|
139
153
|
buildMergePlanBody,
|
|
140
154
|
buildMergePlanBodyFromFacts,
|
|
155
|
+
normalizeAllowedPaths,
|
|
141
156
|
} from './merge-plan.js';
|
|
157
|
+
export {
|
|
158
|
+
isMergePlanForgeScopeRethrowError,
|
|
159
|
+
MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES,
|
|
160
|
+
resolveMergePlanOptsWithForgePaths,
|
|
161
|
+
buildMergePlanFromProviderFacts,
|
|
162
|
+
} from './merge-plan-forge.js';
|
|
142
163
|
export {
|
|
143
164
|
matchPathAllowlist,
|
|
144
165
|
isPathAllowed,
|
|
145
166
|
pathsOutsideAllowlist,
|
|
146
167
|
allPathsAllowed,
|
|
168
|
+
normalizeRepoRelativePath,
|
|
169
|
+
normalizeChangedPathList,
|
|
147
170
|
} from './path-allowlist.js';
|
|
148
171
|
export {
|
|
149
172
|
localHeadShaForPr,
|
|
@@ -154,6 +177,7 @@ export {
|
|
|
154
177
|
throwIfStaleHeadByNumber,
|
|
155
178
|
} from './pr-head-reconcile.js';
|
|
156
179
|
export { parseConfigFile, configSchema } from './config-schema.js';
|
|
180
|
+
export { normalizedForgeOrigin } from './forge-identity.js';
|
|
157
181
|
export {
|
|
158
182
|
findConfigPath,
|
|
159
183
|
loadConfig,
|
package/merge-blockers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allPathsAllowed } from './path-allowlist.js';
|
|
1
|
+
import { allPathsAllowed, normalizeChangedPathList } from './path-allowlist.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Derive merge blockers from already-fetched PR view and checks facts.
|
|
@@ -27,8 +27,15 @@ export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
|
|
|
27
27
|
const changedPaths = pathScope.changed_paths;
|
|
28
28
|
if (changedPaths == null) {
|
|
29
29
|
blockers.push('changed_paths_unavailable');
|
|
30
|
-
} else if (
|
|
31
|
-
|
|
30
|
+
} else if (Array.isArray(changedPaths)) {
|
|
31
|
+
const normalizedPaths = normalizeChangedPathList(changedPaths);
|
|
32
|
+
if (normalizedPaths == null) {
|
|
33
|
+
blockers.push('changed_paths_unavailable');
|
|
34
|
+
} else if (!allPathsAllowed(allowedPaths, normalizedPaths)) {
|
|
35
|
+
blockers.push('path_scope_violation');
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
blockers.push('changed_paths_unavailable');
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
|
+
import {
|
|
3
|
+
applyForgePathScopeForMergePlan,
|
|
4
|
+
buildMergePlanBodyFromFacts,
|
|
5
|
+
normalizeAllowedPaths,
|
|
6
|
+
} from './merge-plan.js';
|
|
7
|
+
|
|
8
|
+
/** Errors that must propagate from crFiles during merge-plan path scope — not mapped to changed_paths_unavailable. */
|
|
9
|
+
export const MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES = new Set([
|
|
10
|
+
ERROR_CODES.UNAUTHENTICATED_PROVIDER,
|
|
11
|
+
ERROR_CODES.INVALID_ARGS,
|
|
12
|
+
ERROR_CODES.UNTRUSTED_BASE_URL,
|
|
13
|
+
ERROR_CODES.PROVIDER_UNSUPPORTED,
|
|
14
|
+
ERROR_CODES.CONFIG_INVALID,
|
|
15
|
+
ERROR_CODES.OVERSIZED_RAW_OUTPUT,
|
|
16
|
+
ERROR_CODES.CONFIG_NOT_FOUND,
|
|
17
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
18
|
+
ERROR_CODES.STALE_HEAD,
|
|
19
|
+
ERROR_CODES.MISSING_REF,
|
|
20
|
+
ERROR_CODES.PR_NOT_OPEN,
|
|
21
|
+
ERROR_CODES.REMOTE_INFER_FAILED,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function isMergePlanForgeScopeRethrowError(err) {
|
|
25
|
+
const code = err?.forgeError?.code;
|
|
26
|
+
return typeof code === 'string' && MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES.has(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { normalizeAllowedPaths } from './merge-plan.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve merge plan opts with forge cr_files when an allowlist is configured.
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {() => Promise<object>} crFilesFn
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveMergePlanOptsWithForgePaths(opts, crFilesFn) {
|
|
37
|
+
if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
|
|
38
|
+
try {
|
|
39
|
+
const crFilesBody = await crFilesFn();
|
|
40
|
+
return applyForgePathScopeForMergePlan(opts, crFilesBody);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (isMergePlanForgeScopeRethrowError(err)) throw err;
|
|
43
|
+
// Intentional mask bucket: transient api_error → changed_paths_unavailable.
|
|
44
|
+
// write_not_configured / idempotency codes are not emitted by crFiles today.
|
|
45
|
+
return applyForgePathScopeForMergePlan(opts, null);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build merge_plan body from provider deps (shared tri-provider orchestration).
|
|
51
|
+
* @param {object} ctx
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @param {{ prView: Function, prChecks: Function, crFiles: Function }} deps
|
|
54
|
+
*/
|
|
55
|
+
export async function buildMergePlanFromProviderFacts(ctx, opts, deps) {
|
|
56
|
+
const view = await deps.prView(ctx, opts);
|
|
57
|
+
const checks = await deps.prChecks(ctx, { number: view.pr_number });
|
|
58
|
+
const mergeOpts = await resolveMergePlanOptsWithForgePaths(opts, () =>
|
|
59
|
+
deps.crFiles(ctx, { number: view.pr_number }),
|
|
60
|
+
);
|
|
61
|
+
return buildMergePlanBodyFromFacts(view, checks, mergeOpts);
|
|
62
|
+
}
|
package/merge-plan.js
CHANGED
|
@@ -1,25 +1,64 @@
|
|
|
1
|
-
import { gitDiffNameOnly } from './git-local.js';
|
|
2
1
|
import { mergeBlockersFromFacts } from './merge-blockers.js';
|
|
2
|
+
import { normalizeChangedPathList } from './path-allowlist.js';
|
|
3
3
|
|
|
4
|
-
function
|
|
4
|
+
function allowlistGlobHasDotDotSegment(glob) {
|
|
5
|
+
if (typeof glob !== 'string') return false;
|
|
6
|
+
const normalized = glob.replace(/\\/g, '/');
|
|
7
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
return normalized.split('/').some((segment) => segment === '..');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeAllowedPaths(allowedPaths) {
|
|
5
14
|
if (!Array.isArray(allowedPaths)) return null;
|
|
6
|
-
const normalized = allowedPaths
|
|
15
|
+
const normalized = allowedPaths
|
|
16
|
+
.filter((entry) => typeof entry === 'string')
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter((entry) => entry.length > 0 && !allowlistGlobHasDotDotSegment(entry));
|
|
7
19
|
return normalized.length > 0 ? normalized : null;
|
|
8
20
|
}
|
|
9
21
|
|
|
10
|
-
|
|
22
|
+
/** True when cr_files body is complete enough for merge-plan path scope. */
|
|
23
|
+
export function isCrFilesScopeComplete(crFilesBody) {
|
|
24
|
+
if (!crFilesBody || crFilesBody.paths_truncated) return false;
|
|
25
|
+
const changedPaths = crFilesBody.changed_paths;
|
|
26
|
+
if (!Array.isArray(changedPaths)) return false;
|
|
27
|
+
const pathCount = Number.isFinite(Number(crFilesBody.path_count))
|
|
28
|
+
? Math.floor(Number(crFilesBody.path_count))
|
|
29
|
+
: changedPaths.length;
|
|
30
|
+
if (pathCount > 0 && changedPaths.length === 0) return false;
|
|
31
|
+
if (pathCount > changedPaths.length) return false;
|
|
32
|
+
if (changedPaths.length > pathCount) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Apply forge cr_files facts to merge plan opts when an allowlist is configured. */
|
|
37
|
+
export function applyForgePathScopeForMergePlan(opts, crFilesBody) {
|
|
38
|
+
if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
|
|
39
|
+
if (!isCrFilesScopeComplete(crFilesBody)) {
|
|
40
|
+
return { ...opts, changed_paths: null };
|
|
41
|
+
}
|
|
42
|
+
const normalizedPaths = normalizeChangedPathList(crFilesBody.changed_paths);
|
|
43
|
+
if (normalizedPaths == null) {
|
|
44
|
+
return { ...opts, changed_paths: null };
|
|
45
|
+
}
|
|
46
|
+
return { ...opts, changed_paths: normalizedPaths };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveMergePlanPathScope(opts = {}) {
|
|
11
50
|
const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
|
|
12
51
|
if (!allowedPaths) {
|
|
13
52
|
return { allowed_paths: null, changed_paths: null };
|
|
14
53
|
}
|
|
15
54
|
if (Array.isArray(opts.changed_paths)) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
|
|
56
|
+
return {
|
|
57
|
+
allowed_paths: allowedPaths,
|
|
58
|
+
changed_paths: normalizedPaths,
|
|
59
|
+
};
|
|
20
60
|
}
|
|
21
|
-
|
|
22
|
-
return { allowed_paths: allowedPaths, changed_paths: changedPaths };
|
|
61
|
+
return { allowed_paths: allowedPaths, changed_paths: null };
|
|
23
62
|
}
|
|
24
63
|
|
|
25
64
|
export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
@@ -31,7 +70,7 @@ export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
|
31
70
|
};
|
|
32
71
|
}
|
|
33
72
|
|
|
34
|
-
export function buildMergePlanBodyFromFacts(
|
|
35
|
-
const pathScope = resolveMergePlanPathScope(
|
|
73
|
+
export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
|
|
74
|
+
const pathScope = resolveMergePlanPathScope(opts);
|
|
36
75
|
return buildMergePlanBody(view, checks, pathScope);
|
|
37
76
|
}
|
package/package.json
CHANGED
package/path-allowlist.js
CHANGED
|
@@ -3,6 +3,56 @@
|
|
|
3
3
|
* Supports `**`, `*`, and literal path segments (e.g. README.md).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Collapse `.` / `..` segments; reject absolute paths and repo-root escape.
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
* @returns {string|null}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRepoRelativePath(filePath) {
|
|
12
|
+
if (typeof filePath !== 'string') return null;
|
|
13
|
+
let path = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
14
|
+
if (path === '') return null;
|
|
15
|
+
if (path.startsWith('/')) return null;
|
|
16
|
+
if (path === '..' || path.startsWith('../')) return null;
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (part === '' || part === '.') continue;
|
|
21
|
+
if (part === '..') {
|
|
22
|
+
if (out.length > 0) out.pop();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(part);
|
|
26
|
+
}
|
|
27
|
+
return out.join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function changedPathHasDotDotSegment(filePath) {
|
|
31
|
+
if (typeof filePath !== 'string') return false;
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
33
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return normalized.split('/').some((segment) => segment === '..');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a forge changed-path list for allowlist scope; null if any path is unnormalizable.
|
|
41
|
+
* @param {unknown} changedPaths
|
|
42
|
+
* @returns {string[]|null}
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeChangedPathList(changedPaths) {
|
|
45
|
+
if (!Array.isArray(changedPaths)) return null;
|
|
46
|
+
const normalized = [];
|
|
47
|
+
for (const filePath of changedPaths) {
|
|
48
|
+
if (changedPathHasDotDotSegment(filePath)) return null;
|
|
49
|
+
const repoPath = normalizeRepoRelativePath(filePath);
|
|
50
|
+
if (repoPath == null) return null;
|
|
51
|
+
normalized.push(repoPath);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
6
56
|
function globToRegExp(glob) {
|
|
7
57
|
let pattern = '';
|
|
8
58
|
for (let i = 0; i < glob.length; i += 1) {
|
|
@@ -29,7 +79,8 @@ function globToRegExp(glob) {
|
|
|
29
79
|
*/
|
|
30
80
|
export function matchPathAllowlist(glob, filePath) {
|
|
31
81
|
if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
|
|
32
|
-
const normalized = filePath
|
|
82
|
+
const normalized = normalizeRepoRelativePath(filePath);
|
|
83
|
+
if (normalized == null) return false;
|
|
33
84
|
return globToRegExp(glob).test(normalized);
|
|
34
85
|
}
|
|
35
86
|
|
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
|
}
|
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,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { parseConfigFile } from './config-schema.js';
|
|
5
5
|
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
6
|
+
import { normalizedForgeOrigin } from './forge-identity.js';
|
|
6
7
|
import { assertGitRemote } from './git-args.js';
|
|
7
8
|
import { gitRepoRoot } from './git-local.js';
|
|
8
9
|
|
|
@@ -145,6 +146,7 @@ export function forgeContext(loaded) {
|
|
|
145
146
|
providerId: config.provider,
|
|
146
147
|
remoteName: config.remote,
|
|
147
148
|
repoId: `${parsed.owner}/${parsed.repo}`,
|
|
149
|
+
baseUrl: normalizedForgeOrigin(config),
|
|
148
150
|
config,
|
|
149
151
|
cwd: loaded.cwd,
|
|
150
152
|
parsed,
|
package/write-config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
3
|
|
|
4
4
|
/** Canonical v1 consumer write command ids (schema + gate single source). */
|
|
5
|
-
export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set']);
|
|
5
|
+
export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set', 'merge']);
|
|
6
6
|
|
|
7
7
|
export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
|
|
8
8
|
|