chapterhouse 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/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
package/dist/api/team.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join, normalize } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { AdoClient } from "../integrations/ado-client.js";
|
|
7
|
+
import { ReportGenerator } from "../integrations/report-generator.js";
|
|
8
|
+
import { BadRequestError, ForbiddenError, InternalServerError, asBadRequest, parseRequest } from "./errors.js";
|
|
9
|
+
import { assertPagePath, ensureWikiStructure, getWikiDir, listPages, readPage, writePage, } from "../wiki/fs.js";
|
|
10
|
+
import { withWikiWrite } from "../wiki/lock.js";
|
|
11
|
+
import { TeamsNotifier, TEAMS_MILESTONE_THRESHOLDS } from "../integrations/teams-notify.js";
|
|
12
|
+
const wikiPageRoute = /^\/wiki\/(.+)$/;
|
|
13
|
+
const updateSchema = z.object({
|
|
14
|
+
engineerId: z.string().min(1),
|
|
15
|
+
activity: z.string().min(1),
|
|
16
|
+
krId: z.union([z.string().min(1), z.number().int().positive()]),
|
|
17
|
+
delta: z.number().finite().min(-100000).max(100000),
|
|
18
|
+
notes: z.string().optional(),
|
|
19
|
+
kr: z.object({
|
|
20
|
+
id: z.string().min(1),
|
|
21
|
+
title: z.string().min(1),
|
|
22
|
+
owner: z.string().min(1),
|
|
23
|
+
currentValue: z.number().finite(),
|
|
24
|
+
targetValue: z.number().finite(),
|
|
25
|
+
unit: z.string(),
|
|
26
|
+
}).optional(),
|
|
27
|
+
});
|
|
28
|
+
const reportSchema = z.object({
|
|
29
|
+
quarter: z.string().trim().min(1).optional(),
|
|
30
|
+
});
|
|
31
|
+
const wikiWriteSchema = z.object({
|
|
32
|
+
content: z.string({ error: "Missing 'content' string in request body" }),
|
|
33
|
+
}).strict();
|
|
34
|
+
function requireTeamLead(user) {
|
|
35
|
+
if (user?.role !== "team-lead") {
|
|
36
|
+
throw new ForbiddenError("Forbidden");
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function canWriteWikiPath(path, role) {
|
|
41
|
+
if (role === "team-lead") {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return path.startsWith("pages/shared/");
|
|
45
|
+
}
|
|
46
|
+
function resolveWikiPath(rawPath) {
|
|
47
|
+
try {
|
|
48
|
+
const path = normalize(decodeURIComponent(rawPath))
|
|
49
|
+
.replace(/\\/g, "/")
|
|
50
|
+
.replace(/^(\.\.(\/|\\|$))+/, "");
|
|
51
|
+
assertPagePath(path);
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
asBadRequest(error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function buildUpdateMarkdown(entry) {
|
|
59
|
+
return [
|
|
60
|
+
`## ${entry.timestamp} — ${entry.engineerId}`,
|
|
61
|
+
"",
|
|
62
|
+
`**Activity**: ${entry.activity} `,
|
|
63
|
+
`**Key Result**: ${entry.krId} `,
|
|
64
|
+
`**Delta**: ${entry.delta} `,
|
|
65
|
+
`**Notes**: ${entry.notes ?? ""}`,
|
|
66
|
+
"",
|
|
67
|
+
"---",
|
|
68
|
+
"",
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
function readUpdatedAt(relativePath) {
|
|
72
|
+
const stats = statSync(join(getWikiDir(), relativePath));
|
|
73
|
+
return stats.mtime.toISOString();
|
|
74
|
+
}
|
|
75
|
+
export function currentQuarter(now = new Date()) {
|
|
76
|
+
return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
|
|
77
|
+
}
|
|
78
|
+
function collectOkrPages() {
|
|
79
|
+
ensureWikiStructure();
|
|
80
|
+
return listPages()
|
|
81
|
+
.filter((path) => path.startsWith("pages/okrs/") && !path.startsWith("pages/okrs/updates/"))
|
|
82
|
+
.sort()
|
|
83
|
+
.map((path) => ({
|
|
84
|
+
path,
|
|
85
|
+
content: readPage(path) ?? "",
|
|
86
|
+
updatedAt: readUpdatedAt(path),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
function isConfiguredTeamWikiPath(path) {
|
|
90
|
+
return config.teamWikiPaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
91
|
+
}
|
|
92
|
+
function collectTeamWikiPages() {
|
|
93
|
+
ensureWikiStructure();
|
|
94
|
+
return listPages()
|
|
95
|
+
.filter((path) => isConfiguredTeamWikiPath(path))
|
|
96
|
+
.sort();
|
|
97
|
+
}
|
|
98
|
+
function getCrossedMilestone(kr, delta) {
|
|
99
|
+
if (!Number.isFinite(delta) || !Number.isFinite(kr.currentValue) || !Number.isFinite(kr.targetValue) || kr.targetValue <= 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const previousValue = kr.currentValue - delta;
|
|
103
|
+
let crossedMilestone = null;
|
|
104
|
+
for (const threshold of TEAMS_MILESTONE_THRESHOLDS) {
|
|
105
|
+
const thresholdValue = (kr.targetValue * threshold) / 100;
|
|
106
|
+
if (previousValue < thresholdValue && kr.currentValue >= thresholdValue) {
|
|
107
|
+
crossedMilestone = threshold;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return crossedMilestone;
|
|
111
|
+
}
|
|
112
|
+
export function createTeamRouter(options) {
|
|
113
|
+
const router = express.Router();
|
|
114
|
+
const now = options.now ?? (() => new Date());
|
|
115
|
+
const teamsNotifier = options.teamsNotifier ?? new TeamsNotifier();
|
|
116
|
+
const adoClient = options.adoClient ?? (config.adoPat ? new AdoClient() : undefined);
|
|
117
|
+
const warn = options.warn ?? ((message) => console.warn(message));
|
|
118
|
+
router.use(options.authMiddleware);
|
|
119
|
+
router.post("/update", async (req, res) => {
|
|
120
|
+
const input = parseRequest(updateSchema, req.body);
|
|
121
|
+
if (config.adoPat && typeof input.krId !== "number") {
|
|
122
|
+
throw new BadRequestError("krId must be a numeric ADO work item ID when ADO_PAT is configured");
|
|
123
|
+
}
|
|
124
|
+
const timestamp = now().toISOString();
|
|
125
|
+
const path = `pages/okrs/updates/${timestamp.slice(0, 10)}.md`;
|
|
126
|
+
const entry = {
|
|
127
|
+
...input,
|
|
128
|
+
timestamp,
|
|
129
|
+
path,
|
|
130
|
+
};
|
|
131
|
+
await withWikiWrite(() => {
|
|
132
|
+
ensureWikiStructure();
|
|
133
|
+
const existing = readPage(path);
|
|
134
|
+
const content = `${existing ?? ""}${buildUpdateMarkdown(entry)}`;
|
|
135
|
+
writePage(path, content);
|
|
136
|
+
});
|
|
137
|
+
if (adoClient) {
|
|
138
|
+
if (typeof entry.krId === "number") {
|
|
139
|
+
await adoClient.updateKRProgress(entry.krId, entry.delta, entry.notes);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
warn(`[team] Skipping Azure DevOps KR update for non-numeric krId "${entry.krId}".`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (entry.kr && getCrossedMilestone(entry.kr, entry.delta) !== null) {
|
|
146
|
+
await teamsNotifier.notifyKRMilestone(entry.kr);
|
|
147
|
+
}
|
|
148
|
+
res.json({ ok: true, entry });
|
|
149
|
+
});
|
|
150
|
+
router.get("/okrs", (_req, res) => {
|
|
151
|
+
res.json({ pages: collectOkrPages() });
|
|
152
|
+
});
|
|
153
|
+
router.get("/wiki", (_req, res) => {
|
|
154
|
+
res.json({ pages: collectTeamWikiPages() });
|
|
155
|
+
});
|
|
156
|
+
router.get(wikiPageRoute, (req, res) => {
|
|
157
|
+
const rawPath = req.params[0];
|
|
158
|
+
if (!rawPath) {
|
|
159
|
+
throw new BadRequestError("Missing wiki page path");
|
|
160
|
+
}
|
|
161
|
+
const path = resolveWikiPath(rawPath);
|
|
162
|
+
const content = readPage(path);
|
|
163
|
+
res.json({ path, content: content ?? "", exists: content !== undefined });
|
|
164
|
+
});
|
|
165
|
+
router.put(wikiPageRoute, async (req, res) => {
|
|
166
|
+
const rawPath = req.params[0];
|
|
167
|
+
if (!rawPath) {
|
|
168
|
+
throw new BadRequestError("Missing wiki page path");
|
|
169
|
+
}
|
|
170
|
+
const { content } = parseRequest(wikiWriteSchema, req.body);
|
|
171
|
+
const normalizedPath = resolveWikiPath(rawPath);
|
|
172
|
+
if (!canWriteWikiPath(normalizedPath, req.user?.role ?? "")) {
|
|
173
|
+
throw new ForbiddenError("Forbidden");
|
|
174
|
+
}
|
|
175
|
+
await withWikiWrite(() => {
|
|
176
|
+
writePage(normalizedPath, content);
|
|
177
|
+
});
|
|
178
|
+
res.json({ ok: true, path: normalizedPath });
|
|
179
|
+
});
|
|
180
|
+
router.post("/report", async (req, res) => {
|
|
181
|
+
requireTeamLead(req.user);
|
|
182
|
+
const input = parseRequest(reportSchema, req.body ?? {});
|
|
183
|
+
const reportQuarter = input.quarter ?? currentQuarter(now());
|
|
184
|
+
const reportGenerator = options.reportGenerator ?? new ReportGenerator({ now });
|
|
185
|
+
try {
|
|
186
|
+
const report = await reportGenerator.generateMonthlyReport(reportQuarter);
|
|
187
|
+
res.json({ ok: true, report });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.error("[team] Failed to generate report:", error);
|
|
191
|
+
throw new InternalServerError();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return router;
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=team.js.map
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import express from "express";
|
|
8
|
+
const chapterhouseHomeRoot = mkdtempSync(join(tmpdir(), "chapterhouse-team-api-"));
|
|
9
|
+
process.env.CHAPTERHOUSE_HOME = chapterhouseHomeRoot;
|
|
10
|
+
function createMockAuthMiddleware(user) {
|
|
11
|
+
return (req, res, next) => {
|
|
12
|
+
if (!user) {
|
|
13
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
req.user = user;
|
|
17
|
+
next();
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async function loadTeamModule() {
|
|
21
|
+
try {
|
|
22
|
+
return await import(new URL("./team.js", import.meta.url).href);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function withServer(app, run) {
|
|
29
|
+
const server = createServer(app);
|
|
30
|
+
await new Promise((resolve) => {
|
|
31
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
32
|
+
});
|
|
33
|
+
const address = server.address();
|
|
34
|
+
assert.ok(address && typeof address === "object", "server should expose a bound address");
|
|
35
|
+
try {
|
|
36
|
+
await run(`http://127.0.0.1:${address.port}`);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let apiErrorHandler;
|
|
45
|
+
let sharedConfig;
|
|
46
|
+
let originalAdoPat;
|
|
47
|
+
async function getApiErrorHandler() {
|
|
48
|
+
if (!apiErrorHandler) {
|
|
49
|
+
const errors = await import("./errors.js");
|
|
50
|
+
apiErrorHandler = errors.createApiErrorHandler();
|
|
51
|
+
}
|
|
52
|
+
return apiErrorHandler;
|
|
53
|
+
}
|
|
54
|
+
async function getConfig() {
|
|
55
|
+
if (!sharedConfig) {
|
|
56
|
+
const configModule = await import("../config.js");
|
|
57
|
+
sharedConfig = configModule.config;
|
|
58
|
+
originalAdoPat = configModule.config.adoPat;
|
|
59
|
+
}
|
|
60
|
+
return sharedConfig;
|
|
61
|
+
}
|
|
62
|
+
async function createTestApp(teamRouter) {
|
|
63
|
+
const app = express();
|
|
64
|
+
app.use(express.json());
|
|
65
|
+
app.use("/api/team", teamRouter);
|
|
66
|
+
app.use(await getApiErrorHandler());
|
|
67
|
+
return app;
|
|
68
|
+
}
|
|
69
|
+
function wikiRootPath(...parts) {
|
|
70
|
+
return join(chapterhouseHomeRoot, ".chapterhouse", ...parts);
|
|
71
|
+
}
|
|
72
|
+
test.beforeEach(async () => {
|
|
73
|
+
(await getConfig()).adoPat = "";
|
|
74
|
+
});
|
|
75
|
+
test.after(async () => {
|
|
76
|
+
const config = await getConfig();
|
|
77
|
+
config.adoPat = originalAdoPat ?? "";
|
|
78
|
+
rmSync(chapterhouseHomeRoot, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
test("unauthenticated requests return 401", async () => {
|
|
81
|
+
const team = await loadTeamModule();
|
|
82
|
+
assert.ok(team, "team router module should exist");
|
|
83
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
84
|
+
const router = team.createTeamRouter({
|
|
85
|
+
authMiddleware: createMockAuthMiddleware(),
|
|
86
|
+
});
|
|
87
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
88
|
+
const response = await fetch(`${baseUrl}/api/team/okrs`);
|
|
89
|
+
assert.equal(response.status, 401);
|
|
90
|
+
assert.deepEqual(await response.json(), { error: "Unauthorized" });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
test("engineer can POST /api/team/update", async () => {
|
|
94
|
+
const team = await loadTeamModule();
|
|
95
|
+
assert.ok(team, "team router module should exist");
|
|
96
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
97
|
+
const router = team.createTeamRouter({
|
|
98
|
+
authMiddleware: createMockAuthMiddleware({
|
|
99
|
+
id: "eng-1",
|
|
100
|
+
name: "Ada Lovelace",
|
|
101
|
+
email: "ada@example.com",
|
|
102
|
+
role: "engineer",
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
106
|
+
const response = await fetch(`${baseUrl}/api/team/update`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "content-type": "application/json" },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
engineerId: "eng-1",
|
|
111
|
+
activity: "Closed migration tasks",
|
|
112
|
+
krId: "KR-2",
|
|
113
|
+
delta: 0.25,
|
|
114
|
+
notes: "Removed the final blockers",
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
assert.equal(response.status, 200);
|
|
118
|
+
const payload = await response.json();
|
|
119
|
+
assert.equal(payload.ok, true);
|
|
120
|
+
assert.equal(payload.entry.engineerId, "eng-1");
|
|
121
|
+
assert.equal(payload.entry.krId, "KR-2");
|
|
122
|
+
});
|
|
123
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
124
|
+
const updatePath = wikiRootPath("wiki", "pages", "okrs", "updates", `${today}.md`);
|
|
125
|
+
const content = readFileSync(updatePath, "utf-8");
|
|
126
|
+
assert.match(content, /## .* — eng-1/);
|
|
127
|
+
assert.match(content, /\*\*Activity\*\*: Closed migration tasks/);
|
|
128
|
+
assert.match(content, /\*\*Key Result\*\*: KR-2/);
|
|
129
|
+
assert.match(content, /\*\*Delta\*\*: 0.25/);
|
|
130
|
+
});
|
|
131
|
+
test("POST /api/team/update rejects deltas outside the allowed range", async () => {
|
|
132
|
+
const team = await loadTeamModule();
|
|
133
|
+
assert.ok(team, "team router module should exist");
|
|
134
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
135
|
+
const router = team.createTeamRouter({
|
|
136
|
+
authMiddleware: createMockAuthMiddleware({
|
|
137
|
+
id: "eng-delta-1",
|
|
138
|
+
name: "Ada Lovelace",
|
|
139
|
+
email: "ada@example.com",
|
|
140
|
+
role: "engineer",
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
144
|
+
const response = await fetch(`${baseUrl}/api/team/update`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
engineerId: "eng-delta-1",
|
|
149
|
+
activity: "Attempted an unsafe jump",
|
|
150
|
+
krId: "KR-2",
|
|
151
|
+
delta: 100001,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
assert.equal(response.status, 400);
|
|
155
|
+
assert.deepEqual(await response.json(), {
|
|
156
|
+
error: "Too big: expected number to be <=100000",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
test("engineer cannot PUT protected team wiki paths", async () => {
|
|
161
|
+
const team = await loadTeamModule();
|
|
162
|
+
assert.ok(team, "team router module should exist");
|
|
163
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
164
|
+
const router = team.createTeamRouter({
|
|
165
|
+
authMiddleware: createMockAuthMiddleware({
|
|
166
|
+
id: "eng-2",
|
|
167
|
+
name: "Grace Hopper",
|
|
168
|
+
email: "grace@example.com",
|
|
169
|
+
role: "engineer",
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
173
|
+
const response = await fetch(`${baseUrl}/api/team/wiki/pages/okrs/2026-Q2.md`, {
|
|
174
|
+
method: "PUT",
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
body: JSON.stringify({ content: "# Q2\n" }),
|
|
177
|
+
});
|
|
178
|
+
assert.equal(response.status, 403);
|
|
179
|
+
assert.deepEqual(await response.json(), { error: "Forbidden" });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
test("engineer cannot traverse from shared wiki paths into protected prefixes", async () => {
|
|
183
|
+
const team = await loadTeamModule();
|
|
184
|
+
assert.ok(team, "team router module should exist");
|
|
185
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
186
|
+
const router = team.createTeamRouter({
|
|
187
|
+
authMiddleware: createMockAuthMiddleware({
|
|
188
|
+
id: "eng-2b",
|
|
189
|
+
name: "Grace Hopper",
|
|
190
|
+
email: "grace@example.com",
|
|
191
|
+
role: "engineer",
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
195
|
+
const response = await fetch(`${baseUrl}/api/team/wiki/pages/shared/../okrs/2026-Q2.md`, {
|
|
196
|
+
method: "PUT",
|
|
197
|
+
headers: { "content-type": "application/json" },
|
|
198
|
+
body: JSON.stringify({ content: "# Q2\n" }),
|
|
199
|
+
});
|
|
200
|
+
assert.equal(response.status, 403);
|
|
201
|
+
assert.deepEqual(await response.json(), { error: "Forbidden" });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
test("engineer can PUT /api/team/wiki/:path inside pages/shared", async () => {
|
|
205
|
+
const team = await loadTeamModule();
|
|
206
|
+
assert.ok(team, "team router module should exist");
|
|
207
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
208
|
+
const router = team.createTeamRouter({
|
|
209
|
+
authMiddleware: createMockAuthMiddleware({
|
|
210
|
+
id: "eng-shared-1",
|
|
211
|
+
name: "Grace Hopper",
|
|
212
|
+
email: "grace@example.com",
|
|
213
|
+
role: "engineer",
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
217
|
+
const response = await fetch(`${baseUrl}/api/team/wiki/pages/shared/runbooks/deploy.md`, {
|
|
218
|
+
method: "PUT",
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ content: "# Deploy Runbook\n" }),
|
|
221
|
+
});
|
|
222
|
+
assert.equal(response.status, 200);
|
|
223
|
+
assert.deepEqual(await response.json(), { ok: true, path: "pages/shared/runbooks/deploy.md" });
|
|
224
|
+
});
|
|
225
|
+
const content = readFileSync(wikiRootPath("wiki", "pages", "shared", "runbooks", "deploy.md"), "utf-8");
|
|
226
|
+
assert.equal(content, "# Deploy Runbook\n");
|
|
227
|
+
});
|
|
228
|
+
test("team lead can PUT /api/team/wiki/:path", async () => {
|
|
229
|
+
const team = await loadTeamModule();
|
|
230
|
+
assert.ok(team, "team router module should exist");
|
|
231
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
232
|
+
const router = team.createTeamRouter({
|
|
233
|
+
authMiddleware: createMockAuthMiddleware({
|
|
234
|
+
id: "lead-1",
|
|
235
|
+
name: "Brian Ketelsen",
|
|
236
|
+
email: "brian@example.com",
|
|
237
|
+
role: "team-lead",
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
241
|
+
const response = await fetch(`${baseUrl}/api/team/wiki/pages/okrs/2026-Q2.md`, {
|
|
242
|
+
method: "PUT",
|
|
243
|
+
headers: { "content-type": "application/json" },
|
|
244
|
+
body: JSON.stringify({ content: "# Q2\n\nUpdated\n" }),
|
|
245
|
+
});
|
|
246
|
+
assert.equal(response.status, 200);
|
|
247
|
+
assert.deepEqual(await response.json(), { ok: true, path: "pages/okrs/2026-Q2.md" });
|
|
248
|
+
});
|
|
249
|
+
const content = readFileSync(wikiRootPath("wiki", "pages", "okrs", "2026-Q2.md"), "utf-8");
|
|
250
|
+
assert.equal(content, "# Q2\n\nUpdated\n");
|
|
251
|
+
});
|
|
252
|
+
test("GET /api/team/wiki lists team wiki pages under synced prefixes", async () => {
|
|
253
|
+
const team = await loadTeamModule();
|
|
254
|
+
assert.ok(team, "team router module should exist");
|
|
255
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
256
|
+
mkdirSync(wikiRootPath("wiki", "pages", "team"), { recursive: true });
|
|
257
|
+
mkdirSync(wikiRootPath("wiki", "pages", "okrs"), { recursive: true });
|
|
258
|
+
mkdirSync(wikiRootPath("wiki", "pages", "kpis"), { recursive: true });
|
|
259
|
+
mkdirSync(wikiRootPath("wiki", "pages", "shared"), { recursive: true });
|
|
260
|
+
mkdirSync(wikiRootPath("wiki", "pages", "people"), { recursive: true });
|
|
261
|
+
writeFileSync(wikiRootPath("wiki", "pages", "team", "vision.md"), "# Team Vision\n");
|
|
262
|
+
writeFileSync(wikiRootPath("wiki", "pages", "okrs", "2026-Q2.md"), "# Q2 OKRs\n");
|
|
263
|
+
writeFileSync(wikiRootPath("wiki", "pages", "kpis", "reliability.md"), "# Reliability\n");
|
|
264
|
+
writeFileSync(wikiRootPath("wiki", "pages", "shared", "README.md"), "# Shared Team Wiki\n");
|
|
265
|
+
writeFileSync(wikiRootPath("wiki", "pages", "people", "ada.md"), "# Ada\n");
|
|
266
|
+
const router = team.createTeamRouter({
|
|
267
|
+
authMiddleware: createMockAuthMiddleware({
|
|
268
|
+
id: "eng-3",
|
|
269
|
+
name: "Margaret Hamilton",
|
|
270
|
+
email: "margaret@example.com",
|
|
271
|
+
role: "engineer",
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
274
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
275
|
+
const response = await fetch(`${baseUrl}/api/team/wiki`);
|
|
276
|
+
assert.equal(response.status, 200);
|
|
277
|
+
const payload = await response.json();
|
|
278
|
+
assert.ok(payload.pages.includes("pages/team/vision.md"));
|
|
279
|
+
assert.ok(payload.pages.includes("pages/okrs/2026-Q2.md"));
|
|
280
|
+
assert.ok(payload.pages.includes("pages/kpis/reliability.md"));
|
|
281
|
+
assert.ok(payload.pages.includes("pages/shared/README.md"));
|
|
282
|
+
assert.equal(payload.pages.includes("pages/people/ada.md"), false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
test("POST /api/team/update notifies Teams when a KR crosses a milestone", async () => {
|
|
286
|
+
const team = await loadTeamModule();
|
|
287
|
+
assert.ok(team, "team router module should exist");
|
|
288
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
289
|
+
const milestones = [];
|
|
290
|
+
const router = team.createTeamRouter({
|
|
291
|
+
authMiddleware: createMockAuthMiddleware({
|
|
292
|
+
id: "eng-4",
|
|
293
|
+
name: "Katherine Johnson",
|
|
294
|
+
email: "katherine@example.com",
|
|
295
|
+
role: "engineer",
|
|
296
|
+
}),
|
|
297
|
+
teamsNotifier: {
|
|
298
|
+
notifyKRMilestone: async (kr) => {
|
|
299
|
+
milestones.push(kr);
|
|
300
|
+
return true;
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
305
|
+
const response = await fetch(`${baseUrl}/api/team/update`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "content-type": "application/json" },
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
engineerId: "eng-4",
|
|
310
|
+
activity: "Improved SSO rollout coverage",
|
|
311
|
+
krId: "KR-9",
|
|
312
|
+
delta: 10,
|
|
313
|
+
notes: "Crossed the halfway mark",
|
|
314
|
+
kr: {
|
|
315
|
+
id: "KR-9",
|
|
316
|
+
title: "Ship SSO to all tenants",
|
|
317
|
+
owner: "Katherine Johnson",
|
|
318
|
+
currentValue: 50,
|
|
319
|
+
targetValue: 100,
|
|
320
|
+
unit: "%",
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
});
|
|
324
|
+
assert.equal(response.status, 200);
|
|
325
|
+
});
|
|
326
|
+
assert.equal(milestones.length, 1);
|
|
327
|
+
assert.deepEqual(milestones[0], {
|
|
328
|
+
id: "KR-9",
|
|
329
|
+
title: "Ship SSO to all tenants",
|
|
330
|
+
owner: "Katherine Johnson",
|
|
331
|
+
currentValue: 50,
|
|
332
|
+
targetValue: 100,
|
|
333
|
+
unit: "%",
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
test("POST /api/team/update pushes numeric KR updates to Azure DevOps", async () => {
|
|
337
|
+
const team = await loadTeamModule();
|
|
338
|
+
assert.ok(team, "team router module should exist");
|
|
339
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
340
|
+
const adoUpdates = [];
|
|
341
|
+
const router = team.createTeamRouter({
|
|
342
|
+
authMiddleware: createMockAuthMiddleware({
|
|
343
|
+
id: "eng-5",
|
|
344
|
+
name: "Dorothy Vaughan",
|
|
345
|
+
email: "dorothy@example.com",
|
|
346
|
+
role: "engineer",
|
|
347
|
+
}),
|
|
348
|
+
adoClient: {
|
|
349
|
+
async updateKRProgress(workItemId, currentValue, notes) {
|
|
350
|
+
adoUpdates.push({ workItemId, currentValue, notes });
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
355
|
+
const response = await fetch(`${baseUrl}/api/team/update`, {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "content-type": "application/json" },
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
engineerId: "eng-5",
|
|
360
|
+
activity: "Advanced the SSO migration",
|
|
361
|
+
krId: 1234,
|
|
362
|
+
delta: 88,
|
|
363
|
+
notes: "Ready for wider rollout",
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
assert.equal(response.status, 200);
|
|
367
|
+
});
|
|
368
|
+
assert.deepEqual(adoUpdates, [
|
|
369
|
+
{
|
|
370
|
+
workItemId: 1234,
|
|
371
|
+
currentValue: 88,
|
|
372
|
+
notes: "Ready for wider rollout",
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
});
|
|
376
|
+
test("POST /api/team/update rejects non-numeric krId values when ADO sync is configured", async () => {
|
|
377
|
+
const { config } = await import("../config.js");
|
|
378
|
+
const previousAdoPat = config.adoPat;
|
|
379
|
+
config.adoPat = "test-pat";
|
|
380
|
+
try {
|
|
381
|
+
const team = await loadTeamModule();
|
|
382
|
+
assert.ok(team, "team router module should exist");
|
|
383
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
384
|
+
const adoUpdates = [];
|
|
385
|
+
const router = team.createTeamRouter({
|
|
386
|
+
authMiddleware: createMockAuthMiddleware({
|
|
387
|
+
id: "eng-ado-1",
|
|
388
|
+
name: "Dorothy Vaughan",
|
|
389
|
+
email: "dorothy@example.com",
|
|
390
|
+
role: "engineer",
|
|
391
|
+
}),
|
|
392
|
+
adoClient: {
|
|
393
|
+
async updateKRProgress(workItemId, currentValue, notes) {
|
|
394
|
+
adoUpdates.push({ workItemId, currentValue, notes });
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
399
|
+
const response = await fetch(`${baseUrl}/api/team/update`, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { "content-type": "application/json" },
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
engineerId: "eng-ado-1",
|
|
404
|
+
activity: "Advanced the SSO migration",
|
|
405
|
+
krId: "KR-1234",
|
|
406
|
+
delta: 5,
|
|
407
|
+
notes: "Should fail fast",
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
assert.equal(response.status, 400);
|
|
411
|
+
assert.deepEqual(await response.json(), {
|
|
412
|
+
error: "krId must be a numeric ADO work item ID when ADO_PAT is configured",
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
assert.deepEqual(adoUpdates, []);
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
config.adoPat = previousAdoPat;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
test("team lead can POST /api/team/report and falls back to the current quarter", async () => {
|
|
422
|
+
const realDate = Date;
|
|
423
|
+
class FixedDate extends Date {
|
|
424
|
+
constructor(value) {
|
|
425
|
+
super(value ?? "2026-05-06T10:00:00.000Z");
|
|
426
|
+
}
|
|
427
|
+
static now() {
|
|
428
|
+
return new realDate("2026-05-06T10:00:00.000Z").valueOf();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
globalThis.Date = FixedDate;
|
|
432
|
+
try {
|
|
433
|
+
const team = await loadTeamModule();
|
|
434
|
+
assert.ok(team, "team router module should exist");
|
|
435
|
+
assert.equal(typeof team.createTeamRouter, "function", "createTeamRouter should be exported");
|
|
436
|
+
const requestedPeriods = [];
|
|
437
|
+
const router = team.createTeamRouter({
|
|
438
|
+
authMiddleware: createMockAuthMiddleware({
|
|
439
|
+
id: "lead-2",
|
|
440
|
+
name: "Brian Ketelsen",
|
|
441
|
+
email: "brian@example.com",
|
|
442
|
+
role: "team-lead",
|
|
443
|
+
}),
|
|
444
|
+
reportGenerator: {
|
|
445
|
+
async generateMonthlyReport(period) {
|
|
446
|
+
requestedPeriods.push(period);
|
|
447
|
+
return `Report for ${period}`;
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
await withServer(await createTestApp(router), async (baseUrl) => {
|
|
452
|
+
const response = await fetch(`${baseUrl}/api/team/report`, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { "content-type": "application/json" },
|
|
455
|
+
body: JSON.stringify({}),
|
|
456
|
+
});
|
|
457
|
+
assert.equal(response.status, 200);
|
|
458
|
+
assert.deepEqual(await response.json(), { ok: true, report: "Report for 2026-Q2" });
|
|
459
|
+
});
|
|
460
|
+
assert.deepEqual(requestedPeriods, ["2026-Q2"]);
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
globalThis.Date = realDate;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
//# sourceMappingURL=team.test.js.map
|