@remogram/core 0.1.0-beta.4 → 0.1.0-beta.6
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 +21 -2
- package/branch-protection.js +155 -0
- package/caps.js +24 -2
- package/contracts/envelope.js +6 -0
- package/contracts/observer-fact-inventory.js +5 -0
- package/contracts/semantic-diff-facts.js +21 -2
- package/cr-comments.js +93 -0
- package/cr-files.js +62 -0
- package/forge-changes.js +181 -0
- package/index.js +65 -0
- package/merge-blockers.js +10 -3
- package/merge-plan-forge.js +62 -0
- package/merge-plan.js +52 -10
- package/package.json +1 -1
- package/path-allowlist.js +52 -1
- package/status-set.js +87 -0
- package/stub-provider.js +7 -0
- package/whoami.js +114 -0
- package/write-config.js +11 -2
package/auth-classes.js
CHANGED
|
@@ -18,6 +18,12 @@ 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
|
+
status_set: AUTH_CLASS.TOKEN_REQUIRED,
|
|
22
|
+
whoami: AUTH_CLASS.TOKEN_REQUIRED,
|
|
23
|
+
branch_protection: AUTH_CLASS.TOKEN_REQUIRED,
|
|
24
|
+
cr_files: AUTH_CLASS.TOKEN_REQUIRED,
|
|
25
|
+
cr_comments: AUTH_CLASS.TOKEN_REQUIRED,
|
|
26
|
+
forge_changes: AUTH_CLASS.TOKEN_REQUIRED,
|
|
21
27
|
};
|
|
22
28
|
|
|
23
29
|
export function commandCapability(name, { implemented = true } = {}) {
|
|
@@ -28,9 +34,22 @@ export function commandCapability(name, { implemented = true } = {}) {
|
|
|
28
34
|
return { name, implemented, auth_class };
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export function apiProviderCommands({
|
|
37
|
+
export function apiProviderCommands({
|
|
38
|
+
writeCommandsImplemented = false,
|
|
39
|
+
statusSetImplemented = false,
|
|
40
|
+
branchProtectionImplemented = false,
|
|
41
|
+
crFilesImplemented = false,
|
|
42
|
+
crCommentsImplemented = false,
|
|
43
|
+
forgeChangesImplemented = false,
|
|
44
|
+
} = {}) {
|
|
32
45
|
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
|
|
33
|
-
|
|
46
|
+
let implemented = true;
|
|
47
|
+
if (name === 'cr_open') implemented = writeCommandsImplemented;
|
|
48
|
+
if (name === 'status_set') implemented = statusSetImplemented;
|
|
49
|
+
if (name === 'branch_protection') implemented = branchProtectionImplemented;
|
|
50
|
+
if (name === 'cr_files') implemented = crFilesImplemented;
|
|
51
|
+
if (name === 'cr_comments') implemented = crCommentsImplemented;
|
|
52
|
+
if (name === 'forge_changes') implemented = forgeChangesImplemented;
|
|
34
53
|
return commandCapability(name, { implemented });
|
|
35
54
|
});
|
|
36
55
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
|
|
3
|
+
export const MAX_BRANCH_PROTECTION_STATUS_CONTEXTS = 64;
|
|
4
|
+
export const MAX_BRANCH_PROTECTION_RULES = 32;
|
|
5
|
+
|
|
6
|
+
/** Gitea exposes required_approvals on branch protection; omit when unavailable. */
|
|
7
|
+
export function unimplementedApprovalsRequiredSignal() {
|
|
8
|
+
return { implemented: false, count: null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sanitizeStringList(values, maxItems) {
|
|
12
|
+
if (!Array.isArray(values)) return [];
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const value of values) {
|
|
15
|
+
if (out.length >= maxItems) break;
|
|
16
|
+
const sanitized = sanitizeField(value);
|
|
17
|
+
if (sanitized) out.push(sanitized);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeApprovalsRequired(signal) {
|
|
23
|
+
if (signal == null || typeof signal !== 'object') {
|
|
24
|
+
return unimplementedApprovalsRequiredSignal();
|
|
25
|
+
}
|
|
26
|
+
if (signal.implemented === false) {
|
|
27
|
+
return { implemented: false, count: null };
|
|
28
|
+
}
|
|
29
|
+
if (signal.implemented === true) {
|
|
30
|
+
if (signal.count == null) {
|
|
31
|
+
return { implemented: true, count: null };
|
|
32
|
+
}
|
|
33
|
+
const count = Number(signal.count);
|
|
34
|
+
if (!Number.isFinite(count) || count < 0) {
|
|
35
|
+
return { implemented: true, count: null };
|
|
36
|
+
}
|
|
37
|
+
return { implemented: true, count: Math.floor(count) };
|
|
38
|
+
}
|
|
39
|
+
return unimplementedApprovalsRequiredSignal();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildBranchProtectionBody({
|
|
43
|
+
branch_ref,
|
|
44
|
+
required_status_contexts,
|
|
45
|
+
protected_branch_rules,
|
|
46
|
+
approvals_required,
|
|
47
|
+
}) {
|
|
48
|
+
const rules = (Array.isArray(protected_branch_rules) ? protected_branch_rules : [])
|
|
49
|
+
.slice(0, MAX_BRANCH_PROTECTION_RULES)
|
|
50
|
+
.map((rule) => {
|
|
51
|
+
const name =
|
|
52
|
+
rule != null && typeof rule === 'object'
|
|
53
|
+
? sanitizeField(rule.name)
|
|
54
|
+
: sanitizeField(rule);
|
|
55
|
+
return name ? { name } : null;
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
branch_ref: sanitizeField(branch_ref),
|
|
61
|
+
required_status_contexts: sanitizeStringList(
|
|
62
|
+
required_status_contexts,
|
|
63
|
+
MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
|
|
64
|
+
),
|
|
65
|
+
protected_branch_rules: rules,
|
|
66
|
+
approvals_required: normalizeApprovalsRequired(approvals_required),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildBranchProtectionFromGitLabProtection(
|
|
71
|
+
branchRef,
|
|
72
|
+
{ protectedBranch = null, approvalRules = [] } = {},
|
|
73
|
+
) {
|
|
74
|
+
if (protectedBranch == null) {
|
|
75
|
+
return buildBranchProtectionBody({
|
|
76
|
+
branch_ref: branchRef,
|
|
77
|
+
required_status_contexts: [],
|
|
78
|
+
protected_branch_rules: [],
|
|
79
|
+
approvals_required: unimplementedApprovalsRequiredSignal(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const ruleName = sanitizeField(protectedBranch.name ?? branchRef);
|
|
83
|
+
let approvals_required = unimplementedApprovalsRequiredSignal();
|
|
84
|
+
if (Array.isArray(approvalRules) && approvalRules.length > 0) {
|
|
85
|
+
const counts = approvalRules
|
|
86
|
+
.map((rule) => Number(rule.approvals_required))
|
|
87
|
+
.filter((count) => Number.isFinite(count) && count >= 0);
|
|
88
|
+
if (counts.length > 0) {
|
|
89
|
+
approvals_required = { implemented: true, count: Math.max(...counts) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return buildBranchProtectionBody({
|
|
93
|
+
branch_ref: branchRef,
|
|
94
|
+
required_status_contexts: [],
|
|
95
|
+
protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
|
|
96
|
+
approvals_required,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildBranchProtectionFromGitHubProtection(branchRef, protectionPayload) {
|
|
101
|
+
if (protectionPayload == null) {
|
|
102
|
+
return buildBranchProtectionBody({
|
|
103
|
+
branch_ref: branchRef,
|
|
104
|
+
required_status_contexts: [],
|
|
105
|
+
protected_branch_rules: [],
|
|
106
|
+
approvals_required: unimplementedApprovalsRequiredSignal(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const payload =
|
|
110
|
+
protectionPayload != null && typeof protectionPayload === 'object' ? protectionPayload : {};
|
|
111
|
+
const required_status_contexts = sanitizeStringList(
|
|
112
|
+
payload.required_status_checks?.contexts,
|
|
113
|
+
MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
|
|
114
|
+
);
|
|
115
|
+
let approvals_required = unimplementedApprovalsRequiredSignal();
|
|
116
|
+
const reviews = payload.required_pull_request_reviews;
|
|
117
|
+
if (reviews != null && typeof reviews === 'object' && 'required_approving_review_count' in reviews) {
|
|
118
|
+
const count = Number(reviews.required_approving_review_count);
|
|
119
|
+
if (Number.isFinite(count) && count >= 0) {
|
|
120
|
+
approvals_required = { implemented: true, count: Math.floor(count) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const ruleName = sanitizeField(branchRef);
|
|
124
|
+
return buildBranchProtectionBody({
|
|
125
|
+
branch_ref: branchRef,
|
|
126
|
+
required_status_contexts,
|
|
127
|
+
protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
|
|
128
|
+
approvals_required,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildBranchProtectionFromGiteaProtection(branchRef, protectionPayload) {
|
|
133
|
+
const payload =
|
|
134
|
+
protectionPayload != null && typeof protectionPayload === 'object' ? protectionPayload : {};
|
|
135
|
+
const ruleName = sanitizeField(payload.branch_name ?? payload.rule_name ?? branchRef);
|
|
136
|
+
const required_status_contexts =
|
|
137
|
+
payload.enable_status_check === false
|
|
138
|
+
? []
|
|
139
|
+
: sanitizeStringList(payload.status_check_contexts, MAX_BRANCH_PROTECTION_STATUS_CONTEXTS);
|
|
140
|
+
|
|
141
|
+
let approvals_required = unimplementedApprovalsRequiredSignal();
|
|
142
|
+
if ('required_approvals' in payload) {
|
|
143
|
+
const count = Number(payload.required_approvals);
|
|
144
|
+
if (Number.isFinite(count) && count >= 0) {
|
|
145
|
+
approvals_required = { implemented: true, count: Math.floor(count) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return buildBranchProtectionBody({
|
|
150
|
+
branch_ref: branchRef,
|
|
151
|
+
required_status_contexts,
|
|
152
|
+
protected_branch_rules: ruleName ? [{ name: ruleName }] : [],
|
|
153
|
+
approvals_required,
|
|
154
|
+
});
|
|
155
|
+
}
|
package/caps.js
CHANGED
|
@@ -1,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
|
/**
|
|
@@ -64,6 +73,17 @@ export function idempotencyScanCapabilityFacts() {
|
|
|
64
73
|
};
|
|
65
74
|
}
|
|
66
75
|
|
|
76
|
+
/** Idempotency scan facts for status set (commit-status list pagination). */
|
|
77
|
+
export function statusSetIdempotencyScanCapabilityFacts() {
|
|
78
|
+
return {
|
|
79
|
+
idempotency_scan: {
|
|
80
|
+
max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
81
|
+
page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
82
|
+
ingest_backoff: 'halve_until_fit',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
/** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
|
|
68
88
|
export function openPullListCapabilityFacts({
|
|
69
89
|
totalCountSource = null,
|
|
@@ -120,6 +140,8 @@ function redactSecretPatterns(text) {
|
|
|
120
140
|
.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
|
|
121
141
|
.replace(/\bghp_[A-Za-z0-9]+\b/g, '[REDACTED]')
|
|
122
142
|
.replace(/\bgho_[A-Za-z0-9]+\b/g, '[REDACTED]')
|
|
143
|
+
.replace(/\bghs_[A-Za-z0-9._-]{36,}/g, '[REDACTED]')
|
|
144
|
+
.replace(/\bghs_[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
123
145
|
.replace(/\bglpat-[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
124
146
|
.replace(/\b(GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|GITEA_TOKEN)\b/gi, '[REDACTED]');
|
|
125
147
|
}
|
package/contracts/envelope.js
CHANGED
|
@@ -14,6 +14,12 @@ 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
|
+
COMMIT_STATUS_SET: 'commit_status_set',
|
|
18
|
+
PROVIDER_IDENTITY: 'provider_identity',
|
|
19
|
+
BRANCH_PROTECTION: 'branch_protection',
|
|
20
|
+
CR_FILES: 'cr_files',
|
|
21
|
+
CR_COMMENTS: 'cr_comments',
|
|
22
|
+
FORGE_CHANGES: 'forge_changes',
|
|
17
23
|
};
|
|
18
24
|
|
|
19
25
|
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
@@ -18,6 +18,11 @@ export const OBSERVER_REMOGRAM_COMMANDS = Object.freeze([
|
|
|
18
18
|
{ command: 'sync plan', mcp_tool: 'sync_plan', read_only: true, observer_proto: false },
|
|
19
19
|
{ command: 'provider capabilities', mcp_tool: 'provider_capabilities', read_only: true, observer_proto: false },
|
|
20
20
|
{ command: 'doctor', mcp_tool: 'doctor', read_only: true, observer_proto: false },
|
|
21
|
+
{ command: 'whoami', mcp_tool: 'whoami', read_only: true, observer_proto: false },
|
|
22
|
+
{ command: 'branch protection', mcp_tool: 'branch_protection', read_only: true, observer_proto: false },
|
|
23
|
+
{ command: 'cr files', mcp_tool: 'cr_files', read_only: true, observer_proto: false },
|
|
24
|
+
{ command: 'cr comments', mcp_tool: 'cr_comments', read_only: true, observer_proto: false },
|
|
25
|
+
{ command: 'forge changes', mcp_tool: 'forge_changes', read_only: true, observer_proto: false },
|
|
21
26
|
]);
|
|
22
27
|
|
|
23
28
|
/** Fact inventory packet types for semantic-diff / branch-workcycle composition. */
|
|
@@ -20,10 +20,15 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
|
|
|
20
20
|
'sync plan',
|
|
21
21
|
'provider capabilities',
|
|
22
22
|
'doctor',
|
|
23
|
+
'whoami',
|
|
24
|
+
'branch protection',
|
|
25
|
+
'cr files',
|
|
26
|
+
'cr comments',
|
|
27
|
+
'forge changes',
|
|
23
28
|
]);
|
|
24
29
|
|
|
25
|
-
/** v1 write surface (Gitea cr open
|
|
26
|
-
export const V1_WRITE_COMMANDS = Object.freeze(['cr open']);
|
|
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']);
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
|
|
@@ -58,12 +63,16 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
|
|
|
58
63
|
truncated: true,
|
|
59
64
|
list_truncated: true,
|
|
60
65
|
checks_truncated: true,
|
|
66
|
+
paths_truncated: true,
|
|
67
|
+
comments_truncated: true,
|
|
68
|
+
events_truncated: true,
|
|
61
69
|
slice_sort: true,
|
|
62
70
|
entry_count: true,
|
|
63
71
|
mergeability_confidence: true,
|
|
64
72
|
write_support: true,
|
|
65
73
|
diverged: true,
|
|
66
74
|
auth_present: true,
|
|
75
|
+
can_write: true,
|
|
67
76
|
reused_existing: true,
|
|
68
77
|
idempotency_scan: true,
|
|
69
78
|
});
|
|
@@ -81,6 +90,16 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
|
81
90
|
sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
|
|
82
91
|
ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
|
|
83
92
|
change_request_opened: ['url', 'title', 'head', 'base'],
|
|
93
|
+
commit_status_set: ['sha', 'context', 'description', 'target_url'],
|
|
94
|
+
provider_identity: ['login'],
|
|
95
|
+
branch_protection: [
|
|
96
|
+
'branch_ref',
|
|
97
|
+
'required_status_contexts[]',
|
|
98
|
+
'protected_branch_rules[].name',
|
|
99
|
+
],
|
|
100
|
+
cr_files: ['changed_paths[]'],
|
|
101
|
+
cr_comments: ['comments[].id', 'comments[].author', 'comments[].path', 'comments[].body'],
|
|
102
|
+
forge_changes: ['events[].title', 'events[].url', 'events[].head_sha'],
|
|
84
103
|
cr_inventory_slice: [
|
|
85
104
|
'entries[].url',
|
|
86
105
|
'entries[].title',
|
package/cr-comments.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
|
|
3
|
+
export const MAX_CR_COMMENTS = 256;
|
|
4
|
+
|
|
5
|
+
export function normalizeCrComment(raw) {
|
|
6
|
+
if (raw == null || typeof raw !== 'object') return null;
|
|
7
|
+
const id = raw.id ?? raw.comment_id;
|
|
8
|
+
if (id == null) return null;
|
|
9
|
+
|
|
10
|
+
const author =
|
|
11
|
+
sanitizeField(
|
|
12
|
+
raw.author ??
|
|
13
|
+
raw.user?.login ??
|
|
14
|
+
raw.user?.username ??
|
|
15
|
+
raw.user?.name ??
|
|
16
|
+
'',
|
|
17
|
+
) || null;
|
|
18
|
+
const path =
|
|
19
|
+
raw.path != null && String(raw.path).trim() !== '' ? sanitizeField(raw.path) : null;
|
|
20
|
+
const lineRaw = raw.line ?? raw.original_line ?? raw.new_line;
|
|
21
|
+
const line =
|
|
22
|
+
lineRaw != null && Number.isFinite(Number(lineRaw)) ? Math.floor(Number(lineRaw)) : null;
|
|
23
|
+
const body = sanitizeField(raw.body ?? raw.note ?? '') ?? '';
|
|
24
|
+
const resolved = Boolean(raw.resolved ?? raw.is_resolved ?? false);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
id: sanitizeField(String(id)),
|
|
28
|
+
author,
|
|
29
|
+
path,
|
|
30
|
+
line,
|
|
31
|
+
body,
|
|
32
|
+
resolved,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildCrCommentsBody({ pr_number, comments, comments_truncated, comment_count }) {
|
|
37
|
+
const list = Array.isArray(comments) ? comments : [];
|
|
38
|
+
const count = Number.isFinite(Number(comment_count))
|
|
39
|
+
? Math.floor(Number(comment_count))
|
|
40
|
+
: list.length;
|
|
41
|
+
return {
|
|
42
|
+
pr_number: Math.floor(Number(pr_number)),
|
|
43
|
+
comments: list,
|
|
44
|
+
comments_truncated: Boolean(comments_truncated),
|
|
45
|
+
comment_count: count,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildCrCommentsFromNormalizedList(prNumber, all) {
|
|
50
|
+
const comment_count = all.length;
|
|
51
|
+
const capped = all.length > MAX_CR_COMMENTS;
|
|
52
|
+
const comments = all.slice(0, MAX_CR_COMMENTS);
|
|
53
|
+
return buildCrCommentsBody({
|
|
54
|
+
pr_number: prNumber,
|
|
55
|
+
comments,
|
|
56
|
+
comments_truncated: capped,
|
|
57
|
+
comment_count,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildCrCommentsFromGiteaComments(prNumber, commentsArray) {
|
|
62
|
+
const all = [];
|
|
63
|
+
if (Array.isArray(commentsArray)) {
|
|
64
|
+
for (const item of commentsArray) {
|
|
65
|
+
const normalized = normalizeCrComment(item);
|
|
66
|
+
if (normalized) all.push(normalized);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return buildCrCommentsFromNormalizedList(prNumber, all);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildCrCommentsFromGitLabDiscussions(prNumber, discussionsArray) {
|
|
73
|
+
const all = [];
|
|
74
|
+
if (Array.isArray(discussionsArray)) {
|
|
75
|
+
for (const discussion of discussionsArray) {
|
|
76
|
+
const notes = Array.isArray(discussion?.notes) ? discussion.notes : [];
|
|
77
|
+
for (const note of notes) {
|
|
78
|
+
if (note?.system === true) continue;
|
|
79
|
+
const position = note.position && typeof note.position === 'object' ? note.position : null;
|
|
80
|
+
const normalized = normalizeCrComment({
|
|
81
|
+
id: note.id,
|
|
82
|
+
author: note.author?.username ?? note.author?.name ?? '',
|
|
83
|
+
path: position?.new_path ?? position?.old_path ?? null,
|
|
84
|
+
line: position?.new_line ?? position?.old_line ?? null,
|
|
85
|
+
body: note.body ?? '',
|
|
86
|
+
resolved: note.resolved ?? false,
|
|
87
|
+
});
|
|
88
|
+
if (normalized) all.push(normalized);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return buildCrCommentsFromNormalizedList(prNumber, all);
|
|
93
|
+
}
|
package/cr-files.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
|
|
3
|
+
export const MAX_CR_FILES_PATHS = 256;
|
|
4
|
+
|
|
5
|
+
function sanitizePathList(filesArray) {
|
|
6
|
+
if (!Array.isArray(filesArray)) return [];
|
|
7
|
+
const paths = [];
|
|
8
|
+
for (const file of filesArray) {
|
|
9
|
+
if (file == null || typeof file !== 'object') continue;
|
|
10
|
+
const sanitized = sanitizeField(file.filename);
|
|
11
|
+
if (sanitized) paths.push(sanitized);
|
|
12
|
+
}
|
|
13
|
+
return paths;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildCrFilesBody({ pr_number, changed_paths, paths_truncated, path_count }) {
|
|
17
|
+
const paths = Array.isArray(changed_paths) ? changed_paths : [];
|
|
18
|
+
const count = Number.isFinite(Number(path_count)) ? Math.floor(Number(path_count)) : paths.length;
|
|
19
|
+
return {
|
|
20
|
+
pr_number: Math.floor(Number(pr_number)),
|
|
21
|
+
changed_paths: paths,
|
|
22
|
+
paths_truncated: Boolean(paths_truncated),
|
|
23
|
+
path_count: count,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildCrFilesFromGiteaFiles(prNumber, filesArray) {
|
|
28
|
+
const allPaths = sanitizePathList(filesArray);
|
|
29
|
+
const path_count = allPaths.length;
|
|
30
|
+
const capped = allPaths.length > MAX_CR_FILES_PATHS;
|
|
31
|
+
const changed_paths = allPaths.slice(0, MAX_CR_FILES_PATHS);
|
|
32
|
+
return buildCrFilesBody({
|
|
33
|
+
pr_number: prNumber,
|
|
34
|
+
changed_paths,
|
|
35
|
+
paths_truncated: capped,
|
|
36
|
+
path_count,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCrFilesFromGitLabChanges(prNumber, changesArray) {
|
|
41
|
+
const paths = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
if (Array.isArray(changesArray)) {
|
|
44
|
+
for (const change of changesArray) {
|
|
45
|
+
if (change == null || typeof change !== 'object') continue;
|
|
46
|
+
const path = sanitizeField(change.new_path ?? change.old_path ?? '');
|
|
47
|
+
if (path && !seen.has(path)) {
|
|
48
|
+
seen.add(path);
|
|
49
|
+
paths.push(path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const path_count = paths.length;
|
|
54
|
+
const capped = paths.length > MAX_CR_FILES_PATHS;
|
|
55
|
+
const changed_paths = paths.slice(0, MAX_CR_FILES_PATHS);
|
|
56
|
+
return buildCrFilesBody({
|
|
57
|
+
pr_number: prNumber,
|
|
58
|
+
changed_paths,
|
|
59
|
+
paths_truncated: capped,
|
|
60
|
+
path_count,
|
|
61
|
+
});
|
|
62
|
+
}
|
package/forge-changes.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { sanitizeField, sanitizeUrl } from './caps.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
|
|
4
|
+
export const MAX_FORGE_CHANGES_EVENTS = 256;
|
|
5
|
+
|
|
6
|
+
export const FORGE_CHANGE_EVENT_KINDS = Object.freeze({
|
|
7
|
+
PR_OPENED: 'pr_opened',
|
|
8
|
+
PR_CLOSED: 'pr_closed',
|
|
9
|
+
PR_MERGED: 'pr_merged',
|
|
10
|
+
HEAD_SHA_MOVED: 'head_sha_moved',
|
|
11
|
+
CHECKS_CONCLUSION_OBSERVED: 'checks_conclusion_observed',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function parseSinceObservedAt(raw) {
|
|
15
|
+
if (raw == null || String(raw).trim() === '') {
|
|
16
|
+
throw Object.assign(new Error('--since required'), {
|
|
17
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--since required for forge changes'),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const ms = Date.parse(String(raw));
|
|
21
|
+
if (!Number.isFinite(ms)) {
|
|
22
|
+
throw Object.assign(new Error('Invalid --since'), {
|
|
23
|
+
forgeError: forgeError(
|
|
24
|
+
ERROR_CODES.INVALID_ARGS,
|
|
25
|
+
'--since must be a parseable ISO-8601 timestamp',
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return new Date(ms).toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function timestampMs(value) {
|
|
33
|
+
const ms = Date.parse(value ?? '');
|
|
34
|
+
return Number.isFinite(ms) ? ms : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isAtOrAfter(value, sinceIso) {
|
|
38
|
+
const ms = timestampMs(value);
|
|
39
|
+
const sinceMs = timestampMs(sinceIso);
|
|
40
|
+
if (ms == null || sinceMs == null) return false;
|
|
41
|
+
return ms >= sinceMs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isBefore(value, sinceIso) {
|
|
45
|
+
const ms = timestampMs(value);
|
|
46
|
+
const sinceMs = timestampMs(sinceIso);
|
|
47
|
+
if (ms == null || sinceMs == null) return false;
|
|
48
|
+
return ms < sinceMs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizePrState(state) {
|
|
52
|
+
const normalized = String(state ?? '').toLowerCase();
|
|
53
|
+
if (normalized === 'open') return 'open';
|
|
54
|
+
if (normalized === 'closed') return 'closed';
|
|
55
|
+
return 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeIsoTimestamp(value) {
|
|
59
|
+
const ms = timestampMs(value);
|
|
60
|
+
return ms == null ? null : new Date(ms).toISOString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function baseEventFields(pull) {
|
|
64
|
+
return {
|
|
65
|
+
pr_number: Math.floor(Number(pull.number)),
|
|
66
|
+
title: sanitizeField(pull.title ?? '') || null,
|
|
67
|
+
url: sanitizeUrl(pull.html_url ?? pull.url) || null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildChecksConclusionObservedEvent(prNumber, checksBody) {
|
|
72
|
+
return {
|
|
73
|
+
kind: FORGE_CHANGE_EVENT_KINDS.CHECKS_CONCLUSION_OBSERVED,
|
|
74
|
+
pr_number: Math.floor(Number(prNumber)),
|
|
75
|
+
head_sha: sanitizeField(checksBody?.head_sha ?? '') || null,
|
|
76
|
+
check_conclusion: sanitizeField(checksBody?.check_conclusion ?? '') || 'unknown',
|
|
77
|
+
checks_truncated: Boolean(checksBody?.checks_truncated ?? false),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildForgeChangesBody({
|
|
82
|
+
since,
|
|
83
|
+
events,
|
|
84
|
+
events_truncated,
|
|
85
|
+
event_count,
|
|
86
|
+
since_kind = 'observed_at',
|
|
87
|
+
}) {
|
|
88
|
+
const list = Array.isArray(events) ? events : [];
|
|
89
|
+
const count = Number.isFinite(Number(event_count))
|
|
90
|
+
? Math.floor(Number(event_count))
|
|
91
|
+
: list.length;
|
|
92
|
+
return {
|
|
93
|
+
since,
|
|
94
|
+
since_kind,
|
|
95
|
+
events: list,
|
|
96
|
+
events_truncated: Boolean(events_truncated),
|
|
97
|
+
event_count: count,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function capForgeChangeEvents(allEvents, sinceIso, { listTruncated = false } = {}) {
|
|
102
|
+
const event_count = allEvents.length;
|
|
103
|
+
const capped = allEvents.length > MAX_FORGE_CHANGES_EVENTS;
|
|
104
|
+
return buildForgeChangesBody({
|
|
105
|
+
since: sinceIso,
|
|
106
|
+
events: allEvents.slice(0, MAX_FORGE_CHANGES_EVENTS),
|
|
107
|
+
events_truncated: capped || listTruncated,
|
|
108
|
+
event_count,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function appendForgeChangeEvents(body, additionalEvents, { listTruncated = false } = {}) {
|
|
113
|
+
const merged = [...(body.events ?? []), ...(Array.isArray(additionalEvents) ? additionalEvents : [])];
|
|
114
|
+
return capForgeChangeEvents(merged, body.since, {
|
|
115
|
+
listTruncated: listTruncated || body.events_truncated,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildForgeChangesFromGiteaPulls(sinceIso, pullsArray, opts = {}) {
|
|
120
|
+
const since = parseSinceObservedAt(sinceIso);
|
|
121
|
+
const events = [];
|
|
122
|
+
if (Array.isArray(pullsArray)) {
|
|
123
|
+
for (const pull of pullsArray) {
|
|
124
|
+
if (pull == null || pull.number == null) continue;
|
|
125
|
+
const state = normalizePrState(pull.state);
|
|
126
|
+
const base = baseEventFields(pull);
|
|
127
|
+
const createdAt = pull.created_at;
|
|
128
|
+
const updatedAt = pull.updated_at;
|
|
129
|
+
const closedAt = pull.closed_at;
|
|
130
|
+
const mergedAt = pull.merged_at;
|
|
131
|
+
const mergedInWindow = isAtOrAfter(mergedAt, since);
|
|
132
|
+
|
|
133
|
+
if (isAtOrAfter(createdAt, since)) {
|
|
134
|
+
events.push({
|
|
135
|
+
kind: FORGE_CHANGE_EVENT_KINDS.PR_OPENED,
|
|
136
|
+
...base,
|
|
137
|
+
state,
|
|
138
|
+
opened_at: normalizeIsoTimestamp(createdAt),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (mergedInWindow) {
|
|
143
|
+
events.push({
|
|
144
|
+
kind: FORGE_CHANGE_EVENT_KINDS.PR_MERGED,
|
|
145
|
+
...base,
|
|
146
|
+
state: 'closed',
|
|
147
|
+
merged_at: normalizeIsoTimestamp(mergedAt),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const closedNotMerged =
|
|
152
|
+
state === 'closed' && (mergedAt == null || String(mergedAt).trim() === '');
|
|
153
|
+
const closedInWindow =
|
|
154
|
+
isAtOrAfter(closedAt, since) ||
|
|
155
|
+
(closedAt == null && closedNotMerged && isAtOrAfter(updatedAt, since));
|
|
156
|
+
if (!mergedInWindow && closedNotMerged && closedInWindow) {
|
|
157
|
+
events.push({
|
|
158
|
+
kind: FORGE_CHANGE_EVENT_KINDS.PR_CLOSED,
|
|
159
|
+
...base,
|
|
160
|
+
state: 'closed',
|
|
161
|
+
closed_at: normalizeIsoTimestamp(closedAt ?? updatedAt),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (
|
|
166
|
+
state === 'open' &&
|
|
167
|
+
isAtOrAfter(updatedAt, since) &&
|
|
168
|
+
isBefore(createdAt, since)
|
|
169
|
+
) {
|
|
170
|
+
events.push({
|
|
171
|
+
kind: FORGE_CHANGE_EVENT_KINDS.HEAD_SHA_MOVED,
|
|
172
|
+
...base,
|
|
173
|
+
state: 'open',
|
|
174
|
+
head_sha: sanitizeField(pull.head?.sha ?? '') || null,
|
|
175
|
+
updated_at: normalizeIsoTimestamp(updatedAt),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return capForgeChangeEvents(events, since, { listTruncated: Boolean(opts.listTruncated) });
|
|
181
|
+
}
|
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,
|
|
@@ -36,6 +37,7 @@ export {
|
|
|
36
37
|
forgeIngestCapabilityFacts,
|
|
37
38
|
checkPaginationCapabilityFacts,
|
|
38
39
|
idempotencyScanCapabilityFacts,
|
|
40
|
+
statusSetIdempotencyScanCapabilityFacts,
|
|
39
41
|
openPullListCapabilityFacts,
|
|
40
42
|
} from './caps.js';
|
|
41
43
|
export {
|
|
@@ -73,24 +75,87 @@ export {
|
|
|
73
75
|
appendSortQuery,
|
|
74
76
|
} from './open-pull-list.js';
|
|
75
77
|
export { buildChangeRequestOpenedBody } from './cr-open.js';
|
|
78
|
+
export {
|
|
79
|
+
STATUS_SET_STATES,
|
|
80
|
+
assertCommitSha,
|
|
81
|
+
normalizeStatusSetState,
|
|
82
|
+
parseStatusSetArgs,
|
|
83
|
+
buildCommitStatusSetBody,
|
|
84
|
+
} from './status-set.js';
|
|
85
|
+
export {
|
|
86
|
+
buildProviderIdentityBody,
|
|
87
|
+
buildProviderIdentityFromGiteaUser,
|
|
88
|
+
parseGitHubOAuthScopes,
|
|
89
|
+
githubCanWriteFromScopes,
|
|
90
|
+
buildProviderIdentityFromGitHubUser,
|
|
91
|
+
normalizeGitLabCanWrite,
|
|
92
|
+
parseGitLabPatSelfSignals,
|
|
93
|
+
buildProviderIdentityFromGitLabUser,
|
|
94
|
+
normalizeGiteaCanWrite,
|
|
95
|
+
unimplementedTokenScopeSignal,
|
|
96
|
+
unimplementedTokenExpirySignal,
|
|
97
|
+
} from './whoami.js';
|
|
98
|
+
export {
|
|
99
|
+
buildBranchProtectionBody,
|
|
100
|
+
buildBranchProtectionFromGiteaProtection,
|
|
101
|
+
buildBranchProtectionFromGitHubProtection,
|
|
102
|
+
buildBranchProtectionFromGitLabProtection,
|
|
103
|
+
unimplementedApprovalsRequiredSignal,
|
|
104
|
+
MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
|
|
105
|
+
MAX_BRANCH_PROTECTION_RULES,
|
|
106
|
+
} from './branch-protection.js';
|
|
107
|
+
export {
|
|
108
|
+
buildCrFilesBody,
|
|
109
|
+
buildCrFilesFromGiteaFiles,
|
|
110
|
+
buildCrFilesFromGitLabChanges,
|
|
111
|
+
MAX_CR_FILES_PATHS,
|
|
112
|
+
} from './cr-files.js';
|
|
113
|
+
export {
|
|
114
|
+
buildCrCommentsBody,
|
|
115
|
+
buildCrCommentsFromGiteaComments,
|
|
116
|
+
buildCrCommentsFromGitLabDiscussions,
|
|
117
|
+
normalizeCrComment,
|
|
118
|
+
MAX_CR_COMMENTS,
|
|
119
|
+
} from './cr-comments.js';
|
|
120
|
+
export {
|
|
121
|
+
parseSinceObservedAt,
|
|
122
|
+
buildForgeChangesBody,
|
|
123
|
+
buildForgeChangesFromGiteaPulls,
|
|
124
|
+
buildChecksConclusionObservedEvent,
|
|
125
|
+
appendForgeChangeEvents,
|
|
126
|
+
MAX_FORGE_CHANGES_EVENTS,
|
|
127
|
+
FORGE_CHANGE_EVENT_KINDS,
|
|
128
|
+
} from './forge-changes.js';
|
|
76
129
|
export {
|
|
77
130
|
WRITE_COMMAND_IDS,
|
|
78
131
|
CONFIGURED_WRITE_COMMANDS,
|
|
79
132
|
writeCommandSchema,
|
|
80
133
|
assertWriteCommandConfigured,
|
|
134
|
+
writeNotConfiguredMessage,
|
|
81
135
|
isWriteCommandConfigured,
|
|
82
136
|
} from './write-config.js';
|
|
83
137
|
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
84
138
|
export {
|
|
139
|
+
applyForgePathScopeForMergePlan,
|
|
140
|
+
isCrFilesScopeComplete,
|
|
85
141
|
resolveMergePlanPathScope,
|
|
86
142
|
buildMergePlanBody,
|
|
87
143
|
buildMergePlanBodyFromFacts,
|
|
144
|
+
normalizeAllowedPaths,
|
|
88
145
|
} from './merge-plan.js';
|
|
146
|
+
export {
|
|
147
|
+
isMergePlanForgeScopeRethrowError,
|
|
148
|
+
MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES,
|
|
149
|
+
resolveMergePlanOptsWithForgePaths,
|
|
150
|
+
buildMergePlanFromProviderFacts,
|
|
151
|
+
} from './merge-plan-forge.js';
|
|
89
152
|
export {
|
|
90
153
|
matchPathAllowlist,
|
|
91
154
|
isPathAllowed,
|
|
92
155
|
pathsOutsideAllowlist,
|
|
93
156
|
allPathsAllowed,
|
|
157
|
+
normalizeRepoRelativePath,
|
|
158
|
+
normalizeChangedPathList,
|
|
94
159
|
} from './path-allowlist.js';
|
|
95
160
|
export {
|
|
96
161
|
localHeadShaForPr,
|
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,22 +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
|
-
if (
|
|
16
|
-
|
|
54
|
+
if (Array.isArray(opts.changed_paths)) {
|
|
55
|
+
const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
|
|
56
|
+
return {
|
|
57
|
+
allowed_paths: allowedPaths,
|
|
58
|
+
changed_paths: normalizedPaths,
|
|
59
|
+
};
|
|
17
60
|
}
|
|
18
|
-
|
|
19
|
-
return { allowed_paths: allowedPaths, changed_paths: changedPaths };
|
|
61
|
+
return { allowed_paths: allowedPaths, changed_paths: null };
|
|
20
62
|
}
|
|
21
63
|
|
|
22
64
|
export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
@@ -28,7 +70,7 @@ export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
|
28
70
|
};
|
|
29
71
|
}
|
|
30
72
|
|
|
31
|
-
export function buildMergePlanBodyFromFacts(
|
|
32
|
-
const pathScope = resolveMergePlanPathScope(
|
|
73
|
+
export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
|
|
74
|
+
const pathScope = resolveMergePlanPathScope(opts);
|
|
33
75
|
return buildMergePlanBody(view, checks, pathScope);
|
|
34
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/status-set.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { sanitizeField, sanitizeUrl } from './caps.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
|
|
4
|
+
/** Supported commit status states (Gitea/GitHub parity). */
|
|
5
|
+
export const STATUS_SET_STATES = Object.freeze(['pending', 'success', 'failure', 'error']);
|
|
6
|
+
|
|
7
|
+
const STATUS_SET_STATE_SET = new Set(STATUS_SET_STATES);
|
|
8
|
+
|
|
9
|
+
const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/;
|
|
10
|
+
|
|
11
|
+
export function assertCommitSha(sha, label = 'sha') {
|
|
12
|
+
const value = String(sha ?? '').trim();
|
|
13
|
+
if (!FULL_SHA_RE.test(value)) {
|
|
14
|
+
throw Object.assign(new Error(`Invalid ${label}`), {
|
|
15
|
+
forgeError: forgeError(
|
|
16
|
+
ERROR_CODES.INVALID_ARGS,
|
|
17
|
+
`${label} must be a 40-character hexadecimal commit SHA`,
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return value.toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeStatusSetState(state) {
|
|
25
|
+
const normalized = String(state ?? '').toLowerCase();
|
|
26
|
+
if (STATUS_SET_STATE_SET.has(normalized)) return normalized;
|
|
27
|
+
if (normalized === 'pass') return 'success';
|
|
28
|
+
if (normalized === 'fail') return 'failure';
|
|
29
|
+
throw Object.assign(new Error('Invalid status state'), {
|
|
30
|
+
forgeError: forgeError(
|
|
31
|
+
ERROR_CODES.INVALID_ARGS,
|
|
32
|
+
`state must be one of: ${STATUS_SET_STATES.join(', ')}`,
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseStatusSetArgs({ sha, context, state, target_url, description }) {
|
|
38
|
+
const parsedSha = assertCommitSha(sha, '--sha');
|
|
39
|
+
if (context == null || String(context).trim() === '') {
|
|
40
|
+
throw Object.assign(new Error('--context required'), {
|
|
41
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--context required for status set'),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (state == null || String(state).trim() === '') {
|
|
45
|
+
throw Object.assign(new Error('--state required'), {
|
|
46
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--state required for status set'),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const parsed = {
|
|
50
|
+
sha: parsedSha,
|
|
51
|
+
context: sanitizeField(String(context)),
|
|
52
|
+
state: normalizeStatusSetState(state),
|
|
53
|
+
};
|
|
54
|
+
if (target_url != null && String(target_url).trim() !== '') {
|
|
55
|
+
parsed.target_url = sanitizeUrl(String(target_url));
|
|
56
|
+
}
|
|
57
|
+
if (description != null && String(description).trim() !== '') {
|
|
58
|
+
parsed.description = sanitizeField(String(description));
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalize forge commit status POST/GET response into commit_status_set body fields. */
|
|
64
|
+
export function buildCommitStatusSetBody(
|
|
65
|
+
response,
|
|
66
|
+
args,
|
|
67
|
+
{ reusedExisting = false } = {},
|
|
68
|
+
) {
|
|
69
|
+
const state = normalizeStatusSetState(response?.status ?? response?.state ?? args.state);
|
|
70
|
+
const body = {
|
|
71
|
+
sha: args.sha,
|
|
72
|
+
context: sanitizeField(args.context),
|
|
73
|
+
state,
|
|
74
|
+
};
|
|
75
|
+
const description = response?.description ?? args.description;
|
|
76
|
+
if (description != null && String(description).trim() !== '') {
|
|
77
|
+
body.description = sanitizeField(String(description));
|
|
78
|
+
}
|
|
79
|
+
const targetUrl = response?.target_url ?? args.target_url;
|
|
80
|
+
if (targetUrl != null && String(targetUrl).trim() !== '') {
|
|
81
|
+
body.target_url = sanitizeUrl(String(targetUrl));
|
|
82
|
+
}
|
|
83
|
+
if (reusedExisting) {
|
|
84
|
+
body.reused_existing = true;
|
|
85
|
+
}
|
|
86
|
+
return body;
|
|
87
|
+
}
|
package/stub-provider.js
CHANGED
|
@@ -29,5 +29,12 @@ export function createStubProvider(id) {
|
|
|
29
29
|
prChecks: unsupported,
|
|
30
30
|
mergePlan: unsupported,
|
|
31
31
|
syncPlan: unsupported,
|
|
32
|
+
whoami: unsupported,
|
|
33
|
+
branchProtection: unsupported,
|
|
34
|
+
crFiles: unsupported,
|
|
35
|
+
crComments: unsupported,
|
|
36
|
+
forgeChanges: unsupported,
|
|
37
|
+
crOpen: unsupported,
|
|
38
|
+
statusSet: unsupported,
|
|
32
39
|
};
|
|
33
40
|
}
|
package/whoami.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
|
|
3
|
+
/** Gitea does not expose OAuth scope or token expiry on GET /user. */
|
|
4
|
+
export function unimplementedTokenScopeSignal() {
|
|
5
|
+
return { implemented: false, scopes: null };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function unimplementedTokenExpirySignal() {
|
|
9
|
+
return { implemented: false, expires_at: null };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Restricted Gitea users are read-only; others may write per forge policy. */
|
|
13
|
+
export function normalizeGiteaCanWrite(user) {
|
|
14
|
+
if (user == null || typeof user !== 'object') return false;
|
|
15
|
+
if (user.restricted === true) return false;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildProviderIdentityBody({
|
|
20
|
+
login,
|
|
21
|
+
can_write,
|
|
22
|
+
token_scope_signal,
|
|
23
|
+
token_expiry_signal,
|
|
24
|
+
}) {
|
|
25
|
+
return {
|
|
26
|
+
login: sanitizeField(login),
|
|
27
|
+
can_write: Boolean(can_write),
|
|
28
|
+
token_scope_signal,
|
|
29
|
+
token_expiry_signal,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildProviderIdentityFromGiteaUser(user) {
|
|
34
|
+
return buildProviderIdentityBody({
|
|
35
|
+
login: user?.login ?? '',
|
|
36
|
+
can_write: normalizeGiteaCanWrite(user),
|
|
37
|
+
token_scope_signal: unimplementedTokenScopeSignal(),
|
|
38
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseGitHubOAuthScopes(headerValue) {
|
|
43
|
+
if (headerValue == null || String(headerValue).trim() === '') {
|
|
44
|
+
return unimplementedTokenScopeSignal();
|
|
45
|
+
}
|
|
46
|
+
const scopes = String(headerValue)
|
|
47
|
+
.split(',')
|
|
48
|
+
.map((scope) => sanitizeField(scope.trim()))
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
if (scopes.length === 0) {
|
|
51
|
+
return unimplementedTokenScopeSignal();
|
|
52
|
+
}
|
|
53
|
+
return { implemented: true, scopes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function githubCanWriteFromScopes(tokenScopeSignal) {
|
|
57
|
+
if (!tokenScopeSignal?.implemented || !Array.isArray(tokenScopeSignal.scopes)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return tokenScopeSignal.scopes.some(
|
|
61
|
+
(scope) => scope === 'repo' || scope === 'public_repo' || scope.startsWith('repo:'),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildProviderIdentityFromGitHubUser(user, oauthScopesHeader) {
|
|
66
|
+
const token_scope_signal = parseGitHubOAuthScopes(oauthScopesHeader);
|
|
67
|
+
return buildProviderIdentityBody({
|
|
68
|
+
login: user?.login ?? '',
|
|
69
|
+
can_write: githubCanWriteFromScopes(token_scope_signal),
|
|
70
|
+
token_scope_signal,
|
|
71
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeGitLabCanWrite(user) {
|
|
76
|
+
if (user == null || typeof user !== 'object') return false;
|
|
77
|
+
if (user.state != null && user.state !== 'active') return false;
|
|
78
|
+
if (user.can_create_project === false) return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseGitLabPatSelfSignals(patSelf) {
|
|
83
|
+
if (patSelf == null || typeof patSelf !== 'object') {
|
|
84
|
+
return {
|
|
85
|
+
token_scope_signal: unimplementedTokenScopeSignal(),
|
|
86
|
+
token_expiry_signal: unimplementedTokenExpirySignal(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
let token_scope_signal = unimplementedTokenScopeSignal();
|
|
90
|
+
if (Array.isArray(patSelf.scopes)) {
|
|
91
|
+
const scopes = patSelf.scopes.map((scope) => sanitizeField(String(scope))).filter(Boolean);
|
|
92
|
+
if (scopes.length > 0) {
|
|
93
|
+
token_scope_signal = { implemented: true, scopes };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const token_expiry_signal =
|
|
97
|
+
'expires_at' in patSelf
|
|
98
|
+
? {
|
|
99
|
+
implemented: true,
|
|
100
|
+
expires_at: patSelf.expires_at == null ? null : sanitizeField(String(patSelf.expires_at)),
|
|
101
|
+
}
|
|
102
|
+
: unimplementedTokenExpirySignal();
|
|
103
|
+
return { token_scope_signal, token_expiry_signal };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildProviderIdentityFromGitLabUser(user, patSelf) {
|
|
107
|
+
const { token_scope_signal, token_expiry_signal } = parseGitLabPatSelfSignals(patSelf);
|
|
108
|
+
return buildProviderIdentityBody({
|
|
109
|
+
login: user?.username ?? user?.login ?? '',
|
|
110
|
+
can_write: normalizeGitLabCanWrite(user),
|
|
111
|
+
token_scope_signal,
|
|
112
|
+
token_expiry_signal,
|
|
113
|
+
});
|
|
114
|
+
}
|
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']);
|
|
5
|
+
export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set']);
|
|
6
6
|
|
|
7
7
|
export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
|
|
8
8
|
|
|
@@ -15,6 +15,15 @@ export function isWriteCommandConfigured(config, commandName) {
|
|
|
15
15
|
return Array.isArray(allowed) && allowed.includes(commandName);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Consumer-facing message when a write command is not opted in via write_commands. */
|
|
19
|
+
export function writeNotConfiguredMessage(commandName) {
|
|
20
|
+
return (
|
|
21
|
+
`Command "${commandName}" is not in write_commands; add it to .remogram.json `
|
|
22
|
+
+ 'for Remogram CLI/MCP writes, or use your forge/CI tooling outside Remogram '
|
|
23
|
+
+ '(read commands still work)'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export function assertWriteCommandConfigured(config, commandName) {
|
|
19
28
|
if (!writeCommandSchema.safeParse(commandName).success) {
|
|
20
29
|
throw Object.assign(new Error(`Unknown write command: ${commandName}`), {
|
|
@@ -28,7 +37,7 @@ export function assertWriteCommandConfigured(config, commandName) {
|
|
|
28
37
|
throw Object.assign(new Error(`Write command not configured: ${commandName}`), {
|
|
29
38
|
forgeError: forgeError(
|
|
30
39
|
ERROR_CODES.WRITE_NOT_CONFIGURED,
|
|
31
|
-
|
|
40
|
+
writeNotConfiguredMessage(commandName),
|
|
32
41
|
),
|
|
33
42
|
});
|
|
34
43
|
}
|