@enrichlayer/el-linear 1.43.0 → 1.44.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 +45 -3
- package/claude-skills/linear-operations/SKILL.md +8 -0
- package/dist/commands/comments.js +43 -1
- package/dist/commands/issues.js +14 -0
- package/dist/commands/profile.d.ts +20 -0
- package/dist/commands/profile.js +150 -2
- package/dist/commands/projects.js +22 -3
- package/dist/config/intake-decision-validation.d.ts +22 -0
- package/dist/config/intake-decision-validation.js +174 -49
- package/dist/config/pin-hint.d.ts +36 -0
- package/dist/config/pin-hint.js +64 -0
- package/dist/config/repo-profile.d.ts +100 -0
- package/dist/config/repo-profile.js +274 -0
- package/dist/main.js +24 -2
- package/dist/queries/projects-types.d.ts +6 -0
- package/dist/queries/projects.d.ts +1 -1
- package/dist/queries/projects.js +6 -0
- package/dist/utils/formatters/summary.js +7 -0
- package/dist/utils/graphql-issues-service.js +1 -1
- package/dist/utils/graphql-service.d.ts +20 -2
- package/dist/utils/graphql-service.js +40 -4
- package/dist/utils/linear-service.d.ts +17 -0
- package/dist/utils/linear-service.js +28 -0
- package/dist/utils/protected-ranges.d.ts +2 -2
- package/dist/utils/protected-ranges.js +9 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -145,6 +145,33 @@ EL_LINEAR_PROFILE=forage el-linear teams list
|
|
|
145
145
|
el-linear profile remove old-profile
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
### Recommended for multiple workspaces: pin the repo
|
|
149
|
+
|
|
150
|
+
If you work across several workspaces, **pin each repo to the profile it belongs to** instead of relying on `profile use`. A pin is scoped to the repo, so a write in that repo always targets the right workspace no matter what the machine-global default happens to be.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# In a repo, pin it to the profile it belongs to (defaults to the active one):
|
|
154
|
+
el-linear profile pin forage
|
|
155
|
+
|
|
156
|
+
# Show the resolved pin + where it came from:
|
|
157
|
+
el-linear profile pin --show
|
|
158
|
+
|
|
159
|
+
# Pin this exact origin owner/repo in your global user config instead:
|
|
160
|
+
el-linear profile pin work --global
|
|
161
|
+
|
|
162
|
+
# Remove the repo-local pin:
|
|
163
|
+
el-linear profile unpin
|
|
164
|
+
|
|
165
|
+
# Remove this repo's exact owner/repo entry from the global user config:
|
|
166
|
+
el-linear profile unpin --global
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A repo-local pin is stored in `.el-git.json` at the repo root as `{ "linearProfile": "<name>" }`. el-linear, el-git, and el-session read the same file and resolve the repo to the same workspace.
|
|
170
|
+
|
|
171
|
+
`--global` writes the exact origin `owner/repo` entry into `~/.config/el-git/linear-profiles.json`; the resolver also honors manually configured `owner/*` wildcard entries. The repo-local file wins over the global table.
|
|
172
|
+
|
|
173
|
+
When a write command (`issues create` / `comments create`) runs under the bare machine-global default in a repo with no effective pin, el-linear prints a one-line, non-blocking stderr hint suggesting `profile pin`. Silence it with `EL_LINEAR_NO_PIN_HINT=1` (or `--quiet`).
|
|
174
|
+
|
|
148
175
|
Fix or clean up a single member's aliases/handles without hand-editing
|
|
149
176
|
`config.json` — `profile members` operates directly on the active profile's
|
|
150
177
|
on-disk config (no Linear API round-trip):
|
|
@@ -164,8 +191,11 @@ The active profile is selected by, in priority:
|
|
|
164
191
|
|
|
165
192
|
1. `--profile <name>` flag (per-invocation)
|
|
166
193
|
2. `EL_LINEAR_PROFILE` env var
|
|
167
|
-
3.
|
|
168
|
-
|
|
194
|
+
3. **Repo pin** — `.el-git.json` at the repo root, else the `owner/repo` /
|
|
195
|
+
`owner/*` entry in `~/.config/el-git/linear-profiles.json` (see
|
|
196
|
+
[Recommended for multiple workspaces](#recommended-for-multiple-workspaces-pin-the-repo))
|
|
197
|
+
4. `~/.config/el-linear/active-profile` (one-line marker, written by `profile use`)
|
|
198
|
+
5. Legacy single-file layout (`~/.config/el-linear/{token,config.json}`)
|
|
169
199
|
|
|
170
200
|
The legacy fallback means **existing single-profile users see no
|
|
171
201
|
behavior change** — profiles are purely opt-in.
|
|
@@ -180,7 +210,10 @@ flag and env var are per-invocation and never touch the marker file, so they
|
|
|
180
210
|
compose safely with any number of concurrent sessions each pinned to their
|
|
181
211
|
own workspace. Reserve `profile use` for a human's own interactive default
|
|
182
212
|
switch; `profile use`/`profile add` print a loud warning when they change the
|
|
183
|
-
global marker so the blast radius is visible instead of silent.
|
|
213
|
+
global marker so the blast radius is visible instead of silent. Better still
|
|
214
|
+
for a human who works across workspaces: **`profile pin` each repo** (above) —
|
|
215
|
+
the pin sits above the global marker in the precedence list, so the right
|
|
216
|
+
workspace is selected automatically per-repo without any per-command flag.
|
|
184
217
|
|
|
185
218
|
## Migrating from v1.0–1.3
|
|
186
219
|
|
|
@@ -731,6 +764,15 @@ el-linear comments list DEV-123 --body
|
|
|
731
764
|
# Full comment body...
|
|
732
765
|
```
|
|
733
766
|
|
|
767
|
+
Both `--body` surfaces treat "no text to print" as a failure rather than an
|
|
768
|
+
empty success, so a caller never has to guess whether silence meant "nothing is
|
|
769
|
+
there" or "the fetch silently dropped it": `comments read --body` exits 1 when
|
|
770
|
+
the comment has no body, and `comments list --body` exits 1 with
|
|
771
|
+
`el-linear: DEV-123 has no comments` (or `… has N comments, none with a body`)
|
|
772
|
+
and writes nothing to stdout. The structured surfaces need no such signal and
|
|
773
|
+
stay exit 0 — `--format summary` prints `(no results)` and the JSON envelope
|
|
774
|
+
carries `meta.count`.
|
|
775
|
+
|
|
734
776
|
### One-line write confirmations: `-q, --quiet`
|
|
735
777
|
|
|
736
778
|
`issues create|update`, `issues relate`, and `comments create|update` accept
|
|
@@ -121,6 +121,14 @@ el-linear comments list DEV-123 --format json 2>&1 | python3 -c "import json,sys
|
|
|
121
121
|
`#comment-<hash>`. `comments list --format summary` includes each comment id
|
|
122
122
|
so you can copy it straight into `comments read`.
|
|
123
123
|
|
|
124
|
+
Empty output from a `--body` surface is never a silent success. `comments list
|
|
125
|
+
--body` exits 1 with `el-linear: DEV-123 has no comments` on stderr when the
|
|
126
|
+
issue has none (and `… has N comments, none with a body` when every body came
|
|
127
|
+
back blank), matching `comments read --body`'s guard — so "no comments" stays
|
|
128
|
+
distinguishable from "the fetch silently dropped the list". `--format summary`
|
|
129
|
+
(`(no results)`) and the JSON envelope (`meta.count`) report emptiness in band
|
|
130
|
+
and stay exit 0.
|
|
131
|
+
|
|
124
132
|
### Attachment reads and downloads
|
|
125
133
|
|
|
126
134
|
List attachments first, then use the attachment ID or exact title. Text files
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
|
|
2
3
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
4
|
import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
4
5
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
@@ -134,6 +135,44 @@ function formatCommentBodyBlocks(comments) {
|
|
|
134
135
|
})
|
|
135
136
|
.join("\n\n---\n\n");
|
|
136
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Why the `--body` list surface has nothing to print, or `null` when it does.
|
|
140
|
+
*
|
|
141
|
+
* Split out from the printer so the two "no text" cases stay distinguishable in
|
|
142
|
+
* the message: zero comments is a fact about the issue, while comments that all
|
|
143
|
+
* carry empty bodies is the shape a partially-dropped payload takes.
|
|
144
|
+
*/
|
|
145
|
+
function describeMissingCommentBodies(comments) {
|
|
146
|
+
if (comments.length === 0) {
|
|
147
|
+
return "has no comments";
|
|
148
|
+
}
|
|
149
|
+
const hasBody = comments.some((comment) => typeof comment.body === "string" && comment.body.trim() !== "");
|
|
150
|
+
if (!hasBody) {
|
|
151
|
+
return `has ${comments.length} comment${comments.length === 1 ? "" : "s"}, none with a body`;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* List-surface sibling of `printRawCommentBody`. `--body` is a raw-text
|
|
157
|
+
* extraction contract, so writing a lone newline and exiting 0 would make "this
|
|
158
|
+
* issue has no comments" byte-identical to "the fetch or render silently
|
|
159
|
+
* dropped the list" — no caller could tell the two apart. Mirror the
|
|
160
|
+
* single-comment guard exactly: nothing on stdout, a stderr hint, exit 1, so
|
|
161
|
+
* scripts branch on the exit code (DEV-7160).
|
|
162
|
+
*
|
|
163
|
+
* The structured surfaces need no such guard — `--format summary` prints
|
|
164
|
+
* `(no results)` and the JSON envelope carries `meta.count`, both of which say
|
|
165
|
+
* "empty" in band.
|
|
166
|
+
*/
|
|
167
|
+
function printRawCommentBodyBlocks(comments, label) {
|
|
168
|
+
const missing = describeMissingCommentBodies(comments);
|
|
169
|
+
if (missing === null) {
|
|
170
|
+
process.stdout.write(`${formatCommentBodyBlocks(comments)}\n`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
process.stderr.write(`el-linear: ${label} ${missing}\n`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
137
176
|
/**
|
|
138
177
|
* Wrap valid issue references in a comment body as markdown links — same logic as for
|
|
139
178
|
* issue descriptions. Returns the rewritten body plus the validated identifier→UUID map
|
|
@@ -186,6 +225,9 @@ async function autoLinkCommentReferences(args) {
|
|
|
186
225
|
}
|
|
187
226
|
async function handleCreateComment(issueId, options, command) {
|
|
188
227
|
const rootOpts = getRootOpts(command);
|
|
228
|
+
// DEV-7277: non-blocking nudge when this comment is landing via the
|
|
229
|
+
// machine-global active profile in a repo that hasn't pinned a workspace.
|
|
230
|
+
maybeEmitPinMismatchHint({ hasProfileFlag: Boolean(rootOpts.profile) });
|
|
189
231
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
190
232
|
const linearService = await createLinearService(rootOpts);
|
|
191
233
|
// Apply messageFooter (config or --footer flag) before any further
|
|
@@ -358,7 +400,7 @@ async function handleListComments(issueId, options, command) {
|
|
|
358
400
|
const fullBodySummary = options.truncate === false && getOutputFormat() === "summary";
|
|
359
401
|
const nodes = result.issue.comments.nodes.map((comment) => transformComment(comment, { fullBodySummary }));
|
|
360
402
|
if (options.body === true) {
|
|
361
|
-
|
|
403
|
+
printRawCommentBodyBlocks(nodes, result.issue.identifier);
|
|
362
404
|
return;
|
|
363
405
|
}
|
|
364
406
|
outputSuccess({
|
package/dist/commands/issues.js
CHANGED
|
@@ -4,6 +4,7 @@ import { enrichProjectResolverError, enrichValidationErrors, } from "../config/e
|
|
|
4
4
|
import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
|
|
5
5
|
import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
|
|
6
6
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
7
|
+
import { maybeEmitPinMismatchHint } from "../config/pin-hint.js";
|
|
7
8
|
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
8
9
|
import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
|
|
9
10
|
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
@@ -812,6 +813,10 @@ async function enforceIntakeDecision(description, options) {
|
|
|
812
813
|
}
|
|
813
814
|
async function handleCreateIssue(title, options, command) {
|
|
814
815
|
const rootOpts = getRootOpts(command);
|
|
816
|
+
// DEV-7277: non-blocking nudge when this write is landing via the
|
|
817
|
+
// machine-global active profile in a repo that hasn't pinned a workspace.
|
|
818
|
+
// stderr only; never blocks; suppressed by --quiet / EL_LINEAR_NO_PIN_HINT.
|
|
819
|
+
maybeEmitPinMismatchHint({ hasProfileFlag: Boolean(rootOpts.profile) });
|
|
815
820
|
// DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
|
|
816
821
|
// else. On create, resolveDescription would otherwise run three times —
|
|
817
822
|
// field validation (inside resolveCreateInputs), the body build, and the
|
|
@@ -1341,6 +1346,14 @@ async function handleMarkBranch(issueId, options, command) {
|
|
|
1341
1346
|
`Pass one explicitly: 'el-linear issues mark-branch DEV-123'.`);
|
|
1342
1347
|
}
|
|
1343
1348
|
}
|
|
1349
|
+
const branchIdentifier = extractIssueIdentifierFromBranch(branch);
|
|
1350
|
+
if (issueId &&
|
|
1351
|
+
branchIdentifier &&
|
|
1352
|
+
branchIdentifier !== identifier &&
|
|
1353
|
+
options.allowBranchMismatch !== true) {
|
|
1354
|
+
throw new Error(`Branch '${branch}' implies ${branchIdentifier}, but the requested marker is ${identifier}. ` +
|
|
1355
|
+
"Run this command from the matching branch, or pass --allow-branch-mismatch for deliberate recovery.");
|
|
1356
|
+
}
|
|
1344
1357
|
const previous = getBranchLinearIssue(branch);
|
|
1345
1358
|
setBranchLinearIssue(branch, identifier);
|
|
1346
1359
|
let claim;
|
|
@@ -1620,5 +1633,6 @@ export function setupIssuesCommands(program) {
|
|
|
1620
1633
|
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
|
|
1621
1634
|
"By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
|
|
1622
1635
|
.option("--no-claim", "only record git metadata; skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1636
|
+
.option("--allow-branch-mismatch", "allow an explicit issue identifier that conflicts with the current branch name")
|
|
1623
1637
|
.action(handleAsyncCommand(handleMarkBranch));
|
|
1624
1638
|
}
|
|
@@ -44,3 +44,23 @@ export declare function runProfileList(): Promise<ProfileListReport>;
|
|
|
44
44
|
export declare function runProfileUse(name: string): Promise<void>;
|
|
45
45
|
export declare function runProfileAdd(name: string): Promise<void>;
|
|
46
46
|
export declare function runProfileRemove(name: string, force: boolean): Promise<void>;
|
|
47
|
+
export interface ProfilePinOptions {
|
|
48
|
+
global?: boolean;
|
|
49
|
+
show?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface ProfileUnpinOptions {
|
|
52
|
+
global?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `el-linear profile pin [name]` — pin the current git repo to a Linear
|
|
56
|
+
* profile. Default target is the repo-local `.el-git.json`; `--global` writes
|
|
57
|
+
* the `owner/repo` entry into the shared global table. `--show` reports the
|
|
58
|
+
* resolved pin without writing.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runProfilePin(name: string | undefined, opts: ProfilePinOptions): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* `el-linear profile unpin` — remove the repo-local pin, or the exact global
|
|
63
|
+
* owner/repo mapping with `--global`. Preserves sibling keys and wildcard
|
|
64
|
+
* mappings; deletes a config file only when the removed pin was its sole data.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runProfileUnpin(opts?: ProfileUnpinOptions): Promise<void>;
|
package/dist/commands/profile.js
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
import { promises as fsp } from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { confirm } from "@inquirer/prompts";
|
|
26
|
+
import { atomicWrite, withFileLock } from "../auth/oauth-fs.js";
|
|
26
27
|
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, readActiveProfileMarker, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
28
|
+
import { applyGlobalPin, applyRepoLocalPin, defaultRepoProfileOps, globalPinTablePath, LINEAR_PROFILE_REPO_FILE, removeGlobalPin, removeRepoLocalPin, resolveRepoLinearProfile, } from "../config/repo-profile.js";
|
|
27
29
|
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
30
|
import { runFullWizard } from "./init/index.js";
|
|
29
31
|
import { registerMembersCommands } from "./profile/members.js";
|
|
@@ -75,6 +77,23 @@ export function setupProfileCommands(program) {
|
|
|
75
77
|
.action(async (name, opts) => {
|
|
76
78
|
await runProfileRemove(name, opts.force === true);
|
|
77
79
|
});
|
|
80
|
+
// `el-linear profile pin [name]` — DEV-7277: pin the current git repo to a
|
|
81
|
+
// Linear profile so multi-workspace users stop defaulting to the wrong one.
|
|
82
|
+
profile
|
|
83
|
+
.command("pin [name]")
|
|
84
|
+
.description("Pin the current git repo to a Linear profile so writes target the right workspace. Writes .el-git.json at the repo root (shared with el-git/el-session); [name] defaults to the active profile.")
|
|
85
|
+
.option("--global", "pin via the global owner/repo table (~/.config/el-git/linear-profiles.json) instead of the repo-local .el-git.json")
|
|
86
|
+
.option("--show", "print the resolved pin + its source without writing anything")
|
|
87
|
+
.action(async (name, opts) => {
|
|
88
|
+
await runProfilePin(name, opts);
|
|
89
|
+
});
|
|
90
|
+
profile
|
|
91
|
+
.command("unpin")
|
|
92
|
+
.description("Remove this repo's pin. Defaults to .el-git.json; --global removes its exact owner/repo mapping from the user-level table.")
|
|
93
|
+
.option("--global", "remove the exact origin owner/repo entry from ~/.config/el-git/linear-profiles.json")
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
await runProfileUnpin(opts);
|
|
96
|
+
});
|
|
78
97
|
// `el-linear profile migrate-legacy` — registered alongside add/list/etc.
|
|
79
98
|
// Lives in its own module so the multi-step migration logic stays
|
|
80
99
|
// self-contained and unit-testable without dragging in the full
|
|
@@ -188,12 +207,141 @@ export async function runProfileRemove(name, force) {
|
|
|
188
207
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
189
208
|
// If the just-removed profile was the active one, clear the marker
|
|
190
209
|
// so subsequent invocations fall back to the default paths.
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
210
|
+
const activeMarker = readActiveProfileMarker();
|
|
211
|
+
if (activeMarker === trimmed && (await pathExists(ACTIVE_PROFILE_FILE))) {
|
|
193
212
|
await fsp.rm(ACTIVE_PROFILE_FILE, { force: true });
|
|
194
213
|
}
|
|
195
214
|
outputSuccess({ data: { removed: trimmed, dir } });
|
|
196
215
|
}
|
|
216
|
+
async function readFileOrNull(p) {
|
|
217
|
+
try {
|
|
218
|
+
return await fsp.readFile(p, "utf8");
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (err.code === "ENOENT")
|
|
222
|
+
return null;
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function assertNotSymbolicLink(p) {
|
|
227
|
+
try {
|
|
228
|
+
if ((await fsp.lstat(p)).isSymbolicLink()) {
|
|
229
|
+
throw new Error(`Refusing to edit symbolic link ${p}. Replace it with a regular JSON file, then retry.`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (err.code === "ENOENT")
|
|
234
|
+
return;
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* `el-linear profile pin [name]` — pin the current git repo to a Linear
|
|
240
|
+
* profile. Default target is the repo-local `.el-git.json`; `--global` writes
|
|
241
|
+
* the `owner/repo` entry into the shared global table. `--show` reports the
|
|
242
|
+
* resolved pin without writing.
|
|
243
|
+
*/
|
|
244
|
+
export async function runProfilePin(name, opts) {
|
|
245
|
+
const ops = defaultRepoProfileOps();
|
|
246
|
+
const cwd = process.cwd();
|
|
247
|
+
const root = ops.repoRoot(cwd);
|
|
248
|
+
if (opts.show) {
|
|
249
|
+
const resolved = resolveRepoLinearProfile(cwd);
|
|
250
|
+
outputSuccess({
|
|
251
|
+
data: {
|
|
252
|
+
pinned: resolved?.profile ?? null,
|
|
253
|
+
source: resolved?.source ?? null,
|
|
254
|
+
repoRoot: root,
|
|
255
|
+
originFullpath: ops.originFullpath(cwd),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!root) {
|
|
261
|
+
throw new Error("Not in a git repository. `profile pin` writes a repo-scoped pin and needs a git repo (run it inside the repo you want to pin).");
|
|
262
|
+
}
|
|
263
|
+
// Default to the active profile when no name is given.
|
|
264
|
+
const target = (name ?? resolveActiveProfile().name ?? "").trim();
|
|
265
|
+
if (!target) {
|
|
266
|
+
throw new Error("No profile name given and no active profile is set (legacy single-profile default). Pass a name explicitly: `el-linear profile pin <name>`.");
|
|
267
|
+
}
|
|
268
|
+
if (!isSafeName(target)) {
|
|
269
|
+
throw new Error(`Profile name "${target}" must contain only [a-z0-9_-]. Pick a different name.`);
|
|
270
|
+
}
|
|
271
|
+
if (opts.global) {
|
|
272
|
+
const fullpath = ops.originFullpath(cwd);
|
|
273
|
+
if (!fullpath) {
|
|
274
|
+
throw new Error("Cannot determine the origin owner/repo for a global pin. Add an `origin` remote, or drop --global to write a repo-local .el-git.json instead.");
|
|
275
|
+
}
|
|
276
|
+
const file = globalPinTablePath();
|
|
277
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
278
|
+
await withFileLock(file, async () => {
|
|
279
|
+
const next = applyGlobalPin(await readFileOrNull(file), fullpath, target);
|
|
280
|
+
await atomicWrite(file, next, 0o644);
|
|
281
|
+
});
|
|
282
|
+
outputSuccess({
|
|
283
|
+
data: { scope: "global", key: fullpath, profile: target, file },
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const file = path.join(root, LINEAR_PROFILE_REPO_FILE);
|
|
288
|
+
await withFileLock(file, async () => {
|
|
289
|
+
await assertNotSymbolicLink(file);
|
|
290
|
+
const next = applyRepoLocalPin(await readFileOrNull(file), target);
|
|
291
|
+
await atomicWrite(file, next, 0o644);
|
|
292
|
+
});
|
|
293
|
+
outputSuccess({ data: { scope: "repo", profile: target, file } });
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* `el-linear profile unpin` — remove the repo-local pin, or the exact global
|
|
297
|
+
* owner/repo mapping with `--global`. Preserves sibling keys and wildcard
|
|
298
|
+
* mappings; deletes a config file only when the removed pin was its sole data.
|
|
299
|
+
*/
|
|
300
|
+
export async function runProfileUnpin(opts = {}) {
|
|
301
|
+
const ops = defaultRepoProfileOps();
|
|
302
|
+
const cwd = process.cwd();
|
|
303
|
+
const root = ops.repoRoot(cwd);
|
|
304
|
+
if (!root) {
|
|
305
|
+
throw new Error("Not in a git repository. `profile unpin` operates on the repo-local .el-git.json.");
|
|
306
|
+
}
|
|
307
|
+
if (opts.global) {
|
|
308
|
+
const fullpath = ops.originFullpath(cwd);
|
|
309
|
+
if (!fullpath) {
|
|
310
|
+
throw new Error("Cannot determine the origin owner/repo for a global unpin. Add an `origin` remote, or drop --global to remove the repo-local .el-git.json pin.");
|
|
311
|
+
}
|
|
312
|
+
const file = globalPinTablePath();
|
|
313
|
+
await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
314
|
+
await withFileLock(file, async () => {
|
|
315
|
+
const existing = await readFileOrNull(file);
|
|
316
|
+
const next = removeGlobalPin(existing, fullpath);
|
|
317
|
+
if (next === null) {
|
|
318
|
+
if (existing !== null)
|
|
319
|
+
await fsp.rm(file, { force: true });
|
|
320
|
+
}
|
|
321
|
+
else if (next !== existing) {
|
|
322
|
+
await atomicWrite(file, next, 0o644);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
outputSuccess({
|
|
326
|
+
data: { scope: "global", key: fullpath, unpinned: true, file },
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const file = path.join(root, LINEAR_PROFILE_REPO_FILE);
|
|
331
|
+
await withFileLock(file, async () => {
|
|
332
|
+
await assertNotSymbolicLink(file);
|
|
333
|
+
const existing = await readFileOrNull(file);
|
|
334
|
+
const next = removeRepoLocalPin(existing);
|
|
335
|
+
if (next === null) {
|
|
336
|
+
if (existing !== null)
|
|
337
|
+
await fsp.rm(file, { force: true });
|
|
338
|
+
}
|
|
339
|
+
else if (next !== existing) {
|
|
340
|
+
await atomicWrite(file, next, 0o644);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
outputSuccess({ data: { scope: "repo", unpinned: true, file } });
|
|
344
|
+
}
|
|
197
345
|
// ---- Helpers ------------------------------------------------------------
|
|
198
346
|
async function pathExists(p) {
|
|
199
347
|
try {
|
|
@@ -280,6 +280,11 @@ function flattenProjectUpdate(projectUpdate) {
|
|
|
280
280
|
name: updatedProject.name,
|
|
281
281
|
description: updatedProject.description ?? undefined,
|
|
282
282
|
content: updatedProject.content ?? undefined,
|
|
283
|
+
// `?.` guards test fixtures/older callers whose mocked response omits
|
|
284
|
+
// the field — real API responses always carry it.
|
|
285
|
+
status: updatedProject.status?.name,
|
|
286
|
+
progress: updatedProject.progress,
|
|
287
|
+
url: updatedProject.url,
|
|
283
288
|
teams: updatedProject.teams.nodes.map((t) => ({
|
|
284
289
|
id: t.id,
|
|
285
290
|
key: t.key,
|
|
@@ -515,13 +520,25 @@ async function handleUpdateProject(projectNameOrId, options, command) {
|
|
|
515
520
|
if (content !== undefined) {
|
|
516
521
|
input.content = content;
|
|
517
522
|
}
|
|
518
|
-
|
|
519
|
-
|
|
523
|
+
// --status resolves through a name lookup (network), unlike the other
|
|
524
|
+
// flags above — checked here only for the "nothing to update" guard so a
|
|
525
|
+
// bare `--status` still counts as work to do before we've made any calls.
|
|
526
|
+
const hasStatus = hasOption(options, "status");
|
|
527
|
+
if (Object.keys(input).length === 0 && !hasStatus) {
|
|
528
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, --content, --content-file, or --status.");
|
|
520
529
|
}
|
|
521
530
|
const rootOpts = getRootOpts(command);
|
|
522
531
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
523
532
|
const linearService = await createLinearService(rootOpts);
|
|
524
533
|
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
534
|
+
if (hasStatus) {
|
|
535
|
+
// DEV-7021: resolves the workspace's configured project statuses by
|
|
536
|
+
// name (case-insensitive) and throws a "Project status ... not found"
|
|
537
|
+
// error listing the valid names on a miss — see
|
|
538
|
+
// `LinearService.resolveProjectStatusId` for why this can't be a
|
|
539
|
+
// static enum the way `VALID_PROJECT_STATES` is.
|
|
540
|
+
input.statusId = await linearService.resolveProjectStatusId(options.status);
|
|
541
|
+
}
|
|
525
542
|
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
|
|
526
543
|
const projectUpdate = updateResult.projectUpdate;
|
|
527
544
|
if (!projectUpdate.success) {
|
|
@@ -558,11 +575,13 @@ export function setupProjectsCommands(program) {
|
|
|
558
575
|
.action(handleAsyncCommand(handleReadProject));
|
|
559
576
|
projects
|
|
560
577
|
.command("update <project>")
|
|
561
|
-
.description("Update project name, short description,
|
|
578
|
+
.description("Update project name, short description, markdown content, or status")
|
|
562
579
|
.option("--name <name>", "project name")
|
|
563
580
|
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
564
581
|
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
565
582
|
.option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
|
|
583
|
+
.option("--status <name>", "project status name (case-insensitive; workspace-configured, e.g. Backlog, Planned, In Progress, Completed, Canceled) — lists valid statuses on a miss")
|
|
584
|
+
.option("-q, --quiet", "print one confirmation line (name, status, url) instead of the full JSON")
|
|
566
585
|
.action(handleAsyncCommand(handleUpdateProject));
|
|
567
586
|
projects
|
|
568
587
|
.command("list")
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
11
|
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
12
|
* `"block"`.
|
|
13
|
+
*
|
|
14
|
+
* DEV-7074: the parser accepts the markdown an author actually types — any
|
|
15
|
+
* list marker (`-`, `*`, `+`, `1.`) and bolded or italicized labels with the
|
|
16
|
+
* colon inside (`**Needed:**`) or outside (`**Needed**:`) the emphasis run.
|
|
17
|
+
* When a field genuinely fails, the diagnostic names WHICH failure it is:
|
|
18
|
+
* an unreadable line, an absent field, an empty value, a placeholder, or a
|
|
19
|
+
* present-but-unjudged value. Reporting a formatting mismatch as empty content
|
|
20
|
+
* sent authors to rewrite prose that was already correct.
|
|
13
21
|
*/
|
|
14
22
|
export declare const DEFAULT_INTAKE_SECTION_HEADERS: string[];
|
|
15
23
|
export type IntakeDecisionGateMode = "off" | "warn" | "block";
|
|
@@ -18,6 +26,13 @@ export interface IntakeDecisionGateConfig {
|
|
|
18
26
|
headers: string[];
|
|
19
27
|
}
|
|
20
28
|
export declare function getIntakeDecisionGateConfig(): IntakeDecisionGateConfig;
|
|
29
|
+
/**
|
|
30
|
+
* What is actually wrong with a field whose label parsed. Keeping these apart
|
|
31
|
+
* matters: "the label didn't parse" and "the value is weak" send the author to
|
|
32
|
+
* completely different fixes, and reporting the first as the second sends them
|
|
33
|
+
* to rewrite prose that was already correct (DEV-7074).
|
|
34
|
+
*/
|
|
35
|
+
export type IntakeFieldProblem = "empty" | "placeholder" | "non-specific" | "no-judgment" | "placeholder-reason";
|
|
21
36
|
export type IntakeDecisionEvaluation = {
|
|
22
37
|
ok: true;
|
|
23
38
|
header: string;
|
|
@@ -28,6 +43,11 @@ export type IntakeDecisionEvaluation = {
|
|
|
28
43
|
ok: false;
|
|
29
44
|
reason: "missing-field";
|
|
30
45
|
field: string;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
reason: "unparsed-field";
|
|
49
|
+
field: string;
|
|
50
|
+
line: string;
|
|
31
51
|
} | {
|
|
32
52
|
ok: false;
|
|
33
53
|
reason: "duplicate-field";
|
|
@@ -40,6 +60,8 @@ export type IntakeDecisionEvaluation = {
|
|
|
40
60
|
ok: false;
|
|
41
61
|
reason: "invalid-field";
|
|
42
62
|
field: string;
|
|
63
|
+
problem: IntakeFieldProblem;
|
|
64
|
+
value: string;
|
|
43
65
|
} | {
|
|
44
66
|
ok: false;
|
|
45
67
|
reason: "not-proceeding";
|