@enrichlayer/el-linear 1.18.1 → 1.20.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 +85 -0
- package/claude-skills/linear-operations/SKILL.md +6 -1
- package/dist/commands/comments.js +6 -0
- package/dist/commands/config.js +164 -1
- package/dist/commands/issues/tree.d.ts +22 -0
- package/dist/commands/issues/tree.js +102 -0
- package/dist/commands/issues.js +40 -5
- package/dist/commands/read-shortcut.js +57 -13
- package/dist/config/migrate-from-personal.d.ts +77 -0
- package/dist/config/migrate-from-personal.js +236 -0
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +1 -0
- package/dist/queries/issue-tree.d.ts +48 -0
- package/dist/queries/issue-tree.js +67 -0
- package/dist/queries/issues-types.d.ts +12 -0
- package/dist/queries/issues.d.ts +29 -7
- package/dist/queries/issues.js +30 -0
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/types/linear.d.ts +7 -0
- package/dist/utils/extract-field.d.ts +16 -0
- package/dist/utils/extract-field.js +22 -0
- package/dist/utils/format-tree.d.ts +14 -0
- package/dist/utils/format-tree.js +47 -0
- package/dist/utils/graphql-issues-service.d.ts +26 -0
- package/dist/utils/graphql-issues-service.js +109 -2
- package/package.json +2 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
2
|
-
import { extractField } from "../utils/extract-field.js";
|
|
2
|
+
import { extractField, extractFields } from "../utils/extract-field.js";
|
|
3
3
|
import { createFileService } from "../utils/file-service.js";
|
|
4
4
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
5
|
-
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
6
6
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
7
|
/**
|
|
8
8
|
* Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
|
|
@@ -23,7 +23,10 @@ export function setupReadShortcut(program) {
|
|
|
23
23
|
.option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
|
|
24
24
|
"Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
|
|
25
25
|
"Outputs the section text only — no JSON envelope.")
|
|
26
|
-
.
|
|
26
|
+
.option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope,Steps"). ' +
|
|
27
|
+
"Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
|
|
28
|
+
"Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
|
|
29
|
+
.addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"')
|
|
27
30
|
.action(handleAsyncCommand(readIssues));
|
|
28
31
|
// Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
|
|
29
32
|
const originalParse = program.parse.bind(program);
|
|
@@ -61,12 +64,28 @@ export async function readIssues(issueIds, options, command) {
|
|
|
61
64
|
const { issuesService } = await createIssuesService(rootOpts);
|
|
62
65
|
const fileService = await createFileService(rootOpts);
|
|
63
66
|
const fieldName = typeof options.field === "string" ? options.field : null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
|
|
68
|
+
if (fieldName && sectionsRaw) {
|
|
69
|
+
throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
|
|
70
|
+
}
|
|
71
|
+
// Both --field and --sections are single-issue only — section extraction
|
|
72
|
+
// can't sensibly fan out to N different bodies, the caller almost always
|
|
73
|
+
// wants the named sections of one issue.
|
|
74
|
+
if ((fieldName || sectionsRaw) && issueIds.length > 1) {
|
|
75
|
+
throw new Error("--field / --sections are single-issue only; pass exactly one issueId. " +
|
|
76
|
+
"For multiple issues, drop the section flags and use --jq or --format summary.");
|
|
77
|
+
}
|
|
78
|
+
// Parse the comma-separated --sections list once. Preserve the caller's
|
|
79
|
+
// order, trim each entry, and drop empties so trailing commas don't
|
|
80
|
+
// surface as ghost "" sections.
|
|
81
|
+
const sectionNames = sectionsRaw
|
|
82
|
+
? sectionsRaw
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter((s) => s.length > 0)
|
|
86
|
+
: null;
|
|
87
|
+
if (sectionNames !== null && sectionNames.length === 0) {
|
|
88
|
+
throw new Error("--sections was empty after trimming. Pass a comma-separated list of section names.");
|
|
70
89
|
}
|
|
71
90
|
if (issueIds.length === 1) {
|
|
72
91
|
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
@@ -83,13 +102,38 @@ export async function readIssues(issueIds, options, command) {
|
|
|
83
102
|
process.stdout.write(`${section}\n`);
|
|
84
103
|
return;
|
|
85
104
|
}
|
|
105
|
+
if (sectionNames !== null) {
|
|
106
|
+
const sectionsMap = extractFields(resolved.description ?? "", sectionNames);
|
|
107
|
+
const sections = {};
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const [name, text] of sectionsMap) {
|
|
110
|
+
sections[name] = text;
|
|
111
|
+
if (text === null)
|
|
112
|
+
missing.push(name);
|
|
113
|
+
}
|
|
114
|
+
if (missing.length > 0) {
|
|
115
|
+
// Coalesce: one warning naming every missing section is denser than
|
|
116
|
+
// N entries and easier for an agent to act on (cycle-1 nit).
|
|
117
|
+
outputWarning(`sections not found in ${resolved.identifier}'s description: ${missing
|
|
118
|
+
.map((n) => `"${n}"`)
|
|
119
|
+
.join(", ")}`);
|
|
120
|
+
}
|
|
121
|
+
outputSuccess({
|
|
122
|
+
identifier: resolved.identifier,
|
|
123
|
+
sections,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
86
127
|
outputSuccess(resolved);
|
|
87
128
|
}
|
|
88
129
|
else {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
// DEV-4477: one batched GraphQL call instead of N parallel single-issue
|
|
131
|
+
// queries. The service preserves input order and throws notFoundError
|
|
132
|
+
// on any missing ref. Attachment download still fans out per-issue —
|
|
133
|
+
// that's HTTP, not GraphQL, and downloadLinearUploads is a no-op when
|
|
134
|
+
// there's nothing to download.
|
|
135
|
+
const issues = await issuesService.getIssuesByRefs(issueIds);
|
|
136
|
+
const results = await Promise.all(issues.map((issue) => downloadLinearUploads(issue, fileService)));
|
|
93
137
|
outputSuccess(results);
|
|
94
138
|
}
|
|
95
139
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan + apply the personal-to-team-backed config slim.
|
|
3
|
+
*
|
|
4
|
+
* After the team-config split (DEV-4172 / ALL-964) members typically still
|
|
5
|
+
* carry full duplicates of the now-shared keys in their personal/profile
|
|
6
|
+
* configs. The duplicates are silent shadows when identical and silent
|
|
7
|
+
* divergences when they drift. This module computes a per-file plan:
|
|
8
|
+
*
|
|
9
|
+
* - **Top-level shadowable keys** (`members`, `teams`, `labels`,
|
|
10
|
+
* `statusDefaults`, `teamAliases`) are *dropped* only when the personal
|
|
11
|
+
* copy is a strict subset of team (zero divergence and zero non-trivial
|
|
12
|
+
* personal-only entries). Otherwise the key is left untouched and the
|
|
13
|
+
* diff is reported, so a human resolves it.
|
|
14
|
+
* - The deprecated `brand: { name, reject }` key is dropped when content-
|
|
15
|
+
* identical to an existing team `terms[]` entry; otherwise it is
|
|
16
|
+
* *converted* into a personal `terms[]` entry (no shadowing of team).
|
|
17
|
+
* - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
|
|
18
|
+
* `teamConfigPath`, anything not in the shadowable set or `brand`) are
|
|
19
|
+
* never touched.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
|
|
22
|
+
*/
|
|
23
|
+
/** Top-level keys typically owned by the team config layer. */
|
|
24
|
+
export declare const TEAM_SHADOWABLE_KEYS: readonly ["members", "teams", "labels", "statusDefaults", "teamAliases"];
|
|
25
|
+
export type TeamShadowableKey = (typeof TEAM_SHADOWABLE_KEYS)[number];
|
|
26
|
+
export type JsonValue = string | number | boolean | null | {
|
|
27
|
+
[k: string]: JsonValue;
|
|
28
|
+
} | JsonValue[];
|
|
29
|
+
export type JsonObject = {
|
|
30
|
+
[k: string]: JsonValue;
|
|
31
|
+
};
|
|
32
|
+
/** Per-key decision in the plan. */
|
|
33
|
+
export interface KeyAction {
|
|
34
|
+
key: string;
|
|
35
|
+
action: "drop" | "keep-divergent" | "keep-additions" | "absent";
|
|
36
|
+
/** Leaves whose personal value matched team exactly. */
|
|
37
|
+
matchCount: number;
|
|
38
|
+
/** Leaves whose personal value differs from team (would be a silent shadow). */
|
|
39
|
+
divergentCount: number;
|
|
40
|
+
/** Non-trivial personal-only leaves (would be lost if dropped). */
|
|
41
|
+
additionCount: number;
|
|
42
|
+
/** First few divergent / addition paths, for human-readable context. */
|
|
43
|
+
sampleDivergent?: string[];
|
|
44
|
+
sampleAdditions?: string[];
|
|
45
|
+
}
|
|
46
|
+
/** What we did with the deprecated `brand` key. */
|
|
47
|
+
export interface BrandAction {
|
|
48
|
+
status: "absent" | "drop-duplicate" | "convert-to-term" /** Couldn't convert safely (e.g. existing personal `terms` is malformed). */ | "keep-malformed";
|
|
49
|
+
/** When status === "convert-to-term", the new entry appended to terms[]. */
|
|
50
|
+
convertedTo?: {
|
|
51
|
+
canonical: string;
|
|
52
|
+
reject: string[];
|
|
53
|
+
};
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface MigrationPlan {
|
|
57
|
+
keys: KeyAction[];
|
|
58
|
+
brand: BrandAction;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface MigrationResult {
|
|
62
|
+
slimmed: JsonObject;
|
|
63
|
+
plan: MigrationPlan;
|
|
64
|
+
}
|
|
65
|
+
/** A canonical JSON-friendly deep equality. */
|
|
66
|
+
export declare function deepEqual(a: JsonValue, b: JsonValue): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Compute the slimmed config + the plan that explains it. Pure.
|
|
69
|
+
*
|
|
70
|
+
* @param personal The personal/profile config object as parsed from disk.
|
|
71
|
+
* @param team The active team config object as parsed from disk. Pass
|
|
72
|
+
* `{}` if no team config is configured — every shadowable
|
|
73
|
+
* key becomes either `keep-additions` or `keep-divergent`,
|
|
74
|
+
* so nothing gets dropped (correct: without team there is
|
|
75
|
+
* nothing to fall back to).
|
|
76
|
+
*/
|
|
77
|
+
export declare function planMigration(personal: JsonObject, team: JsonObject): MigrationResult;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan + apply the personal-to-team-backed config slim.
|
|
3
|
+
*
|
|
4
|
+
* After the team-config split (DEV-4172 / ALL-964) members typically still
|
|
5
|
+
* carry full duplicates of the now-shared keys in their personal/profile
|
|
6
|
+
* configs. The duplicates are silent shadows when identical and silent
|
|
7
|
+
* divergences when they drift. This module computes a per-file plan:
|
|
8
|
+
*
|
|
9
|
+
* - **Top-level shadowable keys** (`members`, `teams`, `labels`,
|
|
10
|
+
* `statusDefaults`, `teamAliases`) are *dropped* only when the personal
|
|
11
|
+
* copy is a strict subset of team (zero divergence and zero non-trivial
|
|
12
|
+
* personal-only entries). Otherwise the key is left untouched and the
|
|
13
|
+
* diff is reported, so a human resolves it.
|
|
14
|
+
* - The deprecated `brand: { name, reject }` key is dropped when content-
|
|
15
|
+
* identical to an existing team `terms[]` entry; otherwise it is
|
|
16
|
+
* *converted* into a personal `terms[]` entry (no shadowing of team).
|
|
17
|
+
* - Genuinely personal keys (`defaultTeam`, `defaultLabels`,
|
|
18
|
+
* `teamConfigPath`, anything not in the shadowable set or `brand`) are
|
|
19
|
+
* never touched.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no fs / no process / no logging. The caller does I/O. DEV-4458.
|
|
22
|
+
*/
|
|
23
|
+
/** Top-level keys typically owned by the team config layer. */
|
|
24
|
+
export const TEAM_SHADOWABLE_KEYS = [
|
|
25
|
+
"members",
|
|
26
|
+
"teams",
|
|
27
|
+
"labels",
|
|
28
|
+
"statusDefaults",
|
|
29
|
+
"teamAliases",
|
|
30
|
+
];
|
|
31
|
+
/** A canonical JSON-friendly deep equality. */
|
|
32
|
+
export function deepEqual(a, b) {
|
|
33
|
+
if (a === b)
|
|
34
|
+
return true;
|
|
35
|
+
if (a === null || b === null)
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof a !== typeof b)
|
|
38
|
+
return false;
|
|
39
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
40
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
41
|
+
return false;
|
|
42
|
+
if (a.length !== b.length)
|
|
43
|
+
return false;
|
|
44
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
45
|
+
}
|
|
46
|
+
if (typeof a === "object") {
|
|
47
|
+
const ak = Object.keys(a).sort();
|
|
48
|
+
const bk = Object.keys(b).sort();
|
|
49
|
+
if (ak.length !== bk.length)
|
|
50
|
+
return false;
|
|
51
|
+
if (ak.some((k, i) => k !== bk[i]))
|
|
52
|
+
return false;
|
|
53
|
+
return ak.every((k) => deepEqual(a[k], b[k]));
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function isTriviallyEmpty(v) {
|
|
58
|
+
if (v === undefined || v === null)
|
|
59
|
+
return true;
|
|
60
|
+
if (Array.isArray(v))
|
|
61
|
+
return v.length === 0;
|
|
62
|
+
if (typeof v === "object")
|
|
63
|
+
return Object.keys(v).length === 0;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function isPlainObject(v) {
|
|
67
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Walk personal and classify each LEAF against team at the same path:
|
|
71
|
+
* match — present in team with the same value
|
|
72
|
+
* divergent — present in team with a different value (would silently shadow)
|
|
73
|
+
* addition — absent from team and non-trivially non-empty (would be lost)
|
|
74
|
+
*
|
|
75
|
+
* Trivially-empty containers (e.g. `members.handles.github = {}`) are not
|
|
76
|
+
* counted as additions — dropping them loses no information.
|
|
77
|
+
*/
|
|
78
|
+
function classifyDeep(personal, team, path, out) {
|
|
79
|
+
if (personal === undefined)
|
|
80
|
+
return;
|
|
81
|
+
// Both objects → recurse into keys.
|
|
82
|
+
if (isPlainObject(personal) && isPlainObject(team)) {
|
|
83
|
+
for (const k of Object.keys(personal)) {
|
|
84
|
+
classifyDeep(personal[k], team[k], path ? `${path}.${k}` : k, out);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Personal is a leaf (primitive / array / object without a matching team obj).
|
|
89
|
+
if (team === undefined) {
|
|
90
|
+
if (!isTriviallyEmpty(personal))
|
|
91
|
+
out.additions.push(path);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (deepEqual(personal, team)) {
|
|
95
|
+
out.matches += 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
out.divergent.push(path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function planTopLevelKey(key, personal, team) {
|
|
102
|
+
if (personal === undefined) {
|
|
103
|
+
return {
|
|
104
|
+
key,
|
|
105
|
+
action: "absent",
|
|
106
|
+
matchCount: 0,
|
|
107
|
+
divergentCount: 0,
|
|
108
|
+
additionCount: 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const acc = { matches: 0, divergent: [], additions: [] };
|
|
112
|
+
classifyDeep(personal, team, key, acc);
|
|
113
|
+
const action = acc.divergent.length === 0 && acc.additions.length === 0
|
|
114
|
+
? "drop"
|
|
115
|
+
: acc.divergent.length > 0
|
|
116
|
+
? "keep-divergent"
|
|
117
|
+
: "keep-additions";
|
|
118
|
+
const ka = {
|
|
119
|
+
key,
|
|
120
|
+
action,
|
|
121
|
+
matchCount: acc.matches,
|
|
122
|
+
divergentCount: acc.divergent.length,
|
|
123
|
+
additionCount: acc.additions.length,
|
|
124
|
+
};
|
|
125
|
+
if (acc.divergent.length > 0)
|
|
126
|
+
ka.sampleDivergent = acc.divergent.slice(0, 5);
|
|
127
|
+
if (acc.additions.length > 0)
|
|
128
|
+
ka.sampleAdditions = acc.additions.slice(0, 5);
|
|
129
|
+
return ka;
|
|
130
|
+
}
|
|
131
|
+
function readBrand(personal) {
|
|
132
|
+
const raw = personal.brand;
|
|
133
|
+
if (!isPlainObject(raw))
|
|
134
|
+
return null;
|
|
135
|
+
const name = raw.name;
|
|
136
|
+
const reject = raw.reject;
|
|
137
|
+
if (typeof name !== "string")
|
|
138
|
+
return null;
|
|
139
|
+
if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return { name, reject: reject };
|
|
143
|
+
}
|
|
144
|
+
function readTeamTerms(team) {
|
|
145
|
+
const t = team.terms;
|
|
146
|
+
if (!Array.isArray(t))
|
|
147
|
+
return [];
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const e of t) {
|
|
150
|
+
if (!isPlainObject(e))
|
|
151
|
+
continue;
|
|
152
|
+
const canonical = e.canonical;
|
|
153
|
+
const reject = e.reject;
|
|
154
|
+
if (typeof canonical !== "string")
|
|
155
|
+
continue;
|
|
156
|
+
if (!Array.isArray(reject) || !reject.every((r) => typeof r === "string")) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
out.push({ canonical, reject: reject });
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function brandMatchesTerm(brand, term) {
|
|
164
|
+
return brand.name === term.canonical && deepEqual(brand.reject, term.reject);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Compute the slimmed config + the plan that explains it. Pure.
|
|
168
|
+
*
|
|
169
|
+
* @param personal The personal/profile config object as parsed from disk.
|
|
170
|
+
* @param team The active team config object as parsed from disk. Pass
|
|
171
|
+
* `{}` if no team config is configured — every shadowable
|
|
172
|
+
* key becomes either `keep-additions` or `keep-divergent`,
|
|
173
|
+
* so nothing gets dropped (correct: without team there is
|
|
174
|
+
* nothing to fall back to).
|
|
175
|
+
*/
|
|
176
|
+
export function planMigration(personal, team) {
|
|
177
|
+
const warnings = [];
|
|
178
|
+
const slimmed = { ...personal };
|
|
179
|
+
const keyActions = TEAM_SHADOWABLE_KEYS.map((key) => planTopLevelKey(key, personal[key], team[key]));
|
|
180
|
+
for (const ka of keyActions) {
|
|
181
|
+
if (ka.action === "drop")
|
|
182
|
+
delete slimmed[ka.key];
|
|
183
|
+
}
|
|
184
|
+
// Brand → terms.
|
|
185
|
+
let brand;
|
|
186
|
+
const brandShape = readBrand(personal);
|
|
187
|
+
if (brandShape === null) {
|
|
188
|
+
brand = { status: "absent" };
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const teamTerms = readTeamTerms(team);
|
|
192
|
+
const dup = teamTerms.find((t) => brandMatchesTerm(brandShape, t));
|
|
193
|
+
if (dup !== undefined) {
|
|
194
|
+
delete slimmed.brand;
|
|
195
|
+
brand = {
|
|
196
|
+
status: "drop-duplicate",
|
|
197
|
+
reason: `brand is content-identical to team terms entry "${dup.canonical}"`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
else if (personal.terms !== undefined && !Array.isArray(personal.terms)) {
|
|
201
|
+
// Refuse to convert: an existing personal `terms` field is not an
|
|
202
|
+
// array (likely a hand-edit gone wrong). The strict-subset gate
|
|
203
|
+
// elsewhere never silently destroys info — neither should this
|
|
204
|
+
// path. Leave brand AND terms as-is; surface as a warning so a
|
|
205
|
+
// human can resolve.
|
|
206
|
+
brand = {
|
|
207
|
+
status: "keep-malformed",
|
|
208
|
+
reason: "existing personal 'terms' field is not an array; leaving 'brand' and 'terms' untouched for human review",
|
|
209
|
+
};
|
|
210
|
+
warnings.push(`Cannot convert deprecated 'brand': existing 'terms' field is not an array (got ${typeof personal.terms}). Left 'brand' and 'terms' as-is — fix 'terms' to a proper array of { canonical, reject } entries and re-run.`);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Convert: append a personal terms entry, drop brand.
|
|
214
|
+
delete slimmed.brand;
|
|
215
|
+
const existing = Array.isArray(slimmed.terms)
|
|
216
|
+
? slimmed.terms
|
|
217
|
+
: [];
|
|
218
|
+
const converted = {
|
|
219
|
+
canonical: brandShape.name,
|
|
220
|
+
reject: brandShape.reject,
|
|
221
|
+
};
|
|
222
|
+
slimmed.terms = [...existing, { ...converted }];
|
|
223
|
+
brand = {
|
|
224
|
+
status: "convert-to-term",
|
|
225
|
+
convertedTo: converted,
|
|
226
|
+
reason: teamTerms.length === 0
|
|
227
|
+
? "team config has no terms; converted brand to a personal terms entry"
|
|
228
|
+
: "brand does not match any team terms entry; converted to a personal terms entry",
|
|
229
|
+
};
|
|
230
|
+
if (teamTerms.length > 0) {
|
|
231
|
+
warnings.push(`brand on this file diverges from team terms — preserved as a personal terms entry. Review whether the team config should adopt it.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { slimmed, plan: { keys: keyActions, brand, warnings } };
|
|
236
|
+
}
|
package/dist/queries/common.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
|
|
2
|
-
export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
|
|
1
|
+
export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
|
|
2
|
+
export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
|
package/dist/queries/common.js
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-fetch query builder for `issues tree <ID>` (DEV-4480).
|
|
3
|
+
*
|
|
4
|
+
* GraphQL has no native depth-N recursion, so the query is generated by
|
|
5
|
+
* nesting `children { nodes { ... } }` N times at build time. This keeps
|
|
6
|
+
* the *fetch* to a single round-trip even for deep trees, at the cost of
|
|
7
|
+
* a larger query string.
|
|
8
|
+
*
|
|
9
|
+
* Each level fetches the minimal field set needed to render a tree entry:
|
|
10
|
+
* id, identifier, title, state.{name,type}, assignee.name. State.type is
|
|
11
|
+
* present so the renderer can flag terminal states (Done / Canceled) and
|
|
12
|
+
* the caller's `--include-closed` filter can be applied client-side after
|
|
13
|
+
* the fetch (Linear doesn't accept a top-level filter that scopes to the
|
|
14
|
+
* issue subtree — the children connection is filterless).
|
|
15
|
+
*
|
|
16
|
+
* Depth bounds: `1` (just the root + its direct children) through `5`.
|
|
17
|
+
* Above 5 the query string grows too large to be useful as a single call;
|
|
18
|
+
* callers wanting deeper walks should iterate `tree` on leaf nodes.
|
|
19
|
+
*/
|
|
20
|
+
export declare const MIN_TREE_DEPTH = 1;
|
|
21
|
+
export declare const MAX_TREE_DEPTH = 5;
|
|
22
|
+
export declare const DEFAULT_TREE_DEPTH = 3;
|
|
23
|
+
export declare function buildIssueTreeQuery(depth: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Shape of a single tree node — used for both the GraphQL response and
|
|
26
|
+
* the post-filter return value (after `--include-closed` is applied
|
|
27
|
+
* client-side).
|
|
28
|
+
*/
|
|
29
|
+
export interface IssueTreeNode {
|
|
30
|
+
id: string;
|
|
31
|
+
identifier: string;
|
|
32
|
+
title: string;
|
|
33
|
+
state: {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
type: string;
|
|
37
|
+
} | null;
|
|
38
|
+
assignee: {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
} | null;
|
|
42
|
+
children?: {
|
|
43
|
+
nodes: IssueTreeNode[];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface GetIssueTreeResponse {
|
|
47
|
+
issue: IssueTreeNode | null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree-fetch query builder for `issues tree <ID>` (DEV-4480).
|
|
3
|
+
*
|
|
4
|
+
* GraphQL has no native depth-N recursion, so the query is generated by
|
|
5
|
+
* nesting `children { nodes { ... } }` N times at build time. This keeps
|
|
6
|
+
* the *fetch* to a single round-trip even for deep trees, at the cost of
|
|
7
|
+
* a larger query string.
|
|
8
|
+
*
|
|
9
|
+
* Each level fetches the minimal field set needed to render a tree entry:
|
|
10
|
+
* id, identifier, title, state.{name,type}, assignee.name. State.type is
|
|
11
|
+
* present so the renderer can flag terminal states (Done / Canceled) and
|
|
12
|
+
* the caller's `--include-closed` filter can be applied client-side after
|
|
13
|
+
* the fetch (Linear doesn't accept a top-level filter that scopes to the
|
|
14
|
+
* issue subtree — the children connection is filterless).
|
|
15
|
+
*
|
|
16
|
+
* Depth bounds: `1` (just the root + its direct children) through `5`.
|
|
17
|
+
* Above 5 the query string grows too large to be useful as a single call;
|
|
18
|
+
* callers wanting deeper walks should iterate `tree` on leaf nodes.
|
|
19
|
+
*/
|
|
20
|
+
export const MIN_TREE_DEPTH = 1;
|
|
21
|
+
export const MAX_TREE_DEPTH = 5;
|
|
22
|
+
export const DEFAULT_TREE_DEPTH = 3;
|
|
23
|
+
// Field set per tree node — kept minimal so the depth-N query stays small.
|
|
24
|
+
// `state.type` is required client-side for `--no-include-closed` pruning;
|
|
25
|
+
// `state.name` for the [Done]/[Canceled] suffix on terminal nodes.
|
|
26
|
+
// `priority` was intentionally dropped after cycle-1: the ASCII formatter
|
|
27
|
+
// doesn't render it and including it inflates the query string by ~N nodes.
|
|
28
|
+
const TREE_NODE_FIELDS = `
|
|
29
|
+
id
|
|
30
|
+
identifier
|
|
31
|
+
title
|
|
32
|
+
state { id name type }
|
|
33
|
+
assignee { id name }
|
|
34
|
+
`;
|
|
35
|
+
/**
|
|
36
|
+
* Recursive children fragment generator. `depth=0` emits nothing (no
|
|
37
|
+
* children fetched at this level — terminal); `depth=1` emits a single
|
|
38
|
+
* `children { nodes { ...fields } }`; higher depths nest further.
|
|
39
|
+
*/
|
|
40
|
+
function buildChildrenBlock(depth) {
|
|
41
|
+
if (depth <= 0) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return `
|
|
45
|
+
children {
|
|
46
|
+
nodes {
|
|
47
|
+
${TREE_NODE_FIELDS}
|
|
48
|
+
${buildChildrenBlock(depth - 1)}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
export function buildIssueTreeQuery(depth) {
|
|
54
|
+
if (!Number.isInteger(depth) ||
|
|
55
|
+
depth < MIN_TREE_DEPTH ||
|
|
56
|
+
depth > MAX_TREE_DEPTH) {
|
|
57
|
+
throw new Error(`Tree depth must be an integer in [${MIN_TREE_DEPTH}, ${MAX_TREE_DEPTH}]; got ${depth}`);
|
|
58
|
+
}
|
|
59
|
+
return `
|
|
60
|
+
query GetIssueTree($id: String!) {
|
|
61
|
+
issue(id: $id) {
|
|
62
|
+
${TREE_NODE_FIELDS}
|
|
63
|
+
${buildChildrenBlock(depth)}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
@@ -25,6 +25,12 @@ import type { LinearPriority } from "../types/linear.js";
|
|
|
25
25
|
interface IdNameRef {
|
|
26
26
|
id: string;
|
|
27
27
|
name: string;
|
|
28
|
+
/**
|
|
29
|
+
* Workflow-state type — selected only on state fragments. Optional so
|
|
30
|
+
* non-state IdNameRef users (project, team, assignee, etc.) stay valid.
|
|
31
|
+
* DEV-4478.
|
|
32
|
+
*/
|
|
33
|
+
type?: string;
|
|
28
34
|
}
|
|
29
35
|
interface IdKeyNameRef {
|
|
30
36
|
id: string;
|
|
@@ -134,6 +140,12 @@ export interface GetIssueByIdentifierResponse {
|
|
|
134
140
|
nodes: IssueWithCommentsNode[];
|
|
135
141
|
};
|
|
136
142
|
}
|
|
143
|
+
/** Response shape for `BATCH_GET_ISSUES_QUERY` (DEV-4477). */
|
|
144
|
+
export interface BatchGetIssuesResponse {
|
|
145
|
+
issues: {
|
|
146
|
+
nodes: IssueWithCommentsNode[];
|
|
147
|
+
};
|
|
148
|
+
}
|
|
137
149
|
/** Response shape for `GET_ISSUES_QUERY`. */
|
|
138
150
|
export interface GetIssuesResponse {
|
|
139
151
|
issues: {
|