@remogram/core 0.1.0-beta.6 → 0.1.0-beta.9

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,12 +18,14 @@ 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
+ issue_open: AUTH_CLASS.TOKEN_REQUIRED,
21
22
  status_set: AUTH_CLASS.TOKEN_REQUIRED,
22
23
  whoami: AUTH_CLASS.TOKEN_REQUIRED,
23
24
  branch_protection: AUTH_CLASS.TOKEN_REQUIRED,
24
25
  cr_files: AUTH_CLASS.TOKEN_REQUIRED,
25
26
  cr_comments: AUTH_CLASS.TOKEN_REQUIRED,
26
27
  forge_changes: AUTH_CLASS.TOKEN_REQUIRED,
28
+ merge_execute: AUTH_CLASS.TOKEN_REQUIRED,
27
29
  };
28
30
 
29
31
  export function commandCapability(name, { implemented = true } = {}) {
@@ -36,20 +38,24 @@ export function commandCapability(name, { implemented = true } = {}) {
36
38
 
37
39
  export function apiProviderCommands({
38
40
  writeCommandsImplemented = false,
41
+ issueOpenImplemented = false,
39
42
  statusSetImplemented = false,
40
43
  branchProtectionImplemented = false,
41
44
  crFilesImplemented = false,
42
45
  crCommentsImplemented = false,
43
46
  forgeChangesImplemented = false,
47
+ mergeExecuteImplemented = false,
44
48
  } = {}) {
45
49
  return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
46
50
  let implemented = true;
47
51
  if (name === 'cr_open') implemented = writeCommandsImplemented;
52
+ if (name === 'issue_open') implemented = issueOpenImplemented;
48
53
  if (name === 'status_set') implemented = statusSetImplemented;
49
54
  if (name === 'branch_protection') implemented = branchProtectionImplemented;
50
55
  if (name === 'cr_files') implemented = crFilesImplemented;
51
56
  if (name === 'cr_comments') implemented = crCommentsImplemented;
52
57
  if (name === 'forge_changes') implemented = forgeChangesImplemented;
58
+ if (name === 'merge_execute') implemented = mergeExecuteImplemented;
53
59
  return commandCapability(name, { implemented });
54
60
  });
55
61
  }
