@remogram/core 0.1.0-beta.4 → 0.1.0-beta.5

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 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({ writeCommandsImplemented = false } = {}) {
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
- const implemented = name === 'cr_open' ? writeCommandsImplemented : true;
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
@@ -64,6 +64,17 @@ export function idempotencyScanCapabilityFacts() {
64
64
  };
65
65
  }
66
66
 
67
+ /** Idempotency scan facts for status set (commit-status list pagination). */
68
+ export function statusSetIdempotencyScanCapabilityFacts() {
69
+ return {
70
+ idempotency_scan: {
71
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
72
+ page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
73
+ ingest_backoff: 'halve_until_fit',
74
+ },
75
+ };
76
+ }
77
+
67
78
  /** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
68
79
  export function openPullListCapabilityFacts({
69
80
  totalCountSource = null,
@@ -120,6 +131,8 @@ function redactSecretPatterns(text) {
120
131
  .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
121
132
  .replace(/\bghp_[A-Za-z0-9]+\b/g, '[REDACTED]')
122
133
  .replace(/\bgho_[A-Za-z0-9]+\b/g, '[REDACTED]')
134
+ .replace(/\bghs_[A-Za-z0-9._-]{36,}/g, '[REDACTED]')
135
+ .replace(/\bghs_[A-Za-z0-9_-]+\b/g, '[REDACTED]')
123
136
  .replace(/\bglpat-[A-Za-z0-9_-]+\b/g, '[REDACTED]')
124
137
  .replace(/\b(GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|GITEA_TOKEN)\b/gi, '[REDACTED]');
125
138
  }
@@ -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 only in first slice). */
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
+ }
@@ -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
@@ -36,6 +36,7 @@ export {
36
36
  forgeIngestCapabilityFacts,
37
37
  checkPaginationCapabilityFacts,
38
38
  idempotencyScanCapabilityFacts,
39
+ statusSetIdempotencyScanCapabilityFacts,
39
40
  openPullListCapabilityFacts,
40
41
  } from './caps.js';
41
42
  export {
@@ -73,11 +74,63 @@ export {
73
74
  appendSortQuery,
74
75
  } from './open-pull-list.js';
75
76
  export { buildChangeRequestOpenedBody } from './cr-open.js';
77
+ export {
78
+ STATUS_SET_STATES,
79
+ assertCommitSha,
80
+ normalizeStatusSetState,
81
+ parseStatusSetArgs,
82
+ buildCommitStatusSetBody,
83
+ } from './status-set.js';
84
+ export {
85
+ buildProviderIdentityBody,
86
+ buildProviderIdentityFromGiteaUser,
87
+ parseGitHubOAuthScopes,
88
+ githubCanWriteFromScopes,
89
+ buildProviderIdentityFromGitHubUser,
90
+ normalizeGitLabCanWrite,
91
+ parseGitLabPatSelfSignals,
92
+ buildProviderIdentityFromGitLabUser,
93
+ normalizeGiteaCanWrite,
94
+ unimplementedTokenScopeSignal,
95
+ unimplementedTokenExpirySignal,
96
+ } from './whoami.js';
97
+ export {
98
+ buildBranchProtectionBody,
99
+ buildBranchProtectionFromGiteaProtection,
100
+ buildBranchProtectionFromGitHubProtection,
101
+ buildBranchProtectionFromGitLabProtection,
102
+ unimplementedApprovalsRequiredSignal,
103
+ MAX_BRANCH_PROTECTION_STATUS_CONTEXTS,
104
+ MAX_BRANCH_PROTECTION_RULES,
105
+ } from './branch-protection.js';
106
+ export {
107
+ buildCrFilesBody,
108
+ buildCrFilesFromGiteaFiles,
109
+ buildCrFilesFromGitLabChanges,
110
+ MAX_CR_FILES_PATHS,
111
+ } from './cr-files.js';
112
+ export {
113
+ buildCrCommentsBody,
114
+ buildCrCommentsFromGiteaComments,
115
+ buildCrCommentsFromGitLabDiscussions,
116
+ normalizeCrComment,
117
+ MAX_CR_COMMENTS,
118
+ } from './cr-comments.js';
119
+ export {
120
+ parseSinceObservedAt,
121
+ buildForgeChangesBody,
122
+ buildForgeChangesFromGiteaPulls,
123
+ buildChecksConclusionObservedEvent,
124
+ appendForgeChangeEvents,
125
+ MAX_FORGE_CHANGES_EVENTS,
126
+ FORGE_CHANGE_EVENT_KINDS,
127
+ } from './forge-changes.js';
76
128
  export {
77
129
  WRITE_COMMAND_IDS,
78
130
  CONFIGURED_WRITE_COMMANDS,
79
131
  writeCommandSchema,
80
132
  assertWriteCommandConfigured,
133
+ writeNotConfiguredMessage,
81
134
  isWriteCommandConfigured,
82
135
  } from './write-config.js';
83
136
  export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
package/merge-plan.js CHANGED
@@ -12,6 +12,9 @@ export function resolveMergePlanPathScope(ctx, view, opts = {}) {
12
12
  if (!allowedPaths) {
13
13
  return { allowed_paths: null, changed_paths: null };
14
14
  }
15
+ if (Array.isArray(opts.changed_paths)) {
16
+ return { allowed_paths: allowedPaths, changed_paths: opts.changed_paths };
17
+ }
15
18
  if (!view.base_sha || !view.head_sha) {
16
19
  return { allowed_paths: allowedPaths, changed_paths: null };
17
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- `Add "${commandName}" to write_commands in .remogram.json to enable this command`,
40
+ writeNotConfiguredMessage(commandName),
32
41
  ),
33
42
  });
34
43
  }