diff --git a/docs/code-bridge/requests/REQ-2026-04-14-issue-tracker.md b/docs/code-bridge/archive/REQ-2026-04-14-issue-tracker.md similarity index 100% rename from docs/code-bridge/requests/REQ-2026-04-14-issue-tracker.md rename to docs/code-bridge/archive/REQ-2026-04-14-issue-tracker.md diff --git a/services/arbiter-3.0/migrations/141_issues.sql b/services/arbiter-3.0/migrations/141_issues.sql new file mode 100644 index 0000000..a0c6e55 --- /dev/null +++ b/services/arbiter-3.0/migrations/141_issues.sql @@ -0,0 +1,46 @@ +-- Migration 141: Trinity Console Issue Tracker (Task #166) +-- Filed by Chronicler #89 on REQ-2026-04-14-issue-tracker. +-- Creates issues + attachments + comments tables. + +CREATE TABLE IF NOT EXISTS issues ( + id SERIAL PRIMARY KEY, + issue_number INTEGER UNIQUE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(20) DEFAULT 'open', -- open, in_progress, blocked, resolved, closed + priority VARCHAR(20) DEFAULT 'medium', -- critical, high, medium, low + category VARCHAR(50) DEFAULT 'general', -- bug, feature, content, infrastructure, holly, general + submitted_by VARCHAR(100) NOT NULL, -- Discord username + assigned_to VARCHAR(100) DEFAULT 'unassigned', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + resolved_by VARCHAR(100) +); + +CREATE INDEX IF NOT EXISTS idx_issues_status ON issues (status); +CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues (priority); +CREATE INDEX IF NOT EXISTS idx_issues_category ON issues (category); +CREATE INDEX IF NOT EXISTS idx_issues_submitter ON issues (submitted_by); + +CREATE TABLE IF NOT EXISTS issue_attachments ( + id SERIAL PRIMARY KEY, + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255), + mime_type VARCHAR(100), + file_size INTEGER, + uploaded_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_attachments_issue ON issue_attachments (issue_id); + +CREATE TABLE IF NOT EXISTS issue_comments ( + id SERIAL PRIMARY KEY, + issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE, + author VARCHAR(100) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_issue_comments_issue ON issue_comments (issue_id); diff --git a/services/arbiter-3.0/package.json b/services/arbiter-3.0/package.json index a589702..70edf2c 100644 --- a/services/arbiter-3.0/package.json +++ b/services/arbiter-3.0/package.json @@ -21,6 +21,7 @@ "express": "^4.18.2", "express-ejs-layouts": "^2.5.1", "express-session": "^1.19.0", + "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", "passport": "^0.7.0", "passport-discord": "^0.1.4", diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index d0ce6a3..af9537d 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -24,6 +24,7 @@ const mcpLogsRouter = require('./mcp-logs'); const tasksRouter = require('./tasks'); const forgeRouter = require('./forge'); const nodeHealthRouter = require('./node-health'); +const issuesRouter = require('./issues'); router.use(requireTrinityAccess); @@ -143,5 +144,6 @@ router.use('/mcp-logs', mcpLogsRouter); router.use('/tasks', tasksRouter); router.use('/forge', forgeRouter); router.use('/node-health', nodeHealthRouter); +router.use('/issues', issuesRouter); module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/issues.js b/services/arbiter-3.0/src/routes/admin/issues.js new file mode 100644 index 0000000..340f2e3 --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/issues.js @@ -0,0 +1,361 @@ +/** + * Issue Tracker — Trinity Console + * + * Task #166 — REQ-2026-04-14-issue-tracker (Chronicler #89) + * Holly's primary submission channel. Mobile-first form, screenshot upload + * from camera roll, minimal fields, fire-and-forget. + * + * Session auth (shared admin middleware). REST-at-distance via /api/internal/issues + * lives in src/routes/api.js for n8n/external submission. + * + * Routes + * GET /admin/issues — list + filters + * GET /admin/issues/new — mobile submit form + * POST /admin/issues — create (with optional screenshots) + * GET /admin/issues/:id — detail page + * POST /admin/issues/:id/status — change status (HTMX) + * POST /admin/issues/:id/assign — change assignee (HTMX) + * POST /admin/issues/:id/comments — add comment + * POST /admin/issues/:id/upload — add more screenshots after create + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const multer = require('multer'); +const db = require('../../database'); +const notifier = require('../../services/issueNotifier'); + +const router = express.Router(); + +const STATUSES = ['open', 'in_progress', 'blocked', 'resolved', 'closed']; +const PRIORITIES = ['critical', 'high', 'medium', 'low']; +const CATEGORIES = ['bug', 'feature', 'content', 'infrastructure', 'holly', 'general']; +const ASSIGNEES = ['Michael', 'Meg', 'Holly', 'Trinity', 'unassigned']; + +// ----------------------------------------------------------------------------- +// Upload storage +// ----------------------------------------------------------------------------- +const UPLOAD_DIR = path.resolve(__dirname, '..', '..', '..', 'uploads', 'issues'); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOAD_DIR), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase().slice(0, 8); + const unique = Date.now() + '-' + Math.random().toString(36).slice(2, 10); + cb(null, `${unique}${ext}`); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024, files: 6 }, // 10MB each, max 6 screenshots + fileFilter: (req, file, cb) => { + if (!/^image\//.test(file.mimetype)) { + return cb(new Error('Only image uploads are allowed')); + } + cb(null, true); + } +}); + +// Lenient wrapper so multer errors don't kill the request +function uploadMaybe(field) { + return (req, res, next) => { + upload.array(field, 6)(req, res, (err) => { + if (err) { + console.warn('[Issues] upload warning:', err.message); + req.uploadError = err.message; + } + next(); + }); + }; +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- +async function nextIssueNumber() { + const r = await db.query('SELECT COALESCE(MAX(issue_number), 0) + 1 AS n FROM issues'); + return r.rows[0].n; +} + +async function loadIssueFull(id) { + const iss = await db.query('SELECT * FROM issues WHERE id = $1', [id]); + if (iss.rows.length === 0) return null; + const issue = iss.rows[0]; + const atts = await db.query( + 'SELECT * FROM issue_attachments WHERE issue_id = $1 ORDER BY uploaded_at ASC', + [id] + ); + const cmts = await db.query( + 'SELECT * FROM issue_comments WHERE issue_id = $1 ORDER BY created_at ASC', + [id] + ); + issue.attachments = atts.rows; + issue.comments = cmts.rows; + return issue; +} + +function currentUsername(req) { + return req.user?.username || req.user?.global_name || 'Trinity Console'; +} + +// ----------------------------------------------------------------------------- +// GET /admin/issues — list +// ----------------------------------------------------------------------------- +router.get('/', async (req, res) => { + try { + const { status, priority, category, submitter, sort } = req.query; + const params = []; + let where = 'WHERE 1=1'; + let p = 0; + + if (status) { p++; where += ` AND status = $${p}`; params.push(status); } + else if (!req.query.all) { where += ` AND status NOT IN ('resolved','closed')`; } + if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); } + if (category) { p++; where += ` AND category = $${p}`; params.push(category); } + if (submitter) { p++; where += ` AND submitted_by = $${p}`; params.push(submitter); } + + let order = `CASE status + WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 WHEN 'open' THEN 3 + WHEN 'resolved' THEN 4 WHEN 'closed' THEN 5 END, + CASE priority + WHEN 'critical' THEN 1 WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END, + issue_number DESC`; + if (sort === 'newest') order = 'created_at DESC'; + if (sort === 'updated') order = 'updated_at DESC'; + if (sort === 'priority') order = `CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END, issue_number DESC`; + + const result = await db.query( + `SELECT i.*, + (SELECT COUNT(*) FROM issue_attachments a WHERE a.issue_id = i.id) AS attachment_count, + (SELECT COUNT(*) FROM issue_comments c WHERE c.issue_id = i.id) AS comment_count + FROM issues i ${where} + ORDER BY ${order}`, + params + ); + + const stats = await db.query(` + SELECT + COUNT(*) FILTER (WHERE status NOT IN ('resolved','closed')) AS active, + COUNT(*) FILTER (WHERE status = 'open') AS open, + COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress, + COUNT(*) FILTER (WHERE status = 'blocked') AS blocked, + COUNT(*) FILTER (WHERE priority IN ('critical','high') AND status NOT IN ('resolved','closed')) AS urgent + FROM issues + `); + + res.render('admin/issues/index', { + title: 'Issues', + currentPath: '/issues', + issues: result.rows, + stats: stats.rows[0], + filters: { status, priority, category, submitter, sort, all: req.query.all }, + statuses: STATUSES, + priorities: PRIORITIES, + categories: CATEGORIES, + assignees: ASSIGNEES, + adminUser: req.user, + layout: 'layout' + }); + } catch (err) { + console.error('[Issues] list error:', err); + res.status(500).send('Error loading issues'); + } +}); + +// ----------------------------------------------------------------------------- +// GET /admin/issues/new — mobile-first submit form +// ----------------------------------------------------------------------------- +router.get('/new', (req, res) => { + res.render('admin/issues/new', { + title: 'New Issue', + currentPath: '/issues', + priorities: PRIORITIES, + categories: CATEGORIES, + adminUser: req.user, + layout: 'layout' + }); +}); + +// ----------------------------------------------------------------------------- +// POST /admin/issues — create +// ----------------------------------------------------------------------------- +router.post('/', uploadMaybe('screenshots'), async (req, res) => { + try { + const { title, description, priority, category } = req.body; + if (!title || !title.trim()) { + return res.status(400).send('Title required'); + } + + const issueNumber = await nextIssueNumber(); + const submittedBy = currentUsername(req); + + const inserted = await db.query( + `INSERT INTO issues (issue_number, title, description, priority, category, submitted_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [ + issueNumber, + title.trim(), + description || null, + PRIORITIES.includes(priority) ? priority : 'medium', + CATEGORIES.includes(category) ? category : 'general', + submittedBy + ] + ); + const issue = inserted.rows[0]; + + // Attach screenshots + if (req.files && req.files.length > 0) { + for (const f of req.files) { + await db.query( + `INSERT INTO issue_attachments (issue_id, filename, original_name, mime_type, file_size) + VALUES ($1, $2, $3, $4, $5)`, + [issue.id, f.filename, f.originalname, f.mimetype, f.size] + ); + } + } + + console.log(`🐛 [Issues] #${issueNumber} opened by ${submittedBy}: ${title}`); + notifier.notifyCreated(issue); + + res.redirect(`/admin/issues/${issue.id}`); + } catch (err) { + console.error('[Issues] create error:', err); + res.status(500).send('Error creating issue'); + } +}); + +// ----------------------------------------------------------------------------- +// GET /admin/issues/:id — detail +// ----------------------------------------------------------------------------- +router.get('/:id(\\d+)', async (req, res) => { + try { + const issue = await loadIssueFull(req.params.id); + if (!issue) return res.status(404).send('Issue not found'); + + res.render('admin/issues/detail', { + title: `Issue #${issue.issue_number}`, + currentPath: '/issues', + issue, + statuses: STATUSES, + priorities: PRIORITIES, + categories: CATEGORIES, + assignees: ASSIGNEES, + adminUser: req.user, + layout: 'layout' + }); + } catch (err) { + console.error('[Issues] detail error:', err); + res.status(500).send('Error loading issue'); + } +}); + +// ----------------------------------------------------------------------------- +// POST /admin/issues/:id/status +// ----------------------------------------------------------------------------- +router.post('/:id(\\d+)/status', async (req, res) => { + try { + const { status } = req.body; + if (!STATUSES.includes(status)) return res.status(400).send('Invalid status'); + + const actor = currentUsername(req); + const extras = []; + const params = [status, req.params.id]; + + if (status === 'resolved' || status === 'closed') { + extras.push('resolved_at = NOW()', `resolved_by = $3`); + params.splice(2, 0, actor); + } + + const sql = `UPDATE issues SET status = $1, updated_at = NOW()${extras.length ? ', ' + extras.join(', ') : ''} + WHERE id = $${params.length} RETURNING *`; + const r = await db.query(sql, params); + if (r.rows.length === 0) return res.status(404).send('Not found'); + + notifier.notifyStatusChange(r.rows[0], status, actor); + res.redirect(`/admin/issues/${req.params.id}`); + } catch (err) { + console.error('[Issues] status error:', err); + res.status(500).send('Error updating status'); + } +}); + +// ----------------------------------------------------------------------------- +// POST /admin/issues/:id/assign +// ----------------------------------------------------------------------------- +router.post('/:id(\\d+)/assign', async (req, res) => { + try { + const { assigned_to } = req.body; + await db.query( + `UPDATE issues SET assigned_to = $1, updated_at = NOW() WHERE id = $2`, + [assigned_to || 'unassigned', req.params.id] + ); + res.redirect(`/admin/issues/${req.params.id}`); + } catch (err) { + console.error('[Issues] assign error:', err); + res.status(500).send('Error updating assignment'); + } +}); + +// ----------------------------------------------------------------------------- +// POST /admin/issues/:id/comments +// ----------------------------------------------------------------------------- +router.post('/:id(\\d+)/comments', async (req, res) => { + try { + const { content } = req.body; + if (!content || !content.trim()) return res.redirect(`/admin/issues/${req.params.id}`); + + const author = currentUsername(req); + await db.query( + `INSERT INTO issue_comments (issue_id, author, content) VALUES ($1, $2, $3)`, + [req.params.id, author, content.trim()] + ); + await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]); + + const issue = await loadIssueFull(req.params.id); + if (issue) notifier.notifyComment(issue, author); + + res.redirect(`/admin/issues/${req.params.id}`); + } catch (err) { + console.error('[Issues] comment error:', err); + res.status(500).send('Error adding comment'); + } +}); + +// ----------------------------------------------------------------------------- +// POST /admin/issues/:id/upload — add screenshots post-hoc +// ----------------------------------------------------------------------------- +router.post('/:id(\\d+)/upload', uploadMaybe('screenshots'), async (req, res) => { + try { + if (req.files && req.files.length > 0) { + for (const f of req.files) { + await db.query( + `INSERT INTO issue_attachments (issue_id, filename, original_name, mime_type, file_size) + VALUES ($1, $2, $3, $4, $5)`, + [req.params.id, f.filename, f.originalname, f.mimetype, f.size] + ); + } + await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]); + } + res.redirect(`/admin/issues/${req.params.id}`); + } catch (err) { + console.error('[Issues] upload error:', err); + res.status(500).send('Error uploading attachments'); + } +}); + +// ----------------------------------------------------------------------------- +// GET /admin/issues/attachments/:filename — serve image +// ----------------------------------------------------------------------------- +router.get('/attachments/:filename', (req, res) => { + const name = req.params.filename; + if (!/^[a-zA-Z0-9._-]+$/.test(name)) return res.status(400).send('Bad filename'); + const abs = path.join(UPLOAD_DIR, name); + if (!abs.startsWith(UPLOAD_DIR) || !fs.existsSync(abs)) return res.status(404).send('Not found'); + res.sendFile(abs); +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/routes/api.js b/services/arbiter-3.0/src/routes/api.js index e7bd061..7dd0e71 100644 --- a/services/arbiter-3.0/src/routes/api.js +++ b/services/arbiter-3.0/src/routes/api.js @@ -516,4 +516,131 @@ router.patch('/tasks/:id', async (req, res) => { } }); +// ============================================================================= +// Issue Tracker — /api/internal/issues* +// Task #166 — REQ-2026-04-14-issue-tracker +// Token-auth'd REST surface that mirrors the admin UI. Use for n8n / external +// submitters. The admin UI under /admin/issues uses the same DB tables. +// ============================================================================= + +const issueNotifier = require('../services/issueNotifier'); + +const ISSUE_STATUSES = ['open', 'in_progress', 'blocked', 'resolved', 'closed']; +const ISSUE_PRIORITIES = ['critical', 'high', 'medium', 'low']; +const ISSUE_CATEGORIES = ['bug', 'feature', 'content', 'infrastructure', 'holly', 'general']; + +router.get('/issues', async (req, res) => { + try { + const { status, priority, category } = req.query; + const params = []; + let where = 'WHERE 1=1'; + let p = 0; + if (status) { p++; where += ` AND status = $${p}`; params.push(status); } + if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); } + if (category) { p++; where += ` AND category = $${p}`; params.push(category); } + const result = await db.query( + `SELECT * FROM issues ${where} ORDER BY issue_number DESC LIMIT 200`, + params + ); + res.json({ issues: result.rows }); + } catch (err) { + console.error('[API Issues] list error:', err); + res.status(500).json({ error: 'Failed to list issues' }); + } +}); + +router.post('/issues', async (req, res) => { + try { + const { title, description, priority, category, submitted_by } = req.body; + if (!title || !submitted_by) { + return res.status(400).json({ error: 'title and submitted_by required' }); + } + const next = await db.query('SELECT COALESCE(MAX(issue_number), 0) + 1 AS n FROM issues'); + const issueNumber = next.rows[0].n; + const result = await db.query( + `INSERT INTO issues (issue_number, title, description, priority, category, submitted_by) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [ + issueNumber, + title, + description || null, + ISSUE_PRIORITIES.includes(priority) ? priority : 'medium', + ISSUE_CATEGORIES.includes(category) ? category : 'general', + submitted_by + ] + ); + const issue = result.rows[0]; + issueNotifier.notifyCreated(issue); + res.json({ success: true, issue }); + } catch (err) { + console.error('[API Issues] create error:', err); + res.status(500).json({ error: 'Failed to create issue' }); + } +}); + +router.get('/issues/:id', async (req, res) => { + try { + const iss = await db.query('SELECT * FROM issues WHERE id = $1', [req.params.id]); + if (iss.rows.length === 0) return res.status(404).json({ error: 'Not found' }); + const atts = await db.query( + 'SELECT * FROM issue_attachments WHERE issue_id = $1 ORDER BY uploaded_at', + [req.params.id] + ); + const cmts = await db.query( + 'SELECT * FROM issue_comments WHERE issue_id = $1 ORDER BY created_at', + [req.params.id] + ); + res.json({ issue: iss.rows[0], attachments: atts.rows, comments: cmts.rows }); + } catch (err) { + console.error('[API Issues] detail error:', err); + res.status(500).json({ error: 'Failed to load issue' }); + } +}); + +router.patch('/issues/:id', async (req, res) => { + try { + const { status, priority, category, assigned_to } = req.body; + const updates = []; + const params = []; + let p = 0; + if (status) { if (!ISSUE_STATUSES.includes(status)) return res.status(400).json({ error: 'bad status' }); p++; updates.push(`status = $${p}`); params.push(status); } + if (priority) { if (!ISSUE_PRIORITIES.includes(priority)) return res.status(400).json({ error: 'bad priority' }); p++; updates.push(`priority = $${p}`); params.push(priority); } + if (category) { if (!ISSUE_CATEGORIES.includes(category)) return res.status(400).json({ error: 'bad category' }); p++; updates.push(`category = $${p}`); params.push(category); } + if (assigned_to) { p++; updates.push(`assigned_to = $${p}`); params.push(assigned_to); } + if (status === 'resolved' || status === 'closed') { + updates.push('resolved_at = NOW()'); + p++; updates.push(`resolved_by = $${p}`); params.push(req.body.actor || 'api'); + } + updates.push('updated_at = NOW()'); + if (updates.length <= 1) return res.status(400).json({ error: 'Nothing to update' }); + p++; params.push(req.params.id); + const r = await db.query( + `UPDATE issues SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`, + params + ); + if (r.rows.length === 0) return res.status(404).json({ error: 'Not found' }); + if (status) issueNotifier.notifyStatusChange(r.rows[0], status, req.body.actor || 'api'); + res.json({ success: true, issue: r.rows[0] }); + } catch (err) { + console.error('[API Issues] patch error:', err); + res.status(500).json({ error: 'Failed to update issue' }); + } +}); + +router.post('/issues/:id/comments', async (req, res) => { + try { + const { author, content } = req.body; + if (!author || !content) return res.status(400).json({ error: 'author and content required' }); + const r = await db.query( + `INSERT INTO issue_comments (issue_id, author, content) VALUES ($1, $2, $3) RETURNING *`, + [req.params.id, author, content] + ); + await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]); + res.json({ success: true, comment: r.rows[0] }); + } catch (err) { + console.error('[API Issues] comment error:', err); + res.status(500).json({ error: 'Failed to add comment' }); + } +}); + module.exports = router; diff --git a/services/arbiter-3.0/src/services/issueNotifier.js b/services/arbiter-3.0/src/services/issueNotifier.js new file mode 100644 index 0000000..4162f78 --- /dev/null +++ b/services/arbiter-3.0/src/services/issueNotifier.js @@ -0,0 +1,54 @@ +/** + * Issue Notifier — Discord webhook dispatch for the Trinity Console Issue Tracker. + * + * Posts to #issue-tracker via DISCORD_ISSUE_WEBHOOK_URL env var. + * Failures are logged but never thrown — issue CRUD must never fail because + * Discord is unreachable. + * + * Task #166 — REQ-2026-04-14-issue-tracker + */ + +const axios = require('axios'); + +const WEBHOOK_URL = process.env.DISCORD_ISSUE_WEBHOOK_URL || ''; + +async function postWebhook(content) { + if (!WEBHOOK_URL) { + console.warn('[IssueNotifier] DISCORD_ISSUE_WEBHOOK_URL not set — skipping notify'); + return; + } + try { + await axios.post(WEBHOOK_URL, { content }, { timeout: 5000 }); + } catch (err) { + console.error('[IssueNotifier] webhook post failed:', err.message); + } +} + +function notifyCreated(issue) { + const pri = String(issue.priority || 'medium').toUpperCase(); + return postWebhook( + `🐛 Issue #${issue.issue_number} opened by **${issue.submitted_by}**: ` + + `${issue.title} _(priority: ${pri}, category: ${issue.category})_` + ); +} + +function notifyStatusChange(issue, newStatus, actor) { + const icon = { + open: '📂', + in_progress: '🔧', + blocked: '⛔', + resolved: '✅', + closed: '📦' + }[newStatus] || '🔄'; + return postWebhook( + `${icon} Issue #${issue.issue_number} → **${newStatus}** by ${actor || 'Trinity Console'}: ${issue.title}` + ); +} + +function notifyComment(issue, author) { + return postWebhook( + `💬 New comment on Issue #${issue.issue_number} by **${author}**: ${issue.title}` + ); +} + +module.exports = { notifyCreated, notifyStatusChange, notifyComment }; diff --git a/services/arbiter-3.0/src/views/admin/issues/detail.ejs b/services/arbiter-3.0/src/views/admin/issues/detail.ejs new file mode 100644 index 0000000..1060aa9 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/issues/detail.ejs @@ -0,0 +1,137 @@ + + + + + +
+
+ ← Back to issues + Submitted by <%= issue.submitted_by %> · <%= new Date(issue.created_at).toLocaleString() %> +
+ +
+
+

+ #<%= issue.issue_number %> + <%= issue.title %> +

+
+ <%= issue.status %> + <%= issue.priority %> + <%= issue.category %> +
+
+ + <% if (issue.description) { %> +
<%= issue.description %>
+ <% } else { %> +
No description provided.
+ <% } %> + + + <% if (issue.attachments && issue.attachments.length > 0) { %> +
+
Screenshots (<%= issue.attachments.length %>)
+
+ <% issue.attachments.forEach(function(a) { %> + <%= a.original_name %> + <% }) %> +
+
+ <% } %> + + + +
+ +
+ + +
+
+
+ + +
+
+
+ + + + +
+ +
+ + + + +
+
+ <% if (issue.resolved_at) { %> +
Resolved <%= new Date(issue.resolved_at).toLocaleString() %> by <%= issue.resolved_by %>
+ <% } %> +
+ + +
+

Comments (<%= issue.comments.length %>)

+ <% if (issue.comments.length === 0) { %> +
No comments yet.
+ <% } else { %> +
+ <% issue.comments.forEach(function(c) { %> +
+
+ <%= c.author %> · <%= new Date(c.created_at).toLocaleString() %> +
+
<%= c.content %>
+
+ <% }) %> +
+ <% } %> + +
+ + + +
+
+
+ + + + diff --git a/services/arbiter-3.0/src/views/admin/issues/index.ejs b/services/arbiter-3.0/src/views/admin/issues/index.ejs new file mode 100644 index 0000000..c61909e --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/issues/index.ejs @@ -0,0 +1,122 @@ + + + + + + +
+

🐛 Issues

+ + New Issue +
+ + +
+
+
Active
+
<%= stats.active %>
+
+
+
Open
+
<%= stats.open %>
+
+
+
In Progress
+
<%= stats.in_progress %>
+
+
+
Blocked
+
<%= stats.blocked %>
+
+
+
Urgent
+
<%= stats.urgent %>
+
+
+ + +
+ + + + + + <% if (filters.status || filters.priority || filters.category || filters.sort || filters.all) { %> + Clear + <% } %> +
+ + +
+ <% if (issues.length === 0) { %> +
No issues match the current filters.
+ <% } else { %> + + + + + + + + + + + + + + <% issues.forEach(function(i) { %> + + + + + + + + + + <% }) %> + +
#TitleStatusPriorityCategorySubmitterUpdated
#<%= i.issue_number %> + <%= i.title %> + <% if (i.attachment_count > 0) { %>📎<%= i.attachment_count %><% } %> + <% if (i.comment_count > 0) { %>💬<%= i.comment_count %><% } %> + <%= i.status %><%= i.priority %><%= i.category %><%= i.submitted_by %><%= new Date(i.updated_at).toLocaleString() %>
+ <% } %> +
diff --git a/services/arbiter-3.0/src/views/admin/issues/new.ejs b/services/arbiter-3.0/src/views/admin/issues/new.ejs new file mode 100644 index 0000000..cce7789 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/issues/new.ejs @@ -0,0 +1,63 @@ + + + +
+
+

🐛 New Issue

+ ← Back +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + +
+ +

+ Submitting as <%= adminUser?.username || 'Trinity Console' %> +

+
diff --git a/services/arbiter-3.0/src/views/layout.ejs b/services/arbiter-3.0/src/views/layout.ejs index 8393b3e..684de19 100644 --- a/services/arbiter-3.0/src/views/layout.ejs +++ b/services/arbiter-3.0/src/views/layout.ejs @@ -108,6 +108,9 @@ <%= codeQueueCount %> <% } %> + + 🐛 Issues + 🖥️ Servers