huddle60 0.0.0-stage → 0.1.1
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 +231 -2
- package/dist/action.js +1601 -0
- package/dist/action.js.map +7 -0
- package/dist/cli.js +3180 -0
- package/dist/cli.js.map +7 -0
- package/package.json +43 -3
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/epic.yml +35 -0
- package/templates/.github/ISSUE_TEMPLATE/request.yml +18 -0
- package/templates/.github/ISSUE_TEMPLATE/story.yml +45 -0
- package/templates/.github/ISSUE_TEMPLATE/task.yml +43 -0
- package/templates/.github/PULL_REQUEST_TEMPLATE.md +12 -0
- package/templates/.github/huddle60/policy.yml +38 -0
- package/templates/.github/workflows/huddle60.yml +26 -0
- package/templates/AGENTS.md +12 -0
- package/ui/app.js +785 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +145 -0
- package/ui/styles.css +873 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
// huddle60 local app (AI-DLC). Plain ES module, no build step. All user/model text is set via textContent.
|
|
2
|
+
|
|
3
|
+
const TOKEN_KEY = "ai-dlc-token";
|
|
4
|
+
const POLL_MS = 3000;
|
|
5
|
+
const POLL_MAX = 60;
|
|
6
|
+
|
|
7
|
+
const state = {
|
|
8
|
+
session: null,
|
|
9
|
+
items: [],
|
|
10
|
+
filter: "mine",
|
|
11
|
+
query: "",
|
|
12
|
+
pending: new Map(), // issueNumber -> action
|
|
13
|
+
draft: null,
|
|
14
|
+
collapsed: new Set(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ---------- utilities ----------
|
|
18
|
+
|
|
19
|
+
const $ = (selector) => document.querySelector(selector);
|
|
20
|
+
|
|
21
|
+
function h(tag, attrs = {}, ...children) {
|
|
22
|
+
const el = document.createElement(tag);
|
|
23
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
24
|
+
if (value === undefined || value === null || value === false) continue;
|
|
25
|
+
if (key === "class") el.className = value;
|
|
26
|
+
else if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
|
|
27
|
+
else if (value === true) el.setAttribute(key, "");
|
|
28
|
+
else el.setAttribute(key, String(value));
|
|
29
|
+
}
|
|
30
|
+
for (const child of children.flat()) {
|
|
31
|
+
if (child === null || child === undefined || child === false) continue;
|
|
32
|
+
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
33
|
+
}
|
|
34
|
+
return el;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readToken() {
|
|
38
|
+
const match = /(?:^|&)token=([\w-]+)/.exec(location.hash.slice(1));
|
|
39
|
+
if (match) {
|
|
40
|
+
sessionStorage.setItem(TOKEN_KEY, match[1]);
|
|
41
|
+
history.replaceState(null, "", location.pathname);
|
|
42
|
+
}
|
|
43
|
+
return sessionStorage.getItem(TOKEN_KEY);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const token = readToken();
|
|
47
|
+
|
|
48
|
+
class ApiError extends Error {
|
|
49
|
+
constructor(code, message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function api(path, body) {
|
|
56
|
+
const init = { headers: { "x-ai-dlc-token": token ?? "" } };
|
|
57
|
+
if (body !== undefined) {
|
|
58
|
+
init.method = "POST";
|
|
59
|
+
init.headers["content-type"] = "application/json";
|
|
60
|
+
init.body = JSON.stringify(body);
|
|
61
|
+
}
|
|
62
|
+
const response = await fetch(path, init);
|
|
63
|
+
const data = await response.json().catch(() => ({}));
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const error = data.error ?? { code: "E_HTTP", message: `HTTP ${response.status}` };
|
|
66
|
+
throw new ApiError(error.code, error.message);
|
|
67
|
+
}
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function issueUrl(number) {
|
|
72
|
+
return `${state.session.repo.url}/issues/${number}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasRole(role) {
|
|
76
|
+
return state.session?.roles.includes(role) ?? false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function withBusy(button, fn) {
|
|
80
|
+
button.disabled = true;
|
|
81
|
+
button.classList.add("busy");
|
|
82
|
+
try {
|
|
83
|
+
return await fn();
|
|
84
|
+
} finally {
|
|
85
|
+
button.disabled = false;
|
|
86
|
+
button.classList.remove("busy");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- activity log ----------
|
|
91
|
+
|
|
92
|
+
function log(kind, title, detail) {
|
|
93
|
+
const time = new Date().toLocaleTimeString([], {
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
second: "2-digit",
|
|
97
|
+
});
|
|
98
|
+
const item = h("li", { class: kind }, h("span", { class: "time" }, time), title);
|
|
99
|
+
const extra = h("div");
|
|
100
|
+
item.append(extra);
|
|
101
|
+
const set = (nextKind, nextTitle, nextDetail) => {
|
|
102
|
+
item.className = nextKind;
|
|
103
|
+
item.childNodes[1].textContent = nextTitle;
|
|
104
|
+
extra.replaceChildren();
|
|
105
|
+
if (nextDetail?.code) extra.append(h("code", {}, nextDetail.code));
|
|
106
|
+
if (nextDetail?.text) extra.append(h("pre", {}, nextDetail.text));
|
|
107
|
+
};
|
|
108
|
+
set(kind, title, detail);
|
|
109
|
+
$("#log").prepend(item);
|
|
110
|
+
return set;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function logError(title, error) {
|
|
114
|
+
log("fail", title, {
|
|
115
|
+
text: error instanceof ApiError ? `${error.code}: ${error.message}` : String(error),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- dialog ----------
|
|
120
|
+
|
|
121
|
+
function openDialog({ title, body, okLabel, copyText }) {
|
|
122
|
+
const dialog = $("#dialog");
|
|
123
|
+
$("#dialog-title").textContent = title;
|
|
124
|
+
$("#dialog-body").replaceChildren(body);
|
|
125
|
+
const ok = $("#dialog-ok");
|
|
126
|
+
ok.hidden = !okLabel;
|
|
127
|
+
ok.textContent = okLabel ?? "Xác nhận";
|
|
128
|
+
const copy = $("#dialog-copy");
|
|
129
|
+
copy.hidden = !copyText;
|
|
130
|
+
copy.textContent = "Sao chép";
|
|
131
|
+
copy.onclick = async () => {
|
|
132
|
+
await navigator.clipboard.writeText(copyText);
|
|
133
|
+
copy.textContent = "Đã sao chép";
|
|
134
|
+
};
|
|
135
|
+
dialog.returnValue = "";
|
|
136
|
+
dialog.showModal();
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------- work items ----------
|
|
143
|
+
|
|
144
|
+
const STAGE_LABEL = {
|
|
145
|
+
"awaiting-approval": "Chờ PO duyệt",
|
|
146
|
+
"awaiting-ready": "Chờ DEV xác nhận",
|
|
147
|
+
"in-progress": "Đang triển khai",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function displayTitle(item) {
|
|
151
|
+
const title = item.title.trim();
|
|
152
|
+
const prefix = /^\[(epic|story|task)\]\s*/i.exec(title);
|
|
153
|
+
return prefix?.[1].toLowerCase() === item.kind
|
|
154
|
+
? title.slice(prefix[0].length).trim() || title
|
|
155
|
+
: title;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function needsMe(item) {
|
|
159
|
+
if (hasRole("po") && item.kind !== "task" && item.stage !== "awaiting-ready") return true;
|
|
160
|
+
if (hasRole("dev") && item.stage === "awaiting-ready") return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function visibleItems() {
|
|
165
|
+
const query = state.query.trim().toLowerCase().replace(/^#/, "");
|
|
166
|
+
return state.items.filter((item) => {
|
|
167
|
+
if (state.filter === "mine" && !needsMe(item)) return false;
|
|
168
|
+
if (!["mine", "all"].includes(state.filter) && item.stage !== state.filter) return false;
|
|
169
|
+
if (!query) return true;
|
|
170
|
+
return String(item.number) === query || displayTitle(item).toLowerCase().includes(query);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function treeRows() {
|
|
175
|
+
const byNumber = new Map(state.items.map((item) => [item.number, item]));
|
|
176
|
+
const matched = new Set(visibleItems().map((item) => item.number));
|
|
177
|
+
const included = new Set(matched);
|
|
178
|
+
const searching = state.query.trim().length > 0;
|
|
179
|
+
for (const number of [...included]) {
|
|
180
|
+
let parent = byNumber.get(number)?.parent;
|
|
181
|
+
while (byNumber.has(parent) && !included.has(parent)) {
|
|
182
|
+
included.add(parent);
|
|
183
|
+
parent = byNumber.get(parent).parent;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const children = new Map();
|
|
187
|
+
for (const item of state.items) {
|
|
188
|
+
if (!included.has(item.number)) continue;
|
|
189
|
+
const siblings = children.get(item.parent) ?? [];
|
|
190
|
+
siblings.push(item);
|
|
191
|
+
children.set(item.parent, siblings);
|
|
192
|
+
}
|
|
193
|
+
const rows = [];
|
|
194
|
+
const visited = new Set();
|
|
195
|
+
const walk = (item, depth, hidden = false) => {
|
|
196
|
+
if (visited.has(item.number)) return;
|
|
197
|
+
visited.add(item.number);
|
|
198
|
+
const descendants = children.get(item.number) ?? [];
|
|
199
|
+
const expanded = searching || !state.collapsed.has(`${state.filter}:${item.number}`);
|
|
200
|
+
if (!hidden)
|
|
201
|
+
rows.push({
|
|
202
|
+
item,
|
|
203
|
+
depth,
|
|
204
|
+
hasChildren: descendants.length > 0,
|
|
205
|
+
expanded,
|
|
206
|
+
contextOnly: !matched.has(item.number),
|
|
207
|
+
});
|
|
208
|
+
for (const child of descendants) walk(child, depth + 1, hidden || !expanded);
|
|
209
|
+
};
|
|
210
|
+
for (const item of state.items) {
|
|
211
|
+
if (included.has(item.number) && !included.has(item.parent)) walk(item, 0);
|
|
212
|
+
}
|
|
213
|
+
// Keep malformed cyclic relationships visible without traversing them forever.
|
|
214
|
+
for (const item of state.items) {
|
|
215
|
+
if (included.has(item.number) && !visited.has(item.number)) walk(item, 0);
|
|
216
|
+
}
|
|
217
|
+
return rows;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readyLanes(item) {
|
|
221
|
+
const lanes = Object.entries(state.session.lanes);
|
|
222
|
+
if (item.kind === "task" || item.stage !== "awaiting-approval")
|
|
223
|
+
return lanes.map(([lane]) => lane);
|
|
224
|
+
return lanes.filter(([, lane]) => !lane.requiresPoApproval).map(([lane]) => lane);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function actionsFor(item) {
|
|
228
|
+
const pending = state.pending.get(item.number);
|
|
229
|
+
if (pending) return [h("span", { class: "meta" }, `${pending} · Chờ Actions`)];
|
|
230
|
+
const buttons = [
|
|
231
|
+
h(
|
|
232
|
+
"a",
|
|
233
|
+
{
|
|
234
|
+
class: "btn ghost",
|
|
235
|
+
href: item.url,
|
|
236
|
+
target: "_blank",
|
|
237
|
+
rel: "noopener noreferrer",
|
|
238
|
+
title: "Mở trên GitHub",
|
|
239
|
+
},
|
|
240
|
+
"↗",
|
|
241
|
+
),
|
|
242
|
+
];
|
|
243
|
+
if (item.stage === "in-progress") {
|
|
244
|
+
buttons.push(
|
|
245
|
+
h(
|
|
246
|
+
"button",
|
|
247
|
+
{
|
|
248
|
+
class: "btn ghost",
|
|
249
|
+
title: "Context cho coding agent",
|
|
250
|
+
onclick: (e) => showContext(e.currentTarget, item),
|
|
251
|
+
},
|
|
252
|
+
"Context",
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (hasRole("dev") && item.stage !== "in-progress") {
|
|
257
|
+
const lanes = readyLanes(item);
|
|
258
|
+
if (lanes.length > 0) {
|
|
259
|
+
const select = h(
|
|
260
|
+
"select",
|
|
261
|
+
{ title: "Luồng triển khai" },
|
|
262
|
+
lanes.map((lane) => h("option", { value: lane, selected: lane === "standard" }, lane)),
|
|
263
|
+
);
|
|
264
|
+
buttons.push(
|
|
265
|
+
select,
|
|
266
|
+
h(
|
|
267
|
+
"button",
|
|
268
|
+
{
|
|
269
|
+
class: "btn",
|
|
270
|
+
title: "DEV xác nhận sẵn sàng triển khai",
|
|
271
|
+
onclick: (e) => decide(e.currentTarget, item, { action: "ready", lane: select.value }),
|
|
272
|
+
},
|
|
273
|
+
"Ready",
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (hasRole("po") && item.kind !== "task" && item.stage === "awaiting-approval") {
|
|
279
|
+
buttons.push(
|
|
280
|
+
h(
|
|
281
|
+
"button",
|
|
282
|
+
{
|
|
283
|
+
class: "btn primary",
|
|
284
|
+
title: "PO duyệt nội dung hiện tại",
|
|
285
|
+
onclick: (e) => decide(e.currentTarget, item, { action: "approve" }),
|
|
286
|
+
},
|
|
287
|
+
"Approve",
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (hasRole("po") && item.kind !== "task" && item.stage === "in-progress") {
|
|
292
|
+
buttons.push(
|
|
293
|
+
h(
|
|
294
|
+
"button",
|
|
295
|
+
{
|
|
296
|
+
class: "btn primary",
|
|
297
|
+
title: "PO nghiệm thu trên build đã merge",
|
|
298
|
+
onclick: (e) => accept(e.currentTarget, item),
|
|
299
|
+
},
|
|
300
|
+
"Accept",
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return buttons;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderItems() {
|
|
308
|
+
const entries = treeRows();
|
|
309
|
+
const rows = entries.map(
|
|
310
|
+
({ item, depth = 0, hasChildren = false, expanded = false, contextOnly = false }) => {
|
|
311
|
+
const meta = [];
|
|
312
|
+
if (item.approvedBy) meta.push(h("div", {}, `Đã duyệt · ${item.approvedBy}`));
|
|
313
|
+
if (item.ready) meta.push(h("div", {}, `Ready · ${item.ready.by} · ${item.ready.lane}`));
|
|
314
|
+
if (item.changed) meta.push(h("div", { class: "changed" }, "Nội dung đã thay đổi"));
|
|
315
|
+
const link = h(
|
|
316
|
+
"a",
|
|
317
|
+
{ href: item.url, target: "_blank", rel: "noopener noreferrer" },
|
|
318
|
+
displayTitle(item),
|
|
319
|
+
);
|
|
320
|
+
const toggle = hasChildren
|
|
321
|
+
? h(
|
|
322
|
+
"button",
|
|
323
|
+
{
|
|
324
|
+
class: "tree-toggle",
|
|
325
|
+
type: "button",
|
|
326
|
+
"aria-expanded": String(expanded),
|
|
327
|
+
"aria-label": `${expanded ? "Thu gọn" : "Mở rộng"} #${item.number}`,
|
|
328
|
+
title: `${expanded ? "Thu gọn" : "Mở rộng"} #${item.number}`,
|
|
329
|
+
onclick: () => {
|
|
330
|
+
const key = `${state.filter}:${item.number}`;
|
|
331
|
+
if (state.collapsed.has(key)) state.collapsed.delete(key);
|
|
332
|
+
else state.collapsed.add(key);
|
|
333
|
+
renderItems();
|
|
334
|
+
document.querySelector(`[data-tree-toggle="${item.number}"]`)?.focus();
|
|
335
|
+
},
|
|
336
|
+
"data-tree-toggle": item.number,
|
|
337
|
+
disabled: state.query.trim().length > 0,
|
|
338
|
+
},
|
|
339
|
+
h("span", { class: "tree-chevron", "aria-hidden": "true" }),
|
|
340
|
+
)
|
|
341
|
+
: h("span", { class: "tree-spacer", "aria-hidden": "true" });
|
|
342
|
+
return h(
|
|
343
|
+
"tr",
|
|
344
|
+
{
|
|
345
|
+
class: contextOnly ? "context-row" : "",
|
|
346
|
+
"data-issue": item.number,
|
|
347
|
+
"data-context": String(contextOnly),
|
|
348
|
+
},
|
|
349
|
+
h("td", { class: "num" }, `#${item.number}`),
|
|
350
|
+
h("td", {}, h("span", { class: `badge ${item.kind}` }, item.kind)),
|
|
351
|
+
h(
|
|
352
|
+
"td",
|
|
353
|
+
{ class: "title-cell" },
|
|
354
|
+
h(
|
|
355
|
+
"div",
|
|
356
|
+
{ class: "item-tree-title" },
|
|
357
|
+
Array.from({ length: depth }, () =>
|
|
358
|
+
h("span", { class: "tree-indent", "aria-hidden": "true" }),
|
|
359
|
+
),
|
|
360
|
+
toggle,
|
|
361
|
+
link,
|
|
362
|
+
),
|
|
363
|
+
item.parent && depth === 0 ? h("span", { class: "meta" }, ` ↳ #${item.parent}`) : null,
|
|
364
|
+
item.error && !contextOnly ? h("span", { class: "err" }, item.error) : null,
|
|
365
|
+
),
|
|
366
|
+
h(
|
|
367
|
+
"td",
|
|
368
|
+
{},
|
|
369
|
+
contextOnly ? null : h("span", { class: `pill ${item.stage}` }, STAGE_LABEL[item.stage]),
|
|
370
|
+
),
|
|
371
|
+
h("td", { class: "meta" }, contextOnly ? [] : meta),
|
|
372
|
+
h("td", {}, contextOnly ? null : h("div", { class: "actions" }, actionsFor(item))),
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
$("#items-body").replaceChildren(...rows);
|
|
377
|
+
const empty = $("#items-empty");
|
|
378
|
+
empty.hidden = rows.length > 0;
|
|
379
|
+
empty.textContent =
|
|
380
|
+
state.items.length === 0 ? "Chưa có công việc đang mở." : "Không có công việc phù hợp.";
|
|
381
|
+
for (const chip of document.querySelectorAll("#filters .chip")) {
|
|
382
|
+
const filter = chip.dataset.filter;
|
|
383
|
+
const count = state.items.filter((item) =>
|
|
384
|
+
filter === "all" ? true : filter === "mine" ? needsMe(item) : item.stage === filter,
|
|
385
|
+
).length;
|
|
386
|
+
chip.replaceChildren(chip.dataset.label, h("span", { class: "count" }, count));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function loadItems() {
|
|
391
|
+
const button = $("#refresh");
|
|
392
|
+
await withBusy(button, async () => {
|
|
393
|
+
try {
|
|
394
|
+
state.items = await api("/api/items");
|
|
395
|
+
renderItems();
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logError("Không tải được công việc", error);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function decide(button, item, input) {
|
|
403
|
+
const command = await withBusy(button, async () => {
|
|
404
|
+
try {
|
|
405
|
+
return await api(`/api/issues/${item.number}/decisions`, input);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
logError(`#${item.number} ${input.action} thất bại`, error);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
if (!command) return;
|
|
412
|
+
state.pending.set(item.number, input.action);
|
|
413
|
+
renderItems();
|
|
414
|
+
const update = log("pending", `#${item.number} ${input.action} đã gửi`, {
|
|
415
|
+
code: command.command,
|
|
416
|
+
});
|
|
417
|
+
pollReply(item.number, command.commentId, input.action, update);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function pollReply(issueNumber, commentId, action, update) {
|
|
421
|
+
for (let attempt = 0; attempt < POLL_MAX; attempt += 1) {
|
|
422
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
423
|
+
try {
|
|
424
|
+
const reply = await api(`/api/issues/${issueNumber}/replies/${commentId}`);
|
|
425
|
+
if (reply.status === "done") {
|
|
426
|
+
update(
|
|
427
|
+
reply.ok ? "ok" : "fail",
|
|
428
|
+
`#${issueNumber} ${action}: ${reply.ok ? "đã ghi nhận" : "bị từ chối"}`,
|
|
429
|
+
{ text: reply.body },
|
|
430
|
+
);
|
|
431
|
+
state.pending.delete(issueNumber);
|
|
432
|
+
await loadItems();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error.code === "E_TOKEN") break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
state.pending.delete(issueNumber);
|
|
440
|
+
renderItems();
|
|
441
|
+
update("fail", `#${issueNumber} ${action}: chưa thấy phản hồi từ GitHub Actions`, {
|
|
442
|
+
text: "Kiểm tra workflow huddle60 trong GitHub Actions, sau đó tải lại.",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function accept(button, item) {
|
|
447
|
+
const builds = await withBusy(button, async () => {
|
|
448
|
+
try {
|
|
449
|
+
return await api("/api/builds");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
logError("Không tải được danh sách build", error);
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
if (!builds) return;
|
|
456
|
+
if (builds.length === 0) {
|
|
457
|
+
await openDialog({
|
|
458
|
+
title: `Nghiệm thu #${item.number}`,
|
|
459
|
+
body: h(
|
|
460
|
+
"p",
|
|
461
|
+
{},
|
|
462
|
+
`Chưa có build thành công trên ${state.session.defaultBranch}. Merge PR và chờ build hoàn tất.`,
|
|
463
|
+
),
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const list = h(
|
|
468
|
+
"div",
|
|
469
|
+
{},
|
|
470
|
+
h("p", { class: "muted" }, "Chọn build đã chạy trên nhánh chính để nghiệm thu:"),
|
|
471
|
+
builds.map((build, index) =>
|
|
472
|
+
h(
|
|
473
|
+
"label",
|
|
474
|
+
{ class: "build" },
|
|
475
|
+
h("input", { type: "radio", name: "build", value: build.id, checked: index === 0 }),
|
|
476
|
+
`run ${build.id}`,
|
|
477
|
+
h("span", { class: "muted" }, build.headSha.slice(0, 7)),
|
|
478
|
+
h("span", { class: "muted" }, build.path.split("/").pop()),
|
|
479
|
+
h("a", { href: build.url, target: "_blank", rel: "noopener noreferrer" }, "↗"),
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
if (
|
|
484
|
+
(await openDialog({
|
|
485
|
+
title: `Nghiệm thu #${item.number} · ${displayTitle(item)}`,
|
|
486
|
+
body: list,
|
|
487
|
+
okLabel: "Accept",
|
|
488
|
+
})) !== "ok"
|
|
489
|
+
)
|
|
490
|
+
return;
|
|
491
|
+
const runId = Number(list.querySelector("input[name=build]:checked")?.value);
|
|
492
|
+
await decide(button, item, { action: "accept", runId });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function showContext(button, item) {
|
|
496
|
+
const result = await withBusy(button, async () => {
|
|
497
|
+
try {
|
|
498
|
+
return await api(`/api/issues/${item.number}/context`);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
logError(`#${item.number} context`, error);
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
if (!result) return;
|
|
505
|
+
await openDialog({
|
|
506
|
+
title: `Context #${item.number} · ${displayTitle(item)}`,
|
|
507
|
+
body: h("pre", {}, result.markdown),
|
|
508
|
+
copyText: result.markdown,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------- plan ----------
|
|
513
|
+
|
|
514
|
+
function renderNode(node, byParent) {
|
|
515
|
+
const criteria =
|
|
516
|
+
node.kind === "task"
|
|
517
|
+
? node.acRefs.map((id) => `→ ${id}`)
|
|
518
|
+
: node.criteria.map((c) => `${c.id}: ${c.text}`);
|
|
519
|
+
const created = state.draft?.created?.issues?.[node.localId];
|
|
520
|
+
return h(
|
|
521
|
+
"div",
|
|
522
|
+
{ class: "node" },
|
|
523
|
+
h(
|
|
524
|
+
"div",
|
|
525
|
+
{ class: "node-head" },
|
|
526
|
+
h("span", { class: `badge ${node.kind}` }, node.kind),
|
|
527
|
+
h("strong", {}, displayTitle(node)),
|
|
528
|
+
h("span", { class: "node-id" }, node.localId),
|
|
529
|
+
node.blockedBy.length
|
|
530
|
+
? h("span", { class: "node-id" }, `Phụ thuộc: ${node.blockedBy.join(", ")}`)
|
|
531
|
+
: null,
|
|
532
|
+
created
|
|
533
|
+
? h(
|
|
534
|
+
"a",
|
|
535
|
+
{ href: issueUrl(created), target: "_blank", rel: "noopener noreferrer" },
|
|
536
|
+
`#${created}`,
|
|
537
|
+
)
|
|
538
|
+
: null,
|
|
539
|
+
),
|
|
540
|
+
criteria.length
|
|
541
|
+
? h(
|
|
542
|
+
"ul",
|
|
543
|
+
{},
|
|
544
|
+
criteria.map((text) => h("li", {}, text)),
|
|
545
|
+
)
|
|
546
|
+
: null,
|
|
547
|
+
node.riskNotes.length
|
|
548
|
+
? h(
|
|
549
|
+
"ul",
|
|
550
|
+
{},
|
|
551
|
+
node.riskNotes.map((text) => h("li", { class: "risk" }, `⚠ ${text}`)),
|
|
552
|
+
)
|
|
553
|
+
: null,
|
|
554
|
+
(byParent.get(node.localId) ?? []).map((child) => renderNode(child, byParent)),
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function fillList(selector, items) {
|
|
559
|
+
const list = $(selector);
|
|
560
|
+
list.replaceChildren(
|
|
561
|
+
...(items.length
|
|
562
|
+
? items.map((text) => h("li", {}, text))
|
|
563
|
+
: [h("li", { class: "muted" }, "Không có")]),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function renderDraft() {
|
|
568
|
+
const draft = state.draft;
|
|
569
|
+
$("#plan-result").hidden = !draft;
|
|
570
|
+
if (!draft) return;
|
|
571
|
+
const { plan } = draft;
|
|
572
|
+
$("#plan-summary").textContent = plan.summary;
|
|
573
|
+
const byParent = new Map();
|
|
574
|
+
for (const node of plan.nodes) {
|
|
575
|
+
const list = byParent.get(node.parentLocalId) ?? [];
|
|
576
|
+
list.push(node);
|
|
577
|
+
byParent.set(node.parentLocalId, list);
|
|
578
|
+
}
|
|
579
|
+
$("#plan-tree").replaceChildren(
|
|
580
|
+
...(byParent.get(null) ?? []).map((node) => renderNode(node, byParent)),
|
|
581
|
+
);
|
|
582
|
+
const blocking = $("#plan-blocking");
|
|
583
|
+
blocking.hidden = draft.blocking.length === 0;
|
|
584
|
+
blocking.querySelector("ul").replaceChildren(...draft.blocking.map((text) => h("li", {}, text)));
|
|
585
|
+
fillList(
|
|
586
|
+
"#plan-open",
|
|
587
|
+
plan.openQuestions.filter((q) => !q.blocking).map((q) => q.text),
|
|
588
|
+
);
|
|
589
|
+
fillList("#plan-assumptions", plan.assumptions);
|
|
590
|
+
fillList("#plan-clarifications", draft.clarifications);
|
|
591
|
+
|
|
592
|
+
const created = Boolean(draft.created);
|
|
593
|
+
$("#refine-form").hidden = created;
|
|
594
|
+
const createButton = $("#create-btn");
|
|
595
|
+
const reasons = [];
|
|
596
|
+
if (!hasRole("po")) reasons.push("Chỉ PO được tạo Issue");
|
|
597
|
+
if (draft.blocking.length) reasons.push("Cần làm rõ câu hỏi chặn");
|
|
598
|
+
createButton.disabled = reasons.length > 0;
|
|
599
|
+
createButton.textContent = `Tạo ${plan.nodes.length} Issue`;
|
|
600
|
+
$("#create-hint").textContent = reasons.join(" · ");
|
|
601
|
+
|
|
602
|
+
const createdBox = $("#plan-created");
|
|
603
|
+
createdBox.hidden = !created;
|
|
604
|
+
if (created) {
|
|
605
|
+
const links = [
|
|
606
|
+
h(
|
|
607
|
+
"li",
|
|
608
|
+
{},
|
|
609
|
+
"Yêu cầu ",
|
|
610
|
+
h(
|
|
611
|
+
"a",
|
|
612
|
+
{
|
|
613
|
+
href: issueUrl(draft.created.requestIssue),
|
|
614
|
+
target: "_blank",
|
|
615
|
+
rel: "noopener noreferrer",
|
|
616
|
+
},
|
|
617
|
+
`#${draft.created.requestIssue}`,
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
...Object.entries(draft.created.issues).map(([localId, number]) =>
|
|
621
|
+
h(
|
|
622
|
+
"li",
|
|
623
|
+
{},
|
|
624
|
+
`${localId} `,
|
|
625
|
+
h(
|
|
626
|
+
"a",
|
|
627
|
+
{ href: issueUrl(number), target: "_blank", rel: "noopener noreferrer" },
|
|
628
|
+
`#${number}`,
|
|
629
|
+
),
|
|
630
|
+
),
|
|
631
|
+
),
|
|
632
|
+
];
|
|
633
|
+
createdBox.querySelector("ul").replaceChildren(...links);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function generatePlan(event) {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
const request = $("#plan-request").value;
|
|
640
|
+
const title = $("#plan-title").value;
|
|
641
|
+
const update = log("pending", "Đang tạo kế hoạch…");
|
|
642
|
+
await withBusy($("#plan-generate"), async () => {
|
|
643
|
+
try {
|
|
644
|
+
state.draft = await api("/api/plans", { title, request });
|
|
645
|
+
renderDraft();
|
|
646
|
+
update(
|
|
647
|
+
"ok",
|
|
648
|
+
`Kế hoạch: ${state.draft.plan.nodes.length} công việc · ${state.draft.blocking.length} câu hỏi chặn`,
|
|
649
|
+
);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
update("fail", "Không thể tạo kế hoạch", { text: `${error.code ?? ""}: ${error.message}` });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function refinePlan(event) {
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
const clarification = $("#refine-text").value;
|
|
659
|
+
const update = log("pending", "Đang cập nhật kế hoạch…");
|
|
660
|
+
await withBusy($("#refine-btn"), async () => {
|
|
661
|
+
try {
|
|
662
|
+
state.draft = await api(`/api/plans/${state.draft.draftId}/refine`, { clarification });
|
|
663
|
+
$("#refine-text").value = "";
|
|
664
|
+
renderDraft();
|
|
665
|
+
update(
|
|
666
|
+
"ok",
|
|
667
|
+
`Đã cập nhật: ${state.draft.plan.nodes.length} công việc · ${state.draft.blocking.length} câu hỏi chặn`,
|
|
668
|
+
);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
update("fail", "Không thể cập nhật kế hoạch", {
|
|
671
|
+
text: `${error.code ?? ""}: ${error.message}`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function createIssues() {
|
|
678
|
+
const draft = state.draft;
|
|
679
|
+
const confirm = await openDialog({
|
|
680
|
+
title: "Tạo Issue trên GitHub",
|
|
681
|
+
body: h(
|
|
682
|
+
"p",
|
|
683
|
+
{},
|
|
684
|
+
`Tạo 1 yêu cầu và ${draft.plan.nodes.length} Issue trong ${state.session.repo.owner}/${state.session.repo.repo} bằng tài khoản ${state.session.viewer.login}.`,
|
|
685
|
+
),
|
|
686
|
+
okLabel: "Tạo Issue",
|
|
687
|
+
});
|
|
688
|
+
if (confirm !== "ok") return;
|
|
689
|
+
const update = log("pending", "Đang tạo Issue…");
|
|
690
|
+
await withBusy($("#create-btn"), async () => {
|
|
691
|
+
try {
|
|
692
|
+
state.draft = await api(`/api/plans/${draft.draftId}/create`, {});
|
|
693
|
+
renderDraft();
|
|
694
|
+
update("ok", `Đã tạo ${Object.keys(state.draft.created.issues).length} Issue`);
|
|
695
|
+
loadItems();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
update("fail", "Tạo Issue chưa hoàn tất. Chọn Tạo Issue để tiếp tục.", {
|
|
698
|
+
text: `${error.code ?? ""}: ${error.message}`,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---------- boot ----------
|
|
705
|
+
|
|
706
|
+
function switchView(view) {
|
|
707
|
+
for (const tab of document.querySelectorAll(".tab"))
|
|
708
|
+
tab.classList.toggle("active", tab.dataset.view === view);
|
|
709
|
+
$("#view-items").hidden = view !== "items";
|
|
710
|
+
$("#view-plan").hidden = view !== "plan";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function fatal(message) {
|
|
714
|
+
const box = $("#fatal");
|
|
715
|
+
box.textContent = message;
|
|
716
|
+
box.hidden = false;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function boot() {
|
|
720
|
+
if (!token) {
|
|
721
|
+
fatal("Thiếu session token.\nMở đúng URL mà lệnh `huddle60 ui` in ra trong terminal.");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
state.session = await api("/api/session");
|
|
726
|
+
} catch (error) {
|
|
727
|
+
fatal(
|
|
728
|
+
error.code === "E_TOKEN"
|
|
729
|
+
? "Session token không hợp lệ (app đã khởi động lại?).\nMở URL mới trong terminal."
|
|
730
|
+
: `${error.code ?? "E"}: ${error.message}`,
|
|
731
|
+
);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const { session } = state;
|
|
735
|
+
const repo = $("#repo");
|
|
736
|
+
repo.textContent = `${session.repo.owner}/${session.repo.repo}`;
|
|
737
|
+
repo.href = session.repo.url;
|
|
738
|
+
$("#viewer").textContent = `@${session.viewer.login}`;
|
|
739
|
+
$("#roles").replaceChildren(
|
|
740
|
+
...(session.roles.length
|
|
741
|
+
? session.roles.map((role) => h("span", { class: "badge story" }, role))
|
|
742
|
+
: [h("span", { class: "muted" }, "Chưa có vai trò")]),
|
|
743
|
+
);
|
|
744
|
+
const dot = $("#planner-status");
|
|
745
|
+
dot.classList.toggle("on", Boolean(session.planner));
|
|
746
|
+
dot.title = session.planner
|
|
747
|
+
? `planner: ${session.planner.model}`
|
|
748
|
+
: "planner off (GEMINI_API_KEY)";
|
|
749
|
+
if (!session.planner) {
|
|
750
|
+
$("#plan-generate").disabled = true;
|
|
751
|
+
$("#plan-hint").textContent =
|
|
752
|
+
"Gemini chưa cấu hình: đặt GEMINI_API_KEY rồi chạy lại huddle60 ui";
|
|
753
|
+
}
|
|
754
|
+
if (!hasRole("po") && !hasRole("dev")) {
|
|
755
|
+
state.filter = "all";
|
|
756
|
+
for (const chip of document.querySelectorAll("#filters .chip"))
|
|
757
|
+
chip.classList.toggle("active", chip.dataset.filter === "all");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
for (const chip of document.querySelectorAll("#filters .chip")) {
|
|
761
|
+
chip.dataset.label = chip.textContent;
|
|
762
|
+
chip.addEventListener("click", () => {
|
|
763
|
+
state.filter = chip.dataset.filter;
|
|
764
|
+
for (const other of document.querySelectorAll("#filters .chip"))
|
|
765
|
+
other.classList.toggle("active", other === chip);
|
|
766
|
+
renderItems();
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
for (const tab of document.querySelectorAll(".tab"))
|
|
770
|
+
tab.addEventListener("click", () => switchView(tab.dataset.view));
|
|
771
|
+
$("#search").addEventListener("input", (event) => {
|
|
772
|
+
state.query = event.target.value;
|
|
773
|
+
renderItems();
|
|
774
|
+
});
|
|
775
|
+
$("#refresh").addEventListener("click", loadItems);
|
|
776
|
+
$("#log-clear").addEventListener("click", () => $("#log").replaceChildren());
|
|
777
|
+
$("#plan-form").addEventListener("submit", generatePlan);
|
|
778
|
+
$("#refine-form").addEventListener("submit", refinePlan);
|
|
779
|
+
$("#create-btn").addEventListener("click", createIssues);
|
|
780
|
+
|
|
781
|
+
log("ok", `Đã kết nối · @${session.viewer.login}`);
|
|
782
|
+
await loadItems();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
boot();
|