Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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();
66 changes: 66 additions & 0 deletions src/core/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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 {
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<string>();
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
);
}

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) {
core.setFailed(`Action failed: ${error.message}`);
}
}

run();

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
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
Loading
Loading