Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"scripts": {
"build": "tsc",
"test": "vitest run",
"prepare": "yarn ncc build src/ActionMain.ts -o dist"
"prepare": "yarn ncc build src/ActionMain.ts -o dist",
"changelog": "ts-node src/generateChangelog.ts",
"build:dist": "ncc build src/ActionMain.ts -o dist"
},
"keywords": [
"github-action",
Expand All @@ -27,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"
}
Expand Down
78 changes: 78 additions & 0 deletions src/core/changelog.ts
Original file line number Diff line number Diff line change
@@ -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<string, TodoItem[]> = {};

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<string, string> = {
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();
}
64 changes: 64 additions & 0 deletions src/core/classifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Classifies a given TODO text into one or more predefined categories based on its content.
*
* @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<string>();

// 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);
}


13 changes: 8 additions & 5 deletions src/core/issueManager.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion src/core/labelManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
Expand All @@ -14,7 +16,10 @@ export const LABEL_COLORS: Record<string, string> = {
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.
Expand All @@ -23,6 +28,16 @@ export function labelsFromMetadata(metadata?: Record<string, string>): 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<typeof github.getOctokit>,
Expand All @@ -49,3 +64,4 @@ export async function ensureLabelExists(
}
}
}

55 changes: 55 additions & 0 deletions src/generateChangelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import fs from 'fs';
import path from 'path';
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(' · ')}`;
}

function generateChangelogContent(todos: TodoItem[]): string {
type GroupKey = string;
const groups: Record<GroupKey, TodoItem[]> = {};

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)', ''];

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]) {
output.push(`- ${todo.text} (\`${todo.file}:${todo.line}\`)`);
}

output.push('');
}

return output.join('\n');
}

async function main() {
const todos = await extractTodosFromDir('src');
const changelog = generateChangelogContent(todos);
fs.writeFileSync('CHANGELOG.md', changelog, 'utf8');
console.log('✅ Changelog saved to CHANGELOG.md');
}

main();
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applyTemplate } from '../../templates/utils';
import { applyTemplate } from '../src/templates/utils';
import { describe, it, expect } from 'vitest'

describe('applyTemplate', () => {
Expand Down
46 changes: 46 additions & 0 deletions tests/classifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { classifyTodoText } from '../src/core/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');
});
});
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';



Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractTodosFromString } from '../../parser/extractTodosFromContent';
import { extractTodosFromString } from '../src/parser/extractTodosFromContent';
import { describe, it, expect } from 'vitest'

describe('extractTodosFromString', () => {
Expand Down
Loading
Loading