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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
15 changes: 10 additions & 5 deletions .github/workflows/todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -36,10 +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


10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`)
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 scripts/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
55 changes: 55 additions & 0 deletions scripts/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 '../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];
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();
8 changes: 8 additions & 0 deletions src/ActionMain.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
try {
Expand Down Expand Up @@ -48,6 +51,10 @@ async function run(): Promise<void> {
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) {
Expand All @@ -56,3 +63,4 @@ async function run(): Promise<void> {
}

run();

44 changes: 44 additions & 0 deletions src/core/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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(' · ')}`;
}

export function generateChangelogFromTodos(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');
}
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(
}
}
}

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
Loading