@dotdotgod/cli 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,6 @@ dotdotgod graph impact . --changed <path> --compact
40
40
  - `resolve` and `expand` map explicit or high-signal prompt references to project files.
41
41
  - `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five.
42
42
  - `traceability links` checks or repairs generated Markdown traceability-link sections.
43
- - `trello sync` plans or writes Trello linked-doc metadata from configured docs paths.
44
43
 
45
44
  ## Commands
46
45
 
@@ -77,7 +76,6 @@ dotdotgod graph communities .
77
76
  | Resolve `[[...]]` references from a prompt | `dotdotgod expand . "Update [[PLAN_MODE]]"` |
78
77
  | Repair generated traceability-link sections | `dotdotgod traceability links . --write` |
79
78
  | See what else to inspect after a change | `dotdotgod graph impact . --changed <path> --compact` |
80
- | Preview Trello linked-doc updates | `dotdotgod trello sync . --dry-run` |
81
79
 
82
80
  ## Changed-File Impact
83
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/cli",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "CLI for dotdotgod validation, local documentation search, and graph indexing.",
5
5
  "type": "module",
6
6
  "license": "Elastic-2.0",
package/src/cli/usage.mjs CHANGED
@@ -42,20 +42,6 @@ Search shared project documentation with the local multilingual E5 vector index.
42
42
  case 'expand':
43
43
  return `Usage:
44
44
  dotdotgod expand <root> <prompt> [--max-results n] [--include-archive] [--with-impact] [--fuzzy] [--json]`;
45
- case 'trello':
46
- return `Usage:
47
- dotdotgod trello sync <root> [--dry-run]`;
48
- case 'trello sync':
49
- return `Usage:
50
- dotdotgod trello sync <root> [--dry-run]
51
-
52
- Options:
53
- --dry-run Preview planned Trello updates without calling Trello APIs.
54
-
55
- Write mode:
56
- Omit --dry-run only in the trusted GitHub Actions default-branch push workflow.
57
- Requires TRELLO_API_KEY and TRELLO_TOKEN in the trusted workflow environment.
58
- Local/manual and pull request usage should run --dry-run.`;
59
45
  case 'traceability':
60
46
  return `Usage:
