@remogram/core 0.1.0-beta.1 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/auth-classes.js +73 -0
  2. package/branch-protection.js +155 -0
  3. package/caps.js +162 -14
  4. package/change-request-merge-execute.js +148 -0
  5. package/check-diagnostics.js +92 -0
  6. package/check-pagination.js +216 -0
  7. package/config-schema.js +21 -0
  8. package/contracts/envelope.js +26 -2
  9. package/contracts/errors.js +14 -2
  10. package/contracts/forge-error-fields.js +124 -0
  11. package/contracts/observer-fact-inventory.js +64 -0
  12. package/contracts/semantic-diff-facts.js +168 -0
  13. package/cr-comments.js +93 -0
  14. package/cr-files.js +62 -0
  15. package/cr-inventory-cursor.js +64 -0
  16. package/cr-inventory.js +136 -0
  17. package/cr-open.js +38 -0
  18. package/effective-write-policy.js +68 -0
  19. package/forge-changes-cursor.js +88 -0
  20. package/forge-changes.js +181 -0
  21. package/forge-identity.js +42 -0
  22. package/git-args.js +19 -0
  23. package/git-local.js +20 -0
  24. package/http.js +36 -2
  25. package/idempotency.js +69 -0
  26. package/index.js +255 -3
  27. package/issue-open.js +50 -0
  28. package/merge-blockers.js +68 -0
  29. package/merge-plan-forge.js +63 -0
  30. package/merge-plan.js +82 -0
  31. package/merge-policy.js +55 -0
  32. package/open-pull-list.js +256 -0
  33. package/operator-config.js +260 -0
  34. package/package.json +1 -1
  35. package/path-allowlist.js +114 -0
  36. package/pr-head-reconcile.js +38 -0
  37. package/provider-health.js +93 -0
  38. package/ref-inventory.js +98 -0
  39. package/resolve.js +53 -4
  40. package/status-set.js +92 -0
  41. package/stub-provider.js +11 -8
  42. package/whoami.js +114 -0
  43. package/write-config.js +63 -0
  44. package/write-field-policy.js +93 -0
  45. package/write-readiness.js +91 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,98 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { sanitizeField } from './caps.js';
