@stephen1620/dsh-tomato-board 0.3.3 → 0.3.4
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/README.md +103 -32
- package/lib/client.js +5078 -831
- package/lib/client.js.map +1 -1
- package/lib/index.js +2509 -17
- package/lib/invariant.js +1 -1
- package/lib/types/client/TomatoBoard.d.ts +1 -0
- package/lib/types/index.d.ts +2 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,2454 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import process from "node:process";
|
|
2
|
+
import process$1 from "node:process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
|
|
4
|
+
import { accessSync, constants } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
|
|
7
|
+
import { mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
//#region src/cli-path.ts
|
|
10
|
+
/** Resolve a CLI for GUI processes whose inherited PATH omits user bin folders. */
|
|
11
|
+
function resolveCliExecutable(executable, path = process.env.PATH ?? "", home = homedir()) {
|
|
12
|
+
if (isAbsolute(executable) || executable.includes("/") || executable.includes("\\")) return executable;
|
|
13
|
+
const directories = [
|
|
14
|
+
...path.split(delimiter).filter(Boolean),
|
|
15
|
+
join(home, ".local", "bin"),
|
|
16
|
+
join(home, "go", "bin"),
|
|
17
|
+
"/opt/homebrew/bin",
|
|
18
|
+
"/usr/local/bin"
|
|
19
|
+
];
|
|
20
|
+
for (const directory of directories) {
|
|
21
|
+
const candidate = join(directory, executable);
|
|
22
|
+
try {
|
|
23
|
+
accessSync(candidate, constants.X_OK);
|
|
24
|
+
return candidate;
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`找不到 ${executable} 可执行文件,请在番茄工作台插件配置 executable 中填写绝对路径`);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/team-metrics.ts
|
|
31
|
+
function teamDate(value) {
|
|
32
|
+
if (typeof value !== "string" && typeof value !== "number") return "";
|
|
33
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/u.test(value)) {
|
|
34
|
+
const time = Date.parse(`${value}T00:00:00Z`);
|
|
35
|
+
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value ? value : "";
|
|
36
|
+
}
|
|
37
|
+
const time = typeof value === "number" ? value : Date.parse(value);
|
|
38
|
+
return Number.isFinite(time) ? new Date(time + 8 * 36e5).toISOString().slice(0, 10) : "";
|
|
39
|
+
}
|
|
40
|
+
function teamPeriod(start, end) {
|
|
41
|
+
const period = {
|
|
42
|
+
start: teamDate(start),
|
|
43
|
+
end: teamDate(end)
|
|
44
|
+
};
|
|
45
|
+
if (!period.start || !period.end || period.end < period.start) throw new Error("迭代缺少有效起止日期,请先在番茄中补全");
|
|
46
|
+
return period;
|
|
47
|
+
}
|
|
48
|
+
const amount = (raw) => {
|
|
49
|
+
if (typeof raw !== "number" && typeof raw !== "string" || raw === "" || typeof raw === "string" && !raw.trim()) return null;
|
|
50
|
+
const value = Number(raw);
|
|
51
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
52
|
+
};
|
|
53
|
+
const days = (start, end) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 864e5) + 1;
|
|
54
|
+
/** Story estimates and Task effort have different units and must stay separate. */
|
|
55
|
+
function teamItemMetrics(itemType, fields, inSprint, period) {
|
|
56
|
+
const start = teamDate(fields.DateSDate);
|
|
57
|
+
const end = teamDate(fields.qiwangwanchengriqi);
|
|
58
|
+
const hasDates = Boolean(start && end && end >= start);
|
|
59
|
+
const overlaps = hasDates && start <= period.end && end >= period.start;
|
|
60
|
+
if (itemType === "Task") {
|
|
61
|
+
if (!overlaps) return null;
|
|
62
|
+
const plannedDays = amount(fields.planStoryPoint);
|
|
63
|
+
const overlapDays = days(start < period.start ? period.start : start, end > period.end ? period.end : end);
|
|
64
|
+
return {
|
|
65
|
+
storyPoints: null,
|
|
66
|
+
plannedDays,
|
|
67
|
+
periodDays: plannedDays === null ? null : Number((plannedDays * overlapDays / days(start, end)).toFixed(4)),
|
|
68
|
+
plannedStart: start,
|
|
69
|
+
plannedEnd: end
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (!["Story", "EnablerStory"].includes(itemType) || (hasDates ? !overlaps : !inSprint)) return null;
|
|
73
|
+
return {
|
|
74
|
+
storyPoints: amount(fields.StoryPoint),
|
|
75
|
+
plannedDays: null,
|
|
76
|
+
periodDays: null,
|
|
77
|
+
plannedStart: start,
|
|
78
|
+
plannedEnd: end
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/pr-assistant.ts
|
|
83
|
+
const execFileAsync$1 = promisify(execFile);
|
|
84
|
+
const ROUTE$1 = "/api/tomato-board/pr/repository";
|
|
85
|
+
const DETAIL_ROUTE = "/api/tomato-board/pr/pull-request";
|
|
86
|
+
const COMMIT_ROUTE = "/api/tomato-board/pr/commit";
|
|
87
|
+
const CONFLICT_PREFLIGHT_ROUTE = "/api/tomato-board/pr/conflict/preflight";
|
|
88
|
+
const CONFLICT_STATUS_ROUTE = "/api/tomato-board/pr/conflict/status";
|
|
89
|
+
const CONFLICT_PUSH_ROUTE = "/api/tomato-board/pr/conflict/push";
|
|
90
|
+
const REVIEW_COMMENT_ROUTE = "/api/tomato-board/pr/review/comment";
|
|
91
|
+
const MAX_REVIEW_COMMENT_LENGTH = 2e4;
|
|
92
|
+
var ClientInputError = class extends Error {};
|
|
93
|
+
const conflictOperations = /* @__PURE__ */ new Map();
|
|
94
|
+
function normalizeGiteeConflictStatus(detail) {
|
|
95
|
+
if (detail.has_conflict === true || detail.has_conflicts === true || detail.conflicts === true) return "conflicting";
|
|
96
|
+
if (detail.has_conflict === false || detail.has_conflicts === false || detail.conflicts === false) return "mergeable";
|
|
97
|
+
const status = String(detail.merge_status || detail.detailed_merge_status || "").toLowerCase();
|
|
98
|
+
if ([
|
|
99
|
+
"cannot_be_merged",
|
|
100
|
+
"conflict",
|
|
101
|
+
"conflicts"
|
|
102
|
+
].includes(status)) return "conflicting";
|
|
103
|
+
if ([
|
|
104
|
+
"can_be_merged",
|
|
105
|
+
"mergeable",
|
|
106
|
+
"can_be_merged_recheck"
|
|
107
|
+
].includes(status)) return "mergeable";
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
function normalizeGithubConflictStatus(detail) {
|
|
111
|
+
if (detail.mergeable === true) return "mergeable";
|
|
112
|
+
if (detail.mergeable === false) return "conflicting";
|
|
113
|
+
return "unknown";
|
|
114
|
+
}
|
|
115
|
+
function sendJson$1(response, status, body) {
|
|
116
|
+
response.statusCode = status;
|
|
117
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
118
|
+
response.setHeader("cache-control", "no-store");
|
|
119
|
+
response.end(JSON.stringify(body));
|
|
120
|
+
}
|
|
121
|
+
function parseRemote(remote) {
|
|
122
|
+
const normalized = remote.trim().replace(/^git@([^:]+):/u, "https://$1/").replace(/\.git$/u, "");
|
|
123
|
+
const url = new URL(normalized);
|
|
124
|
+
const segments = url.pathname.replace(/^\//u, "").split("/").filter(Boolean);
|
|
125
|
+
const repository = segments.at(-1);
|
|
126
|
+
const owner = segments.at(-2);
|
|
127
|
+
const provider = url.hostname === "github.com" ? "github" : url.hostname.includes("gitee") ? "gitee" : null;
|
|
128
|
+
if (!provider || !owner || !repository) throw new Error(`暂不支持该 Git 远端:${url.hostname}`);
|
|
129
|
+
return {
|
|
130
|
+
provider,
|
|
131
|
+
owner,
|
|
132
|
+
repository,
|
|
133
|
+
url: `https://${url.hostname}/${owner}/${repository}`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function parseCliJson(stdout) {
|
|
137
|
+
const withoutAnsi = stdout.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/gu, "").trim();
|
|
138
|
+
const start = withoutAnsi.search(/[\[{]/u);
|
|
139
|
+
if (start < 0) throw new Error("Gitee CLI 未返回 JSON");
|
|
140
|
+
return JSON.parse(withoutAnsi.slice(start));
|
|
141
|
+
}
|
|
142
|
+
async function loadGiteePullRequests(path, config) {
|
|
143
|
+
const result = await execFileAsync$1(resolveCliExecutable(config.executable || "gitee"), [
|
|
144
|
+
"code",
|
|
145
|
+
"pr",
|
|
146
|
+
"list",
|
|
147
|
+
"--states",
|
|
148
|
+
"opened",
|
|
149
|
+
"--states",
|
|
150
|
+
"drafted",
|
|
151
|
+
"--limit",
|
|
152
|
+
"100",
|
|
153
|
+
"--json",
|
|
154
|
+
"--profile",
|
|
155
|
+
config.profile || "osc"
|
|
156
|
+
], {
|
|
157
|
+
cwd: path,
|
|
158
|
+
timeout: 3e4,
|
|
159
|
+
maxBuffer: 16 * 1024 * 1024
|
|
160
|
+
});
|
|
161
|
+
const payload = parseCliJson(result.stdout || result.stderr);
|
|
162
|
+
const record = payload && typeof payload === "object" ? payload : {};
|
|
163
|
+
const items = Array.isArray(record.data) ? record.data : Array.isArray(payload) ? payload : [];
|
|
164
|
+
const meta = record.meta && typeof record.meta === "object" ? record.meta : {};
|
|
165
|
+
const total = Number(meta.total_count);
|
|
166
|
+
return {
|
|
167
|
+
items,
|
|
168
|
+
total: Number.isFinite(total) ? total : items.length,
|
|
169
|
+
truncated: items.length < total
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function countDiffLines(diff) {
|
|
173
|
+
let additions = 0;
|
|
174
|
+
let deletions = 0;
|
|
175
|
+
for (const line of diff.split("\n")) {
|
|
176
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
|
|
177
|
+
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
additions,
|
|
181
|
+
deletions
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function normalizeGiteeFile(value) {
|
|
185
|
+
if (!value || typeof value !== "object") return null;
|
|
186
|
+
const file = value;
|
|
187
|
+
const path = String(file.new_path || file.old_path || "");
|
|
188
|
+
if (!path) return null;
|
|
189
|
+
const counts = countDiffLines(String(file.diff || ""));
|
|
190
|
+
return {
|
|
191
|
+
path,
|
|
192
|
+
previousPath: String(file.old_path || path),
|
|
193
|
+
...counts,
|
|
194
|
+
status: file.new_file === true ? "added" : file.deleted_file === true ? "deleted" : file.renamed_file === true ? "renamed" : "modified",
|
|
195
|
+
diff: String(file.diff || "")
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async function loadGiteeCommits(path, number, config) {
|
|
199
|
+
try {
|
|
200
|
+
const remote = await git(path, [
|
|
201
|
+
"remote",
|
|
202
|
+
"get-url",
|
|
203
|
+
"origin"
|
|
204
|
+
]);
|
|
205
|
+
const pathname = new URL(remote.replace(/^git@([^:]+):/u, "https://$1/")).pathname.replace(/^\//u, "").replace(/\.git$/u, "");
|
|
206
|
+
const project = encodeURIComponent(pathname);
|
|
207
|
+
const result = await execFileAsync$1(resolveCliExecutable(config.executable || "gitee"), [
|
|
208
|
+
"api",
|
|
209
|
+
"GET",
|
|
210
|
+
`/open/code/api/v8/projects/${project}/merge_requests/${number}/commits`,
|
|
211
|
+
"--profile",
|
|
212
|
+
config.profile || "osc",
|
|
213
|
+
"-o",
|
|
214
|
+
"json"
|
|
215
|
+
], {
|
|
216
|
+
cwd: path,
|
|
217
|
+
timeout: 3e4,
|
|
218
|
+
maxBuffer: 16 * 1024 * 1024
|
|
219
|
+
});
|
|
220
|
+
const payload = parseCliJson(result.stdout || result.stderr);
|
|
221
|
+
if (!Array.isArray(payload)) return null;
|
|
222
|
+
return payload.map((value) => {
|
|
223
|
+
const commit = value && typeof value === "object" ? value : {};
|
|
224
|
+
return {
|
|
225
|
+
sha: String(commit.id || ""),
|
|
226
|
+
title: String(commit.title || commit.message || "").split("\n")[0] || "",
|
|
227
|
+
author: String(commit.author_name || ""),
|
|
228
|
+
committedAt: String(commit.created_at || commit.committed_date || ""),
|
|
229
|
+
url: ""
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function loadGiteePullRequest(path, number, config) {
|
|
237
|
+
const executable = resolveCliExecutable(config.executable || "gitee");
|
|
238
|
+
const profile = config.profile || "osc";
|
|
239
|
+
const common = {
|
|
240
|
+
cwd: path,
|
|
241
|
+
timeout: 3e4,
|
|
242
|
+
maxBuffer: 32 * 1024 * 1024
|
|
243
|
+
};
|
|
244
|
+
const [detailResult, diffResult, commits] = await Promise.all([
|
|
245
|
+
execFileAsync$1(executable, [
|
|
246
|
+
"code",
|
|
247
|
+
"pr",
|
|
248
|
+
"view",
|
|
249
|
+
String(number),
|
|
250
|
+
"--json",
|
|
251
|
+
"--profile",
|
|
252
|
+
profile
|
|
253
|
+
], common),
|
|
254
|
+
execFileAsync$1(executable, [
|
|
255
|
+
"code",
|
|
256
|
+
"pr",
|
|
257
|
+
"diff",
|
|
258
|
+
String(number),
|
|
259
|
+
"--json",
|
|
260
|
+
"--profile",
|
|
261
|
+
profile
|
|
262
|
+
], common),
|
|
263
|
+
loadGiteeCommits(path, number, config)
|
|
264
|
+
]);
|
|
265
|
+
const detailValue = parseCliJson(detailResult.stdout || detailResult.stderr);
|
|
266
|
+
const detail = detailValue && typeof detailValue === "object" ? detailValue : {};
|
|
267
|
+
const diffValue = parseCliJson(diffResult.stdout || diffResult.stderr);
|
|
268
|
+
const files = (Array.isArray(diffValue) ? diffValue : []).map(normalizeGiteeFile).filter((file) => file !== null);
|
|
269
|
+
const authorValue = detail.author;
|
|
270
|
+
const author = authorValue && typeof authorValue === "object" ? authorValue : {};
|
|
271
|
+
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
|
272
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
273
|
+
const conflictStatus = normalizeGiteeConflictStatus(detail);
|
|
274
|
+
return {
|
|
275
|
+
number,
|
|
276
|
+
title: String(detail.title || ""),
|
|
277
|
+
description: String(detail.description || ""),
|
|
278
|
+
author: String(author.name || author.user_name || ""),
|
|
279
|
+
sourceBranch: String(detail.source_branch || ""),
|
|
280
|
+
targetBranch: String(detail.target_branch || ""),
|
|
281
|
+
url: String(detail.web_url || detail.html_url || ""),
|
|
282
|
+
updatedAt: String(detail.updated_at || ""),
|
|
283
|
+
commitCount: commits?.length ?? null,
|
|
284
|
+
commits: commits ?? [],
|
|
285
|
+
additions,
|
|
286
|
+
deletions,
|
|
287
|
+
changedFiles: files.length,
|
|
288
|
+
conflictStatus,
|
|
289
|
+
mergeStatus: String(detail.merge_status || detail.detailed_merge_status || ""),
|
|
290
|
+
files
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async function loadGithubPullRequest(identity, number, config) {
|
|
294
|
+
const base = `https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}`;
|
|
295
|
+
const [detailValue, filesValue, commitsValue] = await Promise.all([
|
|
296
|
+
requestJson(`${base}/pulls/${number}`, config.githubToken, "github"),
|
|
297
|
+
requestJsonPages(`${base}/pulls/${number}/files?per_page=100`, config.githubToken, "github", 30),
|
|
298
|
+
requestJsonPages(`${base}/pulls/${number}/commits?per_page=100`, config.githubToken, "github", 50)
|
|
299
|
+
]);
|
|
300
|
+
const detail = detailValue && typeof detailValue === "object" ? detailValue : {};
|
|
301
|
+
const authorValue = detail.user;
|
|
302
|
+
const author = authorValue && typeof authorValue === "object" ? authorValue : {};
|
|
303
|
+
const headValue = detail.head;
|
|
304
|
+
const head = headValue && typeof headValue === "object" ? headValue : {};
|
|
305
|
+
const baseValue = detail.base;
|
|
306
|
+
const target = baseValue && typeof baseValue === "object" ? baseValue : {};
|
|
307
|
+
const files = (Array.isArray(filesValue) ? filesValue : []).map((value) => {
|
|
308
|
+
const file = value && typeof value === "object" ? value : {};
|
|
309
|
+
return {
|
|
310
|
+
path: String(file.filename || ""),
|
|
311
|
+
previousPath: String(file.previous_filename || file.filename || ""),
|
|
312
|
+
additions: Number(file.additions || 0),
|
|
313
|
+
deletions: Number(file.deletions || 0),
|
|
314
|
+
status: [
|
|
315
|
+
"added",
|
|
316
|
+
"deleted",
|
|
317
|
+
"renamed"
|
|
318
|
+
].includes(String(file.status)) ? file.status : "modified",
|
|
319
|
+
diff: String(file.patch || "")
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
const commits = (Array.isArray(commitsValue) ? commitsValue : []).map((value) => {
|
|
323
|
+
const item = value && typeof value === "object" ? value : {};
|
|
324
|
+
const commitValue = item.commit && typeof item.commit === "object" ? item.commit : {};
|
|
325
|
+
const authorValue = commitValue.author && typeof commitValue.author === "object" ? commitValue.author : {};
|
|
326
|
+
const accountValue = item.author && typeof item.author === "object" ? item.author : {};
|
|
327
|
+
return {
|
|
328
|
+
sha: String(item.sha || ""),
|
|
329
|
+
title: String(commitValue.message || "").split("\n")[0] || "",
|
|
330
|
+
author: String(accountValue.login || authorValue.name || ""),
|
|
331
|
+
committedAt: String(authorValue.date || ""),
|
|
332
|
+
url: String(item.html_url || "")
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
const conflictStatus = normalizeGithubConflictStatus(detail);
|
|
336
|
+
return {
|
|
337
|
+
number,
|
|
338
|
+
title: String(detail.title || ""),
|
|
339
|
+
description: String(detail.body || ""),
|
|
340
|
+
author: String(author.login || ""),
|
|
341
|
+
sourceBranch: String(head.ref || ""),
|
|
342
|
+
targetBranch: String(target.ref || ""),
|
|
343
|
+
url: String(detail.html_url || ""),
|
|
344
|
+
updatedAt: String(detail.updated_at || ""),
|
|
345
|
+
commitCount: Number(detail.commits || 0),
|
|
346
|
+
commits,
|
|
347
|
+
additions: Number(detail.additions || 0),
|
|
348
|
+
deletions: Number(detail.deletions || 0),
|
|
349
|
+
changedFiles: Number(detail.changed_files || files.length),
|
|
350
|
+
conflictStatus,
|
|
351
|
+
mergeStatus: String(detail.mergeable_state || ""),
|
|
352
|
+
files
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
async function loadPullRequest(path, number, config) {
|
|
356
|
+
const identity = parseRemote(await git(path, [
|
|
357
|
+
"remote",
|
|
358
|
+
"get-url",
|
|
359
|
+
"origin"
|
|
360
|
+
]));
|
|
361
|
+
return identity.provider === "gitee" ? loadGiteePullRequest(path, number, config) : loadGithubPullRequest(identity, number, config);
|
|
362
|
+
}
|
|
363
|
+
async function loadCommit(path, sha, config) {
|
|
364
|
+
const remote = await git(path, [
|
|
365
|
+
"remote",
|
|
366
|
+
"get-url",
|
|
367
|
+
"origin"
|
|
368
|
+
]);
|
|
369
|
+
const identity = parseRemote(remote);
|
|
370
|
+
if (identity.provider === "gitee") {
|
|
371
|
+
const pathname = new URL(remote.replace(/^git@([^:]+):/u, "https://$1/")).pathname.replace(/^\//u, "").replace(/\.git$/u, "");
|
|
372
|
+
const project = encodeURIComponent(pathname);
|
|
373
|
+
const result = await execFileAsync$1(resolveCliExecutable(config.executable || "gitee"), [
|
|
374
|
+
"api",
|
|
375
|
+
"GET",
|
|
376
|
+
`/open/code/api/v8/projects/${project}/repository/commits/${encodeURIComponent(sha)}/diff`,
|
|
377
|
+
"--profile",
|
|
378
|
+
config.profile || "osc",
|
|
379
|
+
"-o",
|
|
380
|
+
"json"
|
|
381
|
+
], {
|
|
382
|
+
cwd: path,
|
|
383
|
+
timeout: 3e4,
|
|
384
|
+
maxBuffer: 32 * 1024 * 1024
|
|
385
|
+
});
|
|
386
|
+
const payload = parseCliJson(result.stdout || result.stderr);
|
|
387
|
+
return { files: (Array.isArray(payload) ? payload : []).map(normalizeGiteeFile).filter((file) => file !== null) };
|
|
388
|
+
}
|
|
389
|
+
const value = await requestJson(`https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}/commits/${encodeURIComponent(sha)}`, config.githubToken, "github");
|
|
390
|
+
const record = value && typeof value === "object" ? value : {};
|
|
391
|
+
return { files: (Array.isArray(record.files) ? record.files : []).map((value) => {
|
|
392
|
+
const file = value && typeof value === "object" ? value : {};
|
|
393
|
+
return {
|
|
394
|
+
path: String(file.filename || ""),
|
|
395
|
+
previousPath: String(file.previous_filename || file.filename || ""),
|
|
396
|
+
additions: Number(file.additions || 0),
|
|
397
|
+
deletions: Number(file.deletions || 0),
|
|
398
|
+
status: [
|
|
399
|
+
"added",
|
|
400
|
+
"deleted",
|
|
401
|
+
"renamed"
|
|
402
|
+
].includes(String(file.status)) ? file.status : "modified",
|
|
403
|
+
diff: String(file.patch || "")
|
|
404
|
+
};
|
|
405
|
+
}) };
|
|
406
|
+
}
|
|
407
|
+
async function git(cwd, args) {
|
|
408
|
+
return (await execFileAsync$1("git", [
|
|
409
|
+
"-C",
|
|
410
|
+
cwd,
|
|
411
|
+
...args
|
|
412
|
+
], {
|
|
413
|
+
timeout: 3e4,
|
|
414
|
+
maxBuffer: 16 * 1024 * 1024
|
|
415
|
+
})).stdout.trim();
|
|
416
|
+
}
|
|
417
|
+
async function gitExit(cwd, args) {
|
|
418
|
+
try {
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
output: await git(cwd, args)
|
|
422
|
+
};
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const value = error;
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
output: String(value.stdout || value.stderr || "").trim()
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function requireBranchName(value, label) {
|
|
432
|
+
if (!value || value.startsWith("-") || value.length > 255) throw new ClientInputError(`${label}无效`);
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
async function canonicalRepository(path, config) {
|
|
436
|
+
const resolved = await validateRepositoryPath(path, config);
|
|
437
|
+
const root = await realpath(await git(resolved, ["rev-parse", "--show-toplevel"]));
|
|
438
|
+
if (root !== resolved) throw new ClientInputError("请选择 Git 仓库根目录,不能使用仓库子目录");
|
|
439
|
+
return root;
|
|
440
|
+
}
|
|
441
|
+
async function readJsonBody(request) {
|
|
442
|
+
if (String(request.headers["content-type"] || "").split(";", 1)[0]?.trim().toLowerCase() !== "application/json") throw new ClientInputError("Content-Type 必须为 application/json");
|
|
443
|
+
const chunks = [];
|
|
444
|
+
let size = 0;
|
|
445
|
+
await new Promise((resolve, reject) => {
|
|
446
|
+
request.on("data", (chunk) => {
|
|
447
|
+
size += chunk.byteLength;
|
|
448
|
+
if (size > 64 * 1024) reject(new ClientInputError("请求体过大"));
|
|
449
|
+
else chunks.push(chunk);
|
|
450
|
+
});
|
|
451
|
+
request.on("end", () => resolve());
|
|
452
|
+
request.on("error", (error) => reject(error || /* @__PURE__ */ new Error("请求读取失败")));
|
|
453
|
+
});
|
|
454
|
+
try {
|
|
455
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
456
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
457
|
+
return value;
|
|
458
|
+
} catch (error) {
|
|
459
|
+
if (error instanceof ClientInputError) throw error;
|
|
460
|
+
throw new ClientInputError("请求体必须是 JSON 对象");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function assertMutationRequest(request) {
|
|
464
|
+
if (request.method !== "POST") throw new ClientInputError("Method not allowed");
|
|
465
|
+
if (request.headers["x-pr-assistant-action"] !== "1") throw new ClientInputError("缺少操作确认标头");
|
|
466
|
+
const site = request.headers["sec-fetch-site"];
|
|
467
|
+
if (site && site !== "same-origin" && site !== "none") throw new ClientInputError("拒绝跨站请求");
|
|
468
|
+
const origin = request.headers.origin;
|
|
469
|
+
const host = request.headers.host;
|
|
470
|
+
if (origin && host && new URL(String(origin)).host !== String(host)) throw new ClientInputError("拒绝跨站请求");
|
|
471
|
+
}
|
|
472
|
+
async function branchSha(path, branch) {
|
|
473
|
+
requireBranchName(branch, "分支名");
|
|
474
|
+
if (!(await gitExit(path, [
|
|
475
|
+
"check-ref-format",
|
|
476
|
+
"--branch",
|
|
477
|
+
branch
|
|
478
|
+
])).ok) throw new ClientInputError("PR 分支名无效");
|
|
479
|
+
return git(path, [
|
|
480
|
+
"rev-parse",
|
|
481
|
+
"--verify",
|
|
482
|
+
`refs/remotes/origin/${branch}^{commit}`
|
|
483
|
+
]);
|
|
484
|
+
}
|
|
485
|
+
async function refreshBranches(path, sourceBranch, targetBranch) {
|
|
486
|
+
requireBranchName(sourceBranch, "源分支");
|
|
487
|
+
requireBranchName(targetBranch, "目标分支");
|
|
488
|
+
for (const branch of new Set([sourceBranch, targetBranch])) if (!(await gitExit(path, [
|
|
489
|
+
"check-ref-format",
|
|
490
|
+
"--branch",
|
|
491
|
+
branch
|
|
492
|
+
])).ok) throw new ClientInputError(`PR 分支名无效:${branch}`);
|
|
493
|
+
await git(path, [
|
|
494
|
+
"fetch",
|
|
495
|
+
"--no-tags",
|
|
496
|
+
"origin",
|
|
497
|
+
`+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}`,
|
|
498
|
+
`+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`
|
|
499
|
+
]);
|
|
500
|
+
return {
|
|
501
|
+
sourceSha: await branchSha(path, sourceBranch),
|
|
502
|
+
targetSha: await branchSha(path, targetBranch)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async function requestJson(url, token, provider) {
|
|
506
|
+
const headers = {
|
|
507
|
+
accept: "application/json",
|
|
508
|
+
"user-agent": "dsh-pr-assistant"
|
|
509
|
+
};
|
|
510
|
+
if (token) headers.authorization = provider === "github" ? `Bearer ${token}` : `token ${token}`;
|
|
511
|
+
const response = await fetch(url, {
|
|
512
|
+
headers,
|
|
513
|
+
signal: AbortSignal.timeout(15e3)
|
|
514
|
+
});
|
|
515
|
+
const body = await response.json();
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
const message = body && typeof body === "object" && "message" in body ? String(body.message) : `HTTP ${response.status}`;
|
|
518
|
+
throw new Error(message);
|
|
519
|
+
}
|
|
520
|
+
return body;
|
|
521
|
+
}
|
|
522
|
+
async function requestJsonPages(endpoint, token, provider, maxPages) {
|
|
523
|
+
const items = [];
|
|
524
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
525
|
+
const body = await requestJson(`${endpoint}${endpoint.includes("?") ? "&" : "?"}page=${page}`, token, provider);
|
|
526
|
+
if (!Array.isArray(body)) throw new Error("代码平台返回了无法识别的分页数据");
|
|
527
|
+
items.push(...body);
|
|
528
|
+
if (body.length < 100) break;
|
|
529
|
+
}
|
|
530
|
+
return items;
|
|
531
|
+
}
|
|
532
|
+
async function validateRepositoryPath(path, config) {
|
|
533
|
+
if (!isAbsolute(path)) throw new ClientInputError("仓库路径必须是绝对路径");
|
|
534
|
+
const resolved = await realpath(path);
|
|
535
|
+
if (!config.allowedRoots?.length) return resolved;
|
|
536
|
+
if (!(await Promise.all(config.allowedRoots.map((root) => realpath(root)))).some((root) => {
|
|
537
|
+
const child = relative(root, resolved);
|
|
538
|
+
return child === "" || !child.startsWith("..") && !isAbsolute(child);
|
|
539
|
+
})) throw new ClientInputError("仓库路径不在 allowedRoots 允许范围内");
|
|
540
|
+
return resolved;
|
|
541
|
+
}
|
|
542
|
+
async function loadPullRequests(endpoint, token, provider) {
|
|
543
|
+
const items = [];
|
|
544
|
+
for (let page = 1; page <= 50; page += 1) {
|
|
545
|
+
const body = await requestJson(`${endpoint}${endpoint.includes("?") ? "&" : "?"}page=${page}`, token, provider);
|
|
546
|
+
if (!Array.isArray(body)) throw new Error("代码平台返回了无法识别的 PR 数据");
|
|
547
|
+
items.push(...body);
|
|
548
|
+
if (body.length < 100) return {
|
|
549
|
+
items,
|
|
550
|
+
truncated: false
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
items,
|
|
555
|
+
truncated: true
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
async function loadRepository(path, config) {
|
|
559
|
+
const [root, remote] = await Promise.all([git(path, ["rev-parse", "--show-toplevel"]), git(path, [
|
|
560
|
+
"remote",
|
|
561
|
+
"get-url",
|
|
562
|
+
"origin"
|
|
563
|
+
])]);
|
|
564
|
+
const identity = parseRemote(remote);
|
|
565
|
+
const loaded = identity.provider === "gitee" ? await loadGiteePullRequests(path, config) : await loadPullRequests(`https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}/pulls?state=open&per_page=100`, config.githubToken, identity.provider).then((result) => ({
|
|
566
|
+
...result,
|
|
567
|
+
total: result.items.length
|
|
568
|
+
}));
|
|
569
|
+
const { items, truncated } = loaded;
|
|
570
|
+
return {
|
|
571
|
+
...identity,
|
|
572
|
+
root,
|
|
573
|
+
openCount: loaded.total,
|
|
574
|
+
truncated,
|
|
575
|
+
pullRequests: items.map((value) => {
|
|
576
|
+
const item = value && typeof value === "object" ? value : {};
|
|
577
|
+
const authorValue = item.user ?? item.author;
|
|
578
|
+
const author = authorValue && typeof authorValue === "object" ? authorValue : {};
|
|
579
|
+
const headValue = item.head;
|
|
580
|
+
const head = headValue && typeof headValue === "object" ? headValue : {};
|
|
581
|
+
const baseValue = item.base;
|
|
582
|
+
const base = baseValue && typeof baseValue === "object" ? baseValue : {};
|
|
583
|
+
return {
|
|
584
|
+
number: Number(item.number || item.iid || item.id || 0),
|
|
585
|
+
title: String(item.title ?? ""),
|
|
586
|
+
author: String(author.login ?? author.name ?? ""),
|
|
587
|
+
updatedAt: String(item.updated_at ?? ""),
|
|
588
|
+
url: String(item.html_url || item.web_url || item.url || ""),
|
|
589
|
+
draft: item.draft === true || item.state === "drafted",
|
|
590
|
+
conflictStatus: identity.provider === "gitee" ? normalizeGiteeConflictStatus(item) : "unknown",
|
|
591
|
+
sourceBranch: String(item.source_branch || head.ref || ""),
|
|
592
|
+
targetBranch: String(item.target_branch || base.ref || "")
|
|
593
|
+
};
|
|
594
|
+
})
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
async function publishReviewComment(path, number, body, config) {
|
|
598
|
+
const identity = parseRemote(await git(path, [
|
|
599
|
+
"remote",
|
|
600
|
+
"get-url",
|
|
601
|
+
"origin"
|
|
602
|
+
]));
|
|
603
|
+
await loadPullRequest(path, number, config);
|
|
604
|
+
if (identity.provider === "gitee") {
|
|
605
|
+
const result = await execFileAsync$1(resolveCliExecutable(config.executable || "gitee"), [
|
|
606
|
+
"code",
|
|
607
|
+
"pr",
|
|
608
|
+
"comment",
|
|
609
|
+
String(number),
|
|
610
|
+
"--body",
|
|
611
|
+
body,
|
|
612
|
+
"--repo",
|
|
613
|
+
`${identity.owner}/${identity.repository}`,
|
|
614
|
+
"--profile",
|
|
615
|
+
config.profile || "osc"
|
|
616
|
+
], {
|
|
617
|
+
cwd: path,
|
|
618
|
+
timeout: 3e4,
|
|
619
|
+
maxBuffer: 4 * 1024 * 1024
|
|
620
|
+
});
|
|
621
|
+
return {
|
|
622
|
+
provider: identity.provider,
|
|
623
|
+
output: String(result.stdout || result.stderr).trim()
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (!config.githubToken) throw new ClientInputError("GitHub PR 评论需要配置 githubToken,当前无法授权发布");
|
|
627
|
+
const endpoint = `https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}/issues/${number}/comments`;
|
|
628
|
+
const response = await fetch(endpoint, {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: {
|
|
631
|
+
accept: "application/vnd.github+json",
|
|
632
|
+
authorization: `Bearer ${config.githubToken}`,
|
|
633
|
+
"content-type": "application/json",
|
|
634
|
+
"user-agent": "dsh-pr-assistant",
|
|
635
|
+
"x-github-api-version": "2022-11-28"
|
|
636
|
+
},
|
|
637
|
+
body: JSON.stringify({ body }),
|
|
638
|
+
signal: AbortSignal.timeout(15e3)
|
|
639
|
+
});
|
|
640
|
+
const value = await response.json();
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
if (response.status === 401 || response.status === 403) throw new ClientInputError("GitHub 评论无法授权:githubToken 无效或缺少 Issues/Pull requests 评论写权限");
|
|
643
|
+
throw new ClientInputError(`GitHub 评论发布失败:${String(value.message || `HTTP ${response.status}`)}`);
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
provider: identity.provider,
|
|
647
|
+
url: String(value.html_url || "")
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
async function conflictStatus(operation) {
|
|
651
|
+
const currentBranch = await git(operation.path, ["branch", "--show-current"]);
|
|
652
|
+
const headSha = await git(operation.path, ["rev-parse", "HEAD"]);
|
|
653
|
+
const porcelain = await git(operation.path, ["status", "--porcelain=v1"]);
|
|
654
|
+
const unresolved = (await gitExit(operation.path, [
|
|
655
|
+
"diff",
|
|
656
|
+
"--name-only",
|
|
657
|
+
"--diff-filter=U"
|
|
658
|
+
])).output.split("\n").filter(Boolean);
|
|
659
|
+
const sourceAncestor = (await gitExit(operation.path, [
|
|
660
|
+
"merge-base",
|
|
661
|
+
"--is-ancestor",
|
|
662
|
+
operation.sourceSha,
|
|
663
|
+
headSha
|
|
664
|
+
])).ok;
|
|
665
|
+
const targetMerged = (await gitExit(operation.path, [
|
|
666
|
+
"merge-base",
|
|
667
|
+
"--is-ancestor",
|
|
668
|
+
operation.targetSha,
|
|
669
|
+
headSha
|
|
670
|
+
])).ok;
|
|
671
|
+
const changed = await git(operation.path, [
|
|
672
|
+
"diff",
|
|
673
|
+
"--name-status",
|
|
674
|
+
`${operation.sourceSha}..${headSha}`
|
|
675
|
+
]);
|
|
676
|
+
const diff = await git(operation.path, [
|
|
677
|
+
"diff",
|
|
678
|
+
"--no-ext-diff",
|
|
679
|
+
"--unified=3",
|
|
680
|
+
`${operation.sourceSha}..${headSha}`
|
|
681
|
+
]);
|
|
682
|
+
const commits = await git(operation.path, [
|
|
683
|
+
"log",
|
|
684
|
+
"--format=%H%x09%s",
|
|
685
|
+
`${operation.sourceSha}..${headSha}`
|
|
686
|
+
]);
|
|
687
|
+
const clean = porcelain === "";
|
|
688
|
+
const hasCommit = headSha !== operation.sourceSha && sourceAncestor;
|
|
689
|
+
const pushReady = currentBranch === operation.sourceBranch && clean && unresolved.length === 0 && hasCommit && targetMerged;
|
|
690
|
+
return {
|
|
691
|
+
operationId: operation.id,
|
|
692
|
+
number: operation.number,
|
|
693
|
+
sourceBranch: operation.sourceBranch,
|
|
694
|
+
targetBranch: operation.targetBranch,
|
|
695
|
+
sourceSha: operation.sourceSha,
|
|
696
|
+
targetSha: operation.targetSha,
|
|
697
|
+
currentBranch,
|
|
698
|
+
headSha,
|
|
699
|
+
clean,
|
|
700
|
+
unresolved,
|
|
701
|
+
sourceAncestor,
|
|
702
|
+
targetMerged,
|
|
703
|
+
hasCommit,
|
|
704
|
+
pushReady,
|
|
705
|
+
changedFiles: changed ? changed.split("\n").map((line) => {
|
|
706
|
+
const [status, ...parts] = line.split(" ");
|
|
707
|
+
return {
|
|
708
|
+
status,
|
|
709
|
+
path: parts.at(-1) || ""
|
|
710
|
+
};
|
|
711
|
+
}) : [],
|
|
712
|
+
commits: commits ? commits.split("\n").map((line) => {
|
|
713
|
+
const [sha, ...title] = line.split(" ");
|
|
714
|
+
return {
|
|
715
|
+
sha,
|
|
716
|
+
title: title.join(" ")
|
|
717
|
+
};
|
|
718
|
+
}) : [],
|
|
719
|
+
diff: diff.slice(0, 512 * 1024),
|
|
720
|
+
diffTruncated: diff.length > 512 * 1024
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function registerPrAssistant(ctx, config = {}) {
|
|
724
|
+
const webServer = ctx.get("webServer");
|
|
725
|
+
ctx.effect(() => webServer.register({
|
|
726
|
+
kind: "exact",
|
|
727
|
+
path: ROUTE$1,
|
|
728
|
+
async handler(request, response) {
|
|
729
|
+
if (request.method !== "GET") return sendJson$1(response, 405, { error: "Method not allowed" });
|
|
730
|
+
const requestedPath = new URL(request.url ?? ROUTE$1, "http://localhost").searchParams.get("path")?.trim();
|
|
731
|
+
if (!requestedPath) return sendJson$1(response, 400, { error: "缺少仓库路径" });
|
|
732
|
+
try {
|
|
733
|
+
sendJson$1(response, 200, await loadRepository(await validateRepositoryPath(requestedPath, config), config));
|
|
734
|
+
} catch (error) {
|
|
735
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "PR 读取失败" });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}), "pr-assistant: repository route");
|
|
739
|
+
ctx.effect(() => webServer.register({
|
|
740
|
+
kind: "exact",
|
|
741
|
+
path: DETAIL_ROUTE,
|
|
742
|
+
async handler(request, response) {
|
|
743
|
+
if (request.method !== "GET") return sendJson$1(response, 405, { error: "Method not allowed" });
|
|
744
|
+
const url = new URL(request.url ?? DETAIL_ROUTE, "http://localhost");
|
|
745
|
+
const requestedPath = url.searchParams.get("path")?.trim();
|
|
746
|
+
const number = Number(url.searchParams.get("number"));
|
|
747
|
+
if (!requestedPath || !Number.isInteger(number) || number <= 0) return sendJson$1(response, 400, { error: "无效的 PR 详情请求" });
|
|
748
|
+
try {
|
|
749
|
+
sendJson$1(response, 200, await loadPullRequest(await validateRepositoryPath(requestedPath, config), number, config));
|
|
750
|
+
} catch (error) {
|
|
751
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "PR 详情读取失败" });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}), "pr-assistant: pull request detail route");
|
|
755
|
+
ctx.effect(() => webServer.register({
|
|
756
|
+
kind: "exact",
|
|
757
|
+
path: COMMIT_ROUTE,
|
|
758
|
+
async handler(request, response) {
|
|
759
|
+
if (request.method !== "GET") return sendJson$1(response, 405, { error: "Method not allowed" });
|
|
760
|
+
const url = new URL(request.url ?? COMMIT_ROUTE, "http://localhost");
|
|
761
|
+
const requestedPath = url.searchParams.get("path")?.trim();
|
|
762
|
+
const sha = url.searchParams.get("sha")?.trim();
|
|
763
|
+
if (!requestedPath || !sha || !/^[a-f0-9]{7,64}$/iu.test(sha)) return sendJson$1(response, 400, { error: "无效的提交详情请求" });
|
|
764
|
+
try {
|
|
765
|
+
sendJson$1(response, 200, await loadCommit(await validateRepositoryPath(requestedPath, config), sha, config));
|
|
766
|
+
} catch (error) {
|
|
767
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "提交详情读取失败" });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}), "pr-assistant: commit detail route");
|
|
771
|
+
ctx.effect(() => webServer.register({
|
|
772
|
+
kind: "exact",
|
|
773
|
+
path: REVIEW_COMMENT_ROUTE,
|
|
774
|
+
async handler(request, response) {
|
|
775
|
+
try {
|
|
776
|
+
assertMutationRequest(request);
|
|
777
|
+
const body = await readJsonBody(request);
|
|
778
|
+
const requestedPath = typeof body.path === "string" ? body.path.trim() : "";
|
|
779
|
+
const number = Number(body.number);
|
|
780
|
+
const comment = typeof body.body === "string" ? body.body.trim() : "";
|
|
781
|
+
if (!requestedPath || !Number.isInteger(number) || number <= 0) throw new ClientInputError("无效的 PR 评论请求");
|
|
782
|
+
if (!comment) throw new ClientInputError("评论正文不能为空");
|
|
783
|
+
if (comment.length > MAX_REVIEW_COMMENT_LENGTH) throw new ClientInputError(`评论正文不能超过 ${MAX_REVIEW_COMMENT_LENGTH} 个字符`);
|
|
784
|
+
sendJson$1(response, 200, {
|
|
785
|
+
published: true,
|
|
786
|
+
...await publishReviewComment(await canonicalRepository(requestedPath, config), number, comment, config)
|
|
787
|
+
});
|
|
788
|
+
} catch (error) {
|
|
789
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "PR 评论发布失败" });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}), "pr-assistant: review comment route");
|
|
793
|
+
ctx.effect(() => webServer.register({
|
|
794
|
+
kind: "exact",
|
|
795
|
+
path: CONFLICT_PREFLIGHT_ROUTE,
|
|
796
|
+
async handler(request, response) {
|
|
797
|
+
try {
|
|
798
|
+
assertMutationRequest(request);
|
|
799
|
+
const body = await readJsonBody(request);
|
|
800
|
+
const requestedPath = typeof body.path === "string" ? body.path.trim() : "";
|
|
801
|
+
const number = Number(body.number);
|
|
802
|
+
if (!requestedPath || !Number.isInteger(number) || number <= 0) throw new ClientInputError("无效的冲突处理请求");
|
|
803
|
+
const path = await canonicalRepository(requestedPath, config);
|
|
804
|
+
const detail = await loadPullRequest(path, number, config);
|
|
805
|
+
if (detail.conflictStatus !== "conflicting") throw new ClientInputError("该 PR 当前未确认存在冲突");
|
|
806
|
+
const sourceBranch = requireBranchName(detail.sourceBranch, "源分支");
|
|
807
|
+
const targetBranch = requireBranchName(detail.targetBranch, "目标分支");
|
|
808
|
+
if (await git(path, ["status", "--porcelain=v1"])) throw new ClientInputError("工作区存在未提交修改,请先提交或暂存后再处理冲突");
|
|
809
|
+
const { sourceSha, targetSha } = await refreshBranches(path, sourceBranch, targetBranch);
|
|
810
|
+
let currentBranch = await git(path, ["branch", "--show-current"]);
|
|
811
|
+
if (currentBranch !== sourceBranch) {
|
|
812
|
+
await git(path, (await gitExit(path, [
|
|
813
|
+
"show-ref",
|
|
814
|
+
"--verify",
|
|
815
|
+
"--quiet",
|
|
816
|
+
`refs/heads/${sourceBranch}`
|
|
817
|
+
])).ok ? ["switch", sourceBranch] : [
|
|
818
|
+
"switch",
|
|
819
|
+
"--track",
|
|
820
|
+
"-c",
|
|
821
|
+
sourceBranch,
|
|
822
|
+
`origin/${sourceBranch}`
|
|
823
|
+
]);
|
|
824
|
+
currentBranch = await git(path, ["branch", "--show-current"]);
|
|
825
|
+
}
|
|
826
|
+
if (await git(path, ["rev-parse", "HEAD"]) !== sourceSha) throw new ClientInputError("本地源分支与远端不一致,请先同步后重试");
|
|
827
|
+
const operation = {
|
|
828
|
+
id: randomUUID(),
|
|
829
|
+
path,
|
|
830
|
+
number,
|
|
831
|
+
sourceBranch,
|
|
832
|
+
targetBranch,
|
|
833
|
+
sourceSha,
|
|
834
|
+
targetSha,
|
|
835
|
+
createdAt: Date.now()
|
|
836
|
+
};
|
|
837
|
+
conflictOperations.set(operation.id, operation);
|
|
838
|
+
sendJson$1(response, 200, {
|
|
839
|
+
operationId: operation.id,
|
|
840
|
+
path,
|
|
841
|
+
number,
|
|
842
|
+
sourceBranch,
|
|
843
|
+
targetBranch,
|
|
844
|
+
sourceSha,
|
|
845
|
+
targetSha,
|
|
846
|
+
currentBranch
|
|
847
|
+
});
|
|
848
|
+
} catch (error) {
|
|
849
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "冲突处理预检失败" });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}), "pr-assistant: conflict preflight route");
|
|
853
|
+
ctx.effect(() => webServer.register({
|
|
854
|
+
kind: "exact",
|
|
855
|
+
path: CONFLICT_STATUS_ROUTE,
|
|
856
|
+
async handler(request, response) {
|
|
857
|
+
if (request.method !== "GET") return sendJson$1(response, 405, { error: "Method not allowed" });
|
|
858
|
+
const id = new URL(request.url ?? CONFLICT_STATUS_ROUTE, "http://localhost").searchParams.get("operationId")?.trim();
|
|
859
|
+
const operation = id ? conflictOperations.get(id) : void 0;
|
|
860
|
+
if (!operation || Date.now() - operation.createdAt > 1440 * 60 * 1e3) return sendJson$1(response, 404, { error: "冲突处理记录不存在或已过期" });
|
|
861
|
+
try {
|
|
862
|
+
sendJson$1(response, 200, await conflictStatus(operation));
|
|
863
|
+
} catch (error) {
|
|
864
|
+
sendJson$1(response, 502, { error: error instanceof Error ? error.message : "冲突处理状态读取失败" });
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}), "pr-assistant: conflict status route");
|
|
868
|
+
ctx.effect(() => webServer.register({
|
|
869
|
+
kind: "exact",
|
|
870
|
+
path: CONFLICT_PUSH_ROUTE,
|
|
871
|
+
async handler(request, response) {
|
|
872
|
+
try {
|
|
873
|
+
assertMutationRequest(request);
|
|
874
|
+
const body = await readJsonBody(request);
|
|
875
|
+
const id = typeof body.operationId === "string" ? body.operationId : "";
|
|
876
|
+
const expectedHead = typeof body.expectedHead === "string" ? body.expectedHead : "";
|
|
877
|
+
if (!/^[a-f0-9]{40,64}$/iu.test(expectedHead)) throw new ClientInputError("无效的提交版本");
|
|
878
|
+
const operation = conflictOperations.get(id);
|
|
879
|
+
if (!operation || Date.now() - operation.createdAt > 1440 * 60 * 1e3) throw new ClientInputError("冲突处理记录不存在或已过期");
|
|
880
|
+
const status = await conflictStatus(operation);
|
|
881
|
+
if (!status.pushReady || status.headSha !== expectedHead) throw new ClientInputError("仓库状态已变化,当前结果不允许推送");
|
|
882
|
+
await git(operation.path, [
|
|
883
|
+
"fetch",
|
|
884
|
+
"--no-tags",
|
|
885
|
+
"origin",
|
|
886
|
+
`+refs/heads/${operation.sourceBranch}:refs/remotes/origin/${operation.sourceBranch}`
|
|
887
|
+
]);
|
|
888
|
+
if (await branchSha(operation.path, operation.sourceBranch) !== operation.sourceSha) throw new ClientInputError("远端源分支已更新,请重新开始冲突处理");
|
|
889
|
+
await git(operation.path, [
|
|
890
|
+
"push",
|
|
891
|
+
"origin",
|
|
892
|
+
`HEAD:refs/heads/${operation.sourceBranch}`
|
|
893
|
+
]);
|
|
894
|
+
sendJson$1(response, 200, {
|
|
895
|
+
pushed: true,
|
|
896
|
+
sha: await git(operation.path, ["rev-parse", "HEAD"]),
|
|
897
|
+
sourceBranch: operation.sourceBranch
|
|
898
|
+
});
|
|
899
|
+
} catch (error) {
|
|
900
|
+
sendJson$1(response, error instanceof ClientInputError ? 400 : 502, { error: error instanceof Error ? error.message : "推送失败" });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}), "pr-assistant: conflict push route");
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/task-types.ts
|
|
907
|
+
/** Work items supported by the scheduling/reporting tools. Creation remains Task. */
|
|
908
|
+
const scheduledTaskTypes = ["Task", "跟踪任务"];
|
|
909
|
+
const scheduledTaskIql = `类型 in ${JSON.stringify(scheduledTaskTypes)}`;
|
|
910
|
+
function isScheduledTask(type) {
|
|
911
|
+
return scheduledTaskTypes.includes(type);
|
|
912
|
+
}
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/worktime-model.ts
|
|
915
|
+
/** A Task may cover several ranges even though its record belongs to the first. */
|
|
916
|
+
function unitHasCreatedTasks(unit, units) {
|
|
917
|
+
return units.some((owner) => owner.itemKey === unit.itemKey && owner.tasks.some((task) => task.status === "created" && task.start <= unit.end && task.end >= unit.start));
|
|
918
|
+
}
|
|
919
|
+
/** Join touching ranges without changing any Task dates or creation records. */
|
|
920
|
+
function mergeRequirementRanges(units, itemKey, preserveAllocated = false) {
|
|
921
|
+
const group = units.filter((unit) => unit.itemKey === itemKey).sort((a, b) => a.start.localeCompare(b.start) || a.id.localeCompare(b.id));
|
|
922
|
+
const merged = [];
|
|
923
|
+
for (const unit of group) {
|
|
924
|
+
const previous = merged.at(-1);
|
|
925
|
+
const locked = preserveAllocated && previous && (unitHasCreatedTasks(unit, units) || unitHasCreatedTasks(previous, units) || unit.tasks.some((task) => task.status !== "pending") || previous.tasks.some((task) => task.status !== "pending"));
|
|
926
|
+
if (previous && !locked && unit.start <= addDays(previous.end, 1)) merged[merged.length - 1] = {
|
|
927
|
+
...previous,
|
|
928
|
+
end: previous.end > unit.end ? previous.end : unit.end,
|
|
929
|
+
tasks: [...previous.tasks, ...unit.tasks],
|
|
930
|
+
revision: Math.max(previous.revision, unit.revision) + 1,
|
|
931
|
+
updatedAt: previous.updatedAt > unit.updatedAt ? previous.updatedAt : unit.updatedAt,
|
|
932
|
+
assignees: [...new Set([...previous.assignees ?? [], ...unit.assignees ?? []])],
|
|
933
|
+
cost: previous.cost || unit.cost || "",
|
|
934
|
+
team: previous.team || unit.team || "",
|
|
935
|
+
category: previous.category || unit.category || ""
|
|
936
|
+
};
|
|
937
|
+
else merged.push(unit);
|
|
938
|
+
}
|
|
939
|
+
const survivors = new Map(merged.map((unit) => [unit.id, unit]));
|
|
940
|
+
return units.flatMap((unit) => unit.itemKey !== itemKey ? [unit] : survivors.has(unit.id) ? [survivors.get(unit.id)] : []);
|
|
941
|
+
}
|
|
942
|
+
/** Change one range, then merge its continuous neighbours; Task dates stay unchanged. */
|
|
943
|
+
function applyRequirementChange(units, change) {
|
|
944
|
+
if (!change) return units;
|
|
945
|
+
const active = units.find((unit) => unit.id === change.id);
|
|
946
|
+
if (!active) return units;
|
|
947
|
+
return mergeRequirementRanges(units.map((unit) => {
|
|
948
|
+
if (change.action === "resize") return unit.id === change.id ? {
|
|
949
|
+
...unit,
|
|
950
|
+
start: change.start,
|
|
951
|
+
end: change.end
|
|
952
|
+
} : unit;
|
|
953
|
+
if (unit.id !== change.id) return unit;
|
|
954
|
+
return {
|
|
955
|
+
...unit,
|
|
956
|
+
start: addDays(unit.start, change.delta),
|
|
957
|
+
end: addDays(unit.end, change.delta)
|
|
958
|
+
};
|
|
959
|
+
}), active.itemKey);
|
|
960
|
+
}
|
|
961
|
+
/** Format a browser-local calendar date without UTC conversion. */
|
|
962
|
+
function dateKey(date) {
|
|
963
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
964
|
+
}
|
|
965
|
+
/** Add calendar days using local noon to avoid daylight-saving midnight gaps. */
|
|
966
|
+
function addDays(value, days) {
|
|
967
|
+
const date = /* @__PURE__ */ new Date(`${value}T12:00:00`);
|
|
968
|
+
date.setDate(date.getDate() + days);
|
|
969
|
+
return dateKey(date);
|
|
970
|
+
}
|
|
971
|
+
/** Inclusive, bounded dates; rejects invalid or reversed selections. */
|
|
972
|
+
function datesBetween(start, end) {
|
|
973
|
+
const valid = (s) => /^\d{4}-\d{2}-\d{2}$/u.test(s) && dateKey(/* @__PURE__ */ new Date(`${s}T12:00:00`)) === s;
|
|
974
|
+
if (!valid(start) || !valid(end) || end < start) throw new Error("请选择有效的连续日期");
|
|
975
|
+
const dates = [];
|
|
976
|
+
for (let day = start; day <= end; day = addDays(day, 1)) {
|
|
977
|
+
if (dates.length >= 366) throw new Error("一次安排最多 366 天");
|
|
978
|
+
dates.push(day);
|
|
979
|
+
}
|
|
980
|
+
return dates;
|
|
981
|
+
}
|
|
982
|
+
/** Every planned date of one requirement, de-duplicated and chronological. */
|
|
983
|
+
function unitDates(units) {
|
|
984
|
+
const dates = /* @__PURE__ */ new Set();
|
|
985
|
+
for (const unit of units) for (const day of datesBetween(unit.start, unit.end)) dates.add(day);
|
|
986
|
+
return [...dates].sort();
|
|
987
|
+
}
|
|
988
|
+
/** Recover old allocations once, and retain exact half-day dates for subsequent batches. */
|
|
989
|
+
function plannedTaskSlots(dates, tasks) {
|
|
990
|
+
const used = /* @__PURE__ */ new Map();
|
|
991
|
+
const seen = /* @__PURE__ */ new Set();
|
|
992
|
+
const allocations = /* @__PURE__ */ new Map();
|
|
993
|
+
const confirmed = tasks.filter((task) => task.status === "created").sort((a, b) => a.end.localeCompare(b.end) || b.start.localeCompare(a.start));
|
|
994
|
+
const reserve = (day) => used.set(day, (used.get(day) ?? 0) + 1);
|
|
995
|
+
for (const task of confirmed) {
|
|
996
|
+
if (task.itemKey && seen.has(task.itemKey)) continue;
|
|
997
|
+
if (!task.allocationSlots || task.allocationSlots.length !== Math.round(task.days * 2) || task.allocationSlots.some((day) => day < task.start || day > task.end)) continue;
|
|
998
|
+
allocations.set(task, [...task.allocationSlots]);
|
|
999
|
+
task.allocationSlots.forEach(reserve);
|
|
1000
|
+
if (task.itemKey) seen.add(task.itemKey);
|
|
1001
|
+
}
|
|
1002
|
+
for (const task of confirmed) {
|
|
1003
|
+
if (allocations.has(task)) continue;
|
|
1004
|
+
if (task.itemKey && seen.has(task.itemKey)) continue;
|
|
1005
|
+
if (task.itemKey) seen.add(task.itemKey);
|
|
1006
|
+
let remaining = Math.ceil(task.days * 2 - 1e-8);
|
|
1007
|
+
if (!Number.isSafeInteger(remaining) || remaining <= 0) throw new Error("已有 Task 的人天无效,请先核对");
|
|
1008
|
+
const slots = [];
|
|
1009
|
+
const relevant = dates.filter((day) => day >= task.start && day <= task.end);
|
|
1010
|
+
const candidates = [...relevant, ...datesBetween(task.start, task.end).filter((day) => !relevant.includes(day))];
|
|
1011
|
+
for (const day of candidates) {
|
|
1012
|
+
const taken = Math.min(remaining, Math.max(0, 2 - (used.get(day) ?? 0)));
|
|
1013
|
+
used.set(day, (used.get(day) ?? 0) + taken);
|
|
1014
|
+
slots.push(...Array(taken).fill(day));
|
|
1015
|
+
remaining -= taken;
|
|
1016
|
+
if (!remaining) break;
|
|
1017
|
+
}
|
|
1018
|
+
allocations.set(task, slots.sort());
|
|
1019
|
+
}
|
|
1020
|
+
return allocations;
|
|
1021
|
+
}
|
|
1022
|
+
/** Remaining half-day slots; the wider context recovers legacy multi-range Tasks. */
|
|
1023
|
+
function availableWorktimeSlots(dates, tasks, contextDates = dates) {
|
|
1024
|
+
const used = /* @__PURE__ */ new Map();
|
|
1025
|
+
for (const slots of plannedTaskSlots(contextDates, tasks).values()) for (const day of slots) used.set(day, (used.get(day) ?? 0) + 1);
|
|
1026
|
+
return [...new Set(dates)].sort().flatMap((day) => Array(Math.max(0, 2 - (used.get(day) ?? 0))).fill(day));
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Allocate person-days across one requirement's ordered dates. Half days are the
|
|
1030
|
+
* smallest unit, so two stages may share a date; a plan may leave time
|
|
1031
|
+
* unallocated but may never exceed the planned total.
|
|
1032
|
+
*/
|
|
1033
|
+
function allocateGroupTasks(dates, tasks, existing = [], contextDates = dates) {
|
|
1034
|
+
if (!dates.length) throw new Error("请先选择需求的日期");
|
|
1035
|
+
if (!tasks.length || tasks.length > 20) throw new Error("请填写至少一个阶段的人天");
|
|
1036
|
+
const slots = availableWorktimeSlots(dates, existing, contextDates);
|
|
1037
|
+
if (!slots.length) throw new Error("该时间段已创建 Task,当前没有剩余人天,请延长时间段后再安排");
|
|
1038
|
+
let spent = 0;
|
|
1039
|
+
const result = tasks.map((task) => {
|
|
1040
|
+
if (![
|
|
1041
|
+
"understand",
|
|
1042
|
+
"design",
|
|
1043
|
+
"develop",
|
|
1044
|
+
"verify",
|
|
1045
|
+
"bugfix"
|
|
1046
|
+
].includes(task.stage) || !task.title.trim() || task.title.length > 240 || !Number.isFinite(task.days) || task.days <= 0) throw new Error("任务标题或人天无效");
|
|
1047
|
+
if (task.days < .499999999) throw new Error("每个阶段至少 0.5 人天");
|
|
1048
|
+
if (Math.abs(task.days * 2 - Math.round(task.days * 2)) > 1e-9) throw new Error("每个阶段需按 0.5 人天递增");
|
|
1049
|
+
const first = Math.round(spent * 2);
|
|
1050
|
+
spent += task.days;
|
|
1051
|
+
const last = Math.round(spent * 2) - 1;
|
|
1052
|
+
return {
|
|
1053
|
+
...task,
|
|
1054
|
+
title: task.title.trim(),
|
|
1055
|
+
start: slots[first] ?? "",
|
|
1056
|
+
end: slots[last] ?? "",
|
|
1057
|
+
allocationSlots: slots.slice(first, last + 1),
|
|
1058
|
+
status: "pending"
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
if (spent > slots.length / 2 + 1e-6) throw new Error(`阶段合计不能大于剩余 ${slots.length / 2} 人天,当前为 ${Number(spent.toFixed(4))} 人天`);
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/task-bugs.ts
|
|
1066
|
+
const record$1 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1067
|
+
const text$3 = (value) => typeof value === "string" ? value : value && typeof value === "object" ? text$3(record$1(value).name) : "";
|
|
1068
|
+
const array$2 = (value) => Array.isArray(value) ? value : [];
|
|
1069
|
+
const keyPattern$3 = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u;
|
|
1070
|
+
const bugTypes = [
|
|
1071
|
+
"Bug",
|
|
1072
|
+
"测试缺陷",
|
|
1073
|
+
"线上Bug",
|
|
1074
|
+
"升级问题"
|
|
1075
|
+
];
|
|
1076
|
+
const bugFilter = `类型 in ${JSON.stringify(bugTypes)}`;
|
|
1077
|
+
const isBug = (row) => bugTypes.includes(text$3(row.itemType)) || bugTypes.includes(text$3(record$1(row.itemType).key));
|
|
1078
|
+
const bug = (row) => ({
|
|
1079
|
+
itemKey: text$3(row.key),
|
|
1080
|
+
title: text$3(row.name),
|
|
1081
|
+
status: text$3(row.status)
|
|
1082
|
+
});
|
|
1083
|
+
/** All reads and writes use native Team data; no local relationship cache. */
|
|
1084
|
+
function createTaskBugService(run, config) {
|
|
1085
|
+
const names = config.bugLinkNames?.length ? config.bugLinkNames : ["relates to", "relates to2"];
|
|
1086
|
+
const countCache = /* @__PURE__ */ new Map();
|
|
1087
|
+
const countReads = /* @__PURE__ */ new Map();
|
|
1088
|
+
function rememberCount(itemKey, count) {
|
|
1089
|
+
if (countCache.size >= 500 && !countCache.has(itemKey)) countCache.delete(countCache.keys().next().value);
|
|
1090
|
+
countCache.set(itemKey, {
|
|
1091
|
+
count,
|
|
1092
|
+
expires: Date.now() + 3e4
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
async function task(itemKey) {
|
|
1096
|
+
if (!keyPattern$3.test(itemKey)) throw new Error("无效的 Task 编号");
|
|
1097
|
+
const value = record$1(await run([
|
|
1098
|
+
"team",
|
|
1099
|
+
"item",
|
|
1100
|
+
"view",
|
|
1101
|
+
itemKey
|
|
1102
|
+
]));
|
|
1103
|
+
if (!isScheduledTask(text$3(value.itemType))) throw new Error("请选择 Task 或跟踪任务");
|
|
1104
|
+
if (!text$3(value.objectId)) throw new Error("Task 缺少事项标识");
|
|
1105
|
+
return value;
|
|
1106
|
+
}
|
|
1107
|
+
async function search(iql) {
|
|
1108
|
+
const items = /* @__PURE__ */ new Map();
|
|
1109
|
+
for (let page = 1; page <= 20; page++) {
|
|
1110
|
+
const payload = record$1(await run([
|
|
1111
|
+
"team",
|
|
1112
|
+
"item",
|
|
1113
|
+
"search",
|
|
1114
|
+
"--iql",
|
|
1115
|
+
`${bugFilter} and (${iql})`,
|
|
1116
|
+
"--fields",
|
|
1117
|
+
"key,name,status,itemType",
|
|
1118
|
+
"--size",
|
|
1119
|
+
"50",
|
|
1120
|
+
"--page",
|
|
1121
|
+
String(page)
|
|
1122
|
+
]));
|
|
1123
|
+
if (!Array.isArray(payload.items)) throw new Error("番茄返回的缺陷列表格式异常");
|
|
1124
|
+
for (const row of payload.items.map(record$1).filter(isBug)) {
|
|
1125
|
+
const value = bug(row);
|
|
1126
|
+
if (keyPattern$3.test(value.itemKey)) items.set(value.itemKey, value);
|
|
1127
|
+
}
|
|
1128
|
+
if (payload.items.length < 50 || Number(payload.nextPageIndex) === -1 || Number.isFinite(Number(payload.count)) && page * 50 >= Number(payload.count)) return [...items.values()];
|
|
1129
|
+
}
|
|
1130
|
+
throw new Error("缺陷超过 1000 条,请在番茄中缩小范围");
|
|
1131
|
+
}
|
|
1132
|
+
function linkedQuery(item) {
|
|
1133
|
+
return names.map((name) => `item in linkedItemsOf(${JSON.stringify(`id = ${text$3(item.objectId)}`)}, ${JSON.stringify(name)})`).join(" or ");
|
|
1134
|
+
}
|
|
1135
|
+
async function count(itemKey) {
|
|
1136
|
+
const cached = countCache.get(itemKey);
|
|
1137
|
+
if (cached && cached.expires > Date.now()) return cached.count;
|
|
1138
|
+
const pending = countReads.get(itemKey);
|
|
1139
|
+
if (pending) return pending;
|
|
1140
|
+
const snapshot = countCache.get(itemKey);
|
|
1141
|
+
const read = (async () => {
|
|
1142
|
+
const payload = record$1(await run([
|
|
1143
|
+
"team",
|
|
1144
|
+
"item",
|
|
1145
|
+
"search",
|
|
1146
|
+
"--iql",
|
|
1147
|
+
`${bugFilter} and (${linkedQuery(await task(itemKey))})`,
|
|
1148
|
+
"--size",
|
|
1149
|
+
"1",
|
|
1150
|
+
"--fields",
|
|
1151
|
+
"key"
|
|
1152
|
+
]));
|
|
1153
|
+
const value = Number(payload.count);
|
|
1154
|
+
if (payload.count == null || !Number.isSafeInteger(value) || value < 0) throw new Error("番茄返回的关联 Bug 数量异常");
|
|
1155
|
+
const newer = countCache.get(itemKey);
|
|
1156
|
+
if (newer && newer !== snapshot) return newer.count;
|
|
1157
|
+
rememberCount(itemKey, value);
|
|
1158
|
+
return value;
|
|
1159
|
+
})();
|
|
1160
|
+
countReads.set(itemKey, read);
|
|
1161
|
+
try {
|
|
1162
|
+
return await read;
|
|
1163
|
+
} finally {
|
|
1164
|
+
if (countReads.get(itemKey) === read) countReads.delete(itemKey);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
async counts(itemKeys) {
|
|
1169
|
+
if (!Array.isArray(itemKeys) || itemKeys.length > 100 || itemKeys.some((key) => typeof key !== "string" || !keyPattern$3.test(key))) throw new Error("无效的 Task 编号列表(最多 100 个)");
|
|
1170
|
+
const keys = [...new Set(itemKeys)];
|
|
1171
|
+
const counts = {};
|
|
1172
|
+
const failed = [];
|
|
1173
|
+
let index = 0;
|
|
1174
|
+
await Promise.all(Array.from({ length: Math.min(3, keys.length) }, async () => {
|
|
1175
|
+
while (index < keys.length) {
|
|
1176
|
+
const key = keys[index++];
|
|
1177
|
+
try {
|
|
1178
|
+
counts[key] = await count(key);
|
|
1179
|
+
} catch {
|
|
1180
|
+
failed.push(key);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}));
|
|
1184
|
+
return {
|
|
1185
|
+
counts,
|
|
1186
|
+
failed
|
|
1187
|
+
};
|
|
1188
|
+
},
|
|
1189
|
+
async load(itemKey, query = "") {
|
|
1190
|
+
const value = await task(itemKey);
|
|
1191
|
+
if (query) {
|
|
1192
|
+
if (query.length > 120) throw new Error("搜索内容不能超过 120 字");
|
|
1193
|
+
if (keyPattern$3.test(query)) {
|
|
1194
|
+
const target = record$1(await run([
|
|
1195
|
+
"team",
|
|
1196
|
+
"item",
|
|
1197
|
+
"view",
|
|
1198
|
+
query
|
|
1199
|
+
]));
|
|
1200
|
+
return { items: isBug(target) ? [bug(target)] : [] };
|
|
1201
|
+
}
|
|
1202
|
+
return { items: await search(`标题 ~ ${JSON.stringify(query)}`) };
|
|
1203
|
+
}
|
|
1204
|
+
const ancestor = record$1(value.ancestor);
|
|
1205
|
+
const parent = text$3(ancestor.objectId) ? {
|
|
1206
|
+
itemKey: text$3(ancestor.key),
|
|
1207
|
+
title: text$3(ancestor.name)
|
|
1208
|
+
} : null;
|
|
1209
|
+
const [linked, suggested] = await Promise.all([search(linkedQuery(value)), parent ? search(`ancestors in ${JSON.stringify([text$3(ancestor.objectId)])}`) : Promise.resolve([])]);
|
|
1210
|
+
rememberCount(itemKey, linked.length);
|
|
1211
|
+
return {
|
|
1212
|
+
linked,
|
|
1213
|
+
suggested,
|
|
1214
|
+
parent,
|
|
1215
|
+
canLink: Boolean(config.bugLinkTypeId)
|
|
1216
|
+
};
|
|
1217
|
+
},
|
|
1218
|
+
async link(itemKey, bugKey) {
|
|
1219
|
+
if (!config.bugLinkTypeId) throw new Error("尚未配置番茄事项关联类型");
|
|
1220
|
+
if (!keyPattern$3.test(bugKey)) throw new Error("无效的 Bug 编号");
|
|
1221
|
+
const [source, target, user] = await Promise.all([
|
|
1222
|
+
task(itemKey),
|
|
1223
|
+
run([
|
|
1224
|
+
"team",
|
|
1225
|
+
"item",
|
|
1226
|
+
"view",
|
|
1227
|
+
bugKey
|
|
1228
|
+
]).then(record$1),
|
|
1229
|
+
run([
|
|
1230
|
+
"team",
|
|
1231
|
+
"user",
|
|
1232
|
+
"current"
|
|
1233
|
+
]).then(record$1)
|
|
1234
|
+
]);
|
|
1235
|
+
if (!array$2(record$1(source.values).assignee).map(record$1).some((owner) => text$3(user.userId) && text$3(owner.objectId ?? owner.value) === text$3(user.userId) || text$3(user.username) && text$3(owner.username) === text$3(user.username))) throw new Error("只能关联自己负责的 Task");
|
|
1236
|
+
if (!isBug(target) || !text$3(target.objectId)) throw new Error("请选择有效的缺陷事项");
|
|
1237
|
+
const existing = await search(linkedQuery(source));
|
|
1238
|
+
if (existing.some((item) => item.itemKey === bugKey)) {
|
|
1239
|
+
rememberCount(itemKey, existing.length);
|
|
1240
|
+
return {
|
|
1241
|
+
bug: bug(target),
|
|
1242
|
+
count: existing.length
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
await run([
|
|
1246
|
+
"team",
|
|
1247
|
+
"item",
|
|
1248
|
+
"edit",
|
|
1249
|
+
itemKey,
|
|
1250
|
+
"--values",
|
|
1251
|
+
JSON.stringify({ Relations: { links: [{
|
|
1252
|
+
linkType: config.bugLinkTypeId,
|
|
1253
|
+
destinationItems: [text$3(target.objectId)]
|
|
1254
|
+
}] } })
|
|
1255
|
+
]);
|
|
1256
|
+
rememberCount(itemKey, existing.length + 1);
|
|
1257
|
+
return {
|
|
1258
|
+
bug: bug(target),
|
|
1259
|
+
count: existing.length + 1
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
//#endregion
|
|
1265
|
+
//#region src/task-report.ts
|
|
1266
|
+
const row$1 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1267
|
+
const text$2 = (value) => typeof value === "string" ? value : textObject(value);
|
|
1268
|
+
const textObject = (value) => value && typeof value === "object" ? text$2(row$1(value).name) : "";
|
|
1269
|
+
const array$1 = (value) => Array.isArray(value) ? value : [];
|
|
1270
|
+
const fields = (item) => row$1(item.values ?? item.value);
|
|
1271
|
+
const date = (value) => {
|
|
1272
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/u.test(value)) return value.slice(0, 10);
|
|
1273
|
+
return typeof value === "number" && Number.isFinite(value) ? new Date(value + 8 * 36e5).toISOString().slice(0, 10) : "";
|
|
1274
|
+
};
|
|
1275
|
+
const stamp = (day) => Date.parse(`${day}T00:00:00+08:00`);
|
|
1276
|
+
const validDate = (day) => typeof day === "string" && /^\d{4}-\d{2}-\d{2}$/u.test(day) && Number.isFinite(stamp(day)) && date(stamp(day)) === day;
|
|
1277
|
+
const keyPattern$2 = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u;
|
|
1278
|
+
const reviewStates = new Set(["工时核对", "工时核对中"]);
|
|
1279
|
+
const doneStates = new Set(["已完成"]);
|
|
1280
|
+
const progressStates = new Set(["进行中", "开发中"]);
|
|
1281
|
+
async function loadTaskReportConstraints(run) {
|
|
1282
|
+
const definitions = await Promise.all(["ActualFinishTime", "StoryPoint1"].map((key) => run([
|
|
1283
|
+
"team",
|
|
1284
|
+
"field",
|
|
1285
|
+
"list",
|
|
1286
|
+
"--keyword",
|
|
1287
|
+
key,
|
|
1288
|
+
"--fields",
|
|
1289
|
+
"property",
|
|
1290
|
+
"--size",
|
|
1291
|
+
"100"
|
|
1292
|
+
])));
|
|
1293
|
+
const find = (payload, key) => {
|
|
1294
|
+
const definition = array$1(Array.isArray(payload) ? payload : row$1(payload).fields).map(row$1).find((value) => value.key === key);
|
|
1295
|
+
if (!definition) throw new Error(`无法读取报工字段约束:${key}`);
|
|
1296
|
+
return row$1(definition.property);
|
|
1297
|
+
};
|
|
1298
|
+
const finish = find(definitions[0], "ActualFinishTime");
|
|
1299
|
+
const effort = find(definitions[1], "StoryPoint1");
|
|
1300
|
+
const range = array$1(finish.range).map(date);
|
|
1301
|
+
if (finish.range != null && (range.length !== 2 || range.some((value) => !validDate(value)))) throw new Error("无法确认实际完成日期的允许范围");
|
|
1302
|
+
return {
|
|
1303
|
+
...range.length === 2 ? { finishRange: range } : {},
|
|
1304
|
+
...typeof effort.min === "number" ? { daysMin: effort.min } : {},
|
|
1305
|
+
...typeof effort.max === "number" ? { daysMax: effort.max } : {}
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
function transitions(payload) {
|
|
1309
|
+
if (Array.isArray(payload)) return payload.map(row$1);
|
|
1310
|
+
const value = row$1(payload);
|
|
1311
|
+
for (const key of [
|
|
1312
|
+
"transitions",
|
|
1313
|
+
"data",
|
|
1314
|
+
"result"
|
|
1315
|
+
]) {
|
|
1316
|
+
if (Array.isArray(value[key])) return array$1(value[key]).map(row$1);
|
|
1317
|
+
if (Array.isArray(row$1(value[key]).transitions)) return array$1(row$1(value[key]).transitions).map(row$1);
|
|
1318
|
+
}
|
|
1319
|
+
throw new Error("无法读取 Task 工作流");
|
|
1320
|
+
}
|
|
1321
|
+
/** Only follow live, unambiguous business transitions; never approve work hours. */
|
|
1322
|
+
function nextTransition(currentStatus, candidates, preparing = false) {
|
|
1323
|
+
if (preparing) candidates = candidates.map((value) => {
|
|
1324
|
+
const reason = text$2(value.disabledReason);
|
|
1325
|
+
const remainder = reason.replace(/当前事项字段「(工时工作量|实际开始时间|实际完成时间)」应该非空/gu, "").replace(/[\s;;,,。]/gu, "");
|
|
1326
|
+
return value.disabled === true && reason && !remainder ? {
|
|
1327
|
+
...value,
|
|
1328
|
+
disabled: false
|
|
1329
|
+
} : value;
|
|
1330
|
+
});
|
|
1331
|
+
const review = candidates.filter((value) => reviewStates.has(text$2(value.targetStatus ?? value.status)));
|
|
1332
|
+
const done = candidates.filter((value) => doneStates.has(text$2(value.targetStatus ?? value.status)));
|
|
1333
|
+
const progress = candidates.filter((value) => progressStates.has(text$2(value.targetStatus ?? value.status)));
|
|
1334
|
+
const groups = progressStates.has(currentStatus) ? [review, done] : [
|
|
1335
|
+
progress,
|
|
1336
|
+
review,
|
|
1337
|
+
done
|
|
1338
|
+
];
|
|
1339
|
+
const options = groups.find((group) => group.some((value) => value.disabled === false && text$2(value.transition))) ?? groups.flat();
|
|
1340
|
+
const enabled = options.filter((value) => value.disabled === false && text$2(value.transition));
|
|
1341
|
+
if (enabled.length > 1) throw new Error("存在多个报工流转,请在番茄中选择具体分支");
|
|
1342
|
+
if (!enabled.length) throw new Error([...new Set(options.map((value) => text$2(value.disabledReason)).filter(Boolean))].join(";") || "当前没有可用的报工流转,请在番茄中检查工作流和必填字段");
|
|
1343
|
+
return enabled[0];
|
|
1344
|
+
}
|
|
1345
|
+
function createTaskReportService(run) {
|
|
1346
|
+
return async (input, user, start, end, constraints = {}) => {
|
|
1347
|
+
const result = {
|
|
1348
|
+
itemKey: String(input.itemKey ?? ""),
|
|
1349
|
+
outcome: "failed",
|
|
1350
|
+
currentStatus: "",
|
|
1351
|
+
steps: [],
|
|
1352
|
+
fieldsWritten: false
|
|
1353
|
+
};
|
|
1354
|
+
let mutating = false;
|
|
1355
|
+
const read = async () => row$1(await run([
|
|
1356
|
+
"team",
|
|
1357
|
+
"item",
|
|
1358
|
+
"view",
|
|
1359
|
+
input.itemKey
|
|
1360
|
+
]));
|
|
1361
|
+
const owned = (item) => {
|
|
1362
|
+
if (!isScheduledTask(text$2(item.itemType))) throw new Error("只能对 Task 或跟踪任务报工");
|
|
1363
|
+
if (!array$1(fields(item).assignee).map(row$1).some((owner) => text$2(user.userId) && text$2(owner.objectId ?? owner.value) === text$2(user.userId) || text$2(user.username) && text$2(owner.username) === text$2(user.username))) throw new Error("只能对当前登录用户负责的 Task 报工");
|
|
1364
|
+
};
|
|
1365
|
+
try {
|
|
1366
|
+
if (!keyPattern$2.test(input.itemKey) || !validDate(start) || !validDate(end) || end < start || !validDate(input.actualStart) || !validDate(input.actualEnd) || input.actualEnd < input.actualStart || typeof input.days !== "number" || !Number.isFinite(input.days) || input.days <= 0 || !Number.isInteger(input.days * 2)) throw new Error("请填写有效的实际日期和工时,最小单位为 0.5 人天");
|
|
1367
|
+
if (constraints.finishRange && (input.actualEnd < constraints.finishRange[0] || input.actualEnd > constraints.finishRange[1])) throw new Error(`实际完成日期须在 ${constraints.finishRange.join(" — ")} 范围内`);
|
|
1368
|
+
if (constraints.daysMin != null && input.days < constraints.daysMin || constraints.daysMax != null && input.days > constraints.daysMax) throw new Error("实际工时超出番茄允许范围");
|
|
1369
|
+
let item = await read();
|
|
1370
|
+
owned(item);
|
|
1371
|
+
let value = fields(item);
|
|
1372
|
+
result.currentStatus = text$2(item.status);
|
|
1373
|
+
const taskStart = date(value.DateSDate);
|
|
1374
|
+
const taskEnd = date(value.qiwangwanchengriqi);
|
|
1375
|
+
if (!taskStart || !taskEnd || taskStart > end || taskEnd < start) throw new Error("Task 已不在所选周,请刷新");
|
|
1376
|
+
if (reviewStates.has(result.currentStatus) || doneStates.has(result.currentStatus)) return {
|
|
1377
|
+
...result,
|
|
1378
|
+
outcome: "skipped"
|
|
1379
|
+
};
|
|
1380
|
+
if (result.currentStatus !== input.expectedStatus || taskStart !== input.expectedStart || taskEnd !== input.expectedEnd || date(value.StartDate) !== input.expectedActualStart || date(value.ActualFinishTime) !== input.expectedActualEnd || (value.StoryPoint1 == null ? null : Number(value.StoryPoint1)) !== input.expectedActualDays) throw new Error("Task 状态、日期或实际工时已变化,请刷新后重试");
|
|
1381
|
+
nextTransition(result.currentStatus, transitions(await run([
|
|
1382
|
+
"team",
|
|
1383
|
+
"transition",
|
|
1384
|
+
"list",
|
|
1385
|
+
input.itemKey
|
|
1386
|
+
])), true);
|
|
1387
|
+
item = await read();
|
|
1388
|
+
owned(item);
|
|
1389
|
+
value = fields(item);
|
|
1390
|
+
if (text$2(item.status) !== input.expectedStatus || date(value.DateSDate) !== input.expectedStart || date(value.qiwangwanchengriqi) !== input.expectedEnd || date(value.StartDate) !== input.expectedActualStart || date(value.ActualFinishTime) !== input.expectedActualEnd || (value.StoryPoint1 == null ? null : Number(value.StoryPoint1)) !== input.expectedActualDays) throw new Error("报工前 Task 数据已变化,请刷新后重试");
|
|
1391
|
+
const values = {
|
|
1392
|
+
StartDate: stamp(input.actualStart),
|
|
1393
|
+
ActualFinishTime: stamp(input.actualEnd),
|
|
1394
|
+
StoryPoint1: input.days
|
|
1395
|
+
};
|
|
1396
|
+
const matches = (item) => {
|
|
1397
|
+
const actual = fields(item);
|
|
1398
|
+
return date(actual.StartDate) === input.actualStart && date(actual.ActualFinishTime) === input.actualEnd && Number(actual.StoryPoint1) === input.days && date(actual.DateSDate) === input.expectedStart && date(actual.qiwangwanchengriqi) === input.expectedEnd;
|
|
1399
|
+
};
|
|
1400
|
+
mutating = true;
|
|
1401
|
+
await run([
|
|
1402
|
+
"team",
|
|
1403
|
+
"item",
|
|
1404
|
+
"edit",
|
|
1405
|
+
input.itemKey,
|
|
1406
|
+
"--values",
|
|
1407
|
+
JSON.stringify(values)
|
|
1408
|
+
]);
|
|
1409
|
+
item = await read();
|
|
1410
|
+
value = fields(item);
|
|
1411
|
+
result.fieldsWritten = date(value.StartDate) === input.actualStart && date(value.ActualFinishTime) === input.actualEnd && Number(value.StoryPoint1) === input.days;
|
|
1412
|
+
if (!result.fieldsWritten) throw new Error("实际工时或日期回读不一致,已停止流转,请核对番茄");
|
|
1413
|
+
mutating = false;
|
|
1414
|
+
for (let step = 0; step < 3; step++) {
|
|
1415
|
+
owned(item);
|
|
1416
|
+
result.currentStatus = text$2(item.status);
|
|
1417
|
+
if (reviewStates.has(result.currentStatus) || doneStates.has(result.currentStatus)) return {
|
|
1418
|
+
...result,
|
|
1419
|
+
outcome: "success"
|
|
1420
|
+
};
|
|
1421
|
+
const currentStatus = result.currentStatus;
|
|
1422
|
+
const selected = nextTransition(currentStatus, transitions(await run([
|
|
1423
|
+
"team",
|
|
1424
|
+
"transition",
|
|
1425
|
+
"list",
|
|
1426
|
+
input.itemKey
|
|
1427
|
+
])));
|
|
1428
|
+
item = await read();
|
|
1429
|
+
owned(item);
|
|
1430
|
+
if (text$2(item.status) !== currentStatus) throw new Error("流转前 Task 状态已变化,已停止,请刷新后核对");
|
|
1431
|
+
if (!matches(item)) throw new Error("流转前 Task 日期或工时已变化,已停止,请刷新后核对");
|
|
1432
|
+
mutating = true;
|
|
1433
|
+
await run([
|
|
1434
|
+
"team",
|
|
1435
|
+
"transition",
|
|
1436
|
+
"execute",
|
|
1437
|
+
input.itemKey,
|
|
1438
|
+
"--transition",
|
|
1439
|
+
text$2(selected.transition)
|
|
1440
|
+
]);
|
|
1441
|
+
item = await read();
|
|
1442
|
+
result.currentStatus = text$2(item.status);
|
|
1443
|
+
if (result.currentStatus !== text$2(selected.targetStatus ?? selected.status)) throw new Error("流转结果与目标状态不一致,请核对番茄");
|
|
1444
|
+
result.steps.push(`${currentStatus} → ${result.currentStatus}`);
|
|
1445
|
+
mutating = false;
|
|
1446
|
+
}
|
|
1447
|
+
throw new Error("已达到流转步数限制,请核对番茄");
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
result.outcome = mutating ? "unknown" : result.fieldsWritten || result.steps.length ? "partial" : "failed";
|
|
1450
|
+
result.error = error instanceof Error ? error.message : "报工失败";
|
|
1451
|
+
if (mutating) {
|
|
1452
|
+
try {
|
|
1453
|
+
const actual = await read();
|
|
1454
|
+
result.currentStatus = text$2(actual.status);
|
|
1455
|
+
const value = fields(actual);
|
|
1456
|
+
result.fieldsWritten = date(value.StartDate) === input.actualStart && date(value.ActualFinishTime) === input.actualEnd && Number(value.StoryPoint1) === input.days;
|
|
1457
|
+
} catch {}
|
|
1458
|
+
result.error = `结果待核对,未自动重试:${result.error}`;
|
|
1459
|
+
}
|
|
1460
|
+
return result;
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
//#endregion
|
|
1465
|
+
//#region src/task-report-model.ts
|
|
1466
|
+
/** At most two selected Tasks share one person-day, in half-day units. */
|
|
1467
|
+
function suggestReportDays(tasks, start) {
|
|
1468
|
+
const days = Object.fromEntries(tasks.map((task) => [task.itemKey, 0]));
|
|
1469
|
+
const conflicts = [];
|
|
1470
|
+
for (const date of datesBetween(start, addDays(start, 6))) {
|
|
1471
|
+
const active = tasks.filter((task) => task.start <= date && date <= task.end);
|
|
1472
|
+
if (active.length > 2) {
|
|
1473
|
+
conflicts.push({
|
|
1474
|
+
date,
|
|
1475
|
+
itemKeys: active.map((task) => task.itemKey)
|
|
1476
|
+
});
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
for (const task of active) days[task.itemKey] += active.length === 2 ? .5 : 1;
|
|
1480
|
+
}
|
|
1481
|
+
return {
|
|
1482
|
+
days,
|
|
1483
|
+
conflicts
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/requirement-search.ts
|
|
1488
|
+
const row = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1489
|
+
const text$1 = (value) => typeof value === "string" ? value : value && typeof value === "object" ? text$1(row(value).name ?? row(value).label) : "";
|
|
1490
|
+
const array = (value) => Array.isArray(value) ? value : [];
|
|
1491
|
+
const keyPattern$1 = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u;
|
|
1492
|
+
const types = ["Story", "EnablerStory"];
|
|
1493
|
+
function option(raw) {
|
|
1494
|
+
const item = row(raw);
|
|
1495
|
+
if (!types.includes(text$1(item.itemType)) && !types.includes(text$1(row(item.itemType).key))) return null;
|
|
1496
|
+
const itemKey = text$1(item.key);
|
|
1497
|
+
if (!keyPattern$1.test(itemKey) || !text$1(item.name)) return null;
|
|
1498
|
+
const assignees = array(row(item.values ?? item.value).assignee ?? item.assignee).map(row).map((user) => text$1(user.label) || text$1(user.nickname) || text$1(user.username)).filter(Boolean);
|
|
1499
|
+
return {
|
|
1500
|
+
itemKey,
|
|
1501
|
+
title: text$1(item.name),
|
|
1502
|
+
status: text$1(item.status),
|
|
1503
|
+
assignees
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
/** Permission-scoped requirement search, deliberately independent of board owner filters. */
|
|
1507
|
+
async function searchRequirements(run, query) {
|
|
1508
|
+
const q = query.trim();
|
|
1509
|
+
if (!q) return {
|
|
1510
|
+
items: [],
|
|
1511
|
+
truncated: false
|
|
1512
|
+
};
|
|
1513
|
+
if (q.length > 120) throw new Error("搜索内容最多 120 个字符");
|
|
1514
|
+
if (keyPattern$1.test(q)) {
|
|
1515
|
+
const item = option(await run([
|
|
1516
|
+
"team",
|
|
1517
|
+
"item",
|
|
1518
|
+
"view",
|
|
1519
|
+
q
|
|
1520
|
+
]));
|
|
1521
|
+
return {
|
|
1522
|
+
items: item ? [item] : [],
|
|
1523
|
+
truncated: false
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
const payload = row(await run([
|
|
1527
|
+
"team",
|
|
1528
|
+
"item",
|
|
1529
|
+
"search",
|
|
1530
|
+
"--iql",
|
|
1531
|
+
`类型 in ${JSON.stringify(types)} and 标题 ~ ${JSON.stringify(q)}`,
|
|
1532
|
+
"--fields",
|
|
1533
|
+
"key,name,status,itemType,assignee",
|
|
1534
|
+
"--page",
|
|
1535
|
+
"1",
|
|
1536
|
+
"--size",
|
|
1537
|
+
"20"
|
|
1538
|
+
]));
|
|
1539
|
+
if (!Array.isArray(payload.items)) throw new Error("番茄返回的需求列表格式异常");
|
|
1540
|
+
const items = [...new Map(payload.items.map(option).filter((item) => item !== null).map((item) => [item.itemKey, item])).values()];
|
|
1541
|
+
const total = Number(payload.count ?? payload.total);
|
|
1542
|
+
return {
|
|
1543
|
+
items,
|
|
1544
|
+
truncated: Number.isFinite(total) ? total > payload.items.length : payload.items.length === 20 && payload.nextPageIndex !== -1
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
//#endregion
|
|
1548
|
+
//#region src/worktime.ts
|
|
1549
|
+
const keyPattern = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*-\d+$/u;
|
|
1550
|
+
const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1551
|
+
const text = (value) => typeof value === "string" ? value : Array.isArray(value) ? text(value[0]) : value && typeof value === "object" ? text(record(value).name ?? record(value).label ?? record(value).value) : "";
|
|
1552
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
1553
|
+
const sprintIds = (value) => (Array.isArray(value) ? value : value ? [value] : []).map((item) => typeof item === "string" ? item : text(record(item).objectId ?? record(item).value)).filter(Boolean).sort();
|
|
1554
|
+
const fieldValues = (raw) => record(record(raw).values ?? record(raw).value);
|
|
1555
|
+
const localDate = (value) => {
|
|
1556
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/u.test(value)) return value.slice(0, 10);
|
|
1557
|
+
if (typeof value !== "number") return "";
|
|
1558
|
+
return new Date(value + 8 * 36e5).toISOString().slice(0, 10);
|
|
1559
|
+
};
|
|
1560
|
+
const timestamp = (value) => Date.parse(`${value}T00:00:00+08:00`);
|
|
1561
|
+
function freezeAllocations(store, itemKey) {
|
|
1562
|
+
const group = [...store.units, ...store.archivedUnits ?? []].filter((unit) => unit.itemKey === itemKey);
|
|
1563
|
+
for (const [task, slots] of plannedTaskSlots(unitDates(group), group.flatMap((unit) => unit.tasks))) task.allocationSlots = slots;
|
|
1564
|
+
}
|
|
1565
|
+
async function body(request) {
|
|
1566
|
+
if (!request[Symbol.asyncIterator]) throw new Error("请求不支持 JSON 正文");
|
|
1567
|
+
let value = "";
|
|
1568
|
+
for await (const chunk of request) {
|
|
1569
|
+
value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
1570
|
+
if (Buffer.byteLength(value) > 64 * 1024) throw new Error("请求内容过大");
|
|
1571
|
+
}
|
|
1572
|
+
return record(JSON.parse(value));
|
|
1573
|
+
}
|
|
1574
|
+
function validateUnit(value) {
|
|
1575
|
+
if (typeof value.id !== "string" || !/^[a-zA-Z0-9-]{8,80}$/u.test(value.id) || typeof value.itemKey !== "string" || !keyPattern.test(value.itemKey) || typeof value.start !== "string" || typeof value.end !== "string") throw new Error("无效的需求或日期安排");
|
|
1576
|
+
datesBetween(value.start, value.end);
|
|
1577
|
+
return {
|
|
1578
|
+
id: value.id,
|
|
1579
|
+
itemKey: value.itemKey,
|
|
1580
|
+
title: "",
|
|
1581
|
+
workspace: "",
|
|
1582
|
+
start: value.start,
|
|
1583
|
+
end: value.end
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
async function storyContext(run, itemKey) {
|
|
1587
|
+
if (!keyPattern.test(itemKey)) throw new Error("无效的需求编号");
|
|
1588
|
+
const story = record(await run([
|
|
1589
|
+
"team",
|
|
1590
|
+
"item",
|
|
1591
|
+
"view",
|
|
1592
|
+
itemKey
|
|
1593
|
+
]));
|
|
1594
|
+
if (![
|
|
1595
|
+
"Story",
|
|
1596
|
+
"EnablerStory",
|
|
1597
|
+
"Bug",
|
|
1598
|
+
"测试缺陷",
|
|
1599
|
+
"线上Bug",
|
|
1600
|
+
"升级问题"
|
|
1601
|
+
].includes(text(story.itemType))) throw new Error("请选择故事或缺陷需求");
|
|
1602
|
+
const workspace = text(record(story.workspace).key);
|
|
1603
|
+
const fields = fieldValues(story);
|
|
1604
|
+
const project = text(fields.view_project);
|
|
1605
|
+
if (!workspace || !project) throw new Error("需求缺少所属空间或所属项目,请先在番茄中补全");
|
|
1606
|
+
const assignees = list(fields.assignee).map(record);
|
|
1607
|
+
if (!assignees.length || assignees.some((user) => !text(user.objectId))) throw new Error("需求未设置有效负责人,请先在番茄中补全");
|
|
1608
|
+
return {
|
|
1609
|
+
story,
|
|
1610
|
+
fields,
|
|
1611
|
+
workspace,
|
|
1612
|
+
project,
|
|
1613
|
+
assignees
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
async function metadata(run, itemKey) {
|
|
1617
|
+
const context = await storyContext(run, itemKey);
|
|
1618
|
+
const { project, workspace, fields } = context;
|
|
1619
|
+
const current = record(await run([
|
|
1620
|
+
"team",
|
|
1621
|
+
"user",
|
|
1622
|
+
"current"
|
|
1623
|
+
]));
|
|
1624
|
+
const currentId = text(current.userId ?? current.objectId);
|
|
1625
|
+
if (!currentId) throw new Error("无法确定当前登录用户,不能分配 Task");
|
|
1626
|
+
const taskAssignees = [{
|
|
1627
|
+
...current,
|
|
1628
|
+
objectId: currentId
|
|
1629
|
+
}];
|
|
1630
|
+
const definitions = list(await run([
|
|
1631
|
+
"team",
|
|
1632
|
+
"field",
|
|
1633
|
+
"workspace",
|
|
1634
|
+
"--workspace",
|
|
1635
|
+
workspace,
|
|
1636
|
+
"--item-type",
|
|
1637
|
+
"Task",
|
|
1638
|
+
"--fields",
|
|
1639
|
+
"data,property"
|
|
1640
|
+
])).map(record);
|
|
1641
|
+
if (!definitions.length) throw new Error("无法读取 Task 创建字段");
|
|
1642
|
+
const options = (key) => {
|
|
1643
|
+
const field = definitions.find((field) => field.key === key);
|
|
1644
|
+
return list(record(field?.data).customData ?? record(field?.schema).options).map(record).map((option) => ({
|
|
1645
|
+
label: text(option.label),
|
|
1646
|
+
value: text(option.value)
|
|
1647
|
+
}));
|
|
1648
|
+
};
|
|
1649
|
+
const recentValues = list(record(await run([
|
|
1650
|
+
"team",
|
|
1651
|
+
"item",
|
|
1652
|
+
"search",
|
|
1653
|
+
"--iql",
|
|
1654
|
+
`所属空间 = ${JSON.stringify(text(record(context.story.workspace).name))} and 类型 = Task and view_project = ${JSON.stringify(project)} order by 创建时间 desc`,
|
|
1655
|
+
"--size",
|
|
1656
|
+
"10",
|
|
1657
|
+
"--fields",
|
|
1658
|
+
"key,Dropdown2,suoshutuandui"
|
|
1659
|
+
])).items).map(fieldValues);
|
|
1660
|
+
const unanimous = (key) => {
|
|
1661
|
+
const values = [...new Set(recentValues.map((value) => text(value[key])).filter(Boolean))];
|
|
1662
|
+
return values.length === 1 ? values[0] : "";
|
|
1663
|
+
};
|
|
1664
|
+
const range = list(record(definitions.find((field) => field.key === "ActualFinishTime")?.property).range).map(localDate).filter(Boolean);
|
|
1665
|
+
return {
|
|
1666
|
+
context,
|
|
1667
|
+
taskAssignees,
|
|
1668
|
+
definitions,
|
|
1669
|
+
options,
|
|
1670
|
+
data: {
|
|
1671
|
+
project: options("view_project").find((option) => option.value === project)?.label || project,
|
|
1672
|
+
assignee: currentId,
|
|
1673
|
+
assigneeName: text(current.nickname) || text(current.username) || currentId,
|
|
1674
|
+
teams: options("suoshutuandui").map((option) => option.label),
|
|
1675
|
+
costs: options("Dropdown2").map((option) => option.label),
|
|
1676
|
+
categories: options("Dropdown_task").map((option) => option.label).filter((value) => [
|
|
1677
|
+
"软件开发",
|
|
1678
|
+
"修复BUG",
|
|
1679
|
+
"bug修复",
|
|
1680
|
+
"Bug修复"
|
|
1681
|
+
].includes(value)),
|
|
1682
|
+
suggestedTeam: text(fields.suoshutuandui) || unanimous("suoshutuandui"),
|
|
1683
|
+
suggestedCost: text(fields.Dropdown2) || unanimous("Dropdown2"),
|
|
1684
|
+
suggestionSource: recentValues.length ? "Task 由当前登录用户负责;其余字段继承需求,空值参考同项目近期 Task" : "Task 由当前登录用户负责;其余字段继承需求,缺失项请手动选择",
|
|
1685
|
+
actualRange: range.length === 2 ? range : null
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
/** Gitee Team user fields take label/nickname/username/value objects, never LeanCloud pointers. */
|
|
1690
|
+
function userValue(user) {
|
|
1691
|
+
const nickname = text(user.nickname);
|
|
1692
|
+
const username = text(user.username);
|
|
1693
|
+
const label = text(user.label) || (nickname && username ? `${nickname}(${username})` : username || nickname);
|
|
1694
|
+
return Object.fromEntries([
|
|
1695
|
+
["label", label],
|
|
1696
|
+
["nickname", nickname],
|
|
1697
|
+
["username", username],
|
|
1698
|
+
["value", text(user.objectId)]
|
|
1699
|
+
].filter(([, value]) => value !== ""));
|
|
1700
|
+
}
|
|
1701
|
+
/** One requirement is one plan, so touching or overlapping ranges merge into one unit. */
|
|
1702
|
+
function mergeRanges(store, itemKey) {
|
|
1703
|
+
const original = new Map(store.units.map((unit) => [unit.id, unit]));
|
|
1704
|
+
store.units = mergeRequirementRanges(store.units, itemKey, true).map((unit) => original.get(unit.id) === unit ? unit : {
|
|
1705
|
+
...unit,
|
|
1706
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1707
|
+
});
|
|
1708
|
+
return store.units.find((unit) => unit.itemKey === itemKey);
|
|
1709
|
+
}
|
|
1710
|
+
/** The unit that now owns a date the caller used to address. */
|
|
1711
|
+
function survivor(store, itemKey, day) {
|
|
1712
|
+
return store.units.find((unit) => unit.itemKey === itemKey && unit.start <= day && day <= unit.end) ?? store.units.find((unit) => unit.itemKey === itemKey);
|
|
1713
|
+
}
|
|
1714
|
+
/** Register durable planning and verified task-creation routes within the workboard. */
|
|
1715
|
+
function registerWorktime(ctx, deps) {
|
|
1716
|
+
const hash = createHash("sha256").update(deps.scope).digest("hex").slice(0, 16);
|
|
1717
|
+
const file = deps.dataFile || join(process.env.DSH_HOME || join(homedir(), ".dsh"), "tomato-worktime", `${hash}.json`);
|
|
1718
|
+
const load = async () => {
|
|
1719
|
+
let raw;
|
|
1720
|
+
try {
|
|
1721
|
+
raw = await readFile(file, "utf8");
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
if (error.code === "ENOENT") return {
|
|
1724
|
+
version: 1,
|
|
1725
|
+
units: []
|
|
1726
|
+
};
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
const value = JSON.parse(raw);
|
|
1730
|
+
if (value.version !== 1 || !Array.isArray(value.units) || value.archivedUnits !== void 0 && !Array.isArray(value.archivedUnits)) throw new Error("历史工时数据格式异常,已保留原文件");
|
|
1731
|
+
return value;
|
|
1732
|
+
};
|
|
1733
|
+
const save = async (store) => {
|
|
1734
|
+
await mkdir(dirname(file), {
|
|
1735
|
+
recursive: true,
|
|
1736
|
+
mode: 448
|
|
1737
|
+
});
|
|
1738
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
1739
|
+
await writeFile(temp, JSON.stringify(store, null, 2) + "\n", {
|
|
1740
|
+
mode: 384,
|
|
1741
|
+
flag: "wx"
|
|
1742
|
+
});
|
|
1743
|
+
await rename(temp, file);
|
|
1744
|
+
};
|
|
1745
|
+
let queue = Promise.resolve();
|
|
1746
|
+
const serial = (fn) => {
|
|
1747
|
+
const next = queue.then(async () => {
|
|
1748
|
+
await mkdir(dirname(file), {
|
|
1749
|
+
recursive: true,
|
|
1750
|
+
mode: 448
|
|
1751
|
+
});
|
|
1752
|
+
let lock;
|
|
1753
|
+
try {
|
|
1754
|
+
lock = await open(`${file}.lock`, "wx", 384);
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
if (error.code === "EEXIST") throw new Error("工时记录正在由其他工作台修改,请稍后刷新;异常退出遗留锁需先核对创建结果");
|
|
1757
|
+
throw error;
|
|
1758
|
+
}
|
|
1759
|
+
try {
|
|
1760
|
+
return await fn();
|
|
1761
|
+
} finally {
|
|
1762
|
+
await lock.close();
|
|
1763
|
+
await unlink(`${file}.lock`);
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
queue = next.catch(() => void 0);
|
|
1767
|
+
return next;
|
|
1768
|
+
};
|
|
1769
|
+
const route = (path, method, action) => {
|
|
1770
|
+
ctx.effect(() => deps.server.register({
|
|
1771
|
+
kind: "exact",
|
|
1772
|
+
path,
|
|
1773
|
+
async handler(request, response) {
|
|
1774
|
+
const send = (status, value) => {
|
|
1775
|
+
response.statusCode = status;
|
|
1776
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
1777
|
+
response.setHeader("cache-control", "no-store");
|
|
1778
|
+
response.end(JSON.stringify(value));
|
|
1779
|
+
};
|
|
1780
|
+
if (request.method !== method) return send(405, { error: "Method not allowed" });
|
|
1781
|
+
if (method !== "GET" && deps.crossSite(request)) return send(403, { error: "拒绝跨站修改请求" });
|
|
1782
|
+
try {
|
|
1783
|
+
send(200, await action(request, new URL(request.url ?? path, "http://localhost").searchParams));
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
send(400, { error: error instanceof Error ? error.message : "工时操作失败" });
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}), `tomato-board: ${path}`);
|
|
1789
|
+
};
|
|
1790
|
+
const taskBugs = createTaskBugService(deps.run, deps);
|
|
1791
|
+
async function refreshRequirementTasks(store, itemKey) {
|
|
1792
|
+
const group = [...store.units, ...store.archivedUnits ?? []].filter((unit) => unit.itemKey === itemKey);
|
|
1793
|
+
if (!group.length) return;
|
|
1794
|
+
const [context, current] = await Promise.all([storyContext(deps.run, itemKey), deps.run([
|
|
1795
|
+
"team",
|
|
1796
|
+
"user",
|
|
1797
|
+
"current"
|
|
1798
|
+
])]);
|
|
1799
|
+
const user = record(current);
|
|
1800
|
+
const tasks = [];
|
|
1801
|
+
for (let page = 1;; page++) {
|
|
1802
|
+
if (page > 100) throw new Error("需求下 Task 过多,无法完整核对");
|
|
1803
|
+
const payload = record(await deps.run([
|
|
1804
|
+
"team",
|
|
1805
|
+
"item",
|
|
1806
|
+
"search",
|
|
1807
|
+
"--iql",
|
|
1808
|
+
`${scheduledTaskIql} and ancestors in ${JSON.stringify([text(context.story.objectId ?? context.story.itemId)])}`,
|
|
1809
|
+
"--fields",
|
|
1810
|
+
"key,name,assignee,DateSDate,qiwangwanchengriqi,planStoryPoint",
|
|
1811
|
+
"--page",
|
|
1812
|
+
String(page),
|
|
1813
|
+
"--size",
|
|
1814
|
+
"50"
|
|
1815
|
+
]));
|
|
1816
|
+
if (!Array.isArray(payload.items)) throw new Error("读取需求 Task 失败,不能使用本地记录计算工时");
|
|
1817
|
+
for (const raw of payload.items) {
|
|
1818
|
+
const key = text(record(raw).key);
|
|
1819
|
+
if (!keyPattern.test(key) || tasks.some((task) => task.itemKey === key)) continue;
|
|
1820
|
+
const remote = record(await deps.run([
|
|
1821
|
+
"team",
|
|
1822
|
+
"item",
|
|
1823
|
+
"view",
|
|
1824
|
+
key
|
|
1825
|
+
]));
|
|
1826
|
+
const values = fieldValues(remote);
|
|
1827
|
+
if (!isScheduledTask(text(remote.itemType)) || !list(values.assignee).map(record).some((owner) => text(user.userId) && text(owner.objectId ?? owner.value) === text(user.userId) || text(user.username) && text(owner.username) === text(user.username))) continue;
|
|
1828
|
+
const parent = record(remote.ancestor);
|
|
1829
|
+
const ancestors = list(remote.ancestors).map((value) => text(record(value).objectId ?? value));
|
|
1830
|
+
const parentId = text(context.story.objectId ?? context.story.itemId);
|
|
1831
|
+
if (text(parent.objectId) ? text(parent.objectId) !== parentId : ancestors.length !== 1 || ancestors[0] !== parentId) continue;
|
|
1832
|
+
const start = localDate(values.DateSDate);
|
|
1833
|
+
const end = localDate(values.qiwangwanchengriqi);
|
|
1834
|
+
const days = Number(values.planStoryPoint);
|
|
1835
|
+
if (!start || !end || end < start || !Number.isFinite(days) || days <= 0) continue;
|
|
1836
|
+
const journal = group.flatMap((unit) => unit.tasks).find((task) => task.itemKey === key);
|
|
1837
|
+
tasks.push({
|
|
1838
|
+
stage: journal?.stage ?? "develop",
|
|
1839
|
+
itemType: text(remote.itemType),
|
|
1840
|
+
itemKey: key,
|
|
1841
|
+
title: text(remote.name),
|
|
1842
|
+
start,
|
|
1843
|
+
end,
|
|
1844
|
+
days,
|
|
1845
|
+
status: "created",
|
|
1846
|
+
...journal?.start === start && journal.end === end && journal.days === days && journal.allocationSlots ? { allocationSlots: [...journal.allocationSlots] } : {}
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
if (payload.items.length < 50 || payload.nextPageIndex === -1) break;
|
|
1850
|
+
}
|
|
1851
|
+
const journals = group.filter((unit) => unit.tasks.some((task) => task.status !== "pending"));
|
|
1852
|
+
for (const unit of journals) if (!store.creationHistory?.some((previous) => previous.id === unit.id && previous.revision === unit.revision && JSON.stringify(previous.tasks) === JSON.stringify(unit.tasks))) store.creationHistory = [...store.creationHistory ?? [], {
|
|
1853
|
+
...unit,
|
|
1854
|
+
tasks: [...unit.tasks]
|
|
1855
|
+
}];
|
|
1856
|
+
for (const unit of group) unit.tasks = [];
|
|
1857
|
+
for (const task of tasks) (store.units.find((unit) => unit.itemKey === itemKey && unit.start <= task.start && task.start <= unit.end) ?? group[0]).tasks.push(task);
|
|
1858
|
+
}
|
|
1859
|
+
route("/api/tomato-board/worktime/requirements", "GET", async (_, query) => searchRequirements(deps.run, query.get("q") ?? ""));
|
|
1860
|
+
const reportTask = createTaskReportService(deps.run);
|
|
1861
|
+
route("/api/tomato-board/worktime/report-options", "GET", async () => loadTaskReportConstraints(deps.run));
|
|
1862
|
+
route("/api/tomato-board/worktime/report", "POST", async (request) => {
|
|
1863
|
+
const input = await body(request);
|
|
1864
|
+
const start = String(input.start ?? "");
|
|
1865
|
+
const end = String(input.end ?? "");
|
|
1866
|
+
if (datesBetween(start, end).length !== 7) throw new Error("请选择完整的一周");
|
|
1867
|
+
if (!Array.isArray(input.tasks) || !input.tasks.length || input.tasks.length > 100) throw new Error("请选择 1–100 个 Task");
|
|
1868
|
+
const tasks = input.tasks.map((value) => record(value));
|
|
1869
|
+
if (new Set(tasks.map((task) => task.itemKey)).size !== tasks.length) throw new Error("Task 不可重复选择");
|
|
1870
|
+
const allocation = suggestReportDays(tasks.map((task) => ({
|
|
1871
|
+
itemKey: task.itemKey,
|
|
1872
|
+
start: task.expectedStart,
|
|
1873
|
+
end: task.expectedEnd
|
|
1874
|
+
})), start);
|
|
1875
|
+
if (allocation.conflicts.length) throw new Error(`${allocation.conflicts.map((value) => value.date).join("、")} 有超过两个 Task,最小单位为 0.5 人天,请减少本次选择或调整排期`);
|
|
1876
|
+
return serial(async () => {
|
|
1877
|
+
const store = await load();
|
|
1878
|
+
const user = record(await deps.run([
|
|
1879
|
+
"team",
|
|
1880
|
+
"user",
|
|
1881
|
+
"current"
|
|
1882
|
+
]));
|
|
1883
|
+
const constraints = await loadTaskReportConstraints(deps.run);
|
|
1884
|
+
const results = [];
|
|
1885
|
+
try {
|
|
1886
|
+
for (const task of tasks) {
|
|
1887
|
+
if (store.taskOperations?.some((operation) => operation.itemKey === task.itemKey) || store.groupOperations?.some((operation) => operation.tasks.some((value) => value.itemKey === task.itemKey))) {
|
|
1888
|
+
results.push({
|
|
1889
|
+
itemKey: task.itemKey,
|
|
1890
|
+
outcome: "failed",
|
|
1891
|
+
currentStatus: "",
|
|
1892
|
+
steps: [],
|
|
1893
|
+
fieldsWritten: false,
|
|
1894
|
+
error: "上次 Task 操作结果待核对,请先核对番茄"
|
|
1895
|
+
});
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
results.push(await reportTask(task, user, start, end, constraints));
|
|
1899
|
+
}
|
|
1900
|
+
return { results };
|
|
1901
|
+
} finally {
|
|
1902
|
+
deps.invalidate();
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
route("/api/tomato-board/worktime/task-bug-counts", "POST", async (request) => {
|
|
1907
|
+
const input = await body(request);
|
|
1908
|
+
if (!Array.isArray(input.itemKeys)) throw new Error("缺少 Task 编号列表");
|
|
1909
|
+
return taskBugs.counts(input.itemKeys);
|
|
1910
|
+
});
|
|
1911
|
+
route("/api/tomato-board/worktime/task-bugs", "GET", async (_, query) => taskBugs.load(query.get("itemKey") ?? "", query.get("q")?.trim() ?? ""));
|
|
1912
|
+
route("/api/tomato-board/worktime/task-bug", "POST", async (request) => {
|
|
1913
|
+
const input = await body(request);
|
|
1914
|
+
return serial(() => taskBugs.link(String(input.itemKey ?? ""), String(input.bugKey ?? "")));
|
|
1915
|
+
});
|
|
1916
|
+
route("/api/tomato-board/worktime", "GET", async (_, query) => {
|
|
1917
|
+
const store = await load();
|
|
1918
|
+
const assignee = query.get("assignee");
|
|
1919
|
+
if (!assignee || assignee === "currentUser()") {
|
|
1920
|
+
const itemKey = query.get("itemKey");
|
|
1921
|
+
if (itemKey) {
|
|
1922
|
+
if (!keyPattern.test(itemKey)) throw new Error("无效的需求编号");
|
|
1923
|
+
await refreshRequirementTasks(store, itemKey);
|
|
1924
|
+
return {
|
|
1925
|
+
units: store.units.filter((unit) => unit.itemKey === itemKey),
|
|
1926
|
+
archivedUnits: (store.archivedUnits ?? []).filter((unit) => unit.itemKey === itemKey)
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
return {
|
|
1930
|
+
...store,
|
|
1931
|
+
units: store.units.map((unit) => ({
|
|
1932
|
+
...unit,
|
|
1933
|
+
tasks: []
|
|
1934
|
+
})),
|
|
1935
|
+
archivedUnits: (store.archivedUnits ?? []).map((unit) => ({
|
|
1936
|
+
...unit,
|
|
1937
|
+
tasks: []
|
|
1938
|
+
})),
|
|
1939
|
+
creationHistory: void 0
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
if (assignee !== "currentUser()" && !/^[A-Za-z0-9_.@-]{1,128}$/u.test(assignee)) throw new Error("无效的负责人");
|
|
1943
|
+
return {
|
|
1944
|
+
...store,
|
|
1945
|
+
units: [],
|
|
1946
|
+
archivedUnits: [],
|
|
1947
|
+
creationHistory: void 0
|
|
1948
|
+
};
|
|
1949
|
+
});
|
|
1950
|
+
route("/api/tomato-board/worktime/metadata", "GET", async (_, query) => (await metadata(deps.run, query.get("itemKey") ?? "")).data);
|
|
1951
|
+
route("/api/tomato-board/worktime/ranges", "POST", async (request) => {
|
|
1952
|
+
const input = await body(request);
|
|
1953
|
+
const itemKey = String(input.itemKey ?? "");
|
|
1954
|
+
const ranges = list(input.ranges).map((value) => validateUnit({
|
|
1955
|
+
...record(value),
|
|
1956
|
+
itemKey
|
|
1957
|
+
}));
|
|
1958
|
+
if (!keyPattern.test(itemKey) || !Array.isArray(input.ranges) || new Set(ranges.map((range) => range.id)).size !== ranges.length) throw new Error("请选择有效且不重复的时间段");
|
|
1959
|
+
return serial(async () => {
|
|
1960
|
+
const store = await load();
|
|
1961
|
+
if (store.groupOperations?.some((operation) => operation.itemKey === itemKey)) throw new Error("需求上次移动结果待核对,请先核对番茄");
|
|
1962
|
+
const group = store.units.filter((unit) => unit.itemKey === itemKey);
|
|
1963
|
+
const claims = list(input.units).map(record);
|
|
1964
|
+
if (group.length !== claims.length || group.some((unit) => !claims.some((claim) => claim.id === unit.id && claim.revision === unit.revision))) throw new Error("安排已变化,请刷新后编辑");
|
|
1965
|
+
await refreshRequirementTasks(store, itemKey);
|
|
1966
|
+
const removed = group.filter((unit) => !ranges.some((range) => range.id === unit.id));
|
|
1967
|
+
freezeAllocations(store, itemKey);
|
|
1968
|
+
if (removed.some((unit) => unit.tasks.length)) store.archivedUnits = [...store.archivedUnits ?? [], ...removed.filter((unit) => unit.tasks.length)];
|
|
1969
|
+
if (!ranges.length) {
|
|
1970
|
+
store.units = store.units.filter((unit) => unit.itemKey !== itemKey);
|
|
1971
|
+
await save(store);
|
|
1972
|
+
return { units: [] };
|
|
1973
|
+
}
|
|
1974
|
+
const { story, workspace, assignees } = await storyContext(deps.run, itemKey);
|
|
1975
|
+
const next = ranges.map((range) => {
|
|
1976
|
+
const existing = store.units.find((unit) => unit.id === range.id);
|
|
1977
|
+
if (existing && existing.itemKey !== itemKey) throw new Error("时间段编号已被其他需求使用");
|
|
1978
|
+
if (existing && unitHasCreatedTasks(existing, store.units)) {
|
|
1979
|
+
if (existing.start !== range.start || existing.end !== range.end) throw new Error("已创建 Task 的时间段不能改动日期");
|
|
1980
|
+
return existing;
|
|
1981
|
+
}
|
|
1982
|
+
return {
|
|
1983
|
+
...existing,
|
|
1984
|
+
...range,
|
|
1985
|
+
title: text(story.name),
|
|
1986
|
+
workspace,
|
|
1987
|
+
tasks: existing?.tasks ?? [],
|
|
1988
|
+
assignees: assignees.flatMap((user) => [text(user.username), text(user.objectId)]).filter(Boolean),
|
|
1989
|
+
cost: String(input.cost ?? existing?.cost ?? ""),
|
|
1990
|
+
team: String(input.team ?? existing?.team ?? ""),
|
|
1991
|
+
category: String(input.category ?? existing?.category ?? ""),
|
|
1992
|
+
revision: (existing?.revision ?? 0) + 1,
|
|
1993
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1994
|
+
};
|
|
1995
|
+
});
|
|
1996
|
+
store.units = [...store.units.filter((unit) => unit.itemKey !== itemKey), ...next];
|
|
1997
|
+
mergeRanges(store, itemKey);
|
|
1998
|
+
await save(store);
|
|
1999
|
+
return { units: store.units.filter((unit) => unit.itemKey === itemKey) };
|
|
2000
|
+
});
|
|
2001
|
+
});
|
|
2002
|
+
route("/api/tomato-board/worktime/unit", "POST", async (request) => {
|
|
2003
|
+
const input = await body(request);
|
|
2004
|
+
const unit = validateUnit(input);
|
|
2005
|
+
return serial(async () => {
|
|
2006
|
+
const store = await load();
|
|
2007
|
+
if (store.groupOperations?.some((operation) => operation.itemKey === unit.itemKey)) throw new Error("需求上次移动结果待核对,请先核对番茄");
|
|
2008
|
+
const existing = store.units.find((value) => value.id === unit.id);
|
|
2009
|
+
freezeAllocations(store, unit.itemKey);
|
|
2010
|
+
if (existing && existing.itemKey !== unit.itemKey) throw new Error("需求安排不可替换,请新增安排");
|
|
2011
|
+
if (!existing && input.revision !== void 0) throw new Error("安排已发生变化,请刷新后编辑");
|
|
2012
|
+
if (existing && existing.revision !== input.revision) throw new Error("安排已发生变化,请刷新后编辑");
|
|
2013
|
+
if (existing && unitHasCreatedTasks(existing, store.units)) throw new Error("已创建 Task 的时间段不能改动日期;请新增安排以保留历史");
|
|
2014
|
+
const { story, workspace, assignees } = await storyContext(deps.run, unit.itemKey);
|
|
2015
|
+
const value = {
|
|
2016
|
+
...unit,
|
|
2017
|
+
title: text(story.name),
|
|
2018
|
+
workspace,
|
|
2019
|
+
tasks: existing?.tasks ?? [],
|
|
2020
|
+
assignees: assignees.flatMap((user) => [text(user.username), text(user.objectId)]).filter(Boolean),
|
|
2021
|
+
revision: (existing?.revision ?? 0) + 1,
|
|
2022
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2023
|
+
...Object.fromEntries([
|
|
2024
|
+
"cost",
|
|
2025
|
+
"team",
|
|
2026
|
+
"category"
|
|
2027
|
+
].filter((key) => typeof input[key] === "string").map((key) => [key, input[key]]))
|
|
2028
|
+
};
|
|
2029
|
+
if (existing) store.units[store.units.indexOf(existing)] = value;
|
|
2030
|
+
else store.units.push(value);
|
|
2031
|
+
mergeRanges(store, value.itemKey);
|
|
2032
|
+
await save(store);
|
|
2033
|
+
return { unit: survivor(store, value.itemKey, value.start) ?? value };
|
|
2034
|
+
});
|
|
2035
|
+
});
|
|
2036
|
+
route("/api/tomato-board/worktime/delete", "POST", async () => {
|
|
2037
|
+
throw new Error("需求安排不可删除,请对具体 Task 执行删除操作");
|
|
2038
|
+
});
|
|
2039
|
+
/** Verify ownership and the version of a remote Task before writing dates. */
|
|
2040
|
+
async function ownedTask(itemKey, start, end, user) {
|
|
2041
|
+
const raw = record(await deps.run([
|
|
2042
|
+
"team",
|
|
2043
|
+
"item",
|
|
2044
|
+
"view",
|
|
2045
|
+
itemKey
|
|
2046
|
+
]));
|
|
2047
|
+
if (!isScheduledTask(text(raw.itemType))) throw new Error("只允许对 Task 或跟踪任务同步日期或删除");
|
|
2048
|
+
const values = fieldValues(raw);
|
|
2049
|
+
if (!list(values.assignee).map(record).some((owner) => text(user.userId) && text(owner.objectId ?? owner.value) === text(user.userId) || text(user.username) && text(owner.username) === text(user.username))) throw new Error("只能修改当前登录用户负责的 Task");
|
|
2050
|
+
if (localDate(values.DateSDate) !== start || localDate(values.qiwangwanchengriqi) !== end) throw new Error("Task 日期已变化,请刷新后重试");
|
|
2051
|
+
return values;
|
|
2052
|
+
}
|
|
2053
|
+
async function writeTaskDates(itemKey, start, end) {
|
|
2054
|
+
await deps.run([
|
|
2055
|
+
"team",
|
|
2056
|
+
"item",
|
|
2057
|
+
"edit",
|
|
2058
|
+
itemKey,
|
|
2059
|
+
"--values",
|
|
2060
|
+
JSON.stringify({
|
|
2061
|
+
DateSDate: timestamp(start),
|
|
2062
|
+
qiwangwanchengriqi: timestamp(end)
|
|
2063
|
+
})
|
|
2064
|
+
]);
|
|
2065
|
+
const actual = fieldValues(await deps.run([
|
|
2066
|
+
"team",
|
|
2067
|
+
"item",
|
|
2068
|
+
"view",
|
|
2069
|
+
itemKey
|
|
2070
|
+
]));
|
|
2071
|
+
if (localDate(actual.DateSDate) !== start || localDate(actual.qiwangwanchengriqi) !== end) throw new Error("计划日期回读不一致");
|
|
2072
|
+
}
|
|
2073
|
+
route("/api/tomato-board/worktime/requirement", "POST", async (request) => {
|
|
2074
|
+
const input = await body(request);
|
|
2075
|
+
if (!["move", "resize"].includes(String(input.action))) throw new Error("无效的需求操作");
|
|
2076
|
+
if (input.action === "move" && (!Number.isInteger(input.delta) || Math.abs(Number(input.delta)) > 366)) throw new Error("移动范围最多 366 天");
|
|
2077
|
+
if (input.action === "resize") datesBetween(String(input.start ?? ""), String(input.end ?? ""));
|
|
2078
|
+
return serial(async () => {
|
|
2079
|
+
const store = await load();
|
|
2080
|
+
const group = store.units.filter((unit) => unit.itemKey === input.itemKey && unit.id === input.id);
|
|
2081
|
+
freezeAllocations(store, String(input.itemKey));
|
|
2082
|
+
const claims = list(input.units).map(record);
|
|
2083
|
+
if (!group.length || claims.length !== group.length || group.some((unit) => !claims.some((claim) => claim.id === unit.id && claim.revision === unit.revision))) throw new Error("需求安排已变化,请刷新后重试");
|
|
2084
|
+
if (store.groupOperations?.some((operation) => operation.itemKey === input.itemKey)) throw new Error("需求上次移动结果待核对,请先核对番茄");
|
|
2085
|
+
const change = input.action === "move" ? {
|
|
2086
|
+
action: "move",
|
|
2087
|
+
id: String(input.id),
|
|
2088
|
+
delta: Number(input.delta)
|
|
2089
|
+
} : {
|
|
2090
|
+
action: "resize",
|
|
2091
|
+
id: String(input.id),
|
|
2092
|
+
start: String(input.start),
|
|
2093
|
+
end: String(input.end)
|
|
2094
|
+
};
|
|
2095
|
+
if (change.action === "resize" && !group.some((unit) => unit.id === change.id)) throw new Error("时间段不存在");
|
|
2096
|
+
const next = applyRequirementChange(group, change).map((unit) => ({
|
|
2097
|
+
...unit,
|
|
2098
|
+
tasks: [...unit.tasks]
|
|
2099
|
+
}));
|
|
2100
|
+
for (const unit of next) datesBetween(unit.start, unit.end);
|
|
2101
|
+
for (const unit of next) {
|
|
2102
|
+
unit.revision += 1;
|
|
2103
|
+
unit.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2104
|
+
}
|
|
2105
|
+
store.units = store.units.map((unit) => next.find((value) => value.id === unit.id) ?? unit);
|
|
2106
|
+
store.units = mergeRequirementRanges(store.units, String(input.itemKey));
|
|
2107
|
+
await save(store);
|
|
2108
|
+
return { units: store.units };
|
|
2109
|
+
});
|
|
2110
|
+
});
|
|
2111
|
+
route("/api/tomato-board/worktime/task", "POST", async (request) => {
|
|
2112
|
+
const input = await body(request);
|
|
2113
|
+
const itemKey = String(input.itemKey ?? "");
|
|
2114
|
+
if (!keyPattern.test(itemKey) || !["move", "delete"].includes(String(input.action))) throw new Error("无效的 Task 操作");
|
|
2115
|
+
if (input.action === "delete" && input.confirm !== true) throw new Error("请确认将 Task 及其后代移入回收站");
|
|
2116
|
+
const start = String(input.start ?? "");
|
|
2117
|
+
const end = String(input.end ?? "");
|
|
2118
|
+
if (input.action === "move") datesBetween(start, end);
|
|
2119
|
+
return serial(async () => {
|
|
2120
|
+
const store = await load();
|
|
2121
|
+
if (store.taskOperations?.some((operation) => operation.itemKey === itemKey)) throw new Error("该 Task 上次同步结果待核对,请先在番茄确认,不能重复提交");
|
|
2122
|
+
if (store.groupOperations?.some((operation) => operation.tasks.some((task) => task.itemKey === itemKey))) throw new Error("所属需求上次移动结果待核对,请先核对番茄");
|
|
2123
|
+
const user = record(await deps.run([
|
|
2124
|
+
"team",
|
|
2125
|
+
"user",
|
|
2126
|
+
"current"
|
|
2127
|
+
]));
|
|
2128
|
+
await ownedTask(itemKey, String(input.expectedStart), String(input.expectedEnd), user);
|
|
2129
|
+
const local = [...store.units, ...store.archivedUnits ?? []].flatMap((unit) => unit.tasks.map((task) => ({
|
|
2130
|
+
unit,
|
|
2131
|
+
task
|
|
2132
|
+
}))).filter((value) => value.task.itemKey === itemKey);
|
|
2133
|
+
for (const key of new Set(local.map(({ unit }) => unit.itemKey))) freezeAllocations(store, key);
|
|
2134
|
+
const operation = {
|
|
2135
|
+
itemKey,
|
|
2136
|
+
action: String(input.action),
|
|
2137
|
+
...input.action === "move" ? {
|
|
2138
|
+
start,
|
|
2139
|
+
end
|
|
2140
|
+
} : {},
|
|
2141
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2142
|
+
};
|
|
2143
|
+
store.taskOperations = [...store.taskOperations ?? [], operation];
|
|
2144
|
+
await save(store);
|
|
2145
|
+
try {
|
|
2146
|
+
if (input.action === "delete") {
|
|
2147
|
+
await deps.run([
|
|
2148
|
+
"team",
|
|
2149
|
+
"item",
|
|
2150
|
+
"delete",
|
|
2151
|
+
itemKey,
|
|
2152
|
+
"--yes"
|
|
2153
|
+
]);
|
|
2154
|
+
for (const { unit, task } of local) unit.tasks = unit.tasks.filter((value) => value !== task);
|
|
2155
|
+
} else {
|
|
2156
|
+
await writeTaskDates(itemKey, start, end);
|
|
2157
|
+
for (const { task } of local) {
|
|
2158
|
+
task.start = start;
|
|
2159
|
+
task.end = end;
|
|
2160
|
+
delete task.allocationSlots;
|
|
2161
|
+
}
|
|
2162
|
+
for (const key of new Set(local.map(({ unit }) => unit.itemKey))) freezeAllocations(store, key);
|
|
2163
|
+
}
|
|
2164
|
+
for (const { unit } of local) {
|
|
2165
|
+
unit.revision += 1;
|
|
2166
|
+
unit.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2167
|
+
}
|
|
2168
|
+
store.taskOperations = store.taskOperations.filter((value) => value !== operation);
|
|
2169
|
+
await save(store);
|
|
2170
|
+
deps.invalidate();
|
|
2171
|
+
return {
|
|
2172
|
+
itemKey,
|
|
2173
|
+
action: input.action,
|
|
2174
|
+
start,
|
|
2175
|
+
end
|
|
2176
|
+
};
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
throw new Error(`Task 同步结果待核对,请刷新并在番茄确认:${error instanceof Error ? error.message : "操作失败"}`);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
});
|
|
2182
|
+
route("/api/tomato-board/worktime/create", "POST", async (request) => {
|
|
2183
|
+
const input = await body(request);
|
|
2184
|
+
const claims = (list(input.units).length ? list(input.units) : typeof input.id === "string" ? [{
|
|
2185
|
+
id: input.id,
|
|
2186
|
+
revision: input.revision
|
|
2187
|
+
}] : []).map((raw) => {
|
|
2188
|
+
const value = record(raw);
|
|
2189
|
+
return {
|
|
2190
|
+
id: String(value.id ?? ""),
|
|
2191
|
+
revision: Number(value.revision)
|
|
2192
|
+
};
|
|
2193
|
+
});
|
|
2194
|
+
if (!claims.length) throw new Error("缺少操作单元");
|
|
2195
|
+
return serial(async () => {
|
|
2196
|
+
const store = await load();
|
|
2197
|
+
const first = store.units.find((value) => value.id === claims[0].id);
|
|
2198
|
+
if (!first) throw new Error("安排不存在,请先保存");
|
|
2199
|
+
if (store.groupOperations?.some((operation) => operation.itemKey === first.itemKey)) throw new Error("需求上次移动结果待核对,请先核对番茄");
|
|
2200
|
+
const batch = claims.map((claim) => {
|
|
2201
|
+
const unit = store.units.find((value) => value.id === claim.id);
|
|
2202
|
+
if (!unit || unit.itemKey !== first.itemKey || unit.revision !== claim.revision) throw new Error("安排已变化,请刷新后重试");
|
|
2203
|
+
return unit;
|
|
2204
|
+
});
|
|
2205
|
+
await refreshRequirementTasks(store, first.itemKey);
|
|
2206
|
+
const group = store.units.filter((value) => value.itemKey === first.itemKey);
|
|
2207
|
+
const dates = unitDates(batch);
|
|
2208
|
+
const drafts = list(input.tasks).map(record).map((task) => ({
|
|
2209
|
+
stage: String(task.stage ?? ""),
|
|
2210
|
+
title: String(task.title ?? ""),
|
|
2211
|
+
days: Number(task.days)
|
|
2212
|
+
})).filter((task) => task.days !== 0);
|
|
2213
|
+
const existing = [...group, ...(store.archivedUnits ?? []).filter((unit) => unit.itemKey === first.itemKey)].flatMap((unit) => unit.tasks);
|
|
2214
|
+
freezeAllocations(store, first.itemKey);
|
|
2215
|
+
const tasks = allocateGroupTasks(dates, drafts, existing, unitDates([...group, ...(store.archivedUnits ?? []).filter((unit) => unit.itemKey === first.itemKey)]));
|
|
2216
|
+
const { options, data, context, taskAssignees, definitions } = await metadata(deps.run, first.itemKey);
|
|
2217
|
+
const optionValue = (key, label) => {
|
|
2218
|
+
const match = options(key).find((option) => option.label === label);
|
|
2219
|
+
if (!match) throw new Error(`请选择有效的 ${key}`);
|
|
2220
|
+
return [match.value];
|
|
2221
|
+
};
|
|
2222
|
+
const common = {
|
|
2223
|
+
view_project: [context.project],
|
|
2224
|
+
Dropdown2: optionValue("Dropdown2", input.cost),
|
|
2225
|
+
suoshutuandui: optionValue("suoshutuandui", input.team),
|
|
2226
|
+
Dropdown_task: optionValue("Dropdown_task", input.category),
|
|
2227
|
+
assignee: taskAssignees.map(userValue),
|
|
2228
|
+
...sprintIds(context.fields.sprint).length ? { sprint: sprintIds(context.fields.sprint) } : {}
|
|
2229
|
+
};
|
|
2230
|
+
if (!data.categories.includes(String(input.category))) throw new Error("任务分类仅支持软件开发或修复BUG");
|
|
2231
|
+
const planKeys = new Set([
|
|
2232
|
+
"DateSDate",
|
|
2233
|
+
"qiwangwanchengriqi",
|
|
2234
|
+
"planStoryPoint"
|
|
2235
|
+
]);
|
|
2236
|
+
const actualKeys = [
|
|
2237
|
+
"StoryPoint1",
|
|
2238
|
+
"StartDate",
|
|
2239
|
+
"ActualFinishTime"
|
|
2240
|
+
];
|
|
2241
|
+
const actualLabels = {
|
|
2242
|
+
StoryPoint1: "实际工作量",
|
|
2243
|
+
StartDate: "实际开始时间",
|
|
2244
|
+
ActualFinishTime: "实际完成时间"
|
|
2245
|
+
};
|
|
2246
|
+
const prepared = tasks.map((task) => {
|
|
2247
|
+
const values = {
|
|
2248
|
+
...common,
|
|
2249
|
+
DateSDate: timestamp(task.start),
|
|
2250
|
+
qiwangwanchengriqi: timestamp(task.end),
|
|
2251
|
+
planStoryPoint: task.days,
|
|
2252
|
+
StoryPoint1: task.days,
|
|
2253
|
+
StartDate: timestamp(task.start),
|
|
2254
|
+
ActualFinishTime: timestamp(task.end)
|
|
2255
|
+
};
|
|
2256
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
2257
|
+
const skippedLabels = [];
|
|
2258
|
+
for (const field of definitions) {
|
|
2259
|
+
const key = String(field.key);
|
|
2260
|
+
if (field.required === true && ![
|
|
2261
|
+
"name",
|
|
2262
|
+
"workspace",
|
|
2263
|
+
"itemType"
|
|
2264
|
+
].includes(key) && !(key in values)) throw new Error(`创建界面新增必填字段:${text(field.name)},请先补充支持`);
|
|
2265
|
+
const value = values[key];
|
|
2266
|
+
const range = list(record(field.property).range);
|
|
2267
|
+
if (!(typeof value === "number" && range.length === 2 && typeof range[0] === "number" && typeof range[1] === "number" && (value < range[0] || value > range[1]))) continue;
|
|
2268
|
+
if (field.required === true || planKeys.has(key)) throw new Error(`${text(field.name)}允许范围为 ${localDate(range[0])}~${localDate(range[1])},所选日期超出范围,无法创建 Task`);
|
|
2269
|
+
delete values[key];
|
|
2270
|
+
skipped.add(key);
|
|
2271
|
+
skippedLabels.push(text(field.name));
|
|
2272
|
+
}
|
|
2273
|
+
if (actualKeys.some((key) => skipped.has(key))) for (const key of actualKeys) {
|
|
2274
|
+
if (!(key in values)) continue;
|
|
2275
|
+
delete values[key];
|
|
2276
|
+
if (skipped.has(key)) continue;
|
|
2277
|
+
skipped.add(key);
|
|
2278
|
+
skippedLabels.push(actualLabels[key]);
|
|
2279
|
+
}
|
|
2280
|
+
if (skippedLabels.length) task.skipped = skippedLabels;
|
|
2281
|
+
return {
|
|
2282
|
+
task,
|
|
2283
|
+
values,
|
|
2284
|
+
skipped
|
|
2285
|
+
};
|
|
2286
|
+
});
|
|
2287
|
+
for (const unit of batch) unit.tasks = unit.tasks.filter((task) => task.status === "created");
|
|
2288
|
+
for (const task of tasks) (batch.find((unit) => unit.start <= task.start && task.start <= unit.end) ?? batch[0]).tasks.push(task);
|
|
2289
|
+
for (const unit of batch) {
|
|
2290
|
+
unit.project = context.project;
|
|
2291
|
+
unit.team = String(input.team);
|
|
2292
|
+
unit.cost = String(input.cost);
|
|
2293
|
+
unit.category = String(input.category);
|
|
2294
|
+
unit.revision += 1;
|
|
2295
|
+
unit.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2296
|
+
}
|
|
2297
|
+
await save(store);
|
|
2298
|
+
for (const { task, values, skipped } of prepared) {
|
|
2299
|
+
task.status = "creating";
|
|
2300
|
+
await save(store);
|
|
2301
|
+
try {
|
|
2302
|
+
const created = record(await deps.run([
|
|
2303
|
+
"team",
|
|
2304
|
+
"item",
|
|
2305
|
+
"create",
|
|
2306
|
+
"--workspace",
|
|
2307
|
+
context.workspace,
|
|
2308
|
+
"--item-type",
|
|
2309
|
+
"Task",
|
|
2310
|
+
"--parent",
|
|
2311
|
+
first.itemKey,
|
|
2312
|
+
"--title",
|
|
2313
|
+
task.title,
|
|
2314
|
+
"--values",
|
|
2315
|
+
JSON.stringify(values)
|
|
2316
|
+
]));
|
|
2317
|
+
const key = text(created.itemKey ?? created.key ?? record(created.result).itemKey);
|
|
2318
|
+
if (!keyPattern.test(key)) throw new Error("创建返回缺少事项编号,结果待核对");
|
|
2319
|
+
task.itemKey = key;
|
|
2320
|
+
await save(store);
|
|
2321
|
+
const verified = record(await deps.run([
|
|
2322
|
+
"team",
|
|
2323
|
+
"item",
|
|
2324
|
+
"view",
|
|
2325
|
+
key
|
|
2326
|
+
]));
|
|
2327
|
+
const actual = fieldValues(verified);
|
|
2328
|
+
if (!list(verified.ancestors).map((value) => text(record(value).objectId ?? value)).includes(text(context.story.objectId ?? context.story.itemId))) throw new Error("父故事校验失败");
|
|
2329
|
+
const broken = [
|
|
2330
|
+
["name", text(verified.name) === task.title],
|
|
2331
|
+
["view_project", text(actual.view_project) === text(values.view_project)],
|
|
2332
|
+
["sprint", !("sprint" in values) || JSON.stringify(sprintIds(actual.sprint)) === JSON.stringify(sprintIds(values.sprint))],
|
|
2333
|
+
["Dropdown2", text(actual.Dropdown2) === text(values.Dropdown2)],
|
|
2334
|
+
["suoshutuandui", text(actual.suoshutuandui) === text(values.suoshutuandui)],
|
|
2335
|
+
["Dropdown_task", text(actual.Dropdown_task) === text(values.Dropdown_task)],
|
|
2336
|
+
["planStoryPoint", Number(actual.planStoryPoint) === task.days],
|
|
2337
|
+
["DateSDate", localDate(actual.DateSDate) === task.start],
|
|
2338
|
+
["qiwangwanchengriqi", localDate(actual.qiwangwanchengriqi) === task.end]
|
|
2339
|
+
].find(([field, ok]) => !ok && !skipped.has(field));
|
|
2340
|
+
if (broken) throw new Error(`Task ${broken[0]} 回读校验失败`);
|
|
2341
|
+
const readBackActual = [
|
|
2342
|
+
["StoryPoint1", Number(actual.StoryPoint1) === task.days],
|
|
2343
|
+
["StartDate", localDate(actual.StartDate) === task.start],
|
|
2344
|
+
["ActualFinishTime", localDate(actual.ActualFinishTime) === task.end]
|
|
2345
|
+
];
|
|
2346
|
+
for (const [field, ok] of readBackActual) {
|
|
2347
|
+
if (ok || skipped.has(field)) continue;
|
|
2348
|
+
skipped.add(field);
|
|
2349
|
+
task.skipped = [...task.skipped ?? [], actualLabels[field]];
|
|
2350
|
+
}
|
|
2351
|
+
const owners = list(actual.assignee).map((value) => text(record(value).objectId ?? record(value).value));
|
|
2352
|
+
if (owners.length !== taskAssignees.length || !taskAssignees.every((user) => owners.includes(text(user.objectId)))) throw new Error("Task 负责人回读校验失败");
|
|
2353
|
+
task.status = "created";
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
task.status = "uncertain";
|
|
2356
|
+
task.error = error instanceof Error ? error.message : "创建结果待核对";
|
|
2357
|
+
await save(store);
|
|
2358
|
+
return {
|
|
2359
|
+
unit: first,
|
|
2360
|
+
units: group,
|
|
2361
|
+
error: `已保留创建记录,请核对后处理:${task.error}`
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
await save(store);
|
|
2365
|
+
}
|
|
2366
|
+
deps.invalidate();
|
|
2367
|
+
return {
|
|
2368
|
+
unit: first,
|
|
2369
|
+
units: group
|
|
2370
|
+
};
|
|
2371
|
+
});
|
|
2372
|
+
});
|
|
2373
|
+
route("/api/tomato-board/worktime/history", "GET", async (_, query) => {
|
|
2374
|
+
const start = query.get("start") ?? "";
|
|
2375
|
+
const end = query.get("end") ?? "";
|
|
2376
|
+
datesBetween(start, end);
|
|
2377
|
+
const assignee = query.get("assignee") || "currentUser()";
|
|
2378
|
+
if (assignee !== "currentUser()" && !/^[A-Za-z0-9_.@-]{1,128}$/u.test(assignee)) throw new Error("无效的负责人");
|
|
2379
|
+
const iql = `负责人 in [${assignee === "currentUser()" ? assignee : JSON.stringify(assignee)}] and ${scheduledTaskIql} and DateSDate <= ${JSON.stringify(end)} and qiwangwanchengriqi >= ${JSON.stringify(start)}`;
|
|
2380
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
2381
|
+
let seen = 0;
|
|
2382
|
+
let truncated = false;
|
|
2383
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
2384
|
+
const payload = record(await deps.run([
|
|
2385
|
+
"team",
|
|
2386
|
+
"item",
|
|
2387
|
+
"search",
|
|
2388
|
+
"--iql",
|
|
2389
|
+
iql,
|
|
2390
|
+
"--page",
|
|
2391
|
+
String(page),
|
|
2392
|
+
"--size",
|
|
2393
|
+
"50",
|
|
2394
|
+
"--fields",
|
|
2395
|
+
"key,name,status,DateSDate,qiwangwanchengriqi,planStoryPoint,StartDate,ActualFinishTime,StoryPoint1"
|
|
2396
|
+
]));
|
|
2397
|
+
const rows = list(payload.items);
|
|
2398
|
+
for (const raw of rows) candidates.add(text(record(raw).key ?? record(raw).itemKey));
|
|
2399
|
+
seen += rows.length;
|
|
2400
|
+
const total = Number(payload.count ?? payload.total);
|
|
2401
|
+
if (!rows.length || (Number.isFinite(total) ? seen >= total : rows.length < 50)) break;
|
|
2402
|
+
if (page === 100) truncated = true;
|
|
2403
|
+
}
|
|
2404
|
+
const user = assignee === "currentUser()" ? record(await deps.run([
|
|
2405
|
+
"team",
|
|
2406
|
+
"user",
|
|
2407
|
+
"current"
|
|
2408
|
+
])) : {
|
|
2409
|
+
username: assignee,
|
|
2410
|
+
userId: assignee
|
|
2411
|
+
};
|
|
2412
|
+
const keys = [...candidates].filter((key) => keyPattern.test(key));
|
|
2413
|
+
const tasks = [];
|
|
2414
|
+
let cursor = 0;
|
|
2415
|
+
await Promise.all(Array.from({ length: Math.min(4, keys.length) }, async () => {
|
|
2416
|
+
while (cursor < keys.length) {
|
|
2417
|
+
const itemKey = keys[cursor++];
|
|
2418
|
+
const item = record(await deps.run([
|
|
2419
|
+
"team",
|
|
2420
|
+
"item",
|
|
2421
|
+
"view",
|
|
2422
|
+
itemKey
|
|
2423
|
+
]));
|
|
2424
|
+
const value = fieldValues(item);
|
|
2425
|
+
if (!isScheduledTask(text(item.itemType)) || !list(value.assignee).map(record).some((owner) => text(user.userId) && text(owner.objectId ?? owner.value) === text(user.userId) || text(user.username) && text(owner.username) === text(user.username))) continue;
|
|
2426
|
+
const taskStart = localDate(value.DateSDate);
|
|
2427
|
+
const taskEnd = localDate(value.qiwangwanchengriqi);
|
|
2428
|
+
if (!taskStart || !taskEnd || taskStart > taskEnd || taskStart > end || taskEnd < start) continue;
|
|
2429
|
+
tasks.push({
|
|
2430
|
+
itemKey,
|
|
2431
|
+
itemType: text(item.itemType),
|
|
2432
|
+
title: text(item.name ?? item.title),
|
|
2433
|
+
status: text(item.status),
|
|
2434
|
+
start: taskStart,
|
|
2435
|
+
end: taskEnd,
|
|
2436
|
+
days: Number(value.planStoryPoint) || 0,
|
|
2437
|
+
actualDays: value.StoryPoint1 == null ? null : Number(value.StoryPoint1),
|
|
2438
|
+
actualStart: localDate(value.StartDate),
|
|
2439
|
+
actualEnd: localDate(value.ActualFinishTime)
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
}));
|
|
2443
|
+
tasks.sort((a, b) => a.start.localeCompare(b.start) || a.itemKey.localeCompare(b.itemKey));
|
|
2444
|
+
return {
|
|
2445
|
+
tasks,
|
|
2446
|
+
...truncated ? { truncated: true } : {}
|
|
2447
|
+
};
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
//#endregion
|
|
2451
|
+
//#region src/index.ts
|
|
5
2452
|
const execFileAsync = promisify(execFile);
|
|
6
2453
|
const ROUTE = "/api/tomato-board/items";
|
|
7
2454
|
const OPEN_ROUTE = "/api/tomato-board/open";
|
|
@@ -86,6 +2533,7 @@ function normalizeItem(raw, priorityNames = DEFAULT_PRIORITY_NAMES) {
|
|
|
86
2533
|
workspaceKey,
|
|
87
2534
|
workspaceName,
|
|
88
2535
|
creator: firstText(item.creator ?? item.createdBy ?? nested["创建人"] ?? nested.createdBy),
|
|
2536
|
+
createdAt: firstText(item.createdAt ?? nested.createdAt ?? nested["创建时间"]),
|
|
89
2537
|
assignees: textList(item.assignees ?? item.assignee ?? nested.assignees ?? nested.assignee ?? nested["负责人"]),
|
|
90
2538
|
priority: (() => {
|
|
91
2539
|
const priority = firstText(item.priority ?? nested.priority ?? nested["优先级"]);
|
|
@@ -120,8 +2568,8 @@ function tomatoItemUrl(config, itemKey) {
|
|
|
120
2568
|
}
|
|
121
2569
|
function cliSettings(config) {
|
|
122
2570
|
return {
|
|
123
|
-
executable: config.executable || process.env.TOMATO_CLI_EXECUTABLE || "gitee",
|
|
124
|
-
profile: config.profile || process.env.TOMATO_PROFILE || "osc"
|
|
2571
|
+
executable: resolveCliExecutable(config.executable || process$1.env.TOMATO_CLI_EXECUTABLE || "gitee"),
|
|
2572
|
+
profile: config.profile || process$1.env.TOMATO_PROFILE || "osc"
|
|
125
2573
|
};
|
|
126
2574
|
}
|
|
127
2575
|
async function runJson(config, args) {
|
|
@@ -207,7 +2655,7 @@ async function loadItems(config, assignee) {
|
|
|
207
2655
|
"--iql",
|
|
208
2656
|
iql,
|
|
209
2657
|
"--fields",
|
|
210
|
-
"priority,createdBy,assignee",
|
|
2658
|
+
"key,name,status,itemType,workspace,priority,createdBy,assignee,createdAt",
|
|
211
2659
|
"--output",
|
|
212
2660
|
"json"
|
|
213
2661
|
], {
|
|
@@ -340,7 +2788,7 @@ function storyPointValue(raw) {
|
|
|
340
2788
|
const value = Number(raw);
|
|
341
2789
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
342
2790
|
}
|
|
343
|
-
async function
|
|
2791
|
+
async function readSprints(config) {
|
|
344
2792
|
const sprints = [];
|
|
345
2793
|
for (let page = 1; page <= 100; page += 1) {
|
|
346
2794
|
const payload = await runJson(config, [
|
|
@@ -360,9 +2808,34 @@ async function loadSprints(config) {
|
|
|
360
2808
|
}
|
|
361
2809
|
throw new Error("迭代数量超出读取上限,请缩小查询范围");
|
|
362
2810
|
}
|
|
2811
|
+
const sprintReads = /* @__PURE__ */ new Map();
|
|
2812
|
+
function loadSprints(config) {
|
|
2813
|
+
const key = JSON.stringify([
|
|
2814
|
+
config.executable,
|
|
2815
|
+
config.profile,
|
|
2816
|
+
config.tomatoOrigin,
|
|
2817
|
+
config.tomatoTenant
|
|
2818
|
+
]);
|
|
2819
|
+
const cached = sprintReads.get(key);
|
|
2820
|
+
if (cached && cached.expires > Date.now()) return cached.promise;
|
|
2821
|
+
if (sprintReads.size >= 20) sprintReads.delete(sprintReads.keys().next().value);
|
|
2822
|
+
const promise = readSprints(config).catch((error) => {
|
|
2823
|
+
if (sprintReads.get(key)?.promise === promise) sprintReads.delete(key);
|
|
2824
|
+
throw error;
|
|
2825
|
+
});
|
|
2826
|
+
sprintReads.set(key, {
|
|
2827
|
+
expires: Date.now() + 3e4,
|
|
2828
|
+
promise
|
|
2829
|
+
});
|
|
2830
|
+
return promise;
|
|
2831
|
+
}
|
|
363
2832
|
async function loadStoryItems(config, sprint, assignee) {
|
|
364
2833
|
const owner = assignee === "currentUser()" ? assignee : JSON.stringify(assignee);
|
|
365
|
-
const
|
|
2834
|
+
const selected = (await loadSprints(config)).sprints.find((value) => firstText(value.sprintId ?? value.objectId) === sprint);
|
|
2835
|
+
if (!selected) throw new Error("所选迭代不存在或无权查看,请刷新迭代列表");
|
|
2836
|
+
const period = teamPeriod(selected.startDate, selected.endDate);
|
|
2837
|
+
const window = `DateSDate <= ${JSON.stringify(period.end)} and qiwangwanchengriqi >= ${JSON.stringify(period.start)}`;
|
|
2838
|
+
const iql = `负责人 in [${owner}] and ((迭代 = ${JSON.stringify(sprint)} and 类型 in [Story, EnablerStory]) or (类型 in [Story, EnablerStory, Task] and ${window}))`;
|
|
366
2839
|
const items = /* @__PURE__ */ new Map();
|
|
367
2840
|
const limit = Math.max(1, Math.min(config.maxItems ?? 5e3, 2e4));
|
|
368
2841
|
for (let page = 1;; page += 1) {
|
|
@@ -373,7 +2846,7 @@ async function loadStoryItems(config, sprint, assignee) {
|
|
|
373
2846
|
"--iql",
|
|
374
2847
|
iql,
|
|
375
2848
|
"--fields",
|
|
376
|
-
"StoryPoint,assignee,sprint",
|
|
2849
|
+
"key,name,status,itemType,StoryPoint,planStoryPoint,DateSDate,qiwangwanchengriqi,assignee,sprint",
|
|
377
2850
|
"--page",
|
|
378
2851
|
String(page),
|
|
379
2852
|
"--size",
|
|
@@ -385,23 +2858,21 @@ async function loadStoryItems(config, sprint, assignee) {
|
|
|
385
2858
|
const record = raw;
|
|
386
2859
|
const fields = record.values ?? record.value ?? record;
|
|
387
2860
|
const itemSprints = fieldObjectIds(fields.sprint ?? fields.Sprint ?? fields["迭代"]);
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
"EnablerStory",
|
|
391
|
-
"Task"
|
|
392
|
-
].includes(item.itemType);
|
|
393
|
-
if (item.itemKey && isPlanningItem && itemSprints.includes(sprint)) items.set(item.itemKey, {
|
|
2861
|
+
const metrics = teamItemMetrics(item.itemType, fields, itemSprints.includes(sprint), period);
|
|
2862
|
+
if (item.itemKey && metrics) items.set(item.itemKey, {
|
|
394
2863
|
...item,
|
|
395
|
-
|
|
2864
|
+
...metrics,
|
|
396
2865
|
tomatoUrl: tomatoItemUrl(config, item.itemKey)
|
|
397
2866
|
});
|
|
398
2867
|
}
|
|
399
2868
|
const total = Number(payload.count ?? payload.total);
|
|
400
2869
|
if (!(Number(payload.nextPageIndex) > page || payload.hasMore === true || payload.hasNext === true || (Number.isFinite(total) ? page * 50 < total : values.length === 50)) || !values.length) return {
|
|
2870
|
+
period,
|
|
401
2871
|
items: [...items.values()].slice(0, limit),
|
|
402
2872
|
truncated: items.size > limit
|
|
403
2873
|
};
|
|
404
2874
|
if (page * 50 >= limit) return {
|
|
2875
|
+
period,
|
|
405
2876
|
items: [...items.values()].slice(0, limit),
|
|
406
2877
|
truncated: true
|
|
407
2878
|
};
|
|
@@ -443,6 +2914,27 @@ const name = "tomato-board";
|
|
|
443
2914
|
const inject = ["webServer"];
|
|
444
2915
|
function apply(ctx, config = {}) {
|
|
445
2916
|
const webServer = ctx.get("webServer");
|
|
2917
|
+
registerPrAssistant(ctx, {
|
|
2918
|
+
...config.executable ? { executable: config.executable } : {},
|
|
2919
|
+
profile: config.profile || process$1.env.TOMATO_PROFILE || "osc",
|
|
2920
|
+
...config.prAssistant
|
|
2921
|
+
});
|
|
2922
|
+
registerWorktime(ctx, {
|
|
2923
|
+
server: webServer,
|
|
2924
|
+
run: (args) => runJson(config, args),
|
|
2925
|
+
crossSite: isCrossSite,
|
|
2926
|
+
scope: JSON.stringify([
|
|
2927
|
+
config.profile || process$1.env.TOMATO_PROFILE || "osc",
|
|
2928
|
+
config.tomatoOrigin ?? "https://osc.gitee.work",
|
|
2929
|
+
config.tomatoTenant ?? "xly-poc"
|
|
2930
|
+
]),
|
|
2931
|
+
...config.worktimeDataFile ? { dataFile: config.worktimeDataFile } : {},
|
|
2932
|
+
invalidate: () => {
|
|
2933
|
+
itemsCache = null;
|
|
2934
|
+
},
|
|
2935
|
+
bugLinkTypeId: config.bugLinkTypeId ?? ((config.tomatoOrigin ?? "https://osc.gitee.work") === "https://osc.gitee.work" && (config.tomatoTenant ?? "xly-poc") === "xly-poc" ? "c1FJJzOlPe" : ""),
|
|
2936
|
+
...config.bugLinkNames ? { bugLinkNames: config.bugLinkNames } : {}
|
|
2937
|
+
});
|
|
446
2938
|
ctx.effect(() => webServer.register({
|
|
447
2939
|
kind: "exact",
|
|
448
2940
|
path: "/api/tomato-board/sprints",
|
|
@@ -455,9 +2947,9 @@ function apply(ctx, config = {}) {
|
|
|
455
2947
|
}
|
|
456
2948
|
}
|
|
457
2949
|
}), "tomato-board: sprints");
|
|
458
|
-
ctx.effect(() => webServer.register({
|
|
2950
|
+
for (const path of ["/api/tomato-board/team-management", "/api/tomato-board/story-points"]) ctx.effect(() => webServer.register({
|
|
459
2951
|
kind: "exact",
|
|
460
|
-
path
|
|
2952
|
+
path,
|
|
461
2953
|
async handler(request, response) {
|
|
462
2954
|
if (request.method !== "GET") return sendJson(response, 405, { error: "Method not allowed" });
|
|
463
2955
|
const query = new URL(request.url ?? "/", "http://localhost").searchParams;
|