@dotdotgod/cli 0.1.35 → 0.1.40

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.
@@ -0,0 +1,106 @@
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
+ export 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
+ function normalizeEntry(entry = {}) {
26
+ return buildLinkedDocsCustomFieldData(entry);
27
+ }
28
+
29
+ export function serializeLinkedDocsCustomFieldPayload(entries = []) {
30
+ return JSON.stringify({ version: 1, entries: entries.map(normalizeEntry) });
31
+ }
32
+
33
+ export function parseLinkedDocsCustomFieldPayload(text = '') {
34
+ const value = cleanString(text);
35
+ if (!value) return { ok: true, entries: [] };
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(value);
39
+ } catch (error) {
40
+ return {
41
+ ok: false,
42
+ error: {
43
+ code: 'TRELLO_CUSTOM_FIELD_INVALID',
44
+ 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.`,
45
+ },
46
+ };
47
+ }
48
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) {
49
+ return {
50
+ ok: false,
51
+ error: {
52
+ code: 'TRELLO_CUSTOM_FIELD_INVALID',
53
+ 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.',
54
+ },
55
+ };
56
+ }
57
+ const entries = parsed.entries.map(normalizeEntry);
58
+ const keys = entries.map((entry) => entry.repositoryKey).filter(Boolean);
59
+ if (keys.length !== new Set(keys).size) {
60
+ return {
61
+ ok: false,
62
+ error: {
63
+ code: 'TRELLO_CUSTOM_FIELD_DUPLICATE_REPOSITORY',
64
+ message: 'dotdotgod custom field contains duplicate repository entries. Fix: keep one entry per repository key, then retry sync.',
65
+ },
66
+ };
67
+ }
68
+ return { ok: true, entries };
69
+ }
70
+
71
+ export function mergeLinkedDocsCustomFieldValue(existingText = '', entry = {}) {
72
+ const current = normalizeEntry(entry);
73
+ if (!current.repositoryKey) {
74
+ return {
75
+ ok: false,
76
+ error: {
77
+ code: 'TRELLO_CUSTOM_FIELD_REPOSITORY_MISSING',
78
+ 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.',
79
+ },
80
+ };
81
+ }
82
+ const parsed = parseLinkedDocsCustomFieldPayload(existingText);
83
+ if (!parsed.ok) return parsed;
84
+ const entries = [...parsed.entries];
85
+ const index = entries.findIndex((item) => item.repositoryKey === current.repositoryKey);
86
+ let action = 'create-payload';
87
+ if (index >= 0) {
88
+ const before = JSON.stringify(entries[index]);
89
+ const after = JSON.stringify(current);
90
+ action = before === after ? 'unchanged-entry' : 'replace-entry';
91
+ entries[index] = current;
92
+ } else if (entries.length > 0) {
93
+ action = 'append-entry';
94
+ entries.push(current);
95
+ } else {
96
+ entries.push(current);
97
+ }
98
+ const text = serializeLinkedDocsCustomFieldPayload(entries);
99
+ const status = cleanString(existingText) === text ? 'unchanged' : 'written';
100
+ return { ok: true, status, action: status === 'unchanged' ? 'unchanged-entry' : action, text, entries, preservedRepositoryEntries: entries.filter((item) => item.repositoryKey !== current.repositoryKey).length };
101
+ }
102
+
103
+ export function customFieldTextValue(card = {}, idCustomField = '') {
104
+ const item = Array.isArray(card.customFieldItems) ? card.customFieldItems.find((fieldItem) => fieldItem?.idCustomField === idCustomField) : null;
105
+ return typeof item?.value?.text === 'string' ? item.value.text : '';
106
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+ export 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
+ }
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,60 @@
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
+ }
@@ -0,0 +1,60 @@
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
+
@@ -0,0 +1,205 @@
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
+ }