elliot-stack 1.0.29 → 1.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +5 -0
- package/bin/install.cjs +981 -950
- package/hooks/repo-search-nudge.js +32 -32
- package/package.json +1 -1
- package/skills/estack-active-learning-tutor/SKILL.md +339 -339
- package/skills/estack-better-title/SKILL.md +64 -64
- package/skills/estack-better-title/scripts/rename.sh +55 -55
- package/skills/estack-chris-voss/SKILL.md +80 -80
- package/skills/estack-chris-voss/references/elliot-notes.md +120 -120
- package/skills/estack-chris-voss/references/voss-principles.md +210 -210
- package/skills/estack-customer-discovery/SKILL.md +60 -60
- package/skills/estack-flight-planner/SKILL.md +332 -332
- package/skills/estack-flight-planner/references/config_schema.md +156 -156
- package/skills/estack-flight-planner/references/flight_history_schema.md +97 -97
- package/skills/estack-flight-planner/references/shuttle_schedules.md +98 -98
- package/skills/estack-flight-planner/scripts/check_setup.sh +89 -89
- package/skills/estack-flight-planner/scripts/fetch_flights.py +99 -99
- package/skills/estack-flight-planner/scripts/filter_flights.py +265 -265
- package/skills/estack-flight-planner/scripts/pair_shuttles.py +173 -173
- package/skills/estack-github-issue-tracker/SKILL.md +322 -322
- package/skills/estack-github-issue-tracker/bin/tracker-tools.cjs +1358 -1358
- package/skills/estack-github-issue-tracker/references/gh-cli-patterns.md +124 -124
- package/skills/estack-github-issue-tracker/references/result-file-schema.md +156 -156
- package/skills/estack-github-issue-tracker/references/tracker-schema.md +96 -96
- package/skills/estack-github-issue-tracker/tracker-template.md +58 -58
- package/skills/estack-leadership-coach/SKILL.md +235 -0
- package/skills/estack-leadership-coach/adding-references.md +280 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/post-mortem.md +120 -0
- package/skills/estack-leadership-coach/frameworks/delegation/flows/pre-delegation.md +138 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/1-intake.md +145 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/2-trm-assessment.md +119 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/3-enrollment.md +132 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/4-build-brief.md +171 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/5-monitoring.md +134 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/6-reverse-delegation.md +118 -0
- package/skills/estack-leadership-coach/frameworks/delegation/phases/7-diagnose.md +200 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__deci-olafsen-ryan-2017-self-determination-theory-in-work-organizations.md +1881 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__gagne-deci-2005-self-determination-theory-and-work-motivation.md +2058 -0
- package/skills/estack-leadership-coach/references/.source-files/deci-ryan_self-determination-theory__selfdeterminationtheory-org-theory-overview-page.md +61 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-3-key-insights-into-the-global-workplace-2024.md +57 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-managers-account-for-70-percent-of-variance-in-employee-engagement-2015.md +40 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-global-data-summary.md +73 -0
- package/skills/estack-leadership-coach/references/.source-files/gallup_engagement-research__gallup-state-of-the-global-workplace-2026-report-landing.md +42 -0
- package/skills/estack-leadership-coach/references/.source-files/hormozi-leila_4-stages__leila-hormozi-the-art-of-delegation-blog-post.md +91 -0
- package/skills/estack-leadership-coach/references/.source-files/oncken-wass_monkeys-hbr-1974__oncken-wass-management-time-whos-got-the-monkey-hbr-classic-1974.md +969 -0
- package/skills/estack-leadership-coach/references/.source-files/sanchez_main-street-millionaire__codie-sanchez-afford-anything-podcast-ep-565-show-notes.md +89 -0
- package/skills/estack-leadership-coach/references/.source-files/sullivan_who-not-how__dan-sullivan-impact-filter-tool-and-guide-booklet.md +565 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-lewis-howes-school-of-greatness-ep-1231-show-notes.md +122 -0
- package/skills/estack-leadership-coach/references/.source-files/van-edwards_cues__vanessa-van-edwards-roger-dooley-cues-interview.md +194 -0
- package/skills/estack-leadership-coach/references/deci-ryan_self-determination-theory.md +166 -0
- package/skills/estack-leadership-coach/references/doerr_measure-what-matters.md +154 -0
- package/skills/estack-leadership-coach/references/ferriss_4hww.md +189 -0
- package/skills/estack-leadership-coach/references/gallup_engagement-research.md +105 -0
- package/skills/estack-leadership-coach/references/gerber_e-myth-revisited.md +118 -0
- package/skills/estack-leadership-coach/references/grove_high-output-management.md +95 -0
- package/skills/estack-leadership-coach/references/hormozi-alex_followthrough.md +152 -0
- package/skills/estack-leadership-coach/references/hormozi-leila_4-stages.md +146 -0
- package/skills/estack-leadership-coach/references/oncken-wass_monkeys-hbr-1974.md +128 -0
- package/skills/estack-leadership-coach/references/sanchez_main-street-millionaire.md +196 -0
- package/skills/estack-leadership-coach/references/sullivan_who-not-how.md +137 -0
- package/skills/estack-leadership-coach/references/van-edwards_cues.md +189 -0
- package/skills/estack-migrate-claude-session-history/SKILL.md +226 -0
- package/skills/estack-migrate-claude-session-history/references/path-encoding.md +55 -0
- package/skills/estack-migrate-claude-session-history/references/troubleshooting.md +96 -0
- package/skills/estack-migrate-claude-session-history/scripts/migrate-claude-history.js +1123 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-append-note.js +48 -0
- package/skills/estack-migrate-claude-session-history/scripts/test-validate-migration.py +326 -0
- package/skills/estack-migrate-claude-session-history/scripts/validate-migration.py +493 -0
- package/skills/estack-pdf-to-md/SKILL.md +180 -0
- package/skills/estack-pdf-to-md/scripts/pdf_to_md.py +596 -0
- package/skills/estack-productivity-prioritization-coach/SKILL.md +124 -0
- package/skills/estack-productivity-prioritization-coach/sources/01-tony-robbins-rpm.md +39 -0
- package/skills/estack-productivity-prioritization-coach/sources/02-justin-sung-task-prioritization.md +34 -0
- package/skills/estack-prompt-builder-coach/SKILL.md +81 -81
- package/skills/estack-prompt-builder-coach/definition-of-done-generator.md +42 -42
- package/skills/estack-prompt-builder-coach/prompt-builder.md +37 -37
- package/skills/estack-prompt-builder-coach/task-shaper.md +36 -36
- package/skills/estack-prompt-builder-coach/vague-ask-auditor.md +37 -37
- package/skills/estack-read-claude-session-history/SKILL.md +204 -204
- package/skills/estack-read-claude-session-history/references/jsonl-schema.md +126 -126
- package/skills/estack-read-claude-session-history/references/modes.md +423 -423
- package/skills/estack-read-claude-session-history/references/recipes.md +271 -271
- package/skills/estack-read-claude-session-history/scripts/lib/__init__.py +1 -1
- package/skills/estack-read-claude-session-history/scripts/lib/parser.py +460 -460
- package/skills/estack-read-claude-session-history/scripts/lib/paths.py +234 -234
- package/skills/estack-read-claude-session-history/scripts/lib/search.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/lib/subagents.py +88 -88
- package/skills/estack-read-claude-session-history/scripts/lib/tools.py +144 -144
- package/skills/estack-read-claude-session-history/scripts/read_transcript.py +1776 -1776
- package/skills/estack-read-claude-session-history/scripts/tests/conftest.py +40 -40
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/README.md +20 -20
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/all-noise.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/basic-session.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-gaps.jsonl +9 -9
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-noise.jsonl +7 -7
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-a.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-parallel-b.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/engagement-waiting.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/interrupted.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/multi-compact.jsonl +8 -8
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/pending-user.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta/subagents/agent-aaa.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-no-meta.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent/subagents/agent-xyz123.meta.json +1 -1
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/subagent-parent.jsonl +4 -4
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/time-spread.jsonl +6 -6
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/timeline-day-test.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/tool-zoo.jsonl +10 -10
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/truncated.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/unicode.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-advisor.jsonl +3 -3
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-compact.jsonl +5 -5
- package/skills/estack-read-claude-session-history/scripts/tests/fixtures/with-thinking.jsonl +2 -2
- package/skills/estack-read-claude-session-history/scripts/tests/test_backup_roots.py +56 -56
- package/skills/estack-read-claude-session-history/scripts/tests/test_engagement.py +239 -239
- package/skills/estack-read-claude-session-history/scripts/tests/test_json_format.py +201 -201
- package/skills/estack-read-claude-session-history/scripts/tests/test_modes.py +199 -199
- package/skills/estack-read-claude-session-history/scripts/tests/test_parser.py +195 -195
- package/skills/estack-read-claude-session-history/scripts/tests/test_paths.py +133 -133
- package/skills/estack-read-claude-session-history/scripts/tests/test_search.py +78 -78
- package/skills/estack-read-claude-session-history/scripts/tests/test_subagents.py +43 -43
- package/skills/estack-read-claude-session-history/scripts/tests/test_timeline.py +179 -179
- package/skills/estack-read-claude-session-history/scripts/tests/test_timezone_and_project.py +212 -212
- package/skills/estack-read-claude-session-history/scripts/tests/test_tools.py +80 -80
- package/skills/estack-repo-search/SKILL.md +65 -65
- package/skills/estack-vscode-file-recovery/SKILL.md +188 -0
|
@@ -1,1358 +1,1358 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* tracker-tools.cjs — Data service layer for estack-github-issue-tracker skill.
|
|
5
|
-
*
|
|
6
|
-
* Commands:
|
|
7
|
-
* startup --tracker <path> Auth, parse tracker + config, create temp dir, discover issues
|
|
8
|
-
* fetch-issues --temp-dir <dir> --issues <json-file> Parallel gh api fetches per issue
|
|
9
|
-
* compile-report --temp-dir <dir> --date <YYYY-MM-DD> Build overview from result files
|
|
10
|
-
* update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD> Apply changes (incl. Goal)
|
|
11
|
-
* build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> First-run tracker creation
|
|
12
|
-
*
|
|
13
|
-
* All commands return a `today` field with the current date.
|
|
14
|
-
* Zero dependencies. Deterministic output. The agent calls this; it cannot "forget".
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
'use strict';
|
|
18
|
-
|
|
19
|
-
const fs = require('fs');
|
|
20
|
-
const path = require('path');
|
|
21
|
-
const os = require('os');
|
|
22
|
-
const { exec, execSync } = require('child_process');
|
|
23
|
-
|
|
24
|
-
function localDate() {
|
|
25
|
-
const now = new Date();
|
|
26
|
-
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function todayTag() {
|
|
30
|
-
return `today's date is **${localDate()}**, ignore earlier dates`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ─── CLI Router ─────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
const [,, command, ...rawArgs] = process.argv;
|
|
36
|
-
|
|
37
|
-
const commands = {
|
|
38
|
-
'compile-report': cmdCompileReport,
|
|
39
|
-
'update-tracker': cmdUpdateTracker,
|
|
40
|
-
'startup': cmdStartup,
|
|
41
|
-
'fetch-issues': cmdFetchIssues,
|
|
42
|
-
'build-tracker': cmdBuildTracker,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
if (!command || !commands[command]) {
|
|
46
|
-
console.error(`Usage: tracker-tools.cjs <command> [options]`);
|
|
47
|
-
console.error(`Commands: ${Object.keys(commands).join(', ')}`);
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Parse --flag value pairs from rawArgs
|
|
52
|
-
function parseFlags(args) {
|
|
53
|
-
const flags = {};
|
|
54
|
-
for (let i = 0; i < args.length; i++) {
|
|
55
|
-
if (args[i].startsWith('--')) {
|
|
56
|
-
const key = args[i].slice(2);
|
|
57
|
-
const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
|
|
58
|
-
flags[key] = val;
|
|
59
|
-
} else if (!flags._positional) {
|
|
60
|
-
flags._positional = args[i];
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return flags;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const flags = parseFlags(rawArgs);
|
|
67
|
-
|
|
68
|
-
(async () => {
|
|
69
|
-
try {
|
|
70
|
-
await commands[command](flags);
|
|
71
|
-
} catch (err) {
|
|
72
|
-
console.error(`Error in ${command}: ${err.message}`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
})();
|
|
76
|
-
|
|
77
|
-
// ─── Frontmatter Parser ────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
function parseFrontmatter(content) {
|
|
80
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
81
|
-
if (!match) return { meta: {}, body: content };
|
|
82
|
-
|
|
83
|
-
const meta = {};
|
|
84
|
-
const lines = match[1].split(/\r?\n/);
|
|
85
|
-
for (const line of lines) {
|
|
86
|
-
const idx = line.indexOf(':');
|
|
87
|
-
if (idx === -1) continue;
|
|
88
|
-
const key = line.slice(0, idx).trim();
|
|
89
|
-
let val = line.slice(idx + 1).trim();
|
|
90
|
-
|
|
91
|
-
// Boolean
|
|
92
|
-
if (val === 'true') val = true;
|
|
93
|
-
else if (val === 'false') val = false;
|
|
94
|
-
// Number
|
|
95
|
-
else if (/^\d+$/.test(val)) val = parseInt(val, 10);
|
|
96
|
-
// Comma-separated list
|
|
97
|
-
else if (val.includes(',') && !val.startsWith('"')) {
|
|
98
|
-
val = val.split(',').map(s => s.trim()).filter(Boolean);
|
|
99
|
-
}
|
|
100
|
-
// Strip surrounding quotes
|
|
101
|
-
else if (val.startsWith('"') && val.endsWith('"')) {
|
|
102
|
-
val = val.slice(1, -1);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
meta[key] = val;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const body = content.slice(match[0].length).trim();
|
|
109
|
-
return { meta, body };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ─── Tracker Parser ─────────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
function parseTrackerFile(content) {
|
|
115
|
-
const result = {
|
|
116
|
-
username: null,
|
|
117
|
-
config: null,
|
|
118
|
-
active_issues: [],
|
|
119
|
-
closed_issues: [],
|
|
120
|
-
raw: content,
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
// Extract username
|
|
124
|
-
const userMatch = content.match(/GitHub username:\s*\*\*(.+?)\*\*/);
|
|
125
|
-
if (userMatch) result.username = userMatch[1];
|
|
126
|
-
|
|
127
|
-
// Extract Config section (between ## Config and next --- or ##)
|
|
128
|
-
const configMatch = content.match(/## Config\s*\n([\s\S]*?)(?=\n---|\n## (?!Config))/);
|
|
129
|
-
if (configMatch) {
|
|
130
|
-
// Strip HTML comments and trim
|
|
131
|
-
result.config = configMatch[1].replace(/<!--[\s\S]*?-->/g, '').trim() || null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Split into Active and Closed sections
|
|
135
|
-
const activeSectionMatch = content.match(
|
|
136
|
-
/## Active Issues[^\n]*\n([\s\S]*?)(?=\n## (?!Active)|$)/
|
|
137
|
-
);
|
|
138
|
-
const closedSectionMatch = content.match(
|
|
139
|
-
/## Closed[^\n]*\n([\s\S]*?)(?=\n## (?!Closed)|$)/
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
if (activeSectionMatch) {
|
|
143
|
-
result.active_issues = parseIssueSections(activeSectionMatch[1]);
|
|
144
|
-
}
|
|
145
|
-
if (closedSectionMatch) {
|
|
146
|
-
result.closed_issues = parseClosedSections(closedSectionMatch[1]);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return result;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function parseIssueSections(sectionContent) {
|
|
153
|
-
const issues = [];
|
|
154
|
-
// Split by ### headers
|
|
155
|
-
const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
|
|
156
|
-
|
|
157
|
-
for (const part of parts) {
|
|
158
|
-
// Skip HTML comments
|
|
159
|
-
if (part.trim().startsWith('<!--')) continue;
|
|
160
|
-
|
|
161
|
-
const issue = parseIssueBlock(part);
|
|
162
|
-
if (issue) issues.push(issue);
|
|
163
|
-
}
|
|
164
|
-
return issues;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function parseIssueBlock(block) {
|
|
168
|
-
// Parse header: ### owner/repo#NUMBER — Title
|
|
169
|
-
const headerMatch = block.match(
|
|
170
|
-
/^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
|
|
171
|
-
);
|
|
172
|
-
if (!headerMatch) return null;
|
|
173
|
-
|
|
174
|
-
const issue = {
|
|
175
|
-
owner: headerMatch[1].trim(),
|
|
176
|
-
repo: headerMatch[2].trim(),
|
|
177
|
-
number: parseInt(headerMatch[3], 10),
|
|
178
|
-
title: headerMatch[4].trim(),
|
|
179
|
-
goal: extractField(block, 'Goal'),
|
|
180
|
-
role: extractField(block, 'Role'),
|
|
181
|
-
filed: extractField(block, 'Filed'),
|
|
182
|
-
last_check_date: null,
|
|
183
|
-
status_summary: extractField(block, 'Status as of'),
|
|
184
|
-
what_to_check: extractField(block, 'What to check'),
|
|
185
|
-
related: extractListField(block, 'Related'),
|
|
186
|
-
upstream: extractUpstream(block),
|
|
187
|
-
duplicates: extractNestedItems(block, 'Duplicates found'),
|
|
188
|
-
adjacent: extractNestedItems(block, 'Adjacent issues found'),
|
|
189
|
-
next_steps: extractField(block, 'Next steps \\(now\\)'),
|
|
190
|
-
future: extractField(block, 'Future'),
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
// Extract last check date from "Status as of YYYY-MM-DD:"
|
|
194
|
-
const dateMatch = block.match(/\*\*Status as of (\d{4}-\d{2}-\d{2}):\*\*/);
|
|
195
|
-
if (dateMatch) issue.last_check_date = dateMatch[1];
|
|
196
|
-
|
|
197
|
-
// Extract History field (multi-line: bullet list under **History:**)
|
|
198
|
-
issue.history = extractHistoryField(block);
|
|
199
|
-
|
|
200
|
-
return issue;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function extractField(block, fieldName) {
|
|
204
|
-
// Special case: "Status as of YYYY-MM-DD:" has the date embedded in the bold
|
|
205
|
-
if (fieldName === 'Status as of') {
|
|
206
|
-
const statusRe = /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*(.+)/i;
|
|
207
|
-
const statusMatch = block.match(statusRe);
|
|
208
|
-
return statusMatch ? statusMatch[1].trim() : null;
|
|
209
|
-
}
|
|
210
|
-
const re = new RegExp(`\\*\\*${fieldName}(?:\\s+\\S+)?[.:]*\\*\\*\\s*(.+)`, 'i');
|
|
211
|
-
const match = block.match(re);
|
|
212
|
-
return match ? match[1].trim() : null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function extractListField(block, fieldName) {
|
|
216
|
-
const val = extractField(block, fieldName);
|
|
217
|
-
if (!val) return [];
|
|
218
|
-
return val.split(',').map(s => s.trim()).filter(Boolean);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function extractUpstream(block) {
|
|
222
|
-
const val = extractField(block, 'Upstream');
|
|
223
|
-
if (!val || val.toLowerCase() === 'n/a' || val === 'none') return null;
|
|
224
|
-
const match = val.match(/([^/]+)\/([^#]+)#(\d+)/);
|
|
225
|
-
if (!match) return null;
|
|
226
|
-
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function extractNestedItems(block, sectionName) {
|
|
230
|
-
const items = [];
|
|
231
|
-
const re = new RegExp(`\\*\\*${sectionName}[^*]*\\*\\*[:\\s]*\\n([\\s\\S]*?)(?=\\n- \\*\\*[A-Z]|$)`, 'i');
|
|
232
|
-
const match = block.match(re);
|
|
233
|
-
if (!match) return items;
|
|
234
|
-
|
|
235
|
-
const lines = match[1].split(/\r?\n/);
|
|
236
|
-
for (const line of lines) {
|
|
237
|
-
const itemMatch = line.match(/^\s+-\s+\*\*#?(\d+)\*\*\s*[—–-]\s*(.+)/);
|
|
238
|
-
if (itemMatch) {
|
|
239
|
-
items.push({
|
|
240
|
-
number: parseInt(itemMatch[1], 10),
|
|
241
|
-
detail: itemMatch[2].trim(),
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return items;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Extract history entries from an issue block.
|
|
250
|
-
* Matches all " - **YYYY-MM-DD:** ..." lines under "**History:**".
|
|
251
|
-
* Returns array of strings like "**2026-03-15:** Filed issue".
|
|
252
|
-
*/
|
|
253
|
-
function extractHistoryField(block) {
|
|
254
|
-
// Find **History:** and collect indented bullet lines until next top-level field or section
|
|
255
|
-
const historyStart = block.search(/- \*\*History:\*\*/);
|
|
256
|
-
if (historyStart === -1) return [];
|
|
257
|
-
|
|
258
|
-
const afterHistory = block.slice(historyStart);
|
|
259
|
-
// Collect lines after the **History:** line that match date bullets
|
|
260
|
-
const lines = afterHistory.split(/\r?\n/);
|
|
261
|
-
const entries = [];
|
|
262
|
-
let started = false;
|
|
263
|
-
for (const line of lines) {
|
|
264
|
-
if (!started) {
|
|
265
|
-
if (/- \*\*History:\*\*/.test(line)) { started = true; }
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
// Match indented date bullets: " - **YYYY-MM-DD:** ..."
|
|
269
|
-
const bullet = line.match(/^\s+-\s+(\*\*\d{4}-\d{2}-\d{2}:\*\*.+)/);
|
|
270
|
-
if (bullet) {
|
|
271
|
-
entries.push(bullet[1].trim());
|
|
272
|
-
} else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) {
|
|
273
|
-
// Next top-level field or section header — stop
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return entries;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function parseClosedSections(sectionContent) {
|
|
281
|
-
const issues = [];
|
|
282
|
-
const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
|
|
283
|
-
|
|
284
|
-
for (const part of parts) {
|
|
285
|
-
if (part.trim().startsWith('<!--')) continue;
|
|
286
|
-
const headerMatch = part.match(
|
|
287
|
-
/^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
|
|
288
|
-
);
|
|
289
|
-
if (!headerMatch) continue;
|
|
290
|
-
|
|
291
|
-
const lines = part.split(/\r?\n/).slice(1).filter(l => l.trim().startsWith('-'));
|
|
292
|
-
issues.push({
|
|
293
|
-
owner: headerMatch[1].trim(),
|
|
294
|
-
repo: headerMatch[2].trim(),
|
|
295
|
-
number: parseInt(headerMatch[3], 10),
|
|
296
|
-
title: headerMatch[4].trim(),
|
|
297
|
-
resolution: lines.map(l => l.replace(/^-\s*/, '').trim()).join(' '),
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
return issues;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ─── Report Compiler ────────────────────────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
function compileOverviewReport(tempDir, date) {
|
|
306
|
-
const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
|
|
307
|
-
if (files.length === 0) {
|
|
308
|
-
return { error: 'No result files found in temp directory' };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const issueResults = [];
|
|
312
|
-
|
|
313
|
-
for (const file of files) {
|
|
314
|
-
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
315
|
-
const { meta, body } = parseFrontmatter(content);
|
|
316
|
-
|
|
317
|
-
if (meta.type === 'issue') {
|
|
318
|
-
issueResults.push({ meta, body, file });
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Sort: activity first, then by update recency
|
|
323
|
-
const withActivity = issueResults.filter(r => r.meta.has_activity === true);
|
|
324
|
-
const noActivity = issueResults.filter(r => r.meta.has_activity !== true);
|
|
325
|
-
|
|
326
|
-
// Count repos
|
|
327
|
-
const repos = new Set(issueResults.map(r => `${r.meta.owner}/${r.meta.repo}`));
|
|
328
|
-
|
|
329
|
-
// Build report
|
|
330
|
-
let report = '';
|
|
331
|
-
|
|
332
|
-
report += `## GitHub Issues Check-In — ${date}\n\n`;
|
|
333
|
-
report += `**Tracking ${issueResults.length} active issues across ${repos.size} repos**`;
|
|
334
|
-
|
|
335
|
-
// Find oldest check date for "last check"
|
|
336
|
-
const checkDates = issueResults
|
|
337
|
-
.map(r => r.meta.last_check_date)
|
|
338
|
-
.filter(Boolean)
|
|
339
|
-
.sort();
|
|
340
|
-
if (checkDates.length > 0) {
|
|
341
|
-
report += ` | Last check: ${checkDates[0]}`;
|
|
342
|
-
}
|
|
343
|
-
report += '\n\n---\n\n';
|
|
344
|
-
|
|
345
|
-
// ── Issues with Activity ──
|
|
346
|
-
if (withActivity.length > 0) {
|
|
347
|
-
report += '### Issues with Activity\n\n';
|
|
348
|
-
report += `These ${withActivity.length} issue${withActivity.length === 1 ? ' has' : 's have'} new comments or state changes since your last check.\n\n`;
|
|
349
|
-
for (const r of withActivity) {
|
|
350
|
-
report += buildIssueDetailBlock(r);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ── No Activity ──
|
|
355
|
-
if (noActivity.length > 0) {
|
|
356
|
-
report += '### No Activity\n\n';
|
|
357
|
-
for (const r of noActivity) {
|
|
358
|
-
report += buildQuietIssueBlock(r);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ── Upstream Status ──
|
|
363
|
-
const upstreamResults = issueResults.filter(r => {
|
|
364
|
-
const upstreamSection = extractSection(r.body, 'Upstream');
|
|
365
|
-
return upstreamSection && upstreamSection.trim() !== 'N/A' && upstreamSection.trim() !== 'None';
|
|
366
|
-
});
|
|
367
|
-
if (upstreamResults.length > 0) {
|
|
368
|
-
report += '### Upstream Status\n\n';
|
|
369
|
-
report += '| Upstream issue | State | Impact |\n';
|
|
370
|
-
report += '|----------------|-------|--------|\n';
|
|
371
|
-
for (const r of upstreamResults) {
|
|
372
|
-
const section = extractSection(r.body, 'Upstream');
|
|
373
|
-
report += `| ${r.meta.owner}/${r.meta.repo}#${r.meta.number} | ${section.trim()} |\n`;
|
|
374
|
-
}
|
|
375
|
-
report += '\n---\n\n';
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── Summary ──
|
|
379
|
-
const actionItems = withActivity.reduce((count, r) => {
|
|
380
|
-
const steps = extractSection(r.body, 'Next Steps');
|
|
381
|
-
if (steps) count += (steps.match(/^- \[/gm) || []).length;
|
|
382
|
-
return count;
|
|
383
|
-
}, 0);
|
|
384
|
-
|
|
385
|
-
report += '### Summary\n\n';
|
|
386
|
-
report += `**Action items:** ${actionItems}\n`;
|
|
387
|
-
if (withActivity.length > 0) {
|
|
388
|
-
const needAttention = withActivity.slice(0, 3).map(
|
|
389
|
-
r => `${r.meta.owner}/${r.meta.repo}#${r.meta.number}`
|
|
390
|
-
);
|
|
391
|
-
report += `**Issues needing attention:** ${needAttention.join(', ')}\n`;
|
|
392
|
-
}
|
|
393
|
-
if (noActivity.length > 0) {
|
|
394
|
-
report += `**All quiet:** ${noActivity.length} issues with no activity\n`;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return { report, issue_count: issueResults.length, activity_count: withActivity.length };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function buildIssueDetailBlock(r) {
|
|
401
|
-
let block = '';
|
|
402
|
-
block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
|
|
403
|
-
block += `| Field | Value |\n`;
|
|
404
|
-
block += `|-------|-------|\n`;
|
|
405
|
-
block += `| **State** | ${r.meta.state || 'Open'}${r.meta.state_changed ? ' [changed]' : ''} |\n`;
|
|
406
|
-
if (r.meta.labels) {
|
|
407
|
-
const labels = Array.isArray(r.meta.labels) ? r.meta.labels.join(', ') : r.meta.labels;
|
|
408
|
-
block += `| **Labels** | ${labels} |\n`;
|
|
409
|
-
}
|
|
410
|
-
block += `| **Your role** | ${r.meta.role || 'Unknown'} |\n\n`;
|
|
411
|
-
|
|
412
|
-
// Plain English summary of where this issue stands
|
|
413
|
-
const summary = extractSection(r.body, 'Status Summary');
|
|
414
|
-
if (summary) {
|
|
415
|
-
block += `${summary.trim()}\n\n`;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Activity section — what changed since last check
|
|
419
|
-
const activity = extractSection(r.body, 'Activity');
|
|
420
|
-
if (activity) {
|
|
421
|
-
block += `**What changed:**\n${activity}\n\n`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Duplicates
|
|
425
|
-
const dupes = extractSection(r.body, 'Known');
|
|
426
|
-
const newFinds = extractSection(r.body, 'New finds');
|
|
427
|
-
const hasDupes = dupes && !/^(none|no changes)\.?$/i.test(dupes.trim());
|
|
428
|
-
const hasNewFinds = newFinds && !/^(none|none found|skipped)\.?/i.test(newFinds.trim());
|
|
429
|
-
if (hasDupes || hasNewFinds) {
|
|
430
|
-
block += `**Duplicates & related:**\n`;
|
|
431
|
-
if (hasDupes) block += `- Known dupes — ${dupes.trim()}\n`;
|
|
432
|
-
if (hasNewFinds) block += `- New finds — ${newFinds.trim()}\n`;
|
|
433
|
-
block += '\n';
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// What you need to do next
|
|
437
|
-
const steps = extractSection(r.body, 'Next Steps');
|
|
438
|
-
if (steps) {
|
|
439
|
-
block += `**What to do next:**\n${steps}\n\n`;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Watch For
|
|
443
|
-
const watch = extractSection(r.body, 'Watch For');
|
|
444
|
-
if (watch) {
|
|
445
|
-
block += `**Watch for:**\n${watch}\n\n`;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
block += '---\n\n';
|
|
449
|
-
return block;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function buildQuietIssueBlock(r) {
|
|
453
|
-
let block = '';
|
|
454
|
-
block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
|
|
455
|
-
const lastDate = r.meta.last_comment_date || r.meta.last_check_date || 'unknown';
|
|
456
|
-
block += `- Last activity: ${lastDate}\n`;
|
|
457
|
-
|
|
458
|
-
// Keep quiet issues compact to avoid report bloat.
|
|
459
|
-
const summary = extractSection(r.body, 'Status Summary');
|
|
460
|
-
if (summary) {
|
|
461
|
-
block += `- Status: ${firstLine(summary)}\n`;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
const steps = extractSection(r.body, 'Next Steps');
|
|
465
|
-
if (steps) {
|
|
466
|
-
block += `- Next: ${firstLine(steps)}\n`;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const watch = extractSection(r.body, 'Watch For');
|
|
470
|
-
if (watch) {
|
|
471
|
-
block += `- Watch: ${firstLine(watch)}\n`;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
block += '\n';
|
|
475
|
-
return block;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function extractSection(body, headerPattern) {
|
|
479
|
-
// Match ## Header or ### Header, capture until next ## or end
|
|
480
|
-
const re = new RegExp(
|
|
481
|
-
`^#{2,4}\\s+(?:[^\\n]*?${headerPattern}[^\\n]*)\\n([\\s\\S]*?)(?=^#{2,4}\\s|$)`,
|
|
482
|
-
'mi'
|
|
483
|
-
);
|
|
484
|
-
const match = body.match(re);
|
|
485
|
-
return match ? match[1].trim() : null;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function firstLine(text) {
|
|
489
|
-
if (!text) return '';
|
|
490
|
-
const line = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
|
|
491
|
-
return line.replace(/^\s*[-*]\s*/, '').trim();
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ─── Tracker Updater ────────────────────────────────────────────────────────
|
|
495
|
-
|
|
496
|
-
function applyTrackerUpdates(trackerContent, tempDir, date) {
|
|
497
|
-
const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
|
|
498
|
-
let updated = trackerContent;
|
|
499
|
-
const changes = [];
|
|
500
|
-
|
|
501
|
-
for (const file of files) {
|
|
502
|
-
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
503
|
-
const { meta, body } = parseFrontmatter(content);
|
|
504
|
-
|
|
505
|
-
if (meta.type !== 'issue') continue;
|
|
506
|
-
|
|
507
|
-
const issueKey = `${meta.owner}/${meta.repo}#${meta.number}`;
|
|
508
|
-
|
|
509
|
-
// Find this issue's section in the tracker
|
|
510
|
-
const sectionRe = new RegExp(
|
|
511
|
-
`(### ${escapeRegex(meta.owner)}/${escapeRegex(meta.repo)}#${meta.number}\\s*[—–-][^\\n]*\\n[\\s\\S]*?)(?=\\n### |\\n## |$)`,
|
|
512
|
-
'm'
|
|
513
|
-
);
|
|
514
|
-
const sectionMatch = updated.match(sectionRe);
|
|
515
|
-
if (!sectionMatch) continue;
|
|
516
|
-
|
|
517
|
-
let section = sectionMatch[1];
|
|
518
|
-
const originalSection = section;
|
|
519
|
-
|
|
520
|
-
// Update "Status as of" date and summary
|
|
521
|
-
const trackerUpdates = extractSection(body, 'Tracker Updates');
|
|
522
|
-
if (trackerUpdates) {
|
|
523
|
-
const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
|
|
524
|
-
if (statusLine) {
|
|
525
|
-
section = section.replace(
|
|
526
|
-
/\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*.+/,
|
|
527
|
-
`**Status as of ${date}:** ${statusLine[1].trim()}`
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
|
|
532
|
-
if (watchLine) {
|
|
533
|
-
section = section.replace(
|
|
534
|
-
/\*\*What to check\*\*:\s*.+/,
|
|
535
|
-
`**What to check:** ${watchLine[1].trim()}`
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Update or add Goal
|
|
540
|
-
const goalLine = trackerUpdates.match(/^goal:\s*(.+)/m);
|
|
541
|
-
if (goalLine) {
|
|
542
|
-
if (section.includes('**Goal:**')) {
|
|
543
|
-
section = section.replace(
|
|
544
|
-
/\*\*Goal:\*\*\s*.+/,
|
|
545
|
-
`**Goal:** ${goalLine[1].trim()}`
|
|
546
|
-
);
|
|
547
|
-
} else {
|
|
548
|
-
// Insert Goal as first field after the header line
|
|
549
|
-
section = section.replace(
|
|
550
|
-
/(### [^\n]+\n)/,
|
|
551
|
-
`$1- **Goal:** ${goalLine[1].trim()}\n`
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
changes.push(`Set Goal for ${issueKey}`);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Handle state change: open → closed (move to Closed section)
|
|
559
|
-
if (meta.state === 'closed' && meta.state_changed) {
|
|
560
|
-
// Preserve key fields from the original section before removing it
|
|
561
|
-
const closedLines = [];
|
|
562
|
-
closedLines.push(`### ${issueKey} — ${meta.title}`);
|
|
563
|
-
closedLines.push(`- Closed as of ${date}. ${meta.close_reason || ''}`);
|
|
564
|
-
|
|
565
|
-
// Preserve Goal
|
|
566
|
-
const goalMatch = originalSection.match(/- \*\*Goal:\*\*\s*(.+)/);
|
|
567
|
-
if (goalMatch) closedLines.push(`- **Goal:** ${goalMatch[1].trim()}`);
|
|
568
|
-
|
|
569
|
-
// Preserve Role
|
|
570
|
-
const roleMatch = originalSection.match(/- \*\*Role:\*\*\s*(.+)/);
|
|
571
|
-
if (roleMatch) closedLines.push(`- **Role:** ${roleMatch[1].trim()}`);
|
|
572
|
-
|
|
573
|
-
// Preserve History section
|
|
574
|
-
const historyIdx = originalSection.indexOf('- **History:**');
|
|
575
|
-
if (historyIdx !== -1) {
|
|
576
|
-
const historyBlock = originalSection.slice(historyIdx);
|
|
577
|
-
const historyLines = historyBlock.split(/\r?\n/);
|
|
578
|
-
const preserved = [historyLines[0]]; // "- **History:**"
|
|
579
|
-
for (let hi = 1; hi < historyLines.length; hi++) {
|
|
580
|
-
if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[hi])) {
|
|
581
|
-
preserved.push(historyLines[hi]);
|
|
582
|
-
} else if (/^- \*\*[A-Z]/.test(historyLines[hi]) || /^### /.test(historyLines[hi])) {
|
|
583
|
-
break;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
// Add closing history entry
|
|
587
|
-
preserved.push(` - **${date}:** Closed. ${meta.close_reason || ''}`);
|
|
588
|
-
closedLines.push(...preserved);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const closedEntry = closedLines.join('\n') + '\n\n';
|
|
592
|
-
|
|
593
|
-
// Remove from active
|
|
594
|
-
updated = updated.replace(originalSection, '');
|
|
595
|
-
// Add to closed
|
|
596
|
-
updated = updated.replace(
|
|
597
|
-
/(## Closed[^\n]*\n)/,
|
|
598
|
-
`$1\n${closedEntry}`
|
|
599
|
-
);
|
|
600
|
-
changes.push(`Moved ${issueKey} to Closed`);
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Add new duplicates
|
|
605
|
-
if (trackerUpdates) {
|
|
606
|
-
const newDupeLines = [];
|
|
607
|
-
const dupeRe = /new_duplicate:\s*#?(\d+)\s*[—–-]\s*(.+)/gm;
|
|
608
|
-
let dupeMatch;
|
|
609
|
-
while ((dupeMatch = dupeRe.exec(trackerUpdates)) !== null) {
|
|
610
|
-
newDupeLines.push(` - **#${dupeMatch[1]}** — ${dupeMatch[2].trim()}`);
|
|
611
|
-
}
|
|
612
|
-
if (newDupeLines.length > 0) {
|
|
613
|
-
const dupeHeader = `**Duplicates found (${date}):**`;
|
|
614
|
-
if (section.includes('Duplicates found')) {
|
|
615
|
-
// Append to existing duplicates section
|
|
616
|
-
section = section.replace(
|
|
617
|
-
/(Duplicates found[^*]*\*\*:?\s*\n(?:\s+-[^\n]*\n)*)/,
|
|
618
|
-
`$1${dupeHeader}\n${newDupeLines.join('\n')}\n`
|
|
619
|
-
);
|
|
620
|
-
} else {
|
|
621
|
-
// Add before Next steps
|
|
622
|
-
section = section.replace(
|
|
623
|
-
/(\*\*Next steps)/,
|
|
624
|
-
`- ${dupeHeader}\n${newDupeLines.join('\n')}\n- $1`
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
changes.push(`Added ${newDupeLines.length} duplicates to ${issueKey}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
// Append history entries from result file
|
|
632
|
-
if (trackerUpdates) {
|
|
633
|
-
const newHistoryEntries = [];
|
|
634
|
-
const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
|
|
635
|
-
let hm;
|
|
636
|
-
while ((hm = historyRe.exec(trackerUpdates)) !== null) {
|
|
637
|
-
newHistoryEntries.push({ date: hm[1], desc: hm[2].trim() });
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (newHistoryEntries.length > 0) {
|
|
641
|
-
const historyHeaderIdx = section.indexOf('- **History:**');
|
|
642
|
-
if (historyHeaderIdx !== -1) {
|
|
643
|
-
// History section exists — collect existing entries for dedup
|
|
644
|
-
const afterHeader = section.slice(historyHeaderIdx);
|
|
645
|
-
const existingLines = afterHeader.split(/\r?\n/);
|
|
646
|
-
const existingTexts = new Set();
|
|
647
|
-
for (const line of existingLines.slice(1)) {
|
|
648
|
-
const m = line.match(/^\s+-\s+\*\*(\d{4}-\d{2}-\d{2}):\*\*\s*(.+)/);
|
|
649
|
-
if (m) existingTexts.add(`${m[1]}|${m[2].trim()}`);
|
|
650
|
-
else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) break;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Build lines to append (deduped)
|
|
654
|
-
const toAppend = newHistoryEntries
|
|
655
|
-
.filter(e => !existingTexts.has(`${e.date}|${e.desc}`))
|
|
656
|
-
.map(e => ` - **${e.date}:** ${e.desc}`);
|
|
657
|
-
|
|
658
|
-
if (toAppend.length > 0) {
|
|
659
|
-
// Find the last history bullet line position and insert after it
|
|
660
|
-
// We'll insert the new lines before the next top-level field after **History:**
|
|
661
|
-
const historyBlock = section.slice(historyHeaderIdx);
|
|
662
|
-
const historyLines = historyBlock.split(/\r?\n/);
|
|
663
|
-
let lastBulletLine = 0;
|
|
664
|
-
for (let li = 1; li < historyLines.length; li++) {
|
|
665
|
-
if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[li])) {
|
|
666
|
-
lastBulletLine = li;
|
|
667
|
-
} else if (/^- \*\*[A-Z]/.test(historyLines[li]) || /^### /.test(historyLines[li])) {
|
|
668
|
-
break;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
// Insert toAppend after lastBulletLine
|
|
672
|
-
historyLines.splice(lastBulletLine + 1, 0, ...toAppend);
|
|
673
|
-
const newHistoryBlock = historyLines.join('\n');
|
|
674
|
-
section = section.slice(0, historyHeaderIdx) + newHistoryBlock;
|
|
675
|
-
changes.push(`Appended ${toAppend.length} history entries to ${issueKey}`);
|
|
676
|
-
}
|
|
677
|
-
} else {
|
|
678
|
-
// No history section yet — insert before trailing newline / end of section
|
|
679
|
-
const insertPoint = section.lastIndexOf('\n');
|
|
680
|
-
const historyLines = ['- **History:**'];
|
|
681
|
-
for (const e of newHistoryEntries) {
|
|
682
|
-
historyLines.push(` - **${e.date}:** ${e.desc}`);
|
|
683
|
-
}
|
|
684
|
-
section = section.slice(0, insertPoint) + '\n' + historyLines.join('\n') + section.slice(insertPoint);
|
|
685
|
-
changes.push(`Added History section with ${newHistoryEntries.length} entries to ${issueKey}`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
if (section !== originalSection) {
|
|
691
|
-
updated = updated.replace(originalSection, section);
|
|
692
|
-
changes.push(`Updated ${issueKey}`);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
return { content: updated, changes };
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function escapeRegex(str) {
|
|
700
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// ─── Command Implementations ────────────────────────────────────────────────
|
|
704
|
-
|
|
705
|
-
function cmdCompileReport(flags) {
|
|
706
|
-
const tempDir = flags['temp-dir'];
|
|
707
|
-
const date = flags.date;
|
|
708
|
-
|
|
709
|
-
if (!tempDir || !date) {
|
|
710
|
-
console.error('Usage: compile-report --temp-dir <dir> --date <YYYY-MM-DD>');
|
|
711
|
-
process.exit(1);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (!fs.existsSync(tempDir)) {
|
|
715
|
-
console.error(`Temp directory not found: ${tempDir}`);
|
|
716
|
-
process.exit(1);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
const result = compileOverviewReport(tempDir, date);
|
|
720
|
-
if (result.error) {
|
|
721
|
-
console.error(result.error);
|
|
722
|
-
process.exit(1);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Write report to temp dir as well for validation
|
|
726
|
-
fs.writeFileSync(path.join(tempDir, '_compiled-report.md'), result.report, 'utf8');
|
|
727
|
-
|
|
728
|
-
// Output the report to stdout
|
|
729
|
-
console.log(result.report);
|
|
730
|
-
|
|
731
|
-
// Write metadata to stderr for the agent to parse
|
|
732
|
-
console.error(JSON.stringify({
|
|
733
|
-
today: todayTag(),
|
|
734
|
-
issue_count: result.issue_count,
|
|
735
|
-
activity_count: result.activity_count,
|
|
736
|
-
report_file: path.join(tempDir, '_compiled-report.md'),
|
|
737
|
-
}));
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function cmdUpdateTracker(flags) {
|
|
741
|
-
const trackerPath = flags.tracker;
|
|
742
|
-
const tempDir = flags['temp-dir'];
|
|
743
|
-
const date = flags.date;
|
|
744
|
-
|
|
745
|
-
if (!trackerPath || !tempDir || !date) {
|
|
746
|
-
console.error('Usage: update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD>');
|
|
747
|
-
process.exit(1);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (!fs.existsSync(trackerPath)) {
|
|
751
|
-
console.error(`Tracker file not found: ${trackerPath}`);
|
|
752
|
-
process.exit(1);
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const content = fs.readFileSync(trackerPath, 'utf8');
|
|
756
|
-
const { content: updated, changes } = applyTrackerUpdates(content, tempDir, date);
|
|
757
|
-
|
|
758
|
-
if (changes.length === 0) {
|
|
759
|
-
console.log(JSON.stringify({ today: todayTag(), updated: false, changes: [] }));
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
fs.writeFileSync(trackerPath, updated, 'utf8');
|
|
764
|
-
console.log(JSON.stringify({ today: todayTag(), updated: true, changes }));
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// ─── Async Helper ──────────────────────────────────────────────────────────
|
|
768
|
-
|
|
769
|
-
function execAsync(cmd) {
|
|
770
|
-
return new Promise((resolve, reject) => {
|
|
771
|
-
exec(cmd, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
772
|
-
if (err) reject(err);
|
|
773
|
-
else resolve(stdout);
|
|
774
|
-
});
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Run promises in batches to avoid rate limits.
|
|
780
|
-
* @param {Array<() => Promise>} tasks - Array of functions returning promises
|
|
781
|
-
* @param {number} concurrency - Max concurrent tasks
|
|
782
|
-
* @returns {Promise<Array>} Results in same order as tasks
|
|
783
|
-
*/
|
|
784
|
-
async function batchRun(tasks, concurrency = 15) {
|
|
785
|
-
const results = [];
|
|
786
|
-
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
787
|
-
const batch = tasks.slice(i, i + concurrency);
|
|
788
|
-
const batchResults = await Promise.all(batch.map(fn => fn()));
|
|
789
|
-
results.push(...batchResults);
|
|
790
|
-
}
|
|
791
|
-
return results;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// ─── Command: startup ──────────────────────────────────────────────────────
|
|
795
|
-
|
|
796
|
-
async function cmdStartup(flags) {
|
|
797
|
-
const trackerPath = flags.tracker;
|
|
798
|
-
if (!trackerPath) {
|
|
799
|
-
console.error('Usage: startup --tracker <path>');
|
|
800
|
-
process.exit(1);
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Step 1: Check gh auth
|
|
804
|
-
let username = null;
|
|
805
|
-
try {
|
|
806
|
-
const authOutput = execSync('gh auth status 2>&1', { encoding: 'utf8' });
|
|
807
|
-
const userMatch = authOutput.match(/Logged in to github\.com.*account\s+(\S+)/i)
|
|
808
|
-
|| authOutput.match(/Logged in to github\.com\s+as\s+(\S+)/i)
|
|
809
|
-
|| authOutput.match(/account\s+(\S+)/i);
|
|
810
|
-
if (userMatch) username = userMatch[1];
|
|
811
|
-
} catch (err) {
|
|
812
|
-
console.log(JSON.stringify({
|
|
813
|
-
script_ok: true, auth: false, today: todayTag(),
|
|
814
|
-
error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
|
|
815
|
-
}));
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
if (!username) {
|
|
820
|
-
// Try alternate extraction from gh api
|
|
821
|
-
try {
|
|
822
|
-
username = execSync('gh api user --jq .login', { encoding: 'utf8' }).trim();
|
|
823
|
-
} catch (_) {
|
|
824
|
-
console.log(JSON.stringify({
|
|
825
|
-
script_ok: true, auth: false, today: todayTag(),
|
|
826
|
-
error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
|
|
827
|
-
}));
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Step 2: Parse tracker (or set empty defaults if no tracker)
|
|
833
|
-
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
834
|
-
.toISOString().split('T')[0];
|
|
835
|
-
|
|
836
|
-
let trackerExists = false;
|
|
837
|
-
let parsed = { username: null, active_issues: [], closed_issues: [], raw: '' };
|
|
838
|
-
|
|
839
|
-
if (fs.existsSync(trackerPath)) {
|
|
840
|
-
const content = fs.readFileSync(trackerPath, 'utf8');
|
|
841
|
-
if (content.trim()) {
|
|
842
|
-
trackerExists = true;
|
|
843
|
-
parsed = parseTrackerFile(content);
|
|
844
|
-
// Default null last_check_date to 30 days ago
|
|
845
|
-
for (const issue of parsed.active_issues) {
|
|
846
|
-
if (!issue.last_check_date) issue.last_check_date = thirtyDaysAgo;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const oldestCheckDate = parsed.active_issues
|
|
852
|
-
.map(i => i.last_check_date)
|
|
853
|
-
.filter(Boolean)
|
|
854
|
-
.sort()[0] || thirtyDaysAgo;
|
|
855
|
-
|
|
856
|
-
const allTrackedNumbers = [
|
|
857
|
-
...parsed.active_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
|
|
858
|
-
...parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
|
|
859
|
-
];
|
|
860
|
-
|
|
861
|
-
// Step 3: Run two gh api search queries in parallel (always, even without tracker)
|
|
862
|
-
const searchDate = oldestCheckDate;
|
|
863
|
-
const openQuery = `search/issues?q=involves:${username}+is:open+updated:>${searchDate}&per_page=100`;
|
|
864
|
-
const closedQuery = `search/issues?q=involves:${username}+is:closed+closed:>${thirtyDaysAgo}&per_page=50`;
|
|
865
|
-
|
|
866
|
-
let openResults = [];
|
|
867
|
-
let closedResults = [];
|
|
868
|
-
let searchErrors = [];
|
|
869
|
-
|
|
870
|
-
// Run searches individually so one failure doesn't block the other
|
|
871
|
-
try {
|
|
872
|
-
const openRaw = await execAsync(`gh api "${openQuery}" --jq ".items"`);
|
|
873
|
-
openResults = JSON.parse(openRaw || '[]');
|
|
874
|
-
} catch (err) {
|
|
875
|
-
searchErrors.push(`open search failed: ${err.message}`);
|
|
876
|
-
openResults = [];
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
try {
|
|
880
|
-
const closedRaw = await execAsync(`gh api "${closedQuery}" --jq ".items"`);
|
|
881
|
-
closedResults = JSON.parse(closedRaw || '[]');
|
|
882
|
-
} catch (err) {
|
|
883
|
-
searchErrors.push(`closed search failed: ${err.message}`);
|
|
884
|
-
closedResults = [];
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// Step 4: Identify new, reopened, recently closed
|
|
888
|
-
function issueKey(item) {
|
|
889
|
-
const urlParts = (item.repository_url || '').split('/');
|
|
890
|
-
const owner = urlParts[urlParts.length - 2];
|
|
891
|
-
const repo = urlParts[urlParts.length - 1];
|
|
892
|
-
return `${owner}/${repo}#${item.number}`;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function issueInfo(item) {
|
|
896
|
-
const urlParts = (item.repository_url || '').split('/');
|
|
897
|
-
return {
|
|
898
|
-
owner: urlParts[urlParts.length - 2],
|
|
899
|
-
repo: urlParts[urlParts.length - 1],
|
|
900
|
-
number: item.number,
|
|
901
|
-
title: item.title,
|
|
902
|
-
state: item.state,
|
|
903
|
-
updated_at: item.updated_at,
|
|
904
|
-
created_at: item.created_at,
|
|
905
|
-
html_url: item.html_url,
|
|
906
|
-
labels: (item.labels || []).map(l => l.name),
|
|
907
|
-
user: item.user ? item.user.login : null,
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
const trackedSet = new Set(allTrackedNumbers);
|
|
912
|
-
const closedNumbers = new Set(
|
|
913
|
-
parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`)
|
|
914
|
-
);
|
|
915
|
-
|
|
916
|
-
const newIssues = openResults
|
|
917
|
-
.filter(item => !trackedSet.has(issueKey(item)))
|
|
918
|
-
.map(issueInfo);
|
|
919
|
-
|
|
920
|
-
const reopenedIssues = openResults
|
|
921
|
-
.filter(item => closedNumbers.has(issueKey(item)))
|
|
922
|
-
.map(issueInfo);
|
|
923
|
-
|
|
924
|
-
const recentlyClosed = closedResults.map(issueInfo);
|
|
925
|
-
|
|
926
|
-
// Create temp directory for this session
|
|
927
|
-
const prefix = 'giu-checkin-';
|
|
928
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
929
|
-
|
|
930
|
-
console.log(JSON.stringify({
|
|
931
|
-
script_ok: true,
|
|
932
|
-
auth: true,
|
|
933
|
-
today: todayTag(),
|
|
934
|
-
username,
|
|
935
|
-
temp_dir: tempDir,
|
|
936
|
-
tracker_exists: trackerExists,
|
|
937
|
-
tracker_path: trackerPath,
|
|
938
|
-
config: parsed.config,
|
|
939
|
-
tracker_data: {
|
|
940
|
-
username: parsed.username,
|
|
941
|
-
active_issues: parsed.active_issues,
|
|
942
|
-
closed_issues: parsed.closed_issues,
|
|
943
|
-
all_tracked_numbers: allTrackedNumbers,
|
|
944
|
-
oldest_check_date: oldestCheckDate,
|
|
945
|
-
raw: parsed.raw,
|
|
946
|
-
},
|
|
947
|
-
new_issues: newIssues,
|
|
948
|
-
reopened_issues: reopenedIssues,
|
|
949
|
-
recently_closed: recentlyClosed,
|
|
950
|
-
search_errors: searchErrors,
|
|
951
|
-
}, null, 2));
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// ─── Command: fetch-issues ─────────────────────────────────────────────────
|
|
955
|
-
|
|
956
|
-
async function cmdFetchIssues(flags) {
|
|
957
|
-
const tempDir = flags['temp-dir'];
|
|
958
|
-
const issuesFile = flags.issues;
|
|
959
|
-
|
|
960
|
-
if (!tempDir || !issuesFile) {
|
|
961
|
-
console.error('Usage: fetch-issues --temp-dir <dir> --issues <json-file>');
|
|
962
|
-
process.exit(1);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
if (!fs.existsSync(issuesFile)) {
|
|
966
|
-
console.error(`Issues file not found: ${issuesFile}`);
|
|
967
|
-
process.exit(1);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
const issues = JSON.parse(fs.readFileSync(issuesFile, 'utf8'));
|
|
971
|
-
const errors = [];
|
|
972
|
-
const files = [];
|
|
973
|
-
|
|
974
|
-
// Build all fetch tasks
|
|
975
|
-
const allTasks = [];
|
|
976
|
-
const taskMap = []; // Maps task index to { issueIdx, type }
|
|
977
|
-
|
|
978
|
-
// Fetch raw JSON from gh api (no --jq — process in Node to avoid Windows quoting issues)
|
|
979
|
-
function ghFetch(endpoint) {
|
|
980
|
-
return execAsync(`gh api ${endpoint}`)
|
|
981
|
-
.then(raw => JSON.parse(raw || '{}'))
|
|
982
|
-
.catch(e => ({ _error: e.message }));
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
for (let i = 0; i < issues.length; i++) {
|
|
986
|
-
const iss = issues[i];
|
|
987
|
-
const { owner, repo, number, last_check_date, known_dupes, upstream } = iss;
|
|
988
|
-
const issueEndpoint = `repos/${owner}/${repo}/issues/${number}`;
|
|
989
|
-
|
|
990
|
-
// Issue data (single call — extract metadata + body + author in Node)
|
|
991
|
-
allTasks.push(() => ghFetch(issueEndpoint));
|
|
992
|
-
taskMap.push({ issueIdx: i, type: 'issue' });
|
|
993
|
-
|
|
994
|
-
// Comments (all of them — filter by date in Node)
|
|
995
|
-
allTasks.push(() => execAsync(`gh api ${issueEndpoint}/comments --paginate`)
|
|
996
|
-
.then(raw => JSON.parse(raw || '[]'))
|
|
997
|
-
.catch(e => ({ _error: e.message })));
|
|
998
|
-
taskMap.push({ issueIdx: i, type: 'comments' });
|
|
999
|
-
|
|
1000
|
-
// Known dupe state checks
|
|
1001
|
-
const dupes = known_dupes || [];
|
|
1002
|
-
for (const dupe of dupes) {
|
|
1003
|
-
const dupeMatch = String(dupe).match(/(?:([^/]+)\/([^#]+))?#?(\d+)/);
|
|
1004
|
-
if (dupeMatch) {
|
|
1005
|
-
const dOwner = dupeMatch[1] || owner;
|
|
1006
|
-
const dRepo = dupeMatch[2] || repo;
|
|
1007
|
-
const dNumber = dupeMatch[3];
|
|
1008
|
-
allTasks.push(() => ghFetch(`repos/${dOwner}/${dRepo}/issues/${dNumber}`));
|
|
1009
|
-
taskMap.push({ issueIdx: i, type: 'dupe', dupeKey: `${dOwner}/${dRepo}#${dNumber}` });
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
// Upstream check
|
|
1014
|
-
if (upstream) {
|
|
1015
|
-
const uMatch = String(upstream).match(/([^/]+)\/([^#]+)#(\d+)/);
|
|
1016
|
-
if (uMatch) {
|
|
1017
|
-
allTasks.push(() => ghFetch(`repos/${uMatch[1]}/${uMatch[2]}/issues/${uMatch[3]}`));
|
|
1018
|
-
taskMap.push({ issueIdx: i, type: 'upstream' });
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// Run all tasks with batching (max 15 concurrent)
|
|
1024
|
-
const results = await batchRun(allTasks, 15);
|
|
1025
|
-
|
|
1026
|
-
// Organize results by issue — process raw API responses in Node
|
|
1027
|
-
const issueData = issues.map(() => ({
|
|
1028
|
-
metadata: null,
|
|
1029
|
-
body: null,
|
|
1030
|
-
comments: null,
|
|
1031
|
-
dupe_states: {},
|
|
1032
|
-
upstream_state: null,
|
|
1033
|
-
cross_references: [],
|
|
1034
|
-
urls: [],
|
|
1035
|
-
}));
|
|
1036
|
-
|
|
1037
|
-
for (let t = 0; t < results.length; t++) {
|
|
1038
|
-
const { issueIdx, type, dupeKey } = taskMap[t];
|
|
1039
|
-
const raw = results[t];
|
|
1040
|
-
|
|
1041
|
-
switch (type) {
|
|
1042
|
-
case 'issue': {
|
|
1043
|
-
// Extract metadata and body from the single issue response
|
|
1044
|
-
if (raw && !raw._error) {
|
|
1045
|
-
issueData[issueIdx].metadata = {
|
|
1046
|
-
state: raw.state,
|
|
1047
|
-
labels: (raw.labels || []).map(l => l.name),
|
|
1048
|
-
comments: raw.comments,
|
|
1049
|
-
updated: raw.updated_at,
|
|
1050
|
-
created: raw.created_at,
|
|
1051
|
-
html_url: raw.html_url,
|
|
1052
|
-
};
|
|
1053
|
-
issueData[issueIdx].body = {
|
|
1054
|
-
title: raw.title,
|
|
1055
|
-
body: raw.body,
|
|
1056
|
-
author: raw.user ? raw.user.login : null,
|
|
1057
|
-
};
|
|
1058
|
-
} else {
|
|
1059
|
-
issueData[issueIdx].metadata = raw;
|
|
1060
|
-
issueData[issueIdx].body = raw;
|
|
1061
|
-
}
|
|
1062
|
-
break;
|
|
1063
|
-
}
|
|
1064
|
-
case 'comments': {
|
|
1065
|
-
// Filter comments by last_check_date in Node
|
|
1066
|
-
const iss = issues[issueIdx];
|
|
1067
|
-
if (Array.isArray(raw)) {
|
|
1068
|
-
const filtered = iss.last_check_date
|
|
1069
|
-
? raw.filter(c => c.created_at > iss.last_check_date)
|
|
1070
|
-
: raw;
|
|
1071
|
-
issueData[issueIdx].comments = filtered.map(c => ({
|
|
1072
|
-
author: c.user ? c.user.login : null,
|
|
1073
|
-
date: c.created_at ? c.created_at.split('T')[0] : null,
|
|
1074
|
-
body: c.body,
|
|
1075
|
-
}));
|
|
1076
|
-
} else {
|
|
1077
|
-
issueData[issueIdx].comments = raw;
|
|
1078
|
-
}
|
|
1079
|
-
break;
|
|
1080
|
-
}
|
|
1081
|
-
case 'dupe': {
|
|
1082
|
-
if (raw && !raw._error) {
|
|
1083
|
-
issueData[issueIdx].dupe_states[dupeKey] = {
|
|
1084
|
-
state: raw.state,
|
|
1085
|
-
updated: raw.updated_at,
|
|
1086
|
-
};
|
|
1087
|
-
} else {
|
|
1088
|
-
issueData[issueIdx].dupe_states[dupeKey] = raw;
|
|
1089
|
-
}
|
|
1090
|
-
break;
|
|
1091
|
-
}
|
|
1092
|
-
case 'upstream': {
|
|
1093
|
-
if (raw && !raw._error) {
|
|
1094
|
-
issueData[issueIdx].upstream_state = {
|
|
1095
|
-
state: raw.state,
|
|
1096
|
-
labels: (raw.labels || []).map(l => l.name),
|
|
1097
|
-
updated: raw.updated_at,
|
|
1098
|
-
};
|
|
1099
|
-
} else {
|
|
1100
|
-
issueData[issueIdx].upstream_state = raw;
|
|
1101
|
-
}
|
|
1102
|
-
break;
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// Post-process: extract cross_references and urls from body + comments
|
|
1108
|
-
for (let i = 0; i < issues.length; i++) {
|
|
1109
|
-
const data = issueData[i];
|
|
1110
|
-
const allText = collectText(data);
|
|
1111
|
-
|
|
1112
|
-
// Extract #NUMBER and owner/repo#NUMBER patterns
|
|
1113
|
-
const crossRefs = new Set();
|
|
1114
|
-
const refRe = /(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#(\d+)/g;
|
|
1115
|
-
let m;
|
|
1116
|
-
while ((m = refRe.exec(allText)) !== null) {
|
|
1117
|
-
crossRefs.add(m[1] ? `${m[1]}#${m[2]}` : `#${m[2]}`);
|
|
1118
|
-
}
|
|
1119
|
-
data.cross_references = [...crossRefs];
|
|
1120
|
-
|
|
1121
|
-
// Extract URLs
|
|
1122
|
-
const urlSet = new Set();
|
|
1123
|
-
const urlRe = /https?:\/\/[^\s)>\]"']+/g;
|
|
1124
|
-
while ((m = urlRe.exec(allText)) !== null) {
|
|
1125
|
-
urlSet.add(m[0].replace(/[.,;:]+$/, ''));
|
|
1126
|
-
}
|
|
1127
|
-
data.urls = [...urlSet];
|
|
1128
|
-
|
|
1129
|
-
// Write raw JSON per issue
|
|
1130
|
-
const iss = issues[i];
|
|
1131
|
-
const outFile = path.join(tempDir, `raw-${iss.owner}-${iss.repo}-${iss.number}.json`);
|
|
1132
|
-
try {
|
|
1133
|
-
fs.writeFileSync(outFile, JSON.stringify(data, null, 2), 'utf8');
|
|
1134
|
-
files.push(outFile);
|
|
1135
|
-
} catch (err) {
|
|
1136
|
-
errors.push(`Failed to write ${outFile}: ${err.message}`);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
console.log(JSON.stringify({ today: todayTag(), fetched: files.length, files, errors }));
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* Collect all text content from issue data for cross-reference/URL extraction.
|
|
1145
|
-
*/
|
|
1146
|
-
function collectText(data) {
|
|
1147
|
-
const parts = [];
|
|
1148
|
-
if (data.body) {
|
|
1149
|
-
if (typeof data.body === 'string') parts.push(data.body);
|
|
1150
|
-
else if (data.body.body) parts.push(data.body.body);
|
|
1151
|
-
}
|
|
1152
|
-
if (data.comments) {
|
|
1153
|
-
if (typeof data.comments === 'string') parts.push(data.comments);
|
|
1154
|
-
else if (Array.isArray(data.comments)) {
|
|
1155
|
-
for (const c of data.comments) {
|
|
1156
|
-
if (typeof c === 'string') parts.push(c);
|
|
1157
|
-
else if (c && c.body) parts.push(c.body);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
return parts.join('\n');
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// ─── Command: build-tracker ────────────────────────────────────────────────
|
|
1165
|
-
|
|
1166
|
-
function cmdBuildTracker(flags) {
|
|
1167
|
-
const tempDir = flags['temp-dir'];
|
|
1168
|
-
const templatePath = flags.template;
|
|
1169
|
-
const username = flags.username;
|
|
1170
|
-
const trackerPath = flags.tracker;
|
|
1171
|
-
const closedJsonPath = flags['closed-json'];
|
|
1172
|
-
const date = flags.date || localDate();
|
|
1173
|
-
|
|
1174
|
-
if (!tempDir || !templatePath || !username || !trackerPath) {
|
|
1175
|
-
console.error('Usage: build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> [--closed-json <path>]');
|
|
1176
|
-
process.exit(1);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
// Step 1: Read template
|
|
1180
|
-
if (!fs.existsSync(templatePath)) {
|
|
1181
|
-
console.error(`Template not found: ${templatePath}`);
|
|
1182
|
-
process.exit(1);
|
|
1183
|
-
}
|
|
1184
|
-
let tracker = fs.readFileSync(templatePath, 'utf8');
|
|
1185
|
-
|
|
1186
|
-
// Step 2: Replace USERNAME_HERE
|
|
1187
|
-
tracker = tracker.replace(/USERNAME_HERE/g, username);
|
|
1188
|
-
|
|
1189
|
-
// Step 3: Read all issue-*.md result files from temp dir
|
|
1190
|
-
if (!fs.existsSync(tempDir)) {
|
|
1191
|
-
console.error(`Temp directory not found: ${tempDir}`);
|
|
1192
|
-
process.exit(1);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
const resultFiles = fs.readdirSync(tempDir).filter(f =>
|
|
1196
|
-
f.startsWith('issue-') && f.endsWith('.md')
|
|
1197
|
-
);
|
|
1198
|
-
|
|
1199
|
-
const entries = [];
|
|
1200
|
-
for (const file of resultFiles) {
|
|
1201
|
-
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
1202
|
-
const { meta, body } = parseFrontmatter(content);
|
|
1203
|
-
if (meta.type !== 'issue') continue;
|
|
1204
|
-
|
|
1205
|
-
// Build tracker entry from result file
|
|
1206
|
-
const entry = buildTrackerEntry(meta, body, date);
|
|
1207
|
-
entries.push(entry);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
// Step 4: Insert active entries into tracker
|
|
1211
|
-
const activeEntriesText = entries.join('\n');
|
|
1212
|
-
tracker = tracker.replace(
|
|
1213
|
-
/(## Active Issues[^\n]*\n)([\s\S]*?)(## Closed)/,
|
|
1214
|
-
`$1\n${activeEntriesText}\n$3`
|
|
1215
|
-
);
|
|
1216
|
-
|
|
1217
|
-
// Step 5: Handle closed issues if provided
|
|
1218
|
-
let closedCount = 0;
|
|
1219
|
-
if (closedJsonPath && fs.existsSync(closedJsonPath)) {
|
|
1220
|
-
const closedIssues = JSON.parse(fs.readFileSync(closedJsonPath, 'utf8'));
|
|
1221
|
-
closedCount = closedIssues.length;
|
|
1222
|
-
let closedText = '';
|
|
1223
|
-
for (const ci of closedIssues) {
|
|
1224
|
-
closedText += `### ${ci.owner}/${ci.repo}#${ci.number} — ${ci.title}\n`;
|
|
1225
|
-
closedText += `- ${ci.resolution || 'Closed.'}\n\n`;
|
|
1226
|
-
}
|
|
1227
|
-
if (closedText) {
|
|
1228
|
-
tracker = tracker.replace(
|
|
1229
|
-
/(## Closed[^\n]*\n)/,
|
|
1230
|
-
`$1\n${closedText}`
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
// Step 6: Write tracker
|
|
1236
|
-
fs.writeFileSync(trackerPath, tracker, 'utf8');
|
|
1237
|
-
|
|
1238
|
-
console.log(JSON.stringify({
|
|
1239
|
-
today: todayTag(),
|
|
1240
|
-
written: true,
|
|
1241
|
-
path: trackerPath,
|
|
1242
|
-
active_count: entries.length,
|
|
1243
|
-
closed_count: closedCount,
|
|
1244
|
-
}));
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
/**
|
|
1248
|
-
* Build a tracker entry string from result file frontmatter and body.
|
|
1249
|
-
*/
|
|
1250
|
-
function buildTrackerEntry(meta, body, date) {
|
|
1251
|
-
const lines = [];
|
|
1252
|
-
lines.push(`### ${meta.owner}/${meta.repo}#${meta.number} — ${meta.title}`);
|
|
1253
|
-
|
|
1254
|
-
// Goal — from tracker updates or frontmatter
|
|
1255
|
-
const trackerUpdatesForGoal = extractSection(body, 'Tracker Updates') || '';
|
|
1256
|
-
const goalLine = trackerUpdatesForGoal.match(/^goal:\s*(.+)/m);
|
|
1257
|
-
if (goalLine) {
|
|
1258
|
-
lines.push(`- **Goal:** ${goalLine[1].trim()}`);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// Role
|
|
1262
|
-
lines.push(`- **Role:** ${meta.role || 'Unknown'}`);
|
|
1263
|
-
|
|
1264
|
-
// Filed
|
|
1265
|
-
if (meta.filed) {
|
|
1266
|
-
lines.push(`- **Filed:** ${meta.filed}`);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// Status — from ## Status Summary + labels
|
|
1270
|
-
const statusSummary = extractSection(body, 'Status Summary') || 'Open.';
|
|
1271
|
-
const labels = meta.labels
|
|
1272
|
-
? (Array.isArray(meta.labels) ? meta.labels.join(', ') : meta.labels)
|
|
1273
|
-
: '';
|
|
1274
|
-
const trackerUpdates = extractSection(body, 'Tracker Updates') || '';
|
|
1275
|
-
const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
|
|
1276
|
-
const statusText = statusLine
|
|
1277
|
-
? statusLine[1].trim()
|
|
1278
|
-
: `${meta.state || 'Open'}. Labels: ${labels}. ${statusSummary.split('\n')[0]}`;
|
|
1279
|
-
const dateStr = date || localDate();
|
|
1280
|
-
lines.push(`- **Status as of ${dateStr}:** ${statusText}`);
|
|
1281
|
-
|
|
1282
|
-
// What to check — from ## Watch For or tracker updates
|
|
1283
|
-
const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
|
|
1284
|
-
const watchFor = watchLine
|
|
1285
|
-
? watchLine[1].trim()
|
|
1286
|
-
: (extractSection(body, 'Watch For') || 'Monitor for updates.').split('\n')[0].replace(/^-\s*/, '');
|
|
1287
|
-
lines.push(`- **What to check:** ${watchFor}`);
|
|
1288
|
-
|
|
1289
|
-
// Related — from ## Cross-References
|
|
1290
|
-
const crossRefs = extractSection(body, 'Cross-References');
|
|
1291
|
-
if (crossRefs && crossRefs.trim() !== 'None' && crossRefs.trim() !== 'None found.') {
|
|
1292
|
-
lines.push(`- **Related:** ${crossRefs.split('\n')[0].trim()}`);
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
// Upstream
|
|
1296
|
-
const upstream = extractSection(body, 'Upstream');
|
|
1297
|
-
if (upstream && upstream.trim() !== 'N/A' && upstream.trim() !== 'None') {
|
|
1298
|
-
lines.push(`- **Upstream:** ${upstream.split('\n')[0].trim()}`);
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// Key Context -> workaround / future
|
|
1302
|
-
const keyContext = extractSection(body, 'Key Context');
|
|
1303
|
-
if (keyContext && keyContext.trim() !== 'N/A') {
|
|
1304
|
-
// Check for workaround
|
|
1305
|
-
const workaroundMatch = keyContext.match(/[Ww]orkaround:?\s*(.+)/);
|
|
1306
|
-
if (workaroundMatch) {
|
|
1307
|
-
lines.push(`- **Workaround:** ${workaroundMatch[1].trim()}`);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// Duplicates from ## Duplicates and Related
|
|
1312
|
-
const dupeSection = extractSection(body, 'Duplicates and Related')
|
|
1313
|
-
|| extractSection(body, 'Known');
|
|
1314
|
-
if (dupeSection) {
|
|
1315
|
-
const dupeLines = dupeSection.split('\n').filter(l => l.match(/^\s*-?\s*[#*]/));
|
|
1316
|
-
if (dupeLines.length > 0) {
|
|
1317
|
-
lines.push(`- **Duplicates found (${dateStr}):**`);
|
|
1318
|
-
for (const dl of dupeLines) {
|
|
1319
|
-
const cleaned = dl.replace(/^[\s-]*/, ' - ');
|
|
1320
|
-
lines.push(cleaned);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
// Next steps
|
|
1326
|
-
const nextSteps = extractSection(body, 'Next Steps');
|
|
1327
|
-
if (nextSteps && nextSteps.trim() !== 'None' && nextSteps.trim() !== 'None — no action needed.') {
|
|
1328
|
-
lines.push(`- **Next steps (now):** ${nextSteps.split('\n')[0].replace(/^-\s*\[.\]\s*/, '').trim()}`);
|
|
1329
|
-
} else {
|
|
1330
|
-
lines.push(`- **Next steps (now):** None — no action needed.`);
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
// Future — from Key Context
|
|
1334
|
-
if (keyContext && keyContext.trim() !== 'N/A') {
|
|
1335
|
-
lines.push(`- **Future:** ${keyContext.split('\n')[0].replace(/^-\s*/, '').trim()}`);
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
// History — from history_entry lines in Tracker Updates
|
|
1339
|
-
const historyEntries = [];
|
|
1340
|
-
if (trackerUpdates) {
|
|
1341
|
-
const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
|
|
1342
|
-
let hm;
|
|
1343
|
-
while ((hm = historyRe.exec(trackerUpdates)) !== null) {
|
|
1344
|
-
historyEntries.push(` - **${hm[1]}:** ${hm[2].trim()}`);
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
if (historyEntries.length === 0) {
|
|
1348
|
-
// Default entry on initial build
|
|
1349
|
-
historyEntries.push(` - **${dateStr}:** Added to tracker`);
|
|
1350
|
-
}
|
|
1351
|
-
lines.push(`- **History:**`);
|
|
1352
|
-
for (const he of historyEntries) {
|
|
1353
|
-
lines.push(he);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
lines.push('');
|
|
1357
|
-
return lines.join('\n');
|
|
1358
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tracker-tools.cjs — Data service layer for estack-github-issue-tracker skill.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* startup --tracker <path> Auth, parse tracker + config, create temp dir, discover issues
|
|
8
|
+
* fetch-issues --temp-dir <dir> --issues <json-file> Parallel gh api fetches per issue
|
|
9
|
+
* compile-report --temp-dir <dir> --date <YYYY-MM-DD> Build overview from result files
|
|
10
|
+
* update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD> Apply changes (incl. Goal)
|
|
11
|
+
* build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> First-run tracker creation
|
|
12
|
+
*
|
|
13
|
+
* All commands return a `today` field with the current date.
|
|
14
|
+
* Zero dependencies. Deterministic output. The agent calls this; it cannot "forget".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const { exec, execSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
function localDate() {
|
|
25
|
+
const now = new Date();
|
|
26
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function todayTag() {
|
|
30
|
+
return `today's date is **${localDate()}**, ignore earlier dates`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── CLI Router ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const [,, command, ...rawArgs] = process.argv;
|
|
36
|
+
|
|
37
|
+
const commands = {
|
|
38
|
+
'compile-report': cmdCompileReport,
|
|
39
|
+
'update-tracker': cmdUpdateTracker,
|
|
40
|
+
'startup': cmdStartup,
|
|
41
|
+
'fetch-issues': cmdFetchIssues,
|
|
42
|
+
'build-tracker': cmdBuildTracker,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (!command || !commands[command]) {
|
|
46
|
+
console.error(`Usage: tracker-tools.cjs <command> [options]`);
|
|
47
|
+
console.error(`Commands: ${Object.keys(commands).join(', ')}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parse --flag value pairs from rawArgs
|
|
52
|
+
function parseFlags(args) {
|
|
53
|
+
const flags = {};
|
|
54
|
+
for (let i = 0; i < args.length; i++) {
|
|
55
|
+
if (args[i].startsWith('--')) {
|
|
56
|
+
const key = args[i].slice(2);
|
|
57
|
+
const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true;
|
|
58
|
+
flags[key] = val;
|
|
59
|
+
} else if (!flags._positional) {
|
|
60
|
+
flags._positional = args[i];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return flags;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const flags = parseFlags(rawArgs);
|
|
67
|
+
|
|
68
|
+
(async () => {
|
|
69
|
+
try {
|
|
70
|
+
await commands[command](flags);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(`Error in ${command}: ${err.message}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
// ─── Frontmatter Parser ────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function parseFrontmatter(content) {
|
|
80
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
81
|
+
if (!match) return { meta: {}, body: content };
|
|
82
|
+
|
|
83
|
+
const meta = {};
|
|
84
|
+
const lines = match[1].split(/\r?\n/);
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const idx = line.indexOf(':');
|
|
87
|
+
if (idx === -1) continue;
|
|
88
|
+
const key = line.slice(0, idx).trim();
|
|
89
|
+
let val = line.slice(idx + 1).trim();
|
|
90
|
+
|
|
91
|
+
// Boolean
|
|
92
|
+
if (val === 'true') val = true;
|
|
93
|
+
else if (val === 'false') val = false;
|
|
94
|
+
// Number
|
|
95
|
+
else if (/^\d+$/.test(val)) val = parseInt(val, 10);
|
|
96
|
+
// Comma-separated list
|
|
97
|
+
else if (val.includes(',') && !val.startsWith('"')) {
|
|
98
|
+
val = val.split(',').map(s => s.trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
// Strip surrounding quotes
|
|
101
|
+
else if (val.startsWith('"') && val.endsWith('"')) {
|
|
102
|
+
val = val.slice(1, -1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
meta[key] = val;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const body = content.slice(match[0].length).trim();
|
|
109
|
+
return { meta, body };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Tracker Parser ─────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function parseTrackerFile(content) {
|
|
115
|
+
const result = {
|
|
116
|
+
username: null,
|
|
117
|
+
config: null,
|
|
118
|
+
active_issues: [],
|
|
119
|
+
closed_issues: [],
|
|
120
|
+
raw: content,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Extract username
|
|
124
|
+
const userMatch = content.match(/GitHub username:\s*\*\*(.+?)\*\*/);
|
|
125
|
+
if (userMatch) result.username = userMatch[1];
|
|
126
|
+
|
|
127
|
+
// Extract Config section (between ## Config and next --- or ##)
|
|
128
|
+
const configMatch = content.match(/## Config\s*\n([\s\S]*?)(?=\n---|\n## (?!Config))/);
|
|
129
|
+
if (configMatch) {
|
|
130
|
+
// Strip HTML comments and trim
|
|
131
|
+
result.config = configMatch[1].replace(/<!--[\s\S]*?-->/g, '').trim() || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Split into Active and Closed sections
|
|
135
|
+
const activeSectionMatch = content.match(
|
|
136
|
+
/## Active Issues[^\n]*\n([\s\S]*?)(?=\n## (?!Active)|$)/
|
|
137
|
+
);
|
|
138
|
+
const closedSectionMatch = content.match(
|
|
139
|
+
/## Closed[^\n]*\n([\s\S]*?)(?=\n## (?!Closed)|$)/
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (activeSectionMatch) {
|
|
143
|
+
result.active_issues = parseIssueSections(activeSectionMatch[1]);
|
|
144
|
+
}
|
|
145
|
+
if (closedSectionMatch) {
|
|
146
|
+
result.closed_issues = parseClosedSections(closedSectionMatch[1]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseIssueSections(sectionContent) {
|
|
153
|
+
const issues = [];
|
|
154
|
+
// Split by ### headers
|
|
155
|
+
const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
|
|
156
|
+
|
|
157
|
+
for (const part of parts) {
|
|
158
|
+
// Skip HTML comments
|
|
159
|
+
if (part.trim().startsWith('<!--')) continue;
|
|
160
|
+
|
|
161
|
+
const issue = parseIssueBlock(part);
|
|
162
|
+
if (issue) issues.push(issue);
|
|
163
|
+
}
|
|
164
|
+
return issues;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseIssueBlock(block) {
|
|
168
|
+
// Parse header: ### owner/repo#NUMBER — Title
|
|
169
|
+
const headerMatch = block.match(
|
|
170
|
+
/^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
|
|
171
|
+
);
|
|
172
|
+
if (!headerMatch) return null;
|
|
173
|
+
|
|
174
|
+
const issue = {
|
|
175
|
+
owner: headerMatch[1].trim(),
|
|
176
|
+
repo: headerMatch[2].trim(),
|
|
177
|
+
number: parseInt(headerMatch[3], 10),
|
|
178
|
+
title: headerMatch[4].trim(),
|
|
179
|
+
goal: extractField(block, 'Goal'),
|
|
180
|
+
role: extractField(block, 'Role'),
|
|
181
|
+
filed: extractField(block, 'Filed'),
|
|
182
|
+
last_check_date: null,
|
|
183
|
+
status_summary: extractField(block, 'Status as of'),
|
|
184
|
+
what_to_check: extractField(block, 'What to check'),
|
|
185
|
+
related: extractListField(block, 'Related'),
|
|
186
|
+
upstream: extractUpstream(block),
|
|
187
|
+
duplicates: extractNestedItems(block, 'Duplicates found'),
|
|
188
|
+
adjacent: extractNestedItems(block, 'Adjacent issues found'),
|
|
189
|
+
next_steps: extractField(block, 'Next steps \\(now\\)'),
|
|
190
|
+
future: extractField(block, 'Future'),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Extract last check date from "Status as of YYYY-MM-DD:"
|
|
194
|
+
const dateMatch = block.match(/\*\*Status as of (\d{4}-\d{2}-\d{2}):\*\*/);
|
|
195
|
+
if (dateMatch) issue.last_check_date = dateMatch[1];
|
|
196
|
+
|
|
197
|
+
// Extract History field (multi-line: bullet list under **History:**)
|
|
198
|
+
issue.history = extractHistoryField(block);
|
|
199
|
+
|
|
200
|
+
return issue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractField(block, fieldName) {
|
|
204
|
+
// Special case: "Status as of YYYY-MM-DD:" has the date embedded in the bold
|
|
205
|
+
if (fieldName === 'Status as of') {
|
|
206
|
+
const statusRe = /\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*(.+)/i;
|
|
207
|
+
const statusMatch = block.match(statusRe);
|
|
208
|
+
return statusMatch ? statusMatch[1].trim() : null;
|
|
209
|
+
}
|
|
210
|
+
const re = new RegExp(`\\*\\*${fieldName}(?:\\s+\\S+)?[.:]*\\*\\*\\s*(.+)`, 'i');
|
|
211
|
+
const match = block.match(re);
|
|
212
|
+
return match ? match[1].trim() : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function extractListField(block, fieldName) {
|
|
216
|
+
const val = extractField(block, fieldName);
|
|
217
|
+
if (!val) return [];
|
|
218
|
+
return val.split(',').map(s => s.trim()).filter(Boolean);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractUpstream(block) {
|
|
222
|
+
const val = extractField(block, 'Upstream');
|
|
223
|
+
if (!val || val.toLowerCase() === 'n/a' || val === 'none') return null;
|
|
224
|
+
const match = val.match(/([^/]+)\/([^#]+)#(\d+)/);
|
|
225
|
+
if (!match) return null;
|
|
226
|
+
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function extractNestedItems(block, sectionName) {
|
|
230
|
+
const items = [];
|
|
231
|
+
const re = new RegExp(`\\*\\*${sectionName}[^*]*\\*\\*[:\\s]*\\n([\\s\\S]*?)(?=\\n- \\*\\*[A-Z]|$)`, 'i');
|
|
232
|
+
const match = block.match(re);
|
|
233
|
+
if (!match) return items;
|
|
234
|
+
|
|
235
|
+
const lines = match[1].split(/\r?\n/);
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const itemMatch = line.match(/^\s+-\s+\*\*#?(\d+)\*\*\s*[—–-]\s*(.+)/);
|
|
238
|
+
if (itemMatch) {
|
|
239
|
+
items.push({
|
|
240
|
+
number: parseInt(itemMatch[1], 10),
|
|
241
|
+
detail: itemMatch[2].trim(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return items;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Extract history entries from an issue block.
|
|
250
|
+
* Matches all " - **YYYY-MM-DD:** ..." lines under "**History:**".
|
|
251
|
+
* Returns array of strings like "**2026-03-15:** Filed issue".
|
|
252
|
+
*/
|
|
253
|
+
function extractHistoryField(block) {
|
|
254
|
+
// Find **History:** and collect indented bullet lines until next top-level field or section
|
|
255
|
+
const historyStart = block.search(/- \*\*History:\*\*/);
|
|
256
|
+
if (historyStart === -1) return [];
|
|
257
|
+
|
|
258
|
+
const afterHistory = block.slice(historyStart);
|
|
259
|
+
// Collect lines after the **History:** line that match date bullets
|
|
260
|
+
const lines = afterHistory.split(/\r?\n/);
|
|
261
|
+
const entries = [];
|
|
262
|
+
let started = false;
|
|
263
|
+
for (const line of lines) {
|
|
264
|
+
if (!started) {
|
|
265
|
+
if (/- \*\*History:\*\*/.test(line)) { started = true; }
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// Match indented date bullets: " - **YYYY-MM-DD:** ..."
|
|
269
|
+
const bullet = line.match(/^\s+-\s+(\*\*\d{4}-\d{2}-\d{2}:\*\*.+)/);
|
|
270
|
+
if (bullet) {
|
|
271
|
+
entries.push(bullet[1].trim());
|
|
272
|
+
} else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) {
|
|
273
|
+
// Next top-level field or section header — stop
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return entries;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseClosedSections(sectionContent) {
|
|
281
|
+
const issues = [];
|
|
282
|
+
const parts = sectionContent.split(/(?=^### )/m).filter(p => p.trim());
|
|
283
|
+
|
|
284
|
+
for (const part of parts) {
|
|
285
|
+
if (part.trim().startsWith('<!--')) continue;
|
|
286
|
+
const headerMatch = part.match(
|
|
287
|
+
/^### ([^/]+)\/([^#]+)#(\d+)\s*[—–-]\s*(.+)/m
|
|
288
|
+
);
|
|
289
|
+
if (!headerMatch) continue;
|
|
290
|
+
|
|
291
|
+
const lines = part.split(/\r?\n/).slice(1).filter(l => l.trim().startsWith('-'));
|
|
292
|
+
issues.push({
|
|
293
|
+
owner: headerMatch[1].trim(),
|
|
294
|
+
repo: headerMatch[2].trim(),
|
|
295
|
+
number: parseInt(headerMatch[3], 10),
|
|
296
|
+
title: headerMatch[4].trim(),
|
|
297
|
+
resolution: lines.map(l => l.replace(/^-\s*/, '').trim()).join(' '),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return issues;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Report Compiler ────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function compileOverviewReport(tempDir, date) {
|
|
306
|
+
const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
|
|
307
|
+
if (files.length === 0) {
|
|
308
|
+
return { error: 'No result files found in temp directory' };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const issueResults = [];
|
|
312
|
+
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
315
|
+
const { meta, body } = parseFrontmatter(content);
|
|
316
|
+
|
|
317
|
+
if (meta.type === 'issue') {
|
|
318
|
+
issueResults.push({ meta, body, file });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Sort: activity first, then by update recency
|
|
323
|
+
const withActivity = issueResults.filter(r => r.meta.has_activity === true);
|
|
324
|
+
const noActivity = issueResults.filter(r => r.meta.has_activity !== true);
|
|
325
|
+
|
|
326
|
+
// Count repos
|
|
327
|
+
const repos = new Set(issueResults.map(r => `${r.meta.owner}/${r.meta.repo}`));
|
|
328
|
+
|
|
329
|
+
// Build report
|
|
330
|
+
let report = '';
|
|
331
|
+
|
|
332
|
+
report += `## GitHub Issues Check-In — ${date}\n\n`;
|
|
333
|
+
report += `**Tracking ${issueResults.length} active issues across ${repos.size} repos**`;
|
|
334
|
+
|
|
335
|
+
// Find oldest check date for "last check"
|
|
336
|
+
const checkDates = issueResults
|
|
337
|
+
.map(r => r.meta.last_check_date)
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
.sort();
|
|
340
|
+
if (checkDates.length > 0) {
|
|
341
|
+
report += ` | Last check: ${checkDates[0]}`;
|
|
342
|
+
}
|
|
343
|
+
report += '\n\n---\n\n';
|
|
344
|
+
|
|
345
|
+
// ── Issues with Activity ──
|
|
346
|
+
if (withActivity.length > 0) {
|
|
347
|
+
report += '### Issues with Activity\n\n';
|
|
348
|
+
report += `These ${withActivity.length} issue${withActivity.length === 1 ? ' has' : 's have'} new comments or state changes since your last check.\n\n`;
|
|
349
|
+
for (const r of withActivity) {
|
|
350
|
+
report += buildIssueDetailBlock(r);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── No Activity ──
|
|
355
|
+
if (noActivity.length > 0) {
|
|
356
|
+
report += '### No Activity\n\n';
|
|
357
|
+
for (const r of noActivity) {
|
|
358
|
+
report += buildQuietIssueBlock(r);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Upstream Status ──
|
|
363
|
+
const upstreamResults = issueResults.filter(r => {
|
|
364
|
+
const upstreamSection = extractSection(r.body, 'Upstream');
|
|
365
|
+
return upstreamSection && upstreamSection.trim() !== 'N/A' && upstreamSection.trim() !== 'None';
|
|
366
|
+
});
|
|
367
|
+
if (upstreamResults.length > 0) {
|
|
368
|
+
report += '### Upstream Status\n\n';
|
|
369
|
+
report += '| Upstream issue | State | Impact |\n';
|
|
370
|
+
report += '|----------------|-------|--------|\n';
|
|
371
|
+
for (const r of upstreamResults) {
|
|
372
|
+
const section = extractSection(r.body, 'Upstream');
|
|
373
|
+
report += `| ${r.meta.owner}/${r.meta.repo}#${r.meta.number} | ${section.trim()} |\n`;
|
|
374
|
+
}
|
|
375
|
+
report += '\n---\n\n';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Summary ──
|
|
379
|
+
const actionItems = withActivity.reduce((count, r) => {
|
|
380
|
+
const steps = extractSection(r.body, 'Next Steps');
|
|
381
|
+
if (steps) count += (steps.match(/^- \[/gm) || []).length;
|
|
382
|
+
return count;
|
|
383
|
+
}, 0);
|
|
384
|
+
|
|
385
|
+
report += '### Summary\n\n';
|
|
386
|
+
report += `**Action items:** ${actionItems}\n`;
|
|
387
|
+
if (withActivity.length > 0) {
|
|
388
|
+
const needAttention = withActivity.slice(0, 3).map(
|
|
389
|
+
r => `${r.meta.owner}/${r.meta.repo}#${r.meta.number}`
|
|
390
|
+
);
|
|
391
|
+
report += `**Issues needing attention:** ${needAttention.join(', ')}\n`;
|
|
392
|
+
}
|
|
393
|
+
if (noActivity.length > 0) {
|
|
394
|
+
report += `**All quiet:** ${noActivity.length} issues with no activity\n`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { report, issue_count: issueResults.length, activity_count: withActivity.length };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function buildIssueDetailBlock(r) {
|
|
401
|
+
let block = '';
|
|
402
|
+
block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
|
|
403
|
+
block += `| Field | Value |\n`;
|
|
404
|
+
block += `|-------|-------|\n`;
|
|
405
|
+
block += `| **State** | ${r.meta.state || 'Open'}${r.meta.state_changed ? ' [changed]' : ''} |\n`;
|
|
406
|
+
if (r.meta.labels) {
|
|
407
|
+
const labels = Array.isArray(r.meta.labels) ? r.meta.labels.join(', ') : r.meta.labels;
|
|
408
|
+
block += `| **Labels** | ${labels} |\n`;
|
|
409
|
+
}
|
|
410
|
+
block += `| **Your role** | ${r.meta.role || 'Unknown'} |\n\n`;
|
|
411
|
+
|
|
412
|
+
// Plain English summary of where this issue stands
|
|
413
|
+
const summary = extractSection(r.body, 'Status Summary');
|
|
414
|
+
if (summary) {
|
|
415
|
+
block += `${summary.trim()}\n\n`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Activity section — what changed since last check
|
|
419
|
+
const activity = extractSection(r.body, 'Activity');
|
|
420
|
+
if (activity) {
|
|
421
|
+
block += `**What changed:**\n${activity}\n\n`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Duplicates
|
|
425
|
+
const dupes = extractSection(r.body, 'Known');
|
|
426
|
+
const newFinds = extractSection(r.body, 'New finds');
|
|
427
|
+
const hasDupes = dupes && !/^(none|no changes)\.?$/i.test(dupes.trim());
|
|
428
|
+
const hasNewFinds = newFinds && !/^(none|none found|skipped)\.?/i.test(newFinds.trim());
|
|
429
|
+
if (hasDupes || hasNewFinds) {
|
|
430
|
+
block += `**Duplicates & related:**\n`;
|
|
431
|
+
if (hasDupes) block += `- Known dupes — ${dupes.trim()}\n`;
|
|
432
|
+
if (hasNewFinds) block += `- New finds — ${newFinds.trim()}\n`;
|
|
433
|
+
block += '\n';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// What you need to do next
|
|
437
|
+
const steps = extractSection(r.body, 'Next Steps');
|
|
438
|
+
if (steps) {
|
|
439
|
+
block += `**What to do next:**\n${steps}\n\n`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Watch For
|
|
443
|
+
const watch = extractSection(r.body, 'Watch For');
|
|
444
|
+
if (watch) {
|
|
445
|
+
block += `**Watch for:**\n${watch}\n\n`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
block += '---\n\n';
|
|
449
|
+
return block;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function buildQuietIssueBlock(r) {
|
|
453
|
+
let block = '';
|
|
454
|
+
block += `#### ${r.meta.owner}/${r.meta.repo}#${r.meta.number} — ${r.meta.title}\n`;
|
|
455
|
+
const lastDate = r.meta.last_comment_date || r.meta.last_check_date || 'unknown';
|
|
456
|
+
block += `- Last activity: ${lastDate}\n`;
|
|
457
|
+
|
|
458
|
+
// Keep quiet issues compact to avoid report bloat.
|
|
459
|
+
const summary = extractSection(r.body, 'Status Summary');
|
|
460
|
+
if (summary) {
|
|
461
|
+
block += `- Status: ${firstLine(summary)}\n`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const steps = extractSection(r.body, 'Next Steps');
|
|
465
|
+
if (steps) {
|
|
466
|
+
block += `- Next: ${firstLine(steps)}\n`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const watch = extractSection(r.body, 'Watch For');
|
|
470
|
+
if (watch) {
|
|
471
|
+
block += `- Watch: ${firstLine(watch)}\n`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
block += '\n';
|
|
475
|
+
return block;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function extractSection(body, headerPattern) {
|
|
479
|
+
// Match ## Header or ### Header, capture until next ## or end
|
|
480
|
+
const re = new RegExp(
|
|
481
|
+
`^#{2,4}\\s+(?:[^\\n]*?${headerPattern}[^\\n]*)\\n([\\s\\S]*?)(?=^#{2,4}\\s|$)`,
|
|
482
|
+
'mi'
|
|
483
|
+
);
|
|
484
|
+
const match = body.match(re);
|
|
485
|
+
return match ? match[1].trim() : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function firstLine(text) {
|
|
489
|
+
if (!text) return '';
|
|
490
|
+
const line = text.split(/\r?\n/).find(l => l.trim().length > 0) || '';
|
|
491
|
+
return line.replace(/^\s*[-*]\s*/, '').trim();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── Tracker Updater ────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
function applyTrackerUpdates(trackerContent, tempDir, date) {
|
|
497
|
+
const files = fs.readdirSync(tempDir).filter(f => f.endsWith('.md'));
|
|
498
|
+
let updated = trackerContent;
|
|
499
|
+
const changes = [];
|
|
500
|
+
|
|
501
|
+
for (const file of files) {
|
|
502
|
+
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
503
|
+
const { meta, body } = parseFrontmatter(content);
|
|
504
|
+
|
|
505
|
+
if (meta.type !== 'issue') continue;
|
|
506
|
+
|
|
507
|
+
const issueKey = `${meta.owner}/${meta.repo}#${meta.number}`;
|
|
508
|
+
|
|
509
|
+
// Find this issue's section in the tracker
|
|
510
|
+
const sectionRe = new RegExp(
|
|
511
|
+
`(### ${escapeRegex(meta.owner)}/${escapeRegex(meta.repo)}#${meta.number}\\s*[—–-][^\\n]*\\n[\\s\\S]*?)(?=\\n### |\\n## |$)`,
|
|
512
|
+
'm'
|
|
513
|
+
);
|
|
514
|
+
const sectionMatch = updated.match(sectionRe);
|
|
515
|
+
if (!sectionMatch) continue;
|
|
516
|
+
|
|
517
|
+
let section = sectionMatch[1];
|
|
518
|
+
const originalSection = section;
|
|
519
|
+
|
|
520
|
+
// Update "Status as of" date and summary
|
|
521
|
+
const trackerUpdates = extractSection(body, 'Tracker Updates');
|
|
522
|
+
if (trackerUpdates) {
|
|
523
|
+
const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
|
|
524
|
+
if (statusLine) {
|
|
525
|
+
section = section.replace(
|
|
526
|
+
/\*\*Status as of \d{4}-\d{2}-\d{2}:\*\*\s*.+/,
|
|
527
|
+
`**Status as of ${date}:** ${statusLine[1].trim()}`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
|
|
532
|
+
if (watchLine) {
|
|
533
|
+
section = section.replace(
|
|
534
|
+
/\*\*What to check\*\*:\s*.+/,
|
|
535
|
+
`**What to check:** ${watchLine[1].trim()}`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Update or add Goal
|
|
540
|
+
const goalLine = trackerUpdates.match(/^goal:\s*(.+)/m);
|
|
541
|
+
if (goalLine) {
|
|
542
|
+
if (section.includes('**Goal:**')) {
|
|
543
|
+
section = section.replace(
|
|
544
|
+
/\*\*Goal:\*\*\s*.+/,
|
|
545
|
+
`**Goal:** ${goalLine[1].trim()}`
|
|
546
|
+
);
|
|
547
|
+
} else {
|
|
548
|
+
// Insert Goal as first field after the header line
|
|
549
|
+
section = section.replace(
|
|
550
|
+
/(### [^\n]+\n)/,
|
|
551
|
+
`$1- **Goal:** ${goalLine[1].trim()}\n`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
changes.push(`Set Goal for ${issueKey}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Handle state change: open → closed (move to Closed section)
|
|
559
|
+
if (meta.state === 'closed' && meta.state_changed) {
|
|
560
|
+
// Preserve key fields from the original section before removing it
|
|
561
|
+
const closedLines = [];
|
|
562
|
+
closedLines.push(`### ${issueKey} — ${meta.title}`);
|
|
563
|
+
closedLines.push(`- Closed as of ${date}. ${meta.close_reason || ''}`);
|
|
564
|
+
|
|
565
|
+
// Preserve Goal
|
|
566
|
+
const goalMatch = originalSection.match(/- \*\*Goal:\*\*\s*(.+)/);
|
|
567
|
+
if (goalMatch) closedLines.push(`- **Goal:** ${goalMatch[1].trim()}`);
|
|
568
|
+
|
|
569
|
+
// Preserve Role
|
|
570
|
+
const roleMatch = originalSection.match(/- \*\*Role:\*\*\s*(.+)/);
|
|
571
|
+
if (roleMatch) closedLines.push(`- **Role:** ${roleMatch[1].trim()}`);
|
|
572
|
+
|
|
573
|
+
// Preserve History section
|
|
574
|
+
const historyIdx = originalSection.indexOf('- **History:**');
|
|
575
|
+
if (historyIdx !== -1) {
|
|
576
|
+
const historyBlock = originalSection.slice(historyIdx);
|
|
577
|
+
const historyLines = historyBlock.split(/\r?\n/);
|
|
578
|
+
const preserved = [historyLines[0]]; // "- **History:**"
|
|
579
|
+
for (let hi = 1; hi < historyLines.length; hi++) {
|
|
580
|
+
if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[hi])) {
|
|
581
|
+
preserved.push(historyLines[hi]);
|
|
582
|
+
} else if (/^- \*\*[A-Z]/.test(historyLines[hi]) || /^### /.test(historyLines[hi])) {
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Add closing history entry
|
|
587
|
+
preserved.push(` - **${date}:** Closed. ${meta.close_reason || ''}`);
|
|
588
|
+
closedLines.push(...preserved);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const closedEntry = closedLines.join('\n') + '\n\n';
|
|
592
|
+
|
|
593
|
+
// Remove from active
|
|
594
|
+
updated = updated.replace(originalSection, '');
|
|
595
|
+
// Add to closed
|
|
596
|
+
updated = updated.replace(
|
|
597
|
+
/(## Closed[^\n]*\n)/,
|
|
598
|
+
`$1\n${closedEntry}`
|
|
599
|
+
);
|
|
600
|
+
changes.push(`Moved ${issueKey} to Closed`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Add new duplicates
|
|
605
|
+
if (trackerUpdates) {
|
|
606
|
+
const newDupeLines = [];
|
|
607
|
+
const dupeRe = /new_duplicate:\s*#?(\d+)\s*[—–-]\s*(.+)/gm;
|
|
608
|
+
let dupeMatch;
|
|
609
|
+
while ((dupeMatch = dupeRe.exec(trackerUpdates)) !== null) {
|
|
610
|
+
newDupeLines.push(` - **#${dupeMatch[1]}** — ${dupeMatch[2].trim()}`);
|
|
611
|
+
}
|
|
612
|
+
if (newDupeLines.length > 0) {
|
|
613
|
+
const dupeHeader = `**Duplicates found (${date}):**`;
|
|
614
|
+
if (section.includes('Duplicates found')) {
|
|
615
|
+
// Append to existing duplicates section
|
|
616
|
+
section = section.replace(
|
|
617
|
+
/(Duplicates found[^*]*\*\*:?\s*\n(?:\s+-[^\n]*\n)*)/,
|
|
618
|
+
`$1${dupeHeader}\n${newDupeLines.join('\n')}\n`
|
|
619
|
+
);
|
|
620
|
+
} else {
|
|
621
|
+
// Add before Next steps
|
|
622
|
+
section = section.replace(
|
|
623
|
+
/(\*\*Next steps)/,
|
|
624
|
+
`- ${dupeHeader}\n${newDupeLines.join('\n')}\n- $1`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
changes.push(`Added ${newDupeLines.length} duplicates to ${issueKey}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Append history entries from result file
|
|
632
|
+
if (trackerUpdates) {
|
|
633
|
+
const newHistoryEntries = [];
|
|
634
|
+
const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
|
|
635
|
+
let hm;
|
|
636
|
+
while ((hm = historyRe.exec(trackerUpdates)) !== null) {
|
|
637
|
+
newHistoryEntries.push({ date: hm[1], desc: hm[2].trim() });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (newHistoryEntries.length > 0) {
|
|
641
|
+
const historyHeaderIdx = section.indexOf('- **History:**');
|
|
642
|
+
if (historyHeaderIdx !== -1) {
|
|
643
|
+
// History section exists — collect existing entries for dedup
|
|
644
|
+
const afterHeader = section.slice(historyHeaderIdx);
|
|
645
|
+
const existingLines = afterHeader.split(/\r?\n/);
|
|
646
|
+
const existingTexts = new Set();
|
|
647
|
+
for (const line of existingLines.slice(1)) {
|
|
648
|
+
const m = line.match(/^\s+-\s+\*\*(\d{4}-\d{2}-\d{2}):\*\*\s*(.+)/);
|
|
649
|
+
if (m) existingTexts.add(`${m[1]}|${m[2].trim()}`);
|
|
650
|
+
else if (line.match(/^- \*\*[A-Z]/) || line.match(/^### /)) break;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Build lines to append (deduped)
|
|
654
|
+
const toAppend = newHistoryEntries
|
|
655
|
+
.filter(e => !existingTexts.has(`${e.date}|${e.desc}`))
|
|
656
|
+
.map(e => ` - **${e.date}:** ${e.desc}`);
|
|
657
|
+
|
|
658
|
+
if (toAppend.length > 0) {
|
|
659
|
+
// Find the last history bullet line position and insert after it
|
|
660
|
+
// We'll insert the new lines before the next top-level field after **History:**
|
|
661
|
+
const historyBlock = section.slice(historyHeaderIdx);
|
|
662
|
+
const historyLines = historyBlock.split(/\r?\n/);
|
|
663
|
+
let lastBulletLine = 0;
|
|
664
|
+
for (let li = 1; li < historyLines.length; li++) {
|
|
665
|
+
if (/^\s+-\s+\*\*\d{4}-\d{2}-\d{2}:\*\*/.test(historyLines[li])) {
|
|
666
|
+
lastBulletLine = li;
|
|
667
|
+
} else if (/^- \*\*[A-Z]/.test(historyLines[li]) || /^### /.test(historyLines[li])) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Insert toAppend after lastBulletLine
|
|
672
|
+
historyLines.splice(lastBulletLine + 1, 0, ...toAppend);
|
|
673
|
+
const newHistoryBlock = historyLines.join('\n');
|
|
674
|
+
section = section.slice(0, historyHeaderIdx) + newHistoryBlock;
|
|
675
|
+
changes.push(`Appended ${toAppend.length} history entries to ${issueKey}`);
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
// No history section yet — insert before trailing newline / end of section
|
|
679
|
+
const insertPoint = section.lastIndexOf('\n');
|
|
680
|
+
const historyLines = ['- **History:**'];
|
|
681
|
+
for (const e of newHistoryEntries) {
|
|
682
|
+
historyLines.push(` - **${e.date}:** ${e.desc}`);
|
|
683
|
+
}
|
|
684
|
+
section = section.slice(0, insertPoint) + '\n' + historyLines.join('\n') + section.slice(insertPoint);
|
|
685
|
+
changes.push(`Added History section with ${newHistoryEntries.length} entries to ${issueKey}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (section !== originalSection) {
|
|
691
|
+
updated = updated.replace(originalSection, section);
|
|
692
|
+
changes.push(`Updated ${issueKey}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return { content: updated, changes };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function escapeRegex(str) {
|
|
700
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ─── Command Implementations ────────────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
function cmdCompileReport(flags) {
|
|
706
|
+
const tempDir = flags['temp-dir'];
|
|
707
|
+
const date = flags.date;
|
|
708
|
+
|
|
709
|
+
if (!tempDir || !date) {
|
|
710
|
+
console.error('Usage: compile-report --temp-dir <dir> --date <YYYY-MM-DD>');
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!fs.existsSync(tempDir)) {
|
|
715
|
+
console.error(`Temp directory not found: ${tempDir}`);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const result = compileOverviewReport(tempDir, date);
|
|
720
|
+
if (result.error) {
|
|
721
|
+
console.error(result.error);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Write report to temp dir as well for validation
|
|
726
|
+
fs.writeFileSync(path.join(tempDir, '_compiled-report.md'), result.report, 'utf8');
|
|
727
|
+
|
|
728
|
+
// Output the report to stdout
|
|
729
|
+
console.log(result.report);
|
|
730
|
+
|
|
731
|
+
// Write metadata to stderr for the agent to parse
|
|
732
|
+
console.error(JSON.stringify({
|
|
733
|
+
today: todayTag(),
|
|
734
|
+
issue_count: result.issue_count,
|
|
735
|
+
activity_count: result.activity_count,
|
|
736
|
+
report_file: path.join(tempDir, '_compiled-report.md'),
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function cmdUpdateTracker(flags) {
|
|
741
|
+
const trackerPath = flags.tracker;
|
|
742
|
+
const tempDir = flags['temp-dir'];
|
|
743
|
+
const date = flags.date;
|
|
744
|
+
|
|
745
|
+
if (!trackerPath || !tempDir || !date) {
|
|
746
|
+
console.error('Usage: update-tracker --tracker <path> --temp-dir <dir> --date <YYYY-MM-DD>');
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!fs.existsSync(trackerPath)) {
|
|
751
|
+
console.error(`Tracker file not found: ${trackerPath}`);
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const content = fs.readFileSync(trackerPath, 'utf8');
|
|
756
|
+
const { content: updated, changes } = applyTrackerUpdates(content, tempDir, date);
|
|
757
|
+
|
|
758
|
+
if (changes.length === 0) {
|
|
759
|
+
console.log(JSON.stringify({ today: todayTag(), updated: false, changes: [] }));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fs.writeFileSync(trackerPath, updated, 'utf8');
|
|
764
|
+
console.log(JSON.stringify({ today: todayTag(), updated: true, changes }));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ─── Async Helper ──────────────────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
function execAsync(cmd) {
|
|
770
|
+
return new Promise((resolve, reject) => {
|
|
771
|
+
exec(cmd, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
772
|
+
if (err) reject(err);
|
|
773
|
+
else resolve(stdout);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Run promises in batches to avoid rate limits.
|
|
780
|
+
* @param {Array<() => Promise>} tasks - Array of functions returning promises
|
|
781
|
+
* @param {number} concurrency - Max concurrent tasks
|
|
782
|
+
* @returns {Promise<Array>} Results in same order as tasks
|
|
783
|
+
*/
|
|
784
|
+
async function batchRun(tasks, concurrency = 15) {
|
|
785
|
+
const results = [];
|
|
786
|
+
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
787
|
+
const batch = tasks.slice(i, i + concurrency);
|
|
788
|
+
const batchResults = await Promise.all(batch.map(fn => fn()));
|
|
789
|
+
results.push(...batchResults);
|
|
790
|
+
}
|
|
791
|
+
return results;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ─── Command: startup ──────────────────────────────────────────────────────
|
|
795
|
+
|
|
796
|
+
async function cmdStartup(flags) {
|
|
797
|
+
const trackerPath = flags.tracker;
|
|
798
|
+
if (!trackerPath) {
|
|
799
|
+
console.error('Usage: startup --tracker <path>');
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Step 1: Check gh auth
|
|
804
|
+
let username = null;
|
|
805
|
+
try {
|
|
806
|
+
const authOutput = execSync('gh auth status 2>&1', { encoding: 'utf8' });
|
|
807
|
+
const userMatch = authOutput.match(/Logged in to github\.com.*account\s+(\S+)/i)
|
|
808
|
+
|| authOutput.match(/Logged in to github\.com\s+as\s+(\S+)/i)
|
|
809
|
+
|| authOutput.match(/account\s+(\S+)/i);
|
|
810
|
+
if (userMatch) username = userMatch[1];
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.log(JSON.stringify({
|
|
813
|
+
script_ok: true, auth: false, today: todayTag(),
|
|
814
|
+
error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
|
|
815
|
+
}));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!username) {
|
|
820
|
+
// Try alternate extraction from gh api
|
|
821
|
+
try {
|
|
822
|
+
username = execSync('gh api user --jq .login', { encoding: 'utf8' }).trim();
|
|
823
|
+
} catch (_) {
|
|
824
|
+
console.log(JSON.stringify({
|
|
825
|
+
script_ok: true, auth: false, today: todayTag(),
|
|
826
|
+
error: 'Not authenticated with GitHub. Run `gh auth login` in your terminal to fix this.'
|
|
827
|
+
}));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Step 2: Parse tracker (or set empty defaults if no tracker)
|
|
833
|
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
|
834
|
+
.toISOString().split('T')[0];
|
|
835
|
+
|
|
836
|
+
let trackerExists = false;
|
|
837
|
+
let parsed = { username: null, active_issues: [], closed_issues: [], raw: '' };
|
|
838
|
+
|
|
839
|
+
if (fs.existsSync(trackerPath)) {
|
|
840
|
+
const content = fs.readFileSync(trackerPath, 'utf8');
|
|
841
|
+
if (content.trim()) {
|
|
842
|
+
trackerExists = true;
|
|
843
|
+
parsed = parseTrackerFile(content);
|
|
844
|
+
// Default null last_check_date to 30 days ago
|
|
845
|
+
for (const issue of parsed.active_issues) {
|
|
846
|
+
if (!issue.last_check_date) issue.last_check_date = thirtyDaysAgo;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const oldestCheckDate = parsed.active_issues
|
|
852
|
+
.map(i => i.last_check_date)
|
|
853
|
+
.filter(Boolean)
|
|
854
|
+
.sort()[0] || thirtyDaysAgo;
|
|
855
|
+
|
|
856
|
+
const allTrackedNumbers = [
|
|
857
|
+
...parsed.active_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
|
|
858
|
+
...parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`),
|
|
859
|
+
];
|
|
860
|
+
|
|
861
|
+
// Step 3: Run two gh api search queries in parallel (always, even without tracker)
|
|
862
|
+
const searchDate = oldestCheckDate;
|
|
863
|
+
const openQuery = `search/issues?q=involves:${username}+is:open+updated:>${searchDate}&per_page=100`;
|
|
864
|
+
const closedQuery = `search/issues?q=involves:${username}+is:closed+closed:>${thirtyDaysAgo}&per_page=50`;
|
|
865
|
+
|
|
866
|
+
let openResults = [];
|
|
867
|
+
let closedResults = [];
|
|
868
|
+
let searchErrors = [];
|
|
869
|
+
|
|
870
|
+
// Run searches individually so one failure doesn't block the other
|
|
871
|
+
try {
|
|
872
|
+
const openRaw = await execAsync(`gh api "${openQuery}" --jq ".items"`);
|
|
873
|
+
openResults = JSON.parse(openRaw || '[]');
|
|
874
|
+
} catch (err) {
|
|
875
|
+
searchErrors.push(`open search failed: ${err.message}`);
|
|
876
|
+
openResults = [];
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const closedRaw = await execAsync(`gh api "${closedQuery}" --jq ".items"`);
|
|
881
|
+
closedResults = JSON.parse(closedRaw || '[]');
|
|
882
|
+
} catch (err) {
|
|
883
|
+
searchErrors.push(`closed search failed: ${err.message}`);
|
|
884
|
+
closedResults = [];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Step 4: Identify new, reopened, recently closed
|
|
888
|
+
function issueKey(item) {
|
|
889
|
+
const urlParts = (item.repository_url || '').split('/');
|
|
890
|
+
const owner = urlParts[urlParts.length - 2];
|
|
891
|
+
const repo = urlParts[urlParts.length - 1];
|
|
892
|
+
return `${owner}/${repo}#${item.number}`;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function issueInfo(item) {
|
|
896
|
+
const urlParts = (item.repository_url || '').split('/');
|
|
897
|
+
return {
|
|
898
|
+
owner: urlParts[urlParts.length - 2],
|
|
899
|
+
repo: urlParts[urlParts.length - 1],
|
|
900
|
+
number: item.number,
|
|
901
|
+
title: item.title,
|
|
902
|
+
state: item.state,
|
|
903
|
+
updated_at: item.updated_at,
|
|
904
|
+
created_at: item.created_at,
|
|
905
|
+
html_url: item.html_url,
|
|
906
|
+
labels: (item.labels || []).map(l => l.name),
|
|
907
|
+
user: item.user ? item.user.login : null,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const trackedSet = new Set(allTrackedNumbers);
|
|
912
|
+
const closedNumbers = new Set(
|
|
913
|
+
parsed.closed_issues.map(i => `${i.owner}/${i.repo}#${i.number}`)
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
const newIssues = openResults
|
|
917
|
+
.filter(item => !trackedSet.has(issueKey(item)))
|
|
918
|
+
.map(issueInfo);
|
|
919
|
+
|
|
920
|
+
const reopenedIssues = openResults
|
|
921
|
+
.filter(item => closedNumbers.has(issueKey(item)))
|
|
922
|
+
.map(issueInfo);
|
|
923
|
+
|
|
924
|
+
const recentlyClosed = closedResults.map(issueInfo);
|
|
925
|
+
|
|
926
|
+
// Create temp directory for this session
|
|
927
|
+
const prefix = 'giu-checkin-';
|
|
928
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
929
|
+
|
|
930
|
+
console.log(JSON.stringify({
|
|
931
|
+
script_ok: true,
|
|
932
|
+
auth: true,
|
|
933
|
+
today: todayTag(),
|
|
934
|
+
username,
|
|
935
|
+
temp_dir: tempDir,
|
|
936
|
+
tracker_exists: trackerExists,
|
|
937
|
+
tracker_path: trackerPath,
|
|
938
|
+
config: parsed.config,
|
|
939
|
+
tracker_data: {
|
|
940
|
+
username: parsed.username,
|
|
941
|
+
active_issues: parsed.active_issues,
|
|
942
|
+
closed_issues: parsed.closed_issues,
|
|
943
|
+
all_tracked_numbers: allTrackedNumbers,
|
|
944
|
+
oldest_check_date: oldestCheckDate,
|
|
945
|
+
raw: parsed.raw,
|
|
946
|
+
},
|
|
947
|
+
new_issues: newIssues,
|
|
948
|
+
reopened_issues: reopenedIssues,
|
|
949
|
+
recently_closed: recentlyClosed,
|
|
950
|
+
search_errors: searchErrors,
|
|
951
|
+
}, null, 2));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ─── Command: fetch-issues ─────────────────────────────────────────────────
|
|
955
|
+
|
|
956
|
+
async function cmdFetchIssues(flags) {
|
|
957
|
+
const tempDir = flags['temp-dir'];
|
|
958
|
+
const issuesFile = flags.issues;
|
|
959
|
+
|
|
960
|
+
if (!tempDir || !issuesFile) {
|
|
961
|
+
console.error('Usage: fetch-issues --temp-dir <dir> --issues <json-file>');
|
|
962
|
+
process.exit(1);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!fs.existsSync(issuesFile)) {
|
|
966
|
+
console.error(`Issues file not found: ${issuesFile}`);
|
|
967
|
+
process.exit(1);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const issues = JSON.parse(fs.readFileSync(issuesFile, 'utf8'));
|
|
971
|
+
const errors = [];
|
|
972
|
+
const files = [];
|
|
973
|
+
|
|
974
|
+
// Build all fetch tasks
|
|
975
|
+
const allTasks = [];
|
|
976
|
+
const taskMap = []; // Maps task index to { issueIdx, type }
|
|
977
|
+
|
|
978
|
+
// Fetch raw JSON from gh api (no --jq — process in Node to avoid Windows quoting issues)
|
|
979
|
+
function ghFetch(endpoint) {
|
|
980
|
+
return execAsync(`gh api ${endpoint}`)
|
|
981
|
+
.then(raw => JSON.parse(raw || '{}'))
|
|
982
|
+
.catch(e => ({ _error: e.message }));
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
for (let i = 0; i < issues.length; i++) {
|
|
986
|
+
const iss = issues[i];
|
|
987
|
+
const { owner, repo, number, last_check_date, known_dupes, upstream } = iss;
|
|
988
|
+
const issueEndpoint = `repos/${owner}/${repo}/issues/${number}`;
|
|
989
|
+
|
|
990
|
+
// Issue data (single call — extract metadata + body + author in Node)
|
|
991
|
+
allTasks.push(() => ghFetch(issueEndpoint));
|
|
992
|
+
taskMap.push({ issueIdx: i, type: 'issue' });
|
|
993
|
+
|
|
994
|
+
// Comments (all of them — filter by date in Node)
|
|
995
|
+
allTasks.push(() => execAsync(`gh api ${issueEndpoint}/comments --paginate`)
|
|
996
|
+
.then(raw => JSON.parse(raw || '[]'))
|
|
997
|
+
.catch(e => ({ _error: e.message })));
|
|
998
|
+
taskMap.push({ issueIdx: i, type: 'comments' });
|
|
999
|
+
|
|
1000
|
+
// Known dupe state checks
|
|
1001
|
+
const dupes = known_dupes || [];
|
|
1002
|
+
for (const dupe of dupes) {
|
|
1003
|
+
const dupeMatch = String(dupe).match(/(?:([^/]+)\/([^#]+))?#?(\d+)/);
|
|
1004
|
+
if (dupeMatch) {
|
|
1005
|
+
const dOwner = dupeMatch[1] || owner;
|
|
1006
|
+
const dRepo = dupeMatch[2] || repo;
|
|
1007
|
+
const dNumber = dupeMatch[3];
|
|
1008
|
+
allTasks.push(() => ghFetch(`repos/${dOwner}/${dRepo}/issues/${dNumber}`));
|
|
1009
|
+
taskMap.push({ issueIdx: i, type: 'dupe', dupeKey: `${dOwner}/${dRepo}#${dNumber}` });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Upstream check
|
|
1014
|
+
if (upstream) {
|
|
1015
|
+
const uMatch = String(upstream).match(/([^/]+)\/([^#]+)#(\d+)/);
|
|
1016
|
+
if (uMatch) {
|
|
1017
|
+
allTasks.push(() => ghFetch(`repos/${uMatch[1]}/${uMatch[2]}/issues/${uMatch[3]}`));
|
|
1018
|
+
taskMap.push({ issueIdx: i, type: 'upstream' });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Run all tasks with batching (max 15 concurrent)
|
|
1024
|
+
const results = await batchRun(allTasks, 15);
|
|
1025
|
+
|
|
1026
|
+
// Organize results by issue — process raw API responses in Node
|
|
1027
|
+
const issueData = issues.map(() => ({
|
|
1028
|
+
metadata: null,
|
|
1029
|
+
body: null,
|
|
1030
|
+
comments: null,
|
|
1031
|
+
dupe_states: {},
|
|
1032
|
+
upstream_state: null,
|
|
1033
|
+
cross_references: [],
|
|
1034
|
+
urls: [],
|
|
1035
|
+
}));
|
|
1036
|
+
|
|
1037
|
+
for (let t = 0; t < results.length; t++) {
|
|
1038
|
+
const { issueIdx, type, dupeKey } = taskMap[t];
|
|
1039
|
+
const raw = results[t];
|
|
1040
|
+
|
|
1041
|
+
switch (type) {
|
|
1042
|
+
case 'issue': {
|
|
1043
|
+
// Extract metadata and body from the single issue response
|
|
1044
|
+
if (raw && !raw._error) {
|
|
1045
|
+
issueData[issueIdx].metadata = {
|
|
1046
|
+
state: raw.state,
|
|
1047
|
+
labels: (raw.labels || []).map(l => l.name),
|
|
1048
|
+
comments: raw.comments,
|
|
1049
|
+
updated: raw.updated_at,
|
|
1050
|
+
created: raw.created_at,
|
|
1051
|
+
html_url: raw.html_url,
|
|
1052
|
+
};
|
|
1053
|
+
issueData[issueIdx].body = {
|
|
1054
|
+
title: raw.title,
|
|
1055
|
+
body: raw.body,
|
|
1056
|
+
author: raw.user ? raw.user.login : null,
|
|
1057
|
+
};
|
|
1058
|
+
} else {
|
|
1059
|
+
issueData[issueIdx].metadata = raw;
|
|
1060
|
+
issueData[issueIdx].body = raw;
|
|
1061
|
+
}
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
case 'comments': {
|
|
1065
|
+
// Filter comments by last_check_date in Node
|
|
1066
|
+
const iss = issues[issueIdx];
|
|
1067
|
+
if (Array.isArray(raw)) {
|
|
1068
|
+
const filtered = iss.last_check_date
|
|
1069
|
+
? raw.filter(c => c.created_at > iss.last_check_date)
|
|
1070
|
+
: raw;
|
|
1071
|
+
issueData[issueIdx].comments = filtered.map(c => ({
|
|
1072
|
+
author: c.user ? c.user.login : null,
|
|
1073
|
+
date: c.created_at ? c.created_at.split('T')[0] : null,
|
|
1074
|
+
body: c.body,
|
|
1075
|
+
}));
|
|
1076
|
+
} else {
|
|
1077
|
+
issueData[issueIdx].comments = raw;
|
|
1078
|
+
}
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
case 'dupe': {
|
|
1082
|
+
if (raw && !raw._error) {
|
|
1083
|
+
issueData[issueIdx].dupe_states[dupeKey] = {
|
|
1084
|
+
state: raw.state,
|
|
1085
|
+
updated: raw.updated_at,
|
|
1086
|
+
};
|
|
1087
|
+
} else {
|
|
1088
|
+
issueData[issueIdx].dupe_states[dupeKey] = raw;
|
|
1089
|
+
}
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
case 'upstream': {
|
|
1093
|
+
if (raw && !raw._error) {
|
|
1094
|
+
issueData[issueIdx].upstream_state = {
|
|
1095
|
+
state: raw.state,
|
|
1096
|
+
labels: (raw.labels || []).map(l => l.name),
|
|
1097
|
+
updated: raw.updated_at,
|
|
1098
|
+
};
|
|
1099
|
+
} else {
|
|
1100
|
+
issueData[issueIdx].upstream_state = raw;
|
|
1101
|
+
}
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Post-process: extract cross_references and urls from body + comments
|
|
1108
|
+
for (let i = 0; i < issues.length; i++) {
|
|
1109
|
+
const data = issueData[i];
|
|
1110
|
+
const allText = collectText(data);
|
|
1111
|
+
|
|
1112
|
+
// Extract #NUMBER and owner/repo#NUMBER patterns
|
|
1113
|
+
const crossRefs = new Set();
|
|
1114
|
+
const refRe = /(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#(\d+)/g;
|
|
1115
|
+
let m;
|
|
1116
|
+
while ((m = refRe.exec(allText)) !== null) {
|
|
1117
|
+
crossRefs.add(m[1] ? `${m[1]}#${m[2]}` : `#${m[2]}`);
|
|
1118
|
+
}
|
|
1119
|
+
data.cross_references = [...crossRefs];
|
|
1120
|
+
|
|
1121
|
+
// Extract URLs
|
|
1122
|
+
const urlSet = new Set();
|
|
1123
|
+
const urlRe = /https?:\/\/[^\s)>\]"']+/g;
|
|
1124
|
+
while ((m = urlRe.exec(allText)) !== null) {
|
|
1125
|
+
urlSet.add(m[0].replace(/[.,;:]+$/, ''));
|
|
1126
|
+
}
|
|
1127
|
+
data.urls = [...urlSet];
|
|
1128
|
+
|
|
1129
|
+
// Write raw JSON per issue
|
|
1130
|
+
const iss = issues[i];
|
|
1131
|
+
const outFile = path.join(tempDir, `raw-${iss.owner}-${iss.repo}-${iss.number}.json`);
|
|
1132
|
+
try {
|
|
1133
|
+
fs.writeFileSync(outFile, JSON.stringify(data, null, 2), 'utf8');
|
|
1134
|
+
files.push(outFile);
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
errors.push(`Failed to write ${outFile}: ${err.message}`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
console.log(JSON.stringify({ today: todayTag(), fetched: files.length, files, errors }));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Collect all text content from issue data for cross-reference/URL extraction.
|
|
1145
|
+
*/
|
|
1146
|
+
function collectText(data) {
|
|
1147
|
+
const parts = [];
|
|
1148
|
+
if (data.body) {
|
|
1149
|
+
if (typeof data.body === 'string') parts.push(data.body);
|
|
1150
|
+
else if (data.body.body) parts.push(data.body.body);
|
|
1151
|
+
}
|
|
1152
|
+
if (data.comments) {
|
|
1153
|
+
if (typeof data.comments === 'string') parts.push(data.comments);
|
|
1154
|
+
else if (Array.isArray(data.comments)) {
|
|
1155
|
+
for (const c of data.comments) {
|
|
1156
|
+
if (typeof c === 'string') parts.push(c);
|
|
1157
|
+
else if (c && c.body) parts.push(c.body);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return parts.join('\n');
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ─── Command: build-tracker ────────────────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
function cmdBuildTracker(flags) {
|
|
1167
|
+
const tempDir = flags['temp-dir'];
|
|
1168
|
+
const templatePath = flags.template;
|
|
1169
|
+
const username = flags.username;
|
|
1170
|
+
const trackerPath = flags.tracker;
|
|
1171
|
+
const closedJsonPath = flags['closed-json'];
|
|
1172
|
+
const date = flags.date || localDate();
|
|
1173
|
+
|
|
1174
|
+
if (!tempDir || !templatePath || !username || !trackerPath) {
|
|
1175
|
+
console.error('Usage: build-tracker --temp-dir <dir> --template <path> --username <name> --tracker <path> [--closed-json <path>]');
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Step 1: Read template
|
|
1180
|
+
if (!fs.existsSync(templatePath)) {
|
|
1181
|
+
console.error(`Template not found: ${templatePath}`);
|
|
1182
|
+
process.exit(1);
|
|
1183
|
+
}
|
|
1184
|
+
let tracker = fs.readFileSync(templatePath, 'utf8');
|
|
1185
|
+
|
|
1186
|
+
// Step 2: Replace USERNAME_HERE
|
|
1187
|
+
tracker = tracker.replace(/USERNAME_HERE/g, username);
|
|
1188
|
+
|
|
1189
|
+
// Step 3: Read all issue-*.md result files from temp dir
|
|
1190
|
+
if (!fs.existsSync(tempDir)) {
|
|
1191
|
+
console.error(`Temp directory not found: ${tempDir}`);
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const resultFiles = fs.readdirSync(tempDir).filter(f =>
|
|
1196
|
+
f.startsWith('issue-') && f.endsWith('.md')
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
const entries = [];
|
|
1200
|
+
for (const file of resultFiles) {
|
|
1201
|
+
const content = fs.readFileSync(path.join(tempDir, file), 'utf8');
|
|
1202
|
+
const { meta, body } = parseFrontmatter(content);
|
|
1203
|
+
if (meta.type !== 'issue') continue;
|
|
1204
|
+
|
|
1205
|
+
// Build tracker entry from result file
|
|
1206
|
+
const entry = buildTrackerEntry(meta, body, date);
|
|
1207
|
+
entries.push(entry);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Step 4: Insert active entries into tracker
|
|
1211
|
+
const activeEntriesText = entries.join('\n');
|
|
1212
|
+
tracker = tracker.replace(
|
|
1213
|
+
/(## Active Issues[^\n]*\n)([\s\S]*?)(## Closed)/,
|
|
1214
|
+
`$1\n${activeEntriesText}\n$3`
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
// Step 5: Handle closed issues if provided
|
|
1218
|
+
let closedCount = 0;
|
|
1219
|
+
if (closedJsonPath && fs.existsSync(closedJsonPath)) {
|
|
1220
|
+
const closedIssues = JSON.parse(fs.readFileSync(closedJsonPath, 'utf8'));
|
|
1221
|
+
closedCount = closedIssues.length;
|
|
1222
|
+
let closedText = '';
|
|
1223
|
+
for (const ci of closedIssues) {
|
|
1224
|
+
closedText += `### ${ci.owner}/${ci.repo}#${ci.number} — ${ci.title}\n`;
|
|
1225
|
+
closedText += `- ${ci.resolution || 'Closed.'}\n\n`;
|
|
1226
|
+
}
|
|
1227
|
+
if (closedText) {
|
|
1228
|
+
tracker = tracker.replace(
|
|
1229
|
+
/(## Closed[^\n]*\n)/,
|
|
1230
|
+
`$1\n${closedText}`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Step 6: Write tracker
|
|
1236
|
+
fs.writeFileSync(trackerPath, tracker, 'utf8');
|
|
1237
|
+
|
|
1238
|
+
console.log(JSON.stringify({
|
|
1239
|
+
today: todayTag(),
|
|
1240
|
+
written: true,
|
|
1241
|
+
path: trackerPath,
|
|
1242
|
+
active_count: entries.length,
|
|
1243
|
+
closed_count: closedCount,
|
|
1244
|
+
}));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Build a tracker entry string from result file frontmatter and body.
|
|
1249
|
+
*/
|
|
1250
|
+
function buildTrackerEntry(meta, body, date) {
|
|
1251
|
+
const lines = [];
|
|
1252
|
+
lines.push(`### ${meta.owner}/${meta.repo}#${meta.number} — ${meta.title}`);
|
|
1253
|
+
|
|
1254
|
+
// Goal — from tracker updates or frontmatter
|
|
1255
|
+
const trackerUpdatesForGoal = extractSection(body, 'Tracker Updates') || '';
|
|
1256
|
+
const goalLine = trackerUpdatesForGoal.match(/^goal:\s*(.+)/m);
|
|
1257
|
+
if (goalLine) {
|
|
1258
|
+
lines.push(`- **Goal:** ${goalLine[1].trim()}`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Role
|
|
1262
|
+
lines.push(`- **Role:** ${meta.role || 'Unknown'}`);
|
|
1263
|
+
|
|
1264
|
+
// Filed
|
|
1265
|
+
if (meta.filed) {
|
|
1266
|
+
lines.push(`- **Filed:** ${meta.filed}`);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Status — from ## Status Summary + labels
|
|
1270
|
+
const statusSummary = extractSection(body, 'Status Summary') || 'Open.';
|
|
1271
|
+
const labels = meta.labels
|
|
1272
|
+
? (Array.isArray(meta.labels) ? meta.labels.join(', ') : meta.labels)
|
|
1273
|
+
: '';
|
|
1274
|
+
const trackerUpdates = extractSection(body, 'Tracker Updates') || '';
|
|
1275
|
+
const statusLine = trackerUpdates.match(/^status_summary:\s*(.+)/m);
|
|
1276
|
+
const statusText = statusLine
|
|
1277
|
+
? statusLine[1].trim()
|
|
1278
|
+
: `${meta.state || 'Open'}. Labels: ${labels}. ${statusSummary.split('\n')[0]}`;
|
|
1279
|
+
const dateStr = date || localDate();
|
|
1280
|
+
lines.push(`- **Status as of ${dateStr}:** ${statusText}`);
|
|
1281
|
+
|
|
1282
|
+
// What to check — from ## Watch For or tracker updates
|
|
1283
|
+
const watchLine = trackerUpdates.match(/^what_to_check:\s*(.+)/m);
|
|
1284
|
+
const watchFor = watchLine
|
|
1285
|
+
? watchLine[1].trim()
|
|
1286
|
+
: (extractSection(body, 'Watch For') || 'Monitor for updates.').split('\n')[0].replace(/^-\s*/, '');
|
|
1287
|
+
lines.push(`- **What to check:** ${watchFor}`);
|
|
1288
|
+
|
|
1289
|
+
// Related — from ## Cross-References
|
|
1290
|
+
const crossRefs = extractSection(body, 'Cross-References');
|
|
1291
|
+
if (crossRefs && crossRefs.trim() !== 'None' && crossRefs.trim() !== 'None found.') {
|
|
1292
|
+
lines.push(`- **Related:** ${crossRefs.split('\n')[0].trim()}`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Upstream
|
|
1296
|
+
const upstream = extractSection(body, 'Upstream');
|
|
1297
|
+
if (upstream && upstream.trim() !== 'N/A' && upstream.trim() !== 'None') {
|
|
1298
|
+
lines.push(`- **Upstream:** ${upstream.split('\n')[0].trim()}`);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Key Context -> workaround / future
|
|
1302
|
+
const keyContext = extractSection(body, 'Key Context');
|
|
1303
|
+
if (keyContext && keyContext.trim() !== 'N/A') {
|
|
1304
|
+
// Check for workaround
|
|
1305
|
+
const workaroundMatch = keyContext.match(/[Ww]orkaround:?\s*(.+)/);
|
|
1306
|
+
if (workaroundMatch) {
|
|
1307
|
+
lines.push(`- **Workaround:** ${workaroundMatch[1].trim()}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Duplicates from ## Duplicates and Related
|
|
1312
|
+
const dupeSection = extractSection(body, 'Duplicates and Related')
|
|
1313
|
+
|| extractSection(body, 'Known');
|
|
1314
|
+
if (dupeSection) {
|
|
1315
|
+
const dupeLines = dupeSection.split('\n').filter(l => l.match(/^\s*-?\s*[#*]/));
|
|
1316
|
+
if (dupeLines.length > 0) {
|
|
1317
|
+
lines.push(`- **Duplicates found (${dateStr}):**`);
|
|
1318
|
+
for (const dl of dupeLines) {
|
|
1319
|
+
const cleaned = dl.replace(/^[\s-]*/, ' - ');
|
|
1320
|
+
lines.push(cleaned);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Next steps
|
|
1326
|
+
const nextSteps = extractSection(body, 'Next Steps');
|
|
1327
|
+
if (nextSteps && nextSteps.trim() !== 'None' && nextSteps.trim() !== 'None — no action needed.') {
|
|
1328
|
+
lines.push(`- **Next steps (now):** ${nextSteps.split('\n')[0].replace(/^-\s*\[.\]\s*/, '').trim()}`);
|
|
1329
|
+
} else {
|
|
1330
|
+
lines.push(`- **Next steps (now):** None — no action needed.`);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Future — from Key Context
|
|
1334
|
+
if (keyContext && keyContext.trim() !== 'N/A') {
|
|
1335
|
+
lines.push(`- **Future:** ${keyContext.split('\n')[0].replace(/^-\s*/, '').trim()}`);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// History — from history_entry lines in Tracker Updates
|
|
1339
|
+
const historyEntries = [];
|
|
1340
|
+
if (trackerUpdates) {
|
|
1341
|
+
const historyRe = /^history_entry:\s*(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)/gm;
|
|
1342
|
+
let hm;
|
|
1343
|
+
while ((hm = historyRe.exec(trackerUpdates)) !== null) {
|
|
1344
|
+
historyEntries.push(` - **${hm[1]}:** ${hm[2].trim()}`);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (historyEntries.length === 0) {
|
|
1348
|
+
// Default entry on initial build
|
|
1349
|
+
historyEntries.push(` - **${dateStr}:** Added to tracker`);
|
|
1350
|
+
}
|
|
1351
|
+
lines.push(`- **History:**`);
|
|
1352
|
+
for (const he of historyEntries) {
|
|
1353
|
+
lines.push(he);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
lines.push('');
|
|
1357
|
+
return lines.join('\n');
|
|
1358
|
+
}
|