@remogram/core 0.1.0-beta.3 → 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 +25 -4
- package/branch-protection.js +155 -0
- package/caps.js +58 -0
- package/check-pagination.js +94 -17
- package/config-schema.js +2 -0
- package/contracts/envelope.js +18 -2
- package/contracts/errors.js +10 -2
- package/contracts/forge-error-fields.js +97 -0
- package/contracts/observer-fact-inventory.js +7 -5
- package/contracts/semantic-diff-facts.js +30 -2
- package/cr-comments.js +93 -0
- package/cr-files.js +62 -0
- package/cr-inventory.js +19 -12
- package/cr-open.js +33 -0
- package/forge-changes.js +181 -0
- package/git-local.js +12 -0
- package/index.js +104 -2
- package/merge-blockers.js +19 -1
- package/merge-plan.js +37 -0
- package/open-pull-list.js +256 -0
- package/package.json +1 -1
- package/path-allowlist.js +63 -0
- package/status-set.js +87 -0
- package/stub-provider.js +7 -0
- package/whoami.js +114 -0
- package/write-config.js +43 -0
package/auth-classes.js
CHANGED
|
@@ -17,6 +17,13 @@ export const API_PROVIDER_COMMAND_AUTH = {
|
|
|
17
17
|
pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
|
|
18
18
|
merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
|
|
19
19
|
sync_plan: AUTH_CLASS.GIT_ONLY,
|
|
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,
|
|
20
27
|
};
|
|
21
28
|
|
|
22
29
|
export function commandCapability(name, { implemented = true } = {}) {
|
|
@@ -27,10 +34,24 @@ export function commandCapability(name, { implemented = true } = {}) {
|
|
|
27
34
|
return { name, implemented, auth_class };
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
export function apiProviderCommands(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
export function apiProviderCommands({
|
|
38
|
+
writeCommandsImplemented = false,
|
|
39
|
+
statusSetImplemented = false,
|
|
40
|
+
branchProtectionImplemented = false,
|
|
41
|
+
crFilesImplemented = false,
|
|
42
|
+
crCommentsImplemented = false,
|
|
43
|
+
forgeChangesImplemented = false,
|
|
44
|
+
} = {}) {
|
|
45
|
+
return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
|
|
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;
|
|
53
|
+
return commandCapability(name, { implemented });
|
|
54
|
+
});
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
export function stubProviderCommands() {
|
|
@@ -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
|
@@ -6,6 +6,11 @@ export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
|
|
|
6
6
|
export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
|
|
7
7
|
export const MAX_CHECK_STATUS_PAGES = 50;
|
|
8
8
|
|
|
9
|
+
/** Gitea open-pull list page size for idempotency scan and inventory list bounds. */
|
|
10
|
+
export const DEFAULT_OPEN_PULL_LIST_PAGE_SIZE = 100;
|
|
11
|
+
/** Max pages scanned before cr open idempotency fails closed (decoupled from check-status pagination). */
|
|
12
|
+
export const MAX_OPEN_PULL_IDEMPOTENCY_PAGES = 50;
|
|
13
|
+
|
|
9
14
|
export function getEffectiveIngestMaxBytes() {
|
|
10
15
|
const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
|
|
11
16
|
if (raw == null || raw === '') {
|
|
@@ -48,6 +53,57 @@ export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, source
|
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/** Structured idempotency scan facts for provider capabilities (cr open). */
|
|
57
|
+
export function idempotencyScanCapabilityFacts() {
|
|
58
|
+
return {
|
|
59
|
+
idempotency_scan: {
|
|
60
|
+
max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
|
|
61
|
+
page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
62
|
+
ingest_backoff: 'halve_until_fit',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
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
|
+
|
|
78
|
+
/** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
|
|
79
|
+
export function openPullListCapabilityFacts({
|
|
80
|
+
totalCountSource = null,
|
|
81
|
+
totalCountHeader = null,
|
|
82
|
+
sliceSortNotes = null,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const compliantMaxItems = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
|
|
85
|
+
return {
|
|
86
|
+
open_pull_list: {
|
|
87
|
+
max_pages: MAX_CHECK_STATUS_PAGES,
|
|
88
|
+
page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
89
|
+
ingest_backoff: 'halve_until_fit',
|
|
90
|
+
compliant_max_items: compliantMaxItems,
|
|
91
|
+
truncation_packet_field: 'list_truncated',
|
|
92
|
+
incomplete_error_code: 'inventory_list_incomplete',
|
|
93
|
+
default_slice_sort: 'number_asc',
|
|
94
|
+
supported_slice_sorts: [
|
|
95
|
+
'number_asc',
|
|
96
|
+
'number_desc',
|
|
97
|
+
'recent_update',
|
|
98
|
+
'recent_created',
|
|
99
|
+
],
|
|
100
|
+
...(totalCountSource ? { total_count_source: totalCountSource } : {}),
|
|
101
|
+
...(totalCountHeader ? { total_count_header: totalCountHeader } : {}),
|
|
102
|
+
...(sliceSortNotes ? { slice_sort_notes: sliceSortNotes } : {}),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
51
107
|
export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
|
|
52
108
|
if (!text) return { text: '', truncated: false, bytes: 0 };
|
|
53
109
|
const buf = Buffer.from(text, 'utf8');
|
|
@@ -75,6 +131,8 @@ function redactSecretPatterns(text) {
|
|
|
75
131
|
.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
|
|
76
132
|
.replace(/\bghp_[A-Za-z0-9]+\b/g, '[REDACTED]')
|
|
77
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]')
|
|
78
136
|
.replace(/\bglpat-[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
79
137
|
.replace(/\b(GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|GITEA_TOKEN)\b/gi, '[REDACTED]');
|
|
80
138
|
}
|
package/check-pagination.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
2
|
import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
|
|
3
|
+
import { resolvePaginatedEntryCount } from './open-pull-list.js';
|
|
3
4
|
|
|
4
5
|
function isOversizedIngestError(err) {
|
|
5
6
|
return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
|
|
@@ -65,6 +66,21 @@ export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit)
|
|
|
65
66
|
return { items, usedLimit };
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* When a full page lands on maxPages, probe one item on the next page to distinguish
|
|
71
|
+
* end-of-list from truncation.
|
|
72
|
+
* @template T
|
|
73
|
+
* @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
|
|
74
|
+
* @param {number} page
|
|
75
|
+
* @param {number} maxPages
|
|
76
|
+
* @returns {Promise<boolean>} true when list is truncated (more items exist)
|
|
77
|
+
*/
|
|
78
|
+
async function probeNextPageHasItems(fetchPage, page, maxPages) {
|
|
79
|
+
if (page > maxPages) return true;
|
|
80
|
+
const { items: probeItems } = await fetchPageWithIngestBackoff(fetchPage, page + 1, 1);
|
|
81
|
+
return probeItems.length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
/**
|
|
69
85
|
* Offset/limit check-status pagination with ingest-cap backoff.
|
|
70
86
|
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
|
|
@@ -99,8 +115,8 @@ export async function paginateCheckStatusPages({
|
|
|
99
115
|
/**
|
|
100
116
|
* Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
|
|
101
117
|
* listLimit bounds request size per page; callers slice returned items when enforcing a hard cap.
|
|
102
|
-
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean }} opts
|
|
103
|
-
* @returns {Promise<{ items: unknown[], list_truncated: boolean }>}
|
|
118
|
+
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean, retainMax?: number | null, trustedEntryCount?: number | null, seededFirstPage?: { items: unknown[], usedLimit: number } | null, startPage?: number, suppressFinalPageProbe?: boolean }} opts
|
|
119
|
+
* @returns {Promise<{ items: unknown[], list_truncated: boolean, walked_count: number, entry_count?: number }>}
|
|
104
120
|
*/
|
|
105
121
|
export async function paginateOffsetListPages({
|
|
106
122
|
fetchPage,
|
|
@@ -108,31 +124,92 @@ export async function paginateOffsetListPages({
|
|
|
108
124
|
listLimit = null,
|
|
109
125
|
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
110
126
|
maxPagesTruncatesWithLimit = false,
|
|
127
|
+
retainMax = null,
|
|
128
|
+
trustedEntryCount = null,
|
|
129
|
+
seededFirstPage = null,
|
|
130
|
+
startPage = 1,
|
|
131
|
+
suppressFinalPageProbe = false,
|
|
111
132
|
}) {
|
|
112
133
|
const all = [];
|
|
134
|
+
let entryCount = 0;
|
|
113
135
|
let listTruncated = false;
|
|
114
136
|
let activeLimit = pageSize;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
|
|
138
|
+
async function afterPage(page, items, usedLimit) {
|
|
139
|
+
entryCount += items.length;
|
|
140
|
+
if (retainMax != null) {
|
|
141
|
+
const space = Math.max(retainMax - all.length, 0);
|
|
142
|
+
if (space > 0) all.push(...items.slice(0, space));
|
|
143
|
+
} else {
|
|
144
|
+
all.push(...items);
|
|
145
|
+
}
|
|
146
|
+
if (items.length < usedLimit) {
|
|
147
|
+
if (
|
|
148
|
+
trustedEntryCount != null &&
|
|
149
|
+
trustedEntryCount > entryCount &&
|
|
150
|
+
page === 1 &&
|
|
151
|
+
page < maxPages
|
|
152
|
+
) {
|
|
153
|
+
return false;
|
|
127
154
|
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (listLimit != null && all.length >= listLimit) {
|
|
158
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (listLimit != null) {
|
|
128
162
|
if (maxPagesTruncatesWithLimit && page === maxPages) {
|
|
129
163
|
listTruncated = true;
|
|
130
|
-
|
|
164
|
+
return true;
|
|
131
165
|
}
|
|
132
166
|
} else if (page === maxPages) {
|
|
133
|
-
|
|
167
|
+
if (suppressFinalPageProbe) {
|
|
168
|
+
listTruncated = items.length >= usedLimit;
|
|
169
|
+
} else {
|
|
170
|
+
listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let page = startPage;
|
|
178
|
+
if (seededFirstPage && startPage === 1) {
|
|
179
|
+
const { items, usedLimit } = seededFirstPage;
|
|
180
|
+
activeLimit = usedLimit;
|
|
181
|
+
if (await afterPage(1, items, usedLimit)) {
|
|
182
|
+
return {
|
|
183
|
+
items: all,
|
|
184
|
+
list_truncated: listTruncated,
|
|
185
|
+
walked_count: entryCount,
|
|
186
|
+
...(retainMax != null || trustedEntryCount != null
|
|
187
|
+
? { entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount) }
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
page = 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (; page <= maxPages; page += 1) {
|
|
195
|
+
const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
|
|
196
|
+
if (listLimit != null && remaining === 0) break;
|
|
197
|
+
const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
|
|
198
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
|
|
199
|
+
activeLimit = usedLimit;
|
|
200
|
+
if (await afterPage(page, items, usedLimit)) {
|
|
134
201
|
break;
|
|
135
202
|
}
|
|
136
203
|
}
|
|
137
|
-
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
items: all,
|
|
207
|
+
list_truncated: listTruncated,
|
|
208
|
+
walked_count: entryCount,
|
|
209
|
+
...(retainMax != null || trustedEntryCount != null
|
|
210
|
+
? {
|
|
211
|
+
entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount),
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
};
|
|
138
215
|
}
|
package/config-schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { writeCommandSchema } from './write-config.js';
|
|
2
3
|
|
|
3
4
|
const providerSchema = z.enum([
|
|
4
5
|
'gitea-api',
|
|
@@ -23,6 +24,7 @@ export const configSchema = z
|
|
|
23
24
|
owner: repoSegmentSchema,
|
|
24
25
|
repo: repoSegmentSchema,
|
|
25
26
|
baseUrl: z.string().url().optional(),
|
|
27
|
+
write_commands: z.array(writeCommandSchema).optional(),
|
|
26
28
|
})
|
|
27
29
|
.strict();
|
|
28
30
|
|
package/contracts/envelope.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sanitizeField } from '../caps.js';
|
|
2
|
+
import { normalizeForgeErrorFields } from './forge-error-fields.js';
|
|
2
3
|
|
|
3
4
|
export const SCHEMA_VERSION = 1;
|
|
4
5
|
|
|
@@ -12,6 +13,13 @@ export const PACKET_TYPES = {
|
|
|
12
13
|
PROVIDER_CAPABILITIES: 'provider_capabilities',
|
|
13
14
|
PROVIDER_DOCTOR: 'provider_doctor',
|
|
14
15
|
FORGE_ERROR: 'forge_error',
|
|
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',
|
|
15
23
|
};
|
|
16
24
|
|
|
17
25
|
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
@@ -29,7 +37,7 @@ function assertNoForbiddenKeys(value) {
|
|
|
29
37
|
}
|
|
30
38
|
for (const [key, nested] of Object.entries(value)) {
|
|
31
39
|
if (FORBIDDEN_PACKET_KEYS.has(key)) {
|
|
32
|
-
throw new Error(`Forbidden
|
|
40
|
+
throw new Error(`Forbidden workflow/planning-tool key in remogram output: ${key}`);
|
|
33
41
|
}
|
|
34
42
|
assertNoForbiddenKeys(nested);
|
|
35
43
|
}
|
|
@@ -53,13 +61,21 @@ export function forgePacket(type, context, body = {}, error = null) {
|
|
|
53
61
|
packet.error_code = error.code;
|
|
54
62
|
packet.error_message = sanitizeField(error.message);
|
|
55
63
|
if (error.status != null) packet.error_status = error.status;
|
|
64
|
+
if (error.fields != null && typeof error.fields === 'object') {
|
|
65
|
+
assertNoForbiddenKeys(error.fields);
|
|
66
|
+
const trustedFields = normalizeForgeErrorFields(error.code, error.fields);
|
|
67
|
+
if (trustedFields != null) {
|
|
68
|
+
Object.assign(packet, trustedFields);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
return packet;
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
|
|
62
|
-
|
|
77
|
+
const body = error?.fields != null && typeof error.fields === 'object' ? error.fields : {};
|
|
78
|
+
return forgePacket(type, context, body, error);
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
export function unknownForgeContext() {
|
package/contracts/errors.js
CHANGED
|
@@ -13,8 +13,16 @@ export const ERROR_CODES = {
|
|
|
13
13
|
API_ERROR: 'api_error',
|
|
14
14
|
PR_NOT_OPEN: 'pr_not_open',
|
|
15
15
|
REMOTE_INFER_FAILED: 'remote_infer_failed',
|
|
16
|
+
WRITE_NOT_CONFIGURED: 'write_not_configured',
|
|
17
|
+
IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
|
|
18
|
+
INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
|
|
16
19
|
};
|
|
17
20
|
|
|
18
|
-
export function forgeError(code, message, status = null) {
|
|
19
|
-
return {
|
|
21
|
+
export function forgeError(code, message, status = null, fields = null) {
|
|
22
|
+
return {
|
|
23
|
+
code,
|
|
24
|
+
message,
|
|
25
|
+
...(status != null ? { status } : {}),
|
|
26
|
+
...(fields != null && typeof fields === 'object' ? { fields } : {}),
|
|
27
|
+
};
|
|
20
28
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ERROR_CODES } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** Top-level packet keys forge error fields must never override. */
|
|
4
|
+
const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
|
|
5
|
+
'type',
|
|
6
|
+
'schema_version',
|
|
7
|
+
'provider_id',
|
|
8
|
+
'remote_name',
|
|
9
|
+
'repo_id',
|
|
10
|
+
'observed_at',
|
|
11
|
+
'ok',
|
|
12
|
+
'error_code',
|
|
13
|
+
'error_message',
|
|
14
|
+
'error_status',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Trusted body fields allowed per forge error code. */
|
|
18
|
+
export const FORGE_ERROR_FIELD_ALLOWLIST = Object.freeze({
|
|
19
|
+
[ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE]: ['idempotency_scan'],
|
|
20
|
+
[ERROR_CODES.INVENTORY_LIST_INCOMPLETE]: ['inventory_list'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function assertPositiveInteger(name, value) {
|
|
24
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
25
|
+
throw new Error(`Invalid forge error field ${name}: must be a positive integer`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateIdempotencyScan(scan) {
|
|
30
|
+
if (scan == null || typeof scan !== 'object' || Array.isArray(scan)) {
|
|
31
|
+
throw new Error('Invalid forge error field idempotency_scan: must be an object');
|
|
32
|
+
}
|
|
33
|
+
const keys = Object.keys(scan).sort();
|
|
34
|
+
const expected = ['max_pages', 'page_size', 'pages'];
|
|
35
|
+
if (keys.length !== expected.length || !expected.every((k) => keys.includes(k))) {
|
|
36
|
+
throw new Error('Invalid forge error field idempotency_scan: unexpected keys');
|
|
37
|
+
}
|
|
38
|
+
assertPositiveInteger('idempotency_scan.pages', scan.pages);
|
|
39
|
+
assertPositiveInteger('idempotency_scan.max_pages', scan.max_pages);
|
|
40
|
+
assertPositiveInteger('idempotency_scan.page_size', scan.page_size);
|
|
41
|
+
return { idempotency_scan: scan };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateInventoryList(list) {
|
|
45
|
+
if (list == null || typeof list !== 'object' || Array.isArray(list)) {
|
|
46
|
+
throw new Error('Invalid forge error field inventory_list: must be an object');
|
|
47
|
+
}
|
|
48
|
+
const keys = Object.keys(list).sort();
|
|
49
|
+
if (keys.length !== 1 || keys[0] !== 'entry_count') {
|
|
50
|
+
throw new Error('Invalid forge error field inventory_list: unexpected keys');
|
|
51
|
+
}
|
|
52
|
+
assertPositiveInteger('inventory_list.entry_count', list.entry_count);
|
|
53
|
+
return { inventory_list: list };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate and normalize trusted forge_error body fields before packet merge.
|
|
58
|
+
* @param {string} code
|
|
59
|
+
* @param {Record<string, unknown> | null | undefined} fields
|
|
60
|
+
* @returns {Record<string, unknown> | null}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeForgeErrorFields(code, fields) {
|
|
63
|
+
if (fields == null) return null;
|
|
64
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
|
65
|
+
throw new Error('Invalid forge error fields: must be an object');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keys = Object.keys(fields);
|
|
69
|
+
if (keys.length === 0) return null;
|
|
70
|
+
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
if (FORBIDDEN_ERROR_FIELD_KEYS.has(key)) {
|
|
73
|
+
throw new Error(`Forge error fields cannot override packet field ${key}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const allowlist = FORGE_ERROR_FIELD_ALLOWLIST[code];
|
|
78
|
+
if (!allowlist) {
|
|
79
|
+
throw new Error(`Forge error code ${code} does not allow trusted fields`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
if (!allowlist.includes(key)) {
|
|
84
|
+
throw new Error(`Forge error field ${key} is not allowed for code ${code}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (fields.idempotency_scan != null) {
|
|
89
|
+
return validateIdempotencyScan(fields.idempotency_scan);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (fields.inventory_list != null) {
|
|
93
|
+
return validateInventoryList(fields.inventory_list);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return fields;
|
|
97
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remogram fact requirements for
|
|
3
|
-
* Observer proto today captures remogram repo status only;
|
|
2
|
+
* Remogram fact requirements for observer and semantic-diff consumer snapshots.
|
|
3
|
+
* Observer proto today captures remogram repo status only; downstream consumers
|
|
4
4
|
* may compose additional read-only fact packets listed here.
|
|
5
|
-
*
|
|
6
|
-
* @see ../topogram/tools/branch-workcycle/observer-snapshot.sh
|
|
7
|
-
* @see topo/sdlc/acceptance_criteria/semantic_diff_fact_inventory.tg ac_semantic_diff_observer_facts
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
7
|
import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
|
|
@@ -21,6 +18,11 @@ export const OBSERVER_REMOGRAM_COMMANDS = Object.freeze([
|
|
|
21
18
|
{ command: 'sync plan', mcp_tool: 'sync_plan', read_only: true, observer_proto: false },
|
|
22
19
|
{ command: 'provider capabilities', mcp_tool: 'provider_capabilities', read_only: true, observer_proto: false },
|
|
23
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 },
|
|
24
26
|
]);
|
|
25
27
|
|
|
26
28
|
/** Fact inventory packet types for semantic-diff / branch-workcycle composition. */
|