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
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:
204
scripts/auto-close-duplicates.ts
Normal file
204
scripts/auto-close-duplicates.ts
Normal 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
19
scripts/emoji/README.md
Normal 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.
|
||||
41
scripts/emoji/generateEmojiFixture.mjs
Normal file
41
scripts/emoji/generateEmojiFixture.mjs
Normal 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
41
scripts/fossify.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
225
scripts/todo-issue/README.md
Normal file
225
scripts/todo-issue/README.md
Normal 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
|
||||
```
|
||||
25
scripts/todo-issue/bun.lock
Normal file
25
scripts/todo-issue/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
11
scripts/todo-issue/package.json
Normal file
11
scripts/todo-issue/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "todo-issue",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"parse-diff": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
127
scripts/todo-issue/src/diff.ts
Normal file
127
scripts/todo-issue/src/diff.ts
Normal 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;
|
||||
}
|
||||
290
scripts/todo-issue/src/github.ts
Normal file
290
scripts/todo-issue/src/github.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
156
scripts/todo-issue/src/index.ts
Normal file
156
scripts/todo-issue/src/index.ts
Normal 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);
|
||||
});
|
||||
70
scripts/todo-issue/src/matcher.ts
Normal file
70
scripts/todo-issue/src/matcher.ts
Normal 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 };
|
||||
}
|
||||
24
scripts/todo-issue/src/similarity.ts
Normal file
24
scripts/todo-issue/src/similarity.ts
Normal 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;
|
||||
}
|
||||
36
scripts/todo-issue/src/types.ts
Normal file
36
scripts/todo-issue/src/types.ts
Normal 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[];
|
||||
}
|
||||
11
scripts/todo-issue/tsconfig.json
Normal file
11
scripts/todo-issue/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "esnext",
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user