@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 +6 -0
- package/change-request-merge-execute.js +148 -0
- package/check-diagnostics.js +92 -0
- package/check-pagination.js +1 -0
- package/config-schema.js +6 -0
- package/contracts/envelope.js +8 -0
- package/contracts/errors.js +3 -0
- package/contracts/forge-error-fields.js +1 -0
- package/contracts/semantic-diff-facts.js +24 -12
- package/cr-inventory-cursor.js +64 -0
- package/cr-inventory.js +33 -15
- package/cr-open.js +6 -1
- package/forge-changes-cursor.js +88 -0
- package/forge-changes.js +2 -2
- package/forge-identity.js +42 -0
- package/idempotency.js +69 -0
- package/index.js +53 -0
- package/issue-open.js +50 -0
- package/merge-blockers.js +28 -3
- package/merge-plan-forge.js +2 -1
- package/merge-plan.js +9 -3
- package/merge-policy.js +55 -0
- package/package.json +1 -1
- package/pr-head-reconcile.js +3 -3
- package/provider-health.js +93 -0
- package/ref-inventory.js +2 -2
- package/resolve.js +4 -0
- package/status-set.js +6 -1
- package/write-config.js +1 -1
- package/write-readiness.js +76 -0
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
|
+
}
|
package/check-pagination.js
CHANGED
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
|
|
package/contracts/envelope.js
CHANGED
|
@@ -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);
|
package/contracts/errors.js
CHANGED
|
@@ -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) {
|
|
@@ -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: ['
|
|
87
|
-
pr_status: ['url', 'title', '
|
|
88
|
-
pr_checks: ['
|
|
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[].
|
|
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[].
|
|
107
|
-
'entries[].
|
|
108
|
-
'entries[].
|
|
109
|
-
'entries[].
|
|
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.
|
|
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<{
|
|
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,
|
|
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,
|
|
26
|
+
async function resolveOpenPullList(provider, ctx, listWindow, sliceSort) {
|
|
26
27
|
if (typeof provider.listOpenPullsWithMeta === 'function') {
|
|
27
|
-
return provider.listOpenPullsWithMeta(ctx, { retain_max:
|
|
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.
|
|
43
|
-
view.
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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.
|
|
71
|
-
if (view.
|
|
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
|
-
|
|
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,
|
|
97
|
+
} = await resolveOpenPullList(provider, ctx, listWindow, sliceSort);
|
|
89
98
|
const entryCount = providerEntryCount ?? numbers.length;
|
|
90
|
-
const selected = numbers.slice(
|
|
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
|
-
|
|
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
|
}
|