@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,168 @@
1
+ /**
2
+ * Semantic diff fact inventory contract (wave 1 — registry and trust boundaries).
3
+ * Command implementations land in waves 2–6 per plan_semantic_diff_fact_inventory.
4
+ *
5
+ * @see topo/sdlc/decisions/semantic_diff_fact_layer.tg
6
+ * @see topo/sdlc/decisions/packet_trust_doctrine.tg
7
+ */
8
+
9
+ import { forgePacket, FORBIDDEN_PACKET_KEYS, SCHEMA_VERSION } from './envelope.js';
10
+
11
+ /** Authoritative v1 read/plan surface today. Fact inventory extends; does not replace. */
12
+ export const V1_READ_PLAN_COMMANDS = Object.freeze([
13
+ 'repo status',
14
+ 'refs compare',
15
+ 'refs inventory',
16
+ 'cr inventory',
17
+ 'pr view',
18
+ 'pr checks',
19
+ 'merge plan',
20
+ 'sync plan',
21
+ 'provider capabilities',
22
+ 'doctor',
23
+ 'whoami',
24
+ 'branch protection',
25
+ 'cr files',
26
+ 'cr comments',
27
+ 'forge changes',
28
+ ]);
29
+
30
+ /** v1 write surface (Gitea cr open and status set in first slices). */
31
+ export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set', 'merge execute', 'issue open']);
32
+
33
+ /**
34
+ * Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
35
+ * All use schema_version 1 envelope discipline via forgePacket.
36
+ */
37
+ export const FACT_INVENTORY_PACKET_TYPES = Object.freeze({
38
+ REF_INVENTORY: 'ref_inventory',
39
+ CR_INVENTORY_SLICE: 'cr_inventory_slice',
40
+ });
41
+
42
+ /** Trusted structural envelope on every remogram packet (authoritative for agents). */
43
+ export const TRUSTED_ENVELOPE_FIELDS = Object.freeze([
44
+ 'type',
45
+ 'schema_version',
46
+ 'provider_id',
47
+ 'remote_name',
48
+ 'repo_id',
49
+ 'base_url',
50
+ 'observed_at',
51
+ 'ok',
52
+ ]);
53
+
54
+ /**
55
+ * Normalized enum and boolean body fields agents may treat as structural facts
56
+ * (not forge prose). Provider-specific strings that are normalized to enums
57
+ * belong here; raw forge copy does not.
58
+ */
59
+ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
60
+ mergeability: true,
61
+ check_conclusion: true,
62
+ checks_conclusion: true,
63
+ state: true,
64
+ truncated: true,
65
+ list_truncated: true,
66
+ checks_truncated: true,
67
+ paths_truncated: true,
68
+ comments_truncated: true,
69
+ events_truncated: true,
70
+ slice_sort: true,
71
+ entry_count: true,
72
+ mergeability_confidence: true,
73
+ write_support: true,
74
+ diverged: true,
75
+ auth_present: true,
76
+ can_write: true,
77
+ reused_existing: true,
78
+ created: true,
79
+ idempotency_fingerprint: true,
80
+ ambiguous_after_write: true,
81
+ idempotency_scan: true,
82
+ });
83
+
84
+ /**
85
+ * String leaves copied from forge or git resolution that remain semantically
86
+ * untrusted per decision_packet_trust_doctrine. Structurally sanitized only.
87
+ */
88
+ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
89
+ repo_status: ['default_branch', 'auth_env', 'capabilities'],
90
+ ref_compare: ['compare_base_ref', 'compare_head_ref', 'compare_base_sha', 'compare_head_sha'],
91
+ pr_status: ['url', 'title', 'forge_target_branch_ref', 'forge_source_branch_ref', 'forge_target_sha', 'forge_source_sha'],
92
+ pr_checks: ['forge_source_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
93
+ merge_plan: ['blockers[].message', 'blockers[].context'],
94
+ sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
95
+ ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
96
+ change_request_opened: ['url', 'title', 'head', 'base'],
97
+ commit_status_set: ['sha', 'context', 'description', 'target_url'],
98
+ provider_identity: ['login'],
99
+ branch_protection: [
100
+ 'branch_ref',
101
+ 'required_status_contexts[]',
102
+ 'protected_branch_rules[].name',
103
+ ],
104
+ cr_files: ['changed_paths[]'],
105
+ cr_comments: ['comments[].id', 'comments[].author', 'comments[].path', 'comments[].body'],
106
+ forge_changes: ['events[].title', 'events[].url', 'events[].forge_source_sha'],
107
+ cr_inventory_slice: [
108
+ 'entries[].url',
109
+ 'entries[].title',
110
+ 'entries[].forge_target_branch_ref',
111
+ 'entries[].forge_source_branch_ref',
112
+ 'entries[].forge_target_sha',
113
+ 'entries[].forge_source_sha',
114
+ 'entries[].head_reconcile.local_head_sha',
115
+ 'entries[].head_reconcile.forge_source_sha',
116
+ 'entries[].checks[].context',
117
+ 'entries[].checks[].description',
118
+ ],
119
+ });
120
+
121
+ /** Keys that must never appear in remogram output (external planning/SDLC workflow concepts). */
122
+ export { FORBIDDEN_PACKET_KEYS };
123
+
124
+ /**
125
+ * Documented body shapes for planned fact inventory packets (wave 2+).
126
+ * Used by contract tests and provider normalization; not emitted in wave 1.
127
+ */
128
+ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
129
+ [FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY]: {
130
+ refs: 'array<{ name: string, sha: string, kind?: string, is_default?: boolean }>',
131
+ default_ref: 'string optional',
132
+ ancestry_hints: 'array<{ compare_base_ref: string, compare_head_ref: string, ahead_by?: number, behind_by?: number }> optional',
133
+ },
134
+ [FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE]: {
135
+ entries:
136
+ 'array<{ pr_number: number, url?: string, title?: string, state?: string, forge_target_branch_ref?: string, forge_source_branch_ref?: string, forge_target_sha?: string, forge_source_sha?: string, mergeability?: string, checks_conclusion?: string, checks_truncated?: boolean, blockers?: array, head_reconcile?: { stale: boolean, local_head_sha?: string, forge_source_sha?: string } }>',
137
+ entry_count: 'number',
138
+ /** true when list cap applied (entry_count > limit), not missing entries */
139
+ truncated: 'boolean',
140
+ list_truncated: 'boolean',
141
+ /** true when more pages remain via next_cursor */
142
+ has_more: 'boolean',
143
+ /** true when open-list enumeration is complete for slice_sort */
144
+ complete: 'boolean',
145
+ /** opaque cursor for the next page when has_more is true */
146
+ next_cursor: 'string optional',
147
+ /** numbers consumed from open-list ordering including skipped entries */
148
+ entry_count_observed: 'number',
149
+ /** normalized slice sort preset applied to open-list resolution */
150
+ slice_sort: 'string',
151
+ entries_skipped:
152
+ 'array<{ pr_number: number, error_code: pr_not_open | api_error | oversized_raw_output | ... }> optional',
153
+ slice_ref: 'string optional',
154
+ },
155
+ });
156
+
157
+ /**
158
+ * Build a fact-inventory packet body through the standard envelope gate.
159
+ * Throws if body contains forbidden workflow/planning-tool keys.
160
+ */
161
+ export function forgeFactInventoryPacket(type, context, body = {}, error = null) {
162
+ if (!Object.values(FACT_INVENTORY_PACKET_TYPES).includes(type)) {
163
+ throw new Error(`Unknown fact inventory packet type: ${type}`);
164
+ }
165
+ return forgePacket(type, context, body, error);
166
+ }
167
+
168
+ export { SCHEMA_VERSION };
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,64 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+ import { normalizeCrInventorySort } from './open-pull-list.js';
3
+
4
+ export const CR_INVENTORY_CURSOR_VERSION = 1;
5
+
6
+ /**
7
+ * @param {{ sort: string, offset: number }} state
8
+ * @returns {string}
9
+ */
10
+ export function encodeCrInventoryCursor(state) {
11
+ const sort = normalizeCrInventorySort(state.sort);
12
+ const offset = Number(state.offset);
13
+ if (!Number.isInteger(offset) || offset < 0) {
14
+ throw Object.assign(new Error('Invalid cursor offset'), {
15
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'cursor offset must be a non-negative integer'),
16
+ });
17
+ }
18
+ const payload = JSON.stringify({ v: CR_INVENTORY_CURSOR_VERSION, sort, offset });
19
+ return Buffer.from(payload, 'utf8').toString('base64url');
20
+ }
21
+
22
+ /**
23
+ * @param {unknown} raw
24
+ * @param {{ sort?: string | null }} [opts]
25
+ * @returns {{ sort: string, offset: number }}
26
+ */
27
+ export function decodeCrInventoryCursor(raw, opts = {}) {
28
+ if (raw == null || raw === '') {
29
+ throw Object.assign(new Error('Missing cursor'), {
30
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor must not be empty when provided'),
31
+ });
32
+ }
33
+ let decoded;
34
+ try {
35
+ decoded = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'));
36
+ } catch {
37
+ throw Object.assign(new Error('Invalid cursor'), {
38
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor is malformed or not base64url JSON'),
39
+ });
40
+ }
41
+ if (decoded?.v !== CR_INVENTORY_CURSOR_VERSION) {
42
+ throw Object.assign(new Error('Invalid cursor version'), {
43
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor version is unsupported'),
44
+ });
45
+ }
46
+ const sort = normalizeCrInventorySort(decoded.sort);
47
+ const offset = Number(decoded.offset);
48
+ if (!Number.isInteger(offset) || offset < 0) {
49
+ throw Object.assign(new Error('Invalid cursor offset'), {
50
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--cursor offset must be a non-negative integer'),
51
+ });
52
+ }
53
+ const requestedSort =
54
+ opts.sort == null || opts.sort === '' ? null : normalizeCrInventorySort(opts.sort);
55
+ if (requestedSort != null && requestedSort !== sort) {
56
+ throw Object.assign(new Error('Cursor sort mismatch'), {
57
+ forgeError: forgeError(
58
+ ERROR_CODES.INVALID_ARGS,
59
+ '--sort must match the cursor slice_sort when both are provided',
60
+ ),
61
+ });
62
+ }
63
+ return { sort, offset };
64
+ }
@@ -0,0 +1,136 @@
1
+ import { sanitizeField } from './caps.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+ import { decodeCrInventoryCursor, encodeCrInventoryCursor } from './cr-inventory-cursor.js';
4
+ import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
5
+ import {
6
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
7
+ normalizeCrInventorySort,
8
+ } from './open-pull-list.js';
9
+ import { staleHeadDetails } from './pr-head-reconcile.js';
10
+
11
+ export const DEFAULT_CR_INVENTORY_LIMIT = 50;
12
+ /** Default bound when `--limit` is omitted (keeps forge ingest under cap on large repos). */
13
+ export const DEFAULT_CR_INVENTORY_SAFE_LIMIT = 3;
14
+
15
+ export function normalizeCrInventoryLimit(value) {
16
+ if (value == null || value === '') return DEFAULT_CR_INVENTORY_SAFE_LIMIT;
17
+ const n = Number(value);
18
+ if (!Number.isInteger(n) || n <= 0) {
19
+ throw Object.assign(new Error('Invalid cr inventory limit'), {
20
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--limit must be a positive integer'),
21
+ });
22
+ }
23
+ return n;
24
+ }
25
+
26
+ async function resolveOpenPullList(provider, ctx, listWindow, sliceSort) {
27
+ if (typeof provider.listOpenPullsWithMeta === 'function') {
28
+ return provider.listOpenPullsWithMeta(ctx, { retain_max: listWindow, sort: sliceSort });
29
+ }
30
+ const numbers = await provider.listOpenPulls(ctx, {});
31
+ return { numbers, list_truncated: false };
32
+ }
33
+
34
+ function skipErrorCode(err) {
35
+ if (err?.forgeError?.code) return err.forgeError.code;
36
+ return ERROR_CODES.API_ERROR;
37
+ }
38
+
39
+ export function buildHeadReconcile(ctx, view) {
40
+ const details = staleHeadDetails(
41
+ ctx.cwd,
42
+ ctx.config?.remote ?? ctx.remoteName,
43
+ view.forge_source_branch_ref,
44
+ view.forge_source_sha,
45
+ );
46
+ if (!details) return { stale: false };
47
+ return {
48
+ stale: true,
49
+ local_head_sha: details.local_head_sha,
50
+ forge_source_sha: details.forge_source_sha,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Compose one CR inventory entry from pr view and checks facts.
56
+ */
57
+ export function buildCrInventoryEntry(ctx, view, checks) {
58
+ const entry = {
59
+ pr_number: view.pr_number,
60
+ url: view.url,
61
+ title: view.title,
62
+ state: view.state,
63
+ forge_target_branch_ref: view.forge_target_branch_ref,
64
+ forge_source_branch_ref: view.forge_source_branch_ref,
65
+ mergeability: view.mergeability,
66
+ checks_conclusion: checks.check_conclusion,
67
+ checks_truncated: checks.checks_truncated === true,
68
+ blockers: mergeBlockersFromFacts(view, checks, {}, ctx.mergePolicy ?? {}),
69
+ head_reconcile: buildHeadReconcile(ctx, view),
70
+ };
71
+ if (view.forge_target_sha) entry.forge_target_sha = view.forge_target_sha;
72
+ if (view.forge_source_sha) entry.forge_source_sha = view.forge_source_sha;
73
+ return entry;
74
+ }
75
+
76
+ /**
77
+ * Aggregate open change requests into a semantic-diff-oriented inventory slice.
78
+ * @param {object} ctx forge context
79
+ * @param {object} provider must expose listOpenPulls, prView, prChecks
80
+ * @param {{ slice_ref?: string, limit?: number, sort?: string, cursor?: string }} [opts]
81
+ */
82
+ export async function crInventory(ctx, provider, opts = {}) {
83
+ const limit = normalizeCrInventoryLimit(opts.limit);
84
+ let sliceSort = normalizeCrInventorySort(opts.sort);
85
+ let offset = 0;
86
+ if (opts.cursor != null && opts.cursor !== '') {
87
+ const decoded = decodeCrInventoryCursor(opts.cursor, { sort: opts.sort });
88
+ offset = decoded.offset;
89
+ sliceSort = decoded.sort;
90
+ }
91
+
92
+ const listWindow = offset + limit;
93
+ const {
94
+ numbers,
95
+ list_truncated: listTruncated,
96
+ entry_count: providerEntryCount,
97
+ } = await resolveOpenPullList(provider, ctx, listWindow, sliceSort);
98
+ const entryCount = providerEntryCount ?? numbers.length;
99
+ const selected = numbers.slice(offset, offset + limit);
100
+ const entries = [];
101
+ const entries_skipped = [];
102
+ for (const number of selected) {
103
+ try {
104
+ const view = await provider.prView(ctx, { number });
105
+ if (!isOpenPrState(view.state)) {
106
+ entries_skipped.push({ pr_number: number, error_code: ERROR_CODES.PR_NOT_OPEN });
107
+ continue;
108
+ }
109
+ const checks = await provider.prChecks(ctx, { number });
110
+ entries.push(buildCrInventoryEntry(ctx, view, checks));
111
+ } catch (err) {
112
+ entries_skipped.push({
113
+ pr_number: number,
114
+ error_code: skipErrorCode(err),
115
+ });
116
+ }
117
+ }
118
+
119
+ const observedEnd = offset + selected.length;
120
+ const hasMore = listTruncated || observedEnd < entryCount;
121
+ const complete = !hasMore;
122
+
123
+ return {
124
+ entries,
125
+ ...(entries_skipped.length ? { entries_skipped } : {}),
126
+ entry_count: entryCount,
127
+ entry_count_observed: observedEnd,
128
+ truncated: entryCount > observedEnd || listTruncated,
129
+ list_truncated: listTruncated,
130
+ slice_sort: sliceSort ?? DEFAULT_CR_INVENTORY_SLICE_SORT,
131
+ has_more: hasMore,
132
+ complete,
133
+ ...(hasMore ? { next_cursor: encodeCrInventoryCursor({ sort: sliceSort, offset: observedEnd }) } : {}),
134
+ ...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
135
+ };
136
+ }
package/cr-open.js ADDED
@@ -0,0 +1,38 @@
1
+ import { sanitizeField, sanitizeUrl } from './caps.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+
4
+ /** Normalize Gitea pull create response into change_request_opened body fields. */
5
+ export function buildChangeRequestOpenedBody(
6
+ pull,
7
+ { head, base, title },
8
+ { reusedExisting = false, idempotencyFields = null } = {},
9
+ ) {
10
+ const prNumber = Number(pull?.number);
11
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
12
+ throw Object.assign(new Error('Provider returned invalid pull number'), {
13
+ forgeError: forgeError(
14
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
15
+ 'Provider returned invalid pull number',
16
+ ),
17
+ });
18
+ }
19
+ const resolvedTitle = reusedExisting
20
+ ? sanitizeField(pull?.title ?? title)
21
+ : sanitizeField(title ?? pull?.title);
22
+ const body = {
23
+ pr_number: prNumber,
24
+ url: sanitizeUrl(pull.html_url ?? pull.url),
25
+ head: sanitizeField(head),
26
+ base: sanitizeField(base),
27
+ title: resolvedTitle,
28
+ };
29
+ if (reusedExisting) {
30
+ body.reused_existing = true;
31
+ } else {
32
+ body.created = true;
33
+ }
34
+ if (idempotencyFields && typeof idempotencyFields === 'object') {
35
+ Object.assign(body, idempotencyFields);
36
+ }
37
+ return body;
38
+ }
@@ -0,0 +1,68 @@
1
+ import { WRITE_COMMAND_IDS } from './write-config.js';
2
+
3
+ function uniqueSorted(list) {
4
+ return [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b));
5
+ }
6
+
7
+ export function writeSourceForCommand(repoConfigured, operatorConfigured) {
8
+ if (repoConfigured && operatorConfigured) return 'both';
9
+ if (repoConfigured) return 'repo';
10
+ if (operatorConfigured) return 'operator';
11
+ return 'none';
12
+ }
13
+
14
+ /**
15
+ * @param {object} repoConfig
16
+ * @param {{ config?: object | null, meta?: object, error?: object | null }} operatorLoad
17
+ */
18
+ export function resolveEffectiveWritePolicy(repoConfig, operatorLoad = {}) {
19
+ const repoWriteCommands = uniqueSorted(
20
+ Array.isArray(repoConfig?.write_commands) ? repoConfig.write_commands : [],
21
+ );
22
+ const operatorWriteCommands =
23
+ operatorLoad.error || !operatorLoad.config
24
+ ? []
25
+ : uniqueSorted(operatorLoad.config.write_commands ?? []);
26
+ const effectiveWriteCommands = uniqueSorted([...repoWriteCommands, ...operatorWriteCommands]);
27
+
28
+ return {
29
+ repoWriteCommands,
30
+ operatorWriteCommands,
31
+ effectiveWriteCommands,
32
+ operatorMeta: operatorLoad.meta ?? { discovered_via: 'none', path: null, bind_ok: null },
33
+ operatorError: operatorLoad.error ?? null,
34
+ };
35
+ }
36
+
37
+ export function isWriteCommandAllowed(writePolicy, commandName) {
38
+ if (!writePolicy || !commandName) return false;
39
+ return writePolicy.effectiveWriteCommands.includes(commandName);
40
+ }
41
+
42
+ export function writePolicyFromLegacyConfig(config) {
43
+ return resolveEffectiveWritePolicy(config, { config: null, meta: { discovered_via: 'none' }, error: null });
44
+ }
45
+
46
+ export function normalizeWritePolicyInput(input) {
47
+ if (input && Array.isArray(input.effectiveWriteCommands)) {
48
+ return input;
49
+ }
50
+ if (input?.writePolicy && Array.isArray(input.writePolicy.effectiveWriteCommands)) {
51
+ return input.writePolicy;
52
+ }
53
+ return writePolicyFromLegacyConfig(input);
54
+ }
55
+
56
+ export function buildOperatorConfigSnippet(commandId, operatorWriteCommands = []) {
57
+ const next = uniqueSorted([...operatorWriteCommands, commandId]);
58
+ return `"write_commands": ${JSON.stringify(next)}`;
59
+ }
60
+
61
+ export function buildRepoConfigSnippet(commandId, repoWriteCommands = []) {
62
+ const next = uniqueSorted([...repoWriteCommands, commandId]);
63
+ return `"write_commands": ${JSON.stringify(next)}`;
64
+ }
65
+
66
+ export function allKnownWriteCommandIds() {
67
+ return [...WRITE_COMMAND_IDS];
68
+ }