snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env bun
interface GitHubIssue {
number: number;
title: string;
user: { id: number };
created_at: string;
}
interface GitHubComment {
id: number;
body: string;
created_at: string;
user: { type: string; id: number };
}
interface GitHubReaction {
user: { id: number };
content: string;
}
async function githubRequest<T>(endpoint: string, token: string, method: string = 'GET', body?: any): Promise<T> {
const response = await fetch(`https://api.github.com${endpoint}`, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'auto-close-duplicates-script',
...(body && { 'Content-Type': 'application/json' }),
},
...(body && { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
function extractDuplicateIssueNumber(commentBody: string): number | null {
// Try to match #123 format first
let match = commentBody.match(/#(\d+)/);
if (match) {
return parseInt(match[1], 10);
}
// Try to match GitHub issue URL format: https://github.com/owner/repo/issues/123
match = commentBody.match(/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
async function closeIssueAsDuplicate(
owner: string,
repo: string,
issueNumber: number,
duplicateOfNumber: number,
token: string,
): Promise<void> {
await githubRequest(`/repos/${owner}/${repo}/issues/${issueNumber}`, token, 'PATCH', {
state: 'closed',
state_reason: 'duplicate',
labels: ['duplicate'],
});
await githubRequest(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, token, 'POST', {
body: `This issue has been automatically closed as a duplicate of #${duplicateOfNumber}.
If this is incorrect, please re-open this issue or create a new one.
🤖 Generated with [Claude Code](https://claude.ai/code)`,
});
}
async function autoCloseDuplicates(): Promise<void> {
console.log('[DEBUG] Starting auto-close duplicates script');
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
console.log('[DEBUG] GitHub token found');
const owner = process.env.GITHUB_REPOSITORY_OWNER || 'RocketChat';
const repo = process.env.GITHUB_REPOSITORY_NAME || 'Rocket.Chat';
console.log(`[DEBUG] Repository: ${owner}/${repo}`);
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
console.log(`[DEBUG] Checking for duplicate comments older than: ${threeDaysAgo.toISOString()}`);
console.log('[DEBUG] Fetching open issues created more than 3 days ago...');
const allIssues: GitHubIssue[] = [];
let page = 1;
const perPage = 100;
while (true) {
const pageIssues: GitHubIssue[] = await githubRequest(
`/repos/${owner}/${repo}/issues?state=open&per_page=${perPage}&page=${page}`,
token,
);
if (pageIssues.length === 0) break;
// Filter for issues created more than 3 days ago
const oldEnoughIssues = pageIssues.filter((issue) => new Date(issue.created_at) <= threeDaysAgo);
allIssues.push(...oldEnoughIssues);
page++;
// Safety limit to avoid infinite loops
if (page > 20) break;
}
const issues = allIssues;
console.log(`[DEBUG] Found ${issues.length} open issues`);
let processedCount = 0;
let candidateCount = 0;
for (const issue of issues) {
processedCount++;
console.log(`[DEBUG] Processing issue #${issue.number} (${processedCount}/${issues.length}): ${issue.title}`);
console.log(`[DEBUG] Fetching comments for issue #${issue.number}...`);
const comments: GitHubComment[] = await githubRequest(`/repos/${owner}/${repo}/issues/${issue.number}/comments`, token);
console.log(`[DEBUG] Issue #${issue.number} has ${comments.length} comments`);
const dupeComments = comments.filter(
(comment) => comment.body.includes('Found') && comment.body.includes('possible duplicate') && comment.user.type === 'Bot',
);
console.log(`[DEBUG] Issue #${issue.number} has ${dupeComments.length} duplicate detection comments`);
if (dupeComments.length === 0) {
console.log(`[DEBUG] Issue #${issue.number} - no duplicate comments found, skipping`);
continue;
}
const lastDupeComment = dupeComments[dupeComments.length - 1];
const dupeCommentDate = new Date(lastDupeComment.created_at);
console.log(`[DEBUG] Issue #${issue.number} - most recent duplicate comment from: ${dupeCommentDate.toISOString()}`);
if (dupeCommentDate > threeDaysAgo) {
console.log(`[DEBUG] Issue #${issue.number} - duplicate comment is too recent, skipping`);
continue;
}
console.log(
`[DEBUG] Issue #${issue.number} - duplicate comment is old enough (${Math.floor(
(Date.now() - dupeCommentDate.getTime()) / (1000 * 60 * 60 * 24),
)} days)`,
);
const commentsAfterDupe = comments.filter((comment) => new Date(comment.created_at) > dupeCommentDate);
console.log(`[DEBUG] Issue #${issue.number} - ${commentsAfterDupe.length} comments after duplicate detection`);
if (commentsAfterDupe.length > 0) {
console.log(`[DEBUG] Issue #${issue.number} - has activity after duplicate comment, skipping`);
continue;
}
console.log(`[DEBUG] Issue #${issue.number} - checking reactions on duplicate comment...`);
const reactions: GitHubReaction[] = await githubRequest(
`/repos/${owner}/${repo}/issues/comments/${lastDupeComment.id}/reactions`,
token,
);
console.log(`[DEBUG] Issue #${issue.number} - duplicate comment has ${reactions.length} reactions`);
const authorThumbsDown = reactions.some((reaction) => reaction.user.id === issue.user.id && reaction.content === '-1');
console.log(`[DEBUG] Issue #${issue.number} - author thumbs down reaction: ${authorThumbsDown}`);
if (authorThumbsDown) {
console.log(`[DEBUG] Issue #${issue.number} - author disagreed with duplicate detection, skipping`);
continue;
}
const duplicateIssueNumber = extractDuplicateIssueNumber(lastDupeComment.body);
if (!duplicateIssueNumber) {
console.log(`[DEBUG] Issue #${issue.number} - could not extract duplicate issue number from comment, skipping`);
continue;
}
candidateCount++;
const issueUrl = `https://github.com/${owner}/${repo}/issues/${issue.number}`;
try {
console.log(`[INFO] Auto-closing issue #${issue.number} as duplicate of #${duplicateIssueNumber}: ${issueUrl}`);
await closeIssueAsDuplicate(owner, repo, issue.number, duplicateIssueNumber, token);
console.log(`[SUCCESS] Successfully closed issue #${issue.number} as duplicate of #${duplicateIssueNumber}`);
} catch (error) {
console.error(`[ERROR] Failed to close issue #${issue.number} as duplicate: ${error}`);
}
}
console.log(`[DEBUG] Script completed. Processed ${processedCount} issues, found ${candidateCount} candidates for auto-close`);
}
autoCloseDuplicates().catch(console.error);
// Make it a module
export {};

19
scripts/emoji/README.md Normal file
View File

@@ -0,0 +1,19 @@
# emoji
Generates the emoji test fixture used by `@rocket.chat/message-parser`'s coverage test.
## `generateEmojiFixture.mjs`
Writes `packages/message-parser/tests/fixtures/allEmoji.ts` — the full list of every emoji `emojibase-data` ships. The message-parser coverage test parses each one and asserts it is recognized as a single emoji.
### When to run it
After bumping the `emojibase-data` dependency to a newer Unicode version:
```bash
node scripts/emoji/generateEmojiFixture.mjs
```
### Why
The fixture is a committed snapshot, not generated at test time. Regenerating it after a bump adds the newly-introduced emojis to the sweep, so any that the parser grammar doesn't yet recognize fail the test and flag exactly what needs a new grammar rule. Without regenerating, new emojis simply wouldn't be covered.

View File

@@ -0,0 +1,41 @@
// Regenerates the emoji test fixture (packages/message-parser/tests/fixtures/allEmoji.ts) from
// emojibase-data — the canonical set the coverage test parses. Snapshot: rerun after bumping the
// dependency. Run: node scripts/emoji/generateEmojiFixture.mjs
import { writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const data = require('emojibase-data/en/data.json');
const { version } = require('emojibase-data/package.json');
// Every base emoji + skin-tone variant, de-duplicated.
const set = new Set();
for (const e of data) {
if (e.emoji) {
set.add(e.emoji);
}
for (const s of e.skins ?? []) {
if (s.emoji) {
set.add(s.emoji);
}
}
}
const emojis = [...set];
// One space-separated string split at runtime — a single compact line, not thousands. No emoji
// contains a space, and prettier-ignore keeps it off Prettier's long-line wrapping.
const out = `// Auto-generated by scripts/emoji/generateEmojiFixture.mjs — do not edit by hand.
// Full emoji set from emojibase-data@${version} (${emojis.length} entries: base + skin-tone variants).
// Regenerate after bumping emojibase-data: node scripts/emoji/generateEmojiFixture.mjs
export const EMOJIBASE_VERSION = '${version}';
// prettier-ignore
export const ALL_EMOJI: readonly string[] = '${emojis.join(' ')}'.split(' ');
`;
const target = new URL('../../packages/message-parser/tests/fixtures/allEmoji.ts', import.meta.url);
writeFileSync(target, out);
console.log(`Wrote ${emojis.length} emojis (emojibase-data@${version}) to ${target.pathname}`);

41
scripts/fossify.ts Normal file
View File

@@ -0,0 +1,41 @@
import readline from 'readline';
import fs from 'fs/promises';
const removeOptions = { maxRetries: 3, recursive: true };
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const fossify = async () => {
console.log('Removing Premium Apps and Packages...');
await fs.rmdir('./ee', removeOptions);
console.log('Removing Premium code in the main app...');
await fs.rmdir('./apps/meteor/ee', removeOptions);
console.log('Replacing main files...');
await fs.unlink('./apps/meteor/startRocketChat.ts');
await fs.rename('./apps/meteor/startRocketChatFOSS.ts', './apps/meteor/startRocketChat.ts');
console.log('Done.');
};
rl.question('Running this script will permanently delete files from the local directory. Proceed? (n,y) ', (answer) => {
rl.close();
if (answer.toLowerCase() !== 'y') {
return;
}
fossify().catch((e) => {
if (!e) {
console.error('Unknown error');
return;
}
console.error(e);
});
});

View File

@@ -0,0 +1,225 @@
# todo-issue
Scans code for `TODO` comments and automatically creates, closes, updates, and references GitHub Issues.
Runs as a GitHub Actions workflow via [Bun](https://bun.sh/) with a single dependency (`parse-diff`).
## How it works
When code is pushed to `develop`, the script diffs the changes and:
- **Creates** a new issue for each new `TODO` comment
- **Closes** the issue when a `TODO` is removed
- **Updates** the issue title when a `TODO` is edited (detected via similarity matching)
- **References** an existing issue when a duplicate `TODO` is added (adds a comment instead of creating a duplicate)
Every issue created by the script receives the `todo` label, which is also used to efficiently query only relevant issues.
## TODO syntax
Any comment style works (`//`, `#`, `--`, `/* */`, etc.) as long as only symbols and whitespace appear before the `TODO` keyword. The keyword is case-insensitive.
### Examples
**Minimal -- title only:**
```ts
// TODO: Fix the race condition in token refresh
```
Extracted data:
| Field | Value |
| ------ | ----------------------------------------- |
| Title | `Fix the race condition in token refresh` |
| Body | _(none)_ |
| Labels | `todo` |
---
**With body -- continuation lines using the same comment prefix:**
```ts
// TODO: Refactor the auth flow
// The current token refresh logic has race conditions
// when multiple tabs are open simultaneously
```
Extracted data:
| Field | Value |
| ------ | ----------------------------------------------------------------------------------------------------- |
| Title | `Refactor the auth flow` |
| Body | `The current token refresh logic has race conditions`<br>`when multiple tabs are open simultaneously` |
| Labels | `todo` |
The body stops at the first line that doesn't share the same comment prefix or is empty.
---
**With custom labels:**
```ts
// TODO: Make this button red [frontend] [ui]
```
Extracted data:
| Field | Value |
| ------ | ------------------------ |
| Title | `Make this button red` |
| Body | _(none)_ |
| Labels | `todo`, `frontend`, `ui` |
Labels are extracted from `[square brackets]` at the end of the title. The `todo` label is always included.
---
**Full example -- body + labels in different languages:**
```python
# TODO: Add retry logic for S3 uploads [infra] [backend]
# Currently fails silently on timeout
# See https://github.com/org/repo/issues/42
```
Extracted data:
| Field | Value |
| ------ | ------------------------------------------------------------------------------------ |
| Title | `Add retry logic for S3 uploads` |
| Body | `Currently fails silently on timeout`<br>`See https://github.com/org/repo/issues/42` |
| Labels | `todo`, `infra`, `backend` |
---
**Block comments:**
```ts
/**
* TODO: Should we reinvent the wheel here?
* We already have a good one in @rocket.chat/core
*/
```
Extracted data:
| Field | Value |
| ------ | ------------------------------------------------- |
| Title | `Should we reinvent the wheel here?` |
| Body | `We already have a good one in @rocket.chat/core` |
| Labels | `todo` |
---
### Assignees
Mention GitHub usernames with `@` to automatically assign the issue:
```ts
// TODO: Fix flaky test @john-doe [testing]
```
Extracted data:
| Field | Value |
| --------- | ----------------- |
| Title | `Fix flaky test` |
| Body | _(none)_ |
| Labels | `todo`, `testing` |
| Assignees | `john-doe` |
Mentions in the title are removed from the issue title. Mentions in the body are also collected as assignees but kept in the body text:
```ts
// TODO: Migrate to new payments API
// @alice should review the Stripe integration
// @bob handles the webhook setup
```
Extracted data:
| Field | Value |
| --------- | --------------------------------------------------------------------------------- |
| Title | `Migrate to new payments API` |
| Body | `@alice should review the Stripe integration`<br>`@bob handles the webhook setup` |
| Labels | `todo` |
| Assignees | `alice`, `bob` |
---
### What is NOT captured
```ts
// This is not a TODO because the keyword is not at the start after the prefix
// See the TODO documentation for more info
const todo = 'strings containing TODO are ignored';
// TODONT -- partial keyword matches are ignored
```
## Trigger modes
The workflow supports four modes, configured via `workflow_dispatch` inputs or automatic push events.
### Push (automatic)
Triggers on every push to `develop`. Compares `BEFORE_SHA...GITHUB_SHA` to detect added/removed TODOs.
### SHA (manual)
Process a specific commit or range of commits.
| Input | Behavior |
| ------------------- | ------------------------------------------------------- |
| `a1b2c3d` | Diffs commit against its parent (`a1b2c3d~1...a1b2c3d`) |
| `a1b2c3d...f4e5d6a` | Diffs the full range |
### Path (manual)
Import all TODOs from a specific file or directory:
```
apps/meteor/client
packages/core-typings/src/IMessage.ts
```
Only creates issues (no close/update). Skips TODOs that already have a matching issue.
### Import all (manual)
Scans the entire codebase for TODOs. Only creates issues for TODOs that don't already have a matching issue. Use with caution on large codebases.
## Matching logic
The script compares found TODOs against existing issues using:
1. **Exact title match** -- identical titles are considered the same TODO
2. **Similarity match** -- titles within 80% Levenshtein similarity are considered the same TODO (catches typo fixes and small edits)
When a TODO is both added and deleted in the same diff:
- **Same title** -- treated as a move (no action)
- **Similar title** -- treated as an edit (updates the existing issue title)
When a new TODO matches an existing open issue, a reference comment is added to the issue instead of creating a duplicate.
## Rate limiting
The script reads `x-ratelimit-remaining` and `x-ratelimit-reset` from GitHub API response headers and automatically waits when approaching the limit.
## Project structure
```
scripts/todo-issue/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Entry point, config loading, diff resolution, orchestration
├── types.ts # Shared interfaces (Config, TodoItem, GitHubIssue, MatchResult)
├── diff.ts # Diff parsing (parse-diff), TODO extraction, body/label parsing
├── matcher.ts # Match found TODOs against existing issues
├── similarity.ts # Levenshtein distance and similarity check
└── github.ts # GitHub API (REST + GraphQL), rate limiting, issue CRUD
```

View File

@@ -0,0 +1,25 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "todo-issue",
"dependencies": {
"parse-diff": "^0.11.1",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"parse-diff": ["parse-diff@0.11.1", "", {}, "sha512-Oq4j8LAOPOcssanQkIjxosjATBIEJhCxMCxPhMu+Ci4wdNmAEdx0O+a7gzbR2PyKXgKPvRLIN5g224+dJAsKHA=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "todo-issue",
"private": true,
"type": "module",
"dependencies": {
"parse-diff": "^0.11.1"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@@ -0,0 +1,127 @@
import parseDiff from 'parse-diff';
import type { Change } from 'parse-diff';
import type { TodoItem } from './types';
const KEYWORD = 'TODO';
const EXCLUDE_PATTERN = /(^|\/)node_modules\//;
const DEFAULT_LABEL = 'todo';
const MENTION_REGEX = /\B@([a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38})/g;
function extractMentions(text: string): { cleaned: string; mentions: string[] } {
const mentions: string[] = [];
let match;
while ((match = MENTION_REGEX.exec(text)) !== null) {
const username = match[1];
if (!mentions.includes(username)) {
mentions.push(username);
}
}
MENTION_REGEX.lastIndex = 0;
const cleaned = text.replace(MENTION_REGEX, '').replace(/\s{2,}/g, ' ').trim();
return { cleaned, mentions };
}
function extractLabels(title: string): { cleaned: string; labels: string[] } {
const labels: string[] = [];
const tagRegex = /\[([^\]]+)\]$/;
let match = title.match(tagRegex);
while (match) {
labels.push(match[1].trim());
title = title.replace(tagRegex, '').trimEnd();
match = title.match(tagRegex);
}
return { cleaned: title, labels };
}
const TODO_LINE_REGEX = new RegExp(`^\\s*\\W+\\s*${KEYWORD}\\b`, 'i');
function extractBody(changes: Change[], startIndex: number, prefix: string): string | false {
const bodyLines: string[] = [];
const trimmedPrefix = prefix.replace(/\s+$/, '').trim();
const startType = changes[startIndex].type;
for (let j = startIndex + 1; j < changes.length; j++) {
const next = changes[j];
if (next.type !== startType) break;
const content = next.content.slice(1);
if (TODO_LINE_REGEX.test(content)) break;
const prefixMatch = content.match(new RegExp(`^\\s*${trimmedPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
if (!prefixMatch) break;
const lineText = content.slice(prefixMatch[0].length).trim();
if (!lineText) break;
bodyLines.push(lineText);
}
return bodyLines.length ? bodyLines.join('\n') : false;
}
export function extractTodos(diffText: string): TodoItem[] {
const files = parseDiff(diffText);
const todos: TodoItem[] = [];
const regex = new RegExp(`^(?<prefix>\\s*\\W+\\s*)${KEYWORD}\\b\\s*:?\\s*(?<title>.+)`, 'i');
for (const file of files) {
const filename = file.to ?? file.from ?? '';
if (!filename || filename === '/dev/null' || EXCLUDE_PATTERN.test(filename)) continue;
for (const chunk of file.chunks) {
for (let i = 0; i < chunk.changes.length; i++) {
const change = chunk.changes[i];
if (change.type === 'normal') continue;
const raw = change.content.slice(1);
const match = regex.exec(raw);
if (!match?.groups) continue;
let title = match.groups.title.trim();
if (!title) continue;
const prefix = match.groups.prefix;
const body = extractBody(chunk.changes, i, prefix);
const { cleaned: labelCleaned, labels } = extractLabels(title);
const { cleaned: mentionCleaned, mentions: titleMentions } = extractMentions(labelCleaned);
title = mentionCleaned;
if (!title) continue;
const assignees = [...titleMentions];
if (body) {
const { mentions: bodyMentions } = extractMentions(body);
for (const m of bodyMentions) {
if (!assignees.includes(m)) assignees.push(m);
}
}
if (title.length > 256) {
title = title.slice(0, 253) + '...';
}
const line = change.type === 'add' ? (change as { ln: number }).ln : (change as { ln: number }).ln;
const allLabels = labels.includes(DEFAULT_LABEL) ? labels : [DEFAULT_LABEL, ...labels];
todos.push({
type: change.type === 'add' ? 'add' : 'del',
title,
body,
filename,
line,
labels: allLabels,
assignees,
});
}
}
}
return todos;
}

View File

@@ -0,0 +1,290 @@
import type { Config, GitHubIssue, TodoItem } from './types';
const BLOB_LINES = 5;
const DEFAULT_LABEL_COLOR = '00B0D8';
const RATE_LIMIT_BUFFER = 5;
let rateLimitRemaining = Infinity;
let rateLimitResetAt = 0;
async function waitForRateLimit(): Promise<void> {
if (rateLimitRemaining > RATE_LIMIT_BUFFER) return;
const waitMs = rateLimitResetAt * 1000 - Date.now() + 1000;
if (waitMs <= 0) return;
console.log(`[RATE] ${rateLimitRemaining} requests left, waiting ${Math.ceil(waitMs / 1000)}s`);
await new Promise((r) => setTimeout(r, waitMs));
}
function trackRateLimit(response: Response): void {
const remaining = response.headers.get('x-ratelimit-remaining');
const reset = response.headers.get('x-ratelimit-reset');
if (remaining) rateLimitRemaining = parseInt(remaining, 10);
if (reset) rateLimitResetAt = parseInt(reset, 10);
}
async function githubRequest<T>(endpoint: string, token: string, method = 'GET', body?: unknown): Promise<T> {
await waitForRateLimit();
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'todo-issue-script',
};
if (body) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`https://api.github.com${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
trackRateLimit(response);
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub API ${method} ${endpoint} failed: ${response.status} ${text}`);
}
return response.json();
}
async function graphqlRequest<T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> {
await waitForRateLimit();
const response = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': 'todo-issue-script',
},
body: JSON.stringify({ query, variables }),
});
trackRateLimit(response);
if (!response.ok) {
const text = await response.text();
throw new Error(`GitHub GraphQL failed: ${response.status} ${text}`);
}
const json = (await response.json()) as { data?: T; errors?: { message: string }[] };
if (json.errors?.length) {
throw new Error(`GitHub GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`);
}
return json.data as T;
}
export async function getDiffFromApi(endpoint: string, token: string): Promise<string> {
await waitForRateLimit();
const response = await fetch(`https://api.github.com${endpoint}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.diff',
'User-Agent': 'todo-issue-script',
},
});
trackRateLimit(response);
if (!response.ok) {
throw new Error(`GitHub API GET ${endpoint} failed: ${response.status}`);
}
return response.text();
}
export async function fetchExistingIssues(config: Config): Promise<GitHubIssue[]> {
console.log('[INFO] Fetching existing issues via GraphQL...');
const allIssues: GitHubIssue[] = [];
let cursor: string | null = null;
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issues(first: 100, after: $cursor, labels: ["todo"], orderBy: { field: CREATED_AT, direction: DESC }) {
pageInfo { hasNextPage endCursor }
nodes {
number
title
state
assignees(first: 10) { nodes { login } }
}
}
}
}
`;
interface IssuesResponse {
repository: {
issues: {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: { number: number; title: string; state: string; assignees: { nodes: { login: string }[] } }[];
};
};
}
while (true) {
const data: IssuesResponse = await graphqlRequest<IssuesResponse>(
query,
{ owner: config.owner, repo: config.repo, cursor },
config.token,
);
const { nodes, pageInfo } = data.repository.issues;
for (const node of nodes) {
allIssues.push({
number: node.number,
title: node.title,
state: node.state === 'OPEN' ? 'open' : 'closed',
assignees: node.assignees.nodes,
});
}
if (!pageInfo.hasNextPage) break;
cursor = pageInfo.endCursor;
if (allIssues.length > 10_000) {
console.log('[WARN] Reached issue limit (10k), stopping pagination');
break;
}
}
console.log(`[INFO] Fetched ${allIssues.length} existing issues`);
return allIssues;
}
function buildIssueBody(todo: TodoItem, owner: string, repo: string, sha: string): string {
const blobUrl = `https://github.com/${owner}/${repo}/blob/${sha}/${encodeURI(todo.filename)}#L${todo.line}-L${todo.line + BLOB_LINES}`;
const lines = [
todo.body || '',
'',
`<!-- todo-issue -->`,
`📝 Found in [\`${todo.filename}#L${todo.line}\`](${blobUrl})`,
'',
`Commit: ${sha}`,
];
return lines.join('\n');
}
function buildIssueBodyFromGroup(todos: TodoItem[], owner: string, repo: string, sha: string): string {
const bodies = [...new Set(todos.map((t) => t.body).filter(Boolean))] as string[];
const locationLines = todos.map((todo) => {
const blobUrl = `https://github.com/${owner}/${repo}/blob/${sha}/${encodeURI(todo.filename)}#L${todo.line}-L${todo.line + BLOB_LINES}`;
return `- [\`${todo.filename}#L${todo.line}\`](${blobUrl})`;
});
const lines = [
bodies.join('\n\n') || '',
'',
`<!-- todo-issue -->`,
`📝 Found in ${todos.length} location(s):`,
'',
...locationLines,
'',
`Commit: ${sha}`,
];
return lines.join('\n');
}
async function ensureLabelExists(owner: string, repo: string, label: string, token: string): Promise<void> {
try {
await githubRequest(`/repos/${owner}/${repo}/labels`, token, 'POST', {
name: label,
color: DEFAULT_LABEL_COLOR,
});
} catch (err) {
if (!String(err).includes('422')) throw err;
}
}
function mergeTodoGroup(todos: TodoItem[]): TodoItem {
const first = todos[0];
const labels = [...new Set(todos.flatMap((t) => t.labels))];
const assignees = [...new Set(todos.flatMap((t) => t.assignees))];
return { ...first, labels, assignees };
}
export async function createIssue(todoOrGroup: TodoItem | TodoItem[], config: Config, sha: string): Promise<void> {
const group = Array.isArray(todoOrGroup) ? todoOrGroup : [todoOrGroup];
const todo = mergeTodoGroup(group);
for (const label of todo.labels) {
await ensureLabelExists(config.owner, config.repo, label, config.token);
}
const body = group.length > 1 ? buildIssueBodyFromGroup(group, config.owner, config.repo, sha) : buildIssueBody(todo, config.owner, config.repo, sha);
const locations = group.map((t) => `${t.filename}#L${t.line}`).join(', ');
console.log(`[CREATE] "${todo.title}" (${locations})`);
const payload: Record<string, unknown> = {
title: todo.title,
body,
labels: todo.labels,
...(todo.assignees.length && { assignees: todo.assignees }),
};
try {
await githubRequest(`/repos/${config.owner}/${config.repo}/issues`, config.token, 'POST', payload);
} catch (err) {
if (todo.assignees.length && String(err).includes('422')) {
console.log(`[WARN] Assignees ${todo.assignees.join(', ')} may be invalid, retrying without them`);
delete payload.assignees;
await githubRequest(`/repos/${config.owner}/${config.repo}/issues`, config.token, 'POST', payload);
} else {
throw err;
}
}
}
export async function closeIssue(todo: TodoItem, config: Config, sha: string): Promise<void> {
if (!todo.issueId) return;
console.log(`[CLOSE] #${todo.issueId}: "${todo.title}"`);
await githubRequest(`/repos/${config.owner}/${config.repo}/issues/${todo.issueId}/comments`, config.token, 'POST', {
body: `This TODO was removed in commit ${sha}.\n\nClosing automatically.`,
});
await githubRequest(`/repos/${config.owner}/${config.repo}/issues/${todo.issueId}`, config.token, 'PATCH', {
state: 'closed',
state_reason: 'completed',
});
}
export async function updateIssue(todo: TodoItem, config: Config): Promise<void> {
if (!todo.issueId) return;
console.log(`[UPDATE] #${todo.issueId} → "${todo.title}"`);
await githubRequest(`/repos/${config.owner}/${config.repo}/issues/${todo.issueId}`, config.token, 'PATCH', {
title: todo.title,
});
}
export async function addReferenceToIssue(todo: TodoItem, config: Config, sha: string): Promise<void> {
if (!todo.similarIssueId) return;
const blobUrl = `https://github.com/${config.owner}/${config.repo}/blob/${sha}/${encodeURI(todo.filename)}#L${todo.line}-L${todo.line + BLOB_LINES}`;
console.log(`[REFERENCE] #${todo.similarIssueId} ← ${todo.filename}#L${todo.line}`);
await githubRequest(`/repos/${config.owner}/${config.repo}/issues/${todo.similarIssueId}/comments`, config.token, 'POST', {
body: [
`Also referenced in [\`${todo.filename}#L${todo.line}\`](${blobUrl})`,
'',
`> ${todo.title}`,
'',
`Commit: ${sha}`,
].join('\n'),
});
}

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env bun
import type { Config, TodoItem } from './types';
import { extractTodos } from './diff';
import { matchTodos } from './matcher';
import { isSimilar } from './similarity';
import { fetchExistingIssues, getDiffFromApi, createIssue, closeIssue, updateIssue, addReferenceToIssue } from './github';
function loadConfig(): Config {
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error('GITHUB_TOKEN is required');
return {
token,
owner: process.env.GITHUB_REPOSITORY_OWNER || 'RocketChat',
repo: process.env.GITHUB_REPOSITORY_NAME || 'Rocket.Chat',
importAll: process.env.IMPORT_ALL === 'true',
shaInput: process.env.SHA_INPUT || '',
pathFilter: process.env.PATH_FILTER || '',
baseSha: process.env.BEFORE_SHA,
headSha: process.env.GITHUB_SHA,
};
}
function execGitDiff(args: string[]): string {
const { spawnSync } = require('child_process');
const result = spawnSync('git', ['diff', '--no-index', ...args], {
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024,
});
return result.stdout ?? '';
}
async function getDiff(config: Config): Promise<{ diffText: string; resolvedHeadSha: string }> {
if (config.importAll) {
console.log('[INFO] Import-all mode: scanning entire codebase');
return {
diffText: execGitDiff(['/dev/null', '.']),
resolvedHeadSha: config.headSha || 'HEAD',
};
}
if (config.pathFilter) {
console.log(`[INFO] Path mode: scanning "${config.pathFilter}"`);
return {
diffText: execGitDiff(['/dev/null', '--', config.pathFilter]),
resolvedHeadSha: config.headSha || 'HEAD',
};
}
if (config.shaInput) {
let fromSha: string;
let toSha: string;
if (config.shaInput.includes('...')) {
[fromSha, toSha] = config.shaInput.split('...');
} else {
fromSha = `${config.shaInput}~1`;
toSha = config.shaInput;
}
console.log(`[INFO] SHA mode: comparing ${fromSha}...${toSha}`);
return {
diffText: await getDiffFromApi(`/repos/${config.owner}/${config.repo}/compare/${fromSha}...${toSha}`, config.token),
resolvedHeadSha: toSha,
};
}
if (!config.baseSha || !config.headSha) {
throw new Error('BEFORE_SHA and GITHUB_SHA are required for push mode');
}
console.log(`[INFO] Push mode: comparing ${config.baseSha.slice(0, 7)}...${config.headSha.slice(0, 7)}`);
return {
diffText: await getDiffFromApi(`/repos/${config.owner}/${config.repo}/compare/${config.baseSha}...${config.headSha}`, config.token),
resolvedHeadSha: config.headSha,
};
}
async function run(): Promise<void> {
console.log('[INFO] Starting todo-issue');
const config = loadConfig();
console.log(`[INFO] Repository: ${config.owner}/${config.repo}`);
const { diffText, resolvedHeadSha } = await getDiff(config);
const todos = extractTodos(diffText);
const added = todos.filter((t) => t.type === 'add').length;
const deleted = todos.filter((t) => t.type === 'del').length;
console.log(`[INFO] Found ${todos.length} TODOs (${added} added, ${deleted} deleted)`);
if (todos.length === 0) {
console.log('[INFO] No TODOs found, exiting');
return;
}
const existingIssues = await fetchExistingIssues(config);
if (config.importAll || config.pathFilter) {
const added = todos
.filter((t) => t.type === 'add')
.filter((todo) => {
return !existingIssues.find((i) => i.title === todo.title || isSimilar(i.title, todo.title));
});
const byTitle = new Map<string, TodoItem[]>();
for (const todo of added) {
const list = byTitle.get(todo.title) ?? [];
list.push(todo);
byTitle.set(todo.title, list);
}
console.log(`[INFO] Import: ${byTitle.size} new issues to create (${added.length} TODOs grouped by title)`);
for (const group of byTitle.values()) {
await createIssue(group, config, resolvedHeadSha);
}
} else {
const { toCreate, toClose, toUpdate, toReference } = matchTodos(todos, existingIssues);
const createByTitle = new Map<string, TodoItem[]>();
for (const todo of toCreate) {
const list = createByTitle.get(todo.title) ?? [];
list.push(todo);
createByTitle.set(todo.title, list);
}
console.log(
`[INFO] Actions: ${createByTitle.size} create, ${toClose.length} close, ${toUpdate.length} update, ${toReference.length} reference`,
);
for (const group of createByTitle.values()) {
await createIssue(group, config, resolvedHeadSha);
}
for (const todo of toClose) {
await closeIssue(todo, config, resolvedHeadSha);
}
for (const todo of toUpdate) {
await updateIssue(todo, config);
}
for (const todo of toReference) {
await addReferenceToIssue(todo, config, resolvedHeadSha);
}
}
console.log('[INFO] Done');
}
run().catch((err) => {
console.error('[FATAL]', err);
process.exit(1);
});

View File

@@ -0,0 +1,70 @@
import type { GitHubIssue, MatchResult, TodoItem } from './types';
import { isSimilar } from './similarity';
export function matchTodos(found: TodoItem[], existing: GitHubIssue[]): MatchResult {
const toCreate: TodoItem[] = [];
const toClose: TodoItem[] = [];
const toUpdate: TodoItem[] = [];
const toReference: TodoItem[] = [];
const addTodos = found.filter((t) => t.type === 'add');
const delTodos = found.filter((t) => t.type === 'del');
for (const del of delTodos) {
const exactMatch = existing.find((e) => e.title === del.title && e.state === 'open');
if (exactMatch) {
del.issueId = exactMatch.number;
} else {
const similarMatch = existing.find((e) => isSimilar(e.title, del.title) && e.state === 'open');
if (similarMatch) {
del.issueId = similarMatch.number;
}
}
}
const consumedDels = new Set<TodoItem>();
for (const add of addTodos) {
const movedDel = delTodos.find((d) => !consumedDels.has(d) && d.title === add.title);
if (movedDel) {
movedDel.issueId = undefined;
consumedDels.add(movedDel);
continue;
}
const updatedDel = delTodos.find((d) => !consumedDels.has(d) && d.issueId && isSimilar(d.title, add.title));
if (updatedDel) {
add.issueId = updatedDel.issueId;
updatedDel.issueId = undefined;
consumedDels.add(updatedDel);
toUpdate.push(add);
continue;
}
const exactExisting = existing.find((e) => e.title === add.title);
if (exactExisting) {
add.similarIssueId = exactExisting.number;
toReference.push(add);
continue;
}
const similarExisting = existing.find((e) => isSimilar(e.title, add.title));
if (similarExisting) {
add.similarIssueId = similarExisting.number;
toReference.push(add);
continue;
}
toCreate.push(add);
}
const closedIssueIds = new Set<number>();
for (const del of delTodos) {
if (del.issueId && !closedIssueIds.has(del.issueId)) {
closedIssueIds.add(del.issueId);
toClose.push(del);
}
}
return { toCreate, toClose, toUpdate, toReference };
}

View File

@@ -0,0 +1,24 @@
const SIMILARITY_THRESHOLD = 0.8;
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
export function isSimilar(a: string, b: string): boolean {
const avgLen = (a.length + b.length) / 2;
const maxDistance = avgLen * (1 - SIMILARITY_THRESHOLD);
return levenshtein(a, b) <= maxDistance;
}

View File

@@ -0,0 +1,36 @@
export interface Config {
token: string;
owner: string;
repo: string;
importAll: boolean;
shaInput: string;
pathFilter: string;
baseSha?: string;
headSha?: string;
}
export interface GitHubIssue {
number: number;
title: string;
state: 'open' | 'closed';
assignees: { login: string }[];
}
export interface TodoItem {
type: 'add' | 'del';
title: string;
body: string | false;
filename: string;
line: number;
labels: string[];
assignees: string[];
issueId?: number;
similarIssueId?: number;
}
export interface MatchResult {
toCreate: TodoItem[];
toClose: TodoItem[];
toUpdate: TodoItem[];
toReference: TodoItem[];
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}