@remogram/core 0.1.0-beta.2 → 0.1.0-beta.3
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 +2 -0
- package/caps.js +28 -0
- package/check-pagination.js +138 -0
- package/contracts/errors.js +1 -0
- package/contracts/observer-fact-inventory.js +62 -0
- package/contracts/semantic-diff-facts.js +128 -0
- package/cr-inventory.js +111 -0
- package/http.js +36 -2
- package/index.js +38 -1
- package/merge-blockers.js +18 -0
- package/package.json +1 -1
- package/ref-inventory.js +98 -0
- package/stub-provider.js +2 -0
package/auth-classes.js
CHANGED
|
@@ -11,6 +11,8 @@ const AUTH_CLASS_VALUES = new Set(Object.values(AUTH_CLASS));
|
|
|
11
11
|
export const API_PROVIDER_COMMAND_AUTH = {
|
|
12
12
|
repo_status: AUTH_CLASS.NONE,
|
|
13
13
|
ref_compare: AUTH_CLASS.GIT_ONLY,
|
|
14
|
+
ref_inventory: AUTH_CLASS.GIT_ONLY,
|
|
15
|
+
cr_inventory: AUTH_CLASS.TOKEN_REQUIRED,
|
|
14
16
|
pr_status: AUTH_CLASS.TOKEN_REQUIRED,
|
|
15
17
|
pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
|
|
16
18
|
merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
|
package/caps.js
CHANGED
|
@@ -2,6 +2,10 @@ export const DEFAULT_MAX_BYTES = 8192;
|
|
|
2
2
|
export const DEFAULT_FIELD_MAX_BYTES = 512;
|
|
3
3
|
export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
|
|
4
4
|
|
|
5
|
+
/** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
|
|
6
|
+
export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
|
|
7
|
+
export const MAX_CHECK_STATUS_PAGES = 50;
|
|
8
|
+
|
|
5
9
|
export function getEffectiveIngestMaxBytes() {
|
|
6
10
|
const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
|
|
7
11
|
if (raw == null || raw === '') {
|
|
@@ -20,6 +24,30 @@ export function forgeIngestCapabilityFacts() {
|
|
|
20
24
|
return { forge_ingest_cap_bytes: bytes };
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Structured check-list pagination facts for provider capabilities.
|
|
29
|
+
* @param {{ strategy: 'offset_limit' | 'link_header', pageSizeParam: 'limit' | 'per_page' | null, sourceCount?: number }} opts
|
|
30
|
+
*/
|
|
31
|
+
export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, sourceCount = 1 }) {
|
|
32
|
+
const perSource = DEFAULT_CHECK_STATUS_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
|
|
33
|
+
return {
|
|
34
|
+
check_pagination: {
|
|
35
|
+
strategy,
|
|
36
|
+
page_size: DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
37
|
+
max_pages: MAX_CHECK_STATUS_PAGES,
|
|
38
|
+
page_size_param: pageSizeParam,
|
|
39
|
+
ingest_backoff: 'halve_until_fit',
|
|
40
|
+
on_page_cap: 'set_checks_truncated',
|
|
41
|
+
compliant_max_items_per_source: perSource,
|
|
42
|
+
check_source_count: sourceCount,
|
|
43
|
+
truncation_combination:
|
|
44
|
+
sourceCount > 1 ? 'any_source_truncated' : 'single_source',
|
|
45
|
+
compliant_max_items_total: perSource * sourceCount,
|
|
46
|
+
truncation_packet_field: 'checks_truncated',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
|
|
24
52
|
if (!text) return { text: '', truncated: false, bytes: 0 };
|
|
25
53
|
const buf = Buffer.from(text, 'utf8');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
|
+
import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
|
|
3
|
+
|
|
4
|
+
function isOversizedIngestError(err) {
|
|
5
|
+
return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function withPerPageParam(url, limit) {
|
|
9
|
+
const parsed = new URL(url);
|
|
10
|
+
parsed.searchParams.set('per_page', String(limit));
|
|
11
|
+
return parsed.toString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function withLimitParam(url, limit) {
|
|
15
|
+
const parsed = new URL(url);
|
|
16
|
+
parsed.searchParams.set('limit', String(limit));
|
|
17
|
+
return parsed.toString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Retry fetch with halved page size when raw ingest exceeds cap.
|
|
22
|
+
* @template T
|
|
23
|
+
* @param {(url: string) => Promise<T>} fetchFn
|
|
24
|
+
* @param {(limit: number) => string} buildUrl
|
|
25
|
+
* @param {number} [initialLimit]
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchWithIngestPageBackoff(
|
|
28
|
+
fetchFn,
|
|
29
|
+
buildUrl,
|
|
30
|
+
initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
31
|
+
) {
|
|
32
|
+
let limit = initialLimit;
|
|
33
|
+
while (true) {
|
|
34
|
+
try {
|
|
35
|
+
return await fetchFn(buildUrl(limit));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (isOversizedIngestError(err) && limit > 1) {
|
|
38
|
+
limit = Math.max(1, Math.floor(limit / 2));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch one offset page with ingest-cap backoff.
|
|
48
|
+
* @template T
|
|
49
|
+
* @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
|
|
50
|
+
* @param {number} page
|
|
51
|
+
* @param {number} initialLimit
|
|
52
|
+
* @returns {Promise<{ items: unknown[], usedLimit: number }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit) {
|
|
55
|
+
let usedLimit = initialLimit;
|
|
56
|
+
const items = await fetchWithIngestPageBackoff(
|
|
57
|
+
async (limit) => {
|
|
58
|
+
usedLimit = limit;
|
|
59
|
+
const pageItems = await fetchPage({ page, limit });
|
|
60
|
+
return Array.isArray(pageItems) ? pageItems : [];
|
|
61
|
+
},
|
|
62
|
+
(limit) => limit,
|
|
63
|
+
initialLimit,
|
|
64
|
+
);
|
|
65
|
+
return { items, usedLimit };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Offset/limit check-status pagination with ingest-cap backoff.
|
|
70
|
+
* @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
|
|
71
|
+
*/
|
|
72
|
+
export async function paginateCheckStatusPages({
|
|
73
|
+
fetchPage,
|
|
74
|
+
pageSize = DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
75
|
+
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
76
|
+
}) {
|
|
77
|
+
const all = [];
|
|
78
|
+
let truncated = false;
|
|
79
|
+
let activeLimit = pageSize;
|
|
80
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
81
|
+
const { items: pageItems, usedLimit } = await fetchPageWithIngestBackoff(
|
|
82
|
+
fetchPage,
|
|
83
|
+
page,
|
|
84
|
+
activeLimit,
|
|
85
|
+
);
|
|
86
|
+
activeLimit = usedLimit;
|
|
87
|
+
all.push(...pageItems);
|
|
88
|
+
if (pageItems.length < usedLimit) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (page === maxPages) {
|
|
92
|
+
truncated = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { items: all, truncated };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
|
|
101
|
+
* 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 }>}
|
|
104
|
+
*/
|
|
105
|
+
export async function paginateOffsetListPages({
|
|
106
|
+
fetchPage,
|
|
107
|
+
pageSize,
|
|
108
|
+
listLimit = null,
|
|
109
|
+
maxPages = MAX_CHECK_STATUS_PAGES,
|
|
110
|
+
maxPagesTruncatesWithLimit = false,
|
|
111
|
+
}) {
|
|
112
|
+
const all = [];
|
|
113
|
+
let listTruncated = false;
|
|
114
|
+
let activeLimit = pageSize;
|
|
115
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
116
|
+
const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
|
|
117
|
+
if (listLimit != null && remaining === 0) break;
|
|
118
|
+
const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
|
|
119
|
+
const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
|
|
120
|
+
activeLimit = usedLimit;
|
|
121
|
+
all.push(...items);
|
|
122
|
+
if (items.length < usedLimit) break;
|
|
123
|
+
if (listLimit != null) {
|
|
124
|
+
if (all.length >= listLimit) {
|
|
125
|
+
listTruncated = items.length >= usedLimit;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (maxPagesTruncatesWithLimit && page === maxPages) {
|
|
129
|
+
listTruncated = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
} else if (page === maxPages) {
|
|
133
|
+
listTruncated = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { items: all, list_truncated: listTruncated };
|
|
138
|
+
}
|
package/contracts/errors.js
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remogram fact requirements for Topogram branch-workcycle observer snapshots.
|
|
3
|
+
* Observer proto today captures remogram repo status only; semantic-diff consumers
|
|
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
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
|
|
11
|
+
|
|
12
|
+
/** CLI/MCP surface entries observer consumers may call (read-only). */
|
|
13
|
+
export const OBSERVER_REMOGRAM_COMMANDS = Object.freeze([
|
|
14
|
+
{ command: 'repo status', mcp_tool: 'repo_status', read_only: true, observer_proto: true },
|
|
15
|
+
{ command: 'refs inventory', mcp_tool: 'ref_inventory', read_only: true, observer_proto: false },
|
|
16
|
+
{ command: 'cr inventory', mcp_tool: 'cr_inventory', read_only: true, observer_proto: false },
|
|
17
|
+
{ command: 'refs compare', mcp_tool: 'ref_compare', read_only: true, observer_proto: false },
|
|
18
|
+
{ command: 'pr view', mcp_tool: 'pr_status', read_only: true, observer_proto: false },
|
|
19
|
+
{ command: 'pr checks', mcp_tool: 'pr_checks', read_only: true, observer_proto: false },
|
|
20
|
+
{ command: 'merge plan', mcp_tool: 'merge_plan', read_only: true, observer_proto: false },
|
|
21
|
+
{ command: 'sync plan', mcp_tool: 'sync_plan', read_only: true, observer_proto: false },
|
|
22
|
+
{ command: 'provider capabilities', mcp_tool: 'provider_capabilities', read_only: true, observer_proto: false },
|
|
23
|
+
{ command: 'doctor', mcp_tool: 'doctor', read_only: true, observer_proto: false },
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Fact inventory packet types for semantic-diff / branch-workcycle composition. */
|
|
27
|
+
export const OBSERVER_FACT_INVENTORY_PACKETS = Object.freeze([
|
|
28
|
+
{
|
|
29
|
+
packet_type: FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY,
|
|
30
|
+
command: 'refs inventory',
|
|
31
|
+
mcp_tool: 'ref_inventory',
|
|
32
|
+
read_only: true,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
packet_type: FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE,
|
|
36
|
+
command: 'cr inventory',
|
|
37
|
+
mcp_tool: 'cr_inventory',
|
|
38
|
+
read_only: true,
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Commands captured directly by observer-snapshot.sh today. */
|
|
43
|
+
export function observerProtoRemogramCommands() {
|
|
44
|
+
return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => entry.observer_proto);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Extended fact commands for semantic-diff inventory beyond the proto script. */
|
|
48
|
+
export function semanticDiffFactCommands() {
|
|
49
|
+
return OBSERVER_REMOGRAM_COMMANDS.filter((entry) => !entry.observer_proto);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** All v1 read/plan commands remain authoritative alongside fact inventory. */
|
|
53
|
+
export function allObserverEligibleCommands() {
|
|
54
|
+
return V1_READ_PLAN_COMMANDS.map((command) => {
|
|
55
|
+
const entry = OBSERVER_REMOGRAM_COMMANDS.find((c) => c.command === command);
|
|
56
|
+
return {
|
|
57
|
+
command,
|
|
58
|
+
mcp_tool: entry?.mcp_tool ?? null,
|
|
59
|
+
read_only: entry?.read_only ?? true,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
|
|
27
|
+
* All use schema_version 1 envelope discipline via forgePacket.
|
|
28
|
+
*/
|
|
29
|
+
export const FACT_INVENTORY_PACKET_TYPES = Object.freeze({
|
|
30
|
+
REF_INVENTORY: 'ref_inventory',
|
|
31
|
+
CR_INVENTORY_SLICE: 'cr_inventory_slice',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Trusted structural envelope on every remogram packet (authoritative for agents). */
|
|
35
|
+
export const TRUSTED_ENVELOPE_FIELDS = Object.freeze([
|
|
36
|
+
'type',
|
|
37
|
+
'schema_version',
|
|
38
|
+
'provider_id',
|
|
39
|
+
'remote_name',
|
|
40
|
+
'repo_id',
|
|
41
|
+
'observed_at',
|
|
42
|
+
'ok',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalized enum and boolean body fields agents may treat as structural facts
|
|
47
|
+
* (not forge prose). Provider-specific strings that are normalized to enums
|
|
48
|
+
* belong here; raw forge copy does not.
|
|
49
|
+
*/
|
|
50
|
+
export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
|
|
51
|
+
mergeability: true,
|
|
52
|
+
check_conclusion: true,
|
|
53
|
+
checks_conclusion: true,
|
|
54
|
+
state: true,
|
|
55
|
+
truncated: true,
|
|
56
|
+
list_truncated: true,
|
|
57
|
+
checks_truncated: true,
|
|
58
|
+
entry_count: true,
|
|
59
|
+
mergeability_confidence: true,
|
|
60
|
+
write_support: true,
|
|
61
|
+
diverged: true,
|
|
62
|
+
auth_present: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* String leaves copied from forge or git resolution that remain semantically
|
|
67
|
+
* untrusted per decision_packet_trust_doctrine. Structurally sanitized only.
|
|
68
|
+
*/
|
|
69
|
+
export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
|
|
70
|
+
repo_status: ['default_branch', 'auth_env', 'capabilities'],
|
|
71
|
+
ref_compare: ['base_ref', 'head_ref', 'base_sha', 'head_sha'],
|
|
72
|
+
pr_status: ['url', 'title', 'base_ref', 'head_ref', 'base_sha', 'head_sha'],
|
|
73
|
+
pr_checks: ['head_sha', 'statuses[].context', 'statuses[].description', 'statuses[].target_url'],
|
|
74
|
+
merge_plan: ['blockers[].message', 'blockers[].context'],
|
|
75
|
+
sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
|
|
76
|
+
ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
|
|
77
|
+
cr_inventory_slice: [
|
|
78
|
+
'entries[].url',
|
|
79
|
+
'entries[].title',
|
|
80
|
+
'entries[].base_ref',
|
|
81
|
+
'entries[].head_ref',
|
|
82
|
+
'entries[].base_sha',
|
|
83
|
+
'entries[].head_sha',
|
|
84
|
+
'entries[].head_reconcile.local_head_sha',
|
|
85
|
+
'entries[].head_reconcile.head_sha',
|
|
86
|
+
'entries[].checks[].context',
|
|
87
|
+
'entries[].checks[].description',
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** Keys that must never appear in remogram output (Topogram SDLC/workflow concepts). */
|
|
92
|
+
export { FORBIDDEN_PACKET_KEYS };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Documented body shapes for planned fact inventory packets (wave 2+).
|
|
96
|
+
* Used by contract tests and provider normalization; not emitted in wave 1.
|
|
97
|
+
*/
|
|
98
|
+
export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
|
|
99
|
+
[FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY]: {
|
|
100
|
+
refs: 'array<{ name: string, sha: string, kind?: string, is_default?: boolean }>',
|
|
101
|
+
default_ref: 'string optional',
|
|
102
|
+
ancestry_hints: 'array<{ base_ref: string, head_ref: string, ahead_by?: number, behind_by?: number }> optional',
|
|
103
|
+
},
|
|
104
|
+
[FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE]: {
|
|
105
|
+
entries:
|
|
106
|
+
'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 } }>',
|
|
107
|
+
entry_count: 'number',
|
|
108
|
+
/** true when list cap applied (entry_count > limit), not missing entries */
|
|
109
|
+
truncated: 'boolean',
|
|
110
|
+
list_truncated: 'boolean',
|
|
111
|
+
entries_skipped:
|
|
112
|
+
'array<{ pr_number: number, error_code: pr_not_open | api_error | oversized_raw_output | ... }> optional',
|
|
113
|
+
slice_ref: 'string optional',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a fact-inventory packet body through the standard envelope gate.
|
|
119
|
+
* Throws if body contains forbidden Topogram workflow keys.
|
|
120
|
+
*/
|
|
121
|
+
export function forgeFactInventoryPacket(type, context, body = {}, error = null) {
|
|
122
|
+
if (!Object.values(FACT_INVENTORY_PACKET_TYPES).includes(type)) {
|
|
123
|
+
throw new Error(`Unknown fact inventory packet type: ${type}`);
|
|
124
|
+
}
|
|
125
|
+
return forgePacket(type, context, body, error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { SCHEMA_VERSION };
|
package/cr-inventory.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { sanitizeField } from './caps.js';
|
|
2
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
|
+
import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
4
|
+
import { staleHeadDetails } from './pr-head-reconcile.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CR_INVENTORY_LIMIT = 50;
|
|
7
|
+
|
|
8
|
+
export function normalizeCrInventoryLimit(value) {
|
|
9
|
+
if (value == null || value === '') return DEFAULT_CR_INVENTORY_LIMIT;
|
|
10
|
+
const n = Number(value);
|
|
11
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
12
|
+
throw Object.assign(new Error('Invalid cr inventory limit'), {
|
|
13
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--limit must be a positive integer'),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function resolveOpenPullList(provider, ctx, limit) {
|
|
20
|
+
const listOpts = { limit };
|
|
21
|
+
if (typeof provider.listOpenPullsWithMeta === 'function') {
|
|
22
|
+
return provider.listOpenPullsWithMeta(ctx, listOpts);
|
|
23
|
+
}
|
|
24
|
+
const numbers = await provider.listOpenPulls(ctx, listOpts);
|
|
25
|
+
return { numbers, list_truncated: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function skipErrorCode(err) {
|
|
29
|
+
if (err?.forgeError?.code) return err.forgeError.code;
|
|
30
|
+
return ERROR_CODES.API_ERROR;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildHeadReconcile(ctx, view) {
|
|
34
|
+
const details = staleHeadDetails(
|
|
35
|
+
ctx.cwd,
|
|
36
|
+
ctx.config?.remote ?? ctx.remoteName,
|
|
37
|
+
view.head_ref,
|
|
38
|
+
view.head_sha,
|
|
39
|
+
);
|
|
40
|
+
if (!details) return { stale: false };
|
|
41
|
+
return {
|
|
42
|
+
stale: true,
|
|
43
|
+
local_head_sha: details.local_head_sha,
|
|
44
|
+
head_sha: details.head_sha,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compose one CR inventory entry from pr view and checks facts.
|
|
50
|
+
*/
|
|
51
|
+
export function buildCrInventoryEntry(ctx, view, checks) {
|
|
52
|
+
const entry = {
|
|
53
|
+
pr_number: view.pr_number,
|
|
54
|
+
url: view.url,
|
|
55
|
+
title: view.title,
|
|
56
|
+
state: view.state,
|
|
57
|
+
base_ref: view.base_ref,
|
|
58
|
+
head_ref: view.head_ref,
|
|
59
|
+
mergeability: view.mergeability,
|
|
60
|
+
checks_conclusion: checks.check_conclusion,
|
|
61
|
+
checks_truncated: checks.checks_truncated === true,
|
|
62
|
+
blockers: mergeBlockersFromFacts(view, checks),
|
|
63
|
+
head_reconcile: buildHeadReconcile(ctx, view),
|
|
64
|
+
};
|
|
65
|
+
if (view.base_sha) entry.base_sha = view.base_sha;
|
|
66
|
+
if (view.head_sha) entry.head_sha = view.head_sha;
|
|
67
|
+
return entry;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Aggregate open change requests into a semantic-diff-oriented inventory slice.
|
|
72
|
+
* @param {object} ctx forge context
|
|
73
|
+
* @param {object} provider must expose listOpenPulls, prView, prChecks
|
|
74
|
+
* @param {{ slice_ref?: string, limit?: number }} [opts]
|
|
75
|
+
*/
|
|
76
|
+
export async function crInventory(ctx, provider, opts = {}) {
|
|
77
|
+
const limit = normalizeCrInventoryLimit(opts.limit);
|
|
78
|
+
const { numbers, list_truncated: listTruncated } = await resolveOpenPullList(
|
|
79
|
+
provider,
|
|
80
|
+
ctx,
|
|
81
|
+
limit,
|
|
82
|
+
);
|
|
83
|
+
const entryCount = numbers.length;
|
|
84
|
+
const selected = numbers.slice(0, limit);
|
|
85
|
+
const entries = [];
|
|
86
|
+
const entries_skipped = [];
|
|
87
|
+
for (const number of selected) {
|
|
88
|
+
try {
|
|
89
|
+
const view = await provider.prView(ctx, { number });
|
|
90
|
+
if (!isOpenPrState(view.state)) {
|
|
91
|
+
entries_skipped.push({ pr_number: number, error_code: ERROR_CODES.PR_NOT_OPEN });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const checks = await provider.prChecks(ctx, { number });
|
|
95
|
+
entries.push(buildCrInventoryEntry(ctx, view, checks));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
entries_skipped.push({
|
|
98
|
+
pr_number: number,
|
|
99
|
+
error_code: skipErrorCode(err),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
entries,
|
|
105
|
+
...(entries_skipped.length ? { entries_skipped } : {}),
|
|
106
|
+
entry_count: entryCount,
|
|
107
|
+
truncated: entryCount > selected.length,
|
|
108
|
+
list_truncated: listTruncated,
|
|
109
|
+
...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
package/http.js
CHANGED
|
@@ -60,12 +60,46 @@ export function parseLinkHeader(linkHeader) {
|
|
|
60
60
|
if (!linkHeader) return {};
|
|
61
61
|
const links = {};
|
|
62
62
|
for (const segment of String(linkHeader).split(',')) {
|
|
63
|
-
const match = segment.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"/);
|
|
64
|
-
if (match) links[match[2]] = match[1];
|
|
63
|
+
const match = segment.trim().match(/^<([^>]+)>;\s*rel=(?:"([^"]+)"|'([^']+)')/);
|
|
64
|
+
if (match) links[(match[2] ?? match[3]).toLowerCase()] = match[1];
|
|
65
65
|
}
|
|
66
66
|
return links;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function normalizePathname(pathname) {
|
|
70
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
71
|
+
return pathname.replace(/\/+$/, '') || '/';
|
|
72
|
+
}
|
|
73
|
+
return pathname;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Reject Link rel=next URLs that leave the configured API origin (token exfiltration guard). */
|
|
77
|
+
export function isTrustedPaginationUrl(trustedOrigin, url, resolveBase) {
|
|
78
|
+
try {
|
|
79
|
+
if (resolveBase == null || resolveBase === '') {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const baseResolved = new URL(resolveBase);
|
|
83
|
+
if (baseResolved.username !== '' || baseResolved.password !== '') {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const resolved = new URL(url, resolveBase);
|
|
87
|
+
if (resolved.username !== '' || resolved.password !== '') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (resolved.origin !== trustedOrigin) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const basePath = normalizePathname(new URL(resolveBase).pathname);
|
|
94
|
+
if (normalizePathname(resolved.pathname) !== basePath) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
export async function fetchJsonWithMeta(
|
|
70
104
|
url,
|
|
71
105
|
options = {},
|
package/index.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export { SCHEMA_VERSION, PACKET_TYPES, forgePacket, forgeErrorPacket, unknownForgeContext, FORBIDDEN_PACKET_KEYS } from './contracts/envelope.js';
|
|
2
|
+
export {
|
|
3
|
+
V1_READ_PLAN_COMMANDS,
|
|
4
|
+
FACT_INVENTORY_PACKET_TYPES,
|
|
5
|
+
TRUSTED_ENVELOPE_FIELDS,
|
|
6
|
+
TRUSTED_NORMALIZED_BODY_FIELDS,
|
|
7
|
+
FORGE_SOURCED_STRING_LEAVES,
|
|
8
|
+
FACT_INVENTORY_BODY_SHAPES,
|
|
9
|
+
forgeFactInventoryPacket,
|
|
10
|
+
} from './contracts/semantic-diff-facts.js';
|
|
11
|
+
export {
|
|
12
|
+
OBSERVER_REMOGRAM_COMMANDS,
|
|
13
|
+
OBSERVER_FACT_INVENTORY_PACKETS,
|
|
14
|
+
observerProtoRemogramCommands,
|
|
15
|
+
semanticDiffFactCommands,
|
|
16
|
+
allObserverEligibleCommands,
|
|
17
|
+
} from './contracts/observer-fact-inventory.js';
|
|
2
18
|
export { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
3
19
|
export {
|
|
4
20
|
capText,
|
|
@@ -8,11 +24,25 @@ export {
|
|
|
8
24
|
DEFAULT_MAX_BYTES,
|
|
9
25
|
DEFAULT_FIELD_MAX_BYTES,
|
|
10
26
|
FORGE_INGEST_MAX_BYTES_ENV,
|
|
27
|
+
DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
28
|
+
MAX_CHECK_STATUS_PAGES,
|
|
11
29
|
getEffectiveIngestMaxBytes,
|
|
12
30
|
forgeIngestCapabilityFacts,
|
|
31
|
+
checkPaginationCapabilityFacts,
|
|
13
32
|
} from './caps.js';
|
|
33
|
+
export {
|
|
34
|
+
paginateCheckStatusPages,
|
|
35
|
+
paginateOffsetListPages,
|
|
36
|
+
fetchWithIngestPageBackoff,
|
|
37
|
+
fetchPageWithIngestBackoff,
|
|
38
|
+
withPerPageParam,
|
|
39
|
+
withLimitParam,
|
|
40
|
+
} from './check-pagination.js';
|
|
14
41
|
export { assertGitRef, assertGitRemote } from './git-args.js';
|
|
15
42
|
export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot } from './git-local.js';
|
|
43
|
+
export { buildRefInventoryBody, refsInventory } from './ref-inventory.js';
|
|
44
|
+
export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
|
|
45
|
+
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
16
46
|
export {
|
|
17
47
|
localHeadShaForPr,
|
|
18
48
|
staleHeadDetails,
|
|
@@ -32,7 +62,14 @@ export {
|
|
|
32
62
|
assertForgeReady,
|
|
33
63
|
forgeContext,
|
|
34
64
|
} from './resolve.js';
|
|
35
|
-
export {
|
|
65
|
+
export {
|
|
66
|
+
fetchWithTimeout,
|
|
67
|
+
fetchJson,
|
|
68
|
+
fetchJsonWithMeta,
|
|
69
|
+
parseLinkHeader,
|
|
70
|
+
isTrustedPaginationUrl,
|
|
71
|
+
fetchTextCapped,
|
|
72
|
+
} from './http.js';
|
|
36
73
|
export {
|
|
37
74
|
AUTH_CLASS,
|
|
38
75
|
API_PROVIDER_COMMAND_AUTH,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive merge blockers from already-fetched PR view and checks facts.
|
|
3
|
+
* Shared by merge plan and cr inventory aggregation.
|
|
4
|
+
*/
|
|
5
|
+
export function isOpenPrState(state) {
|
|
6
|
+
return String(state ?? '').toLowerCase() === 'open';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function mergeBlockersFromFacts(view, checks) {
|
|
10
|
+
const blockers = [];
|
|
11
|
+
if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
|
|
12
|
+
if (!isOpenPrState(view.state)) blockers.push('pr_not_open');
|
|
13
|
+
if (checks.checks_truncated === true) blockers.push('checks_incomplete');
|
|
14
|
+
if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
|
|
15
|
+
if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
|
|
16
|
+
if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
|
|
17
|
+
return blockers;
|
|
18
|
+
}
|
package/package.json
CHANGED
package/ref-inventory.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { sanitizeField } from './caps.js';
|
|
3
|
+
import { gitAheadBehind, gitCurrentBranch, gitRevParse } from './git-local.js';
|
|
4
|
+
|
|
5
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
function gitExec(cwd, args) {
|
|
8
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function listRefs(cwd) {
|
|
12
|
+
try {
|
|
13
|
+
const out = gitExec(cwd, [
|
|
14
|
+
'for-each-ref',
|
|
15
|
+
'--format=%(refname:short)|%(objectname)|%(refname)',
|
|
16
|
+
'refs/heads',
|
|
17
|
+
'refs/remotes',
|
|
18
|
+
]);
|
|
19
|
+
if (!out) return [];
|
|
20
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
21
|
+
const [name, sha, fullRef] = line.split('|');
|
|
22
|
+
const kind = fullRef.startsWith('refs/heads/') ? 'branch' : 'remote';
|
|
23
|
+
return { name, sha, kind };
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveDefaultRef(cwd, remoteName) {
|
|
31
|
+
try {
|
|
32
|
+
const sym = gitExec(cwd, ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`]);
|
|
33
|
+
const prefix = `refs/remotes/${remoteName}/`;
|
|
34
|
+
if (sym.startsWith(prefix)) {
|
|
35
|
+
return sym.slice(prefix.length);
|
|
36
|
+
}
|
|
37
|
+
return sym.replace(/^refs\/remotes\/[^/]+\//, '');
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildAncestryHints(cwd, defaultRef, refs) {
|
|
44
|
+
if (!defaultRef) return [];
|
|
45
|
+
const defaultEntry = refs.find((r) => r.name === defaultRef || r.name === `origin/${defaultRef}`);
|
|
46
|
+
const headBranch = gitCurrentBranch(cwd);
|
|
47
|
+
if (!headBranch || headBranch === 'HEAD') return [];
|
|
48
|
+
const headEntry = refs.find((r) => r.name === headBranch);
|
|
49
|
+
if (!defaultEntry?.sha || !headEntry?.sha || defaultEntry.sha === headEntry.sha) return [];
|
|
50
|
+
|
|
51
|
+
const counts = gitAheadBehind(cwd, defaultEntry.sha, headEntry.sha);
|
|
52
|
+
if (counts.ahead_by == null && counts.behind_by == null) return [];
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
base_ref: sanitizeField(defaultRef),
|
|
57
|
+
head_ref: sanitizeField(headBranch),
|
|
58
|
+
ahead_by: counts.ahead_by,
|
|
59
|
+
behind_by: counts.behind_by,
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build provider-neutral ref inventory body from local git.
|
|
66
|
+
* @param {string} cwd repository working directory (git root after config load)
|
|
67
|
+
* @param {string} [remoteName]
|
|
68
|
+
*/
|
|
69
|
+
export function buildRefInventoryBody(cwd, remoteName = 'origin') {
|
|
70
|
+
const refs = listRefs(cwd).map((ref) => ({
|
|
71
|
+
name: sanitizeField(ref.name),
|
|
72
|
+
sha: ref.sha,
|
|
73
|
+
kind: ref.kind,
|
|
74
|
+
is_default: false,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const defaultRef = resolveDefaultRef(cwd, remoteName);
|
|
78
|
+
if (defaultRef) {
|
|
79
|
+
for (const ref of refs) {
|
|
80
|
+
if (ref.name === defaultRef || ref.name === `${remoteName}/${defaultRef}`) {
|
|
81
|
+
ref.is_default = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ancestry_hints = buildAncestryHints(cwd, defaultRef, refs);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
refs,
|
|
90
|
+
...(defaultRef ? { default_ref: sanitizeField(defaultRef) } : {}),
|
|
91
|
+
...(ancestry_hints.length > 0 ? { ancestry_hints } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function refsInventory(ctx) {
|
|
96
|
+
const remoteName = ctx.config.remote || 'origin';
|
|
97
|
+
return buildRefInventoryBody(ctx.cwd, remoteName);
|
|
98
|
+
}
|
package/stub-provider.js
CHANGED