@warren-dean/code-kit 0.8.0 → 0.9.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +65 -38
- package/bin/code-kit.mjs +355 -7
- package/hooks/guard-bash.mjs +69 -0
- package/hooks/guard-paths.mjs +19 -0
- package/hooks/lib/adapters/context-graph.mjs +5 -0
- package/hooks/lib/adapters/index.mjs +12 -0
- package/hooks/lib/config.mjs +15 -1
- package/hooks/lib/next.mjs +88 -6
- package/hooks/lib/plan.mjs +3 -1
- package/hooks/lib/storymap.mjs +62 -0
- package/hooks/lib/team.mjs +851 -0
- package/hooks/lib/teamfacts.mjs +187 -0
- package/hooks/mod/codemap.mjs +16 -1
- package/hooks/mod/decisions.mjs +25 -2
- package/hooks/mod/loop.mjs +23 -3
- package/hooks/mod/panes.mjs +99 -50
- package/hooks/mod/register.mjs +70 -1
- package/hooks/mod/register.test.ts +251 -9
- package/hooks/mod/teamview.mjs +243 -0
- package/hooks/mod/view.mjs +10 -8
- package/package.json +1 -1
- package/skills/design/SKILL.md +56 -0
- package/skills/dispatch/SKILL.md +9 -1
- package/skills/init/SKILL.md +19 -6
- package/skills/next/SKILL.md +3 -0
- package/skills/review/SKILL.md +1 -0
- package/skills/spec-design/SKILL.md +9 -3
- package/templates/plan.md +1 -1
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
// Teams (docs/specs/16-teams.md): several people in one repository, each with their own agents. This
|
|
2
|
+
// file holds the team's part of the config (TEAM-1) and who the developer at this machine is.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { matchesAny } from './glob.mjs';
|
|
7
|
+
import {
|
|
8
|
+
isHandle,
|
|
9
|
+
isStoryId,
|
|
10
|
+
discussionActivity,
|
|
11
|
+
intentOf,
|
|
12
|
+
laneOfBranch,
|
|
13
|
+
storyOfBranch,
|
|
14
|
+
touchesOf,
|
|
15
|
+
withStaleness,
|
|
16
|
+
workingDaysBetween,
|
|
17
|
+
BOARD_FIELDS,
|
|
18
|
+
boardChanges,
|
|
19
|
+
boardValues,
|
|
20
|
+
} from './teamfacts.mjs';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
isHandle,
|
|
24
|
+
isStoryId,
|
|
25
|
+
discussionActivity,
|
|
26
|
+
intentOf,
|
|
27
|
+
laneOfBranch,
|
|
28
|
+
storyOfBranch,
|
|
29
|
+
touchesOf,
|
|
30
|
+
withStaleness,
|
|
31
|
+
workingDaysBetween,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
|
|
36
|
+
/** What's wrong with `team` and the lanes' `owners`, as the config's other problems are worded. */
|
|
37
|
+
export function teamProblems(c) {
|
|
38
|
+
const p = [];
|
|
39
|
+
const t = c.team;
|
|
40
|
+
if (t !== undefined) {
|
|
41
|
+
if (!isObj(t)) return ['"team" must be an object'];
|
|
42
|
+
if (!isHandle(t.techLead)) p.push('"team.techLead" must be the tech lead\'s GitHub handle');
|
|
43
|
+
if (t.staleDays !== undefined && !(Number.isInteger(t.staleDays) && t.staleDays >= 1))
|
|
44
|
+
p.push('"team.staleDays" must be a whole number of working days, 1 or more');
|
|
45
|
+
if (
|
|
46
|
+
t.board !== undefined &&
|
|
47
|
+
!(
|
|
48
|
+
isObj(t.board) &&
|
|
49
|
+
typeof t.board.owner === 'string' &&
|
|
50
|
+
t.board.owner.trim() &&
|
|
51
|
+
Number.isInteger(t.board.number) &&
|
|
52
|
+
t.board.number > 0
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
p.push(
|
|
56
|
+
'"team.board" must be { "owner": "<user or organisation>", "number": <the Project\'s number> }',
|
|
57
|
+
);
|
|
58
|
+
if (t.intents !== undefined && !(typeof t.intents === 'string' && t.intents.trim()))
|
|
59
|
+
p.push('"team.intents" must name the discussion category intents go in');
|
|
60
|
+
if (
|
|
61
|
+
t.provider !== undefined &&
|
|
62
|
+
t.provider !== 'github' &&
|
|
63
|
+
!(isObj(t.provider) && typeof t.provider.command === 'string' && t.provider.command.trim())
|
|
64
|
+
)
|
|
65
|
+
p.push('"team.provider" must be "github" or { "command": "<the provider\'s command>" }');
|
|
66
|
+
}
|
|
67
|
+
for (const [name, lane] of Object.entries(isObj(c.lanes) ? c.lanes : {})) {
|
|
68
|
+
if (!isObj(lane) || lane.owners === undefined) continue;
|
|
69
|
+
if (!(Array.isArray(lane.owners) && lane.owners.length && lane.owners.every(isHandle)))
|
|
70
|
+
p.push(`lanes.${name}.owners must list GitHub handles`);
|
|
71
|
+
else if (t === undefined) p.push(`lanes.${name}.owners needs "team", with its tech lead`);
|
|
72
|
+
}
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The team, with its defaults, or null for a project that isn't a team's: { techLead, staleDays,
|
|
78
|
+
* provider: 'github' | { command }, owners: { lane: [handles] } }.
|
|
79
|
+
*/
|
|
80
|
+
export function teamOf(raw) {
|
|
81
|
+
if (!isObj(raw?.team)) return null;
|
|
82
|
+
return {
|
|
83
|
+
techLead: raw.team.techLead,
|
|
84
|
+
staleDays: raw.team.staleDays ?? 3,
|
|
85
|
+
intents: raw.team.intents ?? 'Intents',
|
|
86
|
+
board: raw.team.board ?? null,
|
|
87
|
+
provider: raw.team.provider ?? 'github',
|
|
88
|
+
owners: Object.fromEntries(
|
|
89
|
+
Object.entries(raw.lanes ?? {})
|
|
90
|
+
.filter(([, l]) => Array.isArray(l?.owners))
|
|
91
|
+
.map(([name, l]) => [name, l.owners]),
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const WHOAMI = '.claude/state/whoami.json';
|
|
97
|
+
const WHOAMI_HOURS = 12;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The developer at this machine, by their GitHub handle (`gh api user`), cached in the main checkout
|
|
101
|
+
* for 12 hours so a hook doesn't ask GitHub on every call. Null when gh isn't installed or signed in.
|
|
102
|
+
*/
|
|
103
|
+
export function whoami(root, now = Date.now()) {
|
|
104
|
+
const file = join(root, WHOAMI);
|
|
105
|
+
try {
|
|
106
|
+
const cached = JSON.parse(readFileSync(file, 'utf8'));
|
|
107
|
+
if (isHandle(cached.login) && now - Date.parse(cached.at) < WHOAMI_HOURS * 3600_000)
|
|
108
|
+
return cached.login;
|
|
109
|
+
} catch {
|
|
110
|
+
// not cached yet, or unreadable: ask GitHub
|
|
111
|
+
}
|
|
112
|
+
const ran = spawnSync('gh', ['api', 'user', '-q', '.login'], {
|
|
113
|
+
encoding: 'utf8',
|
|
114
|
+
timeout: 10_000,
|
|
115
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
116
|
+
});
|
|
117
|
+
const login = ran.status === 0 ? ran.stdout.trim() : '';
|
|
118
|
+
if (!isHandle(login)) return null;
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
121
|
+
writeFileSync(file, JSON.stringify({ login, at: new Date(now).toISOString() }) + '\n');
|
|
122
|
+
} catch {
|
|
123
|
+
// a read-only checkout: ask again next time
|
|
124
|
+
}
|
|
125
|
+
return login;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- the provider (TEAM-2) ----------------------------------------------------------------------------
|
|
129
|
+
//
|
|
130
|
+
// The core talks to the team through one interface: whoami, claim(story, lease), release(story),
|
|
131
|
+
// state() and intend(about, touches). The GitHub provider is built in; any other is a command named in
|
|
132
|
+
// the config, called with the operation's name and its input as JSON on stdin, answering JSON.
|
|
133
|
+
|
|
134
|
+
const CLAIMS = 'refs/code-kit/claims';
|
|
135
|
+
const SEEN = 'refs/code-kit/seen-claims';
|
|
136
|
+
const TEAM_STATE = '.claude/state/team.json';
|
|
137
|
+
const STATE_SECONDS = 60;
|
|
138
|
+
|
|
139
|
+
/** A claim's ref message: the claim as JSON. Unreadable ones are left out. */
|
|
140
|
+
function claimOf(message) {
|
|
141
|
+
try {
|
|
142
|
+
const c = JSON.parse(message);
|
|
143
|
+
return isStoryId(c.story) && isHandle(c.by)
|
|
144
|
+
? { story: c.story, by: c.by, at: c.at, lease: Array.isArray(c.lease) ? c.lease : [] }
|
|
145
|
+
: null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The provider for this project's team: GitHub's, or the config's command. */
|
|
152
|
+
export function teamProvider(root, team) {
|
|
153
|
+
return team.provider === 'github'
|
|
154
|
+
? githubProvider(root, team)
|
|
155
|
+
: commandProvider(root, team, team.provider.command);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function githubProvider(root, team) {
|
|
159
|
+
const run = (cmd, args, opts = {}) =>
|
|
160
|
+
spawnSync(cmd, args, {
|
|
161
|
+
cwd: root,
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
timeout: 30_000,
|
|
164
|
+
stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
165
|
+
...opts,
|
|
166
|
+
});
|
|
167
|
+
const git = (args, opts) => run('git', args, opts);
|
|
168
|
+
const ok = (r) => r.status === 0;
|
|
169
|
+
const gh = (args, input) => {
|
|
170
|
+
const r = run('gh', args, input === undefined ? {} : { input });
|
|
171
|
+
return ok(r) ? r.stdout : null;
|
|
172
|
+
};
|
|
173
|
+
/** A GraphQL call through gh; `repo` adds the repository as $owner and $name. Null on failure. */
|
|
174
|
+
const graphql = (query, vars = {}, repo = false) => {
|
|
175
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
176
|
+
if (repo) args.push('-F', 'owner={owner}', '-F', 'name={repo}');
|
|
177
|
+
for (const [k, v] of Object.entries(vars))
|
|
178
|
+
args.push(typeof v === 'number' ? '-F' : '-f', `${k}=${v}`);
|
|
179
|
+
const out = gh(args);
|
|
180
|
+
try {
|
|
181
|
+
return out === null ? null : (JSON.parse(out).data ?? null);
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/** The repository's discussions, the Intents category among its categories: null when unreadable. */
|
|
187
|
+
const discussions = () => {
|
|
188
|
+
const d = graphql(
|
|
189
|
+
`
|
|
190
|
+
query ($owner: String!, $name: String!) {
|
|
191
|
+
repository(owner: $owner, name: $name) {
|
|
192
|
+
id
|
|
193
|
+
hasDiscussionsEnabled
|
|
194
|
+
discussionCategories(first: 50) {
|
|
195
|
+
nodes {
|
|
196
|
+
id
|
|
197
|
+
name
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
discussions(first: 50, orderBy: { field: UPDATED_AT, direction: DESC }) {
|
|
201
|
+
nodes {
|
|
202
|
+
id
|
|
203
|
+
number
|
|
204
|
+
title
|
|
205
|
+
body
|
|
206
|
+
url
|
|
207
|
+
createdAt
|
|
208
|
+
closed
|
|
209
|
+
closedAt
|
|
210
|
+
stateReason
|
|
211
|
+
author {
|
|
212
|
+
login
|
|
213
|
+
}
|
|
214
|
+
category {
|
|
215
|
+
name
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
`,
|
|
222
|
+
{},
|
|
223
|
+
true,
|
|
224
|
+
);
|
|
225
|
+
const repo = d?.repository;
|
|
226
|
+
if (!repo) return null;
|
|
227
|
+
const category =
|
|
228
|
+
repo.discussionCategories.nodes.find(
|
|
229
|
+
(c) => c.name.toLowerCase() === team.intents.toLowerCase(),
|
|
230
|
+
) ?? null;
|
|
231
|
+
return {
|
|
232
|
+
repoId: repo.id,
|
|
233
|
+
enabled: repo.hasDiscussionsEnabled,
|
|
234
|
+
category,
|
|
235
|
+
intents: repo.discussions.nodes.filter((n) => n.category?.name === category?.name),
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
const noCategory = (found) =>
|
|
239
|
+
found.enabled
|
|
240
|
+
? `This repository has no "${team.intents}" discussion category, and GitHub doesn't let a tool create one. Add it once in the repository's Settings › Discussions (an "Open-ended discussion" category named ${team.intents}), then raise the intent again.`
|
|
241
|
+
: `Discussions are off for this repository. Turn them on in its Settings › Features, add a "${team.intents}" category, then raise the intent again.`;
|
|
242
|
+
/** Fetch every claim on the shared repository, or null when it can't be reached. */
|
|
243
|
+
const fetchClaims = () => {
|
|
244
|
+
const fetched = git(['fetch', '--prune', '--quiet', 'origin', `+${CLAIMS}/*:${SEEN}/*`]);
|
|
245
|
+
if (!ok(fetched)) return null;
|
|
246
|
+
const listed = git([
|
|
247
|
+
'for-each-ref',
|
|
248
|
+
'--format=%(objectname)%00%(contents)%1e',
|
|
249
|
+
`${SEEN}/`,
|
|
250
|
+
]).stdout;
|
|
251
|
+
return listed
|
|
252
|
+
.split('\x1e')
|
|
253
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.map((r) => {
|
|
256
|
+
const [sha, message] = r.split('\0');
|
|
257
|
+
const claim = claimOf(message.trim());
|
|
258
|
+
return claim ? { ...claim, sha } : null;
|
|
259
|
+
})
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
};
|
|
262
|
+
const unreachable = (why) => ({ ok: false, unreachable: true, why });
|
|
263
|
+
const notReached = () =>
|
|
264
|
+
unreachable("The shared repository (origin) can't be reached, so claims can't be checked.");
|
|
265
|
+
/** Push `sha` to a claim's ref only if the ref is still `expect` ('' for not there yet). */
|
|
266
|
+
const pushClaim = (story, sha, expect) =>
|
|
267
|
+
git([
|
|
268
|
+
'push',
|
|
269
|
+
'--quiet',
|
|
270
|
+
`--force-with-lease=${CLAIMS}/${story}:${expect}`,
|
|
271
|
+
'origin',
|
|
272
|
+
sha ? `${sha}:${CLAIMS}/${story}` : `:${CLAIMS}/${story}`,
|
|
273
|
+
]);
|
|
274
|
+
const me = () => whoami(root);
|
|
275
|
+
|
|
276
|
+
const provider = {
|
|
277
|
+
whoami: () => ({ login: me() }),
|
|
278
|
+
claim(story, lease = []) {
|
|
279
|
+
if (!isStoryId(story)) return { ok: false, why: `"${story}" isn't a story id.` };
|
|
280
|
+
const by = me();
|
|
281
|
+
if (!by) return { ok: false, why: 'Sign in to GitHub first (gh auth login).' };
|
|
282
|
+
const claims = fetchClaims();
|
|
283
|
+
if (!claims) return notReached();
|
|
284
|
+
const held = claims.find((c) => c.story === story);
|
|
285
|
+
if (held && held.by !== by) return { ok: false, holder: held };
|
|
286
|
+
const at = new Date().toISOString();
|
|
287
|
+
const email = `${by}@users.noreply.github.com`;
|
|
288
|
+
const env = {
|
|
289
|
+
...process.env,
|
|
290
|
+
GIT_AUTHOR_NAME: by,
|
|
291
|
+
GIT_AUTHOR_EMAIL: email,
|
|
292
|
+
GIT_COMMITTER_NAME: by,
|
|
293
|
+
GIT_COMMITTER_EMAIL: email,
|
|
294
|
+
};
|
|
295
|
+
const tree = git(['mktree'], { input: '' }).stdout.trim();
|
|
296
|
+
// A lease only grows while the story is open: a claim again adds to it.
|
|
297
|
+
lease = [...new Set([...(held?.lease ?? []), ...lease])];
|
|
298
|
+
const message = JSON.stringify({ story, by, at, lease });
|
|
299
|
+
const sha = git(['commit-tree', tree, '-m', message], { env }).stdout.trim();
|
|
300
|
+
const pushed = pushClaim(story, sha, held?.sha ?? '');
|
|
301
|
+
if (!ok(pushed)) {
|
|
302
|
+
// Someone claimed it between our fetch and push: say who.
|
|
303
|
+
const now = fetchClaims();
|
|
304
|
+
const holder = now?.find((c) => c.story === story);
|
|
305
|
+
return holder && holder.by !== by ? { ok: false, holder } : notReached();
|
|
306
|
+
}
|
|
307
|
+
provider.forget();
|
|
308
|
+
return { ok: true, claim: { story, by, at, lease } };
|
|
309
|
+
},
|
|
310
|
+
/** `merged`: the story merged, so its claim ends whoever releases it. */
|
|
311
|
+
release(story, { merged = false } = {}) {
|
|
312
|
+
const by = me();
|
|
313
|
+
const claims = fetchClaims();
|
|
314
|
+
if (!claims) return notReached();
|
|
315
|
+
const held = claims.find((c) => c.story === story);
|
|
316
|
+
if (!held) return { ok: true, released: false };
|
|
317
|
+
if (!merged && held.by !== by && by !== team.techLead)
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
holder: held,
|
|
321
|
+
why: `${story} is ${held.by}'s claim: only they or the tech lead (${team.techLead}) can release it.`,
|
|
322
|
+
};
|
|
323
|
+
if (!ok(pushClaim(story, '', held.sha))) return notReached();
|
|
324
|
+
provider.forget();
|
|
325
|
+
return { ok: true, released: true, claim: held };
|
|
326
|
+
},
|
|
327
|
+
state({ fresh = false } = {}) {
|
|
328
|
+
const cached = readCache(root);
|
|
329
|
+
if (!fresh && cached && Date.now() - Date.parse(cached.at) < STATE_SECONDS * 1000)
|
|
330
|
+
return cached;
|
|
331
|
+
const claims = fetchClaims();
|
|
332
|
+
// TEAM-9: when each story's branch was last pushed, from the shared repository's branches.
|
|
333
|
+
const pushed = new Map();
|
|
334
|
+
if (claims && ok(git(['fetch', '--prune', '--quiet', 'origin']))) {
|
|
335
|
+
const refs = git([
|
|
336
|
+
'for-each-ref',
|
|
337
|
+
'--format=%(refname:lstrip=3) %(committerdate:unix)',
|
|
338
|
+
'refs/remotes/origin/',
|
|
339
|
+
]).stdout;
|
|
340
|
+
for (const line of refs.split('\n').filter(Boolean)) {
|
|
341
|
+
const [branch, unix] = line.split(' ');
|
|
342
|
+
const story = storyOfBranch(branch);
|
|
343
|
+
if (story) pushed.set(story, Math.max(pushed.get(story) ?? 0, Number(unix) * 1000));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const found = discussions();
|
|
347
|
+
const intents = found ? found.intents.filter((n) => !n.closed) : null;
|
|
348
|
+
const pulls = JSON.parse(
|
|
349
|
+
gh([
|
|
350
|
+
'pr',
|
|
351
|
+
'list',
|
|
352
|
+
'--state',
|
|
353
|
+
'open',
|
|
354
|
+
'--json',
|
|
355
|
+
'number,title,author,headRefName,isDraft,updatedAt,url',
|
|
356
|
+
]) ?? 'null',
|
|
357
|
+
);
|
|
358
|
+
const state = {
|
|
359
|
+
at: new Date().toISOString(),
|
|
360
|
+
reachable: Boolean(claims),
|
|
361
|
+
github: Boolean(intents && pulls),
|
|
362
|
+
me: me(),
|
|
363
|
+
techLead: team.techLead,
|
|
364
|
+
owners: team.owners,
|
|
365
|
+
staleDays: team.staleDays,
|
|
366
|
+
claims: claims
|
|
367
|
+
? claims.map(({ sha, ...c }) => withStaleness(c, pushed.get(c.story), team.staleDays))
|
|
368
|
+
: (cached?.claims ?? []),
|
|
369
|
+
intents: intents ? intents.map(intentOf) : (cached?.intents ?? []),
|
|
370
|
+
pulls: pulls
|
|
371
|
+
? pulls.map((p) => ({
|
|
372
|
+
number: p.number,
|
|
373
|
+
title: p.title,
|
|
374
|
+
by: p.author?.login ?? null,
|
|
375
|
+
branch: p.headRefName,
|
|
376
|
+
draft: p.isDraft,
|
|
377
|
+
updated: p.updatedAt,
|
|
378
|
+
url: p.url,
|
|
379
|
+
}))
|
|
380
|
+
: (cached?.pulls ?? []),
|
|
381
|
+
};
|
|
382
|
+
writeCache(root, state);
|
|
383
|
+
return state;
|
|
384
|
+
},
|
|
385
|
+
intend(about, touches = []) {
|
|
386
|
+
const by = me();
|
|
387
|
+
if (!by) return { ok: false, why: 'Sign in to GitHub first (gh auth login).' };
|
|
388
|
+
const found = discussions();
|
|
389
|
+
if (!found) return unreachable("GitHub can't be reached, so the intent wasn't raised.");
|
|
390
|
+
if (!found.category) return { ok: false, why: noCategory(found) };
|
|
391
|
+
const body = `${touches.length ? `Touches: ${touches.join(', ')}\n\n` : ''}Raised with code-kit by @${by} before any code, so the team sees it first. The tech lead agrees, drops or marks it a duplicate here.`;
|
|
392
|
+
const made = graphql(
|
|
393
|
+
`
|
|
394
|
+
mutation ($repo: ID!, $category: ID!, $title: String!, $body: String!) {
|
|
395
|
+
createDiscussion(
|
|
396
|
+
input: { repositoryId: $repo, categoryId: $category, title: $title, body: $body }
|
|
397
|
+
) {
|
|
398
|
+
discussion {
|
|
399
|
+
number
|
|
400
|
+
url
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
`,
|
|
405
|
+
{ repo: found.repoId, category: found.category.id, title: about, body },
|
|
406
|
+
);
|
|
407
|
+
const d = made?.createDiscussion?.discussion;
|
|
408
|
+
if (!d) return unreachable("GitHub refused the discussion, so the intent wasn't raised.");
|
|
409
|
+
provider.forget();
|
|
410
|
+
return { ok: true, intent: { id: d.number, by, about, touches, url: d.url, state: 'open' } };
|
|
411
|
+
},
|
|
412
|
+
/** TEAM-9: the tech lead hands a claim, with its lease, to someone else. */
|
|
413
|
+
reassign(story, to) {
|
|
414
|
+
const by = me();
|
|
415
|
+
if (by !== team.techLead)
|
|
416
|
+
return { ok: false, why: `Only the tech lead (${team.techLead}) reassigns a claim.` };
|
|
417
|
+
if (!isHandle(to)) return { ok: false, why: `"${to}" isn't a GitHub handle.` };
|
|
418
|
+
const claims = fetchClaims();
|
|
419
|
+
if (!claims) return notReached();
|
|
420
|
+
const held = claims.find((c) => c.story === story);
|
|
421
|
+
if (!held) return { ok: false, why: `${story} isn't claimed.` };
|
|
422
|
+
const at = new Date().toISOString();
|
|
423
|
+
const email = `${by}@users.noreply.github.com`;
|
|
424
|
+
const env = {
|
|
425
|
+
...process.env,
|
|
426
|
+
GIT_AUTHOR_NAME: by,
|
|
427
|
+
GIT_AUTHOR_EMAIL: email,
|
|
428
|
+
GIT_COMMITTER_NAME: by,
|
|
429
|
+
GIT_COMMITTER_EMAIL: email,
|
|
430
|
+
};
|
|
431
|
+
const tree = git(['mktree'], { input: '' }).stdout.trim();
|
|
432
|
+
const message = JSON.stringify({ story, by: to, at, lease: held.lease });
|
|
433
|
+
const sha = git(['commit-tree', tree, '-m', message], { env }).stdout.trim();
|
|
434
|
+
if (!ok(pushClaim(story, sha, held.sha))) return notReached();
|
|
435
|
+
provider.forget();
|
|
436
|
+
return { ok: true, from: held.by, to };
|
|
437
|
+
},
|
|
438
|
+
/**
|
|
439
|
+
* TEAM-6: what happened since `at` (ms), from the repository's activity on GitHub: [{ at, by, kind,
|
|
440
|
+
* text }], oldest first. Kinds: pushed, branched, opened, merged, closed, reviewed, intended,
|
|
441
|
+
* agreed, dropped. Null when GitHub can't be read.
|
|
442
|
+
*/
|
|
443
|
+
since(at) {
|
|
444
|
+
const raw = gh(['api', 'repos/{owner}/{repo}/events?per_page=100']);
|
|
445
|
+
if (raw === null) return null;
|
|
446
|
+
let events;
|
|
447
|
+
try {
|
|
448
|
+
events = JSON.parse(raw);
|
|
449
|
+
} catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const found = discussions();
|
|
453
|
+
return [
|
|
454
|
+
...activityOf(events, at),
|
|
455
|
+
...discussionActivity(found?.intents ?? [], at, team.techLead),
|
|
456
|
+
].sort((a, b) => a.at - b.at);
|
|
457
|
+
},
|
|
458
|
+
/** TEAM-10: one intent with its comments, for its decision record; null when unreadable. */
|
|
459
|
+
intent(id) {
|
|
460
|
+
const d = graphql(
|
|
461
|
+
`
|
|
462
|
+
query ($owner: String!, $name: String!, $number: Int!) {
|
|
463
|
+
repository(owner: $owner, name: $name) {
|
|
464
|
+
discussion(number: $number) {
|
|
465
|
+
number
|
|
466
|
+
title
|
|
467
|
+
body
|
|
468
|
+
url
|
|
469
|
+
createdAt
|
|
470
|
+
closed
|
|
471
|
+
closedAt
|
|
472
|
+
stateReason
|
|
473
|
+
author {
|
|
474
|
+
login
|
|
475
|
+
}
|
|
476
|
+
category {
|
|
477
|
+
name
|
|
478
|
+
}
|
|
479
|
+
comments(last: 20) {
|
|
480
|
+
nodes {
|
|
481
|
+
author {
|
|
482
|
+
login
|
|
483
|
+
}
|
|
484
|
+
body
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
`,
|
|
491
|
+
{ number: Number(id) },
|
|
492
|
+
true,
|
|
493
|
+
);
|
|
494
|
+
if (!d) return null;
|
|
495
|
+
const found = d.repository?.discussion;
|
|
496
|
+
if (!found || found.category?.name?.toLowerCase() !== team.intents.toLowerCase())
|
|
497
|
+
return { missing: true };
|
|
498
|
+
return { ...found, comments: found.comments?.nodes ?? found.comments ?? [] };
|
|
499
|
+
},
|
|
500
|
+
/** TEAM-5: the tech lead agrees an intent (resolved), drops it (outdated) or marks it a duplicate. */
|
|
501
|
+
settle(id, outcome, why = '') {
|
|
502
|
+
const by = me();
|
|
503
|
+
if (by !== team.techLead)
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
why: `Only the tech lead (${team.techLead}) agrees or drops an intent.`,
|
|
507
|
+
};
|
|
508
|
+
const d = graphql(
|
|
509
|
+
`
|
|
510
|
+
query ($owner: String!, $name: String!, $number: Int!) {
|
|
511
|
+
repository(owner: $owner, name: $name) {
|
|
512
|
+
discussion(number: $number) {
|
|
513
|
+
id
|
|
514
|
+
closed
|
|
515
|
+
category {
|
|
516
|
+
name
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
`,
|
|
522
|
+
{ number: Number(id) },
|
|
523
|
+
true,
|
|
524
|
+
);
|
|
525
|
+
if (!d) return unreachable("GitHub can't be reached, so the intent wasn't settled.");
|
|
526
|
+
const found = d.repository?.discussion;
|
|
527
|
+
if (!found || found.category?.name?.toLowerCase() !== team.intents.toLowerCase())
|
|
528
|
+
return { ok: false, why: `There's no intent #${id} in ${team.intents}.` };
|
|
529
|
+
if (found.closed) return { ok: false, why: `Intent #${id} is already settled.` };
|
|
530
|
+
const words = { agreed: 'Agreed', dropped: 'Dropped', duplicate: 'Marked a duplicate' }[
|
|
531
|
+
outcome
|
|
532
|
+
];
|
|
533
|
+
const said = `${words} by @${by}, the tech lead${why ? `: ${why}` : '.'}`;
|
|
534
|
+
const reason = { agreed: 'RESOLVED', dropped: 'OUTDATED', duplicate: 'DUPLICATE' }[outcome];
|
|
535
|
+
const commented = graphql(
|
|
536
|
+
`
|
|
537
|
+
mutation ($id: ID!, $body: String!) {
|
|
538
|
+
addDiscussionComment(input: { discussionId: $id, body: $body }) {
|
|
539
|
+
comment {
|
|
540
|
+
id
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
`,
|
|
545
|
+
{ id: found.id, body: said },
|
|
546
|
+
);
|
|
547
|
+
const closed = graphql(
|
|
548
|
+
`
|
|
549
|
+
mutation ($id: ID!, $reason: DiscussionCloseReason!) {
|
|
550
|
+
closeDiscussion(input: { discussionId: $id, reason: $reason }) {
|
|
551
|
+
discussion {
|
|
552
|
+
number
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
`,
|
|
557
|
+
{ id: found.id, reason },
|
|
558
|
+
);
|
|
559
|
+
if (!commented || !closed)
|
|
560
|
+
return unreachable("GitHub can't be reached, so the intent wasn't settled.");
|
|
561
|
+
provider.forget();
|
|
562
|
+
return { ok: true, id: Number(id), outcome };
|
|
563
|
+
},
|
|
564
|
+
forget: () => writeCache(root, null),
|
|
565
|
+
};
|
|
566
|
+
return provider;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function commandProvider(root, team, command) {
|
|
570
|
+
const call = (op, input = {}) => {
|
|
571
|
+
const r = spawnSync('sh', ['-c', `${command} ${op}`], {
|
|
572
|
+
cwd: root,
|
|
573
|
+
encoding: 'utf8',
|
|
574
|
+
timeout: 30_000,
|
|
575
|
+
input: JSON.stringify({ ...input, team }),
|
|
576
|
+
});
|
|
577
|
+
if (r.status !== 0)
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
unreachable: true,
|
|
581
|
+
why: `The team provider (${command}) failed: ${(r.stderr || r.stdout || '').trim().split('\n')[0] || `exit ${r.status}`}`,
|
|
582
|
+
};
|
|
583
|
+
try {
|
|
584
|
+
return JSON.parse(r.stdout);
|
|
585
|
+
} catch {
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
unreachable: true,
|
|
589
|
+
why: `The team provider (${command}) didn't answer JSON.`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
whoami: () => call('whoami'),
|
|
595
|
+
claim: (story, lease = []) => call('claim', { story, lease }),
|
|
596
|
+
release: (story, { merged = false } = {}) => call('release', { story, merged }),
|
|
597
|
+
state: ({ fresh = false } = {}) => {
|
|
598
|
+
const s = call('state', { fresh });
|
|
599
|
+
return s.unreachable
|
|
600
|
+
? { ...(readCache(root) ?? {}), reachable: false, why: s.why }
|
|
601
|
+
: (writeCache(root, s), s);
|
|
602
|
+
},
|
|
603
|
+
intend: (about, touches = []) => call('intend', { about, touches }),
|
|
604
|
+
settle: (id, outcome, why = '') => call('settle', { id, outcome, why }),
|
|
605
|
+
reassign: (story, to) => call('reassign', { story, to }),
|
|
606
|
+
intent: (id) => {
|
|
607
|
+
const r = call('intent', { id });
|
|
608
|
+
return r.unreachable ? null : r;
|
|
609
|
+
},
|
|
610
|
+
since: (at) => {
|
|
611
|
+
const r = call('since', { at });
|
|
612
|
+
return Array.isArray(r) ? r : null;
|
|
613
|
+
},
|
|
614
|
+
forget: () => writeCache(root, null),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The team's state as last read, for the hooks and the mod between polls. */
|
|
619
|
+
export function readCache(root) {
|
|
620
|
+
try {
|
|
621
|
+
const s = JSON.parse(readFileSync(join(root, TEAM_STATE), 'utf8'));
|
|
622
|
+
return s && typeof s.at === 'string' ? s : null;
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function writeCache(root, state) {
|
|
629
|
+
try {
|
|
630
|
+
mkdirSync(join(root, '.claude/state'), { recursive: true });
|
|
631
|
+
writeFileSync(join(root, TEAM_STATE), JSON.stringify(state, null, 2) + '\n');
|
|
632
|
+
} catch {
|
|
633
|
+
// a read-only checkout: read fresh next time
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// --- the rules (TEAM-1, TEAM-4) -----------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
/** The lane that owns a file, by its paths. */
|
|
640
|
+
const laneOf = (rel, config) =>
|
|
641
|
+
Object.keys(config.lanes).find((name) => {
|
|
642
|
+
const { paths, exclude = [] } = config.lanes[name];
|
|
643
|
+
return matchesAny(rel, paths) && !matchesAny(rel, exclude);
|
|
644
|
+
}) ?? null;
|
|
645
|
+
|
|
646
|
+
/** The project's own lead paths: the plan, specs and contracts, without the kit's `.claude/**`. */
|
|
647
|
+
const techLeadPaths = (config) => config.lead.paths.filter((p) => p !== '.claude/**' && p !== '*');
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Why this person's agent may not write `rel`, as a team, or null. `actor` is the hooks' actor, `branch`
|
|
651
|
+
* the checkout's branch, `me` the developer's handle, `state` the provider's last-read state. Only what
|
|
652
|
+
* the config says is checked when the shared repository can't be reached (TEAM-8).
|
|
653
|
+
*/
|
|
654
|
+
export function teamRefusal({ actor, rel, branch, config, me, state }) {
|
|
655
|
+
const team = teamOf(config);
|
|
656
|
+
if (!team || !me) return null;
|
|
657
|
+
const intend = `code-kit intend "<what you'd change, and why>" --touches ${rel}`;
|
|
658
|
+
if (actor.kind === 'lead' && me !== team.techLead && matchesAny(rel, techLeadPaths(config)))
|
|
659
|
+
return `${rel} is the tech lead's (${team.techLead}): the plan, specs and contracts change only in their sessions, so the team agrees a change before code is built on it. Raise it as an intent instead: ${intend}`;
|
|
660
|
+
const owners = actor.kind === 'lane' ? team.owners[actor.lane] : null;
|
|
661
|
+
if (owners && !owners.includes(me))
|
|
662
|
+
return `The ${actor.lane} lane is ${owners.join(' and ')}'s, so agents in your session don't work it. Leave this story for them, or ask the tech lead (${team.techLead}) to change the lane's owners.`;
|
|
663
|
+
if (!state || state.reachable === false) return null;
|
|
664
|
+
const story = storyOfBranch(branch);
|
|
665
|
+
const theirs = state.claims.find((c) => c.story === story && c.by !== me);
|
|
666
|
+
const staleNote = (c) =>
|
|
667
|
+
c.stale
|
|
668
|
+
? ` It's stale: nothing on it since ${String(c.active).slice(0, 10)}, so the tech lead may reassign it.`
|
|
669
|
+
: '';
|
|
670
|
+
if (theirs)
|
|
671
|
+
return `${story} is ${theirs.by}'s claim, so this branch is their work. Ask ${theirs.by} or the tech lead (${team.techLead}) to release it before you work on it.${staleNote(theirs)}`;
|
|
672
|
+
const fileLane = laneOf(rel, config);
|
|
673
|
+
if (fileLane && team.owners[fileLane]?.includes(me)) return null;
|
|
674
|
+
const leased = state.claims.find(
|
|
675
|
+
(c) => c.by !== me && c.story !== story && c.lease.length && matchesAny(rel, c.lease),
|
|
676
|
+
);
|
|
677
|
+
if (leased)
|
|
678
|
+
return `${rel} is leased by ${leased.by} for ${leased.story}, which is changing it now. Ask ${leased.by}, wait for ${leased.story} to merge, or raise what you need as an intent: ${intend}${staleNote(leased)}`;
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* TEAM-6: GitHub's repository events since `at` (ms) as plain sentences: [{ at, by, kind, text }],
|
|
684
|
+
* oldest first. Events code-kit doesn't speak about (stars, forks, wiki edits) are left out.
|
|
685
|
+
*/
|
|
686
|
+
export function activityOf(events, at) {
|
|
687
|
+
const out = [];
|
|
688
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
689
|
+
const when = Date.parse(e.created_at);
|
|
690
|
+
if (!(when > at)) continue;
|
|
691
|
+
const by = e.actor?.login ?? 'someone';
|
|
692
|
+
const p = e.payload ?? {};
|
|
693
|
+
const add = (kind, text) => out.push({ at: when, by, kind, text });
|
|
694
|
+
if (e.type === 'PushEvent' && String(p.ref).startsWith('refs/heads/')) {
|
|
695
|
+
const n = p.size ?? p.commits?.length ?? 0;
|
|
696
|
+
add('pushed', `pushed ${n} commit${n === 1 ? '' : 's'} to ${p.ref.slice(11)}`);
|
|
697
|
+
} else if (e.type === 'CreateEvent' && p.ref_type === 'branch')
|
|
698
|
+
add('branched', `started ${p.ref}`);
|
|
699
|
+
else if (e.type === 'PullRequestEvent') {
|
|
700
|
+
const pr = p.pull_request ?? {};
|
|
701
|
+
const name = `#${pr.number} ${pr.title}`;
|
|
702
|
+
if (p.action === 'opened' || p.action === 'reopened')
|
|
703
|
+
add('opened', `opened ${pr.draft ? 'draft ' : ''}pull request ${name}`);
|
|
704
|
+
else if (p.action === 'closed' && pr.merged) add('merged', `merged ${name}`);
|
|
705
|
+
else if (p.action === 'closed') add('closed', `closed ${name} without merging`);
|
|
706
|
+
else if (p.action === 'ready_for_review') add('opened', `marked ${name} ready for review`);
|
|
707
|
+
} else if (e.type === 'PullRequestReviewEvent') {
|
|
708
|
+
const pr = p.pull_request ?? {};
|
|
709
|
+
const state = String(p.review?.state ?? '').toLowerCase();
|
|
710
|
+
const how =
|
|
711
|
+
state === 'approved'
|
|
712
|
+
? 'approved'
|
|
713
|
+
: state === 'changes_requested'
|
|
714
|
+
? 'asked for changes on'
|
|
715
|
+
: 'reviewed';
|
|
716
|
+
add('reviewed', `${how} #${pr.number} ${pr.title}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return out.sort((a, b) => a.at - b.at);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Claims taken and released between two reads: [{ by, kind: 'claimed'|'released', text }]. */
|
|
723
|
+
export function claimChanges(before = [], now = []) {
|
|
724
|
+
const key = (c) => `${c.story}|${c.by}`;
|
|
725
|
+
const was = new Map(before.map((c) => [key(c), c]));
|
|
726
|
+
const is = new Map(now.map((c) => [key(c), c]));
|
|
727
|
+
return [
|
|
728
|
+
...now
|
|
729
|
+
.filter((c) => !was.has(key(c)))
|
|
730
|
+
.map((c) => ({
|
|
731
|
+
at: Date.parse(c.at),
|
|
732
|
+
by: c.by,
|
|
733
|
+
kind: 'claimed',
|
|
734
|
+
text: `claimed ${c.story}`,
|
|
735
|
+
})),
|
|
736
|
+
...before
|
|
737
|
+
.filter((c) => !is.has(key(c)))
|
|
738
|
+
.map((c) => ({
|
|
739
|
+
at: Date.now(),
|
|
740
|
+
by: c.by,
|
|
741
|
+
kind: 'released',
|
|
742
|
+
text: `${c.story} is no longer claimed by them`,
|
|
743
|
+
})),
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// --- the board (TEAM-12) -------------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
const BOARD_STATE = '.claude/state/board.json';
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* TEAM-12: mirror the plan's stories onto the team's GitHub Project as draft items with text fields
|
|
753
|
+
* (Lane, Claimed by, Stale, State), creating the fields and items it needs and editing only what
|
|
754
|
+
* changed since the last sync. `stories` are the plan's, `claims` the team's. The board is a view:
|
|
755
|
+
* claims stay refs. Returns { ok, created, edited } or { ok: false, why }.
|
|
756
|
+
*/
|
|
757
|
+
export function syncBoard(root, team, stories, claims) {
|
|
758
|
+
const { owner, number } = team.board;
|
|
759
|
+
const run = (args) =>
|
|
760
|
+
spawnSync('gh', ['project', ...args], {
|
|
761
|
+
cwd: root,
|
|
762
|
+
encoding: 'utf8',
|
|
763
|
+
timeout: 30_000,
|
|
764
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
765
|
+
});
|
|
766
|
+
const json = (args) => {
|
|
767
|
+
const r = run([...args, '--format', 'json']);
|
|
768
|
+
if (r.status !== 0) throw new Error((r.stderr || r.stdout || '').trim());
|
|
769
|
+
return JSON.parse(r.stdout || 'null');
|
|
770
|
+
};
|
|
771
|
+
const at = ['--owner', owner];
|
|
772
|
+
let cache = {};
|
|
773
|
+
try {
|
|
774
|
+
cache = JSON.parse(readFileSync(join(root, BOARD_STATE), 'utf8'));
|
|
775
|
+
} catch {
|
|
776
|
+
// first sync on this machine
|
|
777
|
+
}
|
|
778
|
+
const key = `${owner}/${number}`;
|
|
779
|
+
if (cache.board !== key) cache = { board: key, items: {} };
|
|
780
|
+
const save = () => {
|
|
781
|
+
mkdirSync(join(root, '.claude/state'), { recursive: true });
|
|
782
|
+
writeFileSync(join(root, BOARD_STATE), JSON.stringify(cache, null, 2) + '\n');
|
|
783
|
+
};
|
|
784
|
+
let created = 0;
|
|
785
|
+
let edited = 0;
|
|
786
|
+
try {
|
|
787
|
+
const project = json(['view', String(number), ...at]);
|
|
788
|
+
const fields = new Map(
|
|
789
|
+
(json(['field-list', String(number), ...at]).fields ?? []).map((f) => [f.name, f.id]),
|
|
790
|
+
);
|
|
791
|
+
for (const name of BOARD_FIELDS)
|
|
792
|
+
if (!fields.has(name))
|
|
793
|
+
fields.set(
|
|
794
|
+
name,
|
|
795
|
+
json(['field-create', String(number), ...at, '--name', name, '--data-type', 'TEXT']).id,
|
|
796
|
+
);
|
|
797
|
+
// Items made before this machine's cache: found by their story id, so nothing is doubled.
|
|
798
|
+
if (stories.some((s) => !cache.items[s.id])) {
|
|
799
|
+
for (const item of json(['item-list', String(number), ...at, '--limit', '1000']).items ??
|
|
800
|
+
[]) {
|
|
801
|
+
const id = String(item.title ?? item.content?.title ?? '').split(' ')[0];
|
|
802
|
+
if (isStoryId(id) && !cache.items[id]) cache.items[id] = { id: item.id, values: {} };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const byStory = new Map(claims.map((c) => [c.story, c]));
|
|
806
|
+
for (const s of stories) {
|
|
807
|
+
const now = boardValues(s, byStory.get(s.id));
|
|
808
|
+
if (!cache.items[s.id]) {
|
|
809
|
+
const item = json([
|
|
810
|
+
'item-create',
|
|
811
|
+
String(number),
|
|
812
|
+
...at,
|
|
813
|
+
'--title',
|
|
814
|
+
`${s.id} ${s.title ?? ''}`.trim(),
|
|
815
|
+
'--body',
|
|
816
|
+
s.requirements?.length ? `Requirements: ${s.requirements.join(', ')}` : '',
|
|
817
|
+
]);
|
|
818
|
+
cache.items[s.id] = { id: item.id, values: {} };
|
|
819
|
+
created += 1;
|
|
820
|
+
}
|
|
821
|
+
const entry = cache.items[s.id];
|
|
822
|
+
for (const [field, value] of boardChanges(entry.values, now)) {
|
|
823
|
+
const r = run([
|
|
824
|
+
'item-edit',
|
|
825
|
+
'--id',
|
|
826
|
+
entry.id,
|
|
827
|
+
'--project-id',
|
|
828
|
+
project.id,
|
|
829
|
+
'--field-id',
|
|
830
|
+
fields.get(field),
|
|
831
|
+
...(value ? ['--text', value] : ['--clear']),
|
|
832
|
+
]);
|
|
833
|
+
if (r.status !== 0) throw new Error((r.stderr || r.stdout || '').trim());
|
|
834
|
+
entry.values[field] = value;
|
|
835
|
+
edited += 1;
|
|
836
|
+
}
|
|
837
|
+
save();
|
|
838
|
+
}
|
|
839
|
+
} catch (e) {
|
|
840
|
+
save();
|
|
841
|
+
const why = String(e.message || e);
|
|
842
|
+
return {
|
|
843
|
+
ok: false,
|
|
844
|
+
why:
|
|
845
|
+
/scope|project/i.test(why) && /token|scope|permission|auth/i.test(why)
|
|
846
|
+
? 'Your gh sign-in needs the project permission to write to the board: run gh auth refresh -s project, then sync again.'
|
|
847
|
+
: `The board couldn't be synced: ${why.split('\n')[0]}`,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return { ok: true, created, edited };
|
|
851
|
+
}
|