From 025b21edb7a653c2d49d88acfd4a63a34a2489a9 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Sun, 9 Aug 2026 02:52:21 +0530 Subject: [PATCH 1/2] fix(export): escape pipes and newlines in issue titles Issue titles are embedded raw into the markdown summary table and section headings. A title containing '|' added phantom columns to the table, and multi-line titles injected extra rows/lines, corrupting the generated report. Titles are now normalized (escaped pipes, line breaks collapsed to spaces) before being rendered in tables and headings. --- README.md | 1 - src/lib/api/github.ts | 62 +++++++++++++++------------------ src/lib/utils/exporters.test.ts | 31 +++++++++++++++++ src/lib/utils/exporters.ts | 17 +++++++-- 4 files changed, 74 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 07c81d5..adfb2c6 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,6 @@ These instructions have been tested on a clean machine to ensure a reliable setu > **Note:** If the repository already includes `.env.example`, you only need to copy it to `.env`. Creating a new `.env.example` is only necessary if the file is missing. - 5. **Start the development server:** ```bash bun dev diff --git a/src/lib/api/github.ts b/src/lib/api/github.ts index 3816c83..d1cb00c 100644 --- a/src/lib/api/github.ts +++ b/src/lib/api/github.ts @@ -137,21 +137,19 @@ export async function searchIssues( const issues = data.items .filter((item) => !item.pull_request) - .map( - (item): Issue => ({ - number: item.number, - title: item.title, - body: item.body, - user: item.user, - labels: item.labels, - assignees: item.assignees, - comments_count: item.comments, - created_at: item.created_at, - updated_at: item.updated_at, - html_url: item.html_url, - state: item.state, - }), - ); + .map((item): Issue => ({ + number: item.number, + title: item.title, + body: item.body, + user: item.user, + labels: item.labels, + assignees: item.assignees, + comments_count: item.comments, + created_at: item.created_at, + updated_at: item.updated_at, + html_url: item.html_url, + state: item.state, + })); allIssues.push(...issues); @@ -190,15 +188,13 @@ export async function fetchIssueComments( const data = await response.json(); - return data.map( - (c: Record): Comment => ({ - id: c.id as number, - user: c.user as Comment['user'], - body: c.body as string, - created_at: c.created_at as string, - updated_at: c.updated_at as string, - }), - ); + return data.map((c: Record): Comment => ({ + id: c.id as number, + user: c.user as Comment['user'], + body: c.body as string, + created_at: c.created_at as string, + updated_at: c.updated_at as string, + })); } export async function fetchIssueTimeline( @@ -217,16 +213,14 @@ export async function fetchIssueTimeline( const data: Record[] = await response.json(); - return data.map( - (e): TimelineEvent => ({ - event: e.event as string, - created_at: e.created_at as string, - actor: e.actor as TimelineEvent['actor'], - source: e.source as TimelineEvent['source'], - commit_id: e.commit_id as string | undefined, - label: e.label as TimelineEvent['label'], - }), - ); + return data.map((e): TimelineEvent => ({ + event: e.event as string, + created_at: e.created_at as string, + actor: e.actor as TimelineEvent['actor'], + source: e.source as TimelineEvent['source'], + commit_id: e.commit_id as string | undefined, + label: e.label as TimelineEvent['label'], + })); } catch { return []; } diff --git a/src/lib/utils/exporters.test.ts b/src/lib/utils/exporters.test.ts index 7579537..b6c44d9 100644 --- a/src/lib/utils/exporters.test.ts +++ b/src/lib/utils/exporters.test.ts @@ -97,6 +97,37 @@ describe('exporters utilities', () => { expect(markdown).toContain(`# Isscope Report — ${mockRepo}`); expect(markdown).toContain('Total issues analyzed: 0'); }); + + it('escapes pipes and newlines in issue titles so tables and headings stay intact', () => { + const trickyIssue: RankedIssue = { + number: 99, + title: 'Bug | Critical\nfeat: second line', + body: null, + user: { login: 'u', avatar_url: '', html_url: '' }, + labels: [], + assignees: [], + comments_count: 0, + created_at: '2026-05-18T10:00:00Z', + updated_at: '2026-05-18T10:00:00Z', + html_url: 'https://github.com/owner/repo/issues/99', + state: 'open', + score: 60, + analysis: undefined, + }; + + const markdown = exportToMarkdown([trickyIssue], mockRepo); + + // Table row must keep exactly 6 columns: the pipe is escaped + const row = '| 1 | #99 | Bug \\| Critical feat: second line | 60/100 | — | — |'; + expect(markdown).toContain(row); + expect(markdown).toContain('## #99 — Bug | Critical feat: second line'); + + // The title's embedded newline must not spawn a second table row + const tableSection = markdown.split('## Summary Table')[1].split('---')[0]; + const dataRows = tableSection.split('\n').filter((l) => l.startsWith('| 1 |')); + expect(dataRows).toHaveLength(1); + expect(tableSection).not.toContain('\n| feat:'); + }); }); describe('downloadMarkdown', () => { diff --git a/src/lib/utils/exporters.ts b/src/lib/utils/exporters.ts index 651652e..d9494c9 100644 --- a/src/lib/utils/exporters.ts +++ b/src/lib/utils/exporters.ts @@ -1,6 +1,19 @@ import type { RankedIssue } from '../types'; import { statusLabel, complexityLabel, friendlinessLabel, progressLabel } from './formatters'; +// GitHub issue titles may contain pipe characters or line breaks; both break +// markdown tables/headings, so normalize before embedding user content. +function escapeTableCell(str: string): string { + return str + .replace(/\|/g, '\\|') + .replace(/[\r\n]+/g, ' ') + .trim(); +} + +function escapeHeading(str: string): string { + return str.replace(/[\r\n]+/g, ' ').trim(); +} + export function exportToMarkdown(issues: RankedIssue[], repoName: string): string { const lines: string[] = [ `# Isscope Report — ${repoName}`, @@ -19,7 +32,7 @@ export function exportToMarkdown(issues: RankedIssue[], repoName: string): strin issues.forEach((issue, i) => { const analysis = issue.analysis; lines.push( - `| ${i + 1} | #${issue.number} | ${issue.title.slice(0, 50)} | ${issue.score}/100 | ${analysis ? statusLabel(analysis.status) : '—'} | ${analysis ? complexityLabel(analysis.complexity) : '—'} |`, + `| ${i + 1} | #${issue.number} | ${escapeTableCell(issue.title).slice(0, 50)} | ${issue.score}/100 | ${analysis ? statusLabel(analysis.status) : '—'} | ${analysis ? complexityLabel(analysis.complexity) : '—'} |`, ); }); @@ -28,7 +41,7 @@ export function exportToMarkdown(issues: RankedIssue[], repoName: string): strin for (const issue of issues) { const a = issue.analysis; lines.push( - `## #${issue.number} — ${issue.title}`, + `## #${issue.number} — ${escapeHeading(issue.title)}`, ``, `- **Doability Score**: ${issue.score}/100`, `- **URL**: ${issue.html_url}`, From 5fef663ae8940a02654ead4e22f31960ec477762 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Sun, 9 Aug 2026 02:53:17 +0530 Subject: [PATCH 2/2] test(export): split table section on standalone separator line --- src/lib/utils/exporters.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/exporters.test.ts b/src/lib/utils/exporters.test.ts index b6c44d9..fcbc66e 100644 --- a/src/lib/utils/exporters.test.ts +++ b/src/lib/utils/exporters.test.ts @@ -123,7 +123,7 @@ describe('exporters utilities', () => { expect(markdown).toContain('## #99 — Bug | Critical feat: second line'); // The title's embedded newline must not spawn a second table row - const tableSection = markdown.split('## Summary Table')[1].split('---')[0]; + const tableSection = markdown.split('## Summary Table')[1].split('\n---\n')[0]; const dataRows = tableSection.split('\n').filter((l) => l.startsWith('| 1 |')); expect(dataRows).toHaveLength(1); expect(tableSection).not.toContain('\n| feat:');