@bdsqqq/lnr-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +34 -0
- package/src/cli.ts +29 -0
- package/src/commands/auth.ts +53 -0
- package/src/commands/config.ts +64 -0
- package/src/commands/cycles.ts +134 -0
- package/src/commands/issues.ts +390 -0
- package/src/commands/me.ts +110 -0
- package/src/commands/projects.ts +214 -0
- package/src/commands/search.ts +52 -0
- package/src/commands/teams.ts +115 -0
- package/src/lib/error.ts +43 -0
- package/src/lib/output.ts +99 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bdsqqq/lnr-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "cli for linear issue tracking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "src/cli.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"lnr": "./src/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/bdsqqq/lnr.git",
|
|
17
|
+
"directory": "packages/cli"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "bdsqqq",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"dev": "bun run src/cli.ts",
|
|
23
|
+
"check": "tsc --noEmit",
|
|
24
|
+
"build": "bun build ./src/cli.ts --compile --outfile lnr"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@bdsqqq/lnr-core": "workspace:*",
|
|
28
|
+
"chalk": "^5.6.2",
|
|
29
|
+
"commander": "^14.0.2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"typescript": "^5"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { registerAuthCommand } from "./commands/auth";
|
|
5
|
+
import { registerIssuesCommand } from "./commands/issues";
|
|
6
|
+
import { registerTeamsCommand } from "./commands/teams";
|
|
7
|
+
import { registerProjectsCommand } from "./commands/projects";
|
|
8
|
+
import { registerCyclesCommand } from "./commands/cycles";
|
|
9
|
+
import { registerMeCommand } from "./commands/me";
|
|
10
|
+
import { registerSearchCommand } from "./commands/search";
|
|
11
|
+
import { registerConfigCommand } from "./commands/config";
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name("lnr")
|
|
17
|
+
.description("command-line interface for Linear")
|
|
18
|
+
.version("0.1.0");
|
|
19
|
+
|
|
20
|
+
registerAuthCommand(program);
|
|
21
|
+
registerIssuesCommand(program);
|
|
22
|
+
registerTeamsCommand(program);
|
|
23
|
+
registerProjectsCommand(program);
|
|
24
|
+
registerCyclesCommand(program);
|
|
25
|
+
registerMeCommand(program);
|
|
26
|
+
registerSearchCommand(program);
|
|
27
|
+
registerConfigCommand(program);
|
|
28
|
+
|
|
29
|
+
program.parse();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
setApiKey,
|
|
4
|
+
clearApiKey,
|
|
5
|
+
getApiKey,
|
|
6
|
+
createClientWithKey,
|
|
7
|
+
getViewer,
|
|
8
|
+
} from "@bdsqqq/lnr-core";
|
|
9
|
+
import { exitWithError, EXIT_CODES } from "../lib/error";
|
|
10
|
+
|
|
11
|
+
export function registerAuthCommand(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command("auth [api-key]")
|
|
14
|
+
.description("authenticate with Linear API")
|
|
15
|
+
.option("--whoami", "show current authenticated user")
|
|
16
|
+
.option("--logout", "clear stored credentials")
|
|
17
|
+
.action(async (apiKey: string | undefined, options: { whoami?: boolean; logout?: boolean }) => {
|
|
18
|
+
if (options.logout) {
|
|
19
|
+
clearApiKey();
|
|
20
|
+
console.log("logged out");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (options.whoami) {
|
|
25
|
+
const storedKey = getApiKey();
|
|
26
|
+
if (!storedKey) {
|
|
27
|
+
exitWithError("not authenticated", "run: li auth <api-key>", EXIT_CODES.AUTH_ERROR);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const client = createClientWithKey(storedKey);
|
|
32
|
+
const viewer = await getViewer(client);
|
|
33
|
+
console.log(`${viewer.name} <${viewer.email}>`);
|
|
34
|
+
} catch {
|
|
35
|
+
exitWithError("invalid api key", "run: li auth <api-key>", EXIT_CODES.AUTH_ERROR);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!apiKey) {
|
|
41
|
+
exitWithError("api key required", "usage: li auth <api-key>");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const client = createClientWithKey(apiKey);
|
|
46
|
+
const viewer = await getViewer(client);
|
|
47
|
+
setApiKey(apiKey);
|
|
48
|
+
console.log(`authenticated as ${viewer.name}`);
|
|
49
|
+
} catch {
|
|
50
|
+
exitWithError("invalid api key", "get one from: https://linear.app/settings/account/security", EXIT_CODES.AUTH_ERROR);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
loadConfig,
|
|
4
|
+
getConfigValue,
|
|
5
|
+
setConfigValue,
|
|
6
|
+
type Config,
|
|
7
|
+
} from "@bdsqqq/lnr-core";
|
|
8
|
+
import { exitWithError } from "../lib/error";
|
|
9
|
+
|
|
10
|
+
const VALID_KEYS: (keyof Config)[] = ["api_key", "default_team", "output_format"];
|
|
11
|
+
|
|
12
|
+
export function registerConfigCommand(program: Command): void {
|
|
13
|
+
const configCmd = program
|
|
14
|
+
.command("config")
|
|
15
|
+
.description("view and manage configuration");
|
|
16
|
+
|
|
17
|
+
configCmd
|
|
18
|
+
.command("get <key>")
|
|
19
|
+
.description("get a config value")
|
|
20
|
+
.action((key: string) => {
|
|
21
|
+
if (!VALID_KEYS.includes(key as keyof Config)) {
|
|
22
|
+
exitWithError(`unknown config key: ${key}`, `valid keys: ${VALID_KEYS.join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const value = getConfigValue(key as keyof Config);
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
console.log("(not set)");
|
|
28
|
+
} else {
|
|
29
|
+
console.log(value);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
configCmd
|
|
34
|
+
.command("set <key> <value>")
|
|
35
|
+
.description("set a config value")
|
|
36
|
+
.action((key: string, value: string) => {
|
|
37
|
+
if (!VALID_KEYS.includes(key as keyof Config)) {
|
|
38
|
+
exitWithError(`unknown config key: ${key}`, `valid keys: ${VALID_KEYS.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (key === "output_format" && !["table", "json", "quiet"].includes(value)) {
|
|
42
|
+
exitWithError(`invalid output_format: ${value}`, "valid values: table, json, quiet");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setConfigValue(key as keyof Config, value as Config[keyof Config]);
|
|
46
|
+
console.log(`${key} = ${value}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
configCmd.action(() => {
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
if (Object.keys(config).length === 0) {
|
|
52
|
+
console.log("(no configuration set)");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [key, value] of Object.entries(config)) {
|
|
57
|
+
if (key === "api_key" && value) {
|
|
58
|
+
console.log(`${key} = ${(value as string).slice(0, 10)}...`);
|
|
59
|
+
} else {
|
|
60
|
+
console.log(`${key} = ${value}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
listCycles,
|
|
5
|
+
getCurrentCycle,
|
|
6
|
+
getCycleIssues,
|
|
7
|
+
} from "@bdsqqq/lnr-core";
|
|
8
|
+
import { handleApiError, exitWithError } from "../lib/error";
|
|
9
|
+
import {
|
|
10
|
+
outputJson,
|
|
11
|
+
outputQuiet,
|
|
12
|
+
outputTable,
|
|
13
|
+
getOutputFormat,
|
|
14
|
+
formatDate,
|
|
15
|
+
truncate,
|
|
16
|
+
type OutputOptions,
|
|
17
|
+
} from "../lib/output";
|
|
18
|
+
|
|
19
|
+
interface CyclesOptions extends OutputOptions {
|
|
20
|
+
team?: string;
|
|
21
|
+
json?: boolean;
|
|
22
|
+
quiet?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CycleOptions extends OutputOptions {
|
|
26
|
+
current?: boolean;
|
|
27
|
+
team?: string;
|
|
28
|
+
issues?: boolean;
|
|
29
|
+
json?: boolean;
|
|
30
|
+
quiet?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerCyclesCommand(program: Command): void {
|
|
34
|
+
program
|
|
35
|
+
.command("cycles")
|
|
36
|
+
.description("list cycles for a team")
|
|
37
|
+
.requiredOption("--team <key>", "team key (required)")
|
|
38
|
+
.option("--json", "output as json")
|
|
39
|
+
.option("--quiet", "output ids only")
|
|
40
|
+
.option("--verbose", "show detailed output")
|
|
41
|
+
.action(async (options: CyclesOptions) => {
|
|
42
|
+
try {
|
|
43
|
+
const client = getClient();
|
|
44
|
+
const cycles = await listCycles(client, options.team!);
|
|
45
|
+
|
|
46
|
+
if (cycles.length === 0) {
|
|
47
|
+
exitWithError(`team "${options.team}" not found`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const format = options.json ? "json" : options.quiet ? "quiet" : getOutputFormat(options);
|
|
51
|
+
|
|
52
|
+
if (format === "json") {
|
|
53
|
+
outputJson(cycles);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (format === "quiet") {
|
|
58
|
+
outputQuiet(cycles.map((c) => c.id));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
outputTable(cycles, [
|
|
63
|
+
{ header: "#", value: (c) => String(c.number), width: 4 },
|
|
64
|
+
{ header: "NAME", value: (c) => c.name ?? `Cycle ${c.number}`, width: 20 },
|
|
65
|
+
{ header: "START", value: (c) => formatDate(c.startsAt), width: 12 },
|
|
66
|
+
{ header: "END", value: (c) => formatDate(c.endsAt), width: 12 },
|
|
67
|
+
], options);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
handleApiError(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
program
|
|
74
|
+
.command("cycle")
|
|
75
|
+
.description("show cycle details")
|
|
76
|
+
.option("--current", "show current active cycle")
|
|
77
|
+
.requiredOption("--team <key>", "team key (required)")
|
|
78
|
+
.option("--issues", "list issues in the cycle")
|
|
79
|
+
.option("--json", "output as json")
|
|
80
|
+
.option("--quiet", "output ids only")
|
|
81
|
+
.option("--verbose", "show detailed output")
|
|
82
|
+
.action(async (options: CycleOptions) => {
|
|
83
|
+
if (!options.current) {
|
|
84
|
+
exitWithError("cycle identifier required", "use --current to show active cycle");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const client = getClient();
|
|
89
|
+
const cycle = await getCurrentCycle(client, options.team!);
|
|
90
|
+
|
|
91
|
+
if (!cycle) {
|
|
92
|
+
exitWithError("no active cycle", `team "${options.team}" has no current cycle`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const format = options.json ? "json" : options.quiet ? "quiet" : getOutputFormat(options);
|
|
96
|
+
|
|
97
|
+
if (options.issues) {
|
|
98
|
+
const issues = await getCycleIssues(client, options.team!);
|
|
99
|
+
|
|
100
|
+
if (format === "json") {
|
|
101
|
+
outputJson(issues);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (format === "quiet") {
|
|
106
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
outputTable(issues, [
|
|
111
|
+
{ header: "ID", value: (i) => i.identifier, width: 10 },
|
|
112
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
|
|
113
|
+
], options);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (format === "json") {
|
|
118
|
+
outputJson(cycle);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (format === "quiet") {
|
|
123
|
+
console.log(cycle.id);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`cycle ${cycle.number}: ${cycle.name ?? `Cycle ${cycle.number}`}`);
|
|
128
|
+
console.log(` start: ${formatDate(cycle.startsAt)}`);
|
|
129
|
+
console.log(` end: ${formatDate(cycle.endsAt)}`);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
handleApiError(error);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
listIssues,
|
|
5
|
+
getIssue,
|
|
6
|
+
createIssue,
|
|
7
|
+
updateIssue,
|
|
8
|
+
addComment,
|
|
9
|
+
priorityFromString,
|
|
10
|
+
getTeamStates,
|
|
11
|
+
getTeamLabels,
|
|
12
|
+
findTeamByKeyOrName,
|
|
13
|
+
getAvailableTeamKeys,
|
|
14
|
+
type Issue,
|
|
15
|
+
type ListIssuesFilter,
|
|
16
|
+
} from "@bdsqqq/lnr-core";
|
|
17
|
+
import { handleApiError, exitWithError, EXIT_CODES } from "../lib/error";
|
|
18
|
+
import {
|
|
19
|
+
outputJson,
|
|
20
|
+
outputQuiet,
|
|
21
|
+
outputTable,
|
|
22
|
+
getOutputFormat,
|
|
23
|
+
formatDate,
|
|
24
|
+
formatPriority,
|
|
25
|
+
truncate,
|
|
26
|
+
type OutputOptions,
|
|
27
|
+
type TableColumn,
|
|
28
|
+
} from "../lib/output";
|
|
29
|
+
|
|
30
|
+
interface ListOptions extends OutputOptions {
|
|
31
|
+
team?: string;
|
|
32
|
+
state?: string;
|
|
33
|
+
assignee?: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
project?: string;
|
|
36
|
+
json?: boolean;
|
|
37
|
+
quiet?: boolean;
|
|
38
|
+
verbose?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ShowOptions extends OutputOptions {
|
|
42
|
+
json?: boolean;
|
|
43
|
+
open?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface UpdateOptions {
|
|
47
|
+
state?: string;
|
|
48
|
+
assignee?: string;
|
|
49
|
+
priority?: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
comment?: string;
|
|
52
|
+
open?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CreateOptions {
|
|
56
|
+
team?: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
assignee?: string;
|
|
60
|
+
label?: string;
|
|
61
|
+
priority?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const issueColumns: TableColumn<Issue>[] = [
|
|
65
|
+
{ header: "ID", value: (i) => i.identifier, width: 10 },
|
|
66
|
+
{ header: "STATE", value: (i) => i.state ?? "-", width: 15 },
|
|
67
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
|
|
68
|
+
{ header: "ASSIGNEE", value: (i) => i.assignee ?? "-", width: 15 },
|
|
69
|
+
{ header: "PRIORITY", value: (i) => formatPriority(i.priority), width: 8 },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
async function handleListIssues(options: ListOptions): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const client = getClient();
|
|
75
|
+
const filter: ListIssuesFilter = {
|
|
76
|
+
team: options.team,
|
|
77
|
+
state: options.state,
|
|
78
|
+
assignee: options.assignee,
|
|
79
|
+
label: options.label,
|
|
80
|
+
project: options.project,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const issues = await listIssues(client, filter);
|
|
84
|
+
const format = options.json ? "json" : options.quiet ? "quiet" : getOutputFormat(options);
|
|
85
|
+
|
|
86
|
+
if (format === "json") {
|
|
87
|
+
outputJson(issues);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (format === "quiet") {
|
|
92
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
outputTable(issues, issueColumns, { verbose: options.verbose });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
handleApiError(error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handleShowIssue(identifier: string, options: ShowOptions): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
const client = getClient();
|
|
105
|
+
const issue = await getIssue(client, identifier);
|
|
106
|
+
|
|
107
|
+
if (!issue) {
|
|
108
|
+
exitWithError(`issue ${identifier} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.open) {
|
|
112
|
+
const { exec } = await import("child_process");
|
|
113
|
+
exec(`open "${issue.url}"`);
|
|
114
|
+
console.log(`opened ${issue.url}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const format = options.json ? "json" : getOutputFormat(options);
|
|
119
|
+
|
|
120
|
+
if (format === "json") {
|
|
121
|
+
outputJson({
|
|
122
|
+
...issue,
|
|
123
|
+
priority: formatPriority(issue.priority),
|
|
124
|
+
createdAt: formatDate(issue.createdAt),
|
|
125
|
+
updatedAt: formatDate(issue.updatedAt),
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log(`${issue.identifier}: ${issue.title}`);
|
|
131
|
+
console.log();
|
|
132
|
+
console.log(`state: ${issue.state ?? "-"}`);
|
|
133
|
+
console.log(`assignee: ${issue.assignee ?? "-"}`);
|
|
134
|
+
console.log(`priority: ${formatPriority(issue.priority)}`);
|
|
135
|
+
console.log(`created: ${formatDate(issue.createdAt)}`);
|
|
136
|
+
console.log(`updated: ${formatDate(issue.updatedAt)}`);
|
|
137
|
+
console.log(`url: ${issue.url}`);
|
|
138
|
+
|
|
139
|
+
if (issue.description) {
|
|
140
|
+
console.log();
|
|
141
|
+
console.log(issue.description);
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
handleApiError(error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function handleUpdateIssue(identifier: string, options: UpdateOptions): Promise<void> {
|
|
149
|
+
try {
|
|
150
|
+
const client = getClient();
|
|
151
|
+
const issue = await getIssue(client, identifier);
|
|
152
|
+
|
|
153
|
+
if (!issue) {
|
|
154
|
+
exitWithError(`issue ${identifier} not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.open) {
|
|
158
|
+
const { exec } = await import("child_process");
|
|
159
|
+
exec(`open "${issue.url}"`);
|
|
160
|
+
console.log(`opened ${issue.url}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.comment) {
|
|
165
|
+
await addComment(client, issue.id, options.comment);
|
|
166
|
+
console.log(`commented on ${identifier}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const updatePayload: Record<string, unknown> = {};
|
|
171
|
+
|
|
172
|
+
if (options.state) {
|
|
173
|
+
const rawIssue = await (client as unknown as { issue: (id: string) => Promise<{ team?: { id: string } | null } | null> }).issue(identifier);
|
|
174
|
+
const teamRef = rawIssue && 'team' in rawIssue ? await (rawIssue as unknown as { team: Promise<{ id: string } | null> }).team : null;
|
|
175
|
+
if (!teamRef) {
|
|
176
|
+
exitWithError("could not determine team for issue");
|
|
177
|
+
}
|
|
178
|
+
const states = await getTeamStates(client, teamRef.id);
|
|
179
|
+
const targetState = states.find(
|
|
180
|
+
(s) => s.name.toLowerCase() === options.state!.toLowerCase()
|
|
181
|
+
);
|
|
182
|
+
if (!targetState) {
|
|
183
|
+
const available = states.map((s) => s.name).join(", ");
|
|
184
|
+
exitWithError(
|
|
185
|
+
`state "${options.state}" not found`,
|
|
186
|
+
`available states: ${available}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
updatePayload.stateId = targetState.id;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.assignee) {
|
|
193
|
+
if (options.assignee === "@me") {
|
|
194
|
+
const viewer = await client.viewer;
|
|
195
|
+
updatePayload.assigneeId = viewer.id;
|
|
196
|
+
} else {
|
|
197
|
+
const users = await client.users({ filter: { email: { eq: options.assignee } } });
|
|
198
|
+
const user = users.nodes[0];
|
|
199
|
+
if (!user) {
|
|
200
|
+
exitWithError(`user "${options.assignee}" not found`);
|
|
201
|
+
}
|
|
202
|
+
updatePayload.assigneeId = user.id;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options.priority) {
|
|
207
|
+
updatePayload.priority = priorityFromString(options.priority);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (options.label) {
|
|
211
|
+
const isAdd = options.label.startsWith("+");
|
|
212
|
+
const isRemove = options.label.startsWith("-");
|
|
213
|
+
const labelName = isAdd || isRemove ? options.label.slice(1) : options.label;
|
|
214
|
+
|
|
215
|
+
const rawIssue = await (client as unknown as { issue: (id: string) => Promise<{ team?: { id: string } | null; labels: () => Promise<{ nodes: { id: string }[] }> } | null> }).issue(identifier);
|
|
216
|
+
const teamRef = rawIssue && 'team' in rawIssue ? await (rawIssue as unknown as { team: Promise<{ id: string } | null> }).team : null;
|
|
217
|
+
if (!teamRef) {
|
|
218
|
+
exitWithError("could not determine team for issue");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const labels = await getTeamLabels(client, teamRef.id);
|
|
222
|
+
const targetLabel = labels.find(
|
|
223
|
+
(l) => l.name.toLowerCase() === labelName.toLowerCase()
|
|
224
|
+
);
|
|
225
|
+
if (!targetLabel) {
|
|
226
|
+
const available = labels.map((l) => l.name).join(", ");
|
|
227
|
+
exitWithError(`label "${labelName}" not found`, `available labels: ${available}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const currentLabelsData = rawIssue ? await rawIssue.labels() : { nodes: [] };
|
|
231
|
+
const currentLabelIds = currentLabelsData.nodes.map((l) => l.id);
|
|
232
|
+
|
|
233
|
+
if (isRemove) {
|
|
234
|
+
updatePayload.labelIds = currentLabelIds.filter((id) => id !== targetLabel.id);
|
|
235
|
+
} else {
|
|
236
|
+
if (!currentLabelIds.includes(targetLabel.id)) {
|
|
237
|
+
updatePayload.labelIds = [...currentLabelIds, targetLabel.id];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
243
|
+
await updateIssue(client, issue.id, updatePayload);
|
|
244
|
+
console.log(`updated ${identifier}`);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
handleApiError(error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function handleCreateIssue(options: CreateOptions): Promise<void> {
|
|
252
|
+
if (!options.team) {
|
|
253
|
+
exitWithError("--team is required", "usage: li issue new --team ENG --title \"...\"");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!options.title) {
|
|
257
|
+
exitWithError("--title is required", "usage: li issue new --team ENG --title \"...\"");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const client = getClient();
|
|
262
|
+
const team = await findTeamByKeyOrName(client, options.team);
|
|
263
|
+
|
|
264
|
+
if (!team) {
|
|
265
|
+
const available = (await getAvailableTeamKeys(client)).join(", ");
|
|
266
|
+
exitWithError(`team "${options.team}" not found`, `available teams: ${available}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const createPayload: {
|
|
270
|
+
teamId: string;
|
|
271
|
+
title: string;
|
|
272
|
+
description?: string;
|
|
273
|
+
assigneeId?: string;
|
|
274
|
+
priority?: number;
|
|
275
|
+
labelIds?: string[];
|
|
276
|
+
} = {
|
|
277
|
+
teamId: team.id,
|
|
278
|
+
title: options.title,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (options.description) {
|
|
282
|
+
createPayload.description = options.description;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (options.assignee) {
|
|
286
|
+
if (options.assignee === "@me") {
|
|
287
|
+
const viewer = await client.viewer;
|
|
288
|
+
createPayload.assigneeId = viewer.id;
|
|
289
|
+
} else {
|
|
290
|
+
const users = await client.users({ filter: { email: { eq: options.assignee } } });
|
|
291
|
+
const user = users.nodes[0];
|
|
292
|
+
if (!user) {
|
|
293
|
+
exitWithError(`user "${options.assignee}" not found`);
|
|
294
|
+
}
|
|
295
|
+
createPayload.assigneeId = user.id;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (options.priority) {
|
|
300
|
+
createPayload.priority = priorityFromString(options.priority);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (options.label) {
|
|
304
|
+
const labels = await getTeamLabels(client, team.id);
|
|
305
|
+
const targetLabel = labels.find(
|
|
306
|
+
(l) => l.name.toLowerCase() === options.label!.toLowerCase()
|
|
307
|
+
);
|
|
308
|
+
if (!targetLabel) {
|
|
309
|
+
const available = labels.map((l) => l.name).join(", ");
|
|
310
|
+
exitWithError(`label "${options.label}" not found`, `available labels: ${available}`);
|
|
311
|
+
}
|
|
312
|
+
createPayload.labelIds = [targetLabel.id];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const issue = await createIssue(client, createPayload);
|
|
316
|
+
if (issue) {
|
|
317
|
+
console.log(`created ${issue.identifier}: ${issue.title}`);
|
|
318
|
+
} else {
|
|
319
|
+
console.log("created issue");
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
handleApiError(error);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function registerIssuesCommand(program: Command): void {
|
|
327
|
+
program
|
|
328
|
+
.command("issues")
|
|
329
|
+
.description("list issues")
|
|
330
|
+
.option("--team <key>", "filter by team key")
|
|
331
|
+
.option("--state <state>", "filter by state name")
|
|
332
|
+
.option("--assignee <email>", "filter by assignee (@me for self)")
|
|
333
|
+
.option("--label <label>", "filter by label")
|
|
334
|
+
.option("--project <project>", "filter by project name")
|
|
335
|
+
.option("--json", "output as JSON")
|
|
336
|
+
.option("--quiet", "output issue IDs only")
|
|
337
|
+
.option("--verbose", "show table headers")
|
|
338
|
+
.action(async (options: ListOptions) => {
|
|
339
|
+
await handleListIssues(options);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
program
|
|
343
|
+
.command("issue <id>")
|
|
344
|
+
.description("show or update an issue, or create with 'new'")
|
|
345
|
+
.option("--json", "output as JSON")
|
|
346
|
+
.option("--open", "open issue in browser")
|
|
347
|
+
.option("--state <state>", "update state")
|
|
348
|
+
.option("--assignee <email>", "update assignee (@me for self)")
|
|
349
|
+
.option("--priority <priority>", "update priority (urgent, high, medium, low)")
|
|
350
|
+
.option("--label <label>", "add (+label) or remove (-label) a label")
|
|
351
|
+
.option("--comment <text>", "add a comment")
|
|
352
|
+
.option("--team <key>", "team for new issue")
|
|
353
|
+
.option("--title <title>", "title for new issue")
|
|
354
|
+
.option("--description <description>", "description for new issue")
|
|
355
|
+
.action(async (id: string, options: ShowOptions & UpdateOptions & CreateOptions) => {
|
|
356
|
+
if (id === "new") {
|
|
357
|
+
await handleCreateIssue(options);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const hasUpdate =
|
|
362
|
+
options.state || options.assignee || options.priority || options.label || options.comment;
|
|
363
|
+
|
|
364
|
+
if (hasUpdate) {
|
|
365
|
+
await handleUpdateIssue(id, options);
|
|
366
|
+
} else {
|
|
367
|
+
await handleShowIssue(id, options);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
program
|
|
372
|
+
.command("i")
|
|
373
|
+
.description("alias for issues")
|
|
374
|
+
.option("--team <key>", "filter by team key")
|
|
375
|
+
.option("--state <state>", "filter by state name")
|
|
376
|
+
.option("--assignee <email>", "filter by assignee (@me for self)")
|
|
377
|
+
.option("--label <label>", "filter by label")
|
|
378
|
+
.option("--project <project>", "filter by project name")
|
|
379
|
+
.option("--json", "output as JSON")
|
|
380
|
+
.option("--quiet", "output issue IDs only")
|
|
381
|
+
.option("--verbose", "show table headers")
|
|
382
|
+
.argument("[subcommand]", "subcommand (new)")
|
|
383
|
+
.action(async (subcommand: string | undefined, options: ListOptions & CreateOptions) => {
|
|
384
|
+
if (subcommand === "new") {
|
|
385
|
+
await handleCreateIssue(options);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
await handleListIssues(options);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
getViewer,
|
|
5
|
+
getMyIssues,
|
|
6
|
+
getMyCreatedIssues,
|
|
7
|
+
} from "@bdsqqq/lnr-core";
|
|
8
|
+
import { handleApiError } from "../lib/error";
|
|
9
|
+
import {
|
|
10
|
+
getOutputFormat,
|
|
11
|
+
outputJson,
|
|
12
|
+
outputQuiet,
|
|
13
|
+
outputTable,
|
|
14
|
+
formatPriority,
|
|
15
|
+
truncate,
|
|
16
|
+
type OutputOptions,
|
|
17
|
+
} from "../lib/output";
|
|
18
|
+
|
|
19
|
+
interface MeOptions extends OutputOptions {
|
|
20
|
+
issues?: boolean;
|
|
21
|
+
created?: boolean;
|
|
22
|
+
json?: boolean;
|
|
23
|
+
quiet?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerMeCommand(program: Command): void {
|
|
27
|
+
program
|
|
28
|
+
.command("me")
|
|
29
|
+
.description("show current user info")
|
|
30
|
+
.option("--issues", "show my assigned issues")
|
|
31
|
+
.option("--created", "show issues i created")
|
|
32
|
+
.option("--json", "output as json")
|
|
33
|
+
.option("--quiet", "output ids only")
|
|
34
|
+
.action(async (options: MeOptions) => {
|
|
35
|
+
const format = options.json
|
|
36
|
+
? "json"
|
|
37
|
+
: options.quiet
|
|
38
|
+
? "quiet"
|
|
39
|
+
: getOutputFormat(options);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const client = getClient();
|
|
43
|
+
|
|
44
|
+
if (options.issues) {
|
|
45
|
+
const issues = await getMyIssues(client);
|
|
46
|
+
|
|
47
|
+
if (format === "json") {
|
|
48
|
+
outputJson(issues);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (format === "quiet") {
|
|
53
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
outputTable(issues, [
|
|
58
|
+
{ header: "ID", value: (i) => i.identifier, width: 12 },
|
|
59
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 40), width: 40 },
|
|
60
|
+
{ header: "STATE", value: (i) => i.state ?? "-", width: 16 },
|
|
61
|
+
{ header: "PRIORITY", value: (i) => formatPriority(i.priority), width: 10 },
|
|
62
|
+
]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.created) {
|
|
67
|
+
const issues = await getMyCreatedIssues(client);
|
|
68
|
+
|
|
69
|
+
if (format === "json") {
|
|
70
|
+
outputJson(issues);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (format === "quiet") {
|
|
75
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
outputTable(issues, [
|
|
80
|
+
{ header: "ID", value: (i) => i.identifier, width: 12 },
|
|
81
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 40), width: 40 },
|
|
82
|
+
{ header: "STATE", value: (i) => i.state ?? "-", width: 16 },
|
|
83
|
+
{ header: "PRIORITY", value: (i) => formatPriority(i.priority), width: 10 },
|
|
84
|
+
]);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const viewer = await getViewer(client);
|
|
89
|
+
|
|
90
|
+
if (format === "json") {
|
|
91
|
+
outputJson(viewer);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (format === "quiet") {
|
|
96
|
+
console.log(viewer.id);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(`${viewer.name}`);
|
|
101
|
+
console.log(`email: ${viewer.email}`);
|
|
102
|
+
if (viewer.displayName && viewer.displayName !== viewer.name) {
|
|
103
|
+
console.log(`display name: ${viewer.displayName}`);
|
|
104
|
+
}
|
|
105
|
+
console.log(`admin: ${viewer.admin ? "yes" : "no"}`);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
handleApiError(error);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
listProjects,
|
|
5
|
+
getProject,
|
|
6
|
+
getProjectIssues,
|
|
7
|
+
createProject,
|
|
8
|
+
deleteProject,
|
|
9
|
+
findTeamByKeyOrName,
|
|
10
|
+
getAvailableTeamKeys,
|
|
11
|
+
} from "@bdsqqq/lnr-core";
|
|
12
|
+
import { handleApiError, exitWithError, EXIT_CODES } from "../lib/error";
|
|
13
|
+
import {
|
|
14
|
+
outputJson,
|
|
15
|
+
outputQuiet,
|
|
16
|
+
outputTable,
|
|
17
|
+
getOutputFormat,
|
|
18
|
+
formatDate,
|
|
19
|
+
truncate,
|
|
20
|
+
type OutputOptions,
|
|
21
|
+
} from "../lib/output";
|
|
22
|
+
|
|
23
|
+
interface CreateProjectOptions {
|
|
24
|
+
name?: string;
|
|
25
|
+
team?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function handleCreateProject(options: CreateProjectOptions): Promise<void> {
|
|
30
|
+
if (!options.name) {
|
|
31
|
+
exitWithError("--name is required", "usage: li project new --name \"...\"");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const client = getClient();
|
|
36
|
+
let teamIds: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (options.team) {
|
|
39
|
+
const team = await findTeamByKeyOrName(client, options.team);
|
|
40
|
+
if (!team) {
|
|
41
|
+
const available = (await getAvailableTeamKeys(client)).join(", ");
|
|
42
|
+
exitWithError(`team "${options.team}" not found`, `available teams: ${available}`, EXIT_CODES.NOT_FOUND);
|
|
43
|
+
}
|
|
44
|
+
teamIds = [team.id];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const project = await createProject(client, {
|
|
48
|
+
name: options.name,
|
|
49
|
+
description: options.description,
|
|
50
|
+
teamIds,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (project) {
|
|
54
|
+
console.log(`created project: ${project.name}`);
|
|
55
|
+
} else {
|
|
56
|
+
console.log("created project");
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
handleApiError(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleDeleteProject(name: string): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
const client = getClient();
|
|
66
|
+
const success = await deleteProject(client, name);
|
|
67
|
+
|
|
68
|
+
if (!success) {
|
|
69
|
+
exitWithError(`project "${name}" not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`deleted project: ${name}`);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
handleApiError(error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function registerProjectsCommand(program: Command): void {
|
|
79
|
+
program
|
|
80
|
+
.command("projects")
|
|
81
|
+
.description("list projects")
|
|
82
|
+
.option("--team <team>", "filter by team")
|
|
83
|
+
.option("--status <status>", "filter by status (active, completed, canceled, paused)")
|
|
84
|
+
.option("--json", "output as json")
|
|
85
|
+
.option("--quiet", "output ids only")
|
|
86
|
+
.option("--verbose", "show detailed output")
|
|
87
|
+
.action(async (options: { team?: string; status?: string; json?: boolean; quiet?: boolean; verbose?: boolean }) => {
|
|
88
|
+
try {
|
|
89
|
+
const client = getClient();
|
|
90
|
+
|
|
91
|
+
const outputOpts: OutputOptions = {
|
|
92
|
+
format: options.json ? "json" : options.quiet ? "quiet" : undefined,
|
|
93
|
+
verbose: options.verbose,
|
|
94
|
+
};
|
|
95
|
+
const format = getOutputFormat(outputOpts);
|
|
96
|
+
|
|
97
|
+
const projects = await listProjects(client, { team: options.team, status: options.status });
|
|
98
|
+
|
|
99
|
+
if (format === "json") {
|
|
100
|
+
outputJson(projects);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (format === "quiet") {
|
|
105
|
+
outputQuiet(projects.map((p) => p.id));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
outputTable(projects, [
|
|
110
|
+
{ header: "NAME", value: (p) => truncate(p.name, 30), width: 30 },
|
|
111
|
+
{ header: "STATE", value: (p) => p.state ?? "-", width: 12 },
|
|
112
|
+
{ header: "PROGRESS", value: (p) => `${Math.round((p.progress ?? 0) * 100)}%`, width: 10 },
|
|
113
|
+
{ header: "TARGET", value: (p) => formatDate(p.targetDate), width: 12 },
|
|
114
|
+
], outputOpts);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
handleApiError(error);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
program
|
|
121
|
+
.command("project <name>")
|
|
122
|
+
.description("show project details, create with 'new', or delete with --delete")
|
|
123
|
+
.option("--issues", "list issues in project")
|
|
124
|
+
.option("--json", "output as json")
|
|
125
|
+
.option("--quiet", "output ids only")
|
|
126
|
+
.option("--verbose", "show detailed output")
|
|
127
|
+
.option("--delete", "delete/archive the project")
|
|
128
|
+
.option("--name <name>", "name for new project")
|
|
129
|
+
.option("--team <team>", "team for new project")
|
|
130
|
+
.option("--description <description>", "description for new project")
|
|
131
|
+
.action(async (name: string, options: { issues?: boolean; json?: boolean; quiet?: boolean; verbose?: boolean; delete?: boolean; name?: string; team?: string; description?: string }) => {
|
|
132
|
+
if (name === "new") {
|
|
133
|
+
await handleCreateProject(options);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (options.delete) {
|
|
138
|
+
await handleDeleteProject(name);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const client = getClient();
|
|
144
|
+
|
|
145
|
+
const outputOpts: OutputOptions = {
|
|
146
|
+
format: options.json ? "json" : options.quiet ? "quiet" : undefined,
|
|
147
|
+
verbose: options.verbose,
|
|
148
|
+
};
|
|
149
|
+
const format = getOutputFormat(outputOpts);
|
|
150
|
+
|
|
151
|
+
const project = await getProject(client, name);
|
|
152
|
+
|
|
153
|
+
if (!project) {
|
|
154
|
+
exitWithError(`project "${name}" not found`, undefined, EXIT_CODES.NOT_FOUND);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options.issues) {
|
|
158
|
+
const issues = await getProjectIssues(client, name);
|
|
159
|
+
|
|
160
|
+
if (format === "json") {
|
|
161
|
+
outputJson(issues);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (format === "quiet") {
|
|
166
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
outputTable(issues, [
|
|
171
|
+
{ header: "ID", value: (i) => i.identifier, width: 12 },
|
|
172
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
|
|
173
|
+
{ header: "CREATED", value: (i) => formatDate(i.createdAt), width: 12 },
|
|
174
|
+
], outputOpts);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (format === "json") {
|
|
179
|
+
outputJson(project);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (format === "quiet") {
|
|
184
|
+
console.log(project.id);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`${project.name}`);
|
|
189
|
+
if (project.description) {
|
|
190
|
+
console.log(` ${truncate(project.description, 80)}`);
|
|
191
|
+
}
|
|
192
|
+
console.log();
|
|
193
|
+
console.log(`state: ${project.state ?? "-"}`);
|
|
194
|
+
console.log(`progress: ${Math.round((project.progress ?? 0) * 100)}%`);
|
|
195
|
+
console.log(`target: ${formatDate(project.targetDate)}`);
|
|
196
|
+
console.log(`started: ${formatDate(project.startDate)}`);
|
|
197
|
+
console.log(`created: ${formatDate(project.createdAt)}`);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
handleApiError(error);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
program
|
|
204
|
+
.command("p")
|
|
205
|
+
.description("alias for projects")
|
|
206
|
+
.option("--team <team>", "filter by team")
|
|
207
|
+
.option("--status <status>", "filter by status")
|
|
208
|
+
.option("--json", "output as json")
|
|
209
|
+
.option("--quiet", "output ids only")
|
|
210
|
+
.option("--verbose", "show detailed output")
|
|
211
|
+
.action(async (options: { team?: string; status?: string; json?: boolean; quiet?: boolean; verbose?: boolean }) => {
|
|
212
|
+
await program.commands.find((c) => c.name() === "projects")?.parseAsync(["projects", ...process.argv.slice(3)], { from: "user" });
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { getClient, searchIssues } from "@bdsqqq/lnr-core";
|
|
3
|
+
import { handleApiError } from "../lib/error";
|
|
4
|
+
import {
|
|
5
|
+
getOutputFormat,
|
|
6
|
+
outputJson,
|
|
7
|
+
outputQuiet,
|
|
8
|
+
outputTable,
|
|
9
|
+
truncate,
|
|
10
|
+
type OutputOptions,
|
|
11
|
+
} from "../lib/output";
|
|
12
|
+
|
|
13
|
+
interface SearchOptions extends OutputOptions {
|
|
14
|
+
team?: string;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
quiet?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerSearchCommand(program: Command): void {
|
|
20
|
+
program
|
|
21
|
+
.command("search <query>")
|
|
22
|
+
.description("search issues")
|
|
23
|
+
.option("--team <key>", "filter by team")
|
|
24
|
+
.option("--json", "output as json")
|
|
25
|
+
.option("--quiet", "output ids only")
|
|
26
|
+
.action(async (query: string, options: SearchOptions) => {
|
|
27
|
+
const format = options.json ? "json" : options.quiet ? "quiet" : getOutputFormat(options);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const client = getClient();
|
|
31
|
+
const issues = await searchIssues(client, query, { team: options.team });
|
|
32
|
+
|
|
33
|
+
if (format === "json") {
|
|
34
|
+
outputJson(issues);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (format === "quiet") {
|
|
39
|
+
outputQuiet(issues.map((i) => i.identifier));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
outputTable(issues, [
|
|
44
|
+
{ header: "ID", value: (i) => i.identifier, width: 12 },
|
|
45
|
+
{ header: "TITLE", value: (i) => truncate(i.title, 50), width: 50 },
|
|
46
|
+
{ header: "STATE", value: (i) => i.state ?? "-", width: 16 },
|
|
47
|
+
]);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
handleApiError(error);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
getClient,
|
|
4
|
+
listTeams,
|
|
5
|
+
getTeam,
|
|
6
|
+
getTeamMembers,
|
|
7
|
+
getAvailableTeamKeys,
|
|
8
|
+
} from "@bdsqqq/lnr-core";
|
|
9
|
+
import { handleApiError, exitWithError, EXIT_CODES } from "../lib/error";
|
|
10
|
+
import {
|
|
11
|
+
getOutputFormat,
|
|
12
|
+
outputJson,
|
|
13
|
+
outputQuiet,
|
|
14
|
+
outputTable,
|
|
15
|
+
type OutputOptions,
|
|
16
|
+
} from "../lib/output";
|
|
17
|
+
|
|
18
|
+
interface TeamListOptions extends OutputOptions {
|
|
19
|
+
json?: boolean;
|
|
20
|
+
quiet?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TeamShowOptions extends OutputOptions {
|
|
24
|
+
members?: boolean;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerTeamsCommand(program: Command): void {
|
|
29
|
+
program
|
|
30
|
+
.command("teams")
|
|
31
|
+
.alias("t")
|
|
32
|
+
.description("list teams")
|
|
33
|
+
.option("--json", "output as json")
|
|
34
|
+
.option("--quiet", "output ids only")
|
|
35
|
+
.action(async (options: TeamListOptions) => {
|
|
36
|
+
const format = options.json ? "json" : options.quiet ? "quiet" : getOutputFormat(options);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const client = getClient();
|
|
40
|
+
const teams = await listTeams(client);
|
|
41
|
+
|
|
42
|
+
if (format === "json") {
|
|
43
|
+
outputJson(teams);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (format === "quiet") {
|
|
48
|
+
outputQuiet(teams.map((t) => t.key));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
outputTable(teams, [
|
|
53
|
+
{ header: "KEY", value: (t) => t.key, width: 8 },
|
|
54
|
+
{ header: "NAME", value: (t) => t.name, width: 24 },
|
|
55
|
+
{ header: "DESCRIPTION", value: (t) => t.description ?? "-", width: 40 },
|
|
56
|
+
]);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
handleApiError(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
program
|
|
63
|
+
.command("team <key>")
|
|
64
|
+
.description("show team details")
|
|
65
|
+
.option("--members", "show team members")
|
|
66
|
+
.option("--json", "output as json")
|
|
67
|
+
.action(async (key: string, options: TeamShowOptions) => {
|
|
68
|
+
const format = options.json ? "json" : getOutputFormat(options);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const client = getClient();
|
|
72
|
+
const team = await getTeam(client, key);
|
|
73
|
+
|
|
74
|
+
if (!team) {
|
|
75
|
+
const availableKeys = (await getAvailableTeamKeys(client)).join(", ");
|
|
76
|
+
exitWithError(
|
|
77
|
+
`team "${key}" not found`,
|
|
78
|
+
`available teams: ${availableKeys}`,
|
|
79
|
+
EXIT_CODES.NOT_FOUND
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.members) {
|
|
84
|
+
const members = await getTeamMembers(client, key);
|
|
85
|
+
|
|
86
|
+
if (format === "json") {
|
|
87
|
+
outputJson(members);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log(`${team.name} (${team.key}) members:\n`);
|
|
92
|
+
outputTable(members, [
|
|
93
|
+
{ header: "NAME", value: (m) => m.name, width: 24 },
|
|
94
|
+
{ header: "EMAIL", value: (m) => m.email ?? "-", width: 32 },
|
|
95
|
+
{ header: "ACTIVE", value: (m) => (m.active ? "yes" : "no"), width: 8 },
|
|
96
|
+
]);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (format === "json") {
|
|
101
|
+
outputJson(team);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(`${team.name} (${team.key})`);
|
|
106
|
+
if (team.description) {
|
|
107
|
+
console.log(team.description);
|
|
108
|
+
}
|
|
109
|
+
console.log(`timezone: ${team.timezone ?? "-"}`);
|
|
110
|
+
console.log(`private: ${team.private ? "yes" : "no"}`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
handleApiError(error);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/lib/error.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export const EXIT_CODES = {
|
|
4
|
+
SUCCESS: 0,
|
|
5
|
+
GENERAL_ERROR: 1,
|
|
6
|
+
AUTH_ERROR: 2,
|
|
7
|
+
NOT_FOUND: 3,
|
|
8
|
+
RATE_LIMITED: 4,
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export function exitWithError(
|
|
12
|
+
message: string,
|
|
13
|
+
hint?: string,
|
|
14
|
+
code: number = EXIT_CODES.GENERAL_ERROR
|
|
15
|
+
): never {
|
|
16
|
+
console.error(chalk.red(`error: ${message}`));
|
|
17
|
+
if (hint) {
|
|
18
|
+
console.error(chalk.dim(` ${hint}`));
|
|
19
|
+
}
|
|
20
|
+
process.exit(code);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handleApiError(error: unknown): never {
|
|
24
|
+
if (error instanceof Error) {
|
|
25
|
+
const msg = error.message.toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (msg.includes("unauthorized") || msg.includes("authentication")) {
|
|
28
|
+
exitWithError("not authenticated", "run: li auth <api-key>", EXIT_CODES.AUTH_ERROR);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (msg.includes("not found")) {
|
|
32
|
+
exitWithError(msg, undefined, EXIT_CODES.NOT_FOUND);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (msg.includes("rate limit")) {
|
|
36
|
+
exitWithError("rate limited, retry in 30s", undefined, EXIT_CODES.RATE_LIMITED);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
exitWithError(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
exitWithError("unknown error occurred");
|
|
43
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getConfigValue } from "@bdsqqq/lnr-core";
|
|
3
|
+
|
|
4
|
+
export type OutputFormat = "table" | "json" | "quiet";
|
|
5
|
+
|
|
6
|
+
export interface OutputOptions {
|
|
7
|
+
format?: OutputFormat;
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getOutputFormat(options: OutputOptions): OutputFormat {
|
|
12
|
+
if (options.format) {
|
|
13
|
+
return options.format;
|
|
14
|
+
}
|
|
15
|
+
return getConfigValue("output_format") ?? "table";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function outputJson(data: unknown): void {
|
|
19
|
+
console.log(JSON.stringify(data, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function outputQuiet(ids: string[]): void {
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
console.log(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TableColumn<T> {
|
|
29
|
+
header: string;
|
|
30
|
+
value: (item: T) => string;
|
|
31
|
+
width?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function outputTable<T>(
|
|
35
|
+
items: T[],
|
|
36
|
+
columns: TableColumn<T>[],
|
|
37
|
+
options: OutputOptions = {}
|
|
38
|
+
): void {
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
console.log(chalk.dim("no results"));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const widths: number[] = columns.map((col) => {
|
|
45
|
+
const headerLen = col.header.length;
|
|
46
|
+
const maxValueLen = Math.max(
|
|
47
|
+
...items.map((item) => col.value(item).length)
|
|
48
|
+
);
|
|
49
|
+
return col.width ?? Math.max(headerLen, maxValueLen);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (options.verbose) {
|
|
53
|
+
const headerLine = columns
|
|
54
|
+
.map((col, i) => col.header.padEnd(widths[i] ?? 10))
|
|
55
|
+
.join(" ");
|
|
56
|
+
console.log(chalk.dim(headerLine));
|
|
57
|
+
console.log(chalk.dim("-".repeat(headerLine.length)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const line = columns
|
|
62
|
+
.map((col, i) => {
|
|
63
|
+
const val = col.value(item);
|
|
64
|
+
const w = widths[i] ?? 10;
|
|
65
|
+
return val.slice(0, w).padEnd(w);
|
|
66
|
+
})
|
|
67
|
+
.join(" ");
|
|
68
|
+
console.log(line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function truncate(str: string, maxLen: number): string {
|
|
73
|
+
if (str.length <= maxLen) return str;
|
|
74
|
+
return str.slice(0, maxLen - 1) + "…";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatDate(date: Date | string | undefined | null): string {
|
|
78
|
+
if (!date) return "-";
|
|
79
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
80
|
+
const result = d.toISOString().split("T")[0];
|
|
81
|
+
return result ?? "-";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatPriority(priority: number | undefined): string {
|
|
85
|
+
switch (priority) {
|
|
86
|
+
case 0:
|
|
87
|
+
return "-";
|
|
88
|
+
case 1:
|
|
89
|
+
return "urgent";
|
|
90
|
+
case 2:
|
|
91
|
+
return "high";
|
|
92
|
+
case 3:
|
|
93
|
+
return "medium";
|
|
94
|
+
case 4:
|
|
95
|
+
return "low";
|
|
96
|
+
default:
|
|
97
|
+
return "-";
|
|
98
|
+
}
|
|
99
|
+
}
|