61
47
  dotdotgod traceability links <root> [--check|--write] [--json]`;
@@ -95,7 +81,6 @@ Commands:
95
81
  traceability links Check or write generated traceability links.
96
82
  graph impact Find files related to changed files.
97
83
  graph communities Find groups of related project-memory nodes.
98
- trello sync Synchronize Trello cards and documentation.
99
84
 
100
85
  Options:
101
86
  -h, --help Show help.
@@ -144,7 +129,6 @@ export function printVersion() {
144
129
  export function helpCommandFromArgs(args) {
145
130
  const nonHelp = args.filter((arg) => !isHelpToken(arg));
146
131
  if (nonHelp[0] === 'graph' && nonHelp[1]) return `graph ${nonHelp[1]}`;
147
- if (nonHelp[0] === 'trello' && nonHelp[1]) return `trello ${nonHelp[1]}`;
148
132
  if (nonHelp[0] === 'traceability' && nonHelp[1]) return `traceability ${nonHelp[1]}`;
149
133
  if (nonHelp[0] === 'config' && nonHelp[1] === 'init') return 'config init';
150
134
  return nonHelp[0] ?? 'root';
@@ -43,7 +43,6 @@ function formatConfigOutput(payload) {
43
43
  lines.push(`- load pinned bodies: ${(payload.config.load?.pinnedBodies ?? []).join(', ') || 'none'}`);
44
44
  lines.push(`- load documentation summary exclude: ${(payload.config.load?.documentationSummary?.exclude ?? []).join(', ') || 'none'}`);
45
45
  lines.push(`- plan mode writable paths: ${(payload.config.planMode?.writablePaths ?? []).join(', ') || 'none'}`);
46
- lines.push(`- trello sync paths: ${(payload.config.integrations?.trello?.syncPaths ?? []).join(', ') || 'none'}`);
47
46
  const lowSignal = payload.config.referenceExpansion?.fuzzy?.lowSignal ?? { terms: [], add: [], remove: [] };
48
47
  lines.push(`- fuzzy low-signal terms: ${(lowSignal.terms ?? []).join(', ') || 'none'} (add: ${(lowSignal.add ?? []).join(', ') || 'none'}; remove: ${(lowSignal.remove ?? []).join(', ') || 'none'})`);
49
48
  if (errors.length > 0) {
package/src/core.mjs CHANGED
@@ -7,14 +7,13 @@ import { runQuery } from './commands/query.mjs';
7
7
  import { runResolve, runExpand } from './reference/resolve.mjs';
8
8
  import { runTraceability } from './commands/traceability.mjs';
9
9
  import { runGraph } from './commands/graph.mjs';
10
- import { runTrelloSync } from './trello/sync.mjs';
11
10
 
12
11
  export { CACHE_VERSION } from './index/constants.mjs';
13
12
  export { commandUsage, hasHelpToken, helpCommandFromArgs, isHelpToken, isVersionToken, parseCommon, printVersion, usage } from './cli/usage.mjs';
14
13
  export { rel } from './common/paths.mjs';
15
14
  export { extractAnchors, extractFirstHeading, extractLinks, headingToAnchor, isKebabCase, isNumberedSeriesFilename, isUpperSnakeMarkdown, removeCodeBlocks, suggestFilenameFromHeading } from './docs/markdown.mjs';
16
15
  export { extractDotdotgodTraceabilityBlocks, findTraceabilityLinksRegion, isLocalRelativeTraceabilityPath, renderCompactTraceabilityBlock, stripTraceabilityLinksRegion, syncTraceabilityLinksInContent, traceabilityExample, validateTraceabilityBlock, validateTraceabilityLinksRegion, validateTraceabilityPlacement } from './docs/traceability.mjs';
17
- export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_INTEGRATIONS_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneIntegrationsPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, trelloSyncPaths, validateMemoryConfigData } from './memory/config.mjs';
16
+ export { DEFAULT_IMPACT_RANKING_POLICY, DEFAULT_LOAD_POLICY, DEFAULT_VALIDATION_POLICY, SEMANTIC_RELATIONS, cloneImpactRankingPolicy, cloneLoadPolicy, cloneReferenceExpansionPolicy, cloneValidationPolicy, defaultDotdotgodConfigData, defaultDotdotgodConfigText, defaultMemoryConfig, isMarkdownSizeExcluded, isSecretLikePathPattern, isValidPathPattern, matchPathPattern, memoryAreaForPath, memoryConfigSummary, normalizeLowSignalTerm, readMemoryConfig, requiresTraceability, resolveMemoryArea, validateMemoryConfigData } from './memory/config.mjs';
18
17
  export { buildCommunities, relationWeight } from './graph/communities.mjs';
19
18
  export { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shardFile, writeJson } from './graph/store.mjs';
20
19
  export { cacheFile, collectIndexFiles, fingerprint, shouldIndexPath } from './index/files.mjs';
@@ -34,13 +33,6 @@ export { extractBracketReferences, extractFuzzyReferences, normalizeReferenceAli
34
33
  export { resolveReferenceCandidates, runResolve, runExpand } from './reference/resolve.mjs';
35
34
  export { parseTraceabilityOptions, runTraceability } from './commands/traceability.mjs';
36
35
  export { parseGraphOptions, runGraph } from './commands/graph.mjs';
37
- export { planTrelloDryRun, runTrelloSync } from './trello/sync.mjs';
38
- export { parseTrelloMetadata, extractTrelloShortLink } from './trello/metadata.mjs';
39
- export { normalizeGitHubRemote, resolveGitHubFileUrl, resolveGitHubRepositoryIdentity } from './trello/github-url.mjs';
40
- export { buildLinkedDocsData, summarizeTraceability } from './trello/summary.mjs';
41
- export { formatTrelloDryRunReport, formatTrelloWriteReport } from './trello/report.mjs';
42
- export { createTrelloClient, resolveTrelloCredentials } from './trello/client.mjs';
43
- export { customFieldTextValue, DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue, parseLinkedDocsCustomFieldPayload, serializeLinkedDocsCustomFieldPayload } from './trello/custom-fields.mjs';
44
36
 
45
37
  export async function runCli(argv = process.argv.slice(2)) {
46
38
  const [command = 'help', ...args] = argv;
@@ -58,13 +50,5 @@ export async function runCli(argv = process.argv.slice(2)) {
58
50
  else if (command === 'expand') runExpand(args);
59
51
  else if (command === 'traceability') runTraceability(args);
60
52
  else if (command === 'graph') await runGraph(args);
61
- else if (command === 'trello') {
62
- const result = await Promise.resolve(runTrelloSync(args));
63
- if (result.stdout) process.stdout.write(result.stdout);
64
- if (result.stderr) {
65
- console.error(result.stderr);
66
- console.error(commandUsage('trello'));
67
- }
68
- process.exit(result.exitCode);
69
- } else usage(`Unknown command: ${command}`);
53
+ else usage(`Unknown command: ${command}`);
70
54
  }
@@ -21,9 +21,6 @@ export const DEFAULT_VALIDATION_POLICY = {
21
21
  },
22
22
  },
23
23
  };
24
- export const DEFAULT_INTEGRATIONS_POLICY = {
25
- trello: { syncPaths: [] },
26
- };
27
24
  export const DEFAULT_LOAD_POLICY = {
28
25
  pinnedPaths: [],
29
26
  pinnedBodies: [],
@@ -177,10 +174,6 @@ function normalizeImpactRankingPolicy(raw) {
177
174
  return cloneImpactRankingPolicy({ ...preset, ...raw, preset: presetName, weights: { ...(preset.weights ?? {}), ...(raw?.weights ?? {}) }, ppr: { ...(preset.ppr ?? {}), ...(raw?.ppr ?? {}) }, semantic: { ...(preset.semantic ?? {}), ...(raw?.semantic ?? {}) } });
178
175
  }
179
176
 
180
- export function cloneIntegrationsPolicy(policy = DEFAULT_INTEGRATIONS_POLICY) {
181
- return { trello: { syncPaths: [...(policy.trello?.syncPaths ?? [])] } };
182
- }
183
-
184
177
  export function cloneLoadPolicy(policy = DEFAULT_LOAD_POLICY) {
185
178
  return {
186
179
  pinnedPaths: [...(policy.pinnedPaths ?? [])],
@@ -196,7 +189,7 @@ export function clonePlanModePolicy(policy = DEFAULT_PLAN_MODE_POLICY) {
196
189
  }
197
190
 
198
191
  export function defaultMemoryConfig() {
199
- return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), integrations: cloneIntegrationsPolicy(), load: cloneLoadPolicy(), planMode: clonePlanModePolicy() };
192
+ return { source: 'default', areas: DEFAULT_MEMORY_AREAS.map(cloneArea), traceability: cloneTraceabilityPolicy(), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), load: cloneLoadPolicy(), planMode: clonePlanModePolicy() };
200
193
  }
201
194
 
202
195
  export function normalizePathPattern(value = '') {
@@ -264,12 +257,6 @@ function normalizeValidationPolicy(raw) {
264
257
  });
265
258
  }
266
259
 
267
- function normalizeIntegrationsPolicy(raw) {
268
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return cloneIntegrationsPolicy();
269
- const syncPaths = Array.isArray(raw.trello?.syncPaths) ? raw.trello.syncPaths.map(normalizePathPattern) : [];
270
- return cloneIntegrationsPolicy({ trello: { syncPaths: [...new Set(syncPaths)] } });
271
- }
272
-
273
260
  export function isSecretLikePathPattern(value = '') {
274
261
  const normalized = normalizePathPattern(value);
275
262
  return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials?|secrets?)(\.|\/|$)/i.test(normalized);
@@ -297,10 +284,6 @@ function normalizeLoadPolicy(raw) {
297
284
  });
298
285
  }
299
286
 
300
- export function trelloSyncPaths(config = defaultMemoryConfig()) {
301
- return [...new Set(['docs/trello/**', ...((config.integrations ?? DEFAULT_INTEGRATIONS_POLICY).trello?.syncPaths ?? [])])];
302
- }
303
-
304
287
  export function isMarkdownSizeExcluded(path = '', config = defaultMemoryConfig()) {
305
288
  const policy = config.validation ?? DEFAULT_VALIDATION_POLICY;
306
289
  return (policy.markdown?.exclude ?? []).some((pattern) => matchPathPattern(path, pattern));
@@ -383,23 +366,6 @@ export function validateMemoryConfigData(data, root = '.', file = 'dotdotgod.con
383
366
  }
384
367
  }
385
368
  }
386
- const integrations = data.integrations;
387
- if (integrations !== undefined) {
388
- if (!integrations || typeof integrations !== 'object' || Array.isArray(integrations)) {
389
- add('INTEGRATIONS_CONFIG_INVALID', 'integrations', 'Expected an object.');
390
- } else if (integrations.trello !== undefined) {
391
- const trello = integrations.trello;
392
- if (!trello || typeof trello !== 'object' || Array.isArray(trello)) {
393
- add('INTEGRATIONS_TRELLO_CONFIG_INVALID', 'integrations.trello', 'Expected an object.');
394
- } else if (trello.syncPaths !== undefined) {
395
- if (!Array.isArray(trello.syncPaths)) add('INTEGRATIONS_TRELLO_INVALID_SYNC_PATHS', 'integrations.trello.syncPaths', 'Expected an array of path strings.');
396
- else {
397
- if (trello.syncPaths.some((value) => !isValidPathPattern(value) || (typeof value === 'string' && value.trim().startsWith('/')))) add('INTEGRATIONS_TRELLO_INVALID_SYNC_PATHS', 'integrations.trello.syncPaths', 'Expected repository-relative path strings using exact paths, /** subtree patterns, or **/suffix patterns.');
398
- if (trello.syncPaths.some((value) => typeof value === 'string' && isSecretLikePathPattern(value))) add('INTEGRATIONS_TRELLO_SECRET_SYNC_PATH', 'integrations.trello.syncPaths', 'Sync paths must not target secrets, credentials, environment files, or private keys.');
399
- }
400
- }
401
- }
402
- }
403
369
  const load = data.load;
404
370
  if (load !== undefined) {
405
371
  if (!load || typeof load !== 'object' || Array.isArray(load)) {
@@ -544,10 +510,9 @@ export function readMemoryConfig(root = '.') {
544
510
  const validation = data.validation === undefined ? cloneValidationPolicy() : normalizeValidationPolicy(data.validation);
545
511
  const impactRanking = normalizeImpactRankingPolicy(data.impactRanking);
546
512
  const referenceExpansion = normalizeReferenceExpansionPolicy(data.referenceExpansion);
547
- const integrations = normalizeIntegrationsPolicy(data.integrations);
548
513
  const load = normalizeLoadPolicy(data.load);
549
514
  const planMode = normalizePlanModePolicy(data.planMode);
550
- return configuredAreas.length > 0 ? { source: MEMORY_CONFIG_FILE, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, integrations, load, planMode, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, integrations, load, planMode, source: MEMORY_CONFIG_FILE, errors: [] };
515
+ return configuredAreas.length > 0 ? { source: MEMORY_CONFIG_FILE, areas: configuredAreas, traceability, validation, impactRanking, referenceExpansion, load, planMode, errors: [] } : { ...defaultMemoryConfig(), traceability, validation, impactRanking, referenceExpansion, load, planMode, source: MEMORY_CONFIG_FILE, errors: [] };
551
516
  } catch (error) {
552
517
  return { ...defaultMemoryConfig(), source: MEMORY_CONFIG_FILE, errors: [{ file: MEMORY_CONFIG_FILE, code: 'MEMORY_CONFIG_INVALID_JSON', message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}\nFix: repair ${MEMORY_CONFIG_FILE} so it is valid JSON, or remove it before regenerating the default config with \`dotdotgod config init <root>\`.` }] };
553
518
  }
