@enrichlayer/el-linear 1.34.0 → 1.35.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/README.md +2 -1
- package/dist/commands/issue-id.js +5 -4
- package/dist/commands/project-updates.d.ts +2 -0
- package/dist/commands/project-updates.js +116 -0
- package/dist/main.js +8 -2
- package/dist/queries/project-updates-types.d.ts +52 -0
- package/dist/queries/project-updates-types.js +8 -0
- package/dist/queries/project-updates.d.ts +3 -0
- package/dist/queries/project-updates.js +49 -0
- package/dist/utils/formatters/summary.d.ts +3 -1
- package/dist/utils/formatters/summary.js +46 -0
- package/dist/utils/linear-service.js +27 -19
- package/dist/utils/output.js +118 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -445,6 +445,7 @@ el-linear <command> --help # detailed help for one command
|
|
|
445
445
|
| Comments | `comments {list, read, create, update, delete}` |
|
|
446
446
|
| Labels | `labels {list, create, retire, restore}` |
|
|
447
447
|
| Projects | `projects {list, add-team, remove-team}` |
|
|
448
|
+
| Project updates | `project-updates {create, list, read}` (post a status update to a project's Updates feed) |
|
|
448
449
|
| Cycles | `cycles {list, read}` |
|
|
449
450
|
| Documents | `documents {list, read, create, update, delete}` |
|
|
450
451
|
| Releases | `releases {list, read, create, pipelines}` |
|
|
@@ -578,7 +579,7 @@ el-linear projects list --format summary --fields name,state,progress,lead,teams
|
|
|
578
579
|
# ...
|
|
579
580
|
```
|
|
580
581
|
|
|
581
|
-
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
582
|
+
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
582
583
|
|
|
583
584
|
### Windowed metadata (`WindowedMeta`)
|
|
584
585
|
|
|
@@ -22,11 +22,12 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
|
22
22
|
// elsewhere in the workspace. Any addition here is the single point of
|
|
23
23
|
// truth that all skills rely on.
|
|
24
24
|
//
|
|
25
|
-
// Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex;
|
|
26
|
-
// mirror tools-repo DEV-4417):
|
|
27
|
-
// feature | fix | chore | refactor | dev
|
|
25
|
+
// Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; DEV-5342
|
|
26
|
+
// adds the feat short-form alias; all mirror tools-repo DEV-4417/DEV-5334):
|
|
27
|
+
// feature | feat | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
|
|
28
28
|
// bug | spike — sanctioned Linear type labels
|
|
29
29
|
// codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
|
|
30
|
+
// feat — short-form alias for feature (DEV-5342)
|
|
30
31
|
// New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
|
|
31
32
|
// type labels (bug, spike) get first-class issue detection so commit guards,
|
|
32
33
|
// MR descriptions, and session handoff don't go dark on a branch a human
|
|
@@ -34,7 +35,7 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
|
34
35
|
//
|
|
35
36
|
// Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
|
|
36
37
|
// vertical-int/tools repo. If you change one, change the other.
|
|
37
|
-
const BRANCH_RE = /^(?:feature|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
38
|
+
const BRANCH_RE = /^(?:feature|feat|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
38
39
|
export function parseBranchName(branch) {
|
|
39
40
|
const m = branch.match(BRANCH_RE);
|
|
40
41
|
if (!m) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { CREATE_PROJECT_UPDATE_MUTATION, GET_PROJECT_UPDATE_BY_ID_QUERY, LIST_PROJECT_UPDATES_QUERY, } from "../queries/project-updates.js";
|
|
3
|
+
import { notFoundError } from "../utils/error-messages.js";
|
|
4
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
5
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
6
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
8
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
9
|
+
const VALID_HEALTH = [
|
|
10
|
+
"onTrack",
|
|
11
|
+
"atRisk",
|
|
12
|
+
"offTrack",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the update body from `--body` / `--body-file` (mutually exclusive,
|
|
16
|
+
* exactly one required) — mirrors the comments-command contract so file-sourced
|
|
17
|
+
* bodies sidestep shell-quoting traps for markdown/tables/backticks.
|
|
18
|
+
*/
|
|
19
|
+
function resolveBody(options) {
|
|
20
|
+
if (options.body && options.bodyFile) {
|
|
21
|
+
throw new Error("--body and --body-file are mutually exclusive — pass one or the other");
|
|
22
|
+
}
|
|
23
|
+
if (options.bodyFile) {
|
|
24
|
+
return readFileSync(options.bodyFile, "utf-8");
|
|
25
|
+
}
|
|
26
|
+
if (options.body) {
|
|
27
|
+
return options.body;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("Either --body or --body-file is required");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validate an optional `--health` value against Linear's
|
|
33
|
+
* `ProjectUpdateHealthType` enum. Returns undefined when the flag is omitted
|
|
34
|
+
* (Linear then defaults the health), throws on an unknown value.
|
|
35
|
+
*/
|
|
36
|
+
function resolveHealth(value) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!VALID_HEALTH.includes(value)) {
|
|
41
|
+
throw new Error(`Invalid --health "${value}" — expected one of: ${VALID_HEALTH.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
async function handleCreateProjectUpdate(options, command) {
|
|
46
|
+
// Validate local input first so bad --body/--health fails with zero network.
|
|
47
|
+
const body = resolveBody(options);
|
|
48
|
+
const health = resolveHealth(options.health);
|
|
49
|
+
const rootOpts = getRootOpts(command);
|
|
50
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
51
|
+
const linearService = await createLinearService(rootOpts);
|
|
52
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
53
|
+
const input = { projectId, body };
|
|
54
|
+
if (health !== undefined) {
|
|
55
|
+
input.health = health;
|
|
56
|
+
}
|
|
57
|
+
if (options.diffHidden) {
|
|
58
|
+
input.isDiffHidden = true;
|
|
59
|
+
}
|
|
60
|
+
const result = await graphQLService.rawRequest(CREATE_PROJECT_UPDATE_MUTATION, { input });
|
|
61
|
+
if (!result.projectUpdateCreate.success ||
|
|
62
|
+
!result.projectUpdateCreate.projectUpdate) {
|
|
63
|
+
throw new Error(`Failed to create project update on project "${options.project}"`);
|
|
64
|
+
}
|
|
65
|
+
outputSuccess(result.projectUpdateCreate.projectUpdate);
|
|
66
|
+
}
|
|
67
|
+
async function handleListProjectUpdates(options, command) {
|
|
68
|
+
const rootOpts = getRootOpts(command);
|
|
69
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
70
|
+
const linearService = await createLinearService(rootOpts);
|
|
71
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
72
|
+
const result = await graphQLService.rawRequest(LIST_PROJECT_UPDATES_QUERY, {
|
|
73
|
+
projectId,
|
|
74
|
+
first: parsePositiveInt(options.limit, "--limit"),
|
|
75
|
+
});
|
|
76
|
+
if (!result.project) {
|
|
77
|
+
throw notFoundError("Project", options.project);
|
|
78
|
+
}
|
|
79
|
+
const nodes = result.project.projectUpdates.nodes;
|
|
80
|
+
outputSuccess({ data: nodes, meta: { count: nodes.length } });
|
|
81
|
+
}
|
|
82
|
+
async function handleReadProjectUpdate(updateId, _options, command) {
|
|
83
|
+
const rootOpts = getRootOpts(command);
|
|
84
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
85
|
+
const result = await graphQLService.rawRequest(GET_PROJECT_UPDATE_BY_ID_QUERY, { id: updateId });
|
|
86
|
+
if (!result.projectUpdate) {
|
|
87
|
+
throw notFoundError("Project update", updateId);
|
|
88
|
+
}
|
|
89
|
+
outputSuccess(result.projectUpdate);
|
|
90
|
+
}
|
|
91
|
+
export function setupProjectUpdatesCommands(program) {
|
|
92
|
+
const projectUpdates = program
|
|
93
|
+
.command("project-updates")
|
|
94
|
+
.description("Project update (status post) operations");
|
|
95
|
+
projectUpdates.action(() => projectUpdates.help());
|
|
96
|
+
projectUpdates
|
|
97
|
+
.command("create")
|
|
98
|
+
.description("Post a status update to a project (appears in the project's Updates feed).")
|
|
99
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
100
|
+
.option("--body <body>", "update body markdown (inline)")
|
|
101
|
+
.option("--body-file <path>", "read update body from file")
|
|
102
|
+
.option("--health <health>", "project health: onTrack | atRisk | offTrack (omit to leave unset)")
|
|
103
|
+
.option("--diff-hidden", "hide the progress diff on the update")
|
|
104
|
+
.option("-q, --quiet", "print one confirmation line (health url) instead of the full JSON")
|
|
105
|
+
.action(handleAsyncCommand(handleCreateProjectUpdate));
|
|
106
|
+
projectUpdates
|
|
107
|
+
.command("list")
|
|
108
|
+
.description("List status updates posted to a project (newest first).")
|
|
109
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
110
|
+
.option("-l, --limit <number>", "limit results", "50")
|
|
111
|
+
.action(handleAsyncCommand(handleListProjectUpdates));
|
|
112
|
+
projectUpdates
|
|
113
|
+
.command("read <updateId>")
|
|
114
|
+
.description("Get a single project update by its ID.")
|
|
115
|
+
.action(handleAsyncCommand(handleReadProjectUpdate));
|
|
116
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { setupIssuesCommands } from "./commands/issues.js";
|
|
|
20
20
|
import { setupLabelsCommands } from "./commands/labels.js";
|
|
21
21
|
import { setupProfileCommands } from "./commands/profile.js";
|
|
22
22
|
import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
|
|
23
|
+
import { setupProjectUpdatesCommands } from "./commands/project-updates.js";
|
|
23
24
|
import { setupProjectsCommands } from "./commands/projects.js";
|
|
24
25
|
import { setupReadShortcut } from "./commands/read-shortcut.js";
|
|
25
26
|
import { setupRefsCommands } from "./commands/refs.js";
|
|
@@ -65,9 +66,13 @@ program
|
|
|
65
66
|
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
|
|
66
67
|
.option("--json", "output as JSON (default, accepted for compatibility)")
|
|
67
68
|
.option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
|
|
68
|
-
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly"
|
|
69
|
+
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly. " +
|
|
70
|
+
"Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
|
|
71
|
+
"they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
|
|
69
72
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
70
|
-
.option("--fields <fields>", "filter output to specific fields (comma-separated)"
|
|
73
|
+
.option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
|
|
74
|
+
"emitted as null plus a `fields_unresolved:` warning — in the JSON " +
|
|
75
|
+
"envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
|
|
71
76
|
.option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
|
|
72
77
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
73
78
|
const rootOpts = actionCommand.optsWithGlobals();
|
|
@@ -143,6 +148,7 @@ setupReleasesCommands(program);
|
|
|
143
148
|
setupProjectsCommands(program);
|
|
144
149
|
setupCyclesCommands(program);
|
|
145
150
|
setupProjectMilestonesCommands(program);
|
|
151
|
+
setupProjectUpdatesCommands(program);
|
|
146
152
|
setupEmbedsCommands(program);
|
|
147
153
|
setupTeamsCommands(program);
|
|
148
154
|
setupTemplatesCommands(program);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./project-updates.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*
|
|
5
|
+
* All three queries (create / list / read) select the same
|
|
6
|
+
* `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
|
|
7
|
+
*/
|
|
8
|
+
export type ProjectUpdateHealth = "onTrack" | "atRisk" | "offTrack";
|
|
9
|
+
interface ProjectUpdateProjectRef {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
interface ProjectUpdateUserRef {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
displayName: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mirrors `PROJECT_UPDATE_FRAGMENT` — the full selection set shared by the
|
|
20
|
+
* create mutation, the project-scoped list, and the by-id read.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectUpdateNode {
|
|
23
|
+
id: string;
|
|
24
|
+
body: string | null;
|
|
25
|
+
health: ProjectUpdateHealth | null;
|
|
26
|
+
url: string | null;
|
|
27
|
+
slugId: string | null;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
editedAt: string | null;
|
|
31
|
+
user: ProjectUpdateUserRef | null;
|
|
32
|
+
project: ProjectUpdateProjectRef | null;
|
|
33
|
+
}
|
|
34
|
+
export interface CreateProjectUpdateResponse {
|
|
35
|
+
projectUpdateCreate: {
|
|
36
|
+
success: boolean;
|
|
37
|
+
projectUpdate: ProjectUpdateNode | null;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface ListProjectUpdatesResponse {
|
|
41
|
+
project: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
projectUpdates: {
|
|
45
|
+
nodes: ProjectUpdateNode[];
|
|
46
|
+
};
|
|
47
|
+
} | null;
|
|
48
|
+
}
|
|
49
|
+
export interface GetProjectUpdateByIdResponse {
|
|
50
|
+
projectUpdate: ProjectUpdateNode | null;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./project-updates.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*
|
|
5
|
+
* All three queries (create / list / read) select the same
|
|
6
|
+
* `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const CREATE_PROJECT_UPDATE_MUTATION = "\n mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {\n projectUpdateCreate(input: $input) {\n success\n projectUpdate {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n";
|
|
2
|
+
export declare const LIST_PROJECT_UPDATES_QUERY = "\n query ListProjectUpdates($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectUpdates(first: $first, orderBy: createdAt) {\n nodes {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n }\n";
|
|
3
|
+
export declare const GET_PROJECT_UPDATE_BY_ID_QUERY = "\n query GetProjectUpdate($id: String!) {\n projectUpdate(id: $id) {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const PROJECT_UPDATE_FRAGMENT = `
|
|
2
|
+
id
|
|
3
|
+
body
|
|
4
|
+
health
|
|
5
|
+
url
|
|
6
|
+
slugId
|
|
7
|
+
createdAt
|
|
8
|
+
updatedAt
|
|
9
|
+
editedAt
|
|
10
|
+
user {
|
|
11
|
+
id
|
|
12
|
+
name
|
|
13
|
+
displayName
|
|
14
|
+
}
|
|
15
|
+
project {
|
|
16
|
+
id
|
|
17
|
+
name
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
export const CREATE_PROJECT_UPDATE_MUTATION = `
|
|
21
|
+
mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
|
|
22
|
+
projectUpdateCreate(input: $input) {
|
|
23
|
+
success
|
|
24
|
+
projectUpdate {
|
|
25
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
export const LIST_PROJECT_UPDATES_QUERY = `
|
|
31
|
+
query ListProjectUpdates($projectId: String!, $first: Int!) {
|
|
32
|
+
project(id: $projectId) {
|
|
33
|
+
id
|
|
34
|
+
name
|
|
35
|
+
projectUpdates(first: $first, orderBy: createdAt) {
|
|
36
|
+
nodes {
|
|
37
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
export const GET_PROJECT_UPDATE_BY_ID_QUERY = `
|
|
44
|
+
query GetProjectUpdate($id: String!) {
|
|
45
|
+
projectUpdate(id: $id) {
|
|
46
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
@@ -30,6 +30,8 @@ export declare function formatCycleSummary(cycle: Record<string, unknown>): stri
|
|
|
30
30
|
export declare function formatCycleList(cycles: unknown[]): string;
|
|
31
31
|
export declare function formatMilestoneSummary(milestone: Record<string, unknown>): string;
|
|
32
32
|
export declare function formatMilestoneList(milestones: unknown[]): string;
|
|
33
|
+
export declare function formatProjectUpdateSummary(update: Record<string, unknown>): string;
|
|
34
|
+
export declare function formatProjectUpdateList(updates: unknown[]): string;
|
|
33
35
|
export declare function formatTeamList(teams: unknown[]): string;
|
|
34
36
|
export declare function formatLabelList(labels: unknown[]): string;
|
|
35
37
|
/**
|
|
@@ -68,7 +70,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
|
|
|
68
70
|
* the full payload. Lists fall back to a simple bulleted list.
|
|
69
71
|
*/
|
|
70
72
|
export declare function formatGenericSummary(value: unknown): string;
|
|
71
|
-
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
|
|
73
|
+
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "project-update" | "project-update-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
|
|
72
74
|
/**
|
|
73
75
|
* Heuristic — used by the central `outputSuccess` path which doesn't know
|
|
74
76
|
* which command produced the payload. Looks at the shape of the data to
|
|
@@ -655,6 +655,35 @@ export function formatMilestoneList(milestones) {
|
|
|
655
655
|
{ header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
|
|
656
656
|
], { emptyText: "(no milestones)", itemNoun: "milestone" });
|
|
657
657
|
}
|
|
658
|
+
// ── project updates ────────────────────────────────────────────
|
|
659
|
+
export function formatProjectUpdateSummary(update) {
|
|
660
|
+
const headerLine = `project-update ${s(update.id)}`;
|
|
661
|
+
const fields = [
|
|
662
|
+
{ label: "Health", value: s(update.health) },
|
|
663
|
+
{ label: "Project", value: getName(update.project) },
|
|
664
|
+
{ label: "Author", value: getName(update.user) },
|
|
665
|
+
{ label: "Created", value: s(update.createdAt) },
|
|
666
|
+
{ label: "URL", value: s(update.url) },
|
|
667
|
+
];
|
|
668
|
+
const header = renderHeader(fields);
|
|
669
|
+
const body = clipDescription(update.body);
|
|
670
|
+
const parts = [headerLine, header];
|
|
671
|
+
if (body)
|
|
672
|
+
parts.push("", body);
|
|
673
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
674
|
+
}
|
|
675
|
+
export function formatProjectUpdateList(updates) {
|
|
676
|
+
return renderTable(updates.map((raw) => asObj(raw) ?? {}), [
|
|
677
|
+
{ header: "HEALTH", minWidth: 6, extract: (u) => s(u.health) },
|
|
678
|
+
{ header: "AUTHOR", minWidth: 6, extract: (u) => getName(u.user) },
|
|
679
|
+
{
|
|
680
|
+
header: "CREATED",
|
|
681
|
+
minWidth: 7,
|
|
682
|
+
extract: (u) => s(u.createdAt).slice(0, 10),
|
|
683
|
+
},
|
|
684
|
+
{ header: "URL", minWidth: 3, maxWidth: 60, extract: (u) => s(u.url) },
|
|
685
|
+
], { emptyText: "(no project updates)", itemNoun: "project update" });
|
|
686
|
+
}
|
|
658
687
|
// ── teams ──────────────────────────────────────────────────────
|
|
659
688
|
export function formatTeamList(teams) {
|
|
660
689
|
return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
|
|
@@ -1036,6 +1065,10 @@ export function inferKindFromPayload(value) {
|
|
|
1036
1065
|
"progress" in obj &&
|
|
1037
1066
|
("state" in obj || "lead" in obj || "teams" in obj))
|
|
1038
1067
|
return "project";
|
|
1068
|
+
// Project updates carry body + createdAt + user like comments; `health` is
|
|
1069
|
+
// the distinguishing field, so this must precede the comment check.
|
|
1070
|
+
if ("body" in obj && "health" in obj)
|
|
1071
|
+
return "project-update";
|
|
1039
1072
|
if ("body" in obj && "createdAt" in obj && "user" in obj)
|
|
1040
1073
|
return "comment";
|
|
1041
1074
|
if (("number" in obj || "isActive" in obj) &&
|
|
@@ -1079,6 +1112,10 @@ function inferListKind(items) {
|
|
|
1079
1112
|
}
|
|
1080
1113
|
return "issue-list";
|
|
1081
1114
|
}
|
|
1115
|
+
// Project-update rows carry body + createdAt like comments; `health` is the
|
|
1116
|
+
// distinguishing field, so this must precede the comment-list check.
|
|
1117
|
+
if ("body" in sample && "health" in sample)
|
|
1118
|
+
return "project-update-list";
|
|
1082
1119
|
if ("body" in sample && "createdAt" in sample)
|
|
1083
1120
|
return "comment-list";
|
|
1084
1121
|
if ("progress" in sample && "name" in sample) {
|
|
@@ -1168,6 +1205,10 @@ export function dispatch(kind, payload, fields) {
|
|
|
1168
1205
|
return formatMilestoneSummary((obj ?? {}));
|
|
1169
1206
|
case "milestone-list":
|
|
1170
1207
|
return formatMilestoneList(list ?? []);
|
|
1208
|
+
case "project-update":
|
|
1209
|
+
return formatProjectUpdateSummary((obj ?? {}));
|
|
1210
|
+
case "project-update-list":
|
|
1211
|
+
return formatProjectUpdateList(list ?? []);
|
|
1171
1212
|
case "team-list":
|
|
1172
1213
|
return formatTeamList(list ?? []);
|
|
1173
1214
|
case "label-list":
|
|
@@ -1230,6 +1271,11 @@ export function formatLine(payload) {
|
|
|
1230
1271
|
if (kind === "comment" && obj) {
|
|
1231
1272
|
return `comment ${s(obj.id)}`;
|
|
1232
1273
|
}
|
|
1274
|
+
if (kind === "project-update" && obj) {
|
|
1275
|
+
// create doesn't carry an identifier/title — health + url are the
|
|
1276
|
+
// stable handles a caller needs (matches the summary header style).
|
|
1277
|
+
return `${s(obj.health)} ${s(obj.url)}`;
|
|
1278
|
+
}
|
|
1233
1279
|
if (kind === "relation-list") {
|
|
1234
1280
|
return formatRelationLine(payload);
|
|
1235
1281
|
}
|
|
@@ -131,20 +131,32 @@ export class LinearService {
|
|
|
131
131
|
else if (options.excludeStates && options.excludeStates.length > 0) {
|
|
132
132
|
filter.state = { nin: options.excludeStates };
|
|
133
133
|
}
|
|
134
|
-
if (options.teamId) {
|
|
135
|
-
filter.teams = { some: { id: { eq: options.teamId } } };
|
|
136
|
-
}
|
|
137
134
|
// `limit === 0` means "no limit" — paginate the full result set.
|
|
138
135
|
// Otherwise a single page of `limit` projects. DEV-4175: `--all` /
|
|
139
136
|
// `--limit 0` must return every project so callers never make a false
|
|
140
137
|
// "does not exist" determination off a silently truncated page.
|
|
141
138
|
const unlimited = limit === 0;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
// DEV-5325: ProjectFilter has no `teams` relation filter — the
|
|
140
|
+
// `teams: { some: … }` shape this used since DEV-4165 errors against
|
|
141
|
+
// the live GraphQL schema ('Field "teams" is not defined by type
|
|
142
|
+
// "ProjectFilter"'). Team scoping queries from the team side instead:
|
|
143
|
+
// `Team.projects` accepts the same name/state ProjectFilter and
|
|
144
|
+
// paginates identically, keeping the filter server-side. (Args stay
|
|
145
|
+
// inline in each branch so `sdkOrderBy`'s generic keeps its contextual
|
|
146
|
+
// type from the SDK signature.)
|
|
147
|
+
let page = options.teamId
|
|
148
|
+
? await (await this.client.team(options.teamId)).projects({
|
|
149
|
+
filter: nonEmptyFilter(filter),
|
|
150
|
+
first: unlimited ? 250 : limit,
|
|
151
|
+
orderBy: sdkOrderBy("updatedAt"),
|
|
152
|
+
includeArchived: false,
|
|
153
|
+
})
|
|
154
|
+
: await this.client.projects({
|
|
155
|
+
filter: nonEmptyFilter(filter),
|
|
156
|
+
first: unlimited ? 250 : limit,
|
|
157
|
+
orderBy: sdkOrderBy("updatedAt"),
|
|
158
|
+
includeArchived: false,
|
|
159
|
+
});
|
|
148
160
|
const projectNodes = [...page.nodes];
|
|
149
161
|
if (unlimited) {
|
|
150
162
|
while (page.pageInfo.hasNextPage) {
|
|
@@ -516,25 +528,21 @@ export class LinearService {
|
|
|
516
528
|
throw notFoundError("Project", projectInput);
|
|
517
529
|
}
|
|
518
530
|
// DEV-4103: scope by team when provided so a name shared across teams
|
|
519
|
-
// doesn't resolve to a different team's project.
|
|
520
|
-
// `
|
|
531
|
+
// doesn't resolve to a different team's project. DEV-5325: the scoping
|
|
532
|
+
// goes through `Team.projects` (same as `getProjects`) — ProjectFilter
|
|
533
|
+
// rejects a `teams` relation filter against the live schema.
|
|
521
534
|
const teamId = teamInput ? await this.resolveTeamId(teamInput) : undefined;
|
|
522
535
|
const filter = {
|
|
523
536
|
name: { eqIgnoreCase: projectInput },
|
|
524
537
|
};
|
|
525
|
-
if (teamId) {
|
|
526
|
-
// Matches the filter shape `getProjects` uses for `--team`.
|
|
527
|
-
filter.teams = { some: { id: { eq: teamId } } };
|
|
528
|
-
}
|
|
529
538
|
// `first: 5` is wide enough to detect ambiguity (same name across
|
|
530
539
|
// multiple teams) without paying for a deeper page. Linear's UI caps
|
|
531
540
|
// effective project-name collisions at a handful in practice; if a
|
|
532
541
|
// workspace ever exceeds this we'll see it as a still-ambiguous error
|
|
533
542
|
// listing the first 5 teams — better than a silent wrong pick.
|
|
534
|
-
const projectsConnection =
|
|
535
|
-
filter,
|
|
536
|
-
first: 5
|
|
537
|
-
});
|
|
543
|
+
const projectsConnection = teamId
|
|
544
|
+
? await (await this.client.team(teamId)).projects({ filter, first: 5 })
|
|
545
|
+
: await this.client.projects({ filter, first: 5 });
|
|
538
546
|
if (projectsConnection.nodes.length === 0) {
|
|
539
547
|
const context = teamInput ? `on team "${teamInput}"` : undefined;
|
|
540
548
|
throw notFoundError("Project", projectInput, context);
|
package/dist/utils/output.js
CHANGED
|
@@ -40,9 +40,64 @@ export function setOutputFormat(format) {
|
|
|
40
40
|
export function getOutputFormat() {
|
|
41
41
|
return outputFormat;
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a dot-separated path against a value (`a.b.c`; array indices are
|
|
45
|
+
* numeric segments, `items.0.id`). Returns `undefined` when any segment is
|
|
46
|
+
* missing so callers can distinguish "missing" from a real `null` value.
|
|
47
|
+
* Same path grammar as el-git's `--field` selector — keeping the two flags'
|
|
48
|
+
* semantics aligned is the point (DEV-5323).
|
|
49
|
+
*/
|
|
50
|
+
function getNestedPath(obj, path) {
|
|
51
|
+
let cur = obj;
|
|
52
|
+
for (const seg of path.split(".")) {
|
|
53
|
+
if (cur === null || cur === undefined || typeof cur !== "object") {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
cur = cur[seg];
|
|
57
|
+
}
|
|
58
|
+
return cur;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Project `obj` down to the requested fields.
|
|
62
|
+
*
|
|
63
|
+
* - Top-level keys copy through as before.
|
|
64
|
+
* - Dot-separated paths (DEV-5323) resolve nested values; the requested path
|
|
65
|
+
* string becomes a flat output key (`{"pipeline.status": "success"}`), so
|
|
66
|
+
* consumers read exactly what they asked for.
|
|
67
|
+
* - A field that resolves nowhere is emitted as an explicit `null` AND
|
|
68
|
+
* reported via `unresolved` — never silently omitted. Silent omission made
|
|
69
|
+
* a typo'd field indistinguishable from an empty value (DEV-5323).
|
|
70
|
+
* - For arrays, each item is projected; a field counts as unresolved only
|
|
71
|
+
* when it resolves on NO item (heterogeneous lists legitimately have
|
|
72
|
+
* per-item gaps).
|
|
73
|
+
*/
|
|
74
|
+
function filterFields(obj, fields, unresolved) {
|
|
44
75
|
if (Array.isArray(obj)) {
|
|
45
|
-
|
|
76
|
+
const resolvedSomewhere = new Set();
|
|
77
|
+
const items = obj.map((item) => {
|
|
78
|
+
const perItem = new Set();
|
|
79
|
+
const projected = filterFields(item, fields, perItem);
|
|
80
|
+
for (const field of fields) {
|
|
81
|
+
// A primitive item short-circuits: filterFields returns it
|
|
82
|
+
// unchanged and records NOTHING in perItem, so every field counts
|
|
83
|
+
// as resolved here — a mixed array holding one primitive suppresses
|
|
84
|
+
// the unresolved warning for all fields. Acceptable: a primitive
|
|
85
|
+
// item can't meaningfully be projected, and heterogeneous lists
|
|
86
|
+
// legitimately have per-item gaps.
|
|
87
|
+
if (!perItem.has(field)) {
|
|
88
|
+
resolvedSomewhere.add(field);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return projected;
|
|
92
|
+
});
|
|
93
|
+
if (unresolved) {
|
|
94
|
+
for (const field of fields) {
|
|
95
|
+
if (!resolvedSomewhere.has(field)) {
|
|
96
|
+
unresolved.add(field);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return items;
|
|
46
101
|
}
|
|
47
102
|
if (obj !== null && typeof obj === "object") {
|
|
48
103
|
const source = obj;
|
|
@@ -50,7 +105,17 @@ function filterFields(obj, fields) {
|
|
|
50
105
|
for (const field of fields) {
|
|
51
106
|
if (field in source) {
|
|
52
107
|
result[field] = source[field];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const nested = field.includes(".")
|
|
111
|
+
? getNestedPath(source, field)
|
|
112
|
+
: undefined;
|
|
113
|
+
if (nested !== undefined) {
|
|
114
|
+
result[field] = nested;
|
|
115
|
+
continue;
|
|
53
116
|
}
|
|
117
|
+
result[field] = null;
|
|
118
|
+
unresolved?.add(field);
|
|
54
119
|
}
|
|
55
120
|
return result;
|
|
56
121
|
}
|
|
@@ -143,18 +208,40 @@ export function outputSuccess(data) {
|
|
|
143
208
|
// (project.name, teams[].key) the formatter needs to render. Skip the
|
|
144
209
|
// JSON filter when summary is active and let the formatter do the work.
|
|
145
210
|
if (fieldsFilter && outputFormat !== "summary") {
|
|
211
|
+
const unresolved = new Set();
|
|
146
212
|
if (Array.isArray(output)) {
|
|
147
|
-
output = filterFields(output, fieldsFilter);
|
|
213
|
+
output = filterFields(output, fieldsFilter, unresolved);
|
|
148
214
|
}
|
|
149
215
|
else if (output !== null && typeof output === "object") {
|
|
150
216
|
const obj = output;
|
|
151
|
-
if (
|
|
152
|
-
|
|
217
|
+
if (obj.data !== null &&
|
|
218
|
+
obj.data !== undefined &&
|
|
219
|
+
typeof obj.data === "object") {
|
|
220
|
+
// Envelope with a `data` payload — project inside `data`,
|
|
221
|
+
// whether it's an array (list envelope) or an object (e.g.
|
|
222
|
+
// el-git context). Arrays ARE objects, so this single
|
|
223
|
+
// `typeof === "object"` check subsumes the former separate
|
|
224
|
+
// `Array.isArray(obj.data)` arm (DEV-5339 collapsed the two
|
|
225
|
+
// byte-identical branches); `filterFields` dispatches on
|
|
226
|
+
// array-vs-object internally. Paths are relative to `data` for
|
|
227
|
+
// every envelope shape. Before DEV-5323 the object case fell
|
|
228
|
+
// through to root filtering, so `--fields branch,issueId` on an
|
|
229
|
+
// envelope returned `{}`.
|
|
230
|
+
output = {
|
|
231
|
+
...obj,
|
|
232
|
+
data: filterFields(obj.data, fieldsFilter, unresolved),
|
|
233
|
+
};
|
|
153
234
|
}
|
|
154
235
|
else {
|
|
155
|
-
output = filterFields(output, fieldsFilter);
|
|
236
|
+
output = filterFields(output, fieldsFilter, unresolved);
|
|
156
237
|
}
|
|
157
238
|
}
|
|
239
|
+
if (unresolved.size > 0) {
|
|
240
|
+
// Fail-visible: the projected keys carry explicit nulls and this
|
|
241
|
+
// warning names them, so a typo'd/missing field is never mistaken
|
|
242
|
+
// for an empty value (DEV-5323).
|
|
243
|
+
outputWarning(`fields_unresolved: ${[...unresolved].join(", ")} did not resolve on this payload (emitted as null). Paths are dot-separated and relative to the envelope's data when one is present.`);
|
|
244
|
+
}
|
|
158
245
|
}
|
|
159
246
|
// summary format takes the post-raw value and renders it as a
|
|
160
247
|
// human-readable block. We bypass the jq path because jq is a
|
|
@@ -179,11 +266,31 @@ export function outputSuccess(data) {
|
|
|
179
266
|
// fields_unprojectable surfaced from a prior dispatch) and embed
|
|
180
267
|
// them as `_warnings` on the envelope.
|
|
181
268
|
const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
|
|
182
|
-
if (warnings.length > 0
|
|
183
|
-
output !== null &&
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
269
|
+
if (warnings.length > 0) {
|
|
270
|
+
if (output !== null &&
|
|
271
|
+
typeof output === "object" &&
|
|
272
|
+
!Array.isArray(output)) {
|
|
273
|
+
output = { ...output, _warnings: warnings };
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Bare-array (or primitive / null) output has no envelope object to
|
|
277
|
+
// carry `_warnings`. This is the DEV-5339 fix: previously the buffer
|
|
278
|
+
// was drained above but only re-embedded for object output, so a
|
|
279
|
+
// warning was silently dropped on a top-level array payload AND on
|
|
280
|
+
// `--raw` (which unwraps { data: [...] } to a bare array *before*
|
|
281
|
+
// this point) — losing exactly the DEV-5323 `fields_unresolved:`
|
|
282
|
+
// fail-visible signal on the `--raw` form our own CLAUDE.md
|
|
283
|
+
// recommends to agents. Route each warning to STDERR prefixed
|
|
284
|
+
// `_warnings: ` so the signal always reaches the consumer while
|
|
285
|
+
// stdout stays a pure JSON array (safe to pipe to `jq`). Mirrors the
|
|
286
|
+
// summary path's `_warnings:` line convention — that path uses
|
|
287
|
+
// logger.info because its stdout is already human text; here stdout
|
|
288
|
+
// must remain machine-parseable JSON, so we use logger.error
|
|
289
|
+
// (stderr).
|
|
290
|
+
for (const w of warnings) {
|
|
291
|
+
logger.error(`_warnings: ${w}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
187
294
|
}
|
|
188
295
|
if (jqFilter) {
|
|
189
296
|
const json = JSON.stringify(output);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|