Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
152 changes: 98 additions & 54 deletions tests/comment-formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,89 +4,133 @@
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: <adf> }` 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',
details: 'Changed the VAT calculation to use the new 2026 rates.\nAlso fixed rounding for CHF and SEK.',
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);
}
});
});
Loading