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