@workit-dev/core 0.0.1 → 1.0.0
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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Issue reads and writes on GitHub and GitLab for `workit issue` and
|
|
2
|
+
// `workit finding record --queue` (#439): one forge-neutral shape over
|
|
3
|
+
// `gh api` / `glab api`, on the workspace's verified credential.
|
|
4
|
+
import { apiJson, apiWrite, type ForgeRunner } from "./exec";
|
|
5
|
+
import { redactText } from "./redact";
|
|
6
|
+
import { failure, success, type ForgeKind, type ForgeResult } from "./types";
|
|
7
|
+
|
|
8
|
+
export type IssueCloseReason = "completed" | "not-planned";
|
|
9
|
+
export const ISSUE_CLOSE_REASONS: readonly IssueCloseReason[] = ["completed", "not-planned"];
|
|
10
|
+
|
|
11
|
+
export type ForgeIssue = {
|
|
12
|
+
number: number;
|
|
13
|
+
url: string | null;
|
|
14
|
+
title: string;
|
|
15
|
+
state: "open" | "closed";
|
|
16
|
+
/** GitHub's close reason (completed, not_planned, reopened); null on GitLab. */
|
|
17
|
+
stateReason: string | null;
|
|
18
|
+
body: string;
|
|
19
|
+
labels: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type CreateIssueInput = {
|
|
23
|
+
title: string;
|
|
24
|
+
body: string;
|
|
25
|
+
labels: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface IssueApi {
|
|
29
|
+
kind: ForgeKind;
|
|
30
|
+
apiHost: string;
|
|
31
|
+
repo: string;
|
|
32
|
+
/** One issue; `invalid_input` when the number is a PR/MR on GitHub. */
|
|
33
|
+
view(n: number): ForgeResult<ForgeIssue>;
|
|
34
|
+
/** Comment on issue `n`: the comment's URL (GitHub) or note id (GitLab: the URL is the issue's plus `#note_<id>`). */
|
|
35
|
+
comment(n: number, body: string): ForgeResult<{ url: string | null; id: string | null }>;
|
|
36
|
+
/** Close issue `n`; GitLab has no close reason and ignores it. */
|
|
37
|
+
close(n: number, reason: IssueCloseReason): ForgeResult<void>;
|
|
38
|
+
create(input: CreateIssueInput): ForgeResult<{ number: number; url: string | null }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Options = { apiHost: string; repo: string; runner: ForgeRunner };
|
|
42
|
+
|
|
43
|
+
const safeUrl = (value: unknown): string | null =>
|
|
44
|
+
typeof value === "string" && value ? redactText(value) : null;
|
|
45
|
+
|
|
46
|
+
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
47
|
+
|
|
48
|
+
export function githubIssues({ apiHost, repo, runner }: Options): IssueApi {
|
|
49
|
+
const write = <T>(args: string[], what: string) =>
|
|
50
|
+
apiWrite<T>(runner, "gh", apiHost, ["api", ...args], what);
|
|
51
|
+
return {
|
|
52
|
+
kind: "github",
|
|
53
|
+
apiHost,
|
|
54
|
+
repo,
|
|
55
|
+
view(n) {
|
|
56
|
+
const read = apiJson<Record<string, unknown>>(
|
|
57
|
+
runner,
|
|
58
|
+
"gh",
|
|
59
|
+
apiHost,
|
|
60
|
+
["api", `repos/${repo}/issues/${n}`],
|
|
61
|
+
`issue #${n}`,
|
|
62
|
+
);
|
|
63
|
+
if (!read.ok) return read;
|
|
64
|
+
const data = read.data;
|
|
65
|
+
if (typeof data.number !== "number")
|
|
66
|
+
return failure("failed", `gh api issues/${n} returned no issue`);
|
|
67
|
+
if (data.pull_request)
|
|
68
|
+
return failure(
|
|
69
|
+
"invalid_input",
|
|
70
|
+
`#${n} is a pull request, not an issue`,
|
|
71
|
+
`workit pr status --pr ${n}`,
|
|
72
|
+
);
|
|
73
|
+
return success({
|
|
74
|
+
number: data.number,
|
|
75
|
+
url: safeUrl(data.html_url),
|
|
76
|
+
title: text(data.title),
|
|
77
|
+
state: data.state === "closed" ? "closed" : "open",
|
|
78
|
+
stateReason: typeof data.state_reason === "string" ? data.state_reason : null,
|
|
79
|
+
body: text(data.body),
|
|
80
|
+
labels: Array.isArray(data.labels)
|
|
81
|
+
? data.labels.flatMap((label: { name?: unknown }) =>
|
|
82
|
+
typeof label?.name === "string" ? [label.name] : [],
|
|
83
|
+
)
|
|
84
|
+
: [],
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
comment(n, body) {
|
|
88
|
+
const posted = write<{ html_url?: unknown }>(
|
|
89
|
+
["-X", "POST", `repos/${repo}/issues/${n}/comments`, "-f", `body=${body}`],
|
|
90
|
+
`comment on issue #${n}`,
|
|
91
|
+
);
|
|
92
|
+
return posted.ok ? success({ url: safeUrl(posted.data?.html_url), id: null }) : posted;
|
|
93
|
+
},
|
|
94
|
+
close(n, reason) {
|
|
95
|
+
const closed = write<unknown>(
|
|
96
|
+
[
|
|
97
|
+
"-X",
|
|
98
|
+
"PATCH",
|
|
99
|
+
`repos/${repo}/issues/${n}`,
|
|
100
|
+
"-f",
|
|
101
|
+
"state=closed",
|
|
102
|
+
"-f",
|
|
103
|
+
`state_reason=${reason === "not-planned" ? "not_planned" : "completed"}`,
|
|
104
|
+
],
|
|
105
|
+
`close issue #${n}`,
|
|
106
|
+
);
|
|
107
|
+
return closed.ok ? success(undefined) : closed;
|
|
108
|
+
},
|
|
109
|
+
create(input) {
|
|
110
|
+
const created = write<{ number?: unknown; html_url?: unknown }>(
|
|
111
|
+
[
|
|
112
|
+
"-X",
|
|
113
|
+
"POST",
|
|
114
|
+
`repos/${repo}/issues`,
|
|
115
|
+
"-f",
|
|
116
|
+
`title=${input.title}`,
|
|
117
|
+
"-f",
|
|
118
|
+
`body=${input.body}`,
|
|
119
|
+
...input.labels.flatMap((label) => ["-f", `labels[]=${label}`]),
|
|
120
|
+
],
|
|
121
|
+
"create issue",
|
|
122
|
+
);
|
|
123
|
+
if (!created.ok) return created;
|
|
124
|
+
if (typeof created.data?.number !== "number")
|
|
125
|
+
return failure("failed", "gh api issues returned no issue number");
|
|
126
|
+
return success({
|
|
127
|
+
number: created.data.number,
|
|
128
|
+
url: safeUrl(created.data.html_url),
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function gitlabIssues({ apiHost, repo, runner }: Options): IssueApi {
|
|
135
|
+
const project = `projects/${encodeURIComponent(repo)}`;
|
|
136
|
+
const write = <T>(args: string[], what: string) =>
|
|
137
|
+
apiWrite<T>(runner, "glab", apiHost, ["api", ...args], what);
|
|
138
|
+
const view = (iid: number): ForgeResult<ForgeIssue> => {
|
|
139
|
+
const read = apiJson<Record<string, unknown>>(
|
|
140
|
+
runner,
|
|
141
|
+
"glab",
|
|
142
|
+
apiHost,
|
|
143
|
+
["api", `${project}/issues/${iid}`],
|
|
144
|
+
`issue #${iid}`,
|
|
145
|
+
);
|
|
146
|
+
if (!read.ok) return read;
|
|
147
|
+
const data = read.data;
|
|
148
|
+
if (typeof data.iid !== "number")
|
|
149
|
+
return failure("failed", `glab api issues/${iid} returned no issue`);
|
|
150
|
+
return success({
|
|
151
|
+
number: data.iid,
|
|
152
|
+
url: safeUrl(data.web_url),
|
|
153
|
+
title: text(data.title),
|
|
154
|
+
state: data.state === "closed" ? "closed" : "open",
|
|
155
|
+
stateReason: null,
|
|
156
|
+
body: text(data.description),
|
|
157
|
+
labels: Array.isArray(data.labels)
|
|
158
|
+
? data.labels.filter((label): label is string => typeof label === "string")
|
|
159
|
+
: [],
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
kind: "gitlab",
|
|
164
|
+
apiHost,
|
|
165
|
+
repo,
|
|
166
|
+
view,
|
|
167
|
+
comment(iid, body) {
|
|
168
|
+
const posted = write<{ id?: unknown }>(
|
|
169
|
+
["-X", "POST", `${project}/issues/${iid}/notes`, "-f", `body=${body}`],
|
|
170
|
+
`note on issue #${iid}`,
|
|
171
|
+
);
|
|
172
|
+
if (!posted.ok) return posted;
|
|
173
|
+
const id = posted.data?.id;
|
|
174
|
+
return success({
|
|
175
|
+
url: null,
|
|
176
|
+
id: typeof id === "number" ? String(id) : null,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
close(iid) {
|
|
180
|
+
const closed = write<unknown>(
|
|
181
|
+
["-X", "PUT", `${project}/issues/${iid}`, "-f", "state_event=close"],
|
|
182
|
+
`close issue #${iid}`,
|
|
183
|
+
);
|
|
184
|
+
return closed.ok ? success(undefined) : closed;
|
|
185
|
+
},
|
|
186
|
+
create(input) {
|
|
187
|
+
const created = write<{ iid?: unknown; web_url?: unknown }>(
|
|
188
|
+
[
|
|
189
|
+
"-X",
|
|
190
|
+
"POST",
|
|
191
|
+
`${project}/issues`,
|
|
192
|
+
"-f",
|
|
193
|
+
`title=${input.title}`,
|
|
194
|
+
"-f",
|
|
195
|
+
`description=${input.body}`,
|
|
196
|
+
...(input.labels.length ? ["-f", `labels=${input.labels.join(",")}`] : []),
|
|
197
|
+
],
|
|
198
|
+
"create issue",
|
|
199
|
+
);
|
|
200
|
+
if (!created.ok) return created;
|
|
201
|
+
if (typeof created.data?.iid !== "number")
|
|
202
|
+
return failure("failed", "glab api issues returned no issue iid");
|
|
203
|
+
return success({
|
|
204
|
+
number: created.data.iid,
|
|
205
|
+
url: safeUrl(created.data.web_url),
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Issue links for `workit pr create` (#324): which issues a PR links, the body
|
|
2
|
+
// line each one gets, the `pr.linked` ledger rows, the optional tracker
|
|
3
|
+
// backlink, and the links `pr status` lists.
|
|
4
|
+
//
|
|
5
|
+
// Issues come from `--issue` (repeatable), else the fanout slice the branch
|
|
6
|
+
// belongs to (its `issue` field), else the branch name when the workspace
|
|
7
|
+
// tracker has `link: true`. `IRPT-74` and `.../issue/IRPT-74` are YouTrack;
|
|
8
|
+
// an issue URL names its own forge; `#42`, `42` and `owner/repo#42` are on
|
|
9
|
+
// the workspace tracker (`issues.provider` github or gitlab), else the PR's
|
|
10
|
+
// forge. An issue on the PR's own forge closes in its syntax (`Closes #42`,
|
|
11
|
+
// `Refs #42`, `Closes owner/repo#42`); one on another forge cannot close
|
|
12
|
+
// across forges and gets `Related to: <issue URL>`.
|
|
13
|
+
import { vcsConfig, type IssueTracker } from "../core/vcs-config";
|
|
14
|
+
import { youTrackBaseUrl } from "../core/youtrack";
|
|
15
|
+
import { defaultOperations, ISSUE_RE, unwrap } from "../core/youtrack-tools";
|
|
16
|
+
import { withMarker, writeOnce, youTrackMarker, type OnceOutcome } from "../core/youtrack-writes";
|
|
17
|
+
import { sliceIssuesFor } from "../fanout";
|
|
18
|
+
import { appendObserved, type LedgerActor, type ReadRow } from "../ledger";
|
|
19
|
+
import type { ForgeRunner } from "./exec";
|
|
20
|
+
import { createGitHubForge } from "./github";
|
|
21
|
+
import { createGitLabForge } from "./gitlab";
|
|
22
|
+
import { deriveGhIssueFromBranch, deriveYouTrackIssueFromBranch } from "./pr-body";
|
|
23
|
+
import type { ResolvedForge } from "./resolve";
|
|
24
|
+
import type { Forge } from "./types";
|
|
25
|
+
|
|
26
|
+
export type ForgeKind = "github" | "gitlab";
|
|
27
|
+
/** What the body line says: a closing keyword, a reference, or a cross-tracker "Related to". */
|
|
28
|
+
export type IssueRelation = "closes" | "refs" | "related";
|
|
29
|
+
|
|
30
|
+
/** The PR's forge: where a same-forge issue reference closes. */
|
|
31
|
+
export type PrForge = { kind: ForgeKind; repo: string; host: string };
|
|
32
|
+
|
|
33
|
+
export type IssueLink = {
|
|
34
|
+
tracker: ForgeKind | "youtrack";
|
|
35
|
+
/** The issue number on a forge, the issue id on YouTrack. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** The issue's repository when it is not the PR's own, else null. */
|
|
38
|
+
repo: string | null;
|
|
39
|
+
/** The issue's forge host when it is not the PR's, else null. */
|
|
40
|
+
host: string | null;
|
|
41
|
+
/** The issue's web URL when it is on another host or tracker, else null. */
|
|
42
|
+
url: string | null;
|
|
43
|
+
relation: IssueRelation;
|
|
44
|
+
source: "flag" | "fanout" | "branch";
|
|
45
|
+
/** The line the PR body carries for it. */
|
|
46
|
+
line: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type IssueLinkPlan = {
|
|
50
|
+
links: IssueLink[];
|
|
51
|
+
/** The workspace tracker's `backlink`. */
|
|
52
|
+
backlink: boolean;
|
|
53
|
+
tracker: IssueTracker | null;
|
|
54
|
+
/** Config read leniently, or a link that could not be derived. */
|
|
55
|
+
warnings: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** An issue as named: its host and repo only when the name carries them. */
|
|
59
|
+
type NamedRef = {
|
|
60
|
+
tracker: ForgeKind | "youtrack";
|
|
61
|
+
id: string;
|
|
62
|
+
repo: string | null;
|
|
63
|
+
host: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const DEFAULT_HOST: Record<ForgeKind, string> = { github: "github.com", gitlab: "gitlab.com" };
|
|
67
|
+
|
|
68
|
+
const same = (left: string | null, right: string | null): boolean =>
|
|
69
|
+
(left ?? "").toLowerCase() === (right ?? "").toLowerCase();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One `--issue` value: `#42`, `42`, `owner/repo#42`, `IRPT-74` or an issue
|
|
73
|
+
* URL. A bare or short form is an issue on `tracker`.
|
|
74
|
+
*/
|
|
75
|
+
export function parseIssueRef(
|
|
76
|
+
raw: string,
|
|
77
|
+
tracker: ForgeKind,
|
|
78
|
+
): { ok: true; ref: NamedRef } | { ok: false; error: string } {
|
|
79
|
+
const value = raw.trim();
|
|
80
|
+
if (ISSUE_RE.test(value))
|
|
81
|
+
return { ok: true, ref: { tracker: "youtrack", id: value, repo: null, host: null } };
|
|
82
|
+
const short = /^(?:([\w.-]+(?:\/[\w.-]+)+))?#?(\d+)$/u.exec(value);
|
|
83
|
+
if (short && (short[1] === undefined || value.includes("#")))
|
|
84
|
+
return { ok: true, ref: { tracker, id: short[2], repo: short[1] ?? null, host: null } };
|
|
85
|
+
let url: URL | null = null;
|
|
86
|
+
try {
|
|
87
|
+
url = /^https?:\/\//iu.test(value) ? new URL(value) : null;
|
|
88
|
+
} catch {
|
|
89
|
+
url = null;
|
|
90
|
+
}
|
|
91
|
+
if (url) {
|
|
92
|
+
const pathname = url.pathname.replace(/\/+$/u, "");
|
|
93
|
+
const youtrack = /\/issues?\/([A-Z][A-Z0-9]*-\d+)(?:\/|$)/u.exec(pathname);
|
|
94
|
+
if (youtrack && ISSUE_RE.test(youtrack[1]))
|
|
95
|
+
return { ok: true, ref: { tracker: "youtrack", id: youtrack[1], repo: null, host: null } };
|
|
96
|
+
const gitlab = /^\/(.+?)\/-\/(?:issues|work_items)\/(\d+)$/u.exec(pathname);
|
|
97
|
+
const github = /^\/([^/]+\/[^/]+)\/issues\/(\d+)$/u.exec(pathname);
|
|
98
|
+
const found: [ForgeKind, RegExpExecArray] | null = gitlab
|
|
99
|
+
? ["gitlab", gitlab]
|
|
100
|
+
: github
|
|
101
|
+
? ["github", github]
|
|
102
|
+
: null;
|
|
103
|
+
if (found) {
|
|
104
|
+
const [kind, match] = found;
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
ref: { tracker: kind, id: match[2], repo: match[1], host: url.host.toLowerCase() },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: `cannot read issue "${raw}" (use #42, owner/repo#42, IRPT-74 or an issue URL)`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const issueUrl = (kind: ForgeKind, host: string, repo: string, id: string): string =>
|
|
118
|
+
`https://${host}/${repo}/${kind === "gitlab" ? "-/" : ""}issues/${id}`;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a forge-tracker issue lives, its body line and its relation. The
|
|
122
|
+
* workspace's `issues.repo`/`issues.host` place a bare number when the
|
|
123
|
+
* tracker is not the PR's own repository.
|
|
124
|
+
*/
|
|
125
|
+
function placeRef(
|
|
126
|
+
ref: NamedRef,
|
|
127
|
+
forge: PrForge,
|
|
128
|
+
tracker: IssueTracker | null,
|
|
129
|
+
relation: "closes" | "refs",
|
|
130
|
+
youtrackBase: string | null,
|
|
131
|
+
): Omit<IssueLink, "source"> | { error: string } {
|
|
132
|
+
if (ref.tracker === "youtrack") {
|
|
133
|
+
const url = youtrackBase ? `${youtrackBase.replace(/\/+$/u, "")}/issue/${ref.id}` : null;
|
|
134
|
+
return {
|
|
135
|
+
...ref,
|
|
136
|
+
url,
|
|
137
|
+
relation: "related",
|
|
138
|
+
line: `Related to: ${url ?? ref.id}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const configured = tracker?.provider === ref.tracker;
|
|
142
|
+
const home = ref.tracker === forge.kind ? forge.host : DEFAULT_HOST[ref.tracker];
|
|
143
|
+
const host = ref.host ?? (configured ? tracker?.host : null) ?? home;
|
|
144
|
+
const repo =
|
|
145
|
+
ref.repo ??
|
|
146
|
+
(configured ? tracker?.repo : null) ??
|
|
147
|
+
(ref.tracker === forge.kind && same(host, forge.host) ? forge.repo : null);
|
|
148
|
+
if (!repo)
|
|
149
|
+
return {
|
|
150
|
+
error: `#${ref.id} is a ${ref.tracker} issue, but the PR is on ${forge.kind}: set issues.repo to the ${ref.tracker} project that holds it, or pass the issue URL`,
|
|
151
|
+
};
|
|
152
|
+
if (ref.tracker === forge.kind && same(host, forge.host)) {
|
|
153
|
+
const own = same(repo, forge.repo);
|
|
154
|
+
return {
|
|
155
|
+
tracker: ref.tracker,
|
|
156
|
+
id: ref.id,
|
|
157
|
+
repo: own ? null : repo,
|
|
158
|
+
host: null,
|
|
159
|
+
url: null,
|
|
160
|
+
relation,
|
|
161
|
+
line: `${relation === "refs" ? "Refs" : "Closes"} ${own ? "" : repo}#${ref.id}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const url = issueUrl(ref.tracker, host, repo, ref.id);
|
|
165
|
+
return {
|
|
166
|
+
tracker: ref.tracker,
|
|
167
|
+
id: ref.id,
|
|
168
|
+
repo,
|
|
169
|
+
host,
|
|
170
|
+
url,
|
|
171
|
+
relation: "related",
|
|
172
|
+
line: `Related to: ${url}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The workspace tracker, or null when none is configured or the config cannot resolve. */
|
|
177
|
+
function trackerFor(cwd: string): IssueTracker | null {
|
|
178
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
179
|
+
if (resolved.ok === false) return null;
|
|
180
|
+
return (resolved.issue_tracker as IssueTracker | null | undefined) ?? null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The issues a PR from `branch` links and their body lines. Named issues
|
|
185
|
+
* (the flag, else the fanout slice) always link; the branch name is read
|
|
186
|
+
* only when the tracker has `link: true`, and a YouTrack id from it only
|
|
187
|
+
* when a base URL is known (unchanged from the branch-only behavior).
|
|
188
|
+
*/
|
|
189
|
+
export function planIssueLinks(
|
|
190
|
+
cwd: string,
|
|
191
|
+
input: {
|
|
192
|
+
issues: readonly string[];
|
|
193
|
+
refs: boolean;
|
|
194
|
+
branch: string | null;
|
|
195
|
+
forge: PrForge;
|
|
196
|
+
},
|
|
197
|
+
tracker: IssueTracker | null = trackerFor(cwd),
|
|
198
|
+
): { ok: true; data: IssueLinkPlan } | { ok: false; error: string } {
|
|
199
|
+
const relation = input.refs ? "refs" : (tracker?.relation ?? "closes");
|
|
200
|
+
const youtrackBase = tracker?.baseUrl ?? youTrackBaseUrl();
|
|
201
|
+
const forgeTracker: ForgeKind =
|
|
202
|
+
tracker?.provider === "github" || tracker?.provider === "gitlab"
|
|
203
|
+
? tracker.provider
|
|
204
|
+
: input.forge.kind;
|
|
205
|
+
const warnings = [...(tracker?.warnings ?? [])];
|
|
206
|
+
const named = input.issues.length ? input.issues : sliceIssuesFor(cwd, input.branch);
|
|
207
|
+
const source: IssueLink["source"] = input.issues.length ? "flag" : "fanout";
|
|
208
|
+
const refs: Array<{ ref: NamedRef; source: IssueLink["source"] }> = [];
|
|
209
|
+
for (const raw of named) {
|
|
210
|
+
const parsed = parseIssueRef(raw, forgeTracker);
|
|
211
|
+
if (!parsed.ok)
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: source === "fanout" ? `fanout slice issue: ${parsed.error}` : parsed.error,
|
|
215
|
+
};
|
|
216
|
+
refs.push({ ref: parsed.ref, source });
|
|
217
|
+
}
|
|
218
|
+
let derived = false;
|
|
219
|
+
if (refs.length === 0 && tracker?.link && input.branch) {
|
|
220
|
+
const id =
|
|
221
|
+
tracker.provider === "youtrack"
|
|
222
|
+
? youtrackBase
|
|
223
|
+
? deriveYouTrackIssueFromBranch(input.branch)
|
|
224
|
+
: ""
|
|
225
|
+
: deriveGhIssueFromBranch(input.branch);
|
|
226
|
+
if (id) {
|
|
227
|
+
refs.push({
|
|
228
|
+
ref: { tracker: tracker.provider, id, repo: null, host: null },
|
|
229
|
+
source: "branch",
|
|
230
|
+
});
|
|
231
|
+
derived = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
const links: IssueLink[] = [];
|
|
236
|
+
for (const { ref, source: from } of refs) {
|
|
237
|
+
const placed = placeRef(ref, input.forge, tracker, relation, youtrackBase);
|
|
238
|
+
if ("error" in placed) {
|
|
239
|
+
if (derived) {
|
|
240
|
+
warnings.push(`the branch issue is not linked: ${placed.error}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
return { ok: false, error: placed.error };
|
|
244
|
+
}
|
|
245
|
+
const key = `${placed.tracker}:${placed.host ?? ""}:${placed.repo?.toLowerCase() ?? ""}#${placed.id}`;
|
|
246
|
+
if (seen.has(key)) continue;
|
|
247
|
+
seen.add(key);
|
|
248
|
+
links.push({ ...placed, source: from });
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, data: { links, backlink: tracker?.backlink === true, tracker, warnings } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The body with each link's line appended once (a line already present is kept as is). */
|
|
254
|
+
export function withIssueLines(
|
|
255
|
+
body: string,
|
|
256
|
+
links: ReadonlyArray<Pick<IssueLink, "line">>,
|
|
257
|
+
): string {
|
|
258
|
+
const present = new Set(body.split(/\r?\n/u).map((line) => line.trim()));
|
|
259
|
+
const lines = links.map((link) => link.line).filter((line) => !present.has(line));
|
|
260
|
+
if (lines.length === 0) return body;
|
|
261
|
+
return body ? `${body}\n\n${lines.join("\n")}` : lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One `pr.linked` row per link, for a PR this call opened. */
|
|
265
|
+
export function recordIssueLinks(
|
|
266
|
+
cwd: string,
|
|
267
|
+
pr: {
|
|
268
|
+
number: number;
|
|
269
|
+
url: string;
|
|
270
|
+
branch: string;
|
|
271
|
+
base: string;
|
|
272
|
+
head: string;
|
|
273
|
+
},
|
|
274
|
+
links: readonly IssueLink[],
|
|
275
|
+
context: { actor: LedgerActor; forge: ForgeKind; repo: string },
|
|
276
|
+
): Array<{ issue: string; id: string } | { issue: string; error: string }> {
|
|
277
|
+
return links.map((link) => {
|
|
278
|
+
const row = appendObserved(cwd, {
|
|
279
|
+
type: "pr.linked",
|
|
280
|
+
actor: context.actor,
|
|
281
|
+
branch: pr.branch,
|
|
282
|
+
head: pr.head,
|
|
283
|
+
pr: pr.number,
|
|
284
|
+
base: pr.base,
|
|
285
|
+
url: pr.url,
|
|
286
|
+
forge: context.forge,
|
|
287
|
+
repo: context.repo,
|
|
288
|
+
tracker: link.tracker,
|
|
289
|
+
issue: link.id,
|
|
290
|
+
issueRepo: link.repo,
|
|
291
|
+
issueHost: link.host,
|
|
292
|
+
issueUrl: link.url,
|
|
293
|
+
relation: link.relation,
|
|
294
|
+
source: link.source,
|
|
295
|
+
});
|
|
296
|
+
return row.ok
|
|
297
|
+
? { issue: label(link), id: String(row.value.id) }
|
|
298
|
+
: { issue: label(link), error: row.error };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** `#42`, `owner/repo#42`, `IRPT-74`, or the URL of an issue on another forge. */
|
|
303
|
+
export const label = (link: Pick<IssueLink, "tracker" | "id" | "repo" | "url">): string =>
|
|
304
|
+
link.tracker === "youtrack" ? link.id : (link.url ?? `${link.repo ?? ""}#${link.id}`);
|
|
305
|
+
|
|
306
|
+
export type LinkedIssue = {
|
|
307
|
+
tracker: string;
|
|
308
|
+
issue: string;
|
|
309
|
+
repo: string | null;
|
|
310
|
+
url: string | null;
|
|
311
|
+
relation: string;
|
|
312
|
+
source: string;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** The issues `pr.linked` rows record for PR `pr` of `repo` (the CLI's own rows only). */
|
|
316
|
+
export function linkedIssues(
|
|
317
|
+
rows: readonly ReadRow[],
|
|
318
|
+
target: { pr: number; repo: string },
|
|
319
|
+
): LinkedIssue[] {
|
|
320
|
+
const out = new Map<string, LinkedIssue>();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
if (row.type !== "pr.linked" || row.observer !== "workit_cli" || row.pr !== target.pr) continue;
|
|
323
|
+
if (typeof row.repo !== "string" || row.repo.toLowerCase() !== target.repo.toLowerCase())
|
|
324
|
+
continue;
|
|
325
|
+
if (typeof row.issue !== "string" || typeof row.tracker !== "string") continue;
|
|
326
|
+
const repo = typeof row.issueRepo === "string" ? row.issueRepo : null;
|
|
327
|
+
const url = typeof row.issueUrl === "string" ? row.issueUrl : null;
|
|
328
|
+
out.set(`${row.tracker}:${url ?? repo ?? ""}#${row.issue}`, {
|
|
329
|
+
tracker: row.tracker,
|
|
330
|
+
issue: row.issue,
|
|
331
|
+
repo,
|
|
332
|
+
url,
|
|
333
|
+
relation:
|
|
334
|
+
typeof row.relation === "string"
|
|
335
|
+
? row.relation
|
|
336
|
+
: row.tracker === "youtrack"
|
|
337
|
+
? "related"
|
|
338
|
+
: "closes",
|
|
339
|
+
source: typeof row.source === "string" ? row.source : "flag",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return [...out.values()];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type BacklinkOutcome = {
|
|
346
|
+
issue: string;
|
|
347
|
+
status: OnceOutcome["status"] | "skipped";
|
|
348
|
+
note?: string;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const defaultYouTrackComment = (
|
|
352
|
+
root: string,
|
|
353
|
+
issueId: string,
|
|
354
|
+
text: string,
|
|
355
|
+
marker: string,
|
|
356
|
+
): Promise<OnceOutcome> =>
|
|
357
|
+
writeOnce(issueId, marker, "comments", async () => {
|
|
358
|
+
try {
|
|
359
|
+
const value = await defaultOperations.postComment(issueId, withMarker(text, marker), root);
|
|
360
|
+
// Not sent (no ytrack, logged out): say how to unblock, skip the read-back.
|
|
361
|
+
if (value?.ok === false && value.outcome === "not_applied")
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
error: `${String(value.error)}${typeof value.unblock === "string" ? `; ${value.unblock}` : ""}`,
|
|
365
|
+
data: { outcome: "not_applied" },
|
|
366
|
+
};
|
|
367
|
+
return { ok: true, data: unwrap(value) };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: error instanceof Error ? error.message : String(error),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The forge an issue lives on: the PR's own, or another repository on the
|
|
378
|
+
* same forge host under the same verified credential. Null for another host:
|
|
379
|
+
* no account is verified there.
|
|
380
|
+
*/
|
|
381
|
+
function issueForge(
|
|
382
|
+
resolved: ResolvedForge,
|
|
383
|
+
link: IssueLink,
|
|
384
|
+
runner: ForgeRunner | undefined,
|
|
385
|
+
): Forge | null {
|
|
386
|
+
if (link.tracker === "youtrack") return null;
|
|
387
|
+
if (link.repo === null && link.host === null) return resolved.forge;
|
|
388
|
+
const apiHost = resolved.forge.apiHost;
|
|
389
|
+
if (link.tracker !== resolved.forge.kind || !same(link.host ?? apiHost, apiHost)) return null;
|
|
390
|
+
const options = {
|
|
391
|
+
apiHost,
|
|
392
|
+
repo: link.repo ?? resolved.forge.repo,
|
|
393
|
+
runner: runner ?? resolved.runner,
|
|
394
|
+
};
|
|
395
|
+
return link.tracker === "github" ? createGitHubForge(options) : createGitLabForge(options);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The tracker backlink (`issues.backlink: true`): a comment with the PR URL
|
|
400
|
+
* on each linked issue, posted at most once: a marker naming the PR is read
|
|
401
|
+
* back first. Forge issues on the PR's host go through the workspace's
|
|
402
|
+
* verified credential; one on another forge host is skipped (no account is
|
|
403
|
+
* verified there). YouTrack goes through its API behind WORKIT_YT_WRITE=1,
|
|
404
|
+
* like `workit youtrack note`. A failure is reported per issue and never
|
|
405
|
+
* undoes the PR.
|
|
406
|
+
*/
|
|
407
|
+
export async function postBacklinks(
|
|
408
|
+
cwd: string,
|
|
409
|
+
resolved: ResolvedForge,
|
|
410
|
+
pr: { number: number; url: string },
|
|
411
|
+
links: readonly IssueLink[],
|
|
412
|
+
deps: { runner?: ForgeRunner; env?: NodeJS.ProcessEnv } = {},
|
|
413
|
+
): Promise<BacklinkOutcome[]> {
|
|
414
|
+
const env = deps.env ?? process.env;
|
|
415
|
+
const noun = resolved.forge.kind === "github" ? `PR #${pr.number}` : `MR !${pr.number}`;
|
|
416
|
+
const text = `Linked from ${noun}: ${pr.url}`;
|
|
417
|
+
const out: BacklinkOutcome[] = [];
|
|
418
|
+
for (const link of links) {
|
|
419
|
+
const issue = label(link);
|
|
420
|
+
if (link.tracker === "youtrack") {
|
|
421
|
+
if (env.WORKIT_YT_WRITE !== "1") {
|
|
422
|
+
out.push({
|
|
423
|
+
issue,
|
|
424
|
+
status: "failed",
|
|
425
|
+
note: "YouTrack writes need WORKIT_YT_WRITE=1 (as for workit youtrack note)",
|
|
426
|
+
});
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const marker = youTrackMarker({
|
|
430
|
+
operation: "pr.backlink",
|
|
431
|
+
issueId: link.id,
|
|
432
|
+
pr: pr.url,
|
|
433
|
+
});
|
|
434
|
+
const outcome = await defaultYouTrackComment(cwd, link.id, text, marker);
|
|
435
|
+
out.push({
|
|
436
|
+
issue,
|
|
437
|
+
status: outcome.status,
|
|
438
|
+
...("error" in outcome ? { note: outcome.error } : {}),
|
|
439
|
+
});
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const forge = issueForge(resolved, link, deps.runner);
|
|
443
|
+
if (!forge) {
|
|
444
|
+
out.push({
|
|
445
|
+
issue,
|
|
446
|
+
status: "skipped",
|
|
447
|
+
note: `an issue on another forge host is not backlinked: no verified account for ${link.host ?? resolved.forge.apiHost}`,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const marker = `<!-- workit-backlink:${pr.url} -->`;
|
|
452
|
+
const number = Number(link.id);
|
|
453
|
+
const existing = forge.issueComments(number);
|
|
454
|
+
if (!existing.ok) {
|
|
455
|
+
out.push({ issue, status: "failed", note: existing.error });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (existing.data.some((body) => body.includes(marker))) {
|
|
459
|
+
out.push({ issue, status: "already_done" });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const posted = forge.commentIssue(number, withMarker(text, marker));
|
|
463
|
+
out.push(
|
|
464
|
+
posted.ok ? { issue, status: "done" } : { issue, status: "failed", note: posted.error },
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|