Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d943692e98 | ||
|
|
ddd0f4549b | ||
|
|
55bd89683f | ||
|
|
48d6e105de | ||
|
|
a2e2178222 | ||
|
|
6a36497205 | ||
|
|
fd2c8ba640 | ||
|
|
4c636d90df | ||
|
|
d0c2daaeb4 | ||
|
|
90efa2494e | ||
|
|
5dfc9c3f09 | ||
|
|
f95f51ed22 | ||
|
|
326c86307a | ||
|
|
8a15c083eb | ||
|
|
718a2267ce | ||
|
|
53525b9b52 | ||
|
|
2d21959330 | ||
|
|
2f1d2c30af | ||
|
|
c6704c3a04 | ||
|
|
1aa28c2a34 |
96 changed files with 3879 additions and 156 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
/bin/**/*.d.ts
|
||||||
|
/bin/**/*.js
|
||||||
|
/lib/
|
||||||
|
/build/
|
||||||
/.npmrc
|
/.npmrc
|
||||||
/devel/
|
/devel/
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -15,10 +15,10 @@ npm install -g @byteb4rb1e/mime-todo
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Create a TODO file and start tracking issues
|
# Create a TODO file and start tracking issues
|
||||||
todo create --type feature --title "Add login" --plan "Implement OAuth2 flow"
|
mime-todo create --type feature --title "Add login" --plan "Implement OAuth2 flow"
|
||||||
todo start 1 --plan "Using passport.js with Google provider"
|
mime-todo start 1 --plan "Using passport.js with Google provider"
|
||||||
todo push # sync to Bugzilla
|
mime-todo push # sync to Bugzilla
|
||||||
todo done 1 --summary "OAuth2 login with Google, GitHub providers"
|
mime-todo done 1 --summary "OAuth2 login with Google, GitHub providers"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
@ -27,11 +27,11 @@ todo done 1 --summary "OAuth2 login with Google, GitHub providers"
|
||||||
|
|
||||||
| Command | Branch | Description |
|
| Command | Branch | Description |
|
||||||
|---------|--------|-------------|
|
|---------|--------|-------------|
|
||||||
| `todo create --type --title --plan` | `develop` | Create a new issue |
|
| `mime-todo create --type --title --plan` | `develop` | Create a new issue |
|
||||||
| `todo start <id> --plan` | `develop` | Set issue to in-progress |
|
| `mime-todo start <id> --plan` | `develop` | Set issue to in-progress |
|
||||||
| `todo done <id> --summary` | `<type>/<id>` | Mark issue as done |
|
| `mime-todo done <id> --summary` | `<type>/<id>` | Mark issue as done |
|
||||||
| `todo hold <id> --reason` | `<type>/<id>` | Put issue on hold |
|
| `mime-todo hold <id> --reason` | `<type>/<id>` | Put issue on hold |
|
||||||
| `todo cancel <id> --reason` | `develop` or `<type>/<id>` | Cancel an issue |
|
| `mime-todo cancel <id> --reason` | `develop` or `<type>/<id>` | Cancel an issue |
|
||||||
|
|
||||||
Each lifecycle command creates a dedicated commit (`todo(<id>): <status>`)
|
Each lifecycle command creates a dedicated commit (`todo(<id>): <status>`)
|
||||||
that modifies only the `TODO` file.
|
that modifies only the `TODO` file.
|
||||||
|
|
@ -40,30 +40,30 @@ that modifies only the `TODO` file.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `todo list` | List all issues |
|
| `mime-todo list` | List all issues |
|
||||||
| `todo show <id>` | Show issue details |
|
| `mime-todo show <id>` | Show issue details |
|
||||||
| `todo sprints` | List all sprints |
|
| `mime-todo sprints` | List all sprints |
|
||||||
| `todo issues-in-sprint <name>` | List issues in a sprint |
|
| `mime-todo issues-in-sprint <name>` | List issues in a sprint |
|
||||||
|
|
||||||
### Bugzilla Integration
|
### Bugzilla Integration
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `todo init` | Check/create Bugzilla products and components |
|
| `mime-todo init` | Check/create Bugzilla products and components |
|
||||||
| `todo push [ref]` | Push commits as Bugzilla comments |
|
| `mime-todo push [ref]` | Push commits as Bugzilla comments |
|
||||||
|
|
||||||
#### `todo init`
|
#### `mime-todo init`
|
||||||
|
|
||||||
Reads the `application/bugzilla` part from the `TODO` file and ensures the
|
Reads the `application/bugzilla` part from the `TODO` file and ensures the
|
||||||
referenced products and components exist on the Bugzilla server.
|
referenced products and components exist on the Bugzilla server.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
todo init # check what exists
|
mime-todo init # check what exists
|
||||||
todo init --dry-run # preview changes
|
mime-todo init --dry-run # preview changes
|
||||||
todo init --confirm --assignee user@example.com # create missing items
|
mime-todo init --confirm --assignee user@example.com # create missing items
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `todo push`
|
#### `mime-todo push`
|
||||||
|
|
||||||
Pushes git commits to Bugzilla as comments. Context-aware:
|
Pushes git commits to Bugzilla as comments. Context-aware:
|
||||||
|
|
||||||
|
|
@ -73,10 +73,10 @@ Pushes git commits to Bugzilla as comments. Context-aware:
|
||||||
updating bug status and posting the commit body as a comment.
|
updating bug status and posting the commit body as a comment.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
todo push # push all unpushed commits
|
mime-todo push # push all unpushed commits
|
||||||
todo push HEAD~3 # push only the last 3 commits
|
mime-todo push HEAD~3 # push only the last 3 commits
|
||||||
todo push --dry-run # preview without pushing
|
mime-todo push --dry-run # preview without pushing
|
||||||
todo push --strategy full # re-scan all bugs (not just targeted)
|
mime-todo push --strategy full # re-scan all bugs (not just targeted)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each comment includes a clickable link to the commit. Comments are tagged
|
Each comment includes a clickable link to the commit. Comments are tagged
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
// Standalone Bugzilla CLI — interact with Bugzilla REST API directly
|
// Standalone Bugzilla CLI — interact with Bugzilla REST API directly
|
||||||
import yargs from "yargs"
|
import yargs from "yargs"
|
||||||
import { hideBin } from "yargs/helpers"
|
import { hideBin } from "yargs/helpers"
|
||||||
import { BugzillaClient } from "../lib/bugzilla/client"
|
import { BugzillaClient } from "../lib/bugzilla/client.js"
|
||||||
import { loadConfig } from "../lib/bugzilla/config"
|
import { loadConfig } from "../lib/bugzilla/config.js"
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
|
|
|
||||||
26
bin/main.ts
26
bin/main.ts
|
|
@ -1,19 +1,19 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// mime-todo CLI — spec-compliant issue lifecycle management
|
// mime-todo CLI — spec-compliant issue lifecycle management
|
||||||
import { CLI } from "../lib/cli/CLI"
|
import { CLI } from "../lib/cli/CLI.js"
|
||||||
import { CreateCommand } from "../lib/commands/CreateCommand"
|
import { CreateCommand } from "../lib/commands/CreateCommand.js"
|
||||||
import { StartCommand } from "../lib/commands/StartCommand"
|
import { StartCommand } from "../lib/commands/StartCommand.js"
|
||||||
import { DoneCommand } from "../lib/commands/DoneCommand"
|
import { DoneCommand } from "../lib/commands/DoneCommand.js"
|
||||||
import { HoldCommand } from "../lib/commands/HoldCommand"
|
import { HoldCommand } from "../lib/commands/HoldCommand.js"
|
||||||
import { CancelCommand } from "../lib/commands/CancelCommand"
|
import { CancelCommand } from "../lib/commands/CancelCommand.js"
|
||||||
import { IssueListCommand } from "../lib/commands/IssueListCommand"
|
import { IssueListCommand } from "../lib/commands/IssueListCommand.js"
|
||||||
import { IssueShowCommand } from "../lib/commands/IssueShowCommand"
|
import { IssueShowCommand } from "../lib/commands/IssueShowCommand.js"
|
||||||
import { SprintsCommand } from "../lib/commands/SprintsCommand"
|
import { SprintsCommand } from "../lib/commands/SprintsCommand.js"
|
||||||
import { IssuesInSprintCommand } from "../lib/commands/IssuesInSprintCommand"
|
import { IssuesInSprintCommand } from "../lib/commands/IssuesInSprintCommand.js"
|
||||||
import { PushCommand } from "../lib/commands/PushCommand"
|
import { PushCommand } from "../lib/commands/PushCommand.js"
|
||||||
import { InitCommand } from "../lib/commands/InitCommand"
|
import { InitCommand } from "../lib/commands/InitCommand.js"
|
||||||
|
|
||||||
const cli = new CLI({ prog: "todo", description: "MIME TODO issue tracker" })
|
const cli = new CLI({ prog: "mime-todo", description: "MIME TODO issue tracker" })
|
||||||
cli.bootstrap([
|
cli.bootstrap([
|
||||||
InitCommand,
|
InitCommand,
|
||||||
CreateCommand,
|
CreateCommand,
|
||||||
|
|
|
||||||
9
eslint.config.mjs
Normal file
9
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
);
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// Re-export all Bugzilla modules for convenient imports
|
|
||||||
export { BugzillaClient } from "./client"
|
|
||||||
export { loadConfig } from "./config"
|
|
||||||
export type { BugzillaConfig } from "./config"
|
|
||||||
export * from "./types"
|
|
||||||
export * from "./fieldmap"
|
|
||||||
export * from "./origin"
|
|
||||||
1411
package-lock.json
generated
1411
package-lock.json
generated
File diff suppressed because it is too large
Load diff
29
package.json
29
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@byteb4rb1e/mime-todo",
|
"name": "@byteb4rb1e/mime-todo",
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"description": "CLI for the MIME TODO issue tracker specification with Bugzilla integration",
|
"description": "CLI for the MIME TODO issue tracker specification with Bugzilla integration",
|
||||||
"author": "Tiara Rodney <me@tiararodney.com>",
|
"author": "Tiara Rodney <me@tiararodney.com>",
|
||||||
"license": "CC-BY-ND-4.0",
|
"license": "CC-BY-ND-4.0",
|
||||||
|
|
@ -22,28 +22,39 @@
|
||||||
"gitflow"
|
"gitflow"
|
||||||
],
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
"todo": "./bin/main.ts",
|
"mime-todo": "./bin/main.js",
|
||||||
"bugzilla": "./bin/bugzilla.ts"
|
"bugzilla": "./bin/bugzilla.js"
|
||||||
},
|
},
|
||||||
"main": "lib/file.ts",
|
"main": "lib/file.js",
|
||||||
"files": [
|
"files": [
|
||||||
"bin/",
|
"bin/*.js",
|
||||||
"lib/",
|
"lib/",
|
||||||
"README.md"
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"todo": "tsx bin/main.ts",
|
"build:bin": "tsc -p tsconfig.bin.json",
|
||||||
|
"build:lib": "tsc -p tsconfig.lib.json && cp src/file.schema.json lib/",
|
||||||
|
"build": "npm run build:lib && npm run build:bin",
|
||||||
|
"mime-todo": "tsx bin/main.ts",
|
||||||
"bugzilla": "tsx bin/bugzilla.ts",
|
"bugzilla": "tsx bin/bugzilla.ts",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"test-reports/unit": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"tsx": "^3.7.0",
|
|
||||||
"yargs": "^18.0.0"
|
"yargs": "^18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.24.0",
|
||||||
|
"@types/mailparser": "^3.4.5",
|
||||||
|
"@types/node": "^22.14.1",
|
||||||
"@types/yargs": "^17.0.35",
|
"@types/yargs": "^17.0.35",
|
||||||
|
"eslint": "^9.25.1",
|
||||||
|
"tsx": "^3.7.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.31.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
20
src/bugzilla/client.d.ts
vendored
Normal file
20
src/bugzilla/client.d.ts
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { BugzillaConfig } from "./config.js";
|
||||||
|
import type { BugzillaBug, BugzillaComment, BugzillaProduct, BugzillaComponent, BugzillaSearchParams, BugzillaCreatePayload, BugzillaUpdatePayload, BugzillaCreateProductPayload, BugzillaCreateComponentPayload, BugzillaUpdateResponse } from "./types.js";
|
||||||
|
export declare class BugzillaClient {
|
||||||
|
private baseUrl;
|
||||||
|
private apiKey;
|
||||||
|
constructor(config: BugzillaConfig);
|
||||||
|
private request;
|
||||||
|
getBug(id: number): Promise<BugzillaBug>;
|
||||||
|
searchBugs(params: BugzillaSearchParams): Promise<BugzillaBug[]>;
|
||||||
|
createBug(payload: BugzillaCreatePayload): Promise<number>;
|
||||||
|
updateBug(id: number, payload: BugzillaUpdatePayload): Promise<BugzillaUpdateResponse>;
|
||||||
|
getComments(bugId: number): Promise<BugzillaComment[]>;
|
||||||
|
addComment(bugId: number, comment: string): Promise<number>;
|
||||||
|
tagComment(commentId: number, tags: string[]): Promise<void>;
|
||||||
|
getProduct(nameOrId: string | number): Promise<BugzillaProduct>;
|
||||||
|
getAccessibleProducts(): Promise<BugzillaProduct[]>;
|
||||||
|
createProduct(payload: BugzillaCreateProductPayload): Promise<number>;
|
||||||
|
getComponents(product: string): Promise<BugzillaComponent[]>;
|
||||||
|
createComponent(payload: BugzillaCreateComponentPayload): Promise<number>;
|
||||||
|
}
|
||||||
115
src/bugzilla/client.js
Normal file
115
src/bugzilla/client.js
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
export class BugzillaClient {
|
||||||
|
baseUrl;
|
||||||
|
apiKey;
|
||||||
|
constructor(config) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||||
|
this.apiKey = config.apiKey;
|
||||||
|
}
|
||||||
|
async request(method, path, body) {
|
||||||
|
// Auth: api_key in query for GET, in body for POST/PUT
|
||||||
|
let url;
|
||||||
|
if (method === "GET") {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
url = `${this.baseUrl}${path}${sep}api_key=${encodeURIComponent(this.apiKey)}`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
url = `${this.baseUrl}${path}`;
|
||||||
|
const bodyObj = (body ?? {});
|
||||||
|
body = { ...bodyObj, api_key: this.apiKey };
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Bugzilla API ${method} ${path} failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
async getBug(id) {
|
||||||
|
const data = await this.request("GET", `/bug/${id}`);
|
||||||
|
if (!data.bugs?.length)
|
||||||
|
throw new Error(`Bug ${id} not found`);
|
||||||
|
return data.bugs[0];
|
||||||
|
}
|
||||||
|
async searchBugs(params) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.product)
|
||||||
|
query.set("product", params.product);
|
||||||
|
if (params.component)
|
||||||
|
query.set("component", params.component);
|
||||||
|
if (params.summary)
|
||||||
|
query.set("summary", params.summary);
|
||||||
|
if (params.limit)
|
||||||
|
query.set("limit", String(params.limit));
|
||||||
|
if (params.offset)
|
||||||
|
query.set("offset", String(params.offset));
|
||||||
|
if (params.last_change_time)
|
||||||
|
query.set("last_change_time", params.last_change_time);
|
||||||
|
if (params.status) {
|
||||||
|
const statuses = Array.isArray(params.status) ? params.status : [params.status];
|
||||||
|
for (const s of statuses)
|
||||||
|
query.append("status", s);
|
||||||
|
}
|
||||||
|
if (params.id) {
|
||||||
|
const ids = Array.isArray(params.id) ? params.id : [params.id];
|
||||||
|
for (const id of ids)
|
||||||
|
query.append("id", String(id));
|
||||||
|
}
|
||||||
|
if (params.alias) {
|
||||||
|
const aliases = Array.isArray(params.alias) ? params.alias : [params.alias];
|
||||||
|
for (const a of aliases)
|
||||||
|
query.append("alias", a);
|
||||||
|
}
|
||||||
|
if (params.url)
|
||||||
|
query.set("url", params.url);
|
||||||
|
const qs = query.toString();
|
||||||
|
const path = `/bug${qs ? `?${qs}` : ""}`;
|
||||||
|
const data = await this.request("GET", path);
|
||||||
|
return data.bugs ?? [];
|
||||||
|
}
|
||||||
|
async createBug(payload) {
|
||||||
|
const data = await this.request("POST", "/bug", payload);
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
async updateBug(id, payload) {
|
||||||
|
return this.request("PUT", `/bug/${id}`, payload);
|
||||||
|
}
|
||||||
|
async getComments(bugId) {
|
||||||
|
const data = await this.request("GET", `/bug/${bugId}/comment`);
|
||||||
|
return data.bugs?.[String(bugId)]?.comments ?? [];
|
||||||
|
}
|
||||||
|
async addComment(bugId, comment) {
|
||||||
|
const data = await this.request("POST", `/bug/${bugId}/comment`, { comment });
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
async tagComment(commentId, tags) {
|
||||||
|
await this.request("PUT", `/bug/comment/${commentId}/tags`, { add: tags });
|
||||||
|
}
|
||||||
|
// Product operations
|
||||||
|
async getProduct(nameOrId) {
|
||||||
|
const data = await this.request("GET", `/product/${encodeURIComponent(String(nameOrId))}`);
|
||||||
|
if (!data.products?.length)
|
||||||
|
throw new Error(`Product "${nameOrId}" not found`);
|
||||||
|
return data.products[0];
|
||||||
|
}
|
||||||
|
async getAccessibleProducts() {
|
||||||
|
const data = await this.request("GET", "/product?type=accessible");
|
||||||
|
return data.products ?? [];
|
||||||
|
}
|
||||||
|
async createProduct(payload) {
|
||||||
|
const data = await this.request("POST", "/product", payload);
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
// Component operations
|
||||||
|
async getComponents(product) {
|
||||||
|
const prod = await this.getProduct(product);
|
||||||
|
return prod.components ?? [];
|
||||||
|
}
|
||||||
|
async createComponent(payload) {
|
||||||
|
const data = await this.request("POST", "/component", payload);
|
||||||
|
return data.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Bugzilla 5.0+ REST API client — thin wrapper around fetch
|
// Bugzilla 5.0+ REST API client — thin wrapper around fetch
|
||||||
import type { BugzillaConfig } from "./config"
|
import type { BugzillaConfig } from "./config.js"
|
||||||
import type {
|
import type {
|
||||||
BugzillaBug,
|
BugzillaBug,
|
||||||
BugzillaComment,
|
BugzillaComment,
|
||||||
|
|
@ -15,7 +15,7 @@ import type {
|
||||||
BugzillaUpdateResponse,
|
BugzillaUpdateResponse,
|
||||||
BugzillaCommentsResponse,
|
BugzillaCommentsResponse,
|
||||||
BugzillaProductResponse,
|
BugzillaProductResponse,
|
||||||
} from "./types"
|
} from "./types.js"
|
||||||
|
|
||||||
export class BugzillaClient {
|
export class BugzillaClient {
|
||||||
private baseUrl: string
|
private baseUrl: string
|
||||||
7
src/bugzilla/config.d.ts
vendored
Normal file
7
src/bugzilla/config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface BugzillaConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
product: string;
|
||||||
|
component: string;
|
||||||
|
}
|
||||||
|
export declare function loadConfig(cwd?: string): BugzillaConfig;
|
||||||
28
src/bugzilla/config.js
Normal file
28
src/bugzilla/config.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Bugzilla configuration — loads from .bugzilla.json or environment variables
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
const CONFIG_FILE = ".bugzilla.json";
|
||||||
|
export function loadConfig(cwd = process.cwd()) {
|
||||||
|
const filePath = path.join(cwd, CONFIG_FILE);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const json = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
baseUrl: json.baseUrl ?? json.base_url ?? "",
|
||||||
|
apiKey: json.apiKey ?? json.api_key,
|
||||||
|
product: json.product ?? "",
|
||||||
|
component: json.component ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const baseUrl = process.env.BUGZILLA_URL;
|
||||||
|
const apiKey = process.env.BUGZILLA_API_KEY;
|
||||||
|
if (!baseUrl || !apiKey) {
|
||||||
|
throw new Error(`Bugzilla config not found. Create ${CONFIG_FILE} or set BUGZILLA_URL and BUGZILLA_API_KEY`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||||
|
apiKey,
|
||||||
|
product: process.env.BUGZILLA_PRODUCT ?? "",
|
||||||
|
component: process.env.BUGZILLA_COMPONENT ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/bugzilla/fieldmap.d.ts
vendored
Normal file
14
src/bugzilla/fieldmap.d.ts
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Issue, IssueType, IssueStatus, IssuePriority } from "../issue.js";
|
||||||
|
import type { BugzillaCreatePayload, BugzillaUpdatePayload } from "./types.js";
|
||||||
|
export declare function statusToBugzilla(status: IssueStatus): {
|
||||||
|
status: string;
|
||||||
|
resolution?: string;
|
||||||
|
};
|
||||||
|
export declare function priorityToBugzilla(priority: IssuePriority): string;
|
||||||
|
export declare function typeToBugzilla(type: IssueType): string;
|
||||||
|
export declare function resolveProductComponent(issue: Issue, bugzilla?: import("../tracker.js").BugzillaTracker): {
|
||||||
|
product: string;
|
||||||
|
component: string;
|
||||||
|
} | null;
|
||||||
|
export declare function issueToBugzillaCreate(issue: Issue, product: string, component: string, url?: string): BugzillaCreatePayload;
|
||||||
|
export declare function issueToBugzillaUpdate(issue: Issue): BugzillaUpdatePayload;
|
||||||
74
src/bugzilla/fieldmap.js
Normal file
74
src/bugzilla/fieldmap.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Status mapping: TODO → Bugzilla
|
||||||
|
const STATUS_TO_BZ = {
|
||||||
|
"open": { status: "CONFIRMED" },
|
||||||
|
"in-progress": { status: "IN_PROGRESS" },
|
||||||
|
"done": { status: "RESOLVED", resolution: "FIXED" },
|
||||||
|
"hold": { status: "RESOLVED", resolution: "LATER" },
|
||||||
|
"cancelled": { status: "RESOLVED", resolution: "WONTFIX" },
|
||||||
|
};
|
||||||
|
// Priority mapping
|
||||||
|
const PRIORITY_TO_BZ = {
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Normal",
|
||||||
|
"high": "Highest",
|
||||||
|
};
|
||||||
|
// Type → Severity mapping
|
||||||
|
const TYPE_TO_SEVERITY = {
|
||||||
|
"feature": "enhancement",
|
||||||
|
"bugfix": "normal",
|
||||||
|
"hotfix": "critical",
|
||||||
|
};
|
||||||
|
export function statusToBugzilla(status) {
|
||||||
|
return STATUS_TO_BZ[status] ?? { status: "NEW" };
|
||||||
|
}
|
||||||
|
export function priorityToBugzilla(priority) {
|
||||||
|
return PRIORITY_TO_BZ[priority] ?? "Normal";
|
||||||
|
}
|
||||||
|
export function typeToBugzilla(type) {
|
||||||
|
return TYPE_TO_SEVERITY[type] ?? "normal";
|
||||||
|
}
|
||||||
|
export function resolveProductComponent(issue, bugzilla) {
|
||||||
|
if (!bugzilla)
|
||||||
|
return null;
|
||||||
|
if (issue.module) {
|
||||||
|
const mapping = bugzilla.mappings.find((m) => m.module === issue.module);
|
||||||
|
if (mapping)
|
||||||
|
return { product: mapping.product, component: mapping.component };
|
||||||
|
}
|
||||||
|
// Fall back to first mapping
|
||||||
|
return bugzilla.mappings.length > 0
|
||||||
|
? { product: bugzilla.mappings[0].product, component: bugzilla.mappings[0].component }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
export function issueToBugzillaCreate(issue, product, component, url) {
|
||||||
|
const bz = statusToBugzilla(issue.status);
|
||||||
|
return {
|
||||||
|
product,
|
||||||
|
component,
|
||||||
|
version: "unspecified",
|
||||||
|
summary: issue.title,
|
||||||
|
url,
|
||||||
|
description: issue.description || undefined,
|
||||||
|
priority: priorityToBugzilla(issue.priority),
|
||||||
|
severity: typeToBugzilla(issue.type),
|
||||||
|
op_sys: "All",
|
||||||
|
rep_platform: "All",
|
||||||
|
depends_on: issue.relationships.dependsOn ?? [],
|
||||||
|
blocks: issue.relationships.blocks ?? [],
|
||||||
|
see_also: (issue.relationships.relatesTo ?? []).map(String),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function issueToBugzillaUpdate(issue) {
|
||||||
|
const bz = statusToBugzilla(issue.status);
|
||||||
|
const payload = {
|
||||||
|
summary: issue.title,
|
||||||
|
status: bz.status,
|
||||||
|
priority: priorityToBugzilla(issue.priority),
|
||||||
|
severity: typeToBugzilla(issue.type),
|
||||||
|
depends_on: { set: issue.relationships.dependsOn ?? [] },
|
||||||
|
blocks: { set: issue.relationships.blocks ?? [] },
|
||||||
|
};
|
||||||
|
if (bz.resolution)
|
||||||
|
payload.resolution = bz.resolution;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Field mapping between MIME TODO issues and Bugzilla bugs
|
// Field mapping between MIME TODO issues and Bugzilla bugs
|
||||||
import type { Issue, IssueType, IssueStatus, IssuePriority } from "../issue"
|
import type { Issue, IssueType, IssueStatus, IssuePriority } from "../issue.js"
|
||||||
import type { BugzillaCreatePayload, BugzillaUpdatePayload } from "./types"
|
import type { BugzillaCreatePayload, BugzillaUpdatePayload } from "./types.js"
|
||||||
|
|
||||||
// Status mapping: TODO → Bugzilla
|
// Status mapping: TODO → Bugzilla
|
||||||
const STATUS_TO_BZ: Record<IssueStatus, { status: string; resolution?: string }> = {
|
const STATUS_TO_BZ: Record<IssueStatus, { status: string; resolution?: string }> = {
|
||||||
|
|
@ -39,11 +39,11 @@ export function typeToBugzilla(type: IssueType): string {
|
||||||
|
|
||||||
export function resolveProductComponent(
|
export function resolveProductComponent(
|
||||||
issue: Issue,
|
issue: Issue,
|
||||||
bugzilla?: import("../tracker").BugzillaTracker
|
bugzilla?: import("../tracker.js").BugzillaTracker
|
||||||
): { product: string; component: string } | null {
|
): { product: string; component: string } | null {
|
||||||
if (!bugzilla) return null
|
if (!bugzilla) return null
|
||||||
if (issue.module) {
|
if (issue.module) {
|
||||||
const mapping = bugzilla.mappings.find(m => m.module === issue.module)
|
const mapping = bugzilla.mappings.find((m: { module: string; product: string; component: string }) => m.module === issue.module)
|
||||||
if (mapping) return { product: mapping.product, component: mapping.component }
|
if (mapping) return { product: mapping.product, component: mapping.component }
|
||||||
}
|
}
|
||||||
// Fall back to first mapping
|
// Fall back to first mapping
|
||||||
7
src/bugzilla/index.ts
Normal file
7
src/bugzilla/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Re-export all Bugzilla modules for convenient imports
|
||||||
|
export { BugzillaClient } from "./client.js"
|
||||||
|
export { loadConfig } from "./config.js"
|
||||||
|
export type { BugzillaConfig } from "./config.js"
|
||||||
|
export * from "./types.js"
|
||||||
|
export * from "./fieldmap.js"
|
||||||
|
export * from "./origin.js"
|
||||||
6
src/bugzilla/origin.d.ts
vendored
Normal file
6
src/bugzilla/origin.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export declare function getGitRemoteUrl(cwd?: string): string;
|
||||||
|
export declare function getGitBranch(cwd?: string): string;
|
||||||
|
export declare function normalizeRemoteUrl(url: string): string;
|
||||||
|
export declare function buildOriginUrl(remoteUrl: string, branch: string, todoPath: string, issueId: number): string;
|
||||||
|
export declare function buildCommitUrl(remoteUrl: string, commitHash: string): string;
|
||||||
|
export declare function shortHash(hash: string): string;
|
||||||
70
src/bugzilla/origin.js
Normal file
70
src/bugzilla/origin.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Origin URL — builds a resolvable URL pointing to a TODO issue in the repo's web UI
|
||||||
|
// Format varies by host:
|
||||||
|
// Bitbucket: <base>/src/<branch>/<path>#<id>
|
||||||
|
// GitHub: <base>/blob/<branch>/<path>#<id>
|
||||||
|
// GitLab: <base>/-/blob/<branch>/<path>#<id>
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
export function getGitRemoteUrl(cwd = process.cwd()) {
|
||||||
|
try {
|
||||||
|
return execSync("git remote get-url origin", { cwd, encoding: "utf-8" }).trim();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw new Error("Could not determine git remote URL. Is this a git repository with an 'origin' remote?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getGitBranch(cwd = process.cwd()) {
|
||||||
|
try {
|
||||||
|
return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return "main";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function normalizeRemoteUrl(url) {
|
||||||
|
let normalized = url;
|
||||||
|
const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
||||||
|
if (sshMatch) {
|
||||||
|
normalized = `https://${sshMatch[1]}/${sshMatch[2]}`;
|
||||||
|
}
|
||||||
|
normalized = normalized.replace(/\.git$/, "");
|
||||||
|
normalized = normalized.replace(/\/+$/, "");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
function detectHost(url) {
|
||||||
|
if (url.includes("bitbucket.org"))
|
||||||
|
return "bitbucket";
|
||||||
|
if (url.includes("github.com"))
|
||||||
|
return "github";
|
||||||
|
if (url.includes("gitlab.com") || url.includes("gitlab"))
|
||||||
|
return "gitlab";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
export function buildOriginUrl(remoteUrl, branch, todoPath, issueId) {
|
||||||
|
const base = normalizeRemoteUrl(remoteUrl);
|
||||||
|
const host = detectHost(base);
|
||||||
|
switch (host) {
|
||||||
|
case "github":
|
||||||
|
return `${base}/blob/${branch}/${todoPath}#${issueId}`;
|
||||||
|
case "gitlab":
|
||||||
|
return `${base}/-/blob/${branch}/${todoPath}#${issueId}`;
|
||||||
|
case "bitbucket":
|
||||||
|
default:
|
||||||
|
return `${base}/src/${branch}/${todoPath}#${issueId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function buildCommitUrl(remoteUrl, commitHash) {
|
||||||
|
const base = normalizeRemoteUrl(remoteUrl);
|
||||||
|
const host = detectHost(base);
|
||||||
|
switch (host) {
|
||||||
|
case "github":
|
||||||
|
return `${base}/commit/${commitHash}`;
|
||||||
|
case "gitlab":
|
||||||
|
return `${base}/-/commit/${commitHash}`;
|
||||||
|
case "bitbucket":
|
||||||
|
default:
|
||||||
|
return `${base}/commits/${commitHash}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function shortHash(hash) {
|
||||||
|
return hash.slice(0, 7);
|
||||||
|
}
|
||||||
125
src/bugzilla/types.d.ts
vendored
Normal file
125
src/bugzilla/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
export interface BugzillaBug {
|
||||||
|
id: number;
|
||||||
|
alias: string[];
|
||||||
|
summary: string;
|
||||||
|
status: string;
|
||||||
|
resolution: string;
|
||||||
|
priority: string;
|
||||||
|
severity: string;
|
||||||
|
product: string;
|
||||||
|
component: string;
|
||||||
|
description?: string;
|
||||||
|
creation_time: string;
|
||||||
|
last_change_time: string;
|
||||||
|
depends_on: number[];
|
||||||
|
blocks: number[];
|
||||||
|
url: string;
|
||||||
|
see_also: string[];
|
||||||
|
assigned_to: string;
|
||||||
|
creator: string;
|
||||||
|
is_open: boolean;
|
||||||
|
}
|
||||||
|
export interface BugzillaComment {
|
||||||
|
id: number;
|
||||||
|
bug_id: number;
|
||||||
|
text: string;
|
||||||
|
creator: string;
|
||||||
|
time: string;
|
||||||
|
count: number;
|
||||||
|
is_private: boolean;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
export interface BugzillaSearchParams {
|
||||||
|
product?: string;
|
||||||
|
component?: string;
|
||||||
|
status?: string | string[];
|
||||||
|
id?: number | number[];
|
||||||
|
alias?: string | string[];
|
||||||
|
url?: string;
|
||||||
|
summary?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
last_change_time?: string;
|
||||||
|
}
|
||||||
|
export interface BugzillaCreatePayload {
|
||||||
|
product: string;
|
||||||
|
component: string;
|
||||||
|
summary: string;
|
||||||
|
version?: string;
|
||||||
|
url?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
priority?: string;
|
||||||
|
severity?: string;
|
||||||
|
op_sys?: string;
|
||||||
|
rep_platform?: string;
|
||||||
|
depends_on?: number[];
|
||||||
|
blocks?: number[];
|
||||||
|
see_also?: string[];
|
||||||
|
}
|
||||||
|
export interface BugzillaUpdatePayload {
|
||||||
|
summary?: string;
|
||||||
|
status?: string;
|
||||||
|
resolution?: string;
|
||||||
|
priority?: string;
|
||||||
|
severity?: string;
|
||||||
|
depends_on?: {
|
||||||
|
set: number[];
|
||||||
|
};
|
||||||
|
blocks?: {
|
||||||
|
set: number[];
|
||||||
|
};
|
||||||
|
see_also?: {
|
||||||
|
add?: string[];
|
||||||
|
remove?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface BugzillaSearchResponse {
|
||||||
|
bugs: BugzillaBug[];
|
||||||
|
}
|
||||||
|
export interface BugzillaCreateResponse {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
export interface BugzillaUpdateResponse {
|
||||||
|
bugs: Array<{
|
||||||
|
id: number;
|
||||||
|
changes: Record<string, {
|
||||||
|
removed: string;
|
||||||
|
added: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
export interface BugzillaCommentsResponse {
|
||||||
|
bugs: Record<string, {
|
||||||
|
comments: BugzillaComment[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
export interface BugzillaComponent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
default_assigned_to: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
export interface BugzillaProduct {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
components: BugzillaComponent[];
|
||||||
|
}
|
||||||
|
export interface BugzillaProductResponse {
|
||||||
|
products: BugzillaProduct[];
|
||||||
|
}
|
||||||
|
export interface BugzillaCreateProductPayload {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
is_open?: boolean;
|
||||||
|
}
|
||||||
|
export interface BugzillaCreateComponentPayload {
|
||||||
|
product: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
default_assignee: string;
|
||||||
|
}
|
||||||
2
src/bugzilla/types.js
Normal file
2
src/bugzilla/types.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Bugzilla 5.0+ REST API type definitions
|
||||||
|
export {};
|
||||||
13
src/cli/CLI.d.ts
vendored
Normal file
13
src/cli/CLI.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { CLICommand } from "./CLICommand.js";
|
||||||
|
interface CLIOpts {
|
||||||
|
prog: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
export declare class CLI {
|
||||||
|
private prog;
|
||||||
|
private description;
|
||||||
|
constructor(opts: CLIOpts);
|
||||||
|
private registerCommand;
|
||||||
|
bootstrap(commands: (new () => CLICommand)[]): void;
|
||||||
|
}
|
||||||
|
export {};
|
||||||
49
src/cli/CLI.js
Normal file
49
src/cli/CLI.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// CLI dispatcher — recursively walks CLICommand tree and wires up yargs
|
||||||
|
import yargs from "yargs";
|
||||||
|
import { hideBin } from "yargs/helpers";
|
||||||
|
export class CLI {
|
||||||
|
prog;
|
||||||
|
description;
|
||||||
|
constructor(opts) {
|
||||||
|
this.prog = opts.prog;
|
||||||
|
this.description = opts.description;
|
||||||
|
}
|
||||||
|
registerCommand(y, CommandClass) {
|
||||||
|
const cmd = new CommandClass();
|
||||||
|
const subcommands = CommandClass._subcommands;
|
||||||
|
if (subcommands?.length) {
|
||||||
|
// Branch command with subcommands
|
||||||
|
y.command(cmd.name, cmd.help, (sub) => {
|
||||||
|
for (const Sub of subcommands) {
|
||||||
|
this.registerCommand(sub, Sub);
|
||||||
|
}
|
||||||
|
return sub.demandCommand(1, `Please specify a ${cmd.name} subcommand`);
|
||||||
|
}, () => { });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Leaf command
|
||||||
|
y.command(cmd.name, cmd.help, (sub) => cmd.addArguments(sub), async (args) => {
|
||||||
|
const code = await cmd.execute(args);
|
||||||
|
process.exit(code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bootstrap(commands) {
|
||||||
|
const y = yargs(hideBin(process.argv))
|
||||||
|
.scriptName(this.prog)
|
||||||
|
.usage(`${this.description}\n\n$0 <command> [options]`)
|
||||||
|
.option("verbose", {
|
||||||
|
alias: "v",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Enable verbose output",
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
.demandCommand(1, "Please specify a command")
|
||||||
|
.strict()
|
||||||
|
.help();
|
||||||
|
for (const Cmd of commands) {
|
||||||
|
this.registerCommand(y, Cmd);
|
||||||
|
}
|
||||||
|
y.parse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// CLI dispatcher — recursively walks CLICommand tree and wires up yargs
|
// CLI dispatcher — recursively walks CLICommand tree and wires up yargs
|
||||||
import yargs, { type Argv } from "yargs"
|
import yargs, { type Argv } from "yargs"
|
||||||
import { hideBin } from "yargs/helpers"
|
import { hideBin } from "yargs/helpers"
|
||||||
import { CLICommand } from "./CLICommand"
|
import { CLICommand } from "./CLICommand.js"
|
||||||
|
|
||||||
interface CLIOpts {
|
interface CLIOpts {
|
||||||
prog: string
|
prog: string
|
||||||
9
src/cli/CLICommand.d.ts
vendored
Normal file
9
src/cli/CLICommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
export declare abstract class CLICommand {
|
||||||
|
abstract readonly name: string;
|
||||||
|
abstract readonly help: string;
|
||||||
|
abstract readonly description: string;
|
||||||
|
static _subcommands: (new () => CLICommand)[];
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
9
src/cli/CLICommand.js
Normal file
9
src/cli/CLICommand.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export class CLICommand {
|
||||||
|
static _subcommands = [];
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs;
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/commands/CancelCommand.d.ts
vendored
Normal file
9
src/commands/CancelCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class CancelCommand extends CLICommand {
|
||||||
|
readonly name = "cancel <id>";
|
||||||
|
readonly help = "Cancel an issue";
|
||||||
|
readonly description = "Set issue to cancelled (must be on issue branch or develop)";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
43
src/commands/CancelCommand.js
Normal file
43
src/commands/CancelCommand.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile, writeTodoFile } from "../file.js";
|
||||||
|
import { getCurrentBranch, commitFileWithBody } from "../git.js";
|
||||||
|
import { validateStatusTransition } from "../issue.js";
|
||||||
|
export class CancelCommand extends CLICommand {
|
||||||
|
name = "cancel <id>";
|
||||||
|
help = "Cancel an issue";
|
||||||
|
description = "Set issue to cancelled (must be on issue branch or develop)";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.positional("id", { type: "number", demandOption: true })
|
||||||
|
.option("reason", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Reason for cancellation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const issue = todo.issues.find(i => i.id === args.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${args.id} not found`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Cancel allowed from develop (if open) or issue branch (if in-progress/hold)
|
||||||
|
const issueBranch = `${issue.type}/${issue.id}`;
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
if (branch !== "develop" && branch !== issueBranch) {
|
||||||
|
console.error(`Must be on develop or ${issueBranch} to cancel (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const err = validateStatusTransition(issue.status, "cancelled");
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
issue.status = "cancelled";
|
||||||
|
writeTodoFile(todo);
|
||||||
|
commitFileWithBody("TODO", `todo(${issue.id}): cancelled`, args.reason);
|
||||||
|
console.log(`Issue #${issue.id} is now cancelled`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile, writeTodoFile } from "../file"
|
import { parseTodoFile, writeTodoFile } from "../file.js"
|
||||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
import { getCurrentBranch, commitFileWithBody } from "../git.js"
|
||||||
import { validateStatusTransition } from "../issue"
|
import { validateStatusTransition } from "../issue.js"
|
||||||
|
|
||||||
export class CancelCommand extends CLICommand {
|
export class CancelCommand extends CLICommand {
|
||||||
readonly name = "cancel <id>"
|
readonly name = "cancel <id>"
|
||||||
|
|
@ -49,6 +49,9 @@ export class CancelCommand extends CLICommand {
|
||||||
args.reason as string
|
args.reason as string
|
||||||
)
|
)
|
||||||
console.log(`Issue #${issue.id} is now cancelled`)
|
console.log(`Issue #${issue.id} is now cancelled`)
|
||||||
|
if (branch !== "develop") {
|
||||||
|
console.log(`Switch back to develop: git checkout develop`)
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
src/commands/CreateCommand.d.ts
vendored
Normal file
9
src/commands/CreateCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class CreateCommand extends CLICommand {
|
||||||
|
readonly name = "create";
|
||||||
|
readonly help = "Create a new issue";
|
||||||
|
readonly description = "Add an issue to the TODO file (must be on develop)";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
72
src/commands/CreateCommand.js
Normal file
72
src/commands/CreateCommand.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile, writeTodoFile } from "../file.js";
|
||||||
|
import { getCurrentBranch, commitFile } from "../git.js";
|
||||||
|
export class CreateCommand extends CLICommand {
|
||||||
|
name = "create";
|
||||||
|
help = "Create a new issue";
|
||||||
|
description = "Add an issue to the TODO file (must be on develop)";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.option("type", {
|
||||||
|
alias: "t",
|
||||||
|
type: "string",
|
||||||
|
choices: ["feature", "bugfix", "hotfix"],
|
||||||
|
demandOption: true,
|
||||||
|
})
|
||||||
|
.option("title", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
})
|
||||||
|
.option("priority", {
|
||||||
|
alias: "p",
|
||||||
|
type: "string",
|
||||||
|
choices: ["low", "medium", "high"],
|
||||||
|
default: "medium",
|
||||||
|
})
|
||||||
|
.option("plan", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Description of what needs to be done",
|
||||||
|
})
|
||||||
|
.option("module", {
|
||||||
|
alias: "m",
|
||||||
|
type: "string",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
if (branch !== "develop") {
|
||||||
|
console.error(`Must be on develop to create issues (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const mod = args.module;
|
||||||
|
if (mod && todo.modules) {
|
||||||
|
const valid = todo.modules.map(m => m.name);
|
||||||
|
if (!valid.includes(mod)) {
|
||||||
|
console.error(`Module "${mod}" not defined. Valid: ${valid.join(", ")}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextId = todo.issues.length > 0
|
||||||
|
? Math.max(...todo.issues.map(i => i.id)) + 1
|
||||||
|
: 1;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
todo.issues.push({
|
||||||
|
id: nextId,
|
||||||
|
type: args.type,
|
||||||
|
title: args.title,
|
||||||
|
status: "open",
|
||||||
|
priority: args.priority,
|
||||||
|
created: today,
|
||||||
|
module: mod,
|
||||||
|
relationships: {},
|
||||||
|
description: args.plan,
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
writeTodoFile(todo);
|
||||||
|
commitFile("TODO", `todo(${nextId}): open`);
|
||||||
|
console.log(`Created issue #${nextId}: ${args.title}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile, writeTodoFile } from "../file"
|
import { parseTodoFile, writeTodoFile } from "../file.js"
|
||||||
import { getCurrentBranch, commitFile } from "../git"
|
import { getCurrentBranch, commitFile } from "../git.js"
|
||||||
import type { IssueType, IssuePriority } from "../issue"
|
import type { IssueType, IssuePriority } from "../issue.js"
|
||||||
|
|
||||||
export class CreateCommand extends CLICommand {
|
export class CreateCommand extends CLICommand {
|
||||||
readonly name = "create"
|
readonly name = "create"
|
||||||
|
|
@ -78,6 +78,7 @@ export class CreateCommand extends CLICommand {
|
||||||
writeTodoFile(todo)
|
writeTodoFile(todo)
|
||||||
commitFile("TODO", `todo(${nextId}): open`)
|
commitFile("TODO", `todo(${nextId}): open`)
|
||||||
console.log(`Created issue #${nextId}: ${args.title}`)
|
console.log(`Created issue #${nextId}: ${args.title}`)
|
||||||
|
console.log(`Start working: mime-todo start ${nextId}`)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
src/commands/DoneCommand.d.ts
vendored
Normal file
9
src/commands/DoneCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class DoneCommand extends CLICommand {
|
||||||
|
readonly name = "done <id>";
|
||||||
|
readonly help = "Mark an issue as done";
|
||||||
|
readonly description = "Set issue to done (must be on issue branch)";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
43
src/commands/DoneCommand.js
Normal file
43
src/commands/DoneCommand.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile, writeTodoFile } from "../file.js";
|
||||||
|
import { getCurrentBranch, commitFileWithBody } from "../git.js";
|
||||||
|
import { validateStatusTransition } from "../issue.js";
|
||||||
|
export class DoneCommand extends CLICommand {
|
||||||
|
name = "done <id>";
|
||||||
|
help = "Mark an issue as done";
|
||||||
|
description = "Set issue to done (must be on issue branch)";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.positional("id", { type: "number", demandOption: true })
|
||||||
|
.option("summary", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "High-level summary of what was delivered",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const issue = todo.issues.find(i => i.id === args.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${args.id} not found`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Must be on the correct issue branch
|
||||||
|
const expectedBranch = `${issue.type}/${issue.id}`;
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
if (branch !== expectedBranch) {
|
||||||
|
console.error(`Must be on ${expectedBranch} to mark issue as done (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const err = validateStatusTransition(issue.status, "done");
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
issue.status = "done";
|
||||||
|
writeTodoFile(todo);
|
||||||
|
commitFileWithBody("TODO", `todo(${issue.id}): done`, args.summary);
|
||||||
|
console.log(`Issue #${issue.id} is now done`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile, writeTodoFile } from "../file"
|
import { parseTodoFile, writeTodoFile } from "../file.js"
|
||||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
import { getCurrentBranch, commitFileWithBody, getDirtySubmodules } from "../git.js"
|
||||||
import { validateStatusTransition } from "../issue"
|
import { validateStatusTransition } from "../issue.js"
|
||||||
|
|
||||||
export class DoneCommand extends CLICommand {
|
export class DoneCommand extends CLICommand {
|
||||||
readonly name = "done <id>"
|
readonly name = "done <id>"
|
||||||
|
|
@ -12,10 +12,15 @@ export class DoneCommand extends CLICommand {
|
||||||
addArguments(yargs: Argv): Argv {
|
addArguments(yargs: Argv): Argv {
|
||||||
return yargs
|
return yargs
|
||||||
.positional("id", { type: "number", demandOption: true })
|
.positional("id", { type: "number", demandOption: true })
|
||||||
.option("summary", {
|
.option("acceptance", {
|
||||||
type: "string",
|
type: "string",
|
||||||
demandOption: true,
|
demandOption: true,
|
||||||
description: "High-level summary of what was delivered",
|
description: "What was delivered against the acceptance criteria",
|
||||||
|
})
|
||||||
|
.option("confirm", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Confirm that submodule branches have been merged and pointer updated",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,14 +46,24 @@ export class DoneCommand extends CLICommand {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for dirty submodules
|
||||||
|
const dirtySubmodules = getDirtySubmodules()
|
||||||
|
if (dirtySubmodules.length > 0 && !(args.confirm as boolean)) {
|
||||||
|
console.error(`Submodule(s) have uncommitted changes or modified pointers: ${dirtySubmodules.join(", ")}`)
|
||||||
|
console.error(`Ensure feature branches are merged and pointers updated, then run with --confirm`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
issue.status = "done"
|
issue.status = "done"
|
||||||
writeTodoFile(todo)
|
writeTodoFile(todo)
|
||||||
commitFileWithBody(
|
commitFileWithBody(
|
||||||
"TODO",
|
"TODO",
|
||||||
`todo(${issue.id}): done`,
|
`todo(${issue.id}): done`,
|
||||||
args.summary as string
|
args.acceptance as string
|
||||||
)
|
)
|
||||||
console.log(`Issue #${issue.id} is now done`)
|
console.log(`Issue #${issue.id} is now done`)
|
||||||
|
console.log(`Merge to develop: git checkout develop && git merge ${expectedBranch} --no-ff`)
|
||||||
|
console.log(`Then push to Bugzilla: mime-todo push`)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
src/commands/HoldCommand.d.ts
vendored
Normal file
9
src/commands/HoldCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class HoldCommand extends CLICommand {
|
||||||
|
readonly name = "hold <id>";
|
||||||
|
readonly help = "Put an issue on hold";
|
||||||
|
readonly description = "Set issue to hold (must be on issue branch)";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
42
src/commands/HoldCommand.js
Normal file
42
src/commands/HoldCommand.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile, writeTodoFile } from "../file.js";
|
||||||
|
import { getCurrentBranch, commitFileWithBody } from "../git.js";
|
||||||
|
import { validateStatusTransition } from "../issue.js";
|
||||||
|
export class HoldCommand extends CLICommand {
|
||||||
|
name = "hold <id>";
|
||||||
|
help = "Put an issue on hold";
|
||||||
|
description = "Set issue to hold (must be on issue branch)";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.positional("id", { type: "number", demandOption: true })
|
||||||
|
.option("reason", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "Reason for holding",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const issue = todo.issues.find(i => i.id === args.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${args.id} not found`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const expectedBranch = `${issue.type}/${issue.id}`;
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
if (branch !== expectedBranch) {
|
||||||
|
console.error(`Must be on ${expectedBranch} to hold issue (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const err = validateStatusTransition(issue.status, "hold");
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
issue.status = "hold";
|
||||||
|
writeTodoFile(todo);
|
||||||
|
commitFileWithBody("TODO", `todo(${issue.id}): hold`, args.reason);
|
||||||
|
console.log(`Issue #${issue.id} is now on hold`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile, writeTodoFile } from "../file"
|
import { parseTodoFile, writeTodoFile } from "../file.js"
|
||||||
import { getCurrentBranch, commitFileWithBody } from "../git"
|
import { getCurrentBranch, commitFileWithBody } from "../git.js"
|
||||||
import { validateStatusTransition } from "../issue"
|
import { validateStatusTransition } from "../issue.js"
|
||||||
|
|
||||||
export class HoldCommand extends CLICommand {
|
export class HoldCommand extends CLICommand {
|
||||||
readonly name = "hold <id>"
|
readonly name = "hold <id>"
|
||||||
|
|
@ -48,6 +48,7 @@ export class HoldCommand extends CLICommand {
|
||||||
args.reason as string
|
args.reason as string
|
||||||
)
|
)
|
||||||
console.log(`Issue #${issue.id} is now on hold`)
|
console.log(`Issue #${issue.id} is now on hold`)
|
||||||
|
console.log(`Switch back to develop: git checkout develop`)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
src/commands/InitCommand.d.ts
vendored
Normal file
9
src/commands/InitCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class InitCommand extends CLICommand {
|
||||||
|
readonly name = "init";
|
||||||
|
readonly help = "Initialize Bugzilla products and components";
|
||||||
|
readonly description = "Check and optionally create Bugzilla products/components from TODO tracker config";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
131
src/commands/InitCommand.js
Normal file
131
src/commands/InitCommand.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
import { BugzillaClient } from "../bugzilla/client.js";
|
||||||
|
import { loadConfig } from "../bugzilla/config.js";
|
||||||
|
export class InitCommand extends CLICommand {
|
||||||
|
name = "init";
|
||||||
|
help = "Initialize Bugzilla products and components";
|
||||||
|
description = "Check and optionally create Bugzilla products/components from TODO tracker config";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.option("dry-run", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Show what would be created without creating",
|
||||||
|
})
|
||||||
|
.option("confirm", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Actually create missing products/components (required for writes)",
|
||||||
|
})
|
||||||
|
.option("assignee", {
|
||||||
|
type: "string",
|
||||||
|
description: "Default assignee email for new components",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
if (!todo.bugzilla) {
|
||||||
|
console.error("No application/bugzilla part found in TODO file");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = new BugzillaClient(config);
|
||||||
|
const dryRun = args.dryRun;
|
||||||
|
const confirm = args.confirm;
|
||||||
|
const assignee = args.assignee;
|
||||||
|
// Collect unique products and their components from the mappings
|
||||||
|
const productMap = new Map();
|
||||||
|
for (const m of todo.bugzilla.mappings) {
|
||||||
|
if (!productMap.has(m.product))
|
||||||
|
productMap.set(m.product, new Set());
|
||||||
|
productMap.get(m.product).add(m.component);
|
||||||
|
}
|
||||||
|
// Check what exists on the server
|
||||||
|
const states = [];
|
||||||
|
const existingProducts = await client.getAccessibleProducts();
|
||||||
|
const existingByName = new Map(existingProducts.map(p => [p.name, p]));
|
||||||
|
for (const [productName, componentNames] of productMap) {
|
||||||
|
const existing = existingByName.get(productName);
|
||||||
|
const existingComponents = new Set(existing?.components?.map(c => c.name) ?? []);
|
||||||
|
states.push({
|
||||||
|
name: productName,
|
||||||
|
exists: !!existing,
|
||||||
|
components: [...componentNames].map(name => ({
|
||||||
|
name,
|
||||||
|
exists: existingComponents.has(name),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Report
|
||||||
|
let allOk = true;
|
||||||
|
for (const product of states) {
|
||||||
|
const pStatus = product.exists ? "ok" : "MISSING";
|
||||||
|
console.log(`Product: ${product.name} [${pStatus}]`);
|
||||||
|
for (const comp of product.components) {
|
||||||
|
const cStatus = comp.exists ? "ok" : "MISSING";
|
||||||
|
console.log(` Component: ${comp.name} [${cStatus}]`);
|
||||||
|
if (!comp.exists)
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
if (!product.exists)
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
if (allOk) {
|
||||||
|
console.log("\nAll products and components exist. Nothing to do.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (dryRun) {
|
||||||
|
console.log("\n[dry-run] Would create the MISSING items above.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!confirm) {
|
||||||
|
console.log("\nMissing items found. Run with --confirm to create them.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Need assignee for component creation
|
||||||
|
const needsComponents = states.some(p => p.components.some(c => !c.exists));
|
||||||
|
if (needsComponents && !assignee) {
|
||||||
|
console.error("\n--assignee is required when creating components");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Create missing products and components
|
||||||
|
let created = 0;
|
||||||
|
for (const product of states) {
|
||||||
|
if (!product.exists) {
|
||||||
|
try {
|
||||||
|
const id = await client.createProduct({
|
||||||
|
name: product.name,
|
||||||
|
description: product.name,
|
||||||
|
version: "unspecified",
|
||||||
|
});
|
||||||
|
console.log(`Created product #${id}: ${product.name}`);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error creating product "${product.name}": ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const comp of product.components) {
|
||||||
|
if (!comp.exists) {
|
||||||
|
try {
|
||||||
|
const id = await client.createComponent({
|
||||||
|
product: product.name,
|
||||||
|
name: comp.name,
|
||||||
|
description: comp.name,
|
||||||
|
default_assignee: assignee,
|
||||||
|
});
|
||||||
|
console.log(`Created component #${id}: ${comp.name} (in ${product.name})`);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error creating component "${comp.name}": ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nInit complete: ${created} items created`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
import { BugzillaClient } from "../bugzilla/client"
|
import { BugzillaClient } from "../bugzilla/client.js"
|
||||||
import { loadConfig } from "../bugzilla/config"
|
import { loadConfig } from "../bugzilla/config.js"
|
||||||
|
|
||||||
interface ProductState {
|
interface ProductState {
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -152,6 +152,7 @@ export class InitCommand extends CLICommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nInit complete: ${created} items created`)
|
console.log(`\nInit complete: ${created} items created`)
|
||||||
|
console.log(`Push issues to Bugzilla: mime-todo push`)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8
src/commands/IssueListCommand.d.ts
vendored
Normal file
8
src/commands/IssueListCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class IssueListCommand extends CLICommand {
|
||||||
|
readonly name = "list";
|
||||||
|
readonly help = "List all issues";
|
||||||
|
readonly description = "List all issues in the TODO file";
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
14
src/commands/IssueListCommand.js
Normal file
14
src/commands/IssueListCommand.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
export class IssueListCommand extends CLICommand {
|
||||||
|
name = "list";
|
||||||
|
help = "List all issues";
|
||||||
|
description = "List all issues in the TODO file";
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
for (const issue of todo.issues) {
|
||||||
|
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ArgumentsCamelCase } from "yargs"
|
import type { ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
|
|
||||||
export class IssueListCommand extends CLICommand {
|
export class IssueListCommand extends CLICommand {
|
||||||
readonly name = "list"
|
readonly name = "list"
|
||||||
9
src/commands/IssueShowCommand.d.ts
vendored
Normal file
9
src/commands/IssueShowCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class IssueShowCommand extends CLICommand {
|
||||||
|
readonly name = "show <id>";
|
||||||
|
readonly help = "Show details for a single issue";
|
||||||
|
readonly description = "Print all fields for one issue";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
40
src/commands/IssueShowCommand.js
Normal file
40
src/commands/IssueShowCommand.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
export class IssueShowCommand extends CLICommand {
|
||||||
|
name = "show <id>";
|
||||||
|
help = "Show details for a single issue";
|
||||||
|
description = "Print all fields for one issue";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs.positional("id", { type: "number", demandOption: true });
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const issue = todo.issues.find(i => i.id === args.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${args.id} not found`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`ID: ${issue.id}`);
|
||||||
|
console.log(`Type: ${issue.type}`);
|
||||||
|
console.log(`Title: ${issue.title}`);
|
||||||
|
console.log(`Status: ${issue.status}`);
|
||||||
|
console.log(`Priority: ${issue.priority}`);
|
||||||
|
console.log(`Created: ${issue.created}`);
|
||||||
|
if (issue.module)
|
||||||
|
console.log(`Module: ${issue.module}`);
|
||||||
|
if (issue.dueStart)
|
||||||
|
console.log(`DueStart: ${issue.dueStart}`);
|
||||||
|
if (issue.dueEnd)
|
||||||
|
console.log(`DueEnd: ${issue.dueEnd}`);
|
||||||
|
const rels = Object.entries(issue.relationships)
|
||||||
|
.map(([k, v]) => `${k}:${v.join(" ")}`)
|
||||||
|
.join(", ");
|
||||||
|
console.log(`Relationships: ${rels}`);
|
||||||
|
console.log(`Description: ${issue.description}`);
|
||||||
|
if (issue.body) {
|
||||||
|
console.log();
|
||||||
|
console.log(issue.body);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
|
|
||||||
export class IssueShowCommand extends CLICommand {
|
export class IssueShowCommand extends CLICommand {
|
||||||
readonly name = "show <id>"
|
readonly name = "show <id>"
|
||||||
9
src/commands/IssuesInSprintCommand.d.ts
vendored
Normal file
9
src/commands/IssuesInSprintCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class IssuesInSprintCommand extends CLICommand {
|
||||||
|
readonly name = "issues-in-sprint <name>";
|
||||||
|
readonly help = "List issues in a sprint";
|
||||||
|
readonly description = "Find issues whose date range overlaps with a sprint";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
29
src/commands/IssuesInSprintCommand.js
Normal file
29
src/commands/IssuesInSprintCommand.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
export class IssuesInSprintCommand extends CLICommand {
|
||||||
|
name = "issues-in-sprint <name>";
|
||||||
|
help = "List issues in a sprint";
|
||||||
|
description = "Find issues whose date range overlaps with a sprint";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs.positional("name", { type: "string", demandOption: true });
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const sprint = todo.sprints.find(s => s.name === args.name);
|
||||||
|
if (!sprint) {
|
||||||
|
console.error(`Sprint not found: ${args.name}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const { start, end } = sprint;
|
||||||
|
for (const issue of todo.issues) {
|
||||||
|
const ds = issue.dueStart ?? issue.dueEnd;
|
||||||
|
const de = issue.dueEnd ?? issue.dueStart;
|
||||||
|
if (!ds || !de)
|
||||||
|
continue;
|
||||||
|
if (ds <= end && de >= start) {
|
||||||
|
console.log(`#${issue.id} [${issue.type}] (${issue.status}) ${issue.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
|
|
||||||
export class IssuesInSprintCommand extends CLICommand {
|
export class IssuesInSprintCommand extends CLICommand {
|
||||||
readonly name = "issues-in-sprint <name>"
|
readonly name = "issues-in-sprint <name>"
|
||||||
17
src/commands/PushCommand.d.ts
vendored
Normal file
17
src/commands/PushCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class PushCommand extends CLICommand {
|
||||||
|
readonly name = "push [ref]";
|
||||||
|
readonly help = "Push commits to Bugzilla";
|
||||||
|
readonly description = "Push git commits as Bugzilla comments, transitions as status updates";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
private pushFromIssueBranch;
|
||||||
|
private pushFromDevelop;
|
||||||
|
private findOrCreateBug;
|
||||||
|
private resolveAllBugs;
|
||||||
|
private fetchCommentMeta;
|
||||||
|
private postComment;
|
||||||
|
private tagAsPushed;
|
||||||
|
private formatComment;
|
||||||
|
}
|
||||||
288
src/commands/PushCommand.js
Normal file
288
src/commands/PushCommand.js
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
import { BugzillaClient } from "../bugzilla/client.js";
|
||||||
|
import { loadConfig } from "../bugzilla/config.js";
|
||||||
|
import { issueToBugzillaCreate, statusToBugzilla, resolveProductComponent, } from "../bugzilla/fieldmap.js";
|
||||||
|
import { buildOriginUrl, buildCommitUrl, shortHash, getGitRemoteUrl, } from "../bugzilla/origin.js";
|
||||||
|
import { getCurrentBranch, parseIssueBranch, getCommitsSinceDiverge, getCommitsFromRef, parseTodoTransition, } from "../git.js";
|
||||||
|
export class PushCommand extends CLICommand {
|
||||||
|
name = "push [ref]";
|
||||||
|
help = "Push commits to Bugzilla";
|
||||||
|
description = "Push git commits as Bugzilla comments, transitions as status updates";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.positional("ref", {
|
||||||
|
type: "string",
|
||||||
|
description: "Git ref specifier (e.g. HEAD~3) — issue branches only",
|
||||||
|
})
|
||||||
|
.option("dry-run", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Show what would be pushed without pushing",
|
||||||
|
})
|
||||||
|
.option("strategy", {
|
||||||
|
type: "string",
|
||||||
|
choices: ["smart", "full"],
|
||||||
|
default: "smart",
|
||||||
|
description: "Fetch strategy: smart (only relevant bugs) or full (all bugs)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
const issueBranch = parseIssueBranch(branch);
|
||||||
|
const isDevelop = branch === "develop";
|
||||||
|
if (!isDevelop && !issueBranch) {
|
||||||
|
console.error(`Must be on develop or an issue branch (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const config = loadConfig();
|
||||||
|
const client = new BugzillaClient(config);
|
||||||
|
const remoteUrl = getGitRemoteUrl();
|
||||||
|
// Origin URL always uses develop — that's where the TODO lives canonically
|
||||||
|
const gitBranch = "develop";
|
||||||
|
const strategy = args.strategy;
|
||||||
|
const dryRun = args.dryRun;
|
||||||
|
if (issueBranch) {
|
||||||
|
return this.pushFromIssueBranch(client, config, todo, remoteUrl, gitBranch, issueBranch, args.ref, strategy, dryRun);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return this.pushFromDevelop(client, config, todo, remoteUrl, gitBranch, args.ref, strategy, dryRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Push work comments from an issue branch
|
||||||
|
async pushFromIssueBranch(client, config, todo, remoteUrl, gitBranch, issueBranch, ref, strategy, dryRun) {
|
||||||
|
const issue = todo.issues.find((i) => i.id === issueBranch.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${issueBranch.id} not found in TODO`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Issue must be in-progress to push work comments
|
||||||
|
if (issue.status !== "in-progress") {
|
||||||
|
console.error(`Issue #${issue.id} is "${issue.status}" — work comments can only be pushed when in-progress`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Resolve the bug
|
||||||
|
const bugId = await this.findOrCreateBug(client, config, todo, remoteUrl, gitBranch, issue, strategy, dryRun);
|
||||||
|
if (!bugId)
|
||||||
|
return 1;
|
||||||
|
// Check Bugzilla comments for a done transition — no work after done
|
||||||
|
const existingComments = await this.fetchCommentMeta(client, bugId);
|
||||||
|
if (existingComments.hasDone) {
|
||||||
|
console.error(`Bug #${bugId} already has a [done] transition — no further work comments allowed`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Get commits to push
|
||||||
|
const commits = ref
|
||||||
|
? getCommitsFromRef(`${ref}..HEAD`)
|
||||||
|
: getCommitsSinceDiverge("develop");
|
||||||
|
if (commits.length === 0) {
|
||||||
|
console.log("No commits to push");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let pushed = 0;
|
||||||
|
for (const commit of commits) {
|
||||||
|
if (existingComments.hashes.has(shortHash(commit.hash)))
|
||||||
|
continue;
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const commitUrl = buildCommitUrl(remoteUrl, commit.hash);
|
||||||
|
const commentId = await this.postComment(client, bugId, commit, commitUrl);
|
||||||
|
await this.tagAsPushed(client, commentId, commit.hash);
|
||||||
|
pushed++;
|
||||||
|
console.log(`bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error pushing ${shortHash(commit.hash)}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Push complete: ${pushed} comments`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Push transitions from develop
|
||||||
|
async pushFromDevelop(client, config, todo, remoteUrl, gitBranch, ref, strategy, dryRun) {
|
||||||
|
// Get transition commits
|
||||||
|
let allCommits;
|
||||||
|
if (ref) {
|
||||||
|
allCommits = getCommitsFromRef(`${ref}..HEAD`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
allCommits = getCommitsFromRef("HEAD");
|
||||||
|
}
|
||||||
|
const relevantCommits = allCommits.filter(c => parseTodoTransition(c.subject) !== null);
|
||||||
|
if (relevantCommits.length === 0) {
|
||||||
|
console.log("No transitions to push");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Resolve all bugs
|
||||||
|
const bugMap = await this.resolveAllBugs(client, config, todo, remoteUrl, gitBranch, strategy, dryRun);
|
||||||
|
let pushed = 0;
|
||||||
|
let transitioned = 0;
|
||||||
|
for (const commit of relevantCommits) {
|
||||||
|
const transition = parseTodoTransition(commit.subject);
|
||||||
|
if (!transition)
|
||||||
|
continue;
|
||||||
|
const bugId = bugMap.get(transition.issueId);
|
||||||
|
if (!bugId)
|
||||||
|
continue;
|
||||||
|
// Idempotency check
|
||||||
|
const meta = await this.fetchCommentMeta(client, bugId);
|
||||||
|
if (meta.hashes.has(shortHash(commit.hash)))
|
||||||
|
continue;
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] bug #${bugId} ← ${shortHash(commit.hash)} ${commit.subject}`);
|
||||||
|
console.log(`[dry-run] → status: ${transition.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const commitUrl = buildCommitUrl(remoteUrl, commit.hash);
|
||||||
|
const commentId = await this.postComment(client, bugId, commit, commitUrl);
|
||||||
|
// Update status, then tag — tag only after everything succeeds
|
||||||
|
const bz = statusToBugzilla(transition.status);
|
||||||
|
const payload = { status: bz.status };
|
||||||
|
if (bz.resolution)
|
||||||
|
payload.resolution = bz.resolution;
|
||||||
|
await client.updateBug(bugId, payload);
|
||||||
|
await this.tagAsPushed(client, commentId, commit.hash);
|
||||||
|
pushed++;
|
||||||
|
transitioned++;
|
||||||
|
console.log(`bug #${bugId} ← ${shortHash(commit.hash)} [${transition.status}]`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error pushing ${shortHash(commit.hash)} to bug #${bugId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Push complete: ${pushed} comments, ${transitioned} transitions`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Find a bug for a specific issue, or create it
|
||||||
|
async findOrCreateBug(client, config, todo, remoteUrl, gitBranch, issue, strategy, dryRun) {
|
||||||
|
// Try to find existing bug by URL
|
||||||
|
const originUrl = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id);
|
||||||
|
if (strategy === "smart") {
|
||||||
|
const bugs = await client.searchBugs({ url: originUrl });
|
||||||
|
if (bugs.length > 0)
|
||||||
|
return bugs[0].id;
|
||||||
|
}
|
||||||
|
// Fallback: search all bugs in product/component
|
||||||
|
const searchTargets = todo.bugzilla
|
||||||
|
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
|
||||||
|
: [{ product: config.product, component: config.component }];
|
||||||
|
for (const target of searchTargets) {
|
||||||
|
if (!target.product)
|
||||||
|
continue;
|
||||||
|
const bugs = await client.searchBugs(target);
|
||||||
|
for (const bug of bugs) {
|
||||||
|
if (bug.url === originUrl)
|
||||||
|
return bug.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not found — create it
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = resolveProductComponent(issue, todo.bugzilla);
|
||||||
|
const product = resolved?.product ?? config.product;
|
||||||
|
const component = resolved?.component ?? config.component;
|
||||||
|
const payload = issueToBugzillaCreate(issue, product, component, originUrl);
|
||||||
|
const bugId = await client.createBug(payload);
|
||||||
|
console.log(`Created bug #${bugId} for issue #${issue.id}`);
|
||||||
|
return bugId;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve bugs for all issues in the TODO
|
||||||
|
async resolveAllBugs(client, config, todo, remoteUrl, gitBranch, strategy, dryRun) {
|
||||||
|
const bugMap = new Map();
|
||||||
|
const searchTargets = todo.bugzilla
|
||||||
|
? todo.bugzilla.mappings.map(m => ({ product: m.product, component: m.component }))
|
||||||
|
: [{ product: config.product, component: config.component }];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const target of searchTargets) {
|
||||||
|
if (!target.product)
|
||||||
|
continue;
|
||||||
|
const bugs = await client.searchBugs(target);
|
||||||
|
for (const bug of bugs) {
|
||||||
|
if (seen.has(bug.id))
|
||||||
|
continue;
|
||||||
|
seen.add(bug.id);
|
||||||
|
if (bug.url) {
|
||||||
|
const match = bug.url.match(/#(\d+)$/);
|
||||||
|
if (match)
|
||||||
|
bugMap.set(Number(match[1]), bug.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create bugs for issues that don't have one yet
|
||||||
|
for (const issue of todo.issues) {
|
||||||
|
if (bugMap.has(issue.id))
|
||||||
|
continue;
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] Would create bug for issue #${issue.id}: ${issue.title}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolved = resolveProductComponent(issue, todo.bugzilla);
|
||||||
|
const product = resolved?.product ?? config.product;
|
||||||
|
const component = resolved?.component ?? config.component;
|
||||||
|
const url = buildOriginUrl(remoteUrl, gitBranch, "TODO", issue.id);
|
||||||
|
const payload = issueToBugzillaCreate(issue, product, component, url);
|
||||||
|
const bugId = await client.createBug(payload);
|
||||||
|
bugMap.set(issue.id, bugId);
|
||||||
|
console.log(`Created bug #${bugId} for issue #${issue.id}`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bugMap;
|
||||||
|
}
|
||||||
|
// Fetch comment metadata: which commit hashes are already tagged + whether done exists
|
||||||
|
async fetchCommentMeta(client, bugId) {
|
||||||
|
const hashes = new Set();
|
||||||
|
let hasDone = false;
|
||||||
|
try {
|
||||||
|
const comments = await client.getComments(bugId);
|
||||||
|
for (const c of comments) {
|
||||||
|
for (const tag of c.tags ?? []) {
|
||||||
|
if (tag.startsWith("git-")) {
|
||||||
|
hashes.add(tag.slice(4)); // short hash without "git-" prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/^\*\*todo\(\d+\): done\*\*$/m.test(c.text))
|
||||||
|
hasDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* non-fatal */ }
|
||||||
|
return { hashes, hasDone };
|
||||||
|
}
|
||||||
|
// Post a comment, returns the comment ID for later tagging
|
||||||
|
async postComment(client, bugId, commit, commitUrl) {
|
||||||
|
const comment = this.formatComment(commit, commitUrl);
|
||||||
|
return await client.addComment(bugId, comment);
|
||||||
|
}
|
||||||
|
// Tag a comment as pushed — call only after all side effects succeed
|
||||||
|
async tagAsPushed(client, commentId, commitHash) {
|
||||||
|
await client.tagComment(commentId, [`git-${shortHash(commitHash)}`]);
|
||||||
|
}
|
||||||
|
// Format a commit as a markdown Bugzilla comment
|
||||||
|
formatComment(commit, commitUrl) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`**${commit.subject}**`);
|
||||||
|
if (commit.body) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(commit.body);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`[\`${shortHash(commit.hash)}\`](${commitUrl})`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
import { BugzillaClient } from "../bugzilla/client"
|
import { BugzillaClient } from "../bugzilla/client.js"
|
||||||
import { loadConfig } from "../bugzilla/config"
|
import { loadConfig } from "../bugzilla/config.js"
|
||||||
import {
|
import {
|
||||||
issueToBugzillaCreate,
|
issueToBugzillaCreate,
|
||||||
statusToBugzilla,
|
statusToBugzilla,
|
||||||
resolveProductComponent,
|
resolveProductComponent,
|
||||||
} from "../bugzilla/fieldmap"
|
} from "../bugzilla/fieldmap.js"
|
||||||
import {
|
import {
|
||||||
buildOriginUrl,
|
buildOriginUrl,
|
||||||
buildCommitUrl,
|
buildCommitUrl,
|
||||||
shortHash,
|
shortHash,
|
||||||
getGitRemoteUrl,
|
getGitRemoteUrl,
|
||||||
getGitBranch,
|
getGitBranch,
|
||||||
} from "../bugzilla/origin"
|
} from "../bugzilla/origin.js"
|
||||||
import {
|
import {
|
||||||
getCurrentBranch,
|
getCurrentBranch,
|
||||||
parseIssueBranch,
|
parseIssueBranch,
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
getCommitsFromRef,
|
getCommitsFromRef,
|
||||||
parseTodoTransition,
|
parseTodoTransition,
|
||||||
type GitCommit,
|
type GitCommit,
|
||||||
} from "../git"
|
} from "../git.js"
|
||||||
|
|
||||||
type Strategy = "smart" | "full"
|
type Strategy = "smart" | "full"
|
||||||
|
|
||||||
|
|
@ -85,8 +85,8 @@ export class PushCommand extends CLICommand {
|
||||||
// Push work comments from an issue branch
|
// Push work comments from an issue branch
|
||||||
private async pushFromIssueBranch(
|
private async pushFromIssueBranch(
|
||||||
client: BugzillaClient,
|
client: BugzillaClient,
|
||||||
config: import("../bugzilla/config").BugzillaConfig,
|
config: import("../bugzilla/config.js").BugzillaConfig,
|
||||||
todo: import("../file").TodoFile,
|
todo: import("../file.js").TodoFile,
|
||||||
remoteUrl: string,
|
remoteUrl: string,
|
||||||
gitBranch: string,
|
gitBranch: string,
|
||||||
issueBranch: { type: string; id: number },
|
issueBranch: { type: string; id: number },
|
||||||
|
|
@ -94,7 +94,7 @@ export class PushCommand extends CLICommand {
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const issue = todo.issues.find(i => i.id === issueBranch.id)
|
const issue = todo.issues.find((i: import("../issue.js").Issue) => i.id === issueBranch.id)
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
console.error(`Issue #${issueBranch.id} not found in TODO`)
|
console.error(`Issue #${issueBranch.id} not found in TODO`)
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -160,8 +160,8 @@ export class PushCommand extends CLICommand {
|
||||||
// Push transitions from develop
|
// Push transitions from develop
|
||||||
private async pushFromDevelop(
|
private async pushFromDevelop(
|
||||||
client: BugzillaClient,
|
client: BugzillaClient,
|
||||||
config: import("../bugzilla/config").BugzillaConfig,
|
config: import("../bugzilla/config.js").BugzillaConfig,
|
||||||
todo: import("../file").TodoFile,
|
todo: import("../file.js").TodoFile,
|
||||||
remoteUrl: string,
|
remoteUrl: string,
|
||||||
gitBranch: string,
|
gitBranch: string,
|
||||||
ref: string | undefined,
|
ref: string | undefined,
|
||||||
|
|
@ -177,7 +177,7 @@ export class PushCommand extends CLICommand {
|
||||||
}
|
}
|
||||||
const relevantCommits = allCommits.filter(c =>
|
const relevantCommits = allCommits.filter(c =>
|
||||||
parseTodoTransition(c.subject) !== null
|
parseTodoTransition(c.subject) !== null
|
||||||
)
|
).reverse()
|
||||||
|
|
||||||
if (relevantCommits.length === 0) {
|
if (relevantCommits.length === 0) {
|
||||||
console.log("No transitions to push")
|
console.log("No transitions to push")
|
||||||
|
|
@ -235,11 +235,11 @@ export class PushCommand extends CLICommand {
|
||||||
// Find a bug for a specific issue, or create it
|
// Find a bug for a specific issue, or create it
|
||||||
private async findOrCreateBug(
|
private async findOrCreateBug(
|
||||||
client: BugzillaClient,
|
client: BugzillaClient,
|
||||||
config: import("../bugzilla/config").BugzillaConfig,
|
config: import("../bugzilla/config.js").BugzillaConfig,
|
||||||
todo: import("../file").TodoFile,
|
todo: import("../file.js").TodoFile,
|
||||||
remoteUrl: string,
|
remoteUrl: string,
|
||||||
gitBranch: string,
|
gitBranch: string,
|
||||||
issue: import("../issue").Issue,
|
issue: import("../issue.js").Issue,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
|
|
@ -277,6 +277,7 @@ export class PushCommand extends CLICommand {
|
||||||
const payload = issueToBugzillaCreate(issue, product, component, originUrl)
|
const payload = issueToBugzillaCreate(issue, product, component, originUrl)
|
||||||
const bugId = await client.createBug(payload)
|
const bugId = await client.createBug(payload)
|
||||||
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
||||||
|
await this.reconcileBugStatus(client, bugId, issue)
|
||||||
return bugId
|
return bugId
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
||||||
|
|
@ -287,8 +288,8 @@ export class PushCommand extends CLICommand {
|
||||||
// Resolve bugs for all issues in the TODO
|
// Resolve bugs for all issues in the TODO
|
||||||
private async resolveAllBugs(
|
private async resolveAllBugs(
|
||||||
client: BugzillaClient,
|
client: BugzillaClient,
|
||||||
config: import("../bugzilla/config").BugzillaConfig,
|
config: import("../bugzilla/config.js").BugzillaConfig,
|
||||||
todo: import("../file").TodoFile,
|
todo: import("../file.js").TodoFile,
|
||||||
remoteUrl: string,
|
remoteUrl: string,
|
||||||
gitBranch: string,
|
gitBranch: string,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
|
|
@ -330,6 +331,7 @@ export class PushCommand extends CLICommand {
|
||||||
const bugId = await client.createBug(payload)
|
const bugId = await client.createBug(payload)
|
||||||
bugMap.set(issue.id, bugId)
|
bugMap.set(issue.id, bugId)
|
||||||
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
console.log(`Created bug #${bugId} for issue #${issue.id}`)
|
||||||
|
await this.reconcileBugStatus(client, bugId, issue)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
console.error(`Error creating bug for issue #${issue.id}: ${err.message}`)
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +381,23 @@ export class PushCommand extends CLICommand {
|
||||||
await client.tagComment(commentId, [`git-${shortHash(commitHash)}`])
|
await client.tagComment(commentId, [`git-${shortHash(commitHash)}`])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile bug status after creation if issue is not open
|
||||||
|
private async reconcileBugStatus(
|
||||||
|
client: BugzillaClient,
|
||||||
|
bugId: number,
|
||||||
|
issue: import("../issue.js").Issue
|
||||||
|
): Promise<void> {
|
||||||
|
if (issue.status === "open") return
|
||||||
|
try {
|
||||||
|
const bz = statusToBugzilla(issue.status)
|
||||||
|
const update: Record<string, string> = { status: bz.status }
|
||||||
|
if (bz.resolution) update.resolution = bz.resolution
|
||||||
|
await client.updateBug(bugId, update)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Error reconciling status for bug #${bugId}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format a commit as a markdown Bugzilla comment
|
// Format a commit as a markdown Bugzilla comment
|
||||||
private formatComment(commit: GitCommit, commitUrl: string): string {
|
private formatComment(commit: GitCommit, commitUrl: string): string {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
8
src/commands/SprintsCommand.d.ts
vendored
Normal file
8
src/commands/SprintsCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class SprintsCommand extends CLICommand {
|
||||||
|
readonly name = "sprints";
|
||||||
|
readonly help = "List all sprints";
|
||||||
|
readonly description = "List all sprints in the TODO file";
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
14
src/commands/SprintsCommand.js
Normal file
14
src/commands/SprintsCommand.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile } from "../file.js";
|
||||||
|
export class SprintsCommand extends CLICommand {
|
||||||
|
name = "sprints";
|
||||||
|
help = "List all sprints";
|
||||||
|
description = "List all sprints in the TODO file";
|
||||||
|
async execute(args) {
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
for (const sprint of todo.sprints) {
|
||||||
|
console.log(`${sprint.name}: ${sprint.start}..${sprint.end}`);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ArgumentsCamelCase } from "yargs"
|
import type { ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile } from "../file"
|
import { parseTodoFile } from "../file.js"
|
||||||
|
|
||||||
export class SprintsCommand extends CLICommand {
|
export class SprintsCommand extends CLICommand {
|
||||||
readonly name = "sprints"
|
readonly name = "sprints"
|
||||||
9
src/commands/StartCommand.d.ts
vendored
Normal file
9
src/commands/StartCommand.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { Argv, ArgumentsCamelCase } from "yargs";
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
export declare class StartCommand extends CLICommand {
|
||||||
|
readonly name = "start <id>";
|
||||||
|
readonly help = "Start work on an issue";
|
||||||
|
readonly description = "Set issue to in-progress (must be on develop)";
|
||||||
|
addArguments(yargs: Argv): Argv;
|
||||||
|
execute(args: ArgumentsCamelCase): Promise<number>;
|
||||||
|
}
|
||||||
48
src/commands/StartCommand.js
Normal file
48
src/commands/StartCommand.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { CLICommand } from "../cli/CLICommand.js";
|
||||||
|
import { parseTodoFile, writeTodoFile } from "../file.js";
|
||||||
|
import { getCurrentBranch, branchExists, commitFileWithBody } from "../git.js";
|
||||||
|
import { validateStatusTransition } from "../issue.js";
|
||||||
|
export class StartCommand extends CLICommand {
|
||||||
|
name = "start <id>";
|
||||||
|
help = "Start work on an issue";
|
||||||
|
description = "Set issue to in-progress (must be on develop)";
|
||||||
|
addArguments(yargs) {
|
||||||
|
return yargs
|
||||||
|
.positional("id", { type: "number", demandOption: true })
|
||||||
|
.option("plan", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
description: "High-level description of planned approach",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async execute(args) {
|
||||||
|
const branch = getCurrentBranch();
|
||||||
|
if (branch !== "develop") {
|
||||||
|
console.error(`Must be on develop to start an issue (currently on ${branch})`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const todo = await parseTodoFile();
|
||||||
|
const issue = todo.issues.find(i => i.id === args.id);
|
||||||
|
if (!issue) {
|
||||||
|
console.error(`Issue #${args.id} not found`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const err = validateStatusTransition(issue.status, "in-progress");
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// Check that the issue branch doesn't already exist
|
||||||
|
const issueBranch = `${issue.type}/${issue.id}`;
|
||||||
|
if (branchExists(issueBranch)) {
|
||||||
|
console.error(`Branch ${issueBranch} already exists`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
issue.status = "in-progress";
|
||||||
|
writeTodoFile(todo);
|
||||||
|
commitFileWithBody("TODO", `todo(${issue.id}): in-progress`, args.plan);
|
||||||
|
console.log(`Issue #${issue.id} is now in-progress`);
|
||||||
|
console.log(`Create the issue branch: git checkout -b ${issueBranch}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Argv, ArgumentsCamelCase } from "yargs"
|
import type { Argv, ArgumentsCamelCase } from "yargs"
|
||||||
import { CLICommand } from "../cli/CLICommand"
|
import { CLICommand } from "../cli/CLICommand.js"
|
||||||
import { parseTodoFile, writeTodoFile } from "../file"
|
import { parseTodoFile, writeTodoFile } from "../file.js"
|
||||||
import { getCurrentBranch, branchExists, commitFileWithBody } from "../git"
|
import { getCurrentBranch, branchExists, commitFileWithBody } from "../git.js"
|
||||||
import { validateStatusTransition } from "../issue"
|
import { validateStatusTransition } from "../issue.js"
|
||||||
|
|
||||||
export class StartCommand extends CLICommand {
|
export class StartCommand extends CLICommand {
|
||||||
readonly name = "start <id>"
|
readonly name = "start <id>"
|
||||||
|
|
@ -12,10 +12,10 @@ export class StartCommand extends CLICommand {
|
||||||
addArguments(yargs: Argv): Argv {
|
addArguments(yargs: Argv): Argv {
|
||||||
return yargs
|
return yargs
|
||||||
.positional("id", { type: "number", demandOption: true })
|
.positional("id", { type: "number", demandOption: true })
|
||||||
.option("plan", {
|
.option("acceptance-criteria", {
|
||||||
type: "string",
|
type: "string",
|
||||||
demandOption: true,
|
demandOption: true,
|
||||||
description: "High-level description of planned approach",
|
description: "What must be true for this issue to be considered done",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,10 +51,11 @@ export class StartCommand extends CLICommand {
|
||||||
commitFileWithBody(
|
commitFileWithBody(
|
||||||
"TODO",
|
"TODO",
|
||||||
`todo(${issue.id}): in-progress`,
|
`todo(${issue.id}): in-progress`,
|
||||||
args.plan as string
|
args.acceptanceCriteria as string
|
||||||
)
|
)
|
||||||
console.log(`Issue #${issue.id} is now in-progress`)
|
console.log(`Issue #${issue.id} is now in-progress`)
|
||||||
console.log(`Create the issue branch: git checkout -b ${issueBranch}`)
|
console.log(`Create the issue branch: git checkout -b ${issueBranch}`)
|
||||||
|
console.log(`If working in a submodule, also checkout there: cd <submodule> && git checkout -b ${issueBranch}`)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
src/file.d.ts
vendored
Normal file
13
src/file.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Issue } from "./issue.js";
|
||||||
|
import { Sprint } from "./sprint.js";
|
||||||
|
import { Module, BugzillaTracker } from "./tracker.js";
|
||||||
|
export interface TodoFile {
|
||||||
|
sprints: Sprint[];
|
||||||
|
issues: Issue[];
|
||||||
|
modules?: Module[];
|
||||||
|
bugzilla?: BugzillaTracker;
|
||||||
|
}
|
||||||
|
export declare function parseMime(mimeText: string): Promise<import("mailparser").ParsedMail>;
|
||||||
|
export declare function preprocessTODO(raw: string): string;
|
||||||
|
export declare function parseTodoFile(path?: string): Promise<TodoFile>;
|
||||||
|
export declare function writeTodoFile(todo: TodoFile, path?: string): void;
|
||||||
148
src/file.js
Normal file
148
src/file.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// mime-todo/lib/file.ts
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { simpleParser } from "mailparser";
|
||||||
|
import { parseIssue } from "./issue.js";
|
||||||
|
import { parseSprints } from "./sprint.js";
|
||||||
|
import { parseModules, parseBugzillaTracker } from "./tracker.js";
|
||||||
|
import { serializeTodoFile } from "./serializer.js";
|
||||||
|
import schema from "./file.schema.json" with { type: "json" };
|
||||||
|
import Ajv from "ajv";
|
||||||
|
const ajv = new Ajv.default({ allErrors: true });
|
||||||
|
const validateFile = ajv.compile(schema);
|
||||||
|
export async function parseMime(mimeText) {
|
||||||
|
return await simpleParser(mimeText);
|
||||||
|
}
|
||||||
|
export function preprocessTODO(raw) {
|
||||||
|
const boundary = "ISSUE";
|
||||||
|
const rawParts = raw
|
||||||
|
.split(`--${boundary}`)
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const parts = [];
|
||||||
|
for (const rawPart of rawParts) {
|
||||||
|
const lines = rawPart.split(/\r?\n/);
|
||||||
|
const typeLine = lines[0];
|
||||||
|
if (!typeLine?.toLowerCase().startsWith("content-type:")) {
|
||||||
|
throw new Error(`Part missing Content-Type header:\n${rawPart}`);
|
||||||
|
}
|
||||||
|
const type = typeLine.slice("Content-Type:".length).trim();
|
||||||
|
const body = lines.slice(1).join("\n").trim();
|
||||||
|
parts.push({ type, body });
|
||||||
|
}
|
||||||
|
// Validate singleton parts
|
||||||
|
for (const ct of ["application/sprints", "application/modules", "application/bugzilla"]) {
|
||||||
|
const count = parts.filter(p => p.type === ct).length;
|
||||||
|
if (count > 1) {
|
||||||
|
throw new Error(`Multiple ${ct} parts found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sprintsPart = parts.find(p => p.type === "application/sprints") ?? null;
|
||||||
|
const modulesPart = parts.find(p => p.type === "application/modules") ?? null;
|
||||||
|
const bugzillaPart = parts.find(p => p.type === "application/bugzilla") ?? null;
|
||||||
|
const singletonParts = new Set([sprintsPart, modulesPart, bugzillaPart]);
|
||||||
|
// Reorder: sprints, modules, bugzilla, then everything else
|
||||||
|
const orderedParts = [];
|
||||||
|
if (sprintsPart)
|
||||||
|
orderedParts.push(sprintsPart);
|
||||||
|
if (modulesPart)
|
||||||
|
orderedParts.push(modulesPart);
|
||||||
|
if (bugzillaPart)
|
||||||
|
orderedParts.push(bugzillaPart);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!singletonParts.has(part))
|
||||||
|
orderedParts.push(part);
|
||||||
|
}
|
||||||
|
// MIME envelope
|
||||||
|
const out = [];
|
||||||
|
out.push(`MIME-Version: 1.0`);
|
||||||
|
out.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
||||||
|
out.push("");
|
||||||
|
for (const part of orderedParts) {
|
||||||
|
out.push(`--${boundary}`);
|
||||||
|
out.push(`Content-Type: ${part.type}`);
|
||||||
|
out.push("");
|
||||||
|
out.push(part.body);
|
||||||
|
out.push("");
|
||||||
|
}
|
||||||
|
out.push(`--${boundary}--`);
|
||||||
|
out.push("");
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
export async function parseTodoFile(path = "TODO") {
|
||||||
|
const raw = fs.readFileSync(path, "utf-8");
|
||||||
|
const mimeWrapped = preprocessTODO(raw);
|
||||||
|
const parsed = await parseMime(mimeWrapped);
|
||||||
|
const sprints = [];
|
||||||
|
const issues = [];
|
||||||
|
let modules;
|
||||||
|
let bugzilla;
|
||||||
|
const parts = parsed.attachments ??
|
||||||
|
[];
|
||||||
|
for (const part of parts) {
|
||||||
|
const contentType = String(part.contentType || "").toLowerCase();
|
||||||
|
const body = part.content?.toString("utf-8") ?? "";
|
||||||
|
if (contentType.startsWith("application/sprints")) {
|
||||||
|
sprints.push(...parseSprints(body));
|
||||||
|
}
|
||||||
|
else if (contentType.startsWith("application/modules")) {
|
||||||
|
modules = parseModules(body);
|
||||||
|
}
|
||||||
|
else if (contentType.startsWith("application/bugzilla")) {
|
||||||
|
bugzilla = parseBugzillaTracker(body);
|
||||||
|
}
|
||||||
|
else if (contentType.startsWith("application/issue")) {
|
||||||
|
issues.push(parseIssue(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const file = { sprints, issues, modules, bugzilla };
|
||||||
|
if (!validateFile(file)) {
|
||||||
|
throw new Error("Schema validation failed: " +
|
||||||
|
JSON.stringify(validateFile.errors, null, 2));
|
||||||
|
}
|
||||||
|
// Validate: issue modules must reference defined modules
|
||||||
|
if (modules) {
|
||||||
|
const validModules = new Set(modules.map(m => m.name));
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.module && !validModules.has(issue.module)) {
|
||||||
|
throw new Error(`Issue #${issue.id} references module "${issue.module}" ` +
|
||||||
|
`which is not defined in application/modules. ` +
|
||||||
|
`Valid modules: ${[...validModules].join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate: bugzilla tracker mappings must reference defined modules
|
||||||
|
if (bugzilla && modules) {
|
||||||
|
const validModules = new Set(modules.map(m => m.name));
|
||||||
|
for (const mapping of bugzilla.mappings) {
|
||||||
|
if (!validModules.has(mapping.module)) {
|
||||||
|
throw new Error(`Bugzilla mapping references module "${mapping.module}" ` +
|
||||||
|
`which is not defined in application/modules. ` +
|
||||||
|
`Valid modules: ${[...validModules].join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validate: relationship targets must reference existing issue IDs
|
||||||
|
const issueIds = new Set(issues.map(i => i.id));
|
||||||
|
const issueMap = new Map(issues.map(i => [i.id, i]));
|
||||||
|
for (const issue of issues) {
|
||||||
|
for (const [kind, targets] of Object.entries(issue.relationships)) {
|
||||||
|
for (const targetId of targets) {
|
||||||
|
if (!issueIds.has(targetId)) {
|
||||||
|
throw new Error(`Issue #${issue.id} has ${kind} relationship to #${targetId}, ` +
|
||||||
|
`but issue #${targetId} does not exist`);
|
||||||
|
}
|
||||||
|
// Warn on stale relationships (target is cancelled)
|
||||||
|
const target = issueMap.get(targetId);
|
||||||
|
if (target?.status === "cancelled") {
|
||||||
|
console.error(`Warning: Issue #${issue.id} has ${kind} relationship to #${targetId}, ` +
|
||||||
|
`but issue #${targetId} is cancelled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
export function writeTodoFile(todo, path = "TODO") {
|
||||||
|
const content = serializeTodoFile(todo);
|
||||||
|
fs.writeFileSync(path, content);
|
||||||
|
}
|
||||||
144
src/file.schema.json
Normal file
144
src/file.schema.json
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "TODO File Schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sprints": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/Sprint" }
|
||||||
|
},
|
||||||
|
"issues": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/Issue" }
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/Module" }
|
||||||
|
},
|
||||||
|
"bugzilla": {
|
||||||
|
"$ref": "#/definitions/BugzillaTracker"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["sprints", "issues"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
|
||||||
|
"definitions": {
|
||||||
|
"Sprint": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"start": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name", "start", "end"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"Issue": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["feature", "bugfix", "hotfix"]
|
||||||
|
},
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["open", "in-progress", "done", "hold", "cancelled"]
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"]
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"relationships": {
|
||||||
|
"$ref": "#/definitions/IssueRelationships"
|
||||||
|
},
|
||||||
|
"dueStart": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||||
|
},
|
||||||
|
"dueEnd": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
|
||||||
|
},
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"body": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"priority",
|
||||||
|
"created",
|
||||||
|
"relationships",
|
||||||
|
"description",
|
||||||
|
"body"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"IssueRelationships": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
"^(dependsOn|relatesTo|blocks)$": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"Module": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"path": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"BugzillaTrackerMapping": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"module": { "type": "string" },
|
||||||
|
"product": { "type": "string" },
|
||||||
|
"component": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["module", "product", "component"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
|
||||||
|
"BugzillaTracker": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": { "type": "string" },
|
||||||
|
"mappings": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/BugzillaTrackerMapping" },
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url", "mappings"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,15 @@ import * as fs from "fs"
|
||||||
|
|
||||||
import { simpleParser } from "mailparser"
|
import { simpleParser } from "mailparser"
|
||||||
|
|
||||||
import { parseIssue, Issue } from "./issue"
|
import { parseIssue, Issue } from "./issue.js"
|
||||||
import { parseSprints, Sprint } from "./sprint"
|
import { parseSprints, Sprint } from "./sprint.js"
|
||||||
import { parseModules, parseBugzillaTracker, Module, BugzillaTracker } from "./tracker"
|
import { parseModules, parseBugzillaTracker, Module, BugzillaTracker } from "./tracker.js"
|
||||||
import { serializeTodoFile } from "./serializer"
|
import { serializeTodoFile } from "./serializer.js"
|
||||||
|
|
||||||
import * as schema from "./file.schema.json"
|
import schema from "./file.schema.json" with { type: "json" }
|
||||||
|
|
||||||
import Ajv from "ajv"
|
import Ajv from "ajv"
|
||||||
|
const ajv = new Ajv.default({ allErrors: true })
|
||||||
const ajv = new Ajv({ allErrors: true })
|
|
||||||
|
|
||||||
const validateFile = ajv.compile(schema)
|
const validateFile = ajv.compile(schema)
|
||||||
|
|
||||||
21
src/git.d.ts
vendored
Normal file
21
src/git.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export declare function getCurrentBranch(cwd?: string): string;
|
||||||
|
export declare function isClean(cwd?: string): boolean;
|
||||||
|
export declare function commitFile(path: string, message: string, cwd?: string): void;
|
||||||
|
export declare function commitFileWithBody(path: string, header: string, body: string, cwd?: string): void;
|
||||||
|
export declare function branchExists(branch: string, cwd?: string): boolean;
|
||||||
|
export declare function parseIssueBranch(branch: string): {
|
||||||
|
type: string;
|
||||||
|
id: number;
|
||||||
|
} | null;
|
||||||
|
export interface GitCommit {
|
||||||
|
hash: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
export declare function getCommitsSinceDiverge(base?: string, cwd?: string): GitCommit[];
|
||||||
|
export declare function getCommitsFromRef(refSpec: string, cwd?: string): GitCommit[];
|
||||||
|
export declare function getAllCommits(branch: string, cwd?: string): GitCommit[];
|
||||||
|
export declare function parseTodoTransition(subject: string): {
|
||||||
|
issueId: number;
|
||||||
|
status: string;
|
||||||
|
} | null;
|
||||||
77
src/git.js
Normal file
77
src/git.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Git helpers for branch validation and commit operations
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
export function getCurrentBranch(cwd = process.cwd()) {
|
||||||
|
return execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim();
|
||||||
|
}
|
||||||
|
export function isClean(cwd = process.cwd()) {
|
||||||
|
const status = execSync("git status --porcelain", { cwd, encoding: "utf-8" }).trim();
|
||||||
|
return status === "";
|
||||||
|
}
|
||||||
|
export function commitFile(path, message, cwd = process.cwd()) {
|
||||||
|
execSync(`git add ${path}`, { cwd });
|
||||||
|
execSync(`git commit -m ${JSON.stringify(message)}`, { cwd });
|
||||||
|
}
|
||||||
|
export function commitFileWithBody(path, header, body, cwd = process.cwd()) {
|
||||||
|
execSync(`git add ${path}`, { cwd });
|
||||||
|
const msg = `${header}\n\n${body}`;
|
||||||
|
execSync(`git commit -m ${JSON.stringify(msg)}`, { cwd });
|
||||||
|
}
|
||||||
|
export function branchExists(branch, cwd = process.cwd()) {
|
||||||
|
try {
|
||||||
|
execSync(`git rev-parse --verify ${branch}`, { cwd, stdio: "pipe" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse the issue branch name into type and ID
|
||||||
|
export function parseIssueBranch(branch) {
|
||||||
|
const match = branch.match(/^(feature|bugfix|hotfix)\/(\d+)$/);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
return { type: match[1], id: Number(match[2]) };
|
||||||
|
}
|
||||||
|
// Get commits on current branch since it diverged from a base branch
|
||||||
|
export function getCommitsSinceDiverge(base = "develop", cwd = process.cwd()) {
|
||||||
|
return parseGitLog(`${base}..HEAD`, cwd);
|
||||||
|
}
|
||||||
|
// Get commits from a ref spec (e.g. HEAD~3..HEAD)
|
||||||
|
export function getCommitsFromRef(refSpec, cwd = process.cwd()) {
|
||||||
|
return parseGitLog(refSpec, cwd);
|
||||||
|
}
|
||||||
|
// Get all commits on a branch
|
||||||
|
export function getAllCommits(branch, cwd = process.cwd()) {
|
||||||
|
return parseGitLog(branch, cwd);
|
||||||
|
}
|
||||||
|
function parseGitLog(range, cwd) {
|
||||||
|
const SEP = "---COMMIT-SEP---";
|
||||||
|
const format = `%H%n%s%n%b%n${SEP}`;
|
||||||
|
let output;
|
||||||
|
try {
|
||||||
|
output = execSync(`git log ${range} --format=${JSON.stringify(format)}`, { cwd, encoding: "utf-8" });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const commits = [];
|
||||||
|
const entries = output.split(SEP).filter(e => e.trim());
|
||||||
|
for (const entry of entries) {
|
||||||
|
const lines = entry.trim().split("\n");
|
||||||
|
if (lines.length < 2)
|
||||||
|
continue;
|
||||||
|
commits.push({
|
||||||
|
hash: lines[0],
|
||||||
|
subject: lines[1],
|
||||||
|
body: lines.slice(2).join("\n").trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
// Check if a commit subject is a todo transition
|
||||||
|
export function parseTodoTransition(subject) {
|
||||||
|
const match = subject.match(/^todo\((\d+)\):\s*(\S+)/);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
return { issueId: Number(match[1]), status: match[2] };
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ export function commitFile(path: string, message: string, cwd = process.cwd()):
|
||||||
export function commitFileWithBody(path: string, header: string, body: string, cwd = process.cwd()): void {
|
export function commitFileWithBody(path: string, header: string, body: string, cwd = process.cwd()): void {
|
||||||
execSync(`git add ${path}`, { cwd })
|
execSync(`git add ${path}`, { cwd })
|
||||||
const msg = `${header}\n\n${body}`
|
const msg = `${header}\n\n${body}`
|
||||||
execSync(`git commit -m ${JSON.stringify(msg)}`, { cwd })
|
execSync(`git commit -F -`, { cwd, input: msg })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function branchExists(branch: string, cwd = process.cwd()): boolean {
|
export function branchExists(branch: string, cwd = process.cwd()): boolean {
|
||||||
|
|
@ -87,9 +87,22 @@ function parseGitLog(range: string, cwd: string): GitCommit[] {
|
||||||
return commits
|
return commits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for dirty or modified submodules
|
||||||
|
export function getDirtySubmodules(cwd = process.cwd()): string[] {
|
||||||
|
try {
|
||||||
|
const output = execSync("git submodule status", { cwd, encoding: "utf-8" }).trim()
|
||||||
|
if (!output) return []
|
||||||
|
return output.split("\n")
|
||||||
|
.filter(line => line.startsWith("+") || line.startsWith("-"))
|
||||||
|
.map(line => line.trim().split(/\s+/)[1])
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if a commit subject is a todo transition
|
// Check if a commit subject is a todo transition
|
||||||
export function parseTodoTransition(subject: string): { issueId: number; status: string } | null {
|
export function parseTodoTransition(subject: string): { issueId: number; status: string } | null {
|
||||||
const match = subject.match(/^todo\((\d+)\):\s*(\S+)/)
|
const match = subject.match(/^todo\((\d+)\):\s*([a-z-]+)/)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
return { issueId: Number(match[1]), status: match[2] }
|
return { issueId: Number(match[1]), status: match[2] }
|
||||||
}
|
}
|
||||||
24
src/issue.d.ts
vendored
Normal file
24
src/issue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export type RelationshipKind = "dependsOn" | "relatesTo" | "blocks";
|
||||||
|
export interface IssueRelationships {
|
||||||
|
[kind: string]: number[];
|
||||||
|
}
|
||||||
|
export type IssueType = "feature" | "bugfix" | "hotfix";
|
||||||
|
export type IssueStatus = "open" | "in-progress" | "done" | "hold" | "cancelled";
|
||||||
|
export type IssuePriority = "low" | "medium" | "high";
|
||||||
|
export interface Issue {
|
||||||
|
id: number;
|
||||||
|
type: IssueType;
|
||||||
|
title: string;
|
||||||
|
status: IssueStatus;
|
||||||
|
priority: IssuePriority;
|
||||||
|
created: string;
|
||||||
|
module?: string;
|
||||||
|
relationships: IssueRelationships;
|
||||||
|
dueStart?: string;
|
||||||
|
dueEnd?: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
export declare function validateStatusTransition(from: IssueStatus, to: IssueStatus): string | null;
|
||||||
|
export declare function parseRelationships(text: string): IssueRelationships;
|
||||||
|
export declare function parseIssue(text: string): Issue;
|
||||||
89
src/issue.js
Normal file
89
src/issue.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// mime-todo/lib/issue.ts
|
||||||
|
// Valid status transitions
|
||||||
|
const VALID_TRANSITIONS = {
|
||||||
|
"open": ["in-progress", "hold", "cancelled"],
|
||||||
|
"in-progress": ["done", "hold", "open", "cancelled"],
|
||||||
|
"hold": ["open", "in-progress", "cancelled"],
|
||||||
|
"done": ["open"],
|
||||||
|
"cancelled": ["open"],
|
||||||
|
};
|
||||||
|
export function validateStatusTransition(from, to) {
|
||||||
|
if (from === to)
|
||||||
|
return null; // no-op is fine
|
||||||
|
const allowed = VALID_TRANSITIONS[from];
|
||||||
|
if (!allowed?.includes(to)) {
|
||||||
|
return `Invalid status transition: ${from} → ${to}. Allowed from ${from}: ${allowed?.join(", ") ?? "none"}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
export function parseRelationships(text) {
|
||||||
|
const relationships = {};
|
||||||
|
// Handle empty relationships
|
||||||
|
if (!text || text.trim() === "") {
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
// Simple parsing for format like "dependsOn:3"
|
||||||
|
const parts = text.split(/,\s*/).filter(p => p.trim() !== "");
|
||||||
|
for (const part of parts) {
|
||||||
|
const [key, value] = part.split(":").map(s => s.trim());
|
||||||
|
if (key && value) {
|
||||||
|
relationships[key] = value.split(/[\s]+/).map(Number).filter(n => !isNaN(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
export function parseIssue(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const issue = {};
|
||||||
|
let inDescription = false;
|
||||||
|
const descLines = [];
|
||||||
|
const bodyLines = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!inDescription) {
|
||||||
|
if (line.startsWith("ID:"))
|
||||||
|
issue.id = Number(line.slice(3).trim());
|
||||||
|
else if (line.startsWith("Type:"))
|
||||||
|
issue.type = line.slice(5).trim();
|
||||||
|
else if (line.startsWith("Title:"))
|
||||||
|
issue.title = line.slice(6).trim();
|
||||||
|
else if (line.startsWith("Status:"))
|
||||||
|
issue.status = line.slice(7).trim();
|
||||||
|
else if (line.startsWith("Priority:"))
|
||||||
|
issue.priority = line.slice(9).trim();
|
||||||
|
else if (line.startsWith("Created:"))
|
||||||
|
issue.created = line.slice(8).trim();
|
||||||
|
else if (line.startsWith("DueStart:"))
|
||||||
|
issue.dueStart = line.slice(9).trim();
|
||||||
|
else if (line.startsWith("DueEnd:"))
|
||||||
|
issue.dueEnd = line.slice(7).trim();
|
||||||
|
else if (line.startsWith("Module:"))
|
||||||
|
issue.module = line.slice("Module:".length).trim();
|
||||||
|
else if (line.startsWith("Relationships:")) {
|
||||||
|
const rel = line.slice("Relationships:".length).trim();
|
||||||
|
issue.relationships = parseRelationships(rel);
|
||||||
|
}
|
||||||
|
else if (line.startsWith("Description:")) {
|
||||||
|
inDescription = true;
|
||||||
|
descLines.push(line.slice("Description:".length).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Description continuation
|
||||||
|
if (line.trim() === "") {
|
||||||
|
// blank line ends description
|
||||||
|
inDescription = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s+/.test(line)) {
|
||||||
|
descLines.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// first non-indented line ends description
|
||||||
|
inDescription = false;
|
||||||
|
bodyLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issue.description = descLines.join("\n");
|
||||||
|
issue.body = bodyLines.join("\n");
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
@ -75,10 +75,10 @@ export function parseIssue(text: string): Issue {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!inDescription) {
|
if (!inDescription) {
|
||||||
if (line.startsWith("ID:")) issue.id = Number(line.slice(3).trim())
|
if (line.startsWith("ID:")) issue.id = Number(line.slice(3).trim())
|
||||||
else if (line.startsWith("Type:")) issue.type = line.slice(5).trim()
|
else if (line.startsWith("Type:")) issue.type = line.slice(5).trim() as IssueType
|
||||||
else if (line.startsWith("Title:")) issue.title = line.slice(6).trim()
|
else if (line.startsWith("Title:")) issue.title = line.slice(6).trim()
|
||||||
else if (line.startsWith("Status:")) issue.status = line.slice(7).trim()
|
else if (line.startsWith("Status:")) issue.status = line.slice(7).trim() as IssueStatus
|
||||||
else if (line.startsWith("Priority:")) issue.priority = line.slice(9).trim()
|
else if (line.startsWith("Priority:")) issue.priority = line.slice(9).trim() as IssuePriority
|
||||||
else if (line.startsWith("Created:")) issue.created = line.slice(8).trim()
|
else if (line.startsWith("Created:")) issue.created = line.slice(8).trim()
|
||||||
else if (line.startsWith("DueStart:")) issue.dueStart = line.slice(9).trim()
|
else if (line.startsWith("DueStart:")) issue.dueStart = line.slice(9).trim()
|
||||||
else if (line.startsWith("DueEnd:")) issue.dueEnd = line.slice(7).trim()
|
else if (line.startsWith("DueEnd:")) issue.dueEnd = line.slice(7).trim()
|
||||||
11
src/serializer.d.ts
vendored
Normal file
11
src/serializer.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { TodoFile } from "./file.js";
|
||||||
|
import type { Issue } from "./issue.js";
|
||||||
|
import type { Sprint } from "./sprint.js";
|
||||||
|
import type { Module, BugzillaTracker } from "./tracker.js";
|
||||||
|
export declare function wordWrap(text: string, maxCol: number, firstPrefixLen: number, contIndentLen: number): string[];
|
||||||
|
export declare function serializeRelationships(rels: Issue["relationships"]): string;
|
||||||
|
export declare function serializeSprints(sprints: Sprint[]): string;
|
||||||
|
export declare function serializeIssue(issue: Issue): string;
|
||||||
|
export declare function serializeModules(modules: Module[]): string;
|
||||||
|
export declare function serializeBugzillaTracker(tracker: BugzillaTracker): string;
|
||||||
|
export declare function serializeTodoFile(todo: TodoFile): string;
|
||||||
137
src/serializer.js
Normal file
137
src/serializer.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// Word-wrap text to fit within maxCol, respecting prefix widths
|
||||||
|
// Returns array of lines (without prefix/indent — caller adds those)
|
||||||
|
export function wordWrap(text, maxCol, firstPrefixLen, contIndentLen) {
|
||||||
|
const result = [];
|
||||||
|
// Split on existing newlines first, then wrap each paragraph
|
||||||
|
const paragraphs = text.split("\n");
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
const words = para.split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) {
|
||||||
|
result.push("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = "";
|
||||||
|
const width = result.length === 0 ? maxCol - firstPrefixLen : maxCol - contIndentLen;
|
||||||
|
for (const word of words) {
|
||||||
|
const lineWidth = result.length === 0 && line === ""
|
||||||
|
? maxCol - firstPrefixLen
|
||||||
|
: maxCol - contIndentLen;
|
||||||
|
if (line === "") {
|
||||||
|
line = word;
|
||||||
|
}
|
||||||
|
else if (line.length + 1 + word.length <= lineWidth) {
|
||||||
|
line += " " + word;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result.push(line);
|
||||||
|
line = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line)
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
return result.length > 0 ? result : [""];
|
||||||
|
}
|
||||||
|
export function serializeRelationships(rels) {
|
||||||
|
const parts = [];
|
||||||
|
for (const [kind, ids] of Object.entries(rels)) {
|
||||||
|
if (ids?.length) {
|
||||||
|
parts.push(`${kind}:${ids.join(" ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
export function serializeSprints(sprints) {
|
||||||
|
if (sprints.length === 0)
|
||||||
|
return "Sprints:";
|
||||||
|
const lines = ["Sprints:"];
|
||||||
|
for (const sprint of sprints) {
|
||||||
|
lines.push(` - Name: ${sprint.name}`);
|
||||||
|
lines.push(` Range: ${sprint.start}..${sprint.end}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
export function serializeIssue(issue) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`ID: ${issue.id}`);
|
||||||
|
lines.push(`Type: ${issue.type}`);
|
||||||
|
lines.push(`Title: ${issue.title}`);
|
||||||
|
lines.push(`Status: ${issue.status}`);
|
||||||
|
lines.push(`Priority: ${issue.priority}`);
|
||||||
|
lines.push(`Created: ${issue.created}`);
|
||||||
|
if (issue.module)
|
||||||
|
lines.push(`Module: ${issue.module}`);
|
||||||
|
if (issue.dueStart)
|
||||||
|
lines.push(`DueStart: ${issue.dueStart}`);
|
||||||
|
if (issue.dueEnd)
|
||||||
|
lines.push(`DueEnd: ${issue.dueEnd}`);
|
||||||
|
lines.push(`Relationships: ${serializeRelationships(issue.relationships)}`);
|
||||||
|
// Description with word-wrap at 80 columns, continuation indented to column 14
|
||||||
|
const INDENT = " "; // 13 spaces — aligns with text after "Description: "
|
||||||
|
const MAX_COL = 80;
|
||||||
|
const firstPrefix = "Description: ";
|
||||||
|
if (issue.description) {
|
||||||
|
const wrapped = wordWrap(issue.description, MAX_COL, firstPrefix.length, INDENT.length);
|
||||||
|
lines.push(`${firstPrefix}${wrapped[0]}`);
|
||||||
|
for (let i = 1; i < wrapped.length; i++) {
|
||||||
|
lines.push(`${INDENT}${wrapped[i]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push("Description:");
|
||||||
|
}
|
||||||
|
if (issue.body) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(issue.body);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
export function serializeModules(modules) {
|
||||||
|
const lines = ["Modules:"];
|
||||||
|
for (const m of modules) {
|
||||||
|
lines.push(` - Name: ${m.name}`);
|
||||||
|
if (m.path)
|
||||||
|
lines.push(` Path: ${m.path}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
export function serializeBugzillaTracker(tracker) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`URL: ${tracker.url}`);
|
||||||
|
lines.push("Mappings:");
|
||||||
|
for (const m of tracker.mappings) {
|
||||||
|
lines.push(` - Module: ${m.module}`);
|
||||||
|
lines.push(` Product: ${m.product}`);
|
||||||
|
lines.push(` Component: ${m.component}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
export function serializeTodoFile(todo) {
|
||||||
|
const parts = [];
|
||||||
|
// Sprints part
|
||||||
|
parts.push("--ISSUE");
|
||||||
|
parts.push("Content-Type: application/sprints");
|
||||||
|
parts.push(serializeSprints(todo.sprints));
|
||||||
|
// Modules part
|
||||||
|
if (todo.modules?.length) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push("--ISSUE");
|
||||||
|
parts.push("Content-Type: application/modules");
|
||||||
|
parts.push(serializeModules(todo.modules));
|
||||||
|
}
|
||||||
|
// Bugzilla tracker part
|
||||||
|
if (todo.bugzilla) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push("--ISSUE");
|
||||||
|
parts.push("Content-Type: application/bugzilla");
|
||||||
|
parts.push(serializeBugzillaTracker(todo.bugzilla));
|
||||||
|
}
|
||||||
|
// Issue parts
|
||||||
|
for (const issue of todo.issues) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push("--ISSUE");
|
||||||
|
parts.push("Content-Type: application/issue");
|
||||||
|
parts.push(serializeIssue(issue));
|
||||||
|
}
|
||||||
|
return parts.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Inverse of the parser — serializes TodoFile back to MIME TODO format
|
// Inverse of the parser — serializes TodoFile back to MIME TODO format
|
||||||
import type { TodoFile } from "./file"
|
import type { TodoFile } from "./file.js"
|
||||||
import type { Issue } from "./issue"
|
import type { Issue } from "./issue.js"
|
||||||
import type { Sprint } from "./sprint"
|
import type { Sprint } from "./sprint.js"
|
||||||
import type { Module, BugzillaTracker } from "./tracker"
|
import type { Module, BugzillaTracker } from "./tracker.js"
|
||||||
|
|
||||||
// Word-wrap text to fit within maxCol, respecting prefix widths
|
// Word-wrap text to fit within maxCol, respecting prefix widths
|
||||||
// Returns array of lines (without prefix/indent — caller adds those)
|
// Returns array of lines (without prefix/indent — caller adds those)
|
||||||
6
src/sprint.d.ts
vendored
Normal file
6
src/sprint.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface Sprint {
|
||||||
|
name: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
export declare function parseSprints(text: string): Sprint[];
|
||||||
33
src/sprint.js
Normal file
33
src/sprint.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export function parseSprints(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const sprints = [];
|
||||||
|
let current = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
// Start of sprint entry (must not trim indentation)
|
||||||
|
if (/^\s*-\s*(Name:.*)?$/.test(line)) {
|
||||||
|
if (current)
|
||||||
|
sprints.push(current);
|
||||||
|
current = {};
|
||||||
|
const match = line.match(/^\s*-\s*Name:\s*(.*)$/);
|
||||||
|
if (match)
|
||||||
|
current.name = match[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Key-value pairs (must be indented)
|
||||||
|
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
|
||||||
|
if (kv && current) {
|
||||||
|
const key = kv[1];
|
||||||
|
const value = kv[2];
|
||||||
|
if (key === "Name")
|
||||||
|
current.name = value;
|
||||||
|
if (key === "Range") {
|
||||||
|
const [start, end] = value.split("..");
|
||||||
|
current.start = start;
|
||||||
|
current.end = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current)
|
||||||
|
sprints.push(current);
|
||||||
|
return sprints;
|
||||||
|
}
|
||||||
15
src/tracker.d.ts
vendored
Normal file
15
src/tracker.d.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export interface Module {
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
export interface BugzillaTrackerMapping {
|
||||||
|
module: string;
|
||||||
|
product: string;
|
||||||
|
component: string;
|
||||||
|
}
|
||||||
|
export interface BugzillaTracker {
|
||||||
|
url: string;
|
||||||
|
mappings: BugzillaTrackerMapping[];
|
||||||
|
}
|
||||||
|
export declare function parseModules(text: string): Module[];
|
||||||
|
export declare function parseBugzillaTracker(text: string): BugzillaTracker;
|
||||||
77
src/tracker.js
Normal file
77
src/tracker.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// Module and tracker part parsers
|
||||||
|
// application/modules — defines the repo's logical module structure
|
||||||
|
// application/bugzilla — maps modules to Bugzilla products/components
|
||||||
|
export function parseModules(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const modules = [];
|
||||||
|
let current = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip the "Modules:" header
|
||||||
|
if (/^\s*Modules:\s*$/.test(line))
|
||||||
|
continue;
|
||||||
|
// Start of module entry
|
||||||
|
if (/^\s*-\s*(Name:.*)?$/.test(line)) {
|
||||||
|
if (current?.name) {
|
||||||
|
modules.push(current);
|
||||||
|
}
|
||||||
|
current = {};
|
||||||
|
const match = line.match(/^\s*-\s*Name:\s*(.*)$/);
|
||||||
|
if (match)
|
||||||
|
current.name = match[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
|
||||||
|
if (kv && current) {
|
||||||
|
if (kv[1] === "Name")
|
||||||
|
current.name = kv[2];
|
||||||
|
else if (kv[1] === "Path")
|
||||||
|
current.path = kv[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current?.name) {
|
||||||
|
modules.push(current);
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
|
export function parseBugzillaTracker(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const tracker = {};
|
||||||
|
const mappings = [];
|
||||||
|
let inMappings = false;
|
||||||
|
let current = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!inMappings) {
|
||||||
|
if (line.startsWith("URL:")) {
|
||||||
|
tracker.url = line.slice("URL:".length).trim();
|
||||||
|
}
|
||||||
|
else if (line.startsWith("Mappings:")) {
|
||||||
|
inMappings = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*-\s*(Module:.*)?$/.test(line)) {
|
||||||
|
if (current?.module && current?.product && current?.component) {
|
||||||
|
mappings.push(current);
|
||||||
|
}
|
||||||
|
current = {};
|
||||||
|
const match = line.match(/^\s*-\s*Module:\s*(.*)$/);
|
||||||
|
if (match)
|
||||||
|
current.module = match[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kv = line.match(/^\s+([A-Za-z][A-Za-z0-9]*):\s*(.*)$/);
|
||||||
|
if (kv && current) {
|
||||||
|
if (kv[1] === "Module")
|
||||||
|
current.module = kv[2];
|
||||||
|
else if (kv[1] === "Product")
|
||||||
|
current.product = kv[2];
|
||||||
|
else if (kv[1] === "Component")
|
||||||
|
current.component = kv[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current?.module && current?.product && current?.component) {
|
||||||
|
mappings.push(current);
|
||||||
|
}
|
||||||
|
tracker.mappings = mappings;
|
||||||
|
return tracker;
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,9 @@ import {
|
||||||
typeToBugzilla,
|
typeToBugzilla,
|
||||||
issueToBugzillaCreate,
|
issueToBugzillaCreate,
|
||||||
resolveProductComponent,
|
resolveProductComponent,
|
||||||
} from "../../../lib/bugzilla/fieldmap"
|
} from "../../../src/bugzilla/fieldmap.js"
|
||||||
import type { Issue } from "../../../lib/issue"
|
import type { Issue } from "../../../src/issue.js"
|
||||||
import type { BugzillaTracker } from "../../../lib/tracker"
|
import type { BugzillaTracker } from "../../../src/tracker.js"
|
||||||
|
|
||||||
describe("status mapping", () => {
|
describe("status mapping", () => {
|
||||||
it("maps TODO statuses to Bugzilla", () => {
|
it("maps TODO statuses to Bugzilla", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"
|
||||||
import {
|
import {
|
||||||
normalizeRemoteUrl,
|
normalizeRemoteUrl,
|
||||||
buildOriginUrl,
|
buildOriginUrl,
|
||||||
} from "../../../lib/bugzilla/origin"
|
} from "../../../src/bugzilla/origin.js"
|
||||||
|
|
||||||
describe("normalizeRemoteUrl", () => {
|
describe("normalizeRemoteUrl", () => {
|
||||||
it("passes HTTPS URLs through", () => {
|
it("passes HTTPS URLs through", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { CLICommand } from "../../lib/cli/CLICommand"
|
import { CLICommand } from "../../src/cli/CLICommand.js"
|
||||||
|
|
||||||
class TestLeafCommand extends CLICommand {
|
class TestLeafCommand extends CLICommand {
|
||||||
readonly name = "leaf"
|
readonly name = "leaf"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { preprocessTODO, parseTodoFile } from "../../lib/file"
|
import { preprocessTODO, parseTodoFile } from "../../src/file.js"
|
||||||
|
|
||||||
|
|
||||||
describe("parseTodoFile", () => {
|
describe("parseTodoFile", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { parseIssue, validateStatusTransition } from "../../lib/issue"
|
import { parseIssue, validateStatusTransition } from "../../src/issue.js"
|
||||||
|
|
||||||
describe("parseIssue", () => {
|
describe("parseIssue", () => {
|
||||||
it("parses all required fields", () => {
|
it("parses all required fields", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { parseTodoFile } from "../../lib/file"
|
import { parseTodoFile } from "../../src/file.js"
|
||||||
import {
|
import {
|
||||||
serializeTodoFile,
|
serializeTodoFile,
|
||||||
serializeIssue,
|
serializeIssue,
|
||||||
serializeSprints,
|
serializeSprints,
|
||||||
serializeRelationships,
|
serializeRelationships,
|
||||||
} from "../../lib/serializer"
|
} from "../../src/serializer.js"
|
||||||
|
|
||||||
describe("serializeRelationships", () => {
|
describe("serializeRelationships", () => {
|
||||||
it("serializes empty relationships", () => {
|
it("serializes empty relationships", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { parseSprints } from "../../lib/sprint"
|
import { parseSprints } from "../../src/sprint.js"
|
||||||
|
|
||||||
describe("parseSprints", () => {
|
describe("parseSprints", () => {
|
||||||
it("parses compact and expanded sprint entries", () => {
|
it("parses compact and expanded sprint entries", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import * as fs from "fs"
|
import * as fs from "fs"
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
import { parseModules, parseBugzillaTracker } from "../../lib/tracker"
|
import { parseModules, parseBugzillaTracker } from "../../src/tracker.js"
|
||||||
import { parseTodoFile } from "../../lib/file"
|
import { parseTodoFile } from "../../src/file.js"
|
||||||
import { serializeTodoFile, serializeModules, serializeBugzillaTracker } from "../../lib/serializer"
|
import { serializeTodoFile, serializeModules, serializeBugzillaTracker } from "../../src/serializer.js"
|
||||||
|
|
||||||
describe("parseModules", () => {
|
describe("parseModules", () => {
|
||||||
it("parses modules list", () => {
|
it("parses modules list", () => {
|
||||||
|
|
|
||||||
7
tsconfig.bin.json
Normal file
7
tsconfig.bin.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "bin"
|
||||||
|
},
|
||||||
|
"include": ["bin/**/*.ts"]
|
||||||
|
}
|
||||||
18
tsconfig.debug.json
Normal file
18
tsconfig.debug.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"sourceMap": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"incremental": true,
|
||||||
|
"outDir": "build/debug"
|
||||||
|
},
|
||||||
|
"watchOptions": {
|
||||||
|
"watchFile": "useFsEvents",
|
||||||
|
"watchDirectory": "useFsEvents",
|
||||||
|
"fallbackPolling": "dynamicPriority",
|
||||||
|
"synchronousWatchDirectory": true,
|
||||||
|
"excludeDirectories": ["**/node_modules", "build", "lib"]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"strict": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": false,
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["es2022"]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
tsconfig.lib.json
Normal file
7
tsconfig.lib.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue