diff --git a/package.json b/package.json index 99f04b7..e9b116d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-jira", - "version": "0.4.0", + "version": "0.4.1", "description": "Jira REST API integration tools for opencode agents — 6 custom tools (get_issue, add_comment, transition, extract_key, sync_progress, validate_config), project-local .opencode/jira.json config, optional hook automation. Source: Perplexity P49.", "license": "Apache-2.0", "type": "module", diff --git a/tests/comment-formatter.test.ts b/tests/comment-formatter.test.ts index 24f6332..3af81f3 100644 --- a/tests/comment-formatter.test.ts +++ b/tests/comment-formatter.test.ts @@ -4,6 +4,33 @@ import { describe, it, expect } from 'bun:test'; import { formatComment } from '../src/comment-formatter'; +// ──────────────────────────────────────────────────────────────── +// formatComment — every template produces an ADF document object. +// Jira Cloud v3 requires ADF for comment bodies, and `addComment` +// posts the returned object as `{ body: }` without stringifying. +// ──────────────────────────────────────────────────────────────── + +interface ADFDocShape { + type: string; + version: number; + content: unknown[]; +} + +/** Narrow the `string | object` return type to an ADF doc, failing loudly if it is not one. */ +function asDoc(result: string | object): ADFDocShape { + expect(typeof result).toBe('object'); + const doc = result as ADFDocShape; + expect(doc.type).toBe('doc'); + expect(doc.version).toBe(1); + expect(Array.isArray(doc.content)).toBe(true); + return doc; +} + +/** All text carried anywhere in the node tree, for content assertions. */ +function textOf(doc: ADFDocShape): string { + return JSON.stringify(doc.content); +} + describe('formatComment', () => { const testData = { summary: 'Updated pricing logic for EU markets', @@ -11,82 +38,99 @@ describe('formatComment', () => { statusHint: 'In Progress', }; - it('formats markdown with all fields', () => { - const result = formatComment('markdown', testData); + it('formats markdown as an ADF document with all fields', () => { + const doc = asDoc(formatComment('markdown', testData)); - expect(result).toContain('### 🤖 OpenCode Update'); - expect(result).toContain('> Status: In Progress'); - expect(result).toContain('**Updated pricing logic for EU markets**'); - expect(result).toContain('Changed the VAT calculation'); - expect(result).toContain('fixed rounding for CHF and SEK'); - expect(result).toContain('⏱️ '); + expect(doc.content.length).toBeGreaterThan(0); + const text = textOf(doc); + expect(text).toContain('In Progress'); + expect(text).toContain('Updated pricing logic for EU markets'); + expect(text).toContain('Changed the VAT calculation'); + expect(text).toContain('fixed rounding for CHF and SEK'); }); it('formats markdown without optional fields', () => { - const result = formatComment('markdown', { - summary: 'Simple update', - }); - - expect(result).toContain('### 🤖 OpenCode Update'); - expect(result).toContain('**Simple update**'); - expect(result).not.toContain('> Status:'); - expect(result).toContain('⏱️ '); + const doc = asDoc(formatComment('markdown', { summary: 'Simple update' })); + + expect(textOf(doc)).toContain('Simple update'); + // No statusHint was given, so no status node should be emitted. + expect(textOf(doc)).not.toContain('In Progress'); + }); + + it('renders the status hint as a strong node', () => { + const doc = asDoc(formatComment('markdown', testData)); + + const first = doc.content[0] as { type: string; content: Array<{ text: string; marks?: Array<{ type: string }> }> }; + expect(first.type).toBe('paragraph'); + expect(first.content[0]!.text).toBe('In Progress'); + expect(first.content[0]!.marks?.[0]!.type).toBe('strong'); }); - it('formats plain text', () => { - const result = formatComment('plain', testData); + it('formats plain text as an ADF document', () => { + const doc = asDoc(formatComment('plain', testData)); - expect(result).toContain('OpenCode Update'); - expect(result).toContain('Status: In Progress'); - expect(result).toContain('Updated pricing logic for EU markets'); - expect(result).not.toContain('**'); // No markdown - expect(result).not.toContain('###'); // No headings + const text = textOf(doc); + expect(text).toContain('In Progress'); + expect(text).toContain('Updated pricing logic for EU markets'); + // Plain keeps every detail line as its own paragraph, unformatted. + const types = doc.content.map((node) => (node as { type: string }).type); + expect(new Set(types)).toEqual(new Set(['paragraph'])); }); it('formats plain text without optional fields', () => { - const result = formatComment('plain', { - summary: 'Simple update', - }); + const doc = asDoc(formatComment('plain', { summary: 'Simple update' })); - expect(result).toContain('OpenCode Update'); - expect(result).toContain('Simple update'); - expect(result).not.toContain('Status:'); + expect(doc.content).toHaveLength(1); + expect(textOf(doc)).toContain('Simple update'); }); - it('formats ADF as valid JSON', () => { - const result = formatComment('adf', testData); + it('splits plain-text details into one paragraph per line', () => { + const doc = asDoc(formatComment('plain', { + summary: 'Two-line detail', + details: 'first line\n\nsecond line', + })); - // Should be valid JSON - const parsed = JSON.parse(result); - expect(parsed.type).toBe('doc'); - expect(parsed.version).toBe(1); - expect(Array.isArray(parsed.content)).toBe(true); - expect(parsed.content.length).toBeGreaterThan(0); + // summary + two non-blank detail lines; the blank line is dropped. + expect(doc.content).toHaveLength(3); + }); + + it('returns a pre-built ADF document untouched', () => { + const prebuilt = { + type: 'doc', + version: 1, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'already ADF' }] }], + }; + + const doc = asDoc(formatComment('adf', { summary: '', details: JSON.stringify(prebuilt) })); - // Should contain summary text somewhere - const contentStr = JSON.stringify(parsed.content); - expect(contentStr).toContain('Updated pricing logic for EU markets'); + expect(doc).toEqual(prebuilt); }); - it('formatComment with "adf" produces ADF the client can use directly', () => { - const result = formatComment('adf', { + it('builds ADF when the adf template gets text rather than a document', () => { + const doc = asDoc(formatComment('adf', { summary: 'Done: completed feature #42', statusHint: 'Done', - }); + })); - const parsed = JSON.parse(result); - expect(parsed.type).toBe('doc'); - // The ADF has heading + panel (status) + paragraph (summary) + timestamp paragraph - expect(parsed.content.length).toBeGreaterThanOrEqual(3); + expect(doc.content.length).toBeGreaterThanOrEqual(2); + const text = textOf(doc); + expect(text).toContain('Done: completed feature #42'); + expect(text).toContain('Done'); }); - it('includes timestamp in all templates', () => { - const markdown = formatComment('markdown', testData); - const plain = formatComment('plain', testData); - const adf = formatComment('adf', testData); + it('falls back to a built document for an unknown template', () => { + const doc = asDoc(formatComment('does-not-exist', testData)); + + expect(textOf(doc)).toContain('Updated pricing logic for EU markets'); + }); - expect(markdown).toContain('⏱️ '); - expect(plain).toMatch(/\d{4}-\d{2}-\d{2}/); // ISO date - expect(adf).toContain('⏱️ '); + it('produces a document the client can post directly for every template', () => { + for (const template of ['markdown', 'plain', 'adf']) { + const doc = asDoc(formatComment(template, testData)); + // addComment sends this as `{ body: doc }`, so the document must survive + // a JSON round-trip unchanged — an `undefined` field anywhere in the node + // tree would be dropped in transit and reach Jira as a different document. + expect(JSON.parse(JSON.stringify(doc))).toEqual(doc); + } }); });