@@ -577,7 +542,6 @@ export function defaultDotdotgodConfigData() {
577
542
  validation: cloneValidationPolicy(config.validation),
578
543
  impactRanking: cloneImpactRankingPolicy(config.impactRanking),
579
544
  referenceExpansion: { fuzzy: { lowSignal: { add: [], remove: [] } } },
580
- integrations: cloneIntegrationsPolicy(config.integrations),
581
545
  load: cloneLoadPolicy(config.load),
582
546
  planMode: clonePlanModePolicy(config.planMode),
583
547
  };
@@ -605,7 +569,6 @@ export function memoryConfigSummary(config) {
605
569
  validation: cloneValidationPolicy(config.validation ?? DEFAULT_VALIDATION_POLICY),
606
570
  impactRanking: cloneImpactRankingPolicy(config.impactRanking ?? DEFAULT_IMPACT_RANKING_POLICY),
607
571
  referenceExpansion: cloneReferenceExpansionPolicy(config.referenceExpansion),
608
- integrations: cloneIntegrationsPolicy(config.integrations),
609
572
  load: cloneLoadPolicy(config.load),
610
573
  planMode: clonePlanModePolicy(config.planMode),
611
574
  };
@@ -61,9 +61,17 @@ export function runValidate(argv) {
61
61
  const maxChars = options.maxChars ?? validationPolicy.markdown.maxChars;
62
62
  const markdownSizePrompt = (path) => {
63
63
  const area = resolveMemoryArea(path, memoryConfig);
64
- const areaText = area ? `${area.label ?? area.id} (${area.id})` : 'the matching documentation area';
65
- const roleText = area?.role ?? 'the document role';
66
- return `Split ${path} into focused documents by documentation area and role. Keep the current ${areaText} area semantics and ${roleText} role intact, move role-specific detail into smaller UPPER_SNAKE_CASE markdown files, and update the nearest README.md index with each new file's purpose.`;
64
+ const clarify = area?.clarify;
65
+ const guidance = [
66
+ clarify?.documentType ? `document type: ${clarify.documentType}` : null,
67
+ clarify?.audience?.length ? `audience: ${clarify.audience.join(', ')}` : null,
68
+ clarify?.clarityGoal ? `clarity goal: ${clarify.clarityGoal}` : null,
69
+ clarify?.editRules?.length ? `edit rules: ${clarify.editRules.join('; ')}` : null,
70
+ ].filter(Boolean);
71
+ const evidence = guidance.length > 0
72
+ ? ` Follow this resolved clarification guidance: ${guidance.join('; ')}.`
73
+ : ' Preserve the document\'s current purpose and established meaning, using its content, links, and nearest README.md as evidence.';
74
+ return `Split ${path} into focused UPPER_SNAKE_CASE markdown files.${evidence} Keep each file focused and update the nearest README.md index with every new file's purpose.`;
67
75
  };
68
76
  for (const error of memoryConfig.errors ?? []) errors.push(error);
69
77
  const FILENAME_QUALITY_AREAS = new Set(
@@ -73,18 +81,14 @@ export function runValidate(argv) {
73
81
  .map(p => { const m = p.match(/^docs\/([^/*]+)\/\*\*$/); return m ? m[1] : null; })
74
82
  .filter(Boolean)
75
83
  );
84
+ const filenameQualityScope = [...FILENAME_QUALITY_AREAS].sort().map(area => `docs/${area}`).join(', ') || 'configured shared documentation areas';
76
85
  const FILENAME_QUALITY_PROMPT =
77
- 'Background: Filenames and paths are LLM/agent context signals used for impact ranking, search, and retrieval. ' +
78
- 'Abstract or sequentially numbered filenames carry low information signal.\n' +
79
- 'Naming principles:\n' +
80
- '1. Filenames in docs/spec, docs/arch, docs/test should be meaning-based wherever possible.\n' +
81
- '2. The filename alone should let you infer the primary subject: endpoint, screen, policy, state, or response type.\n' +
82
- '3. When splitting documents, derive names from headings, API paths, or domain terms rather than sequence numbers or abstract words.\n' +
83
- 'Avoid - numbered splits: API_1.md, API_2.md, API_3_1_*.md, 01_*.md, *_01.md\n' +
84
- 'Avoid - overly abstract: COMMON.md, OVERVIEW.md, DETAILS.md, NOTES.md, MISC.md, PART_1.md, SECTION_1.md\n' +
85
- 'Acceptable exceptions: README.md (directory index), files with clear HTTP status code semantics (e.g. RESPONSE_200_OK.md), ' +
86
- 'files in the project validation.markdown.filename.allow allowlist.\n' +
87
- 'Good renames: API_1.md → BIZ_RESERVATIONS_SCHEDULE.md, COMMON.md → BIZ_AUTH_ERROR_CONVENTIONS.md, OVERVIEW.md → REGISTRATION_HANDOFF_SUMMARY.md';
86
+ 'Filenames and paths are LLM/agent context signals used for impact ranking, search, and retrieval.\n' +
87
+ `Use meaning-based filenames in ${filenameQualityScope}. A filename should identify its primary endpoint, screen, policy, state, response type, or domain subject. ` +
88
+ 'Derive split-document names from headings, API paths, and established domain terms.\n' +
89
+ 'Rename numbered or abstract series such as API_1.md, PART_1.md, COMMON.md, or DETAILS.md to subject-specific names. ' +
90
+ 'Examples: API_1.md → BIZ_RESERVATIONS_SCHEDULE.md; COMMON.md → BIZ_AUTH_ERROR_CONVENTIONS.md.\n' +
91
+ 'README.md directory indexes, clear HTTP-status filenames such as RESPONSE_200_OK.md, and configured filename allowlist entries remain valid.';
88
92
  walk(docs);
89
93
  const dirSiblingMap = new Map();
90
94
  for (const f of markdownFiles) {
@@ -1,93 +0,0 @@
1
- const TRELLO_API_BASE = 'https://api.trello.com';
2
-
3
- function clean(value) {
4
- return typeof value === 'string' ? value.trim() : '';
5
- }
6
-
7
- function credentialError(message) {
8
- return {
9
- ok: false,
10
- error: {
11
- code: 'TRELLO_CREDENTIALS_MISSING',
12
- message: `${message} Fix: configure GitHub Actions secrets or environment variables TRELLO_API_KEY and TRELLO_TOKEN, then retry from the trusted Trello sync workflow.`,
13
- },
14
- };
15
- }
16
-
17
- export function resolveTrelloCredentials({ env = process.env } = {}) {
18
- const apiKey = clean(env.TRELLO_API_KEY);
19
- const token = clean(env.TRELLO_TOKEN);
20
- if (!apiKey || !token) return credentialError('Trello write mode credentials are missing or empty.');
21
- return { ok: true, credentials: { apiKey, token, source: 'env' } };
22
- }
23
-
24
- function trelloApiError({ status = 0, statusText = '', body = '', retryAfter = null, operation = 'Trello API request', idOrShortLink = '', redact = (value) => value } = {}) {
25
- const bodyText = redact(typeof body === 'string' ? body : JSON.stringify(body ?? ''));
26
- const code = bodyText.includes('API_TOKEN_LIMIT_EXCEEDED') ? 'API_TOKEN_LIMIT_EXCEEDED' : bodyText.includes('API_KEY_LIMIT_EXCEEDED') ? 'API_KEY_LIMIT_EXCEEDED' : 'TRELLO_API_FAILED';
27
- const rateLimitGuidance = status === 429 ? ` Wait and retry${retryAfter ? ` after ${retryAfter} seconds` : ''}.` : '';
28
- return {
29
- code,
30
- status,
31
- retryAfter,
32
- message: `${operation} failed${idOrShortLink ? ` for Trello card ${idOrShortLink}` : ''}${status ? ` with HTTP ${status}${statusText ? ` ${statusText}` : ''}` : ''}${bodyText ? `: ${bodyText}` : ''}. Fix:${rateLimitGuidance || ' check card access, credentials, and Trello response details, then retry.'}`,
33
- };
34
- }
35
-
36
- export function createTrelloClient({ credentials, fetchImpl = globalThis.fetch, baseUrl = TRELLO_API_BASE } = {}) {
37
- if (typeof fetchImpl !== 'function') throw new Error('Trello client requires a fetch implementation.');
38
- const auth = { key: credentials?.apiKey ?? '', token: credentials?.token ?? '' };
39
- const redact = (value = '') => String(value).split(auth.key).join('[REDACTED_API_KEY]').split(auth.token).join('[REDACTED_TOKEN]');
40
-
41
- async function request(path, { method = 'GET', params = {}, body = null, operation, idOrShortLink } = {}) {
42
- const url = new URL(path, baseUrl);
43
- for (const [key, value] of Object.entries({ ...params, ...auth })) url.searchParams.set(key, value);
44
- const init = { method };
45
- if (body !== null) {
46
- init.headers = { 'Content-Type': 'application/json' };
47
- init.body = JSON.stringify(body);
48
- }
49
- let response;
50
- try {
51
- response = await fetchImpl(url, init);
52
- } catch (error) {
53
- throw trelloApiError({ operation, idOrShortLink, body: error?.message ?? 'network error', redact });
54
- }
55
- const bodyText = await response.text();
56
- if (!response.ok) {
57
- throw trelloApiError({ status: response.status, statusText: response.statusText, body: bodyText, retryAfter: response.headers?.get?.('Retry-After') ?? null, operation, idOrShortLink, redact });
58
- }
59
- if (!bodyText) return null;
60
- try {
61
- return JSON.parse(bodyText);
62
- } catch {
63
- throw trelloApiError({ status: response.status, statusText: response.statusText, body: 'Malformed JSON response', operation, idOrShortLink, redact });
64
- }
65
- }
66
-
67
- return {
68
- async getCard(idOrShortLink) {
69
- const card = await request(`/1/cards/${encodeURIComponent(idOrShortLink)}`, { params: { fields: 'id,idBoard,desc', customFieldItems: 'true' }, operation: 'Card lookup', idOrShortLink });
70
- if (!card || typeof card.id !== 'string' || typeof card.idBoard !== 'string') throw trelloApiError({ body: 'Malformed card response', operation: 'Card lookup', idOrShortLink, redact });
71
- return card;
72
- },
73
- async listAttachments(idOrShortLink) {
74
- const attachments = await request(`/1/cards/${encodeURIComponent(idOrShortLink)}/attachments`, { operation: 'Attachment lookup', idOrShortLink });
75
- if (!Array.isArray(attachments)) throw trelloApiError({ body: 'Malformed attachments response', operation: 'Attachment lookup', idOrShortLink, redact });
76
- return attachments;
77
- },
78
- async createAttachment(idOrShortLink, githubUrl) {
79
- return request(`/1/cards/${encodeURIComponent(idOrShortLink)}/attachments`, { method: 'POST', params: { url: githubUrl }, operation: 'Attachment creation', idOrShortLink });
80
- },
81
- async listCustomFields(idBoard) {
82
- const fields = await request(`/1/boards/${encodeURIComponent(idBoard)}/customFields`, { operation: 'Custom field lookup' });
83
- if (!Array.isArray(fields)) throw trelloApiError({ body: 'Malformed custom fields response', operation: 'Custom field lookup', redact });
84
- return fields;
85
- },
86
- async createCustomField(idBoard, { name, type = 'text' } = {}) {
87
- return request('/1/customFields', { method: 'POST', params: { idModel: idBoard, modelType: 'board', name, type, pos: 'bottom', display_cardFront: 'false' }, operation: 'Custom field creation' });
88
- },
89
- async updateCardCustomFieldText(idCard, idCustomField, text) {
90
- return request(`/1/cards/${encodeURIComponent(idCard)}/customField/${encodeURIComponent(idCustomField)}/item`, { method: 'PUT', body: { value: { text } }, operation: 'Custom field update', idOrShortLink: idCard });
91
- },
92
- };
93
- }
@@ -1,102 +0,0 @@
1
- export const DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME = 'dotdotgod-view';
2
-
3
- function cleanString(value) {
4
- return typeof value === 'string' ? value.trim() : '';
5
- }
6
-
7
- export function dotdotgodCustomFieldName({ env = process.env, name = '' } = {}) {
8
- return cleanString(name) || cleanString(env.DOTDOTGOD_TRELLO_CUSTOM_FIELD_NAME) || DEFAULT_DOTDOTGOD_CUSTOM_FIELD_NAME;
9
- }
10
-
11
- function buildLinkedDocsCustomFieldData({ docsPath, githubUrl, trelloUrl, repositoryKey, repositoryLabel, title, summary, traceabilityState, traceabilityDetails = [] } = {}) {
12
- return {
13
- repositoryKey: cleanString(repositoryKey),
14
- repositoryLabel: cleanString(repositoryLabel) || cleanString(repositoryKey).split('/').filter(Boolean).at(-1) || '',
15
- docsPath: cleanString(docsPath),
16
- githubUrl: cleanString(githubUrl),
17
- trelloUrl: cleanString(trelloUrl),
18
- title: cleanString(title),
19
- summary: cleanString(summary),
20
- traceabilityState: cleanString(traceabilityState) || 'not_required',
21
- traceabilityDetails: Array.isArray(traceabilityDetails) ? traceabilityDetails.map((item) => String(item)) : [],
22
- };
23
- }
24
-
25
- export function serializeLinkedDocsCustomFieldPayload(entries = []) {
26
- return JSON.stringify({ version: 1, entries: entries.map((entry) => buildLinkedDocsCustomFieldData(entry)) });
27
- }
28
-
29
- export function parseLinkedDocsCustomFieldPayload(text = '') {
30
- const value = cleanString(text);
31
- if (!value) return { ok: true, entries: [] };
32
- let parsed;
33
- try {
34
- parsed = JSON.parse(value);
35
- } catch (error) {
36
- return {
37
- ok: false,
38
- error: {
39
- code: 'TRELLO_CUSTOM_FIELD_INVALID',
40
- message: `dotdotgod custom field contains invalid JSON. Cause: ${error.message}. Fix: clear the custom field or restore a valid dotdotgod linked-docs payload, then retry sync.`,
41
- },
42
- };
43
- }
44
- if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) {
45
- return {
46
- ok: false,
47
- error: {
48
- code: 'TRELLO_CUSTOM_FIELD_INVALID',
49
- message: 'dotdotgod custom field has an unsupported shape. Fix: clear the custom field or restore a version 1 linked-docs payload with an entries array, then retry sync.',
50
- },
51
- };
52
- }
53
- const entries = parsed.entries.map((entry) => buildLinkedDocsCustomFieldData(entry));
54
- const keys = entries.map((entry) => entry.repositoryKey).filter(Boolean);
55
- if (keys.length !== new Set(keys).size) {
56
- return {
57
- ok: false,
58
- error: {
59
- code: 'TRELLO_CUSTOM_FIELD_DUPLICATE_REPOSITORY',
60
- message: 'dotdotgod custom field contains duplicate repository entries. Fix: keep one entry per repository key, then retry sync.',
61
- },
62
- };
63
- }
64
- return { ok: true, entries };
65
- }
66
-
67
- export function mergeLinkedDocsCustomFieldValue(existingText = '', entry = {}) {
68
- const current = buildLinkedDocsCustomFieldData(entry);
69
- if (!current.repositoryKey) {
70
- return {
71
- ok: false,
72
- error: {
73
- code: 'TRELLO_CUSTOM_FIELD_REPOSITORY_MISSING',
74
- message: 'Cannot update dotdotgod custom field without a repository key. Fix: run from a trusted GitHub Actions repository or configure repository metadata, then retry sync.',
75
- },
76
- };
77
- }
78
- const parsed = parseLinkedDocsCustomFieldPayload(existingText);
79
- if (!parsed.ok) return parsed;
80
- const entries = [...parsed.entries];
81
- const index = entries.findIndex((item) => item.repositoryKey === current.repositoryKey);
82
- let action = 'create-payload';
83
- if (index >= 0) {
84
- const before = JSON.stringify(entries[index]);
85
- const after = JSON.stringify(current);
86
- action = before === after ? 'unchanged-entry' : 'replace-entry';
87
- entries[index] = current;
88
- } else if (entries.length > 0) {
89
- action = 'append-entry';
90
- entries.push(current);
91
- } else {
92
- entries.push(current);
93
- }
94
- const text = serializeLinkedDocsCustomFieldPayload(entries);
95
- const status = cleanString(existingText) === text ? 'unchanged' : 'written';
96
- return { ok: true, status, action: status === 'unchanged' ? 'unchanged-entry' : action, text, entries, preservedRepositoryEntries: entries.filter((item) => item.repositoryKey !== current.repositoryKey).length };
97
- }
98
-
99
- export function customFieldTextValue(card = {}, idCustomField = '') {
100
- const item = Array.isArray(card.customFieldItems) ? card.customFieldItems.find((fieldItem) => fieldItem?.idCustomField === idCustomField) : null;
101
- return typeof item?.value?.text === 'string' ? item.value.text : '';
102
- }
@@ -1,107 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { spawnSync } from 'node:child_process';
3
- import { join } from 'node:path';
4
-
5
- function normalizeGitHubRepositoryKey(value = '') {
6
- const text = String(value).trim();
7
- const match = text.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u);
8
- if (!match) return null;
9
- return `${match[1]}/${match[2]}`;
10
- }
11
-
12
- export function normalizeGitHubRemote(value = '') {
13
- const text = String(value).trim();
14
- if (!text) return null;
15
- let match = text.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/u);
16
- if (match) return { repositoryUrl: `https://github.com/${match[1]}/${match[2]}`, repositoryKey: `${match[1]}/${match[2]}` };
17
- match = text.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/u);
18
- if (match) return { repositoryUrl: `https://github.com/${match[1]}/${match[2]}`, repositoryKey: `${match[1]}/${match[2]}` };
19
- return null;
20
- }
21
-
22
- function encodePath(path = '') {
23
- return path.split('/').map((part) => encodeURIComponent(part)).join('/');
24
- }
25
-
26
- function repositoryFromPackage(root) {
27
- try {
28
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
29
- const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
30
- return normalizeGitHubRemote(String(raw ?? '').replace(/^git\+/, ''));
31
- } catch {
32
- return null;
33
- }
34
- }
35
-
36
- function gitValue(root, args) {
37
- const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
38
- if (result.status !== 0) return '';
39
- return result.stdout.trim();
40
- }
41
-
42
- function repositoryResult(repositoryUrl, repositoryKey, source, warnings = []) {
43
- return { ok: true, repositoryUrl, repositoryKey, source, warnings, errors: [] };
44
- }
45
-
46
- export function resolveGitHubRepositoryIdentity({ root = '.', repository, repositoryUrl, remoteUrl, env = process.env, git = true, mode = 'dry-run' } = {}) {
47
- const warnings = [];
48
- const explicitKey = normalizeGitHubRepositoryKey(repository);
49
- if (explicitKey) return repositoryResult(`https://github.com/${explicitKey}`, explicitKey, 'explicit-repository', warnings);
50
-
51
- const explicitRemote = normalizeGitHubRemote(repositoryUrl);
52
- if (explicitRemote) return repositoryResult(explicitRemote.repositoryUrl, explicitRemote.repositoryKey, 'explicit-repository-url', warnings);
53
-
54
- const actionsKey = normalizeGitHubRepositoryKey(env?.GITHUB_REPOSITORY);
55
- if (actionsKey) return repositoryResult(`https://github.com/${actionsKey}`, actionsKey, 'github-actions', warnings);
56
- if (mode === 'write' && env?.GITHUB_REPOSITORY && !actionsKey) {
57
- return { ok: false, repositoryUrl: '', repositoryKey: '', source: '', warnings, errors: [{ code: 'GITHUB_REPOSITORY_KEY_INVALID', message: 'Could not resolve a valid GitHub repository key from GITHUB_REPOSITORY. Fix: run the trusted workflow with GITHUB_REPOSITORY in owner/name form, then retry sync.' }] };
58
- }
59
-
60
- const injectedRemote = normalizeGitHubRemote(remoteUrl);
61
- if (injectedRemote) {
62
- warnings.push({ code: 'GITHUB_URL_REMOTE_FALLBACK', message: 'GitHub URL resolved from injected git remote fallback.' });
63
- return repositoryResult(injectedRemote.repositoryUrl, injectedRemote.repositoryKey, 'remote-url', warnings);
64
- }
65
-
66
- if (mode !== 'write') {
67
- const packageRepo = repositoryFromPackage(root);
68
- if (packageRepo) {
69
- warnings.push({ code: 'GITHUB_URL_PACKAGE_FALLBACK', message: 'GitHub URL resolved from package repository metadata.' });
70
- return repositoryResult(packageRepo.repositoryUrl, packageRepo.repositoryKey, 'package', warnings);
71
- }
72
- if (git) {
73
- const remote = gitValue(root, ['remote', 'get-url', 'origin']);
74
- const gitRepo = normalizeGitHubRemote(remote);
75
- if (gitRepo) {
76
- warnings.push({ code: 'GITHUB_URL_GIT_REMOTE_FALLBACK', message: 'GitHub URL resolved from local git origin remote.' });
77
- return repositoryResult(gitRepo.repositoryUrl, gitRepo.repositoryKey, 'git-remote', warnings);
78
- }
79
- }
80
- }
81
-
82
- const code = mode === 'write' ? 'GITHUB_REPOSITORY_KEY_UNRESOLVED' : 'GITHUB_URL_UNRESOLVED_REPOSITORY';
83
- const message = mode === 'write'
84
- ? 'Could not resolve a trusted GitHub repository key for Trello custom field entry. Fix: run write mode from GitHub Actions with GITHUB_REPOSITORY or provide explicit GitHub repository metadata, then retry sync.'
85
- : 'Could not resolve a GitHub repository URL.';
86
- return { ok: false, repositoryUrl: '', repositoryKey: '', source: '', warnings, errors: [{ code, message }] };
87
- }
88
-
89
- export function resolveGitHubFileUrl({ root = '.', file, repository, repositoryUrl, branch, remoteUrl, env = process.env, git = true, mode = 'dry-run' } = {}) {
90
- const identity = resolveGitHubRepositoryIdentity({ root, repository, repositoryUrl, remoteUrl, env, git, mode });
91
- const warnings = [...(identity.warnings ?? [])];
92
- let resolvedBranch = branch;
93
- if (!resolvedBranch && mode === 'write') {
94
- if (env?.GITHUB_REF_NAME) resolvedBranch = env.GITHUB_REF_NAME;
95
- else if (typeof env?.GITHUB_REF === 'string' && env.GITHUB_REF.startsWith('refs/heads/')) resolvedBranch = env.GITHUB_REF.slice('refs/heads/'.length);
96
- else if (env?.GITHUB_DEFAULT_BRANCH) resolvedBranch = env.GITHUB_DEFAULT_BRANCH;
97
- }
98
- if (!resolvedBranch && git && mode !== 'write') {
99
- resolvedBranch = gitValue(root, ['branch', '--show-current']);
100
- if (resolvedBranch) warnings.push({ code: 'GITHUB_URL_GIT_BRANCH_FALLBACK', message: 'GitHub branch resolved from local git state.' });
101
- }
102
- const errors = [];
103
- if (!identity.ok) errors.push(...identity.errors);
104
- if (!resolvedBranch) errors.push({ code: 'GITHUB_URL_UNRESOLVED_BRANCH', message: 'Could not resolve a GitHub branch.' });
105
- if (errors.length > 0) return { ok: false, url: '', repositoryKey: identity.repositoryKey ?? '', repositoryUrl: identity.repositoryUrl ?? '', warnings, errors };
106
- return { ok: true, url: `${identity.repositoryUrl}/blob/${encodeURIComponent(resolvedBranch)}/${encodePath(file)}`, repositoryKey: identity.repositoryKey, repositoryUrl: identity.repositoryUrl, warnings, errors: [] };
107
- }
@@ -1,52 +0,0 @@
1
- const TRELLO_KEYS = new Set(['trelloUrl', 'trelloShortLink', 'trelloCardId']);
2
-
3
- export function parseFrontmatter(content = '') {
4
- if (!String(content).startsWith('---\n')) return { data: {}, errors: [] };
5
- const end = String(content).indexOf('\n---', 4);
6
- if (end === -1) return { data: {}, errors: [{ code: 'TRELLO_FRONTMATTER_INVALID', message: 'Frontmatter start marker is missing a closing marker.' }] };
7
- const raw = String(content).slice(4, end).split('\n');
8
- const data = {};
9
- const errors = [];
10
- for (const [index, line] of raw.entries()) {
11
- const trimmed = line.trim();
12
- if (!trimmed || trimmed.startsWith('#')) continue;
13
- const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
14
- if (!match) continue;
15
- const key = match[1];
16
- let value = match[2].trim();
17
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
18
- if (Object.hasOwn(data, key)) errors.push({ code: 'TRELLO_FRONTMATTER_DUPLICATE_KEY', message: `Duplicate frontmatter key: ${key}.`, line: index + 2 });
19
- data[key] = value;
20
- }
21
- return { data, errors };
22
- }
23
-
24
- export function extractTrelloShortLink(trelloUrl = '') {
25
- let url;
26
- try {
27
- url = new URL(trelloUrl);
28
- } catch {
29
- return null;
30
- }
31
- if (url.protocol !== 'https:' || url.hostname !== 'trello.com') return null;
32
- const parts = url.pathname.split('/').filter(Boolean);
33
- if (parts[0] !== 'c' || !parts[1] || parts.length > 3) return null;
34
- return parts[1];
35
- }
36
-
37
- export function parseTrelloMetadata(content = '') {
38
- const { data, errors } = parseFrontmatter(content);
39
- const warnings = [];
40
- const unsupported = [...TRELLO_KEYS].filter((key) => key !== 'trelloUrl' && Object.hasOwn(data, key));
41
- const trelloUrl = typeof data.trelloUrl === 'string' ? data.trelloUrl.trim() : '';
42
- if (unsupported.length > 0) {
43
- warnings.push({ code: 'TRELLO_UNSUPPORTED_METADATA', message: `Unsupported Trello metadata ignored in Phase 1: ${unsupported.join(', ')}. Use trelloUrl only.` });
44
- }
45
- if (!trelloUrl) {
46
- errors.push({ code: 'TRELLO_METADATA_MISSING_URL', message: 'Missing required frontmatter field: trelloUrl.' });
47
- return { ok: false, trelloUrl: '', shortLink: '', warnings, errors };
48
- }
49
- const shortLink = extractTrelloShortLink(trelloUrl);
50
- if (!shortLink) errors.push({ code: 'TRELLO_METADATA_INVALID_URL', message: 'trelloUrl must match https://trello.com/c/<shortLink>/ or https://trello.com/c/<shortLink>/<slug>.' });
51
- return { ok: errors.length === 0, trelloUrl, shortLink: shortLink ?? '', warnings, errors };
52
- }
@@ -1,60 +0,0 @@
1
- function plural(value, label) {
2
- return `${value} ${label}${value === 1 ? '' : 's'}`;
3
- }
4
-
5
- function pushMessages(lines, label, messages, indent = '') {
6
- if ((messages ?? []).length === 0) return;
7
- lines.push(`${indent}${label}:`);
8
- for (const message of messages) lines.push(`${indent} - ${message.code}: ${message.message}`);
9
- }
10
-
11
- export function formatTrelloDryRunReport(report) {
12
- const lines = ['Trello docs sync dry-run'];
13
- lines.push(`Root: ${report.root}`);
14
- lines.push(`Scan paths: ${report.scanPaths.join(', ')}`);
15
- lines.push(`Matched: ${plural(report.totals.matchedFiles, 'file')}; planned: ${plural(report.totals.plannedUpdates, 'update')}; warnings: ${report.totals.warnings}; errors: ${report.totals.errors}`);
16
- pushMessages(lines, 'Warnings', report.warnings);
17
- pushMessages(lines, 'Errors', report.errors);
18
- for (const file of report.files) {
19
- lines.push('');
20
- lines.push(`File: ${file.file}`);
21
- lines.push(` Trello URL: ${file.trelloUrl || '(missing)'}`);
22
- if (file.shortLink) lines.push(` Trello short link: ${file.shortLink}`);
23
- lines.push(` GitHub URL: ${file.githubUrl || '(unresolved)'}`);
24
- if (file.repositoryKey) lines.push(` Repository entry: ${file.repositoryKey}`);
25
- lines.push(` Planned attachment: ${file.plannedAttachmentAction}`);
26
- lines.push(` Planned custom field: ${file.plannedCustomFieldAction}`);
27
- lines.push(` Sync status: ${file.syncStatus}`);
28
- lines.push(` Traceability: ${file.traceability.state}${file.traceability.details.length ? ` (${file.traceability.details.join(', ')})` : ''}`);
29
- pushMessages(lines, 'Warnings', file.warnings, ' ');
30
- pushMessages(lines, 'Errors', file.errors, ' ');
31
- }
32
- return `${lines.join('\n')}\n`;
33
- }
34
-
35
- export function formatTrelloWriteReport(report) {
36
- const lines = ['Trello docs sync write'];
37
- lines.push(`Root: ${report.root}`);
38
- lines.push(`Scan paths: ${report.scanPaths.join(', ')}`);
39
- lines.push(`Matched: ${plural(report.totals.matchedFiles, 'file')}; written: ${report.totals.written}; unchanged: ${report.totals.unchanged}; blocked: ${report.totals.blocked}; conflict: ${report.totals.conflict}; api-failed: ${report.totals.apiFailed}; warnings: ${report.totals.warnings}; errors: ${report.totals.errors}`);
40
- pushMessages(lines, 'Warnings', report.warnings);
41
- pushMessages(lines, 'Errors', report.errors);
42
- for (const file of report.files) {
43
- lines.push('');
44
- lines.push(`File: ${file.file}`);
45
- lines.push(` Trello URL: ${file.trelloUrl || '(missing)'}`);
46
- if (file.shortLink) lines.push(` Trello short link: ${file.shortLink}`);
47
- lines.push(` GitHub URL: ${file.githubUrl || '(unresolved)'}`);
48
- if (file.repositoryKey) lines.push(` Repository entry: ${file.repositoryKey}`);
49
- lines.push(` Attachment: ${file.attachment?.status ?? 'not-run'}`);
50
- const customFieldAction = file.customField?.action ? ` (${file.customField.action})` : '';
51
- lines.push(` Custom field: ${file.customField?.status ?? 'not-run'}${customFieldAction}`);
52
- if (file.customField?.name) lines.push(` Custom field name: ${file.customField.name}`);
53
- if (file.customField?.preservedRepositoryEntries > 0) lines.push(` Preserved repository entries: ${file.customField.preservedRepositoryEntries}`);
54
- lines.push(` Final status: ${file.finalStatus ?? file.syncStatus}`);
55
- lines.push(` Traceability: ${file.traceability.state}${file.traceability.details.length ? ` (${file.traceability.details.join(', ')})` : ''}`);
56
- pushMessages(lines, 'Warnings', file.warnings, ' ');
57
- pushMessages(lines, 'Errors', file.errors, ' ');
58
- }
59
- return `${lines.join('\n')}\n`;
60
- }
@@ -1,60 +0,0 @@
1
- import { extractDotdotgodTraceabilityBlocks, validateTraceabilityBlock } from '../docs/traceability.mjs';
2
- import { requiresTraceability } from '../memory/config.mjs';
3
-
4
- export function summarizeTraceability({ content = '', root = '.', file = '', config } = {}) {
5
- const blocks = extractDotdotgodTraceabilityBlocks(content);
6
- if (blocks.some((block) => block.error)) {
7
- return { state: 'invalid', details: blocks.filter((block) => block.error).map((block) => `Invalid JSON at line ${block.line}: ${block.error}`), errors: [{ code: 'TRELLO_TRACEABILITY_INVALID', message: 'Malformed json dotdotgod traceability block.' }] };
8
- }
9
- if (blocks.length === 0) {
10
- const required = requiresTraceability(file, config);
11
- return { state: required ? 'missing' : 'not_required', details: [], errors: required ? [{ code: 'TRELLO_TRACEABILITY_MISSING', message: 'Missing required json dotdotgod traceability block.' }] : [] };
12
- }
13
- const errors = blocks.flatMap((block) => validateTraceabilityBlock(block.data, root, file, block.line, config));
14
- if (errors.length > 0) return { state: 'invalid', details: errors.map((error) => `${error.code}: ${error.message.split('\n')[0]}`), errors: [{ code: 'TRELLO_TRACEABILITY_INVALID', message: 'Invalid json dotdotgod traceability block.' }] };
15
- const latest = blocks.at(-1).data;
16
- const details = [];
17
- if (Array.isArray(latest.implementedBy) && latest.implementedBy.length > 0) details.push(`implementedBy: ${latest.implementedBy.length}`);
18
- if (Array.isArray(latest.verifiedBy) && latest.verifiedBy.length > 0) details.push(`verifiedBy: ${latest.verifiedBy.length}`);
19
- if (Array.isArray(latest.relatedDocs) && latest.relatedDocs.length > 0) details.push(`relatedDocs: ${latest.relatedDocs.length}`);
20
- return { state: 'present', details, errors: [] };
21
- }
22
-
23
- function stripFrontmatter(content = '') {
24
- return String(content).replace(/^---\n[\s\S]*?\n---\n?/u, '');
25
- }
26
-
27
- function stripCodeBlocks(content = '') {
28
- return String(content).replace(/```[\s\S]*?```/gu, '');
29
- }
30
-
31
- function compactText(value = '', maxLength = 280) {
32
- const compact = String(value).replace(/\s+/gu, ' ').trim();
33
- if (compact.length <= maxLength) return compact;
34
- return `${compact.slice(0, maxLength - 1).trimEnd()}…`;
35
- }
36
-
37
- export function summarizeMarkdownPreview({ content = '', file = '' } = {}) {
38
- const body = stripCodeBlocks(stripFrontmatter(content));
39
- const lines = body.split(/\r?\n/u).map((line) => line.trim());
40
- const title = lines.find((line) => /^#\s+\S/u.test(line))?.replace(/^#\s+/u, '').trim() || file.split('/').at(-1)?.replace(/\.md$/u, '') || '';
41
- const summary = compactText(lines.find((line) => line && !line.startsWith('#') && !line.startsWith('<!--') && !line.startsWith('- ') && !line.startsWith('|')) ?? '');
42
- return { title: compactText(title, 120), summary };
43
- }
44
-
45
- export function buildLinkedDocsData({ file, content = '', githubUrl, trelloUrl, syncStatus, traceability, repositoryKey } = {}) {
46
- const preview = summarizeMarkdownPreview({ content, file });
47
- return {
48
- docsPath: file,
49
- githubUrl,
50
- trelloUrl,
51
- syncStatus,
52
- repositoryKey,
53
- repositoryLabel: String(repositoryKey ?? '').split('/').filter(Boolean).at(-1) || '',
54
- title: preview.title,
55
- summary: preview.summary,
56
- traceabilityState: traceability?.state ?? 'not_required',
57
- traceabilityDetails: [...(traceability?.details ?? [])],
58
- };
59
- }
60
-
@@ -1,205 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
- import { rel } from '../common/paths.mjs';
4
- import { matchPathPattern, readMemoryConfig, trelloSyncPaths } from '../memory/config.mjs';
5
- import { parseTrelloMetadata } from './metadata.mjs';
6
- import { resolveGitHubFileUrl } from './github-url.mjs';
7
- import { buildLinkedDocsData, summarizeTraceability } from './summary.mjs';
8
- import { formatTrelloDryRunReport, formatTrelloWriteReport } from './report.mjs';
9
- import { createTrelloClient, resolveTrelloCredentials } from './client.mjs';
10
- import { customFieldTextValue, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue } from './custom-fields.mjs';
11
-
12
- function shouldSkipDir(path) {
13
- return path === '.git' || path === 'node_modules' || path === '.dotdotgod';
14
- }
15
-
16
- function walkMarkdown(root, dir = root, files = []) {
17
- if (!existsSync(dir)) return files;
18
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
19
- if (entry.isDirectory()) {
20
- if (!shouldSkipDir(entry.name)) walkMarkdown(root, join(dir, entry.name), files);
21
- } else if (entry.isFile() && entry.name.endsWith('.md')) files.push(join(dir, entry.name));
22
- }
23
- return files;
24
- }
25
-
26
- function matchedByPatterns(path, patterns) {
27
- return patterns.some((pattern) => matchPathPattern(path, pattern));
28
- }
29
-
30
- function fileReport({ file, trelloUrl = '', shortLink = '', githubUrl = '', repositoryKey = '', syncStatus = 'blocked', traceability = { state: 'not_required', details: [] }, warnings = [], errors = [], linkedDocsData = null }) {
31
- return { file, trelloUrl, shortLink, githubUrl, repositoryKey, plannedAttachmentAction: errors.length > 0 ? 'blocked' : 'add-or-verify', plannedCustomFieldAction: errors.length > 0 ? 'blocked' : 'sync-linked-docs', syncStatus, traceability, linkedDocsData, warnings, errors };
32
- }
33
-
34
- function actionableError(error, fallback = 'Trello API request failed. Fix: check credentials, card access, and Trello response details, then retry.') {
35
- if (error && typeof error === 'object' && error.code && error.message) return error;
36
- return { code: 'TRELLO_API_FAILED', message: error?.message ? `${error.message} Fix: check credentials, card access, and Trello response details, then retry.` : fallback };
37
- }
38
-
39
- function duplicateErrors(files) {
40
- const byShortLink = new Map();
41
- for (const file of files.filter((item) => item.syncStatus === 'planned')) {
42
- byShortLink.set(file.shortLink, [...(byShortLink.get(file.shortLink) ?? []), file]);
43
- }
44
- const duplicateShortLinks = new Set([...byShortLink.entries()].filter(([, group]) => group.length > 1).map(([shortLink]) => shortLink));
45
- for (const shortLink of duplicateShortLinks) {
46
- const paths = byShortLink.get(shortLink).map((file) => file.file).join(', ');
47
- for (const file of byShortLink.get(shortLink)) {
48
- file.errors.push({ code: 'TRELLO_DUPLICATE_CARD', message: `Multiple markdown files point to Trello card ${shortLink}: ${paths}. Fix: keep only one markdown file linked to this Trello card, then retry sync.` });
49
- file.syncStatus = 'conflict';
50
- file.plannedAttachmentAction = 'conflict';
51
- file.plannedCustomFieldAction = 'conflict';
52
- }
53
- }
54
- return duplicateShortLinks.size;
55
- }
56
-
57
- export function planTrelloDryRun(rootInput = '.', options = {}) {
58
- const root = resolve(rootInput);
59
- const config = options.config ?? readMemoryConfig(root);
60
- const scanPaths = trelloSyncPaths(config);
61
- const reportWarnings = [];
62
- const reportErrors = [];
63
- if (config.errors?.length) reportErrors.push(...config.errors.map((error) => ({ code: error.code, message: error.message })));
64
- const allFiles = walkMarkdown(root).map((file) => ({ abs: file, rel: rel(root, file) })).filter((file) => matchedByPatterns(file.rel, scanPaths)).sort((a, b) => a.rel.localeCompare(b.rel));
65
- const files = [];
66
- if (allFiles.length === 0) reportWarnings.push({ code: 'TRELLO_NO_MATCHING_DOCS', message: 'No markdown files matched Trello sync paths.' });
67
- for (const item of allFiles) {
68
- const content = readFileSync(item.abs, 'utf8');
69
- const metadata = parseTrelloMetadata(content);
70
- const warnings = [...metadata.warnings];
71
- const errors = [...metadata.errors];
72
- let githubUrl = '';
73
- let repositoryKey = '';
74
- if (errors.length === 0) {
75
- const resolved = resolveGitHubFileUrl({ root, file: item.rel, env: options.env ?? process.env, mode: options.mode ?? 'dry-run', ...(options.github ?? {}) });
76
- warnings.push(...resolved.warnings);
77
- if (resolved.ok) {
78
- githubUrl = resolved.url;
79
- repositoryKey = resolved.repositoryKey;
80
- } else errors.push(...resolved.errors);
81
- }
82
- const traceability = summarizeTraceability({ content, root, file: item.rel, config });
83
- if (traceability.errors.length > 0) errors.push(...traceability.errors);
84
- const syncStatus = errors.length > 0 ? 'blocked' : 'planned';
85
- const linkedDocsData = buildLinkedDocsData({ file: item.rel, content, githubUrl, trelloUrl: metadata.trelloUrl, syncStatus, traceability, repositoryKey });
86
- files.push(fileReport({ file: item.rel, trelloUrl: metadata.trelloUrl, shortLink: metadata.shortLink, githubUrl, repositoryKey, syncStatus, traceability: { state: traceability.state, details: traceability.details }, warnings, errors, linkedDocsData }));
87
- }
88
- duplicateErrors(files);
89
- const warningCount = reportWarnings.length + files.reduce((sum, file) => sum + file.warnings.length, 0);
90
- const errorCount = reportErrors.length + files.reduce((sum, file) => sum + file.errors.length, 0);
91
- const report = { ok: errorCount === 0, command: 'trello sync', mode: 'dry-run', root, scanPaths, totals: { matchedFiles: files.length, plannedUpdates: files.filter((file) => file.syncStatus === 'planned').length, warnings: warningCount, errors: errorCount }, warnings: reportWarnings, errors: reportErrors, files };
92
- return { report, exitCode: errorCount > 0 ? 1 : 0 };
93
- }
94
-
95
- function trustedCiWriteContext(env = process.env) {
96
- const defaultBranch = env.GITHUB_DEFAULT_BRANCH || env.DOTDOTGOD_DEFAULT_BRANCH || env.GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH;
97
- return env.GITHUB_ACTIONS === 'true' && env.GITHUB_EVENT_NAME === 'push' && Boolean(defaultBranch) && env.GITHUB_REF === `refs/heads/${defaultBranch}`;
98
- }
99
-
100
- async function runTrelloWrite(rootInput = '.', options = {}) {
101
- const env = options.env ?? process.env;
102
- const { report: dryRunReport } = planTrelloDryRun(rootInput, { ...options, mode: 'write', env });
103
- const report = { ...dryRunReport, mode: 'write', customFieldName: dotdotgodCustomFieldName({ env, name: options.customFieldName }), files: dryRunReport.files.map((file) => ({ ...file, attachment: { status: 'not-run' }, customField: { status: 'not-run' }, finalStatus: file.syncStatus === 'blocked' ? 'blocked' : 'pending' })) };
104
-
105
- if (!trustedCiWriteContext(env)) {
106
- return { exitCode: 2, stderr: 'Trello write mode is only allowed from the trusted GitHub Actions default-branch push workflow. Fix: run dotdotgod trello sync <root> --dry-run locally or merge docs to the default branch so the workflow can write to Trello.', report };
107
- }
108
-
109
- if (report.files.length === 0) {
110
- report.totals = { matchedFiles: 0, written: 0, unchanged: 0, blocked: 0, conflict: 0, apiFailed: 0, warnings: report.totals.warnings, errors: report.totals.errors };
111
- report.ok = report.totals.errors === 0;
112
- return { report, exitCode: report.ok ? 0 : 1 };
113
- }
114
-
115
- const credentials = options.credentials ?? resolveTrelloCredentials({ env });
116
- if (!credentials.ok) return { exitCode: 2, stderr: credentials.error.message, report };
117
-
118
- const client = options.trelloClient ?? createTrelloClient({ credentials: credentials.credentials, fetchImpl: options.fetch, baseUrl: options.trelloBaseUrl });
119
-
120
- for (const file of report.files) {
121
- if (file.syncStatus === 'blocked') {
122
- file.finalStatus = 'blocked';
123
- continue;
124
- }
125
- if (file.syncStatus === 'conflict') {
126
- file.finalStatus = 'conflict';
127
- continue;
128
- }
129
- let step = 'card';
130
- try {
131
- const card = await client.getCard(file.shortLink);
132
- step = 'attachments';
133
- const attachments = await client.listAttachments(file.shortLink);
134
- if (attachments.some((attachment) => attachment?.url === file.githubUrl)) {
135
- file.attachment.status = 'unchanged';
136
- } else {
137
- step = 'attachment-create';
138
- await client.createAttachment(file.shortLink, file.githubUrl);
139
- file.attachment.status = 'written';
140
- }
141
- step = 'custom-field-definition';
142
- const fields = await client.listCustomFields(card.idBoard);
143
- let field = fields.find((item) => item?.name === report.customFieldName && item?.type === 'text');
144
- if (!field) field = await client.createCustomField(card.idBoard, { name: report.customFieldName, type: 'text' });
145
- if (!field?.id) throw { code: 'TRELLO_CUSTOM_FIELD_INVALID', message: 'Trello custom field creation returned no field id. Fix: check board custom field permissions and retry.' };
146
- const merged = mergeLinkedDocsCustomFieldValue(customFieldTextValue(card, field.id), file.linkedDocsData);
147
- if (!merged.ok) {
148
- file.customField.status = 'conflict';
149
- file.errors.push(merged.error);
150
- file.finalStatus = 'conflict';
151
- continue;
152
- }
153
- file.customField.action = merged.action;
154
- file.customField.preservedRepositoryEntries = merged.preservedRepositoryEntries ?? 0;
155
- file.customField.name = report.customFieldName;
156
- if (merged.status === 'unchanged') {
157
- file.customField.status = 'unchanged';
158
- } else {
159
- step = 'custom-field-update';
160
- await client.updateCardCustomFieldText(card.id, field.id, merged.text);
161
- file.customField.status = 'written';
162
- }
163
- file.finalStatus = file.attachment.status === 'written' || file.customField.status === 'written' ? 'written' : 'unchanged';
164
- } catch (error) {
165
- const normalized = actionableError(error);
166
- if (step === 'attachment-create') file.attachment.status = 'failed';
167
- else if (step.startsWith('custom-field')) file.customField.status = 'failed';
168
- file.finalStatus = 'api-failed';
169
- file.errors.push(normalized);
170
- }
171
- }
172
-
173
- const counts = { written: 0, unchanged: 0, blocked: 0, conflict: 0, apiFailed: 0 };
174
- for (const file of report.files) {
175
- if (file.finalStatus === 'written') counts.written += 1;
176
- else if (file.finalStatus === 'unchanged') counts.unchanged += 1;
177
- else if (file.finalStatus === 'blocked') counts.blocked += 1;
178
- else if (file.finalStatus === 'conflict') counts.conflict += 1;
179
- else if (file.finalStatus === 'api-failed') counts.apiFailed += 1;
180
- }
181
- const warningCount = report.warnings.length + report.files.reduce((sum, file) => sum + file.warnings.length, 0);
182
- const errorCount = report.errors.length + report.files.reduce((sum, file) => sum + file.errors.length, 0);
183
- report.totals = { matchedFiles: report.files.length, ...counts, warnings: warningCount, errors: errorCount };
184
- report.ok = counts.blocked === 0 && counts.conflict === 0 && counts.apiFailed === 0 && errorCount === 0;
185
- return { report, exitCode: report.ok ? 0 : 1 };
186
- }
187
-
188
- export function runTrelloSync(argv = [], options = {}) {
189
- if (argv[0] !== 'sync') return { exitCode: 2, stderr: `Unknown Trello command: ${argv[0] ?? ''}`.trim() };
190
- const args = argv.slice(1);
191
- let root = '.';
192
- let dryRun = false;
193
- for (const arg of args) {
194
- if (arg === '--dry-run') dryRun = true;
195
- else if (arg.startsWith('-')) return { exitCode: 2, stderr: `Unknown option: ${arg}` };
196
- else root = arg;
197
- }
198
- const resolvedRoot = resolve(root);
199
- if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return { exitCode: 2, stderr: `Project root not found: ${resolvedRoot}` };
200
- if (dryRun) {
201
- const { report, exitCode } = planTrelloDryRun(resolvedRoot, options);
202
- return { exitCode, stdout: formatTrelloDryRunReport(report), report };
203
- }
204
- return runTrelloWrite(resolvedRoot, options).then((result) => ({ ...result, stdout: result.stdout ?? (result.report ? formatTrelloWriteReport(result.report) : '') }));
205
- }