@enrichlayer/el-linear 1.37.2 → 1.38.1
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 +8 -4
- package/claude-skills/linear-operations/SKILL.md +45 -30
- package/dist/commands/attachments.js +60 -0
- package/dist/commands/comments.js +2 -1
- package/dist/commands/issues/description.js +2 -1
- package/dist/commands/issues.js +118 -7
- package/dist/commands/labels.js +2 -1
- package/dist/commands/projects.js +53 -1
- package/dist/config/config.d.ts +20 -0
- package/dist/config/goal-completion-validation.d.ts +92 -0
- package/dist/config/goal-completion-validation.js +158 -0
- package/dist/queries/projects-types.d.ts +10 -0
- package/dist/queries/projects.d.ts +1 -0
- package/dist/queries/projects.js +20 -0
- package/dist/types/linear.d.ts +9 -0
- package/dist/utils/file-service.d.ts +3 -1
- package/dist/utils/file-service.js +53 -7
- package/dist/utils/gate-telemetry.d.ts +20 -0
- package/dist/utils/gate-telemetry.js +26 -1
- package/dist/utils/graphql-issues-service.d.ts +2 -0
- package/dist/utils/graphql-issues-service.js +41 -0
- package/dist/utils/inline-text-input.d.ts +7 -0
- package/dist/utils/inline-text-input.js +12 -0
- package/dist/utils/relation-candidate-prompt.d.ts +12 -5
- package/dist/utils/relation-candidate-prompt.js +23 -9
- package/package.json +3 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-completion ("Done when") validation — DEV-5920.
|
|
3
|
+
*
|
|
4
|
+
* A create-time gate that checks the issue description for a goal-completion
|
|
5
|
+
* section ("Done when", "Acceptance criteria", "Success criteria", …) that
|
|
6
|
+
* contains at least one FALSIFIABLE criterion — something a later session can
|
|
7
|
+
* mechanically verify (a command with an expected result, a threshold number,
|
|
8
|
+
* a named artifact path, an exit-code/status assertion, or an explicit
|
|
9
|
+
* "verifiable via X" phrase). A section made only of bare quality adjectives
|
|
10
|
+
* ("improved", "better", "cleaner", "faster") gives the implementing agent no
|
|
11
|
+
* terminal state to converge on, which is the concrete-goals failure mode this
|
|
12
|
+
* gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
|
|
13
|
+
* gate and the DEV-5378 SOP-label parent gate.
|
|
14
|
+
*
|
|
15
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
16
|
+
* not Enrich Layer and must not be surprised by a new refusal. The gate is
|
|
17
|
+
* dormant unless `validation.goalCompletionGate` is set to `"warn"` or
|
|
18
|
+
* `"block"` (the EL workspace flips it on in its shared team config). Section
|
|
19
|
+
* matching reuses the `extractField` header semantics (`##`/`###` ATX headers
|
|
20
|
+
* and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
|
|
21
|
+
* so the gate accepts exactly what `issues read --field "Done when"` can later
|
|
22
|
+
* extract.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Default section headers accepted as the goal-completion section, matched
|
|
26
|
+
* with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
|
|
27
|
+
* forms, trailing colons stripped). Overridable via
|
|
28
|
+
* `config.validation.goalSectionHeaders`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_GOAL_SECTION_HEADERS: string[];
|
|
31
|
+
/** Gate mode: dormant, stderr warning, or hard block. */
|
|
32
|
+
export type GoalCompletionGateMode = "off" | "warn" | "block";
|
|
33
|
+
export interface GoalCompletionGateConfig {
|
|
34
|
+
/**
|
|
35
|
+
* The mode in effect. OPT-IN: `"off"` unless validation is not turned off
|
|
36
|
+
* AND `goalCompletionGate` is explicitly `"warn"` or `"block"`.
|
|
37
|
+
*/
|
|
38
|
+
mode: GoalCompletionGateMode;
|
|
39
|
+
/** The section headers in effect (config override or {@link DEFAULT_GOAL_SECTION_HEADERS}). */
|
|
40
|
+
headers: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the goal-completion-gate config from the merged el-linear config.
|
|
44
|
+
*
|
|
45
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
46
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly
|
|
47
|
+
* set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
|
|
48
|
+
* value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
|
|
49
|
+
* must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
|
|
50
|
+
* falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getGoalCompletionGateConfig(): GoalCompletionGateConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Does the section text contain at least one falsifiable criterion?
|
|
55
|
+
* See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
|
|
56
|
+
* section trivially fails — a bare header is not a criterion.
|
|
57
|
+
*/
|
|
58
|
+
export declare function hasFalsifiableCriterion(sectionText: string): boolean;
|
|
59
|
+
export type GoalCompletionEvaluation = {
|
|
60
|
+
ok: true;
|
|
61
|
+
header: string;
|
|
62
|
+
} | {
|
|
63
|
+
ok: false;
|
|
64
|
+
reason: "no-section";
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: "vague-section";
|
|
68
|
+
header: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Evaluate a description against the goal-completion rule. Headers are tried
|
|
72
|
+
* in **configured list order**, not document order — the first header in
|
|
73
|
+
* `headers` that is present in the description decides the outcome, even if a
|
|
74
|
+
* later-listed header appears earlier in the body. This is intentional: the
|
|
75
|
+
* list order encodes the operator's preferred canonical header, so the block
|
|
76
|
+
* message names the header they'd rather authors use. A present-but-vague
|
|
77
|
+
* section is reported as `vague-section` with the header that matched, so the
|
|
78
|
+
* error can point at the exact section rather than a generic "missing".
|
|
79
|
+
*/
|
|
80
|
+
export declare function evaluateGoalCompletion(description: string, headers?: string[]): GoalCompletionEvaluation;
|
|
81
|
+
/**
|
|
82
|
+
* Render the human/agent-facing block emitted when the gate fires. `reason`
|
|
83
|
+
* distinguishes "no goal-completion section at all" from "section present but
|
|
84
|
+
* nothing falsifiable in it", so the message points at the exact fix. Names
|
|
85
|
+
* the rule and the `--allow-vague-goal` escape hatch.
|
|
86
|
+
*/
|
|
87
|
+
export declare function formatGoalCompletionBlock(opts: {
|
|
88
|
+
reason: "no-section" | "vague-section";
|
|
89
|
+
headers: string[];
|
|
90
|
+
/** The header that matched, when reason is `vague-section`. */
|
|
91
|
+
sectionHeader?: string;
|
|
92
|
+
}): string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-completion ("Done when") validation — DEV-5920.
|
|
3
|
+
*
|
|
4
|
+
* A create-time gate that checks the issue description for a goal-completion
|
|
5
|
+
* section ("Done when", "Acceptance criteria", "Success criteria", …) that
|
|
6
|
+
* contains at least one FALSIFIABLE criterion — something a later session can
|
|
7
|
+
* mechanically verify (a command with an expected result, a threshold number,
|
|
8
|
+
* a named artifact path, an exit-code/status assertion, or an explicit
|
|
9
|
+
* "verifiable via X" phrase). A section made only of bare quality adjectives
|
|
10
|
+
* ("improved", "better", "cleaner", "faster") gives the implementing agent no
|
|
11
|
+
* terminal state to converge on, which is the concrete-goals failure mode this
|
|
12
|
+
* gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
|
|
13
|
+
* gate and the DEV-5378 SOP-label parent gate.
|
|
14
|
+
*
|
|
15
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
16
|
+
* not Enrich Layer and must not be surprised by a new refusal. The gate is
|
|
17
|
+
* dormant unless `validation.goalCompletionGate` is set to `"warn"` or
|
|
18
|
+
* `"block"` (the EL workspace flips it on in its shared team config). Section
|
|
19
|
+
* matching reuses the `extractField` header semantics (`##`/`###` ATX headers
|
|
20
|
+
* and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
|
|
21
|
+
* so the gate accepts exactly what `issues read --field "Done when"` can later
|
|
22
|
+
* extract.
|
|
23
|
+
*/
|
|
24
|
+
import { extractField } from "../utils/extract-field.js";
|
|
25
|
+
import { loadConfig } from "./config.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default section headers accepted as the goal-completion section, matched
|
|
28
|
+
* with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
|
|
29
|
+
* forms, trailing colons stripped). Overridable via
|
|
30
|
+
* `config.validation.goalSectionHeaders`.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_GOAL_SECTION_HEADERS = [
|
|
33
|
+
"Done when",
|
|
34
|
+
"Done-when",
|
|
35
|
+
"Acceptance criteria",
|
|
36
|
+
"Success criteria",
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the goal-completion-gate config from the merged el-linear config.
|
|
40
|
+
*
|
|
41
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
42
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly
|
|
43
|
+
* set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
|
|
44
|
+
* value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
|
|
45
|
+
* must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
|
|
46
|
+
* falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
|
|
47
|
+
*/
|
|
48
|
+
export function getGoalCompletionGateConfig() {
|
|
49
|
+
const validation = loadConfig().validation;
|
|
50
|
+
const raw = validation?.goalCompletionGate;
|
|
51
|
+
const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
|
|
52
|
+
? raw
|
|
53
|
+
: "off";
|
|
54
|
+
const headers = validation?.goalSectionHeaders && validation.goalSectionHeaders.length > 0
|
|
55
|
+
? validation.goalSectionHeaders
|
|
56
|
+
: DEFAULT_GOAL_SECTION_HEADERS;
|
|
57
|
+
return { mode, headers };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Falsifiability proxies — any single match makes the section pass. Each is a
|
|
61
|
+
* cheap textual stand-in for "a later session can mechanically check this":
|
|
62
|
+
*
|
|
63
|
+
* - **command** — inline code or a fenced block (`` `pnpm test` `` and its
|
|
64
|
+
* expected output live in code spans by Markdown convention).
|
|
65
|
+
* - **number** — a digit anywhere in the section: thresholds ("under 200ms",
|
|
66
|
+
* "95%"), counts ("all 12 tests"), issue/artifact ids. Deliberately
|
|
67
|
+
* permissive — the failure mode this gate targets is a section with NO
|
|
68
|
+
* number/command/artifact at all, not a weak number.
|
|
69
|
+
* - **artifact path** — a bare filename with a code-adjacent extension
|
|
70
|
+
* (`foo.ts`, `report.json` — this also matches the file segment inside a
|
|
71
|
+
* longer path like `src/utils/foo.ts`), or an ANCHORED slash-path (leading
|
|
72
|
+
* `./`, `../`, `/`, or `~/`) for extension-less dirs like `./scripts/run`.
|
|
73
|
+
* The anchor requirement is deliberate: an un-anchored two-segment
|
|
74
|
+
* `a/b` pattern over-fires on English prose ("and/or", "read/write",
|
|
75
|
+
* "client/server"), so a real relative artifact path must either carry a
|
|
76
|
+
* file extension or start with a path anchor to count.
|
|
77
|
+
* - **exit/status assertion** — "exits non-zero", "exit code 0", "tests
|
|
78
|
+
* pass", "CI green", "returns nonzero".
|
|
79
|
+
* - **verifiable-via phrase** — "verifiable via/by/with/through X".
|
|
80
|
+
*/
|
|
81
|
+
const FALSIFIABLE_PROXY_RES = [
|
|
82
|
+
// Inline code span or a fenced code block opener.
|
|
83
|
+
/`[^`\n]+`/,
|
|
84
|
+
/^ {0,3}(?:`{3,}|~{3,})/m,
|
|
85
|
+
// Threshold number / percentage / count.
|
|
86
|
+
/\d/,
|
|
87
|
+
// Artifact path — a filename with a code-adjacent extension (also fires on
|
|
88
|
+
// the file segment of a longer path), OR an anchored slash-path for
|
|
89
|
+
// extension-less dirs. The anchor (`./` `../` `/` `~/`) is required so a
|
|
90
|
+
// bare `a/b` doesn't over-fire on prose like "and/or" / "read/write".
|
|
91
|
+
/\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|ya?ml|sh|css|html|txt|csv|toml|sql|py|go|rs|lock)\b/i,
|
|
92
|
+
/(?:^|[\s("'[])(?:\.{1,2}\/|~\/|\/)[\w.-]+(?:\/[\w.-]+)*/m,
|
|
93
|
+
// Exit-code / status assertion.
|
|
94
|
+
/\bexit(?:s|ed)?\s+(?:code\s+|status\s+)?(?:non-?zero|zero|\d+)\b/i,
|
|
95
|
+
/\bexit\s+(?:code|status)\b/i,
|
|
96
|
+
/\breturns?\s+non-?zero\b/i,
|
|
97
|
+
/\b(?:test|tests|suite|ci|pipeline|lint|typecheck|build|check|checks)\s+(?:is\s+|are\s+|stays?\s+|go(?:es)?\s+)?(?:pass(?:es|ing)?|green|fail(?:s|ing)?|red)\b/i,
|
|
98
|
+
// Explicit "verifiable via X" escape phrase.
|
|
99
|
+
/\bverifi(?:able|ed)\s+(?:via|by|with|through)\b/i,
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Does the section text contain at least one falsifiable criterion?
|
|
103
|
+
* See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
|
|
104
|
+
* section trivially fails — a bare header is not a criterion.
|
|
105
|
+
*/
|
|
106
|
+
export function hasFalsifiableCriterion(sectionText) {
|
|
107
|
+
if (!sectionText || sectionText.trim().length === 0) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return FALSIFIABLE_PROXY_RES.some((re) => re.test(sectionText));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate a description against the goal-completion rule. Headers are tried
|
|
114
|
+
* in **configured list order**, not document order — the first header in
|
|
115
|
+
* `headers` that is present in the description decides the outcome, even if a
|
|
116
|
+
* later-listed header appears earlier in the body. This is intentional: the
|
|
117
|
+
* list order encodes the operator's preferred canonical header, so the block
|
|
118
|
+
* message names the header they'd rather authors use. A present-but-vague
|
|
119
|
+
* section is reported as `vague-section` with the header that matched, so the
|
|
120
|
+
* error can point at the exact section rather than a generic "missing".
|
|
121
|
+
*/
|
|
122
|
+
export function evaluateGoalCompletion(description, headers = DEFAULT_GOAL_SECTION_HEADERS) {
|
|
123
|
+
for (const header of headers) {
|
|
124
|
+
const section = extractField(description, header);
|
|
125
|
+
if (section !== null) {
|
|
126
|
+
return hasFalsifiableCriterion(section)
|
|
127
|
+
? { ok: true, header }
|
|
128
|
+
: { ok: false, reason: "vague-section", header };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, reason: "no-section" };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Render the human/agent-facing block emitted when the gate fires. `reason`
|
|
135
|
+
* distinguishes "no goal-completion section at all" from "section present but
|
|
136
|
+
* nothing falsifiable in it", so the message points at the exact fix. Names
|
|
137
|
+
* the rule and the `--allow-vague-goal` escape hatch.
|
|
138
|
+
*/
|
|
139
|
+
export function formatGoalCompletionBlock(opts) {
|
|
140
|
+
const headerList = opts.headers.join(", ");
|
|
141
|
+
let head;
|
|
142
|
+
if (opts.reason === "no-section") {
|
|
143
|
+
head =
|
|
144
|
+
`Issue description has no goal-completion section (looked for: ${headerList}).\n` +
|
|
145
|
+
` Add a "${opts.headers[0]}" section stating how completion will be verified.\n`;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
head =
|
|
149
|
+
`The "${opts.sectionHeader}" section contains no falsifiable criterion.\n` +
|
|
150
|
+
' Bare quality adjectives ("improved", "better", "cleaner", "faster") give the\n' +
|
|
151
|
+
" implementing session no terminal state to converge on.\n";
|
|
152
|
+
}
|
|
153
|
+
const criteria = " At least one criterion must be mechanically checkable: a command with its\n" +
|
|
154
|
+
" expected exit/output, a threshold number or percentage, a named artifact path,\n" +
|
|
155
|
+
' an exit-code/status assertion, or a "verifiable via X" phrase.\n';
|
|
156
|
+
const hatch = " If the goal is intentionally open-ended, re-run with --allow-vague-goal.";
|
|
157
|
+
return head + criteria + hatch;
|
|
158
|
+
}
|
|
@@ -77,6 +77,16 @@ export interface UpdateProjectResponse {
|
|
|
77
77
|
project: ProjectBaseNode | null;
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
interface ProjectUpdateFieldsNode extends ProjectBaseNode {
|
|
81
|
+
description: string | null;
|
|
82
|
+
content: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface UpdateProjectFieldsResponse {
|
|
85
|
+
projectUpdate: {
|
|
86
|
+
success: boolean;
|
|
87
|
+
project: ProjectUpdateFieldsNode | null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
80
90
|
interface ProjectArchiveEntity {
|
|
81
91
|
id: string;
|
|
82
92
|
}
|
|
@@ -14,5 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
|
|
|
14
14
|
export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
15
15
|
export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
16
16
|
export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
|
+
export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
18
|
export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
18
19
|
export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
package/dist/queries/projects.js
CHANGED
|
@@ -129,6 +129,26 @@ export const UPDATE_PROJECT_MUTATION = `
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
`;
|
|
132
|
+
export const UPDATE_PROJECT_FIELDS_MUTATION = `
|
|
133
|
+
mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {
|
|
134
|
+
projectUpdate(id: $id, input: $input) {
|
|
135
|
+
success
|
|
136
|
+
project {
|
|
137
|
+
id
|
|
138
|
+
name
|
|
139
|
+
description
|
|
140
|
+
content
|
|
141
|
+
teams {
|
|
142
|
+
nodes {
|
|
143
|
+
id
|
|
144
|
+
key
|
|
145
|
+
name
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
132
152
|
export const ARCHIVE_PROJECT_MUTATION = `
|
|
133
153
|
mutation ArchiveProject($id: String!) {
|
|
134
154
|
projectArchive(id: $id) {
|
package/dist/types/linear.d.ts
CHANGED
|
@@ -223,6 +223,15 @@ export type FileDownloadResult = {
|
|
|
223
223
|
error: string;
|
|
224
224
|
statusCode?: number;
|
|
225
225
|
};
|
|
226
|
+
export type FileReadResult = {
|
|
227
|
+
success: true;
|
|
228
|
+
content: string;
|
|
229
|
+
contentType: string;
|
|
230
|
+
} | {
|
|
231
|
+
success: false;
|
|
232
|
+
error: string;
|
|
233
|
+
statusCode?: number;
|
|
234
|
+
};
|
|
226
235
|
export type FileUploadResult = {
|
|
227
236
|
success: true;
|
|
228
237
|
assetUrl: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LinearCredential } from "../auth/linear-credential.js";
|
|
2
|
-
import type { FileDownloadResult, FileUploadResult } from "../types/linear.js";
|
|
2
|
+
import type { FileDownloadResult, FileReadResult, FileUploadResult } from "../types/linear.js";
|
|
3
3
|
/**
|
|
4
4
|
* Constructor arg for `FileService`. Re-exported alias of the shared
|
|
5
5
|
* `LinearCredential` union (`{ apiKey } | { oauthToken }`). See
|
|
@@ -10,6 +10,8 @@ export type FileServiceAuth = LinearCredential;
|
|
|
10
10
|
export declare class FileService {
|
|
11
11
|
private readonly authHeader;
|
|
12
12
|
constructor(auth: FileServiceAuth);
|
|
13
|
+
private fetchUpload;
|
|
14
|
+
readTextFile(url: string): Promise<FileReadResult>;
|
|
13
15
|
downloadFile(url: string, options?: {
|
|
14
16
|
output?: string;
|
|
15
17
|
overwrite?: boolean;
|
|
@@ -47,6 +47,58 @@ export class FileService {
|
|
|
47
47
|
constructor(auth) {
|
|
48
48
|
this.authHeader = buildAuthHeader(auth);
|
|
49
49
|
}
|
|
50
|
+
async fetchUpload(url) {
|
|
51
|
+
const urlObj = new URL(url);
|
|
52
|
+
const headers = {};
|
|
53
|
+
if (!urlObj.searchParams.has("signature")) {
|
|
54
|
+
headers.Authorization = this.authHeader;
|
|
55
|
+
}
|
|
56
|
+
return fetch(url, { method: "GET", headers });
|
|
57
|
+
}
|
|
58
|
+
async readTextFile(url) {
|
|
59
|
+
if (!isLinearUploadUrl(url)) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: "URL must be from uploads.linear.app domain",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const response = await this.fetchUpload(url);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
71
|
+
statusCode: response.status,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const contentType = (response.headers.get("content-type") ?? "")
|
|
75
|
+
.split(";", 1)[0]
|
|
76
|
+
.toLowerCase();
|
|
77
|
+
const isText = contentType.startsWith("text/") ||
|
|
78
|
+
[
|
|
79
|
+
"application/json",
|
|
80
|
+
"application/xml",
|
|
81
|
+
"application/javascript",
|
|
82
|
+
].includes(contentType);
|
|
83
|
+
if (!isText) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
error: `Attachment is not text (${contentType || "unknown content type"}); use attachments download instead.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
content: await response.text(),
|
|
92
|
+
contentType,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
50
102
|
async downloadFile(url, options = {}) {
|
|
51
103
|
if (!isLinearUploadUrl(url)) {
|
|
52
104
|
return {
|
|
@@ -68,13 +120,7 @@ export class FileService {
|
|
|
68
120
|
}
|
|
69
121
|
}
|
|
70
122
|
try {
|
|
71
|
-
const
|
|
72
|
-
const isSignedUrl = urlObj.searchParams.has("signature");
|
|
73
|
-
const headers = {};
|
|
74
|
-
if (!isSignedUrl) {
|
|
75
|
-
headers.Authorization = this.authHeader;
|
|
76
|
-
}
|
|
77
|
-
const response = await fetch(url, { method: "GET", headers });
|
|
123
|
+
const response = await this.fetchUpload(url);
|
|
78
124
|
if (!response.ok) {
|
|
79
125
|
return {
|
|
80
126
|
success: false,
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
|
|
3
|
+
*
|
|
4
|
+
* The `issues create` duplicate-detection gate (DEV-4823) records each decision
|
|
5
|
+
* it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
|
|
6
|
+
* command, or any JSONL consumer) can compute the gate's override-rate
|
|
7
|
+
* (overridden / total) and tell whether the threshold is noisy.
|
|
8
|
+
*
|
|
9
|
+
* **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
|
|
10
|
+
* must never write files a user didn't ask for. Emission is therefore OFF by
|
|
11
|
+
* default and turns on only when telemetry is actually configured — see
|
|
12
|
+
* {@link decideGateLedger}. The ledger is a plain local JSONL file
|
|
13
|
+
* (`gate-events.jsonl`); there is no server or database. el-linear can't import
|
|
14
|
+
* `el-telemetry` (separate package), so it writes by **path-contract** — the
|
|
15
|
+
* same approach `el-hook` uses. The path mirrors `el-telemetry`'s
|
|
16
|
+
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
17
|
+
* `docs/telemetry.md`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GATE_LEDGER_MAX_BYTES: number;
|
|
20
|
+
export declare const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
1
21
|
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
2
22
|
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
3
23
|
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
/**
|
|
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
|
|
|
20
20
|
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
21
21
|
* `docs/telemetry.md`.
|
|
22
22
|
*/
|
|
23
|
+
export const GATE_LEDGER_MAX_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
export const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
23
25
|
/** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
|
|
24
26
|
function defaultTelemetryDir() {
|
|
25
27
|
return join(homedir(), ".cache", "el-telemetry");
|
|
@@ -67,6 +69,28 @@ function gateLedgerIfEnabled() {
|
|
|
67
69
|
defaultDirExists: existsSync(defaultDir),
|
|
68
70
|
});
|
|
69
71
|
}
|
|
72
|
+
async function rotateGateLedgerIfOverLimit(path) {
|
|
73
|
+
let size = 0;
|
|
74
|
+
try {
|
|
75
|
+
const s = await stat(path);
|
|
76
|
+
if (!s.isFile())
|
|
77
|
+
return;
|
|
78
|
+
size = s.size;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (size <= GATE_LEDGER_MAX_BYTES)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const backupPath = `${path}${GATE_LEDGER_BACKUP_SUFFIX}`;
|
|
87
|
+
await rm(backupPath, { force: true });
|
|
88
|
+
await rename(path, backupPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// best-effort — telemetry never blocks issue creation
|
|
92
|
+
}
|
|
93
|
+
}
|
|
70
94
|
/**
|
|
71
95
|
* Best-effort append of a gate event to the local ledger — but only when
|
|
72
96
|
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
@@ -80,6 +104,7 @@ export async function emitGateEvent(name, subcommand, event) {
|
|
|
80
104
|
}
|
|
81
105
|
try {
|
|
82
106
|
await mkdir(dirname(path), { recursive: true });
|
|
107
|
+
await rotateGateLedgerIfOverLimit(path);
|
|
83
108
|
const record = {
|
|
84
109
|
ts: new Date().toISOString(),
|
|
85
110
|
kind: "gate",
|
|
@@ -209,6 +209,7 @@ export declare class GraphQLIssuesService {
|
|
|
209
209
|
startIssue(issueId: string): Promise<StartIssueResult>;
|
|
210
210
|
claimIssue(issueId: string): Promise<ClaimIssueResult>;
|
|
211
211
|
updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
|
|
212
|
+
private updateIssueImpl;
|
|
212
213
|
archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
|
|
213
214
|
deleteIssue(issueId: string, options?: {
|
|
214
215
|
permanentlyDelete?: boolean;
|
|
@@ -216,6 +217,7 @@ export declare class GraphQLIssuesService {
|
|
|
216
217
|
private extractMilestoneNodes;
|
|
217
218
|
private executeUpdateMutation;
|
|
218
219
|
createIssue(args: CreateIssueArgs): Promise<LinearIssue>;
|
|
220
|
+
private createIssueImpl;
|
|
219
221
|
/**
|
|
220
222
|
* Folds the `@include`-gated `projectsByName` / `projectsById` aliases
|
|
221
223
|
* from a create batch response into the single `projects` field the
|
|
@@ -28,6 +28,31 @@ function extractSummaryText(summary) {
|
|
|
28
28
|
walk(summary.content);
|
|
29
29
|
return parts.length > 0 ? parts.join("") : undefined;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* FE-926: Linear's description-content store can end up in a corrupted state
|
|
33
|
+
* for a specific issue — observed on FE-921, where a description saved
|
|
34
|
+
* cleanly at creation was empty ~1 minute later with no issue-history trace,
|
|
35
|
+
* and every subsequent write attempt raised this same raw GraphQL error
|
|
36
|
+
* against a *different* DocumentContent id each time. It can surface from
|
|
37
|
+
* any call that touches the issue's description, including a plain
|
|
38
|
+
* batch-resolve read (not just the create/update mutation itself), so
|
|
39
|
+
* `updateIssue`/`createIssue` wrap their whole body rather than a single
|
|
40
|
+
* call site. Retrying does not self-heal — the raw backend message is
|
|
41
|
+
* replaced with the known workaround.
|
|
42
|
+
*/
|
|
43
|
+
function rethrowWithDocumentContentHint(error, issueId) {
|
|
44
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
45
|
+
if (msg.includes("Conflict on insert of DocumentContent")) {
|
|
46
|
+
const detail = issueId
|
|
47
|
+
? `for ${issueId}. This does not self-heal by retrying — each attempt fails against a new conflicting id. Workaround: create a replacement issue with the same content, relate it back with --duplicate-of ${issueId}, and cancel the original.`
|
|
48
|
+
: "for this issue. This does not self-heal by retrying — each attempt fails against a new conflicting id. If it recurs on the same issue after creation, recreate it under a fresh id rather than repairing via update.";
|
|
49
|
+
throw new Error(`Linear reports its description store is in conflict ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
if (error instanceof Error) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(msg);
|
|
55
|
+
}
|
|
31
56
|
export class GraphQLIssuesService {
|
|
32
57
|
graphQLService;
|
|
33
58
|
linearService;
|
|
@@ -262,6 +287,14 @@ export class GraphQLIssuesService {
|
|
|
262
287
|
};
|
|
263
288
|
}
|
|
264
289
|
async updateIssue(args, labelMode = "overwriting") {
|
|
290
|
+
try {
|
|
291
|
+
return await this.updateIssueImpl(args, labelMode);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
rethrowWithDocumentContentHint(error, args.id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async updateIssueImpl(args, labelMode = "overwriting") {
|
|
265
298
|
// Normalize URL/slug-id --project inputs to UUIDs before the batch
|
|
266
299
|
// resolver runs; see comment on `withNormalizedProjectId`.
|
|
267
300
|
const normalizedArgs = await this.withNormalizedProjectId(args);
|
|
@@ -368,6 +401,14 @@ export class GraphQLIssuesService {
|
|
|
368
401
|
return this.transformIssueData(issueUpdate.issue);
|
|
369
402
|
}
|
|
370
403
|
async createIssue(args) {
|
|
404
|
+
try {
|
|
405
|
+
return await this.createIssueImpl(args);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
rethrowWithDocumentContentHint(error);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async createIssueImpl(args) {
|
|
371
412
|
// Pre-resolve URL/slug-id forms of --project to a UUID so the batch
|
|
372
413
|
// resolver below (which uses a `name eqIgnoreCase` filter) can skip
|
|
373
414
|
// the project lookup entirely. Plain name inputs flow through unchanged
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeInlineTextInput(value: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeInlineTextInput(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\\r\\n/g, "\n")
|
|
10
|
+
.replace(/\\n/g, "\n")
|
|
11
|
+
.replace(/\\r/g, "\n");
|
|
12
|
+
}
|
|
@@ -41,16 +41,23 @@
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
|
|
43
43
|
/**
|
|
44
|
-
* Build the relation-candidate
|
|
45
|
-
*
|
|
44
|
+
* Build the relation-candidate warning string, or `null` when the result set
|
|
45
|
+
* has no identifier-bearing rows (nothing to surface).
|
|
46
46
|
*
|
|
47
47
|
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
48
48
|
*
|
|
49
49
|
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Link the relevant ones now — at create time with --related-to, or
|
|
51
|
+
* `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
|
|
52
|
+
* agent-inferred relate, reply with the IDs you want linked
|
|
53
|
+
* (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
|
|
52
54
|
*
|
|
53
|
-
*
|
|
55
|
+
* DEV-5853: the primary framing is **proactive** — this is a convenience list
|
|
56
|
+
* of link candidates, not a stop sign. Relate the relevant ones directly
|
|
57
|
+
* rather than waiting to be told; the reply flow is the *fallback* for when
|
|
58
|
+
* the auto-mode classifier actually blocks an agent-inferred `issues relate`
|
|
59
|
+
* (create-time `--related-to` typically passes, so prefer it). The
|
|
60
|
+
* `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
54
61
|
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
55
62
|
* harness can grep for without parsing free-form prose.
|
|
56
63
|
*/
|