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.
Files changed (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,178 @@
1
+ import { config } from "../config.js";
2
+ import { ensureWikiStructure, listPages, readPage } from "../wiki/fs.js";
3
+ const RECENT_UPDATE_DAYS = 31;
4
+ function escapeODataString(value) {
5
+ return value.replace(/'/g, "''");
6
+ }
7
+ function encodeODataValue(value) {
8
+ return encodeURIComponent(value).replace(/%27/g, "'");
9
+ }
10
+ function buildDefaultBaseUrl(org, project) {
11
+ const normalizedOrg = org.trim().replace(/\/+$/, "");
12
+ const normalizedProject = project.trim().replace(/^\/+|\/+$/g, "");
13
+ return `${normalizedOrg.replace("https://dev.azure.com", "https://analytics.dev.azure.com")}/${normalizedProject}/_odata/v4.0-preview`;
14
+ }
15
+ function buildODataUrl(baseUrl, filter, select) {
16
+ return `${baseUrl.replace(/\/+$/, "")}/WorkItems?$filter=${encodeODataValue(filter)}&$select=${select.join(",")}`;
17
+ }
18
+ function asNumber(value) {
19
+ if (typeof value === "number" && Number.isFinite(value)) {
20
+ return value;
21
+ }
22
+ if (typeof value === "string" && value.trim().length > 0) {
23
+ const parsed = Number(value);
24
+ if (Number.isFinite(parsed)) {
25
+ return parsed;
26
+ }
27
+ }
28
+ return 0;
29
+ }
30
+ function asInteger(value) {
31
+ const parsed = asNumber(value);
32
+ return Number.isInteger(parsed) ? parsed : null;
33
+ }
34
+ function asString(value) {
35
+ return typeof value === "string" ? value.trim() : "";
36
+ }
37
+ function toPercentComplete(currentValue, targetValue) {
38
+ if (!Number.isFinite(currentValue) || !Number.isFinite(targetValue) || targetValue <= 0) {
39
+ return 0;
40
+ }
41
+ return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
42
+ }
43
+ function toRecentUpdateCutoff(now) {
44
+ const cutoff = new Date(now);
45
+ cutoff.setUTCDate(cutoff.getUTCDate() - (RECENT_UPDATE_DAYS - 1));
46
+ return cutoff.toISOString().slice(0, 10);
47
+ }
48
+ function getRecentUpdatePaths(now, listPagePaths) {
49
+ ensureWikiStructure();
50
+ const cutoff = toRecentUpdateCutoff(now);
51
+ return listPagePaths()
52
+ .filter((path) => path.startsWith("pages/okrs/updates/"))
53
+ .filter((path) => path.slice("pages/okrs/updates/".length).replace(/\.md$/, "") >= cutoff)
54
+ .sort()
55
+ .reverse();
56
+ }
57
+ function extractHighlights(content) {
58
+ const matches = content.matchAll(/\*\*Activity\*\*:\s*(.+?)(?:\s{2,}|\n|$)/g);
59
+ return Array.from(matches, (match) => (match[1] ?? "").trim()).filter(Boolean);
60
+ }
61
+ export class AdoAnalytics {
62
+ baseUrl;
63
+ pat;
64
+ fetchImpl;
65
+ now;
66
+ listPagesImpl;
67
+ readPageImpl;
68
+ constructor(options = {}) {
69
+ this.baseUrl = (options.baseUrl ?? buildDefaultBaseUrl(config.ADO_ORG, config.ADO_PROJECT)).trim().replace(/\/+$/, "");
70
+ this.pat = (options.pat ?? config.adoPat).trim();
71
+ this.fetchImpl = options.fetchImpl ?? fetch;
72
+ this.now = options.now ?? (() => new Date());
73
+ this.listPagesImpl = options.listPages ?? listPages;
74
+ this.readPageImpl = options.readPage ?? readPage;
75
+ }
76
+ async getKRProgress(period) {
77
+ const rows = await this.getKRProgressWithParents(period);
78
+ return rows.map(({ parentWorkItemId: _parentWorkItemId, ...row }) => row);
79
+ }
80
+ async getObjectiveRollup(period) {
81
+ const [objectives, keyResults] = await Promise.all([
82
+ this.fetchJson(buildODataUrl(this.baseUrl, `WorkItemType eq 'Epic' and Custom_OKRPeriod eq '${escapeODataString(period)}'`, ["WorkItemId", "Title", "Custom_OKROwner"])),
83
+ this.getKRProgressWithParents(period),
84
+ ]);
85
+ const krsByObjective = new Map();
86
+ for (const kr of keyResults) {
87
+ if (kr.parentWorkItemId === null) {
88
+ continue;
89
+ }
90
+ const existing = krsByObjective.get(kr.parentWorkItemId) ?? [];
91
+ existing.push({
92
+ workItemId: kr.workItemId,
93
+ title: kr.title,
94
+ currentValue: kr.currentValue,
95
+ targetValue: kr.targetValue,
96
+ percentComplete: kr.percentComplete,
97
+ owner: kr.owner,
98
+ state: kr.state,
99
+ });
100
+ krsByObjective.set(kr.parentWorkItemId, existing);
101
+ }
102
+ return (objectives.value ?? [])
103
+ .map((objective) => {
104
+ const workItemId = asInteger(objective.WorkItemId);
105
+ if (workItemId === null) {
106
+ return null;
107
+ }
108
+ const krs = krsByObjective.get(workItemId) ?? [];
109
+ const percentComplete = krs.length === 0
110
+ ? 0
111
+ : Math.round(krs.reduce((sum, kr) => sum + kr.percentComplete, 0) / krs.length);
112
+ return {
113
+ workItemId,
114
+ title: asString(objective.Title),
115
+ owner: asString(objective.Custom_OKROwner),
116
+ percentComplete,
117
+ krs,
118
+ };
119
+ })
120
+ .filter((objective) => objective !== null);
121
+ }
122
+ async buildReportData(period) {
123
+ const objectives = await this.getObjectiveRollup(period);
124
+ const recentUpdates = getRecentUpdatePaths(this.now(), this.listPagesImpl);
125
+ const highlights = recentUpdates
126
+ .flatMap((path) => extractHighlights(this.readPageImpl(path) ?? ""))
127
+ .slice(0, 10);
128
+ const totalKRs = objectives.reduce((sum, objective) => sum + objective.krs.length, 0);
129
+ const completedKRs = objectives.reduce((sum, objective) => sum + objective.krs.filter((kr) => kr.percentComplete >= 100 || /^done$/i.test(kr.state)).length, 0);
130
+ return {
131
+ period,
132
+ generatedAt: this.now().toISOString(),
133
+ objectives,
134
+ highlights,
135
+ totalKRs,
136
+ completedKRs,
137
+ };
138
+ }
139
+ async getKRProgressWithParents(period) {
140
+ const payload = await this.fetchJson(buildODataUrl(this.baseUrl, `WorkItemType eq 'Feature' and Custom_OKRPeriod eq '${escapeODataString(period)}'`, ["WorkItemId", "Title", "Custom_CurrentValue", "Custom_TargetValue", "Custom_OKROwner", "State", "ParentWorkItemId"]));
141
+ return (payload.value ?? [])
142
+ .map((row) => {
143
+ const workItemId = asInteger(row.WorkItemId);
144
+ if (workItemId === null) {
145
+ return null;
146
+ }
147
+ const currentValue = asNumber(row.Custom_CurrentValue);
148
+ const targetValue = asNumber(row.Custom_TargetValue);
149
+ return {
150
+ workItemId,
151
+ title: asString(row.Title),
152
+ currentValue,
153
+ targetValue,
154
+ percentComplete: toPercentComplete(currentValue, targetValue),
155
+ owner: asString(row.Custom_OKROwner),
156
+ state: asString(row.State),
157
+ parentWorkItemId: asInteger(row.ParentWorkItemId),
158
+ };
159
+ })
160
+ .filter((row) => row !== null);
161
+ }
162
+ async fetchJson(url) {
163
+ if (this.pat.length === 0) {
164
+ throw new Error("ADO_PAT is not configured.");
165
+ }
166
+ const response = await this.fetchImpl(url, {
167
+ headers: {
168
+ authorization: `Basic ${Buffer.from(`:${this.pat}`).toString("base64")}`,
169
+ accept: "application/json",
170
+ },
171
+ });
172
+ if (!response.ok) {
173
+ throw new Error(`ADO Analytics request failed: HTTP ${response.status}`);
174
+ }
175
+ return await response.json();
176
+ }
177
+ }
178
+ //# sourceMappingURL=ado-analytics.js.map
@@ -0,0 +1,284 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
6
+ async function loadAnalyticsModule() {
7
+ try {
8
+ return await import(new URL(`./ado-analytics.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ test("getKRProgress builds the expected OData URL and parses the response", async () => {
15
+ const analyticsModule = await loadAnalyticsModule();
16
+ assert.ok(analyticsModule, "ado analytics module should exist");
17
+ const requests = [];
18
+ const analytics = new analyticsModule.AdoAnalytics({
19
+ baseUrl: "https://analytics.dev.azure.com/example-org/example-project/_odata/v4.0-preview",
20
+ pat: "test-pat",
21
+ fetchImpl: async (input, init) => {
22
+ requests.push({ input: String(input), init });
23
+ return new Response(JSON.stringify({
24
+ value: [
25
+ {
26
+ WorkItemId: 101,
27
+ Title: "Ship SSO to all tenants",
28
+ Custom_CurrentValue: 75,
29
+ Custom_TargetValue: 100,
30
+ Custom_OKROwner: "ada@example.com",
31
+ State: "Active",
32
+ },
33
+ ],
34
+ }), {
35
+ status: 200,
36
+ headers: { "content-type": "application/json" },
37
+ });
38
+ },
39
+ });
40
+ const rows = await analytics.getKRProgress("2026-Q2");
41
+ assert.equal(requests.length, 1);
42
+ const request = requests[0];
43
+ assert.ok(request, "request should be captured");
44
+ assert.equal(request.input, "https://analytics.dev.azure.com/example-org/example-project/_odata/v4.0-preview/WorkItems?$filter=WorkItemType%20eq%20'Feature'%20and%20Custom_OKRPeriod%20eq%20'2026-Q2'&$select=WorkItemId,Title,Custom_CurrentValue,Custom_TargetValue,Custom_OKROwner,State,ParentWorkItemId");
45
+ assert.equal((request.init?.headers).authorization, `Basic ${Buffer.from(":test-pat").toString("base64")}`);
46
+ assert.deepEqual(rows, [
47
+ {
48
+ workItemId: 101,
49
+ title: "Ship SSO to all tenants",
50
+ currentValue: 75,
51
+ targetValue: 100,
52
+ percentComplete: 75,
53
+ owner: "ada@example.com",
54
+ state: "Active",
55
+ },
56
+ ]);
57
+ });
58
+ test("getKRProgress caps percentComplete at 100", async () => {
59
+ const analyticsModule = await loadAnalyticsModule();
60
+ assert.ok(analyticsModule, "ado analytics module should exist");
61
+ const analytics = new analyticsModule.AdoAnalytics({
62
+ pat: "test-pat",
63
+ fetchImpl: async () => new Response(JSON.stringify({
64
+ value: [
65
+ {
66
+ WorkItemId: 202,
67
+ Title: "Over-achieve the KR",
68
+ Custom_CurrentValue: 140,
69
+ Custom_TargetValue: 100,
70
+ Custom_OKROwner: "grace@example.com",
71
+ State: "Done",
72
+ },
73
+ ],
74
+ }), {
75
+ status: 200,
76
+ headers: { "content-type": "application/json" },
77
+ }),
78
+ });
79
+ const rows = await analytics.getKRProgress("2026-Q2");
80
+ assert.equal(rows[0]?.percentComplete, 100);
81
+ });
82
+ test("uses configured ADO org and project for the default analytics base URL", async () => {
83
+ const result = spawnSync(process.execPath, [
84
+ "--import",
85
+ "tsx",
86
+ "--input-type=module",
87
+ "--eval",
88
+ `
89
+ const mod = await import("./src/integrations/ado-analytics.ts");
90
+ const analytics = new mod.AdoAnalytics({
91
+ pat: "test-pat",
92
+ fetchImpl: async (input) => {
93
+ console.log("__RESULT__" + String(input));
94
+ return new Response(JSON.stringify({ value: [] }), {
95
+ status: 200,
96
+ headers: { "content-type": "application/json" },
97
+ });
98
+ },
99
+ });
100
+ await analytics.getKRProgress("2026-Q2");
101
+ `,
102
+ ], {
103
+ cwd: REPO_ROOT,
104
+ env: {
105
+ ...process.env,
106
+ ADO_ORG: "https://dev.azure.com/example-org",
107
+ ADO_PROJECT: "example-project",
108
+ },
109
+ encoding: "utf8",
110
+ });
111
+ assert.equal(result.status, 0, result.stderr);
112
+ const outputLine = result.stdout
113
+ .split("\n")
114
+ .find((line) => line.startsWith("__RESULT__"));
115
+ assert.ok(outputLine, "child process should print the default analytics URL");
116
+ assert.equal(outputLine.replace("__RESULT__", ""), "https://analytics.dev.azure.com/example-org/example-project/_odata/v4.0-preview/WorkItems?$filter=WorkItemType%20eq%20'Feature'%20and%20Custom_OKRPeriod%20eq%20'2026-Q2'&$select=WorkItemId,Title,Custom_CurrentValue,Custom_TargetValue,Custom_OKROwner,State,ParentWorkItemId");
117
+ });
118
+ test("getObjectiveRollup averages KR percentages for each objective", async () => {
119
+ const analyticsModule = await loadAnalyticsModule();
120
+ assert.ok(analyticsModule, "ado analytics module should exist");
121
+ const analytics = new analyticsModule.AdoAnalytics({
122
+ pat: "test-pat",
123
+ fetchImpl: async (input) => {
124
+ const url = String(input);
125
+ if (url.includes("WorkItemType%20eq%20'Epic'")) {
126
+ return new Response(JSON.stringify({
127
+ value: [
128
+ {
129
+ WorkItemId: 7,
130
+ Title: "Improve identity coverage",
131
+ Custom_OKROwner: "ada@example.com",
132
+ },
133
+ ],
134
+ }), {
135
+ status: 200,
136
+ headers: { "content-type": "application/json" },
137
+ });
138
+ }
139
+ return new Response(JSON.stringify({
140
+ value: [
141
+ {
142
+ WorkItemId: 101,
143
+ Title: "Ship SSO to all tenants",
144
+ Custom_CurrentValue: 75,
145
+ Custom_TargetValue: 100,
146
+ Custom_OKROwner: "ada@example.com",
147
+ State: "Active",
148
+ ParentWorkItemId: 7,
149
+ },
150
+ {
151
+ WorkItemId: 102,
152
+ Title: "Migrate legacy auth flows",
153
+ Custom_CurrentValue: 20,
154
+ Custom_TargetValue: 40,
155
+ Custom_OKROwner: "ada@example.com",
156
+ State: "Active",
157
+ ParentWorkItemId: 7,
158
+ },
159
+ ],
160
+ }), {
161
+ status: 200,
162
+ headers: { "content-type": "application/json" },
163
+ });
164
+ },
165
+ });
166
+ const objectives = await analytics.getObjectiveRollup("2026-Q2");
167
+ assert.deepEqual(objectives, [
168
+ {
169
+ workItemId: 7,
170
+ title: "Improve identity coverage",
171
+ owner: "ada@example.com",
172
+ percentComplete: 63,
173
+ krs: [
174
+ {
175
+ workItemId: 101,
176
+ title: "Ship SSO to all tenants",
177
+ currentValue: 75,
178
+ targetValue: 100,
179
+ percentComplete: 75,
180
+ owner: "ada@example.com",
181
+ state: "Active",
182
+ },
183
+ {
184
+ workItemId: 102,
185
+ title: "Migrate legacy auth flows",
186
+ currentValue: 20,
187
+ targetValue: 40,
188
+ percentComplete: 50,
189
+ owner: "ada@example.com",
190
+ state: "Active",
191
+ },
192
+ ],
193
+ },
194
+ ]);
195
+ });
196
+ test("buildReportData combines objective rollups and recent update highlights", async () => {
197
+ const analyticsModule = await loadAnalyticsModule();
198
+ assert.ok(analyticsModule, "ado analytics module should exist");
199
+ const analytics = new analyticsModule.AdoAnalytics({
200
+ pat: "test-pat",
201
+ now: () => new Date("2026-05-06T00:00:00.000Z"),
202
+ listPages: () => [
203
+ "pages/okrs/updates/2026-05-02.md",
204
+ "pages/okrs/updates/2026-04-28.md",
205
+ "pages/okrs/2026-Q2.md",
206
+ ],
207
+ readPage: (path) => {
208
+ if (path === "pages/okrs/updates/2026-05-02.md") {
209
+ return [
210
+ "## 2026-05-02T10:00:00.000Z — eng-1",
211
+ "",
212
+ "**Activity**: Finished SSO rollout dry-run ",
213
+ "**Key Result**: 101 ",
214
+ "**Delta**: 10 ",
215
+ "",
216
+ ].join("\n");
217
+ }
218
+ if (path === "pages/okrs/updates/2026-04-28.md") {
219
+ return [
220
+ "## 2026-04-28T10:00:00.000Z — eng-2",
221
+ "",
222
+ "**Activity**: Removed the last auth migration blocker ",
223
+ "**Key Result**: 102 ",
224
+ "**Delta**: 5 ",
225
+ "",
226
+ ].join("\n");
227
+ }
228
+ return undefined;
229
+ },
230
+ fetchImpl: async (input) => {
231
+ const url = String(input);
232
+ if (url.includes("WorkItemType%20eq%20'Epic'")) {
233
+ return new Response(JSON.stringify({
234
+ value: [
235
+ {
236
+ WorkItemId: 7,
237
+ Title: "Improve identity coverage",
238
+ Custom_OKROwner: "ada@example.com",
239
+ },
240
+ ],
241
+ }), {
242
+ status: 200,
243
+ headers: { "content-type": "application/json" },
244
+ });
245
+ }
246
+ return new Response(JSON.stringify({
247
+ value: [
248
+ {
249
+ WorkItemId: 101,
250
+ Title: "Ship SSO to all tenants",
251
+ Custom_CurrentValue: 100,
252
+ Custom_TargetValue: 100,
253
+ Custom_OKROwner: "ada@example.com",
254
+ State: "Done",
255
+ ParentWorkItemId: 7,
256
+ },
257
+ {
258
+ WorkItemId: 102,
259
+ Title: "Migrate legacy auth flows",
260
+ Custom_CurrentValue: 20,
261
+ Custom_TargetValue: 40,
262
+ Custom_OKROwner: "ada@example.com",
263
+ State: "Active",
264
+ ParentWorkItemId: 7,
265
+ },
266
+ ],
267
+ }), {
268
+ status: 200,
269
+ headers: { "content-type": "application/json" },
270
+ });
271
+ },
272
+ });
273
+ const reportData = await analytics.buildReportData("2026-Q2");
274
+ assert.equal(reportData.period, "2026-Q2");
275
+ assert.equal(reportData.generatedAt, "2026-05-06T00:00:00.000Z");
276
+ assert.equal(reportData.totalKRs, 2);
277
+ assert.equal(reportData.completedKRs, 1);
278
+ assert.deepEqual(reportData.highlights, [
279
+ "Finished SSO rollout dry-run",
280
+ "Removed the last auth migration blocker",
281
+ ]);
282
+ assert.equal(reportData.objectives[0]?.percentComplete, 75);
283
+ });
284
+ //# sourceMappingURL=ado-analytics.test.js.map
@@ -0,0 +1,227 @@
1
+ import * as azureDevops from "azure-devops-node-api";
2
+ import { config } from "../config.js";
3
+ import { ADO_ORG, ADO_PROJECT, FIELDS, STD_FIELDS, UNIT_FIELD, WIT } from "./ado-schema.js";
4
+ function escapeWiqlLiteral(value) {
5
+ return value.replace(/'/g, "''");
6
+ }
7
+ function getStringField(fields, fieldName) {
8
+ const value = fields?.[fieldName];
9
+ if (typeof value === "string") {
10
+ return value.trim();
11
+ }
12
+ if (typeof value === "number" && Number.isFinite(value)) {
13
+ return String(value);
14
+ }
15
+ return "";
16
+ }
17
+ function getNumberField(fields, fieldName) {
18
+ const value = fields?.[fieldName];
19
+ if (typeof value === "number" && Number.isFinite(value)) {
20
+ return value;
21
+ }
22
+ if (typeof value === "string" && value.trim().length > 0) {
23
+ const parsed = Number(value);
24
+ if (Number.isFinite(parsed)) {
25
+ return parsed;
26
+ }
27
+ }
28
+ return 0;
29
+ }
30
+ function getParentId(fields) {
31
+ const value = fields?.[STD_FIELDS.PARENT];
32
+ if (typeof value === "number" && Number.isInteger(value)) {
33
+ return value;
34
+ }
35
+ if (typeof value === "string" && value.trim().length > 0) {
36
+ const parsed = Number(value);
37
+ if (Number.isInteger(parsed)) {
38
+ return parsed;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ function toPercentComplete(currentValue, targetValue) {
44
+ if (!Number.isFinite(currentValue) || !Number.isFinite(targetValue) || targetValue <= 0) {
45
+ return 0;
46
+ }
47
+ return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
48
+ }
49
+ function buildWiql(workItemType, period) {
50
+ const periodClause = period
51
+ ? ` AND [${FIELDS.OKR_PERIOD}] = '${escapeWiqlLiteral(period)}'`
52
+ : "";
53
+ return [
54
+ "SELECT [System.Id]",
55
+ "FROM WorkItems",
56
+ "WHERE [System.TeamProject] = @project",
57
+ `AND [System.WorkItemType] = '${workItemType}'${periodClause}`,
58
+ ].join(" ");
59
+ }
60
+ function getWorkItemIds(result) {
61
+ return (result.workItems ?? [])
62
+ .map((item) => item.id)
63
+ .filter((id) => typeof id === "number");
64
+ }
65
+ export class AdoClient {
66
+ org;
67
+ project;
68
+ pat;
69
+ workItemTrackingApi;
70
+ constructor(options = {}) {
71
+ this.org = options.org?.trim() || config.adoOrg || ADO_ORG;
72
+ this.project = options.project?.trim() || config.adoProject || ADO_PROJECT;
73
+ this.pat = options.pat?.trim() || config.adoPat || "";
74
+ this.workItemTrackingApi = options.workItemTrackingApi;
75
+ }
76
+ async getKeyResults(period) {
77
+ const api = await this.getApi();
78
+ const result = await api.queryByWiql({ query: buildWiql(WIT.KEY_RESULT, period) }, { project: this.project });
79
+ const ids = getWorkItemIds(result);
80
+ if (ids.length === 0) {
81
+ return [];
82
+ }
83
+ const workItems = await api.getWorkItems(ids, [
84
+ STD_FIELDS.TITLE,
85
+ STD_FIELDS.PARENT,
86
+ FIELDS.CURRENT_VALUE,
87
+ FIELDS.TARGET_VALUE,
88
+ FIELDS.OKR_OWNER,
89
+ UNIT_FIELD,
90
+ ]);
91
+ return workItems
92
+ .filter((item) => typeof item.id === "number")
93
+ .map((item) => ({
94
+ id: item.id,
95
+ title: getStringField(item.fields, STD_FIELDS.TITLE),
96
+ currentValue: getNumberField(item.fields, FIELDS.CURRENT_VALUE),
97
+ targetValue: getNumberField(item.fields, FIELDS.TARGET_VALUE),
98
+ unit: getStringField(item.fields, UNIT_FIELD),
99
+ owner: getStringField(item.fields, FIELDS.OKR_OWNER),
100
+ parentId: getParentId(item.fields),
101
+ }));
102
+ }
103
+ async getObjectives(period) {
104
+ const [objectiveWorkItems, keyResults] = await Promise.all([
105
+ this.getObjectiveWorkItems(period),
106
+ this.getKeyResults(period),
107
+ ]);
108
+ const keyResultIdsByObjective = new Map();
109
+ for (const keyResult of keyResults) {
110
+ if (keyResult.parentId === null) {
111
+ continue;
112
+ }
113
+ const existing = keyResultIdsByObjective.get(keyResult.parentId) ?? [];
114
+ existing.push(keyResult.id);
115
+ keyResultIdsByObjective.set(keyResult.parentId, existing);
116
+ }
117
+ return objectiveWorkItems
118
+ .filter((item) => typeof item.id === "number")
119
+ .map((item) => ({
120
+ id: item.id,
121
+ title: getStringField(item.fields, STD_FIELDS.TITLE),
122
+ owner: getStringField(item.fields, FIELDS.OKR_OWNER),
123
+ keyResultIds: keyResultIdsByObjective.get(item.id) ?? [],
124
+ }));
125
+ }
126
+ async updateKRProgress(workItemId, currentValue, notes) {
127
+ const api = await this.getApi();
128
+ const document = [
129
+ { op: "add", path: `/fields/${FIELDS.CURRENT_VALUE}`, value: currentValue },
130
+ ];
131
+ await api.updateWorkItem(undefined, document, workItemId, this.project);
132
+ if (notes && notes.trim().length > 0) {
133
+ await api.addComment({ text: notes }, this.project, workItemId);
134
+ }
135
+ }
136
+ async getOKRSummary(period) {
137
+ const [objectives, keyResults] = await Promise.all([
138
+ this.getObjectives(period),
139
+ this.getKeyResults(period),
140
+ ]);
141
+ const keyResultsByObjective = new Map();
142
+ for (const keyResult of keyResults) {
143
+ if (keyResult.parentId === null) {
144
+ continue;
145
+ }
146
+ const withPercent = {
147
+ ...keyResult,
148
+ percentComplete: toPercentComplete(keyResult.currentValue, keyResult.targetValue),
149
+ };
150
+ const existing = keyResultsByObjective.get(keyResult.parentId) ?? [];
151
+ existing.push(withPercent);
152
+ keyResultsByObjective.set(keyResult.parentId, existing);
153
+ }
154
+ return {
155
+ period,
156
+ objectives: objectives.map((objective) => {
157
+ const objectiveKeyResults = keyResultsByObjective.get(objective.id) ?? [];
158
+ const percentComplete = objectiveKeyResults.length === 0
159
+ ? 0
160
+ : Math.round(objectiveKeyResults.reduce((sum, keyResult) => sum + keyResult.percentComplete, 0) / objectiveKeyResults.length);
161
+ return {
162
+ id: objective.id,
163
+ title: objective.title,
164
+ owner: objective.owner,
165
+ percentComplete,
166
+ keyResults: objectiveKeyResults,
167
+ };
168
+ }),
169
+ };
170
+ }
171
+ async createKeyResult(objectiveId, kr) {
172
+ const api = await this.getApi();
173
+ const document = [
174
+ { op: "add", path: `/fields/${STD_FIELDS.TITLE}`, value: kr.title },
175
+ { op: "add", path: `/fields/${FIELDS.CURRENT_VALUE}`, value: kr.currentValue },
176
+ { op: "add", path: `/fields/${FIELDS.TARGET_VALUE}`, value: kr.targetValue },
177
+ { op: "add", path: `/fields/${FIELDS.OKR_PERIOD}`, value: kr.period },
178
+ { op: "add", path: `/fields/${FIELDS.OKR_OWNER}`, value: kr.owner },
179
+ { op: "add", path: `/fields/${UNIT_FIELD}`, value: kr.unit },
180
+ {
181
+ op: "add",
182
+ path: "/relations/-",
183
+ value: {
184
+ rel: "System.LinkTypes.Hierarchy-Reverse",
185
+ url: `${this.org}/_apis/wit/workItems/${objectiveId}`,
186
+ },
187
+ },
188
+ ];
189
+ if (kr.tags && kr.tags.length > 0) {
190
+ document.push({
191
+ op: "add",
192
+ path: `/fields/${STD_FIELDS.TAGS}`,
193
+ value: kr.tags.join("; "),
194
+ });
195
+ }
196
+ const created = await api.createWorkItem(undefined, document, this.project, WIT.KEY_RESULT);
197
+ if (typeof created.id !== "number") {
198
+ throw new Error("Azure DevOps did not return a new work item ID.");
199
+ }
200
+ return created.id;
201
+ }
202
+ async getObjectiveWorkItems(period) {
203
+ const api = await this.getApi();
204
+ const result = await api.queryByWiql({ query: buildWiql(WIT.OBJECTIVE, period) }, { project: this.project });
205
+ const ids = getWorkItemIds(result);
206
+ if (ids.length === 0) {
207
+ return [];
208
+ }
209
+ return await api.getWorkItems(ids, [
210
+ STD_FIELDS.TITLE,
211
+ FIELDS.OKR_OWNER,
212
+ ]);
213
+ }
214
+ async getApi() {
215
+ if (this.workItemTrackingApi) {
216
+ return this.workItemTrackingApi;
217
+ }
218
+ if (this.pat.trim().length === 0) {
219
+ throw new Error("ADO_PAT is not configured.");
220
+ }
221
+ const handler = azureDevops.getPersonalAccessTokenHandler(this.pat);
222
+ const connection = new azureDevops.WebApi(this.org, handler);
223
+ this.workItemTrackingApi = await connection.getWorkItemTrackingApi();
224
+ return this.workItemTrackingApi;
225
+ }
226
+ }
227
+ //# sourceMappingURL=ado-client.js.map