forge-workflow 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dev.md +2 -2
- package/.claude/commands/plan.md +2 -2
- package/.claude/commands/ship.md +2 -2
- package/.claude/commands/status.md +4 -4
- package/.cline/workflows/dev.md +2 -2
- package/.cline/workflows/plan.md +2 -2
- package/.cline/workflows/ship.md +2 -2
- package/.cline/workflows/status.md +4 -4
- package/.codex/skills/dev/SKILL.md +2 -2
- package/.codex/skills/plan/SKILL.md +2 -2
- package/.codex/skills/ship/SKILL.md +2 -2
- package/.codex/skills/status/SKILL.md +4 -4
- package/.cursor/commands/dev.md +2 -2
- package/.cursor/commands/plan.md +2 -2
- package/.cursor/commands/ship.md +2 -2
- package/.cursor/commands/status.md +4 -4
- package/.github/prompts/dev.prompt.md +2 -2
- package/.github/prompts/plan.prompt.md +2 -2
- package/.github/prompts/ship.prompt.md +2 -2
- package/.github/prompts/status.prompt.md +4 -4
- package/.github/workflows/beads-to-github.yml +43 -10
- package/.github/workflows/github-to-beads.yml +10 -7
- package/.kilocode/workflows/dev.md +2 -2
- package/.kilocode/workflows/plan.md +2 -2
- package/.kilocode/workflows/ship.md +2 -2
- package/.kilocode/workflows/status.md +4 -4
- package/.opencode/commands/dev.md +2 -2
- package/.opencode/commands/plan.md +2 -2
- package/.opencode/commands/ship.md +2 -2
- package/.opencode/commands/status.md +4 -4
- package/.roo/commands/dev.md +2 -2
- package/.roo/commands/plan.md +2 -2
- package/.roo/commands/ship.md +2 -2
- package/.roo/commands/status.md +4 -4
- package/AGENTS.md +1 -0
- package/CLAUDE.md +12 -0
- package/bin/forge.js +10 -5
- package/docs/BEADS_GITHUB_SYNC.md +26 -0
- package/docs/TOOLCHAIN.md +130 -148
- package/lib/beads-bootstrap.js +225 -0
- package/lib/beads-health-check.js +55 -10
- package/lib/beads-setup.js +104 -28
- package/lib/beads-sync-scaffold.js +11 -6
- package/lib/commands/_issue.js +11 -1
- package/lib/commands/issues.js +49 -0
- package/lib/commands/recommend.js +22 -1
- package/lib/commands/setup.js +16 -10
- package/lib/commands/status.js +181 -0
- package/lib/commands/team.js +11 -1
- package/lib/commands/test.js +37 -2
- package/lib/commands/validate.js +14 -8
- package/lib/commands/worktree.js +27 -54
- package/lib/dep-guard/keyword-ripple.js +184 -0
- package/lib/detect-worktree.js +9 -10
- package/lib/forge-issues.js +326 -0
- package/lib/issue-sync/authority.js +100 -0
- package/lib/issue-sync/github-pull.js +184 -0
- package/lib/issue-sync/import-primitives.js +98 -0
- package/lib/issue-sync/legacy-link-bridge.js +436 -0
- package/lib/issue-sync/link-store.js +292 -0
- package/lib/issue-sync/project-github.js +123 -0
- package/lib/issue-sync/reconcile.js +195 -0
- package/lib/issue-sync/schema.js +126 -0
- package/lib/lefthook-check.js +5 -2
- package/lib/project-memory.js +564 -0
- package/lib/runtime-health.js +100 -12
- package/lib/smart-status/conflicts.js +205 -0
- package/lib/smart-status/scoring.js +177 -0
- package/lib/status/beads-snapshot.js +102 -0
- package/lib/status/presenter.js +65 -0
- package/lib/workflow/enforce-stage.js +3 -1
- package/lib/workflow/state-manager.js +164 -8
- package/package.json +12 -4
- package/scripts/beads-context.sh +124 -5
- package/scripts/beads-context.test.js +21 -4
- package/scripts/beads-migrate-to-dolt.sh +7 -0
- package/scripts/beads-upgrade-smoke.sh +263 -0
- package/scripts/behavioral-judge.sh +115 -11
- package/scripts/benchmark.js +349 -63
- package/scripts/dep-guard-analyze.js +52 -17
- package/scripts/dep-guard-keyword-ripple.js +29 -0
- package/scripts/dep-guard-render-review.js +86 -0
- package/scripts/dep-guard.sh +45 -232
- package/scripts/forge-team/lib/sync-github.sh +160 -28
- package/scripts/forge-team/tests/sync-github.test.sh +195 -58
- package/scripts/github-beads-sync/index.mjs +122 -98
- package/scripts/github-beads-sync/mapping.mjs +54 -0
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
- package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
- package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
- package/scripts/preflight.sh +181 -0
- package/scripts/smart-status-score.js +31 -0
- package/scripts/smart-status-sessions.js +51 -0
- package/scripts/smart-status.sh +74 -329
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/test-ci-shard.js +244 -0
- package/scripts/test-dashboard.js +188 -52
- package/scripts/test-full-suite.js +186 -0
- package/scripts/test-profile.js +278 -0
- package/scripts/test.js +219 -28
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeGitHubNumber } = require('./link-store.js');
|
|
4
|
+
|
|
5
|
+
const GITHUB_OWNED_FIELD_PATHS = [
|
|
6
|
+
'github.number',
|
|
7
|
+
'github.nodeId',
|
|
8
|
+
'github.url',
|
|
9
|
+
'shared.title',
|
|
10
|
+
'shared.body',
|
|
11
|
+
'shared.state',
|
|
12
|
+
'shared.assignees',
|
|
13
|
+
'shared.labels',
|
|
14
|
+
'shared.milestone',
|
|
15
|
+
'sync.remoteUpdatedAt',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function cloneValue(value) {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return value.map(cloneValue);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
const copy = {};
|
|
25
|
+
|
|
26
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
27
|
+
copy[key] = cloneValue(nestedValue);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return copy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeText(value, fallback = '') {
|
|
37
|
+
return typeof value === 'string' ? value : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeState(value) {
|
|
41
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return 'open';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeCollectionEntries(values) {
|
|
49
|
+
if (Array.isArray(values)) {
|
|
50
|
+
return values;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!values) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof values === 'object') {
|
|
58
|
+
if (Array.isArray(values.nodes)) {
|
|
59
|
+
return values.nodes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (Array.isArray(values.edges)) {
|
|
63
|
+
return values.edges
|
|
64
|
+
.map((edge) => edge?.node ?? edge)
|
|
65
|
+
.filter((entry) => entry != null);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [values];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeNameList(values) {
|
|
73
|
+
const source = normalizeCollectionEntries(values);
|
|
74
|
+
const normalized = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
|
|
77
|
+
for (const entry of source) {
|
|
78
|
+
const name = typeof entry === 'string'
|
|
79
|
+
? entry
|
|
80
|
+
: entry?.login ?? entry?.name ?? entry?.title ?? null;
|
|
81
|
+
|
|
82
|
+
if (typeof name !== 'string' || name.length === 0 || seen.has(name)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
seen.add(name);
|
|
87
|
+
normalized.push(name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeMilestone(milestone) {
|
|
94
|
+
if (!milestone) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof milestone === 'string') {
|
|
99
|
+
return milestone;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return milestone.title ?? milestone.name ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeNodeId(issue = {}) {
|
|
106
|
+
if (typeof issue.node_id === 'string' && issue.node_id.length > 0) {
|
|
107
|
+
return issue.node_id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof issue.nodeId === 'string' && issue.nodeId.length > 0) {
|
|
111
|
+
return issue.nodeId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof issue.id === 'string' && issue.id.length > 0) {
|
|
115
|
+
return issue.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeIssueUrl(issue = {}) {
|
|
122
|
+
const candidates = [issue.html_url, issue.htmlUrl, issue.url];
|
|
123
|
+
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
if (typeof candidate !== 'string' || candidate.length === 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (/^https:\/\/api\.github\.com\//iu.test(candidate)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeAssignees(issue = {}) {
|
|
140
|
+
const assignees = normalizeNameList(issue.assignees);
|
|
141
|
+
|
|
142
|
+
if (assignees.length > 0) {
|
|
143
|
+
return assignees;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return normalizeNameList(issue.assignee);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeRemoteIssue(issue = {}) {
|
|
150
|
+
return {
|
|
151
|
+
github: {
|
|
152
|
+
number: normalizeGitHubNumber(issue.number ?? issue.issue_number ?? issue.issueNumber),
|
|
153
|
+
nodeId: normalizeNodeId(issue),
|
|
154
|
+
url: normalizeIssueUrl(issue),
|
|
155
|
+
},
|
|
156
|
+
shared: {
|
|
157
|
+
title: normalizeText(issue.title),
|
|
158
|
+
body: normalizeText(issue.body),
|
|
159
|
+
state: normalizeState(issue.state),
|
|
160
|
+
assignees: normalizeAssignees(issue),
|
|
161
|
+
labels: normalizeNameList(issue.labels),
|
|
162
|
+
milestone: normalizeMilestone(issue.milestone),
|
|
163
|
+
},
|
|
164
|
+
sync: {
|
|
165
|
+
remoteUpdatedAt: issue.updated_at ?? issue.updatedAt ?? null,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const normalizeGitHubIssuePayload = normalizeRemoteIssue;
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
GITHUB_OWNED_FIELD_PATHS,
|
|
174
|
+
cloneValue,
|
|
175
|
+
normalizeGitHubIssuePayload,
|
|
176
|
+
normalizeAssignees,
|
|
177
|
+
normalizeCollectionEntries,
|
|
178
|
+
normalizeIssueUrl,
|
|
179
|
+
normalizeMilestone,
|
|
180
|
+
normalizeNameList,
|
|
181
|
+
normalizeNodeId,
|
|
182
|
+
normalizeRemoteIssue,
|
|
183
|
+
normalizeState,
|
|
184
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { cloneValue, normalizeRemoteIssue: normalizeSharedRemoteIssue } = require('./github-pull.js');
|
|
4
|
+
const { reconcileSharedIssueRecord } = require('./reconcile.js');
|
|
5
|
+
const { resolveCanonicalLink } = require('./link-store.js');
|
|
6
|
+
|
|
7
|
+
function toIssueArray(page = {}) {
|
|
8
|
+
if (Array.isArray(page)) {
|
|
9
|
+
return page;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!page || typeof page !== 'object') {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (Array.isArray(page.nodes)) {
|
|
17
|
+
return page.nodes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (Array.isArray(page.items)) {
|
|
21
|
+
return page.items;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(page.issues)) {
|
|
25
|
+
return page.issues;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(page.edges)) {
|
|
29
|
+
return page.edges.map((edge) => edge?.node).filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const nestedNodes = page.data?.repository?.issues?.nodes;
|
|
33
|
+
if (Array.isArray(nestedNodes)) {
|
|
34
|
+
return nestedNodes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nestedEdges = page.data?.repository?.issues?.edges;
|
|
38
|
+
if (Array.isArray(nestedEdges)) {
|
|
39
|
+
return nestedEdges.map((edge) => edge?.node).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listRemoteIssues(page = {}) {
|
|
46
|
+
return toIssueArray(page).map(cloneValue);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeRemoteIssue(issue = {}) {
|
|
50
|
+
return normalizeSharedRemoteIssue(issue);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveSharedLink(linkStore, remoteIssue = {}) {
|
|
54
|
+
if (!linkStore) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lookup = {
|
|
59
|
+
githubNodeId: remoteIssue.github?.nodeId ?? remoteIssue.node_id ?? remoteIssue.nodeId ?? null,
|
|
60
|
+
githubNumber: remoteIssue.github?.number ?? remoteIssue.number ?? remoteIssue.issue_number ?? remoteIssue.issueNumber ?? null,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (typeof linkStore.resolveCanonicalLink === 'function') {
|
|
64
|
+
return linkStore.resolveCanonicalLink(lookup);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof linkStore === 'object' && typeof linkStore.byGitHubNumber?.get === 'function') {
|
|
68
|
+
return resolveCanonicalLink(linkStore, lookup);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function materializeLocalIssue(localRecord = {}, remoteIssue = {}, linkStore, options = {}) {
|
|
75
|
+
const normalizedRemoteIssue = remoteIssue?.github && remoteIssue?.shared && remoteIssue?.sync
|
|
76
|
+
? cloneValue(remoteIssue)
|
|
77
|
+
: normalizeRemoteIssue(remoteIssue);
|
|
78
|
+
|
|
79
|
+
const link = resolveSharedLink(linkStore, normalizedRemoteIssue);
|
|
80
|
+
const { record, diagnostics } = reconcileSharedIssueRecord(
|
|
81
|
+
localRecord,
|
|
82
|
+
normalizedRemoteIssue,
|
|
83
|
+
options.reconcileOptions ?? {},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
link,
|
|
88
|
+
diagnostics,
|
|
89
|
+
record,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
listRemoteIssues,
|
|
95
|
+
materializeLocalIssue,
|
|
96
|
+
normalizeRemoteIssue,
|
|
97
|
+
resolveSharedLink,
|
|
98
|
+
};
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createLinkStore,
|
|
3
|
+
normalizeGitHubNumber,
|
|
4
|
+
resolveCanonicalLink,
|
|
5
|
+
upsertCanonicalLink,
|
|
6
|
+
} = require('./link-store.js');
|
|
7
|
+
|
|
8
|
+
const SOURCE_PRECEDENCE = new Map([
|
|
9
|
+
['githubNodeId', 0],
|
|
10
|
+
['githubNumber', 1],
|
|
11
|
+
['existing', 2],
|
|
12
|
+
['externalRef', 3],
|
|
13
|
+
['githubIssue', 4],
|
|
14
|
+
['mapping', 5],
|
|
15
|
+
['syncComment', 6],
|
|
16
|
+
['descriptionUrl', 7],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function precedenceFor(source) {
|
|
20
|
+
return SOURCE_PRECEDENCE.get(source) ?? Number.MAX_SAFE_INTEGER;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseExternalRef(externalRef) {
|
|
24
|
+
if (typeof externalRef !== 'string') {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const match = externalRef.match(/\bgh-(\d+)\b/i);
|
|
29
|
+
return match ? normalizeGitHubNumber(match[1]) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractGitHubIssueUrl(value) {
|
|
33
|
+
if (typeof value !== 'string') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const match = value.match(/https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
|
|
38
|
+
if (!match) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
owner: match[1],
|
|
44
|
+
repo: match[2],
|
|
45
|
+
number: normalizeGitHubNumber(match[3]),
|
|
46
|
+
url: match[0],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseSyncComment(comment) {
|
|
51
|
+
const issueUrl = extractGitHubIssueUrl(comment?.body);
|
|
52
|
+
const githubNumber = normalizeGitHubNumber(
|
|
53
|
+
comment?.githubNumber ?? comment?.issueNumber ?? issueUrl?.number ?? parseExternalRef(comment?.body),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
forgeIssueId: comment?.forgeIssueId ?? comment?.beadsId ?? null,
|
|
58
|
+
githubNumber,
|
|
59
|
+
githubNodeId: comment?.githubNodeId ?? comment?.nodeId ?? null,
|
|
60
|
+
url: issueUrl?.url ?? null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildSourceRecord(source, input = {}) {
|
|
65
|
+
const record = { source };
|
|
66
|
+
|
|
67
|
+
if (input.githubNumber !== null && input.githubNumber !== undefined) {
|
|
68
|
+
record.githubNumber = normalizeGitHubNumber(input.githubNumber);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (input.githubNodeId) {
|
|
72
|
+
record.githubNodeId = input.githubNodeId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (input.forgeIssueId) {
|
|
76
|
+
record.forgeIssueId = input.forgeIssueId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (input.url) {
|
|
80
|
+
record.url = input.url;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasStructuredMappingEntry(mapping) {
|
|
87
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
'forgeIssueId',
|
|
93
|
+
'githubNumber',
|
|
94
|
+
'issueNumber',
|
|
95
|
+
'githubNodeId',
|
|
96
|
+
'nodeId',
|
|
97
|
+
'url',
|
|
98
|
+
].some((key) => Object.hasOwn(mapping, key));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function collectMappingSources(mapping, forgeIssueId, explicitGitHubNumber) {
|
|
102
|
+
if (mapping === undefined || mapping === null) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof mapping === 'object' && !Array.isArray(mapping)) {
|
|
107
|
+
if (hasStructuredMappingEntry(mapping)) {
|
|
108
|
+
return [buildSourceRecord('mapping', {
|
|
109
|
+
forgeIssueId: mapping.forgeIssueId ?? forgeIssueId,
|
|
110
|
+
githubNumber: mapping.githubNumber ?? mapping.issueNumber,
|
|
111
|
+
githubNodeId: mapping.githubNodeId ?? mapping.nodeId,
|
|
112
|
+
url: mapping.url,
|
|
113
|
+
})];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sources = [];
|
|
117
|
+
|
|
118
|
+
for (const [issueNumber, beadsId] of Object.entries(mapping)) {
|
|
119
|
+
const githubNumber = normalizeGitHubNumber(issueNumber);
|
|
120
|
+
if (githubNumber === null || typeof beadsId !== 'string' || beadsId.length === 0) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (forgeIssueId && beadsId === forgeIssueId) {
|
|
125
|
+
sources.push(buildSourceRecord('mapping', {
|
|
126
|
+
forgeIssueId: beadsId,
|
|
127
|
+
githubNumber,
|
|
128
|
+
}));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!forgeIssueId && explicitGitHubNumber !== null && githubNumber === explicitGitHubNumber) {
|
|
133
|
+
sources.push(buildSourceRecord('mapping', {
|
|
134
|
+
forgeIssueId: beadsId,
|
|
135
|
+
githubNumber,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return sources;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [buildSourceRecord('mapping', {
|
|
144
|
+
forgeIssueId,
|
|
145
|
+
githubNumber: mapping,
|
|
146
|
+
})];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectLegacySources(legacyHints) {
|
|
150
|
+
const sources = [];
|
|
151
|
+
const forgeIssueId = legacyHints.forgeIssueId ?? null;
|
|
152
|
+
|
|
153
|
+
const explicitGithub = legacyHints.github ?? {};
|
|
154
|
+
if (explicitGithub.nodeId) {
|
|
155
|
+
sources.push(buildSourceRecord('githubNodeId', {
|
|
156
|
+
forgeIssueId,
|
|
157
|
+
githubNodeId: explicitGithub.nodeId,
|
|
158
|
+
githubNumber: explicitGithub.number,
|
|
159
|
+
url: explicitGithub.url,
|
|
160
|
+
}));
|
|
161
|
+
} else if (explicitGithub.number) {
|
|
162
|
+
sources.push(buildSourceRecord('githubNumber', {
|
|
163
|
+
forgeIssueId,
|
|
164
|
+
githubNumber: explicitGithub.number,
|
|
165
|
+
url: explicitGithub.url,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
sources.push(...collectMappingSources(
|
|
170
|
+
legacyHints.mapping,
|
|
171
|
+
forgeIssueId,
|
|
172
|
+
normalizeGitHubNumber(explicitGithub.number),
|
|
173
|
+
));
|
|
174
|
+
|
|
175
|
+
const githubIssueState = legacyHints.githubIssue ?? legacyHints.github_issue;
|
|
176
|
+
if (githubIssueState !== undefined && githubIssueState !== null) {
|
|
177
|
+
if (typeof githubIssueState === 'object') {
|
|
178
|
+
sources.push(buildSourceRecord('githubIssue', {
|
|
179
|
+
forgeIssueId: githubIssueState.forgeIssueId ?? forgeIssueId,
|
|
180
|
+
githubNumber: githubIssueState.githubNumber ?? githubIssueState.issueNumber,
|
|
181
|
+
githubNodeId: githubIssueState.githubNodeId ?? githubIssueState.nodeId,
|
|
182
|
+
url: githubIssueState.url,
|
|
183
|
+
}));
|
|
184
|
+
} else {
|
|
185
|
+
sources.push(buildSourceRecord('githubIssue', {
|
|
186
|
+
forgeIssueId,
|
|
187
|
+
githubNumber: githubIssueState,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const comment of legacyHints.syncComments ?? []) {
|
|
193
|
+
const parsed = parseSyncComment(comment);
|
|
194
|
+
sources.push(buildSourceRecord('syncComment', {
|
|
195
|
+
forgeIssueId: parsed.forgeIssueId ?? forgeIssueId,
|
|
196
|
+
githubNumber: parsed.githubNumber,
|
|
197
|
+
githubNodeId: parsed.githubNodeId,
|
|
198
|
+
url: parsed.url,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const externalRefNumber = parseExternalRef(legacyHints.externalRef);
|
|
203
|
+
if (externalRefNumber !== null) {
|
|
204
|
+
sources.push(buildSourceRecord('externalRef', {
|
|
205
|
+
forgeIssueId,
|
|
206
|
+
githubNumber: externalRefNumber,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const descriptionMatch = extractGitHubIssueUrl(legacyHints.description ?? legacyHints.descriptionUrl);
|
|
211
|
+
if (descriptionMatch) {
|
|
212
|
+
sources.push(buildSourceRecord('descriptionUrl', {
|
|
213
|
+
forgeIssueId,
|
|
214
|
+
githubNumber: descriptionMatch.number,
|
|
215
|
+
url: descriptionMatch.url,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return sources.filter((source) =>
|
|
220
|
+
source.forgeIssueId || source.githubNodeId || source.githubNumber || source.url);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function findExistingLink(store, legacyHints, sources) {
|
|
224
|
+
const forgeIssueId = legacyHints.forgeIssueId ?? null;
|
|
225
|
+
|
|
226
|
+
if (forgeIssueId) {
|
|
227
|
+
const existing = resolveCanonicalLink(store, { forgeIssueId });
|
|
228
|
+
if (existing) {
|
|
229
|
+
return existing;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const source of sources) {
|
|
234
|
+
if (source.githubNodeId) {
|
|
235
|
+
const existing = resolveCanonicalLink(store, { githubNodeId: source.githubNodeId });
|
|
236
|
+
if (existing) {
|
|
237
|
+
return existing;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (source.githubNumber) {
|
|
242
|
+
const existing = resolveCanonicalLink(store, { githubNumber: source.githubNumber });
|
|
243
|
+
if (existing) {
|
|
244
|
+
return existing;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pickPreferredCandidate(candidates, fieldName) {
|
|
253
|
+
const fieldKey = fieldName === 'github.nodeId' ? 'githubNodeId' : 'githubNumber';
|
|
254
|
+
const filtered = candidates.filter((candidate) => candidate[fieldKey] !== null && candidate[fieldKey] !== undefined);
|
|
255
|
+
|
|
256
|
+
if (filtered.length === 0) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
filtered.sort((left, right) => precedenceFor(left.source) - precedenceFor(right.source));
|
|
261
|
+
return filtered[0];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildFieldDriftDiagnostic(candidates, fieldName, selectedCandidate) {
|
|
265
|
+
if (!selectedCandidate) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const fieldKey = fieldName === 'github.nodeId' ? 'githubNodeId' : 'githubNumber';
|
|
270
|
+
const conflicts = [];
|
|
271
|
+
const seenValues = new Set();
|
|
272
|
+
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
if (candidate[fieldKey] === null || candidate[fieldKey] === undefined) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (candidate[fieldKey] === selectedCandidate[fieldKey]) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const key = `${candidate.source}:${candidate[fieldKey]}`;
|
|
283
|
+
if (seenValues.has(key)) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
seenValues.add(key);
|
|
288
|
+
conflicts.push({
|
|
289
|
+
source: candidate.source,
|
|
290
|
+
value: candidate[fieldKey],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (conflicts.length === 0) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return [{
|
|
299
|
+
type: 'legacy-link-drift',
|
|
300
|
+
field: fieldName,
|
|
301
|
+
selected: {
|
|
302
|
+
source: selectedCandidate.source,
|
|
303
|
+
value: selectedCandidate[fieldKey],
|
|
304
|
+
},
|
|
305
|
+
conflicts,
|
|
306
|
+
}];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildDriftDiagnostics(candidates, selectedNodeCandidate, selectedNumberCandidate) {
|
|
310
|
+
return [
|
|
311
|
+
...buildFieldDriftDiagnostic(candidates, 'github.nodeId', selectedNodeCandidate),
|
|
312
|
+
...buildFieldDriftDiagnostic(candidates, 'github.number', selectedNumberCandidate),
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function chooseCanonicalUrl(existingLink, sources, selectedNumber) {
|
|
317
|
+
if (existingLink?.github?.url && existingLink.github.number === selectedNumber) {
|
|
318
|
+
return existingLink.github.url;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const source of sources) {
|
|
322
|
+
if (source.url && source.githubNumber === selectedNumber) {
|
|
323
|
+
return source.url;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function shouldSeedLegacyMappingEntries(legacyHints = {}) {
|
|
331
|
+
const mapping = legacyHints.mapping;
|
|
332
|
+
const explicitGithub = legacyHints.github ?? {};
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
!legacyHints.forgeIssueId &&
|
|
336
|
+
mapping &&
|
|
337
|
+
typeof mapping === 'object' &&
|
|
338
|
+
!Array.isArray(mapping) &&
|
|
339
|
+
!hasStructuredMappingEntry(mapping) &&
|
|
340
|
+
!explicitGithub.nodeId &&
|
|
341
|
+
normalizeGitHubNumber(explicitGithub.number) === null &&
|
|
342
|
+
legacyHints.githubIssue == null &&
|
|
343
|
+
legacyHints.github_issue == null &&
|
|
344
|
+
(legacyHints.syncComments ?? []).length === 0 &&
|
|
345
|
+
!legacyHints.externalRef &&
|
|
346
|
+
!legacyHints.description &&
|
|
347
|
+
!legacyHints.descriptionUrl
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function seedLegacyMappingEntries(store, mapping) {
|
|
352
|
+
const links = [];
|
|
353
|
+
const diagnostics = [];
|
|
354
|
+
|
|
355
|
+
for (const [issueNumber, forgeIssueId] of Object.entries(mapping)) {
|
|
356
|
+
if (typeof forgeIssueId !== 'string' || forgeIssueId.length === 0) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const githubNumber = normalizeGitHubNumber(issueNumber);
|
|
361
|
+
if (githubNumber === null) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = bridgeLegacyLinkHints(
|
|
366
|
+
{
|
|
367
|
+
forgeIssueId,
|
|
368
|
+
mapping: githubNumber,
|
|
369
|
+
},
|
|
370
|
+
{ store },
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (result.link) {
|
|
374
|
+
links.push(result.link);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (Array.isArray(result.diagnostics)) {
|
|
378
|
+
diagnostics.push(...result.diagnostics);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { links, diagnostics };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function bridgeLegacyLinkHints(legacyHints, options = {}) {
|
|
386
|
+
const store = options.store ?? createLinkStore();
|
|
387
|
+
if (shouldSeedLegacyMappingEntries(legacyHints)) {
|
|
388
|
+
const { links, diagnostics } = seedLegacyMappingEntries(store, legacyHints.mapping);
|
|
389
|
+
return {
|
|
390
|
+
link: links.at(-1) ?? null,
|
|
391
|
+
diagnostics,
|
|
392
|
+
links,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const sources = collectLegacySources(legacyHints);
|
|
397
|
+
const existingLink = findExistingLink(store, legacyHints, sources);
|
|
398
|
+
|
|
399
|
+
const candidates = existingLink ? [
|
|
400
|
+
buildSourceRecord('existing', {
|
|
401
|
+
forgeIssueId: existingLink.forgeIssueId,
|
|
402
|
+
githubNumber: existingLink.github.number,
|
|
403
|
+
githubNodeId: existingLink.github.nodeId,
|
|
404
|
+
url: existingLink.github.url,
|
|
405
|
+
}),
|
|
406
|
+
...sources,
|
|
407
|
+
] : sources;
|
|
408
|
+
|
|
409
|
+
const selectedNodeCandidate = pickPreferredCandidate(candidates, 'github.nodeId');
|
|
410
|
+
const selectedNumberCandidate = pickPreferredCandidate(candidates, 'github.number');
|
|
411
|
+
const diagnostics = buildDriftDiagnostics(candidates, selectedNodeCandidate, selectedNumberCandidate);
|
|
412
|
+
|
|
413
|
+
const link = upsertCanonicalLink(store, {
|
|
414
|
+
forgeIssueId: legacyHints.forgeIssueId ?? existingLink?.forgeIssueId ?? null,
|
|
415
|
+
github: {
|
|
416
|
+
nodeId: selectedNodeCandidate?.githubNodeId ?? existingLink?.github?.nodeId ?? null,
|
|
417
|
+
number: selectedNumberCandidate?.githubNumber ?? existingLink?.github?.number ?? null,
|
|
418
|
+
url: chooseCanonicalUrl(existingLink, sources, selectedNumberCandidate?.githubNumber ?? null),
|
|
419
|
+
},
|
|
420
|
+
sources,
|
|
421
|
+
diagnostics,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
link,
|
|
426
|
+
diagnostics: link.diagnostics,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
module.exports = {
|
|
431
|
+
bridgeLegacyLinkHints,
|
|
432
|
+
buildSourceRecord,
|
|
433
|
+
collectLegacySources,
|
|
434
|
+
extractGitHubIssueUrl,
|
|
435
|
+
parseExternalRef,
|
|
436
|
+
};
|