3
+ import { gitAheadBehind, gitCurrentBranch, gitRevParse } from './git-local.js';
4
+
5
+ const GIT_TIMEOUT_MS = 10_000;
6
+
7
+ function gitExec(cwd, args) {
8
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
9
+ }
10
+
11
+ function listRefs(cwd) {
12
+ try {
13
+ const out = gitExec(cwd, [
14
+ 'for-each-ref',
15
+ '--format=%(refname:short)|%(objectname)|%(refname)',
16
+ 'refs/heads',
17
+ 'refs/remotes',
18
+ ]);
19
+ if (!out) return [];
20
+ return out.split('\n').filter(Boolean).map((line) => {
21
+ const [name, sha, fullRef] = line.split('|');
22
+ const kind = fullRef.startsWith('refs/heads/') ? 'branch' : 'remote';
23
+ return { name, sha, kind };
24
+ });
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function resolveDefaultRef(cwd, remoteName) {
31
+ try {
32
+ const sym = gitExec(cwd, ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`]);
33
+ const prefix = `refs/remotes/${remoteName}/`;
34
+ if (sym.startsWith(prefix)) {
35
+ return sym.slice(prefix.length);
36
+ }
37
+ return sym.replace(/^refs\/remotes\/[^/]+\//, '');
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function buildAncestryHints(cwd, defaultRef, refs) {
44
+ if (!defaultRef) return [];
45
+ const defaultEntry = refs.find((r) => r.name === defaultRef || r.name === `origin/${defaultRef}`);
46
+ const headBranch = gitCurrentBranch(cwd);
47
+ if (!headBranch || headBranch === 'HEAD') return [];
48
+ const headEntry = refs.find((r) => r.name === headBranch);
49
+ if (!defaultEntry?.sha || !headEntry?.sha || defaultEntry.sha === headEntry.sha) return [];
50
+
51
+ const counts = gitAheadBehind(cwd, defaultEntry.sha, headEntry.sha);
52
+ if (counts.ahead_by == null && counts.behind_by == null) return [];
53
+
54
+ return [
55
+ {
56
+ compare_base_ref: sanitizeField(defaultRef),
57
+ compare_head_ref: sanitizeField(headBranch),
58
+ ahead_by: counts.ahead_by,
59
+ behind_by: counts.behind_by,
60
+ },
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * Build provider-neutral ref inventory body from local git.
66
+ * @param {string} cwd repository working directory (git root after config load)
67
+ * @param {string} [remoteName]
68
+ */
69
+ export function buildRefInventoryBody(cwd, remoteName = 'origin') {
70
+ const refs = listRefs(cwd).map((ref) => ({
71
+ name: sanitizeField(ref.name),
72
+ sha: ref.sha,
73
+ kind: ref.kind,
74
+ is_default: false,
75
+ }));
76
+
77
+ const defaultRef = resolveDefaultRef(cwd, remoteName);
78
+ if (defaultRef) {
79
+ for (const ref of refs) {
80
+ if (ref.name === defaultRef || ref.name === `${remoteName}/${defaultRef}`) {
81
+ ref.is_default = true;
82
+ }
83
+ }
84
+ }
85
+
86
+ const ancestry_hints = buildAncestryHints(cwd, defaultRef, refs);
87
+
88
+ return {
89
+ refs,
90
+ ...(defaultRef ? { default_ref: sanitizeField(defaultRef) } : {}),
91
+ ...(ancestry_hints.length > 0 ? { ancestry_hints } : {}),
92
+ };
93
+ }
94
+
95
+ export async function refsInventory(ctx) {
96
+ const remoteName = ctx.config.remote || 'origin';
97
+ return buildRefInventoryBody(ctx.cwd, remoteName);
98
+ }
package/resolve.js CHANGED
@@ -1,20 +1,40 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { parseConfigFile } from './config-schema.js';
5
5
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
6
+ import { normalizedForgeOrigin } from './forge-identity.js';
7
+ import { resolveMergePolicy } from './merge-policy.js';
8
+ import { loadOperatorConfig } from './operator-config.js';
9
+ import { resolveEffectiveWritePolicy } from './effective-write-policy.js';
10
+ import { resolveEffectiveWriteFieldPolicy } from './write-field-policy.js';
6
11
  import { assertGitRemote } from './git-args.js';
12
+ import { gitRepoRoot } from './git-local.js';
7
13
 
8
14
  const HOST_ALIASES = new Map([
9
15
  ['localhost:3000', '127.0.0.1:3000'],
10
16
  ['127.0.0.1:3000', 'localhost:3000'],
11
17
  ]);
12
18
 
19
+ function samePath(a, b) {
20
+ try {
21
+ return realpathSync(a) === realpathSync(b);
22
+ } catch {
23
+ return resolve(a) === resolve(b);
24
+ }
25
+ }
26
+
13
27
  export function findConfigPath(startDir = process.cwd()) {
28
+ const repoRoot = gitRepoRoot(startDir);
14
29
  let dir = startDir;
15
30
  while (true) {
16
31
  const candidate = join(dir, '.remogram.json');
17
32
  if (existsSync(candidate)) return candidate;
33
+ if (repoRoot) {
34
+ if (samePath(dir, repoRoot)) break;
35
+ } else {
36
+ break;
37
+ }
18
38
  const parent = dirname(dir);
19
39
  if (parent === dir) break;
20
40
  dir = parent;
@@ -124,14 +144,43 @@ export function assertForgeReady(loaded) {
124
144
  return { ...loaded, remoteUrl, parsed };
125
145
  }
126
146
 
127
- export function forgeContext(loaded) {
147
+ export function forgeContext(loaded, options = {}) {
128
148
  const { config, parsed } = loaded;
129
- return {
149
+ const baseCtx = {
130
150
  providerId: config.provider,
131
151
  remoteName: config.remote,
132
152
  repoId: `${parsed.owner}/${parsed.repo}`,
153
+ baseUrl: normalizedForgeOrigin(config),
133
154
  config,
134
155
  cwd: loaded.cwd,
135
156
  parsed,
157
+ mergePolicy: resolveMergePolicy(config),
158
+ };
159
+
160
+ const operatorLoad = loadOperatorConfig({
161
+ cliPath: options.operatorConfigPath ?? null,
162
+ forgeContext: baseCtx,
163
+ });
164
+ const writePolicy = resolveEffectiveWritePolicy(config, operatorLoad);
165
+ const writeFieldPolicy = resolveEffectiveWriteFieldPolicy(config, operatorLoad);
166
+
167
+ return {
168
+ ...baseCtx,
169
+ writePolicy,
170
+ writeFieldPolicy,
171
+ operatorConfigMeta: operatorLoad.meta,
136
172
  };
137
173
  }
174
+
175
+ export function resolveWritePolicyForForge(forgeCtx, options = {}) {
176
+ const operatorLoad = loadOperatorConfig({
177
+ cliPath: options.operatorConfigPath ?? null,
178
+ forgeContext: forgeCtx,
179
+ });
180
+ return resolveEffectiveWritePolicy(forgeCtx.config, operatorLoad);
181
+ }
182
+
183
+ export function prepareForgeContext(cwd = process.cwd(), options = {}) {
184
+ const loaded = assertForgeReady(loadConfig(cwd));
185
+ return forgeContext(loaded, options);
186
+ }
package/status-set.js ADDED
@@ -0,0 +1,92 @@
1
+ import { sanitizeReadField, sanitizeWriteBody, 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 }, writeFieldPolicy = null) {
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: sanitizeReadField(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 = sanitizeWriteBody(String(description), writeFieldPolicy);
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, idempotencyFields = null } = {},
68
+ ) {
69
+ const state = normalizeStatusSetState(response?.status ?? response?.state ?? args.state);
70
+ const body = {
71
+ sha: args.sha,
72
+ context: sanitizeReadField(args.context),
73
+ state,
74
+ };
75
+ const description = response?.description ?? args.description;
76
+ if (description != null && String(description).trim() !== '') {
77
+ body.description = sanitizeReadField(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
+ } else {
86
+ body.created = true;
87
+ }
88
+ if (idempotencyFields && typeof idempotencyFields === 'object') {
89
+ Object.assign(body, idempotencyFields);
90
+ }
91
+ return body;
92
+ }
package/stub-provider.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+ import { stubProviderCommands } from './auth-classes.js';
2
3
 
3
4
  export function createStubProvider(id) {
4
5
  function unsupported() {
@@ -8,14 +9,7 @@ export function createStubProvider(id) {
8
9
  }
9
10
  function providerCapabilities() {
10
11
  return {
11
- commands: [
12
- { name: 'repo_status', implemented: false },
13
- { name: 'ref_compare', implemented: false },
14
- { name: 'pr_status', implemented: false },
15
- { name: 'pr_checks', implemented: false },
16
- { name: 'merge_plan', implemented: false },
17
- { name: 'sync_plan', implemented: false },
18
- ],
12
+ commands: stubProviderCommands(),
19
13
  auth_envs: [],
20
14
  check_sources: [],
21
15
  mergeability_confidence: 'unknown',
@@ -29,9 +23,18 @@ export function createStubProvider(id) {
29
23
  providerCapabilities,
30
24
  repoStatus: unsupported,
31
25
  refsCompare: unsupported,
26
+ refsInventory: unsupported,
27
+ crInventory: unsupported,
32
28
  prView: unsupported,
33
29
  prChecks: unsupported,
34
30
  mergePlan: unsupported,
35
31
  syncPlan: unsupported,
32
+ whoami: unsupported,
33
+ branchProtection: unsupported,
34
+ crFiles: unsupported,
35
+ crComments: unsupported,
36
+ forgeChanges: unsupported,
37
+ crOpen: unsupported,
38
+ statusSet: unsupported,
36
39
  };
37
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
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+ import {
4
+ isWriteCommandAllowed,
5
+ normalizeWritePolicyInput,
6
+ buildOperatorConfigSnippet,
7
+ buildRepoConfigSnippet,
8
+ } from './effective-write-policy.js';
9
+
10
+ /** Canonical v1 consumer write command ids (schema + gate single source). */
11
+ export const WRITE_COMMAND_IDS = Object.freeze(['cr_open', 'status_set', 'merge', 'issue_open']);
12
+
13
+ export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
14
+
15
+ /** @deprecated use WRITE_COMMAND_IDS */
16
+ export const CONFIGURED_WRITE_COMMANDS = WRITE_COMMAND_IDS;
17
+
18
+ export function isWriteCommandConfigured(configOrPolicy, commandName) {
19
+ if (!writeCommandSchema.safeParse(commandName).success) return false;
20
+ const policy = normalizeWritePolicyInput(configOrPolicy);
21
+ return isWriteCommandAllowed(policy, commandName);
22
+ }
23
+
24
+ /** Consumer-facing message when a write command is not opted in. */
25
+ export function writeNotConfiguredMessage(commandName) {
26
+ return (
27
+ `Command "${commandName}" is not in effective write_commands; add it to .remogram.json `
28
+ + 'or a bound operator config (REMOGRAM_OPERATOR_CONFIG / --operator-config) '
29
+ + 'for Remogram CLI/MCP writes, or use your forge/CI tooling outside Remogram '
30
+ + '(read commands still work)'
31
+ );
32
+ }
33
+
34
+ export function assertWriteCommandConfigured(configOrPolicy, commandName) {
35
+ if (!writeCommandSchema.safeParse(commandName).success) {
36
+ throw Object.assign(new Error(`Unknown write command: ${commandName}`), {
37
+ forgeError: forgeError(
38
+ ERROR_CODES.INVALID_ARGS,
39
+ `Unknown write command "${commandName}"; supported: ${WRITE_COMMAND_IDS.join(', ')}`,
40
+ ),
41
+ });
42
+ }
43
+ const policy = normalizeWritePolicyInput(configOrPolicy);
44
+ if (policy.operatorError) {
45
+ const err = policy.operatorError;
46
+ const message =
47
+ err.fields?.reason === 'operator_bind_mismatch' && err.fields?.remediation
48
+ ? err.message
49
+ : err.message;
50
+ throw Object.assign(new Error(message), {
51
+ forgeError: err,
52
+ });
53
+ }
54
+ if (isWriteCommandAllowed(policy, commandName)) return;
55
+ throw Object.assign(new Error(`Write command not configured: ${commandName}`), {
56
+ forgeError: forgeError(
57
+ ERROR_CODES.WRITE_NOT_CONFIGURED,
58
+ writeNotConfiguredMessage(commandName),
59
+ ),
60
+ });
61
+ }
62
+
63
+ export { buildOperatorConfigSnippet, buildRepoConfigSnippet };
@@ -0,0 +1,93 @@
1
+ import { DEFAULT_FIELD_MAX_BYTES } from './caps.js';
2
+
3
+ export const WRITE_FIELD_MAX_BYTES_ENV = 'REMOGRAM_WRITE_FIELD_MAX_BYTES';
4
+ /** Upper bound for undocumented REMOGRAM_WRITE_FIELD_MAX_BYTES debug override. */
5
+ export const MAX_WRITE_FIELD_ENV_BYTES = 65536;
6
+
7
+ const forgeWritePolicySchemaValue = (value) => {
8
+ if (value == null || value === 'none') return null;
9
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return Math.floor(value);
10
+ return undefined;
11
+ };
12
+
13
+ /**
14
+ * @param {object | null | undefined} policyBlock
15
+ * @returns {number | null | undefined} bytes cap, null = uncapped, undefined = unset
16
+ */
17
+ export function parseForgeWritePolicyBlock(policyBlock) {
18
+ if (!policyBlock || typeof policyBlock !== 'object') return undefined;
19
+ return forgeWritePolicySchemaValue(policyBlock.field_max_bytes);
20
+ }
21
+
22
+ /**
23
+ * @param {object} repoConfig
24
+ * @param {{ config?: object | null }} operatorLoad
25
+ */
26
+ export function resolveEffectiveWriteFieldPolicy(repoConfig, operatorLoad = {}) {
27
+ const repoCap = parseForgeWritePolicyBlock(repoConfig?.forge_write_policy);
28
+ const operatorCap =
29
+ operatorLoad.config != null
30
+ ? parseForgeWritePolicyBlock(operatorLoad.config.forge_write_policy)
31
+ : undefined;
32
+
33
+ const envFacts = getEffectiveWriteFieldMaxBytesFromEnv();
34
+ let fieldMaxBytes = DEFAULT_FIELD_MAX_BYTES;
35
+ let source = 'default';
36
+
37
+ if (repoCap !== undefined) {
38
+ fieldMaxBytes = repoCap;
39
+ source = 'repo';
40
+ }
41
+ if (operatorCap !== undefined) {
42
+ fieldMaxBytes = operatorCap;
43
+ source = 'operator';
44
+ }
45
+ if (envFacts.envOverride) {
46
+ fieldMaxBytes = envFacts.bytes;
47
+ source = 'env';
48
+ }
49
+
50
+ return {
51
+ fieldMaxBytes,
52
+ uncapped: fieldMaxBytes == null,
53
+ source,
54
+ readFieldMaxBytes: DEFAULT_FIELD_MAX_BYTES,
55
+ repoFieldMaxBytes: repoCap,
56
+ operatorFieldMaxBytes: operatorCap,
57
+ envOverride: envFacts.envOverride,
58
+ envClamped: envFacts.clamped ?? false,
59
+ envInvalid: envFacts.invalidEnv ?? false,
60
+ };
61
+ }
62
+
63
+ export function getEffectiveWriteFieldMaxBytesFromEnv() {
64
+ const raw = process.env[WRITE_FIELD_MAX_BYTES_ENV];
65
+ if (raw == null || raw === '') {
66
+ return { bytes: DEFAULT_FIELD_MAX_BYTES, envOverride: false };
67
+ }
68
+ if (raw === '0' || raw === 'none' || raw === 'null') {
69
+ return { bytes: null, envOverride: true };
70
+ }
71
+ const parsed = Number.parseInt(String(raw), 10);
72
+ if (!Number.isFinite(parsed) || parsed <= 0) {
73
+ return { bytes: DEFAULT_FIELD_MAX_BYTES, envOverride: false, invalidEnv: true };
74
+ }
75
+ if (parsed > MAX_WRITE_FIELD_ENV_BYTES) {
76
+ return { bytes: MAX_WRITE_FIELD_ENV_BYTES, envOverride: true, clamped: true };
77
+ }
78
+ return { bytes: parsed, envOverride: true };
79
+ }
80
+
81
+ /** Facts for provider capabilities / doctor packets. */
82
+ export function forgeWriteFieldCapabilityFacts(writeFieldPolicy) {
83
+ const policy = writeFieldPolicy ?? resolveEffectiveWriteFieldPolicy({}, {});
84
+ return {
85
+ read_field_max_bytes: policy.readFieldMaxBytes,
86
+ write_field_max_bytes: policy.uncapped ? null : policy.fieldMaxBytes,
87
+ write_field_uncapped: policy.uncapped,
88
+ write_field_policy_source: policy.source,
89
+ ...(policy.envOverride ? { write_field_env_override: true } : {}),
90
+ ...(policy.envClamped ? { write_field_cap_clamped: true } : {}),
91
+ ...(policy.envInvalid ? { write_field_env_invalid: true } : {}),
92
+ };
93
+ }