tissues 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { findRepoRoot, loadConfig } from './defaults.js'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Built-in templates
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const BUILT_IN_TEMPLATES = {
11
+ default: {
12
+ name: 'Default',
13
+ body: `## Summary\n\n{{description}}\n\n## Details\n\n## Additional context\n`,
14
+ },
15
+ bug: {
16
+ name: 'Bug Report',
17
+ body: `## Bug\n\n{{description}}\n\n## Steps to reproduce\n\n1. \n\n## Expected behavior\n\n## Actual behavior\n\n## Environment\n\n`,
18
+ },
19
+ feature: {
20
+ name: 'Feature Request',
21
+ body: `## Feature\n\n{{description}}\n\n## Motivation\n\n## Proposed solution\n\n## Alternatives considered\n\n`,
22
+ },
23
+ security: {
24
+ name: 'Security Issue',
25
+ body: `## Security Issue\n\n{{description}}\n\n## Severity\n\n## Affected components\n\n## Suggested fix\n\n`,
26
+ },
27
+ performance: {
28
+ name: 'Performance Issue',
29
+ body: `## Performance\n\n{{description}}\n\n## Current metric\n\n## Target metric\n\n## Affected area\n\n`,
30
+ },
31
+ refactor: {
32
+ name: 'Refactor',
33
+ body: `## Refactor\n\n{{description}}\n\n## Motivation\n\n## Scope\n\n## Risk assessment\n\n`,
34
+ },
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Derive the template name from a filename by stripping its `.md` extension.
43
+ *
44
+ * @param {string} filename - e.g. 'bug.md'
45
+ * @returns {string} e.g. 'bug'
46
+ */
47
+ function templateNameFromFile(filename) {
48
+ return path.basename(filename, '.md')
49
+ }
50
+
51
+ /**
52
+ * Read all `.md` files from a directory and return them as a map of
53
+ * `{ name -> { name, body } }`. Returns an empty object if the directory does
54
+ * not exist or cannot be read.
55
+ *
56
+ * @param {string} dir
57
+ * @param {'repo' | 'user'} source
58
+ * @returns {Array<{ key: string, name: string, body: string, source: string }>}
59
+ */
60
+ function readTemplatesFromDir(dir, source) {
61
+ try {
62
+ const entries = fs.readdirSync(dir)
63
+ return entries
64
+ .filter((f) => f.endsWith('.md'))
65
+ .map((f) => {
66
+ const key = templateNameFromFile(f)
67
+ const body = fs.readFileSync(path.join(dir, f), 'utf8')
68
+ // Use capitalized key as display name unless a front-matter `name:` line
69
+ // exists. Keep it simple — no full YAML parser dependency.
70
+ const nameLine = body.match(/^name:\s*(.+)$/m)
71
+ const name = nameLine ? nameLine[1].trim() : key.charAt(0).toUpperCase() + key.slice(1)
72
+ return { key, name, body, source }
73
+ })
74
+ } catch {
75
+ return []
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve the template directory for a given source.
81
+ *
82
+ * @param {'repo' | 'user'} source
83
+ * @param {string|null} repoRoot
84
+ * @param {object} cfg - loaded config object
85
+ * @returns {string}
86
+ */
87
+ function resolveTemplateDir(source, repoRoot, cfg) {
88
+ if (source === 'user') {
89
+ return path.join(os.homedir(), '.config', 'gitissues', 'templates')
90
+ }
91
+ // repo source
92
+ const templateDir = cfg.templates?.dir ?? '.gitissues/templates'
93
+ if (path.isAbsolute(templateDir)) return templateDir
94
+ if (repoRoot) return path.join(repoRoot, templateDir)
95
+ return path.resolve(templateDir)
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Public API
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * List all available templates from all sources.
104
+ *
105
+ * Returns an array of descriptor objects. When multiple sources define a
106
+ * template with the same key, all entries are included (higher-priority sources
107
+ * will shadow lower-priority ones in `loadTemplate`).
108
+ *
109
+ * Priority (highest → lowest): repo > user > built-in
110
+ *
111
+ * @param {string} [repoRoot] - path to repo root; auto-detected if omitted
112
+ * @returns {Array<{ key: string, name: string, source: 'built-in' | 'repo' | 'user' }>}
113
+ */
114
+ export function listTemplates(repoRoot) {
115
+ const root = repoRoot ?? findRepoRoot()
116
+ const cfg = loadConfig(root)
117
+
118
+ // Built-in (lowest priority, listed last)
119
+ const builtIn = Object.entries(BUILT_IN_TEMPLATES).map(([key, tpl]) => ({
120
+ key,
121
+ name: tpl.name,
122
+ source: /** @type {'built-in'} */ ('built-in'),
123
+ }))
124
+
125
+ // User templates
126
+ const userDir = resolveTemplateDir('user', root, cfg)
127
+ const userTemplates = readTemplatesFromDir(userDir, 'user').map(({ key, name }) => ({
128
+ key,
129
+ name,
130
+ source: /** @type {'user'} */ ('user'),
131
+ }))
132
+
133
+ // Repo templates (highest priority among file-based)
134
+ const repoDir = resolveTemplateDir('repo', root, cfg)
135
+ const repoTemplates = readTemplatesFromDir(repoDir, 'repo').map(({ key, name }) => ({
136
+ key,
137
+ name,
138
+ source: /** @type {'repo'} */ ('repo'),
139
+ }))
140
+
141
+ // Return repo first so callers can see which keys override which
142
+ return [...repoTemplates, ...userTemplates, ...builtIn]
143
+ }
144
+
145
+ /**
146
+ * Load a template by name, applying source priority:
147
+ * repo templates > user templates > built-in templates
148
+ *
149
+ * @param {string} [name] - template key (e.g. 'bug'); defaults to config value
150
+ * @param {string} [repoRoot] - path to repo root; auto-detected if omitted
151
+ * @returns {{ key: string, name: string, body: string, source: 'built-in' | 'repo' | 'user' }}
152
+ * @throws {Error} if the named template cannot be found in any source
153
+ */
154
+ export function loadTemplate(name, repoRoot) {
155
+ const root = repoRoot ?? findRepoRoot()
156
+ const cfg = loadConfig(root)
157
+ const templateName = name ?? cfg.templates?.default ?? 'default'
158
+
159
+ // 1. Repo templates (highest priority)
160
+ const repoDir = resolveTemplateDir('repo', root, cfg)
161
+ const repoFile = path.join(repoDir, `${templateName}.md`)
162
+ if (fs.existsSync(repoFile)) {
163
+ const body = fs.readFileSync(repoFile, 'utf8')
164
+ const nameLine = body.match(/^name:\s*(.+)$/m)
165
+ const displayName = nameLine
166
+ ? nameLine[1].trim()
167
+ : templateName.charAt(0).toUpperCase() + templateName.slice(1)
168
+ return { key: templateName, name: displayName, body, source: 'repo' }
169
+ }
170
+
171
+ // 2. User templates
172
+ const userDir = resolveTemplateDir('user', root, cfg)
173
+ const userFile = path.join(userDir, `${templateName}.md`)
174
+ if (fs.existsSync(userFile)) {
175
+ const body = fs.readFileSync(userFile, 'utf8')
176
+ const nameLine = body.match(/^name:\s*(.+)$/m)
177
+ const displayName = nameLine
178
+ ? nameLine[1].trim()
179
+ : templateName.charAt(0).toUpperCase() + templateName.slice(1)
180
+ return { key: templateName, name: displayName, body, source: 'user' }
181
+ }
182
+
183
+ // 3. Built-in templates (lowest priority)
184
+ const builtIn = BUILT_IN_TEMPLATES[templateName]
185
+ if (builtIn) {
186
+ return { key: templateName, name: builtIn.name, body: builtIn.body, source: 'built-in' }
187
+ }
188
+
189
+ throw new Error(
190
+ `Template "${templateName}" not found. ` +
191
+ `Available templates: ${Object.keys(BUILT_IN_TEMPLATES).join(', ')}`
192
+ )
193
+ }
194
+
195
+ /**
196
+ * Render a template body by replacing `{{variable}}` placeholders with values
197
+ * from the `variables` map.
198
+ *
199
+ * Supported variables (any key can be provided):
200
+ * - `title` — issue title
201
+ * - `description` — user's description
202
+ * - `agent` — agent name
203
+ * - `session` — session ID
204
+ * - `date` — ISO date string
205
+ * - `repo` — repo name (owner/name)
206
+ *
207
+ * Unknown variables in the template that have no matching key in `variables`
208
+ * are left as-is so callers can run multiple passes if needed.
209
+ *
210
+ * @param {string} templateBody - raw template string with `{{...}}` tokens
211
+ * @param {Record<string, string|number|null|undefined>} variables - substitution map
212
+ * @returns {string} rendered body
213
+ */
214
+ export function renderTemplate(templateBody, variables) {
215
+ return templateBody.replace(/\{\{(\w+)\}\}/g, (match, key) => {
216
+ const value = variables[key]
217
+ if (value == null) return match // leave unknown/missing vars intact
218
+ return String(value)
219
+ })
220
+ }