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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/bin/gitissues.js +11 -0
- package/package.json +56 -0
- package/src/cli.js +64 -0
- package/src/commands/auth.js +62 -0
- package/src/commands/create.js +360 -0
- package/src/commands/list.js +59 -0
- package/src/commands/open.js +17 -0
- package/src/commands/status.js +166 -0
- package/src/lib/attribution.js +216 -0
- package/src/lib/config.js +16 -0
- package/src/lib/db.js +436 -0
- package/src/lib/dedup.js +205 -0
- package/src/lib/defaults.js +252 -0
- package/src/lib/gh.js +273 -0
- package/src/lib/repo-picker.js +54 -0
- package/src/lib/safety.js +259 -0
- package/src/lib/templates.js +220 -0
|
@@ -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
|
+
}
|