From 6b64c5184a149b5f4816203bd3051c7e9f012bd5 Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Wed, 16 Apr 2025 23:37:33 +0100 Subject: [PATCH 1/7] chore: work --- package.json | 3 +- src/core/__tests__/classifier.test.ts | 46 ++++++++++++++++++ src/core/__tests__/labelManager.test.ts | 3 ++ src/core/classifier.ts | 64 +++++++++++++++++++++++++ src/core/issueManager.ts | 13 +++-- src/core/labelManager.ts | 18 ++++++- tsconfig.json | 2 +- 7 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 src/core/__tests__/classifier.test.ts create mode 100644 src/core/classifier.ts diff --git a/package.json b/package.json index b5071b0..caa124e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "tsc", "test": "vitest run", - "prepare": "yarn ncc build src/ActionMain.ts -o dist" + "prepare": "yarn ncc build src/ActionMain.ts -o dist", + "build:dist": "if [ -d src/core/__tests__ ]; then mv src/core/__tests__ src/core/__tests__._; fi && ncc build src/ActionMain.ts -o dist && if [ -d src/core/__tests__._ ]; then mv src/core/__tests__._ src/core/__tests__; fi" }, "keywords": [ "github-action", diff --git a/src/core/__tests__/classifier.test.ts b/src/core/__tests__/classifier.test.ts new file mode 100644 index 0000000..f63e132 --- /dev/null +++ b/src/core/__tests__/classifier.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { classifyTodoText } from '../classifier'; + +describe('classifyTodoText', () => { + it('classifies refactor-related TODOs', () => { + const text = 'Refactor this function to improve readability'; + expect(classifyTodoText(text)).toContain('refactor'); + }); + + it('classifies test-related TODOs', () => { + const text = 'Add unit tests for edge cases'; + expect(classifyTodoText(text)).toContain('test'); + }); + + it('classifies doc-related TODOs', () => { + const text = 'Document this method and its arguments'; + expect(classifyTodoText(text)).toContain('doc'); + }); + + it('classifies performance-related TODOs', () => { + const text = 'Optimize this query to reduce latency'; + expect(classifyTodoText(text)).toContain('performance'); + }); + + it('classifies security-related TODOs', () => { + const text = 'Check for injection vulnerabilities'; + expect(classifyTodoText(text)).toContain('security'); + }); + + it('classifies maintenance-related TODOs', () => { + const text = 'Deprecate old API and migrate to v2'; + expect(classifyTodoText(text)).toContain('maintenance'); + }); + + it('returns empty array for unrelated TODOs', () => { + const text = 'Ask John about next steps'; + expect(classifyTodoText(text)).toEqual([]); + }); + + it('handles mixed case and accents', () => { + const text = 'Rewrite legacy logic and add docs'; + const labels = classifyTodoText(text); + expect(labels).toContain('refactor'); + expect(labels).toContain('doc'); + }); +}); diff --git a/src/core/__tests__/labelManager.test.ts b/src/core/__tests__/labelManager.test.ts index 75ee22b..89f72ae 100644 --- a/src/core/__tests__/labelManager.test.ts +++ b/src/core/__tests__/labelManager.test.ts @@ -55,3 +55,6 @@ describe('ensureLabelExists', () => { }); }); }); +function beforeEach(setupFunction: () => void): void { + setupFunction(); +} diff --git a/src/core/classifier.ts b/src/core/classifier.ts new file mode 100644 index 0000000..cb0cf1f --- /dev/null +++ b/src/core/classifier.ts @@ -0,0 +1,64 @@ +/** + * classifyTodoText.ts + * ------------------- + * Heuristic classification of TODO comment text into common categories like `refactor`, `test`, `doc`, etc. + * These labels are added in addition to tag-based and metadata-based labels. + * This can be replaced with a smarter LLM-powered classifier later on. + */ + +/** + * Returns semantic labels based on the content of the TODO text. + * These are meant to capture the intent of the TODO using simple keyword heuristics. + * + * @param text The body of the TODO comment (without tag). + * @returns A list of labels like 'refactor', 'test', 'doc', etc. + */ +export function classifyTodoText(text: string): string[] { + const lower = text.toLowerCase(); + const labels = new Set(); + + // Refactor / cleanup + if ( + /\b(refactor|simplify|cleanup|restructure|optimi[sz]e|rework|rewrite)\b/.test(lower) + ) { + labels.add('refactor'); + } + + // Testing + if ( + /\b(test|add test|unit test|coverage|verify)\b/.test(lower) + ) { + labels.add('test'); + } + + // Documentation + if ( + /\b(doc|docs|documentation|comment|explain)\b/.test(lower) + ) { + labels.add('doc'); + } + + // Performance + if ( + /\b(performance|perf|slow|latency|optimi[sz]e)\b/.test(lower) + ) { + labels.add('performance'); + } + + // Security + if ( + /\b(security|vuln|injection|auth|encrypt|sanitize)\b/.test(lower) + ) { + labels.add('security'); + } + + // Deprecation or migration + if ( + /\b(deprecate|migrate|upgrade|legacy|remove)\b/.test(lower) + ) { + labels.add('maintenance'); + } + + return Array.from(labels); + } + \ No newline at end of file diff --git a/src/core/issueManager.ts b/src/core/issueManager.ts index f44c5de..ab886a4 100644 --- a/src/core/issueManager.ts +++ b/src/core/issueManager.ts @@ -1,7 +1,12 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; import { TodoItem } from '../parser/types'; -import { LABELS_BY_TAG, labelsFromMetadata, ensureLabelExists } from './labelManager'; +import { + LABELS_BY_TAG, + labelsFromMetadata, + ensureLabelExists, + labelsFromTodo // ⬅️ novo +} from './labelManager'; import { loadTemplate, applyTemplate } from '../templates/utils'; export async function getExistingIssueTitles( @@ -65,10 +70,8 @@ export async function createIssueIfNeeded( return; } - const tag = todo.tag.toUpperCase(); - const baseLabels = LABELS_BY_TAG[tag] || ['todo']; - const metaLabels = labelsFromMetadata(todo.metadata); - const labels = [...baseLabels, ...metaLabels]; + const labels = labelsFromTodo(todo); + for (const label of labels) { await ensureLabelExists(octokit, owner, repo, label); diff --git a/src/core/labelManager.ts b/src/core/labelManager.ts index be73ed5..ac10b68 100644 --- a/src/core/labelManager.ts +++ b/src/core/labelManager.ts @@ -1,5 +1,7 @@ import * as github from '@actions/github'; import * as core from '@actions/core'; +import { TodoItem } from '../parser/types'; +import { classifyTodoText } from './classifier'; // Novo: classificador heurístico ou LLM // Labels atribuídas por tipo de comentário export const LABELS_BY_TAG: Record = { @@ -14,7 +16,10 @@ export const LABEL_COLORS: Record = { bug: 'd73a4a', enhancement: 'a2eeef', todo: 'cfd3d7', - 'technical-debt': 'e99695' + 'technical-debt': 'e99695', + refactor: 'f9d0c4', + test: 'fef2c0', + doc: '0075ca' }; // Fallback para labels metadata:priority, due, etc. @@ -23,6 +28,16 @@ export function labelsFromMetadata(metadata?: Record): string[] return Object.entries(metadata).map(([key, value]) => `${key}:${value}`); } +// Novo: combina tag, metadata e classificação semântica +export function labelsFromTodo(todo: TodoItem): string[] { + const tag = todo.tag.toUpperCase(); + const tagLabels = LABELS_BY_TAG[tag] || ['todo']; + const metaLabels = labelsFromMetadata(todo.metadata); + const semanticLabels = classifyTodoText(todo.text); // ← vem de `classifier.ts` + + return Array.from(new Set([...tagLabels, ...metaLabels, ...semanticLabels])); +} + // Garante que uma label existe no repositório export async function ensureLabelExists( octokit: ReturnType, @@ -49,3 +64,4 @@ export async function ensureLabelExists( } } } + diff --git a/tsconfig.json b/tsconfig.json index e2efd40..175a3c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,6 @@ ], "exclude": [ "node_modules", - "dist" + "dist", ] } \ No newline at end of file From 52d2e450ac6ea5249e3015444f6ac500993af16d Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 12:02:49 +0100 Subject: [PATCH 2/7] chore: development work --- tests/applyTemplate.test.ts | 40 ++++++++++++++ tests/classifier.test.ts | 46 ++++++++++++++++ tests/commentPatterns.test.ts | 46 ++++++++++++++++ tests/extractTodos.test.ts | 46 ++++++++++++++++ tests/extractTodosFromContent.test.ts | 31 +++++++++++ tests/extractTodosFromDir.test.ts | 28 ++++++++++ tests/fixtures/nested/inner.py | 2 + tests/fixtures/one-file.ts | 2 + tests/issueManager.test.ts | 79 +++++++++++++++++++++++++++ tests/labelManager.test.ts | 60 ++++++++++++++++++++ 10 files changed, 380 insertions(+) create mode 100644 tests/applyTemplate.test.ts create mode 100644 tests/classifier.test.ts create mode 100644 tests/commentPatterns.test.ts create mode 100644 tests/extractTodos.test.ts create mode 100644 tests/extractTodosFromContent.test.ts create mode 100644 tests/extractTodosFromDir.test.ts create mode 100644 tests/fixtures/nested/inner.py create mode 100644 tests/fixtures/one-file.ts create mode 100644 tests/issueManager.test.ts create mode 100644 tests/labelManager.test.ts diff --git a/tests/applyTemplate.test.ts b/tests/applyTemplate.test.ts new file mode 100644 index 0000000..dea01fa --- /dev/null +++ b/tests/applyTemplate.test.ts @@ -0,0 +1,40 @@ +import { applyTemplate } from '../../templates/utils'; +import { describe, it, expect } from 'vitest' + +describe('applyTemplate', () => { + it('replaces simple variables', () => { + const template = '[{{tag}}] {{text}}'; + const data = { + tag: 'TODO', + text: 'Implement login flow' + }; + + const result = applyTemplate(template, data); + expect(result).toBe('[TODO] Implement login flow'); + }); + + it('ignores missing variables', () => { + const template = 'Priority: {{priority}}'; + const data = { + tag: 'TODO' + }; + + const result = applyTemplate(template, data); + expect(result).toBe('Priority: '); + }); + + it('handles numeric values', () => { + const template = 'Line {{line}}: {{text}}'; + const data = { + line: 42, + text: 'Optimize loop' + }; + + const result = applyTemplate(template, data); + expect(result).toBe('Line 42: Optimize loop'); + }); +}); + + + + diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts new file mode 100644 index 0000000..f63e132 --- /dev/null +++ b/tests/classifier.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { classifyTodoText } from '../classifier'; + +describe('classifyTodoText', () => { + it('classifies refactor-related TODOs', () => { + const text = 'Refactor this function to improve readability'; + expect(classifyTodoText(text)).toContain('refactor'); + }); + + it('classifies test-related TODOs', () => { + const text = 'Add unit tests for edge cases'; + expect(classifyTodoText(text)).toContain('test'); + }); + + it('classifies doc-related TODOs', () => { + const text = 'Document this method and its arguments'; + expect(classifyTodoText(text)).toContain('doc'); + }); + + it('classifies performance-related TODOs', () => { + const text = 'Optimize this query to reduce latency'; + expect(classifyTodoText(text)).toContain('performance'); + }); + + it('classifies security-related TODOs', () => { + const text = 'Check for injection vulnerabilities'; + expect(classifyTodoText(text)).toContain('security'); + }); + + it('classifies maintenance-related TODOs', () => { + const text = 'Deprecate old API and migrate to v2'; + expect(classifyTodoText(text)).toContain('maintenance'); + }); + + it('returns empty array for unrelated TODOs', () => { + const text = 'Ask John about next steps'; + expect(classifyTodoText(text)).toEqual([]); + }); + + it('handles mixed case and accents', () => { + const text = 'Rewrite legacy logic and add docs'; + const labels = classifyTodoText(text); + expect(labels).toContain('refactor'); + expect(labels).toContain('doc'); + }); +}); diff --git a/tests/commentPatterns.test.ts b/tests/commentPatterns.test.ts new file mode 100644 index 0000000..90d7d21 --- /dev/null +++ b/tests/commentPatterns.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { extractTodosFromString } from '../../parser/extractTodosFromContent'; + +describe('extractTodosFromString - comment support by extension', () => { + it('extracts from JS-style (//) for .js/.ts/.go/.java', () => { + const code = `// TODO: js comment\n// BUG: broken`; + const extensions = ['.js', '.ts', '.go', '.java']; + + for (const ext of extensions) { + const todos = extractTodosFromString(code, ext); + expect(todos.length).toBe(2); + expect(todos[0].tag).toBe('TODO'); + expect(todos[1].tag).toBe('BUG'); + } + }); + + it('extracts from Python-style (#) for .py/.sh/.rb', () => { + const code = `# TODO: python comment\n# FIXME: fix me`; + const extensions = ['.py', '.sh', '.rb']; + + for (const ext of extensions) { + const todos = extractTodosFromString(code, ext); + expect(todos.length).toBe(2); + expect(todos[0].tag).toBe('TODO'); + expect(todos[1].tag).toBe('FIXME'); + } + }); + + it('extracts from HTML-style () for .html/.xml', () => { + const code = `\n`; + const extensions = ['.html', '.xml']; + + for (const ext of extensions) { + const todos = extractTodosFromString(code, ext); + expect(todos.length).toBe(2); + expect(todos[0].tag).toBe('TODO'); + expect(todos[1].tag).toBe('HACK'); + } + }); + + it('returns [] for unsupported extensions', () => { + const code = `// TODO: will not be parsed`; + const todos = extractTodosFromString(code, '.txt'); + expect(todos).toEqual([]); + }); +}); diff --git a/tests/extractTodos.test.ts b/tests/extractTodos.test.ts new file mode 100644 index 0000000..00f6e61 --- /dev/null +++ b/tests/extractTodos.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { extractTodosFromString } from '../../parser/extractTodosFromContent'; +import { TodoItem } from '../../parser/types'; + + + +describe('extractTodos', () => { + it('extracts simple TODOs with //', () => { + const content = `// TODO: clean this up\nconst a = 1;`; + const todos = extractTodosFromString(content, '.js'); + expect(todos.length).toBe(1); + expect(todos[0].text).toBe('clean this up'); + expect(todos[0].tag).toBe('TODO'); + expect(todos[0].line).toBe(1); + }); + + it('extracts multiple tags', () => { + const content = `# BUG: crashes\n# FIXME: something wrong`; + const todos = extractTodosFromString(content, '.py'); + expect(todos.length).toBe(2); + expect(todos.map(t => t.tag)).toEqual(['BUG', 'FIXME']); + }); + + it('extracts metadata key=value pairs', () => { + const content = `// TODO(priority=high, due=2025-06-01): fix it`; + const todos = extractTodosFromString(content, '.js'); + expect(todos.length).toBe(1); + expect(todos[0].metadata).toEqual({ + priority: 'high', + due: '2025-06-01' + }); + }); + + it('supports HTML comments', () => { + const content = ``; + const todos = extractTodosFromString(content, '.html'); + expect(todos.length).toBe(1); + expect(todos[0].tag).toBe('TODO'); + }); + + it('returns empty list if no TODOs are found', () => { + const content = `const x = 5; // just a comment`; + const todos = extractTodosFromString(content, '.js'); + expect(todos.length).toBe(0); + }); +}); diff --git a/tests/extractTodosFromContent.test.ts b/tests/extractTodosFromContent.test.ts new file mode 100644 index 0000000..7e6844e --- /dev/null +++ b/tests/extractTodosFromContent.test.ts @@ -0,0 +1,31 @@ +import { extractTodosFromString } from '../../parser/extractTodosFromContent'; +import { describe, it, expect } from 'vitest' + +describe('extractTodosFromString', () => { + it('extracts multiple TODO-style tags', () => { + const content = `// BUG: crash here\n# FIXME: wrong\n`; + const jsTodos = extractTodosFromString(content, '.js'); + const pyTodos = extractTodosFromString(content, '.py'); + const htmlTodos = extractTodosFromString(content, '.html'); + + expect(jsTodos.length).toBe(1); + expect(jsTodos[0].tag).toBe('BUG'); + + expect(pyTodos.length).toBe(1); + expect(pyTodos[0].tag).toBe('FIXME'); + + expect(htmlTodos.length).toBe(1); + expect(htmlTodos[0].tag).toBe('TODO'); + }); + + it('extracts metadata key=value pairs', () => { + const content = `// TODO(priority=high, due=2025-06-01): fix it`; + const todos = extractTodosFromString(content, '.js'); + + expect(todos.length).toBe(1); + expect(todos[0].metadata).toEqual({ + priority: 'high', + due: '2025-06-01' + }); + }); +}); diff --git a/tests/extractTodosFromDir.test.ts b/tests/extractTodosFromDir.test.ts new file mode 100644 index 0000000..485c9a9 --- /dev/null +++ b/tests/extractTodosFromDir.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { extractTodosFromDir } from '../../parser/extractTodosFromDir'; + +describe('extractTodosFromDir', () => { + const base = path.join(__dirname, 'fixtures'); + + it('should extract TODOs from supported files recursively', () => { + const todos = extractTodosFromDir(base); + expect(todos.length).toBe(2); + + const texts = todos.map(t => t.text); + expect(texts).toContain('Refactor this module'); + expect(texts).toContain('Handle edge case'); + + const tags = todos.map(t => t.tag); + expect(tags).toContain('TODO'); + expect(tags).toContain('FIXME'); + }); + + it('should include correct file and line information', () => { + const todos = extractTodosFromDir(base); + const one = todos.find(t => t.text.includes('Refactor')); + expect(one?.file.endsWith('one-file.ts')).toBe(true); + expect(typeof one?.line).toBe('number'); + expect(one?.line).toBeGreaterThan(0); + }); +}); diff --git a/tests/fixtures/nested/inner.py b/tests/fixtures/nested/inner.py new file mode 100644 index 0000000..270bd10 --- /dev/null +++ b/tests/fixtures/nested/inner.py @@ -0,0 +1,2 @@ +# FIXME: Handle edge case +print("Running") diff --git a/tests/fixtures/one-file.ts b/tests/fixtures/one-file.ts new file mode 100644 index 0000000..cdfd083 --- /dev/null +++ b/tests/fixtures/one-file.ts @@ -0,0 +1,2 @@ +// TODO: Refactor this module +export const a = 42; diff --git a/tests/issueManager.test.ts b/tests/issueManager.test.ts new file mode 100644 index 0000000..719a000 --- /dev/null +++ b/tests/issueManager.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as core from '@actions/core'; +import { getExistingIssueTitles, createIssueIfNeeded } from '../issueManager'; +import { TodoItem } from '../../parser/types'; + +// Mocks +const mockOctokit = { + rest: { + issues: { + listForRepo: vi.fn(), + getLabel: vi.fn(), + createLabel: vi.fn(), + create: vi.fn() + } + } +}; + +vi.mock('../labelManager', () => ({ + ensureLabelExists: vi.fn(), + LABELS_BY_TAG: { TODO: ['enhancement'] }, + labelsFromMetadata: () => ['priority:high'] +})); + +describe('getExistingIssueTitles', () => { + const octokit = mockOctokit as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should collect titles of open issues (paginated)', async () => { + octokit.rest.issues.listForRepo + .mockResolvedValueOnce({ data: [{ title: 'Issue 1' }, { title: 'Issue 2' }] }) + .mockResolvedValueOnce({ data: [] }); + + const titles = await getExistingIssueTitles(octokit, 'test-owner', 'test-repo'); + expect([...titles]).toEqual(['Issue 1', 'Issue 2']); + }); +}); + +describe('createIssueIfNeeded', () => { + const octokit = mockOctokit as any; + const owner = 'test-owner'; + const repo = 'test-repo'; + + const todo: TodoItem = { + tag: 'TODO', + text: 'Refactor component', + file: 'src/file.ts', + line: 42, + metadata: { priority: 'high' } + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should skip duplicate issues', async () => { + const existingTitles: Set = new Set(['[TODO] Refactor component']); + + await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); + + expect(octokit.rest.issues.create).not.toHaveBeenCalled(); + }); + + it('should create a new issue if not duplicated', async () => { + const existingTitles: Set = new Set(); + + await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); + + expect(octokit.rest.issues.create).toHaveBeenCalledWith({ + owner, + repo, + title: '[TODO] Refactor component', + body: expect.stringContaining('src/file.ts'), + labels: ['enhancement', 'priority:high'] + }); + }); +}); diff --git a/tests/labelManager.test.ts b/tests/labelManager.test.ts new file mode 100644 index 0000000..89f72ae --- /dev/null +++ b/tests/labelManager.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import * as core from '@actions/core'; +import { labelsFromMetadata, ensureLabelExists } from '../labelManager'; + +const mockOctokit = { + rest: { + issues: { + getLabel: vi.fn(), + createLabel: vi.fn() + } + } +}; + +describe('labelsFromMetadata', () => { + it('should return key:value pairs from metadata object', () => { + const metadata = { priority: 'high', due: '2025-06-01' }; + const labels = labelsFromMetadata(metadata); + expect(labels).toEqual(['priority:high', 'due:2025-06-01']); + }); + + it('should return empty array if metadata is undefined', () => { + expect(labelsFromMetadata(undefined)).toEqual([]); + }); +}); + +describe('ensureLabelExists', () => { + const octokit = mockOctokit as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should not create label if it already exists', async () => { + octokit.rest.issues.getLabel.mockResolvedValueOnce({ status: 200 }); + + await ensureLabelExists(octokit, 'test-owner', 'test-repo', 'bug'); + + expect(octokit.rest.issues.getLabel).toHaveBeenCalled(); + expect(octokit.rest.issues.createLabel).not.toHaveBeenCalled(); + }); + + it('should create label if it does not exist (404)', async () => { + const error = { status: 404 }; + octokit.rest.issues.getLabel.mockRejectedValueOnce(error); + octokit.rest.issues.createLabel.mockResolvedValueOnce({}); + + await ensureLabelExists(octokit, 'test-owner', 'test-repo', 'priority:high'); + + expect(octokit.rest.issues.createLabel).toHaveBeenCalledWith({ + owner: 'test-owner', + repo: 'test-repo', + name: 'priority:high', + color: 'cccccc', + description: 'Auto-created by smart-todo-action' + }); + }); +}); +function beforeEach(setupFunction: () => void): void { + setupFunction(); +} From 20548fccb47f21beb6c90631b4859c3a582d0dbc Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 19:08:16 +0100 Subject: [PATCH 3/7] chore: develop work --- package.json | 4 +- src/core/__tests__/applyTemplate.test.ts | 40 ------ src/core/__tests__/classifier.test.ts | 46 ------- src/core/__tests__/commentPatterns.test.ts | 46 ------- src/core/__tests__/extractTodos.test.ts | 46 ------- .../__tests__/extractTodosFromContent.test.ts | 31 ----- .../__tests__/extractTodosFromDir.test.ts | 28 ----- src/core/__tests__/fixtures/nested/inner.py | 2 - src/core/__tests__/fixtures/one-file.ts | 2 - src/core/__tests__/issueManager.test.ts | 79 ------------ src/core/__tests__/labelManager.test.ts | 60 --------- src/core/changelog.ts | 78 ++++++++++++ src/core/classifier.ts | 116 +++++++++--------- src/generateChangelog.ts | 61 +++++++++ tests/applyTemplate.test.ts | 2 +- tests/classifier.test.ts | 2 +- tests/commentPatterns.test.ts | 2 +- tests/extractTodos.test.ts | 4 +- tests/extractTodosFromContent.test.ts | 2 +- tests/extractTodosFromDir.test.ts | 2 +- tests/issueManager.test.ts | 19 +-- tests/labelManager.test.ts | 2 +- yarn.lock | 98 ++++++++++++++- 23 files changed, 315 insertions(+), 457 deletions(-) delete mode 100644 src/core/__tests__/applyTemplate.test.ts delete mode 100644 src/core/__tests__/classifier.test.ts delete mode 100644 src/core/__tests__/commentPatterns.test.ts delete mode 100644 src/core/__tests__/extractTodos.test.ts delete mode 100644 src/core/__tests__/extractTodosFromContent.test.ts delete mode 100644 src/core/__tests__/extractTodosFromDir.test.ts delete mode 100644 src/core/__tests__/fixtures/nested/inner.py delete mode 100644 src/core/__tests__/fixtures/one-file.ts delete mode 100644 src/core/__tests__/issueManager.test.ts delete mode 100644 src/core/__tests__/labelManager.test.ts create mode 100644 src/core/changelog.ts create mode 100644 src/generateChangelog.ts diff --git a/package.json b/package.json index caa124e..44ea42f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "tsc", "test": "vitest run", "prepare": "yarn ncc build src/ActionMain.ts -o dist", - "build:dist": "if [ -d src/core/__tests__ ]; then mv src/core/__tests__ src/core/__tests__._; fi && ncc build src/ActionMain.ts -o dist && if [ -d src/core/__tests__._ ]; then mv src/core/__tests__._ src/core/__tests__; fi" + "changelog": "ts-node src/generateChangelog.ts", + "build:dist": "ncc build src/ActionMain.ts -o dist" }, "keywords": [ "github-action", @@ -28,6 +29,7 @@ "@types/node": "^20.11.17", "@vercel/ncc": "^0.38.3", "@vitest/coverage-v8": "^3.1.1", + "ts-node": "^10.9.2", "typescript": "^5.3.3", "vitest": "^3.1.1" } diff --git a/src/core/__tests__/applyTemplate.test.ts b/src/core/__tests__/applyTemplate.test.ts deleted file mode 100644 index dea01fa..0000000 --- a/src/core/__tests__/applyTemplate.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { applyTemplate } from '../../templates/utils'; -import { describe, it, expect } from 'vitest' - -describe('applyTemplate', () => { - it('replaces simple variables', () => { - const template = '[{{tag}}] {{text}}'; - const data = { - tag: 'TODO', - text: 'Implement login flow' - }; - - const result = applyTemplate(template, data); - expect(result).toBe('[TODO] Implement login flow'); - }); - - it('ignores missing variables', () => { - const template = 'Priority: {{priority}}'; - const data = { - tag: 'TODO' - }; - - const result = applyTemplate(template, data); - expect(result).toBe('Priority: '); - }); - - it('handles numeric values', () => { - const template = 'Line {{line}}: {{text}}'; - const data = { - line: 42, - text: 'Optimize loop' - }; - - const result = applyTemplate(template, data); - expect(result).toBe('Line 42: Optimize loop'); - }); -}); - - - - diff --git a/src/core/__tests__/classifier.test.ts b/src/core/__tests__/classifier.test.ts deleted file mode 100644 index f63e132..0000000 --- a/src/core/__tests__/classifier.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { classifyTodoText } from '../classifier'; - -describe('classifyTodoText', () => { - it('classifies refactor-related TODOs', () => { - const text = 'Refactor this function to improve readability'; - expect(classifyTodoText(text)).toContain('refactor'); - }); - - it('classifies test-related TODOs', () => { - const text = 'Add unit tests for edge cases'; - expect(classifyTodoText(text)).toContain('test'); - }); - - it('classifies doc-related TODOs', () => { - const text = 'Document this method and its arguments'; - expect(classifyTodoText(text)).toContain('doc'); - }); - - it('classifies performance-related TODOs', () => { - const text = 'Optimize this query to reduce latency'; - expect(classifyTodoText(text)).toContain('performance'); - }); - - it('classifies security-related TODOs', () => { - const text = 'Check for injection vulnerabilities'; - expect(classifyTodoText(text)).toContain('security'); - }); - - it('classifies maintenance-related TODOs', () => { - const text = 'Deprecate old API and migrate to v2'; - expect(classifyTodoText(text)).toContain('maintenance'); - }); - - it('returns empty array for unrelated TODOs', () => { - const text = 'Ask John about next steps'; - expect(classifyTodoText(text)).toEqual([]); - }); - - it('handles mixed case and accents', () => { - const text = 'Rewrite legacy logic and add docs'; - const labels = classifyTodoText(text); - expect(labels).toContain('refactor'); - expect(labels).toContain('doc'); - }); -}); diff --git a/src/core/__tests__/commentPatterns.test.ts b/src/core/__tests__/commentPatterns.test.ts deleted file mode 100644 index 90d7d21..0000000 --- a/src/core/__tests__/commentPatterns.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; - -describe('extractTodosFromString - comment support by extension', () => { - it('extracts from JS-style (//) for .js/.ts/.go/.java', () => { - const code = `// TODO: js comment\n// BUG: broken`; - const extensions = ['.js', '.ts', '.go', '.java']; - - for (const ext of extensions) { - const todos = extractTodosFromString(code, ext); - expect(todos.length).toBe(2); - expect(todos[0].tag).toBe('TODO'); - expect(todos[1].tag).toBe('BUG'); - } - }); - - it('extracts from Python-style (#) for .py/.sh/.rb', () => { - const code = `# TODO: python comment\n# FIXME: fix me`; - const extensions = ['.py', '.sh', '.rb']; - - for (const ext of extensions) { - const todos = extractTodosFromString(code, ext); - expect(todos.length).toBe(2); - expect(todos[0].tag).toBe('TODO'); - expect(todos[1].tag).toBe('FIXME'); - } - }); - - it('extracts from HTML-style () for .html/.xml', () => { - const code = `\n`; - const extensions = ['.html', '.xml']; - - for (const ext of extensions) { - const todos = extractTodosFromString(code, ext); - expect(todos.length).toBe(2); - expect(todos[0].tag).toBe('TODO'); - expect(todos[1].tag).toBe('HACK'); - } - }); - - it('returns [] for unsupported extensions', () => { - const code = `// TODO: will not be parsed`; - const todos = extractTodosFromString(code, '.txt'); - expect(todos).toEqual([]); - }); -}); diff --git a/src/core/__tests__/extractTodos.test.ts b/src/core/__tests__/extractTodos.test.ts deleted file mode 100644 index 00f6e61..0000000 --- a/src/core/__tests__/extractTodos.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; -import { TodoItem } from '../../parser/types'; - - - -describe('extractTodos', () => { - it('extracts simple TODOs with //', () => { - const content = `// TODO: clean this up\nconst a = 1;`; - const todos = extractTodosFromString(content, '.js'); - expect(todos.length).toBe(1); - expect(todos[0].text).toBe('clean this up'); - expect(todos[0].tag).toBe('TODO'); - expect(todos[0].line).toBe(1); - }); - - it('extracts multiple tags', () => { - const content = `# BUG: crashes\n# FIXME: something wrong`; - const todos = extractTodosFromString(content, '.py'); - expect(todos.length).toBe(2); - expect(todos.map(t => t.tag)).toEqual(['BUG', 'FIXME']); - }); - - it('extracts metadata key=value pairs', () => { - const content = `// TODO(priority=high, due=2025-06-01): fix it`; - const todos = extractTodosFromString(content, '.js'); - expect(todos.length).toBe(1); - expect(todos[0].metadata).toEqual({ - priority: 'high', - due: '2025-06-01' - }); - }); - - it('supports HTML comments', () => { - const content = ``; - const todos = extractTodosFromString(content, '.html'); - expect(todos.length).toBe(1); - expect(todos[0].tag).toBe('TODO'); - }); - - it('returns empty list if no TODOs are found', () => { - const content = `const x = 5; // just a comment`; - const todos = extractTodosFromString(content, '.js'); - expect(todos.length).toBe(0); - }); -}); diff --git a/src/core/__tests__/extractTodosFromContent.test.ts b/src/core/__tests__/extractTodosFromContent.test.ts deleted file mode 100644 index 7e6844e..0000000 --- a/src/core/__tests__/extractTodosFromContent.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; -import { describe, it, expect } from 'vitest' - -describe('extractTodosFromString', () => { - it('extracts multiple TODO-style tags', () => { - const content = `// BUG: crash here\n# FIXME: wrong\n`; - const jsTodos = extractTodosFromString(content, '.js'); - const pyTodos = extractTodosFromString(content, '.py'); - const htmlTodos = extractTodosFromString(content, '.html'); - - expect(jsTodos.length).toBe(1); - expect(jsTodos[0].tag).toBe('BUG'); - - expect(pyTodos.length).toBe(1); - expect(pyTodos[0].tag).toBe('FIXME'); - - expect(htmlTodos.length).toBe(1); - expect(htmlTodos[0].tag).toBe('TODO'); - }); - - it('extracts metadata key=value pairs', () => { - const content = `// TODO(priority=high, due=2025-06-01): fix it`; - const todos = extractTodosFromString(content, '.js'); - - expect(todos.length).toBe(1); - expect(todos[0].metadata).toEqual({ - priority: 'high', - due: '2025-06-01' - }); - }); -}); diff --git a/src/core/__tests__/extractTodosFromDir.test.ts b/src/core/__tests__/extractTodosFromDir.test.ts deleted file mode 100644 index 485c9a9..0000000 --- a/src/core/__tests__/extractTodosFromDir.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import path from 'path'; -import { extractTodosFromDir } from '../../parser/extractTodosFromDir'; - -describe('extractTodosFromDir', () => { - const base = path.join(__dirname, 'fixtures'); - - it('should extract TODOs from supported files recursively', () => { - const todos = extractTodosFromDir(base); - expect(todos.length).toBe(2); - - const texts = todos.map(t => t.text); - expect(texts).toContain('Refactor this module'); - expect(texts).toContain('Handle edge case'); - - const tags = todos.map(t => t.tag); - expect(tags).toContain('TODO'); - expect(tags).toContain('FIXME'); - }); - - it('should include correct file and line information', () => { - const todos = extractTodosFromDir(base); - const one = todos.find(t => t.text.includes('Refactor')); - expect(one?.file.endsWith('one-file.ts')).toBe(true); - expect(typeof one?.line).toBe('number'); - expect(one?.line).toBeGreaterThan(0); - }); -}); diff --git a/src/core/__tests__/fixtures/nested/inner.py b/src/core/__tests__/fixtures/nested/inner.py deleted file mode 100644 index 270bd10..0000000 --- a/src/core/__tests__/fixtures/nested/inner.py +++ /dev/null @@ -1,2 +0,0 @@ -# FIXME: Handle edge case -print("Running") diff --git a/src/core/__tests__/fixtures/one-file.ts b/src/core/__tests__/fixtures/one-file.ts deleted file mode 100644 index cdfd083..0000000 --- a/src/core/__tests__/fixtures/one-file.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: Refactor this module -export const a = 42; diff --git a/src/core/__tests__/issueManager.test.ts b/src/core/__tests__/issueManager.test.ts deleted file mode 100644 index 719a000..0000000 --- a/src/core/__tests__/issueManager.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import * as core from '@actions/core'; -import { getExistingIssueTitles, createIssueIfNeeded } from '../issueManager'; -import { TodoItem } from '../../parser/types'; - -// Mocks -const mockOctokit = { - rest: { - issues: { - listForRepo: vi.fn(), - getLabel: vi.fn(), - createLabel: vi.fn(), - create: vi.fn() - } - } -}; - -vi.mock('../labelManager', () => ({ - ensureLabelExists: vi.fn(), - LABELS_BY_TAG: { TODO: ['enhancement'] }, - labelsFromMetadata: () => ['priority:high'] -})); - -describe('getExistingIssueTitles', () => { - const octokit = mockOctokit as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should collect titles of open issues (paginated)', async () => { - octokit.rest.issues.listForRepo - .mockResolvedValueOnce({ data: [{ title: 'Issue 1' }, { title: 'Issue 2' }] }) - .mockResolvedValueOnce({ data: [] }); - - const titles = await getExistingIssueTitles(octokit, 'test-owner', 'test-repo'); - expect([...titles]).toEqual(['Issue 1', 'Issue 2']); - }); -}); - -describe('createIssueIfNeeded', () => { - const octokit = mockOctokit as any; - const owner = 'test-owner'; - const repo = 'test-repo'; - - const todo: TodoItem = { - tag: 'TODO', - text: 'Refactor component', - file: 'src/file.ts', - line: 42, - metadata: { priority: 'high' } - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should skip duplicate issues', async () => { - const existingTitles: Set = new Set(['[TODO] Refactor component']); - - await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); - - expect(octokit.rest.issues.create).not.toHaveBeenCalled(); - }); - - it('should create a new issue if not duplicated', async () => { - const existingTitles: Set = new Set(); - - await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); - - expect(octokit.rest.issues.create).toHaveBeenCalledWith({ - owner, - repo, - title: '[TODO] Refactor component', - body: expect.stringContaining('src/file.ts'), - labels: ['enhancement', 'priority:high'] - }); - }); -}); diff --git a/src/core/__tests__/labelManager.test.ts b/src/core/__tests__/labelManager.test.ts deleted file mode 100644 index 89f72ae..0000000 --- a/src/core/__tests__/labelManager.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import * as core from '@actions/core'; -import { labelsFromMetadata, ensureLabelExists } from '../labelManager'; - -const mockOctokit = { - rest: { - issues: { - getLabel: vi.fn(), - createLabel: vi.fn() - } - } -}; - -describe('labelsFromMetadata', () => { - it('should return key:value pairs from metadata object', () => { - const metadata = { priority: 'high', due: '2025-06-01' }; - const labels = labelsFromMetadata(metadata); - expect(labels).toEqual(['priority:high', 'due:2025-06-01']); - }); - - it('should return empty array if metadata is undefined', () => { - expect(labelsFromMetadata(undefined)).toEqual([]); - }); -}); - -describe('ensureLabelExists', () => { - const octokit = mockOctokit as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should not create label if it already exists', async () => { - octokit.rest.issues.getLabel.mockResolvedValueOnce({ status: 200 }); - - await ensureLabelExists(octokit, 'test-owner', 'test-repo', 'bug'); - - expect(octokit.rest.issues.getLabel).toHaveBeenCalled(); - expect(octokit.rest.issues.createLabel).not.toHaveBeenCalled(); - }); - - it('should create label if it does not exist (404)', async () => { - const error = { status: 404 }; - octokit.rest.issues.getLabel.mockRejectedValueOnce(error); - octokit.rest.issues.createLabel.mockResolvedValueOnce({}); - - await ensureLabelExists(octokit, 'test-owner', 'test-repo', 'priority:high'); - - expect(octokit.rest.issues.createLabel).toHaveBeenCalledWith({ - owner: 'test-owner', - repo: 'test-repo', - name: 'priority:high', - color: 'cccccc', - description: 'Auto-created by smart-todo-action' - }); - }); -}); -function beforeEach(setupFunction: () => void): void { - setupFunction(); -} diff --git a/src/core/changelog.ts b/src/core/changelog.ts new file mode 100644 index 0000000..9168c26 --- /dev/null +++ b/src/core/changelog.ts @@ -0,0 +1,78 @@ +// src/core/changelog.ts +import { TodoItem } from '../parser/types'; +import { labelsFromTodo } from './labelManager'; + +/** + * Generates a changelog from a list of TODO items, grouping them by labels and formatting + * them into a markdown string. + * + * @param todos - An array of `TodoItem` objects representing the TODO items to include in the changelog. + * @returns A formatted markdown string representing the changelog, grouped by labels with icons. + * + * The changelog includes: + * - A header (`## Changelog de TODOs`). + * - Grouped sections for each label, sorted alphabetically. + * - Each section includes a list of TODO items with their tag, text, file, line, and optional metadata. + * + * Label icons are mapped as follows: + * - `bug`: 🐞 + * - `enhancement`: ✨ + * - `technical-debt`: 💣 + * - `test`: 🔬 + * - `doc`: 📄 + * - `refactor`: 🚧 + * - `performance`: ⚡️ + * - `security`: 🔐 + * - Default: 🔖 + * + * Example output: + * ``` + * ## Changelog de TODOs + * + * ### 🐞 Bug + * - [BUG-123] Fix null pointer exception (`file.ts:42`) — `priority:high` + * + * ### ✨ Enhancement + * - [ENH-456] Add new feature (`feature.ts:10`) + * ``` + */ +export function generateChangelog(todos: TodoItem[]): string { + const grouped: Record = {}; + + for (const todo of todos) { + for (const label of labelsFromTodo(todo)) { + if (!grouped[label]) grouped[label] = []; + grouped[label].push(todo); + } + } + + let output = `## Changelog de TODOs\n\n`; + const labelIcons: Record = { + bug: '🐞', + enhancement: '✨', + 'technical-debt': '💣', + test: '🔬', + doc: '📄', + refactor: '🚧', + performance: '⚡️', + security: '🔐' + }; + + for (const label of Object.keys(grouped).sort()) { + const icon = labelIcons[label] || '🔖'; + output += `### ${icon} ${label[0].toUpperCase() + label.slice(1)}\n`; + for (const todo of grouped[label]) { + output += `- [${todo.tag}] ${todo.text} (\`${todo.file}:${todo.line}\`)`; + if (todo.metadata) { + const meta = Object.entries(todo.metadata) + .map(([k, v]) => `\`${k}:${v}\``) + .join(' '); + output += ` — ${meta}`; + } + output += `\n`; + } + output += `\n`; + } + + return output.trim(); +} diff --git a/src/core/classifier.ts b/src/core/classifier.ts index cb0cf1f..49f6bc2 100644 --- a/src/core/classifier.ts +++ b/src/core/classifier.ts @@ -1,64 +1,64 @@ /** - * classifyTodoText.ts - * ------------------- - * Heuristic classification of TODO comment text into common categories like `refactor`, `test`, `doc`, etc. - * These labels are added in addition to tag-based and metadata-based labels. - * This can be replaced with a smarter LLM-powered classifier later on. - */ - -/** - * Returns semantic labels based on the content of the TODO text. - * These are meant to capture the intent of the TODO using simple keyword heuristics. + * Classifies a given TODO text into one or more predefined categories based on its content. * - * @param text The body of the TODO comment (without tag). - * @returns A list of labels like 'refactor', 'test', 'doc', etc. + * @param text - The TODO text to classify. + * @returns An array of strings representing the labels that match the content of the text. + * + * The function identifies the following categories: + * - `refactor`: Matches keywords related to code refactoring, simplification, or optimization. + * - `test`: Matches keywords related to testing, such as adding tests or verifying functionality. + * - `doc`: Matches keywords related to documentation, comments, or explaining code. + * - `performance`: Matches keywords related to performance improvements or latency issues. + * - `security`: Matches keywords related to security concerns, vulnerabilities, or sanitization. + * - `maintenance`: Matches keywords related to deprecation, migration, upgrades, or legacy code removal. */ export function classifyTodoText(text: string): string[] { - const lower = text.toLowerCase(); - const labels = new Set(); - - // Refactor / cleanup - if ( - /\b(refactor|simplify|cleanup|restructure|optimi[sz]e|rework|rewrite)\b/.test(lower) - ) { - labels.add('refactor'); - } - - // Testing - if ( - /\b(test|add test|unit test|coverage|verify)\b/.test(lower) - ) { - labels.add('test'); - } - - // Documentation - if ( - /\b(doc|docs|documentation|comment|explain)\b/.test(lower) - ) { - labels.add('doc'); - } - - // Performance - if ( - /\b(performance|perf|slow|latency|optimi[sz]e)\b/.test(lower) - ) { - labels.add('performance'); - } - - // Security - if ( - /\b(security|vuln|injection|auth|encrypt|sanitize)\b/.test(lower) - ) { - labels.add('security'); - } - - // Deprecation or migration - if ( - /\b(deprecate|migrate|upgrade|legacy|remove)\b/.test(lower) - ) { - labels.add('maintenance'); - } - - return Array.from(labels); + const lower = text.toLowerCase(); + const labels = new Set(); + + // Refactor / cleanup + if ( + /\b(refactor|simplify|clean[\s\-]?up|restructure|optimi[sz]e|rework|rewrite)\b/.test(lower) + ) { + labels.add('refactor'); + } + + // Testing + if ( + /\b(tests?|add(ed)? tests?|unit tests?|test\s+coverage|verify|assert)\b/.test(lower) + ) { + labels.add('test'); + } + + // Documentation + if ( + /\b(docs?|documentation|comment[s]?|explain|document(ed|ing)?)\b/.test(lower) + ) { + labels.add('doc'); } + + // Performance + if ( + /\b(performance|perf|slow|latency|optimi[sz]e)\b/.test(lower) + ) { + labels.add('performance'); + } + + // Security + if ( + /\b(security|vuln(?:erability)?|injection|auth|encrypt|sanitize)\b/.test(lower) + ) { + labels.add('security'); + } + + // Deprecation / migration / upgrade + if ( + /\b(deprecat(e|ed|ing)?|migrat(e|ed|ing)?|upgrade[d]?|legacy|remove[d]?)\b/.test(lower) + ) { + labels.add('maintenance'); + } + + return Array.from(labels); +} + \ No newline at end of file diff --git a/src/generateChangelog.ts b/src/generateChangelog.ts new file mode 100644 index 0000000..4b6e77f --- /dev/null +++ b/src/generateChangelog.ts @@ -0,0 +1,61 @@ +// scripts/generateChangelog.ts + +import fs from 'fs'; +import path from 'path'; +import { extractTodosFromDir } from '../src/parser/extractTodosFromDir'; +import { labelsFromTodo } from '../src/core/labelManager'; +import { TodoItem } from '../src/parser/types'; + +// Util: agrupador por chave composta +function groupTodos(todos: TodoItem[]): Record { + const groups: Record = {}; + + for (const todo of todos) { + const semanticLabels = labelsFromTodo(todo); + const meta = todo.metadata ?? {}; + + for (const label of semanticLabels) { + const metaKeys = Object.entries(meta) + .map(([k, v]) => `${k}:${v}`) + .join(', '); + + const key = `[${todo.tag}] + [${label}]${metaKeys ? ` + [${metaKeys}]` : ''}`; + + if (!groups[key]) groups[key] = []; + groups[key].push(todo); + } + } + + return groups; +} + +// Util: gera string formatada para cada grupo +function formatChangelog(groups: Record): string { + const lines: string[] = []; + + for (const key of Object.keys(groups).sort()) { + lines.push(`## ${key}\n`); + + for (const todo of groups[key]) { + lines.push(`- (${todo.file}:${todo.line}) ${todo.text}`); + } + + lines.push(''); + } + + return lines.join('\n'); +} + +async function main() { + const todos = await extractTodosFromDir('./'); + const grouped = groupTodos(todos); + const changelog = formatChangelog(grouped); + + const filePath = path.resolve('CHANGELOG.md'); + fs.writeFileSync(filePath, changelog, 'utf-8'); + + console.log(`📦 Changelog gerado com ${todos.length} TODOs agrupados → ${filePath}`); +} + +main(); + diff --git a/tests/applyTemplate.test.ts b/tests/applyTemplate.test.ts index dea01fa..55b4019 100644 --- a/tests/applyTemplate.test.ts +++ b/tests/applyTemplate.test.ts @@ -1,4 +1,4 @@ -import { applyTemplate } from '../../templates/utils'; +import { applyTemplate } from '../src/templates/utils'; import { describe, it, expect } from 'vitest' describe('applyTemplate', () => { diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts index f63e132..fd02ab7 100644 --- a/tests/classifier.test.ts +++ b/tests/classifier.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { classifyTodoText } from '../classifier'; +import { classifyTodoText } from '../src/core/classifier'; describe('classifyTodoText', () => { it('classifies refactor-related TODOs', () => { diff --git a/tests/commentPatterns.test.ts b/tests/commentPatterns.test.ts index 90d7d21..408f1f3 100644 --- a/tests/commentPatterns.test.ts +++ b/tests/commentPatterns.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; +import { extractTodosFromString } from '../src/parser/extractTodosFromContent'; describe('extractTodosFromString - comment support by extension', () => { it('extracts from JS-style (//) for .js/.ts/.go/.java', () => { diff --git a/tests/extractTodos.test.ts b/tests/extractTodos.test.ts index 00f6e61..a2d2daf 100644 --- a/tests/extractTodos.test.ts +++ b/tests/extractTodos.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; -import { TodoItem } from '../../parser/types'; +import { extractTodosFromString } from '../src/parser/extractTodosFromContent'; +import { TodoItem } from '../src/parser/types'; diff --git a/tests/extractTodosFromContent.test.ts b/tests/extractTodosFromContent.test.ts index 7e6844e..8a802be 100644 --- a/tests/extractTodosFromContent.test.ts +++ b/tests/extractTodosFromContent.test.ts @@ -1,4 +1,4 @@ -import { extractTodosFromString } from '../../parser/extractTodosFromContent'; +import { extractTodosFromString } from '../src/parser/extractTodosFromContent'; import { describe, it, expect } from 'vitest' describe('extractTodosFromString', () => { diff --git a/tests/extractTodosFromDir.test.ts b/tests/extractTodosFromDir.test.ts index 485c9a9..5b6812f 100644 --- a/tests/extractTodosFromDir.test.ts +++ b/tests/extractTodosFromDir.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import path from 'path'; -import { extractTodosFromDir } from '../../parser/extractTodosFromDir'; +import { extractTodosFromDir } from '../src/parser/extractTodosFromDir'; describe('extractTodosFromDir', () => { const base = path.join(__dirname, 'fixtures'); diff --git a/tests/issueManager.test.ts b/tests/issueManager.test.ts index 719a000..81325a4 100644 --- a/tests/issueManager.test.ts +++ b/tests/issueManager.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as core from '@actions/core'; -import { getExistingIssueTitles, createIssueIfNeeded } from '../issueManager'; -import { TodoItem } from '../../parser/types'; +import { getExistingIssueTitles, createIssueIfNeeded } from '../src/core/issueManager'; +import { TodoItem } from '../src/parser/types'; // Mocks const mockOctokit = { @@ -15,10 +15,11 @@ const mockOctokit = { } }; -vi.mock('../labelManager', () => ({ +vi.mock('../src/core/labelManager', () => ({ ensureLabelExists: vi.fn(), LABELS_BY_TAG: { TODO: ['enhancement'] }, - labelsFromMetadata: () => ['priority:high'] + labelsFromMetadata: () => ['priority:high'], + labelsFromTodo: (todo: TodoItem) => ['enhancement', 'priority:high', 'refactor'] // 🆕 mock do novo método })); describe('getExistingIssueTitles', () => { @@ -56,15 +57,15 @@ describe('createIssueIfNeeded', () => { }); it('should skip duplicate issues', async () => { - const existingTitles: Set = new Set(['[TODO] Refactor component']); + const existingTitles: Set = new Set(['[TODO] Refactor component']); await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); - expect(octokit.rest.issues.create).not.toHaveBeenCalled(); + expect(octokit.rest.issues.create).not.toHaveBeenCalled(); // ✅ espera não ser chamado }); it('should create a new issue if not duplicated', async () => { - const existingTitles: Set = new Set(); + const existingTitles: Set = new Set(); await createIssueIfNeeded(octokit, owner, repo, todo, existingTitles); @@ -72,8 +73,8 @@ describe('createIssueIfNeeded', () => { owner, repo, title: '[TODO] Refactor component', - body: expect.stringContaining('src/file.ts'), - labels: ['enhancement', 'priority:high'] + body: expect.stringContaining('Refactor component'), + labels: ['enhancement', 'priority:high', 'refactor'] // ✅ agora incluindo semantic label }); }); }); diff --git a/tests/labelManager.test.ts b/tests/labelManager.test.ts index 89f72ae..10ec123 100644 --- a/tests/labelManager.test.ts +++ b/tests/labelManager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import * as core from '@actions/core'; -import { labelsFromMetadata, ensureLabelExists } from '../labelManager'; +import { labelsFromMetadata, ensureLabelExists } from '../src/core/labelManager'; const mockOctokit = { rest: { diff --git a/yarn.lock b/yarn.lock index e0c1a5c..4215abd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -78,6 +78,13 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + "@esbuild/aix-ppc64@0.25.2": version "0.25.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz#b87036f644f572efb2b3c75746c97d1d2d87ace8" @@ -234,7 +241,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== @@ -249,6 +256,14 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -554,6 +569,26 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz#fd92d31a2931483c25677b9c6698106490cbbc76" integrity sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ== +"@tsconfig/node10@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" + integrity sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/estree@1.0.7", "@types/estree@^1.0.0": version "1.0.7" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" @@ -648,6 +683,18 @@ loupe "^3.1.3" tinyrainbow "^2.0.0" +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.4.1: + version "8.14.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -670,6 +717,11 @@ ansi-styles@^6.1.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -730,6 +782,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -756,6 +813,11 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -952,6 +1014,11 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" @@ -1208,6 +1275,25 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + tunnel@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" @@ -1240,6 +1326,11 @@ universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.2.tgz#52e7d0e9b3dc4df06cc33cb2b9fd79041a54827e" integrity sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q== +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + vite-node@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.1.1.tgz#ad186c07859a6e5fca7c7f563e55fb11b16557bc" @@ -1341,3 +1432,8 @@ wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== From 6ef79e066e8aa65d69b212051a6c24909acce4b1 Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 19:15:04 +0100 Subject: [PATCH 4/7] chore: develop --- .github/workflows/run_tests.yml | 1 + .github/workflows/todo.yml | 19 +++++++++ src/generateChangelog.ts | 74 +++++++++++++++------------------ 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index eeece84..2aab3ad 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -24,6 +24,7 @@ jobs: - name: Run tests with coverage run: yarn vitest run --coverage + # - name: Upload coverage to Codecov # uses: codecov/codecov-action@v5 diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml index 3061d3d..e58cec9 100644 --- a/.github/workflows/todo.yml +++ b/.github/workflows/todo.yml @@ -43,3 +43,22 @@ jobs: git add TODO_REPORT.md git commit -m "chore(report): update TODO report" || echo "No changes" git push + + - name: Generate Changelog from TODOs + run: | + yarn changelog + git diff --quiet CHANGELOG.md || echo "Changelog updated" + + - name: Commit CHANGELOG.md if changed + if: success() + run: | + if git diff --quiet CHANGELOG.md; then + echo "No changelog changes to commit." + else + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "chore(changelog): update CHANGELOG from TODOs" + git push + fi + \ No newline at end of file diff --git a/src/generateChangelog.ts b/src/generateChangelog.ts index 4b6e77f..4eb00cd 100644 --- a/src/generateChangelog.ts +++ b/src/generateChangelog.ts @@ -1,61 +1,55 @@ -// scripts/generateChangelog.ts - import fs from 'fs'; import path from 'path'; -import { extractTodosFromDir } from '../src/parser/extractTodosFromDir'; -import { labelsFromTodo } from '../src/core/labelManager'; -import { TodoItem } from '../src/parser/types'; +import { extractTodosFromDir } from './core/extractTodosFromDir'; +import { classifyTodoText } from './core/classifier'; +import { TodoItem } from './parser/types'; + +function formatGroupHeader(tag: string, semantic: string, metadataKey?: string, metadataValue?: string): string { + const parts = [tag]; + if (semantic) parts.push(semantic); + if (metadataKey && metadataValue) parts.push(`${metadataKey}:${metadataValue}`); + return `## ${parts.join(' · ')}`; +} -// Util: agrupador por chave composta -function groupTodos(todos: TodoItem[]): Record { - const groups: Record = {}; +function generateChangelogContent(todos: TodoItem[]): string { + type GroupKey = string; + const groups: Record = {}; for (const todo of todos) { - const semanticLabels = labelsFromTodo(todo); - const meta = todo.metadata ?? {}; - - for (const label of semanticLabels) { - const metaKeys = Object.entries(meta) - .map(([k, v]) => `${k}:${v}`) - .join(', '); - - const key = `[${todo.tag}] + [${label}]${metaKeys ? ` + [${metaKeys}]` : ''}`; - - if (!groups[key]) groups[key] = []; - groups[key].push(todo); + const semantics = classifyTodoText(todo.text); + const metadataEntries = Object.entries(todo.metadata || {}) || [['', '']]; + const tag = todo.tag.toUpperCase(); + + for (const semantic of semantics.length ? semantics : ['']) { + for (const [metaKey, metaValue] of metadataEntries.length ? metadataEntries : [['', '']]) { + const key = JSON.stringify({ tag, semantic, metaKey, metaValue }); + if (!groups[key]) groups[key] = []; + groups[key].push(todo); + } } } - return groups; -} - -// Util: gera string formatada para cada grupo -function formatChangelog(groups: Record): string { - const lines: string[] = []; + const output: string[] = ['# 📝 Changelog (from TODOs)', '']; - for (const key of Object.keys(groups).sort()) { - lines.push(`## ${key}\n`); + for (const key of Object.keys(groups)) { + const { tag, semantic, metaKey, metaValue } = JSON.parse(key); + output.push(formatGroupHeader(tag, semantic, metaKey, metaValue)); for (const todo of groups[key]) { - lines.push(`- (${todo.file}:${todo.line}) ${todo.text}`); + output.push(`- ${todo.text} (\`${todo.file}:${todo.line}\`)`); } - lines.push(''); + output.push(''); } - return lines.join('\n'); + return output.join('\n'); } async function main() { - const todos = await extractTodosFromDir('./'); - const grouped = groupTodos(todos); - const changelog = formatChangelog(grouped); - - const filePath = path.resolve('CHANGELOG.md'); - fs.writeFileSync(filePath, changelog, 'utf-8'); - - console.log(`📦 Changelog gerado com ${todos.length} TODOs agrupados → ${filePath}`); + const todos = await extractTodosFromDir('src'); + const changelog = generateChangelogContent(todos); + fs.writeFileSync('CHANGELOG.md', changelog, 'utf8'); + console.log('✅ Changelog saved to CHANGELOG.md'); } main(); - From eeed07fa3d285142ea5c3bb582bf54e0f782d350 Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 19:22:30 +0100 Subject: [PATCH 5/7] chore: develop work --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- {src => scripts}/generateChangelog.ts | 6 +++--- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md rename {src => scripts}/generateChangelog.ts (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3db9676 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# 📝 Changelog (from TODOs) + +## TODO +- .ts (`src/testTodo.ts:1`) + +## TODO · refactor +- Refactor this logic to improve performance (`src/testTodo.ts:2`) + +## TODO · performance +- Refactor this logic to improve performance (`src/testTodo.ts:2`) diff --git a/package.json b/package.json index 44ea42f..437cd7c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsc", "test": "vitest run", "prepare": "yarn ncc build src/ActionMain.ts -o dist", - "changelog": "ts-node src/generateChangelog.ts", + "changelog": "ts-node scripts/generateChangelog.ts", "build:dist": "ncc build src/ActionMain.ts -o dist" }, "keywords": [ diff --git a/src/generateChangelog.ts b/scripts/generateChangelog.ts similarity index 90% rename from src/generateChangelog.ts rename to scripts/generateChangelog.ts index 4eb00cd..c87f17e 100644 --- a/src/generateChangelog.ts +++ b/scripts/generateChangelog.ts @@ -1,8 +1,8 @@ import fs from 'fs'; import path from 'path'; -import { extractTodosFromDir } from './core/extractTodosFromDir'; -import { classifyTodoText } from './core/classifier'; -import { TodoItem } from './parser/types'; +import { extractTodosFromDir } from '../src/parser/extractTodosFromDir'; +import { classifyTodoText } from '../src/core/classifier'; +import { TodoItem } from '../src/parser/types'; function formatGroupHeader(tag: string, semantic: string, metadataKey?: string, metadataValue?: string): string { const parts = [tag]; From 3787735bb21ec9b6c1b392b0707ecf7be4bf53c6 Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 19:38:01 +0100 Subject: [PATCH 6/7] chore: develop --- .github/workflows/todo.yml | 30 +++------ src/core/changelog.ts | 128 +++++++++++++++++-------------------- 2 files changed, 66 insertions(+), 92 deletions(-) diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml index e58cec9..a05c181 100644 --- a/.github/workflows/todo.yml +++ b/.github/workflows/todo.yml @@ -12,10 +12,10 @@ jobs: issues: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -36,29 +36,15 @@ jobs: name: todo-report path: TODO_REPORT.md - - name: Commit TODO report + - name: Generate Changelog from TODOs + run: yarn changelog + + - name: Commit TODO report and CHANGELOG run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - git add TODO_REPORT.md - git commit -m "chore(report): update TODO report" || echo "No changes" + git add TODO_REPORT.md CHANGELOG.md + git diff --cached --quiet && echo "No changes to commit." || git commit -m "chore(report): update TODO report and changelog [skip ci]" git push - - name: Generate Changelog from TODOs - run: | - yarn changelog - git diff --quiet CHANGELOG.md || echo "Changelog updated" - - - name: Commit CHANGELOG.md if changed - if: success() - run: | - if git diff --quiet CHANGELOG.md; then - echo "No changelog changes to commit." - else - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git commit -m "chore(changelog): update CHANGELOG from TODOs" - git push - fi \ No newline at end of file diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 9168c26..3e16293 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -1,78 +1,66 @@ -// src/core/changelog.ts -import { TodoItem } from '../parser/types'; -import { labelsFromTodo } from './labelManager'; +// src/ActionMain.ts +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import path from 'path'; +import fs from 'fs'; +import { extractTodosFromDir } from './parser/extractTodosFromDir'; +import { TodoItem } from './parser/types'; +import { getExistingIssueTitles, createIssueIfNeeded } from './core/issueManager'; +import { generateMarkdownReport } from './core/report'; +import { limitTodos, todoKey } from './core/todoUtils'; +import { generateChangelogFromTodos } from './core/changelog'; -/** - * Generates a changelog from a list of TODO items, grouping them by labels and formatting - * them into a markdown string. - * - * @param todos - An array of `TodoItem` objects representing the TODO items to include in the changelog. - * @returns A formatted markdown string representing the changelog, grouped by labels with icons. - * - * The changelog includes: - * - A header (`## Changelog de TODOs`). - * - Grouped sections for each label, sorted alphabetically. - * - Each section includes a list of TODO items with their tag, text, file, line, and optional metadata. - * - * Label icons are mapped as follows: - * - `bug`: 🐞 - * - `enhancement`: ✨ - * - `technical-debt`: 💣 - * - `test`: 🔬 - * - `doc`: 📄 - * - `refactor`: 🚧 - * - `performance`: ⚡️ - * - `security`: 🔐 - * - Default: 🔖 - * - * Example output: - * ``` - * ## Changelog de TODOs - * - * ### 🐞 Bug - * - [BUG-123] Fix null pointer exception (`file.ts:42`) — `priority:high` - * - * ### ✨ Enhancement - * - [ENH-456] Add new feature (`feature.ts:10`) - * ``` - */ -export function generateChangelog(todos: TodoItem[]): string { - const grouped: Record = {}; +async function run(): Promise { + try { + const token = core.getInput('repo-token', { required: true }); + const generateReport = core.getInput('report') === 'true'; + const titleTemplatePath = core.getInput('issue-title-template'); + const bodyTemplatePath = core.getInput('issue-body-template'); + const workspace = process.env.GITHUB_WORKSPACE || '.'; - for (const todo of todos) { - for (const label of labelsFromTodo(todo)) { - if (!grouped[label]) grouped[label] = []; - grouped[label].push(todo); + const todos: TodoItem[] = extractTodosFromDir(workspace); + const octokit = github.getOctokit(token); + const { owner, repo } = github.context.repo; + + core.info(`🔍 Found ${todos.length} TODOs`); + + const existingTitles = await getExistingIssueTitles(octokit, owner, repo); + + const seenKeys = new Set(); + const uniqueTodos = todos.filter(todo => { + const key = todoKey(todo); + if (seenKeys.has(key)) return false; + seenKeys.add(key); + return true; + }); + + const todosToCreate = limitTodos(uniqueTodos, 5); + + for (const todo of todosToCreate) { + await createIssueIfNeeded( + octokit, + owner, + repo, + todo, + existingTitles, + titleTemplatePath, + bodyTemplatePath + ); } - } - let output = `## Changelog de TODOs\n\n`; - const labelIcons: Record = { - bug: '🐞', - enhancement: '✨', - 'technical-debt': '💣', - test: '🔬', - doc: '📄', - refactor: '🚧', - performance: '⚡️', - security: '🔐' - }; + if (generateReport) { + generateMarkdownReport(todos); + core.info('📝 Generated TODO_REPORT.md'); - for (const label of Object.keys(grouped).sort()) { - const icon = labelIcons[label] || '🔖'; - output += `### ${icon} ${label[0].toUpperCase() + label.slice(1)}\n`; - for (const todo of grouped[label]) { - output += `- [${todo.tag}] ${todo.text} (\`${todo.file}:${todo.line}\`)`; - if (todo.metadata) { - const meta = Object.entries(todo.metadata) - .map(([k, v]) => `\`${k}:${v}\``) - .join(' '); - output += ` — ${meta}`; - } - output += `\n`; + const changelog = generateChangelogFromTodos(todos); + fs.writeFileSync('CHANGELOG.md', changelog, 'utf8'); + core.info('📦 Generated CHANGELOG.md'); } - output += `\n`; - } - return output.trim(); + } catch (error: any) { + core.setFailed(`Action failed: ${error.message}`); + } } + +run(); + From e5cda8dc3e10d09b73ec83c88203279fe69050f8 Mon Sep 17 00:00:00 2001 From: Diogo Ribeiro Date: Thu, 17 Apr 2025 19:42:14 +0100 Subject: [PATCH 7/7] chore: develop --- src/ActionMain.ts | 8 ++++ src/core/changelog.ts | 94 +++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 58 deletions(-) diff --git a/src/ActionMain.ts b/src/ActionMain.ts index f0b0648..3e16293 100644 --- a/src/ActionMain.ts +++ b/src/ActionMain.ts @@ -1,11 +1,14 @@ +// src/ActionMain.ts import * as core from '@actions/core'; import * as github from '@actions/github'; import path from 'path'; +import fs from 'fs'; import { extractTodosFromDir } from './parser/extractTodosFromDir'; import { TodoItem } from './parser/types'; import { getExistingIssueTitles, createIssueIfNeeded } from './core/issueManager'; import { generateMarkdownReport } from './core/report'; import { limitTodos, todoKey } from './core/todoUtils'; +import { generateChangelogFromTodos } from './core/changelog'; async function run(): Promise { try { @@ -48,6 +51,10 @@ async function run(): Promise { if (generateReport) { generateMarkdownReport(todos); core.info('📝 Generated TODO_REPORT.md'); + + const changelog = generateChangelogFromTodos(todos); + fs.writeFileSync('CHANGELOG.md', changelog, 'utf8'); + core.info('📦 Generated CHANGELOG.md'); } } catch (error: any) { @@ -56,3 +63,4 @@ async function run(): Promise { } run(); + diff --git a/src/core/changelog.ts b/src/core/changelog.ts index 3e16293..65c11e6 100644 --- a/src/core/changelog.ts +++ b/src/core/changelog.ts @@ -1,66 +1,44 @@ -// src/ActionMain.ts -import * as core from '@actions/core'; -import * as github from '@actions/github'; -import path from 'path'; -import fs from 'fs'; -import { extractTodosFromDir } from './parser/extractTodosFromDir'; -import { TodoItem } from './parser/types'; -import { getExistingIssueTitles, createIssueIfNeeded } from './core/issueManager'; -import { generateMarkdownReport } from './core/report'; -import { limitTodos, todoKey } from './core/todoUtils'; -import { generateChangelogFromTodos } from './core/changelog'; - -async function run(): Promise { - try { - const token = core.getInput('repo-token', { required: true }); - const generateReport = core.getInput('report') === 'true'; - const titleTemplatePath = core.getInput('issue-title-template'); - const bodyTemplatePath = core.getInput('issue-body-template'); - const workspace = process.env.GITHUB_WORKSPACE || '.'; - - const todos: TodoItem[] = extractTodosFromDir(workspace); - const octokit = github.getOctokit(token); - const { owner, repo } = github.context.repo; - - core.info(`🔍 Found ${todos.length} TODOs`); - - const existingTitles = await getExistingIssueTitles(octokit, owner, repo); - - const seenKeys = new Set(); - const uniqueTodos = todos.filter(todo => { - const key = todoKey(todo); - if (seenKeys.has(key)) return false; - seenKeys.add(key); - return true; - }); - - const todosToCreate = limitTodos(uniqueTodos, 5); +// src/core/changelog.ts +import { TodoItem } from '../parser/types'; +import { classifyTodoText } from './classifier'; + +function formatGroupHeader(tag: string, semantic: string, metadataKey?: string, metadataValue?: string): string { + const parts = [tag]; + if (semantic) parts.push(semantic); + if (metadataKey && metadataValue) parts.push(`${metadataKey}:${metadataValue}`); + return `## ${parts.join(' · ')}`; +} - for (const todo of todosToCreate) { - await createIssueIfNeeded( - octokit, - owner, - repo, - todo, - existingTitles, - titleTemplatePath, - bodyTemplatePath - ); +export function generateChangelogFromTodos(todos: TodoItem[]): string { + type GroupKey = string; + const groups: Record = {}; + + for (const todo of todos) { + const semantics = classifyTodoText(todo.text); + const metadataEntries = Object.entries(todo.metadata || {}) || [['', '']]; + const tag = todo.tag.toUpperCase(); + + for (const semantic of semantics.length ? semantics : ['']) { + for (const [metaKey, metaValue] of metadataEntries.length ? metadataEntries : [['', '']]) { + const key = JSON.stringify({ tag, semantic, metaKey, metaValue }); + if (!groups[key]) groups[key] = []; + groups[key].push(todo); + } } + } + + const output: string[] = ['# 📝 Changelog (from TODOs)', '']; - if (generateReport) { - generateMarkdownReport(todos); - core.info('📝 Generated TODO_REPORT.md'); + for (const key of Object.keys(groups)) { + const { tag, semantic, metaKey, metaValue } = JSON.parse(key); + output.push(formatGroupHeader(tag, semantic, metaKey, metaValue)); - const changelog = generateChangelogFromTodos(todos); - fs.writeFileSync('CHANGELOG.md', changelog, 'utf8'); - core.info('📦 Generated CHANGELOG.md'); + for (const todo of groups[key]) { + output.push(`- ${todo.text} (\`${todo.file}:${todo.line}\`)`); } - } catch (error: any) { - core.setFailed(`Action failed: ${error.message}`); + output.push(''); } -} - -run(); + return output.join('\n'); +}