@@ -0,0 +1,148 @@
1
+ import { sanitizeField } from './caps.js';
2
+ import { mergeBlockersFromFacts } from './merge-blockers.js';
3
+ import { mergePolicyAuditFacts } from './merge-policy.js';
4
+
5
+ const SHA40 = /^[0-9a-f]{40}$/i;
6
+
7
+ export function mergeExecuteViewFacts(view) {
8
+ return {
9
+ sourceBranchRef: view.forge_source_branch_ref ?? view.head_ref ?? null,
10
+ sourceSha: view.forge_source_sha ?? view.head_sha ?? null,
11
+ targetSha: view.forge_target_sha ?? view.base_sha ?? null,
12
+ };
13
+ }
14
+
15
+ export function mergeExecuteChecksFacts(checks) {
16
+ return {
17
+ sourceSha: checks.forge_source_sha ?? checks.head_sha ?? null,
18
+ };
19
+ }
20
+
21
+ export function assertExpectedSha(value, flagName) {
22
+ const normalized = String(value ?? '').trim().toLowerCase();
23
+ if (!SHA40.test(normalized)) {
24
+ throw Object.assign(new Error(`Invalid ${flagName}`), {
25
+ invalidArgs: `${flagName} must be a 40-character git SHA`,
26
+ });
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ export function buildMergeExecuteBeforeFacts(
32
+ view,
33
+ checks,
34
+ mergePlanBody,
35
+ forgeHeadRefSha = null,
36
+ mergePolicy = null,
37
+ ) {
38
+ const viewFacts = mergeExecuteViewFacts(view);
39
+ const checksFacts = mergeExecuteChecksFacts(checks);
40
+ const audit = mergePolicyAuditFacts(mergePolicy);
41
+ return {
42
+ base_sha: viewFacts.targetSha ?? null,
43
+ head_sha: viewFacts.sourceSha ?? null,
44
+ checks_head_sha: checksFacts.sourceSha ?? null,
45
+ forge_head_ref_sha: forgeHeadRefSha ?? null,
46
+ mergeability: view.mergeability ?? 'unknown',
47
+ checks_conclusion: checks.check_conclusion ?? 'unknown',
48
+ checks_truncated: checks.checks_truncated === true,
49
+ merge_plan_blockers: Array.isArray(mergePlanBody.blockers) ? [...mergePlanBody.blockers] : [],
50
+ ...(audit ? { merge_policy: audit } : {}),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Collect merge-execute blockers before forge mutation.
56
+ * @returns {string[]} normalized blocker ids
57
+ */
58
+ export function collectMergeExecuteBlockers(
59
+ view,
60
+ checks,
61
+ mergePlanBody,
62
+ expected,
63
+ { forgeHeadRefSha, mergePolicy = {} } = {},
64
+ ) {
65
+ const viewFacts = mergeExecuteViewFacts(view);
66
+ const checksFacts = mergeExecuteChecksFacts(checks);
67
+ const blockers = [];
68
+ const baseSha = viewFacts.targetSha ? String(viewFacts.targetSha).toLowerCase() : null;
69
+ const headSha = viewFacts.sourceSha ? String(viewFacts.sourceSha).toLowerCase() : null;
70
+
71
+ if (baseSha && expected.baseSha !== baseSha) blockers.push('base_sha_mismatch');
72
+ if (headSha && expected.headSha !== headSha) blockers.push('head_sha_mismatch');
73
+
74
+ const forgeHead = forgeHeadRefSha ? String(forgeHeadRefSha).toLowerCase() : null;
75
+ const checksHead = checksFacts.sourceSha ? String(checksFacts.sourceSha).toLowerCase() : null;
76
+ if (headSha && checksHead && headSha !== checksHead) blockers.push('checks_head_sha_mismatch');
77
+ if (headSha && forgeHead && headSha !== forgeHead) blockers.push('forge_pr_head_mismatch');
78
+ if (checksHead && forgeHead && checksHead !== forgeHead) blockers.push('checks_forge_head_mismatch');
79
+ if (forgeHead && forgeHead !== expected.headSha) blockers.push('head_ref_moved');
80
+
81
+ const planBlockers = mergeBlockersFromFacts(view, checks, {}, mergePolicy);
82
+ for (const blocker of planBlockers) {
83
+ if (!blockers.includes(blocker)) blockers.push(blocker);
84
+ }
85
+
86
+ if (view.mergeability !== 'clean') {
87
+ if (view.mergeability === 'conflicted' && !blockers.includes('merge_conflict')) {
88
+ blockers.push('merge_conflict');
89
+ } else if (view.mergeability !== 'conflicted' && !blockers.includes('mergeability_not_clean')) {
90
+ blockers.push('mergeability_not_clean');
91
+ }
92
+ }
93
+
94
+ return blockers;
95
+ }
96
+
97
+ export function buildCrMergeBlockedBody({
98
+ prNumber,
99
+ expected,
100
+ before,
101
+ blockers,
102
+ }) {
103
+ return {
104
+ change_request: { number: prNumber },
105
+ expected: {
106
+ base_sha: expected.baseSha,
107
+ head_sha: expected.headSha,
108
+ },
109
+ before,
110
+ blockers,
111
+ };
112
+ }
113
+
114
+ export function buildCrMergedBody({
115
+ prNumber,
116
+ expected,
117
+ before,
118
+ merge,
119
+ after,
120
+ }) {
121
+ return {
122
+ change_request: { number: prNumber, state: 'merged' },
123
+ expected: {
124
+ base_sha: expected.baseSha,
125
+ head_sha: expected.headSha,
126
+ },
127
+ before,
128
+ merge,
129
+ after,
130
+ };
131
+ }
132
+
133
+ export function buildMergeExecuteAfterFacts(view, mergeResult = {}) {
134
+ const viewFacts = mergeExecuteViewFacts(view);
135
+ return {
136
+ state: 'merged',
137
+ base_sha: mergeResult.base_sha ?? viewFacts.targetSha ?? null,
138
+ head_sha: viewFacts.sourceSha ?? null,
139
+ };
140
+ }
141
+
142
+ export function buildMergeExecuteMergeFacts(method, providerResult = {}) {
143
+ return {
144
+ method: sanitizeField(method),
145
+ commit_sha: providerResult.commit_sha ?? null,
146
+ provider_status: providerResult.provider_status ?? null,
147
+ };
148
+ }
@@ -0,0 +1,92 @@
1
+ import { sanitizeField } from './caps.js';
2
+
3
+ function uniqueSorted(values) {
4
+ return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
5
+ }
6
+
7
+ /**
8
+ * @param {object} entry
9
+ * @param {{ headSha?: string | null, requiredContexts?: Set<string> }} opts
10
+ */
11
+ export function enrichCheckStatus(entry, opts = {}) {
12
+ const headSha = opts.headSha ?? null;
13
+ const context = sanitizeField(entry?.context);
14
+ const rawSha = entry?.sha != null ? sanitizeField(entry.sha) : headSha;
15
+ const sha = rawSha || null;
16
+ const stale = Boolean(headSha && sha && sha !== headSha);
17
+ const required = context ? opts.requiredContexts?.has(context) === true : false;
18
+ return {
19
+ context,
20
+ state: entry?.state ?? 'unknown',
21
+ ...(sha ? { sha } : {}),
22
+ required,
23
+ source: sanitizeField(entry?.source) || 'commit_status',
24
+ ...(entry?.target_url ? { target_url: sanitizeField(entry.target_url) } : {}),
25
+ ...(entry?.description ? { description: sanitizeField(entry.description) } : {}),
26
+ ...(stale ? { stale: true } : {}),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * @param {object[]} statuses
32
+ * @param {{ headSha?: string | null, requiredContexts?: string[] }} opts
33
+ */
34
+ export function buildCheckDiagnostics(statuses, opts = {}) {
35
+ const headSha = opts.headSha ?? null;
36
+ const requiredContexts = uniqueSorted(
37
+ Array.isArray(opts.requiredContexts) ? opts.requiredContexts.map((c) => sanitizeField(c)) : [],
38
+ );
39
+ const requiredSet = new Set(requiredContexts);
40
+ const enriched = (Array.isArray(statuses) ? statuses : []).map((entry) =>
41
+ enrichCheckStatus(entry, { headSha, requiredContexts: requiredSet }),
42
+ );
43
+ const byContext = new Map();
44
+ for (const status of enriched) {
45
+ if (status.context) byContext.set(status.context, status);
46
+ }
47
+
48
+ const failed_contexts = [];
49
+ const pending_contexts = [];
50
+ const stale_contexts = [];
51
+ for (const status of enriched) {
52
+ if (!status.context) continue;
53
+ if (status.stale === true) stale_contexts.push(status.context);
54
+ if (status.state === 'failure') failed_contexts.push(status.context);
55
+ if (status.state === 'pending') pending_contexts.push(status.context);
56
+ }
57
+
58
+ const missing_required_contexts = [];
59
+ for (const context of requiredContexts) {
60
+ if (!byContext.has(context)) missing_required_contexts.push(context);
61
+ }
62
+
63
+ return {
64
+ statuses: enriched,
65
+ required_contexts: requiredContexts,
66
+ missing_required_contexts: uniqueSorted(missing_required_contexts),
67
+ failed_contexts: uniqueSorted(failed_contexts),
68
+ pending_contexts: uniqueSorted(pending_contexts),
69
+ stale_contexts: uniqueSorted(stale_contexts),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * @param {{ forge_source_sha: string, check_conclusion: string, checks_truncated?: boolean, statuses?: object[], required_contexts?: string[] }} body
75
+ */
76
+ export function buildPrChecksBody(body) {
77
+ const diagnostics = buildCheckDiagnostics(body.statuses ?? [], {
78
+ headSha: body.forge_source_sha,
79
+ requiredContexts: body.required_contexts ?? [],
80
+ });
81
+ return {
82
+ forge_source_sha: body.forge_source_sha,
83
+ check_conclusion: body.check_conclusion,
84
+ checks_truncated: body.checks_truncated === true,
85
+ statuses: diagnostics.statuses,
86
+ required_contexts: diagnostics.required_contexts,
87
+ missing_required_contexts: diagnostics.missing_required_contexts,
88
+ failed_contexts: diagnostics.failed_contexts,
89
+ pending_contexts: diagnostics.pending_contexts,
90
+ stale_contexts: diagnostics.stale_contexts,
91
+ };
92
+ }
@@ -189,6 +189,7 @@ export async function paginateOffsetListPages({
189
189
  };
190
190
  }
191
191
  page = 2;
192
+ activeLimit = pageSize;
192
193
  }
193
194
 
194
195
  for (; page <= maxPages; page += 1) {
package/config-schema.js CHANGED
@@ -25,6 +25,12 @@ export const configSchema = z
25
25
  repo: repoSegmentSchema,
26
26
  baseUrl: z.string().url().optional(),
27
27
  write_commands: z.array(writeCommandSchema).optional(),
28
+ merge_policy: z
29
+ .object({
30
+ allow_missing_checks: z.boolean().optional(),
31
+ allow_pending_checks: z.boolean().optional(),
32
+ })
33
+ .optional(),
28
34
  })
29
35
  .strict();
30
36
 
@@ -14,12 +14,15 @@ 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
+ ISSUE_OPENED: 'issue_opened',
17
18
  COMMIT_STATUS_SET: 'commit_status_set',
18
19
  PROVIDER_IDENTITY: 'provider_identity',
19
20
  BRANCH_PROTECTION: 'branch_protection',
20
21
  CR_FILES: 'cr_files',
21
22
  CR_COMMENTS: 'cr_comments',
22
23
  FORGE_CHANGES: 'forge_changes',
24
+ CR_MERGED: 'cr_merged',
25
+ CR_MERGE_BLOCKED: 'cr_merge_blocked',
23
26
  };
24
27
 
25
28
  export const FORBIDDEN_PACKET_KEYS = new Set([
@@ -57,6 +60,11 @@ export function forgePacket(type, context, body = {}, error = null) {
57
60
  ok: error == null,
58
61
  };
59
62
 
63
+ delete packet.base_url;
64
+ if (context.baseUrl) {
65
+ packet.base_url = context.baseUrl;
66
+ }
67
+
60
68
  if (error) {
61
69
  packet.error_code = error.code;
62
70
  packet.error_message = sanitizeField(error.message);
@@ -15,7 +15,10 @@ export const ERROR_CODES = {
15
15
  REMOTE_INFER_FAILED: 'remote_infer_failed',
16
16
  WRITE_NOT_CONFIGURED: 'write_not_configured',
17
17
  IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
18
+ IDEMPOTENCY_CONFLICT: 'idempotency_conflict',
18
19
  INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
20
+ MERGE_BLOCKED: 'merge_blocked',
21
+ MERGE_ENDPOINT_FAILED: 'merge_endpoint_failed',
19
22
  };
20
23
 
21
24
  export function forgeError(code, message, status = null, fields = null) {
@@ -7,6 +7,7 @@ const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
7
7
  'provider_id',
8
8
  'remote_name',
9
9
  'repo_id',
10
+ 'base_url',
10
11
  'observed_at',
11
12
  'ok',
12
13
  'error_code',
@@ -28,7 +28,7 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
28
28
  ]);
29
29
 
30
30
  /** v1 write surface (Gitea cr open and status set in first slices). */
31
- export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set']);
31
+ export const V1_WRITE_COMMANDS = Object.freeze(['cr open', 'status set', 'merge execute', 'issue open']);
32
32
 
33
33
  /**
34
34
  * Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
@@ -46,6 +46,7 @@ export const TRUSTED_ENVELOPE_FIELDS = Object.freeze([
46
46
  'provider_id',
47
47
  'remote_name',
48
48
  'repo_id',
49
+ 'base_url',
49
50
  'observed_at',
50
51
  'ok',
51
52
  ]);
@@ -74,6 +75,9 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
74
75
  auth_present: true,
75
76
  can_write: true,
76
77
  reused_existing: true,
78
+ created: true,
79
+ idempotency_fingerprint: true,
80
+ ambiguous_after_write: true,
77
81
  idempotency_scan: true,
78
82
  });
79
83
 
@@ -83,9 +87,9 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
83
87
  */
84
88
  export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
85
89
  repo_status: ['default_branch', 'auth_env', 'capabilities'],
86
- ref_compare: ['base_ref', 'head_ref', 'base_sha', 'head_sha'],
87
- pr_status: ['url', 'title', 'base_ref', 'head_ref', 'base_sha', 'head_sha'],
88
- pr_checks: ['head_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
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'],
89
93
  merge_plan: ['blockers[].message', 'blockers[].context'],
90
94
  sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
91
95
  ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
@@ -99,16 +103,16 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
99
103
  ],
100
104
  cr_files: ['changed_paths[]'],
101
105
  cr_comments: ['comments[].id', 'comments[].author', 'comments[].path', 'comments[].body'],
102
- forge_changes: ['events[].title', 'events[].url', 'events[].head_sha'],
106
+ forge_changes: ['events[].title', 'events[].url', 'events[].forge_source_sha'],
103
107
  cr_inventory_slice: [
104
108
  'entries[].url',
105
109
  'entries[].title',
106
- 'entries[].base_ref',
107
- 'entries[].head_ref',
108
- 'entries[].base_sha',
109
- 'entries[].head_sha',
110
+ 'entries[].forge_target_branch_ref',
111
+ 'entries[].forge_source_branch_ref',
112
+ 'entries[].forge_target_sha',
113
+ 'entries[].forge_source_sha',
110
114
  'entries[].head_reconcile.local_head_sha',
111
- 'entries[].head_reconcile.head_sha',
115
+ 'entries[].head_reconcile.forge_source_sha',
112
116
  'entries[].checks[].context',
113
117
  'entries[].checks[].description',
114
118
  ],
@@ -125,15 +129,23 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
125
129
  [FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY]: {
126
130
  refs: 'array<{ name: string, sha: string, kind?: string, is_default?: boolean }>',
127
131
  default_ref: 'string optional',
128
- ancestry_hints: 'array<{ base_ref: string, head_ref: string, ahead_by?: number, behind_by?: number }> optional',
132
+ ancestry_hints: 'array<{ compare_base_ref: string, compare_head_ref: string, ahead_by?: number, behind_by?: number }> optional',
129
133
  },
130
134
  [FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE]: {
131
135
  entries:
132
- 'array<{ pr_number: number, url?: string, title?: string, state?: string, base_ref?: string, head_ref?: string, base_sha?: string, head_sha?: string, mergeability?: string, checks_conclusion?: string, checks_truncated?: boolean, blockers?: array, head_reconcile?: { stale: boolean, local_head_sha?: string, head_sha?: string } }>',
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 } }>',
133
137
  entry_count: 'number',
134
138
  /** true when list cap applied (entry_count > limit), not missing entries */
135
139
  truncated: 'boolean',
136
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',
137
149
  /** normalized slice sort preset applied to open-list resolution */
138
150
  slice_sort: 'string',
139
151
  entries_skipped:
@@ -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
+ }
package/cr-inventory.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { sanitizeField } from './caps.js';
2
2
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+ import { decodeCrInventoryCursor, encodeCrInventoryCursor } from './cr-inventory-cursor.js';
3
4
  import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
4
5
  import {
5
6
  DEFAULT_CR_INVENTORY_SLICE_SORT,
@@ -22,9 +23,9 @@ export function normalizeCrInventoryLimit(value) {
22
23
  return n;
23
24
  }
24
25
 
25
- async function resolveOpenPullList(provider, ctx, entryLimit, sliceSort) {
26
+ async function resolveOpenPullList(provider, ctx, listWindow, sliceSort) {
26
27
  if (typeof provider.listOpenPullsWithMeta === 'function') {
27
- return provider.listOpenPullsWithMeta(ctx, { retain_max: entryLimit, sort: sliceSort });
28
+ return provider.listOpenPullsWithMeta(ctx, { retain_max: listWindow, sort: sliceSort });
28
29
  }
29
30
  const numbers = await provider.listOpenPulls(ctx, {});
30
31
  return { numbers, list_truncated: false };
@@ -39,14 +40,14 @@ export function buildHeadReconcile(ctx, view) {
39
40
  const details = staleHeadDetails(
40
41
  ctx.cwd,
41
42
  ctx.config?.remote ?? ctx.remoteName,
42
- view.head_ref,
43
- view.head_sha,
43
+ view.forge_source_branch_ref,
44
+ view.forge_source_sha,
44
45
  );
45
46
  if (!details) return { stale: false };
46
47
  return {
47
48
  stale: true,
48
49
  local_head_sha: details.local_head_sha,
49
- head_sha: details.head_sha,
50
+ forge_source_sha: details.forge_source_sha,
50
51
  };
51
52
  }
52
53
 
@@ -59,16 +60,16 @@ export function buildCrInventoryEntry(ctx, view, checks) {
59
60
  url: view.url,
60
61
  title: view.title,
61
62
  state: view.state,
62
- base_ref: view.base_ref,
63
- head_ref: view.head_ref,
63
+ forge_target_branch_ref: view.forge_target_branch_ref,
64
+ forge_source_branch_ref: view.forge_source_branch_ref,
64
65
  mergeability: view.mergeability,
65
66
  checks_conclusion: checks.check_conclusion,
66
67
  checks_truncated: checks.checks_truncated === true,
67
- blockers: mergeBlockersFromFacts(view, checks),
68
+ blockers: mergeBlockersFromFacts(view, checks, {}, ctx.mergePolicy ?? {}),
68
69
  head_reconcile: buildHeadReconcile(ctx, view),
69
70
  };
70
- if (view.base_sha) entry.base_sha = view.base_sha;
71
- if (view.head_sha) entry.head_sha = view.head_sha;
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;
72
73
  return entry;
73
74
  }
74
75
 
@@ -76,18 +77,26 @@ export function buildCrInventoryEntry(ctx, view, checks) {
76
77
  * Aggregate open change requests into a semantic-diff-oriented inventory slice.
77
78
  * @param {object} ctx forge context
78
79
  * @param {object} provider must expose listOpenPulls, prView, prChecks
79
- * @param {{ slice_ref?: string, limit?: number, sort?: string }} [opts]
80
+ * @param {{ slice_ref?: string, limit?: number, sort?: string, cursor?: string }} [opts]
80
81
  */
81
82
  export async function crInventory(ctx, provider, opts = {}) {
82
83
  const limit = normalizeCrInventoryLimit(opts.limit);
83
- const sliceSort = normalizeCrInventorySort(opts.sort);
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;
84
93
  const {
85
94
  numbers,
86
95
  list_truncated: listTruncated,
87
96
  entry_count: providerEntryCount,
88
- } = await resolveOpenPullList(provider, ctx, limit, sliceSort);
97
+ } = await resolveOpenPullList(provider, ctx, listWindow, sliceSort);
89
98
  const entryCount = providerEntryCount ?? numbers.length;
90
- const selected = numbers.slice(0, limit);
99
+ const selected = numbers.slice(offset, offset + limit);
91
100
  const entries = [];
92
101
  const entries_skipped = [];
93
102
  for (const number of selected) {
@@ -106,13 +115,22 @@ export async function crInventory(ctx, provider, opts = {}) {
106
115
  });
107
116
  }
108
117
  }
118
+
119
+ const observedEnd = offset + selected.length;
120
+ const hasMore = listTruncated || observedEnd < entryCount;
121
+ const complete = !hasMore;
122
+
109
123
  return {
110
124
  entries,
111
125
  ...(entries_skipped.length ? { entries_skipped } : {}),
112
126
  entry_count: entryCount,
113
- truncated: entryCount > selected.length,
127
+ entry_count_observed: observedEnd,
128
+ truncated: entryCount > observedEnd || listTruncated,
114
129
  list_truncated: listTruncated,
115
130
  slice_sort: sliceSort ?? DEFAULT_CR_INVENTORY_SLICE_SORT,
131
+ has_more: hasMore,
132
+ complete,
133
+ ...(hasMore ? { next_cursor: encodeCrInventoryCursor({ sort: sliceSort, offset: observedEnd }) } : {}),
116
134
  ...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
117
135
  };
118
136
  }
package/cr-open.js CHANGED
@@ -5,7 +5,7 @@ import { ERROR_CODES, forgeError } from './contracts/errors.js';
5
5
  export function buildChangeRequestOpenedBody(
6
6
  pull,
7
7
  { head, base, title },
8
- { reusedExisting = false } = {},
8
+ { reusedExisting = false, idempotencyFields = null } = {},
9
9
  ) {
10
10
  const prNumber = Number(pull?.number);
11
11
  if (!Number.isInteger(prNumber) || prNumber <= 0) {
@@ -28,6 +28,11 @@ export function buildChangeRequestOpenedBody(
28
28
  };
29
29
  if (reusedExisting) {
30
30
  body.reused_existing = true;
31
+ } else {
32
+ body.created = true;
33
+ }
34
+ if (idempotencyFields && typeof idempotencyFields === 'object') {
35
+ Object.assign(body, idempotencyFields);
31
36
  }
32
37
  return body;
33
38
  }