@codyswann/lisa 2.247.3 → 2.250.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 +5 -0
- package/all/copy-overwrite/scripts/lisa-work-item.mjs +1102 -0
- package/dist/cli/doctor-kane.d.ts +15 -0
- package/dist/cli/doctor-kane.d.ts.map +1 -0
- package/dist/cli/doctor-kane.js +28 -0
- package/dist/cli/doctor-kane.js.map +1 -0
- package/dist/cli/doctor.d.ts +3 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +4 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +56 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kane-cmd.d.ts +45 -0
- package/dist/cli/kane-cmd.d.ts.map +1 -0
- package/dist/cli/kane-cmd.js +91 -0
- package/dist/cli/kane-cmd.js.map +1 -0
- package/dist/cli/ui-lisa-version.d.ts.map +1 -1
- package/dist/cli/ui-lisa-version.js +1 -5
- package/dist/cli/ui-lisa-version.js.map +1 -1
- package/dist/core/kane-cli-parser.d.ts +9 -0
- package/dist/core/kane-cli-parser.d.ts.map +1 -0
- package/dist/core/kane-cli-parser.js +145 -0
- package/dist/core/kane-cli-parser.js.map +1 -0
- package/dist/core/kane-cli-process.d.ts +15 -0
- package/dist/core/kane-cli-process.d.ts.map +1 -0
- package/dist/core/kane-cli-process.js +77 -0
- package/dist/core/kane-cli-process.js.map +1 -0
- package/dist/core/kane-cli-types.d.ts +61 -0
- package/dist/core/kane-cli-types.d.ts.map +1 -0
- package/dist/core/kane-cli-types.js +3 -0
- package/dist/core/kane-cli-types.js.map +1 -0
- package/dist/core/kane-cli.d.ts +42 -0
- package/dist/core/kane-cli.d.ts.map +1 -0
- package/dist/core/kane-cli.js +253 -0
- package/dist/core/kane-cli.js.map +1 -0
- package/dist/core/kane-pilot-report.d.ts +21 -0
- package/dist/core/kane-pilot-report.d.ts.map +1 -0
- package/dist/core/kane-pilot-report.js +169 -0
- package/dist/core/kane-pilot-report.js.map +1 -0
- package/dist/core/kane-pilot-types.d.ts +60 -0
- package/dist/core/kane-pilot-types.d.ts.map +1 -0
- package/dist/core/kane-pilot-types.js +2 -0
- package/dist/core/kane-pilot-types.js.map +1 -0
- package/dist/core/kane-pilot.d.ts +25 -0
- package/dist/core/kane-pilot.d.ts.map +1 -0
- package/dist/core/kane-pilot.js +255 -0
- package/dist/core/kane-pilot.js.map +1 -0
- package/dist/core/project-config-kane.d.ts +27 -0
- package/dist/core/project-config-kane.d.ts.map +1 -0
- package/dist/core/project-config-kane.js +152 -0
- package/dist/core/project-config-kane.js.map +1 -0
- package/dist/core/project-config.d.ts +11 -0
- package/dist/core/project-config.d.ts.map +1 -1
- package/dist/core/project-config.js +7 -5
- package/dist/core/project-config.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-github-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/lisa/.codex-plugin/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-improve-harness/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-kane-browser/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-kane/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-track/SKILL.md +71 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-track/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-verify/SKILL.md +1 -0
- package/plugins/lisa/agents/product-specialist.md +2 -0
- package/plugins/lisa/agents/quality-specialist.md +4 -1
- package/plugins/lisa/agents/verification-specialist.md +2 -2
- package/plugins/lisa/commands/improve-harness.md +6 -0
- package/plugins/lisa/commands/track.md +6 -0
- package/plugins/lisa/rules/eager/config-resolution.md +9 -0
- package/plugins/lisa/rules/eager/convergent-review.md +19 -0
- package/plugins/lisa/rules/eager/tracked-work.md +7 -0
- package/plugins/lisa/rules/eager/verification.md +1 -1
- package/plugins/lisa/rules/reference/config-resolution.md +26 -1
- package/plugins/lisa/rules/reference/convergent-review.md +69 -0
- package/plugins/lisa/rules/reference/tracked-work.md +26 -0
- package/plugins/lisa/rules/reference/verification.md +4 -2
- package/plugins/lisa/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/lisa/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/lisa/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-github-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/lisa/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa/skills/lisa-improve-harness/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-jira-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa/skills/lisa-kane-browser/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-linear-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/lisa/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/lisa/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/lisa/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa/skills/lisa-setup-kane/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-track/SKILL.md +71 -0
- package/plugins/lisa/skills/lisa-track/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa/skills/lisa-tracker-claim/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/lisa/skills/lisa-verify/SKILL.md +1 -0
- package/plugins/lisa-agy/agents/product-specialist.md +2 -0
- package/plugins/lisa-agy/agents/quality-specialist.md +4 -1
- package/plugins/lisa-agy/agents/verification-specialist.md +2 -2
- package/plugins/lisa-agy/commands/lisa/improve-harness.md +6 -0
- package/plugins/lisa-agy/commands/lisa/track.md +6 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/lisa-agy/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/lisa-agy/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/lisa-agy/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa-agy/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa-agy/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/lisa-agy/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/lisa-agy/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa-agy/skills/lisa-track/SKILL.md +71 -0
- package/plugins/lisa-agy/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa-agy/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/lisa-agy/skills/lisa-verify/SKILL.md +1 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/agents/product-specialist.agent.md +2 -0
- package/plugins/lisa-copilot/agents/quality-specialist.agent.md +4 -1
- package/plugins/lisa-copilot/agents/verification-specialist.agent.md +2 -2
- package/plugins/lisa-copilot/commands/lisa/improve-harness.md +6 -0
- package/plugins/lisa-copilot/commands/lisa/track.md +6 -0
- package/plugins/lisa-copilot/rules/eager/config-resolution.md +9 -0
- package/plugins/lisa-copilot/rules/eager/convergent-review.md +19 -0
- package/plugins/lisa-copilot/rules/eager/tracked-work.md +7 -0
- package/plugins/lisa-copilot/rules/eager/verification.md +1 -1
- package/plugins/lisa-copilot/rules/reference/config-resolution.md +26 -1
- package/plugins/lisa-copilot/rules/reference/convergent-review.md +69 -0
- package/plugins/lisa-copilot/rules/reference/tracked-work.md +26 -0
- package/plugins/lisa-copilot/rules/reference/verification.md +4 -2
- package/plugins/lisa-copilot/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/lisa-copilot/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/lisa-copilot/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa-copilot/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa-copilot/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/lisa-copilot/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/lisa-copilot/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa-copilot/skills/lisa-track/SKILL.md +71 -0
- package/plugins/lisa-copilot/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa-copilot/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/lisa-copilot/skills/lisa-verify/SKILL.md +1 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/agents/product-specialist.md +2 -0
- package/plugins/lisa-cursor/agents/quality-specialist.md +4 -1
- package/plugins/lisa-cursor/agents/verification-specialist.md +2 -2
- package/plugins/lisa-cursor/commands/lisa/improve-harness.md +6 -0
- package/plugins/lisa-cursor/commands/lisa/track.md +6 -0
- package/plugins/lisa-cursor/rules/config-resolution-reference.mdc +26 -1
- package/plugins/lisa-cursor/rules/config-resolution.mdc +9 -0
- package/plugins/lisa-cursor/rules/convergent-review-reference.mdc +74 -0
- package/plugins/lisa-cursor/rules/convergent-review.mdc +24 -0
- package/plugins/lisa-cursor/rules/tracked-work-reference.mdc +31 -0
- package/plugins/lisa-cursor/rules/tracked-work.mdc +12 -0
- package/plugins/lisa-cursor/rules/verification-reference.mdc +4 -2
- package/plugins/lisa-cursor/rules/verification.mdc +1 -1
- package/plugins/lisa-cursor/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/lisa-cursor/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/lisa-cursor/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa-cursor/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa-cursor/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/lisa-cursor/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/lisa-cursor/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa-cursor/skills/lisa-track/SKILL.md +71 -0
- package/plugins/lisa-cursor/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa-cursor/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/lisa-cursor/skills/lisa-verify/SKILL.md +1 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/agents/product-specialist.md +2 -0
- package/plugins/src/base/agents/quality-specialist.md +4 -1
- package/plugins/src/base/agents/verification-specialist.md +2 -2
- package/plugins/src/base/commands/improve-harness.md +6 -0
- package/plugins/src/base/commands/track.md +6 -0
- package/plugins/src/base/rules/eager/config-resolution.md +9 -0
- package/plugins/src/base/rules/eager/convergent-review.md +19 -0
- package/plugins/src/base/rules/eager/tracked-work.md +7 -0
- package/plugins/src/base/rules/eager/verification.md +1 -1
- package/plugins/src/base/rules/reference/config-resolution.md +26 -1
- package/plugins/src/base/rules/reference/convergent-review.md +69 -0
- package/plugins/src/base/rules/reference/tracked-work.md +26 -0
- package/plugins/src/base/rules/reference/verification.md +4 -2
- package/plugins/src/base/skills/lisa-codify-verification/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +7 -0
- package/plugins/src/base/skills/lisa-exploratory-qa/SKILL.md +3 -0
- package/plugins/src/base/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +50 -14
- package/plugins/src/base/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/src/base/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/src/base/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/src/base/skills/lisa-linear-claim/SKILL.md +27 -0
- package/plugins/src/base/skills/lisa-monitor/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-parity-coderabbit/SKILL.md +10 -0
- package/plugins/src/base/skills/lisa-product-walkthrough/SKILL.md +2 -0
- package/plugins/src/base/skills/lisa-review-local/SKILL.md +11 -0
- package/plugins/src/base/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/src/base/skills/lisa-track/SKILL.md +71 -0
- package/plugins/src/base/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/src/base/skills/lisa-use-the-product/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-verification-lifecycle/SKILL.md +3 -1
- package/plugins/src/base/skills/lisa-verify/SKILL.md +1 -0
- package/rails/copy-overwrite/lefthook.yml +10 -0
- package/scripts/lisa-work-item.mjs +5 -0
- package/typescript/copy-contents/.husky/commit-msg +9 -23
- package/typescript/copy-contents/.husky/pre-push +10 -0
- package/typescript/copy-contents/.husky/prepare-commit-msg +15 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Provider-neutral work-item binding and Git enforcement for Lisa projects.
|
|
4
|
+
* State is private to the current linked worktree; durable linkage lives in
|
|
5
|
+
* commit trailers, pull-request bodies, and the configured tracker.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
const RELEASE_SUBJECT =
|
|
20
|
+
/^chore\(release\): \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? \[skip ci\]$/;
|
|
21
|
+
const ZERO_OID = /^0+$/;
|
|
22
|
+
const MARKER = "[lisa-pr-link]";
|
|
23
|
+
const GUIDANCE = [
|
|
24
|
+
"Mention the ticket this work relates to, or ask Lisa to create one:",
|
|
25
|
+
" Work-Item: <configured-project-ticket>",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
class TrackingError extends Error {}
|
|
29
|
+
|
|
30
|
+
function run(command, args, options = {}) {
|
|
31
|
+
const result = spawnSync(command, args, {
|
|
32
|
+
cwd: options.cwd ?? process.cwd(),
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: options.env ?? process.env,
|
|
35
|
+
input: options.input,
|
|
36
|
+
});
|
|
37
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
38
|
+
throw new TrackingError(
|
|
39
|
+
options.error ?? `${command} ${args.join(" ")} failed`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function git(args, options = {}) {
|
|
46
|
+
return run("git", args, options).stdout.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function projectRoot() {
|
|
50
|
+
return git(["rev-parse", "--show-toplevel"]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function statePath() {
|
|
54
|
+
return resolve(git(["rev-parse", "--git-path", "lisa/work-item.json"]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function currentBranch() {
|
|
58
|
+
return git(["branch", "--show-current"]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deepMerge(base, override) {
|
|
62
|
+
if (Array.isArray(base) || Array.isArray(override)) return override;
|
|
63
|
+
if (
|
|
64
|
+
base === null ||
|
|
65
|
+
override === null ||
|
|
66
|
+
typeof base !== "object" ||
|
|
67
|
+
typeof override !== "object"
|
|
68
|
+
) {
|
|
69
|
+
return override;
|
|
70
|
+
}
|
|
71
|
+
const merged = { ...base };
|
|
72
|
+
for (const [key, value] of Object.entries(override)) {
|
|
73
|
+
merged[key] = key in merged ? deepMerge(merged[key], value) : value;
|
|
74
|
+
}
|
|
75
|
+
return merged;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readJson(file, required = false) {
|
|
79
|
+
if (!existsSync(file)) {
|
|
80
|
+
if (required) throw new TrackingError(`Required file not found: ${file}`);
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
85
|
+
} catch {
|
|
86
|
+
throw new TrackingError(`Invalid JSON: ${file}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readConfig() {
|
|
91
|
+
const override = process.env.LISA_TRACKING_CONFIG_FILE;
|
|
92
|
+
if (override) return readJson(resolve(process.cwd(), override), true);
|
|
93
|
+
const root = projectRoot();
|
|
94
|
+
return deepMerge(
|
|
95
|
+
readJson(join(root, ".lisa.config.json"), true),
|
|
96
|
+
readJson(join(root, ".lisa.config.local.json"))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function values(value) {
|
|
101
|
+
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
102
|
+
if (!value || typeof value !== "object") return [];
|
|
103
|
+
return Object.values(value).flatMap(values);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lifecycleContract(config, provider) {
|
|
107
|
+
const configured =
|
|
108
|
+
provider === "jira"
|
|
109
|
+
? config.jira?.workflow
|
|
110
|
+
: provider === "github"
|
|
111
|
+
? config.github?.labels?.build
|
|
112
|
+
: config.linear?.labels?.build;
|
|
113
|
+
const defaults =
|
|
114
|
+
provider === "jira"
|
|
115
|
+
? {
|
|
116
|
+
ready: "Ready",
|
|
117
|
+
claimed: "In Progress",
|
|
118
|
+
review: "Code Review",
|
|
119
|
+
blocked: "Blocked",
|
|
120
|
+
done: {
|
|
121
|
+
dev: "On Dev",
|
|
122
|
+
staging: "On Stg",
|
|
123
|
+
production: "Done",
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
: {
|
|
127
|
+
ready: "status:ready",
|
|
128
|
+
claimed: "status:in-progress",
|
|
129
|
+
...(provider === "linear" ? { review: "status:code-review" } : {}),
|
|
130
|
+
blocked: "status:blocked",
|
|
131
|
+
done: {
|
|
132
|
+
dev: "status:on-dev",
|
|
133
|
+
staging: "status:on-stg",
|
|
134
|
+
production: "status:done",
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const roles = deepMerge(defaults, configured ?? {});
|
|
138
|
+
const done = values(roles.done);
|
|
139
|
+
const terminal =
|
|
140
|
+
typeof roles.done === "string"
|
|
141
|
+
? roles.done
|
|
142
|
+
: (roles.done?.production ?? done.at(-1));
|
|
143
|
+
return {
|
|
144
|
+
active: [roles.claimed, roles.review, roles.blocked, ...done]
|
|
145
|
+
.filter(value => typeof value === "string" && value !== terminal)
|
|
146
|
+
.map(value => value.toLowerCase()),
|
|
147
|
+
claimed: requireString(roles.claimed, `${provider} claimed lifecycle role`),
|
|
148
|
+
ready: requireString(roles.ready, `${provider} ready lifecycle role`),
|
|
149
|
+
terminal: String(terminal ?? "").toLowerCase(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function requireString(value, path) {
|
|
154
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
155
|
+
throw new TrackingError(`Tracker configuration is missing ${path}`);
|
|
156
|
+
}
|
|
157
|
+
return value.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function repoBasename(value) {
|
|
161
|
+
const normalized = String(value ?? "")
|
|
162
|
+
.trim()
|
|
163
|
+
.replace(/\.git$/, "");
|
|
164
|
+
return normalized.split(/[/:]/).filter(Boolean).at(-1) ?? "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function currentRepoIdentity(config) {
|
|
168
|
+
const configured = repoBasename(config.repo ?? config.github?.repo);
|
|
169
|
+
if (configured) return configured;
|
|
170
|
+
const githubRepository = repoBasename(process.env.GITHUB_REPOSITORY);
|
|
171
|
+
if (githubRepository) return githubRepository;
|
|
172
|
+
const remote = run("git", ["remote", "get-url", "origin"], {
|
|
173
|
+
allowFailure: true,
|
|
174
|
+
});
|
|
175
|
+
const inferred = remote.status === 0 ? repoBasename(remote.stdout) : "";
|
|
176
|
+
if (inferred) return inferred;
|
|
177
|
+
throw new TrackingError(
|
|
178
|
+
"Cannot resolve the current repository; configure repo or github.repo"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function trackerContract(config = readConfig()) {
|
|
183
|
+
const provider = requireString(config.tracker, "tracker").toLowerCase();
|
|
184
|
+
const identityRepo = currentRepoIdentity(config);
|
|
185
|
+
if (provider === "github") {
|
|
186
|
+
const org = requireString(config.github?.org, "github.org");
|
|
187
|
+
const githubRepo = requireString(config.github?.repo, "github.repo");
|
|
188
|
+
const queue =
|
|
189
|
+
typeof config.github?.queueRepo === "string" &&
|
|
190
|
+
config.github.queueRepo.trim() !== ""
|
|
191
|
+
? config.github.queueRepo.trim()
|
|
192
|
+
: `${org}/${githubRepo}`;
|
|
193
|
+
const repository = queue.includes("/") ? queue : `${org}/${queue}`;
|
|
194
|
+
return {
|
|
195
|
+
provider,
|
|
196
|
+
repository,
|
|
197
|
+
identityRepo,
|
|
198
|
+
lifecycle: lifecycleContract(config, provider),
|
|
199
|
+
repositoryIsIdentity:
|
|
200
|
+
repository.toLowerCase() === `${org}/${githubRepo}`.toLowerCase(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (provider === "jira") {
|
|
204
|
+
return {
|
|
205
|
+
provider,
|
|
206
|
+
identityRepo,
|
|
207
|
+
project: requireString(
|
|
208
|
+
config.jira?.project,
|
|
209
|
+
"jira.project"
|
|
210
|
+
).toUpperCase(),
|
|
211
|
+
cloudId: String(config.atlassian?.cloudId ?? "").trim(),
|
|
212
|
+
site: String(config.atlassian?.site ?? process.env.JIRA_SERVER ?? "")
|
|
213
|
+
.replace(/^https?:\/\//, "")
|
|
214
|
+
.replace(/\/$/, ""),
|
|
215
|
+
email: String(config.atlassian?.email ?? "").trim(),
|
|
216
|
+
lifecycle: lifecycleContract(config, provider),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (provider === "linear") {
|
|
220
|
+
return {
|
|
221
|
+
provider,
|
|
222
|
+
identityRepo,
|
|
223
|
+
workspace: requireString(config.linear?.workspace, "linear.workspace"),
|
|
224
|
+
teamKey: requireString(
|
|
225
|
+
config.linear?.teamKey,
|
|
226
|
+
"linear.teamKey"
|
|
227
|
+
).toUpperCase(),
|
|
228
|
+
lifecycle: lifecycleContract(config, provider),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
throw new TrackingError(
|
|
232
|
+
`Unknown tracker '${provider}'. Expected github, jira, or linear`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalizeRef(raw, contract = trackerContract()) {
|
|
237
|
+
const value = String(raw ?? "").trim();
|
|
238
|
+
if (contract.provider === "github") {
|
|
239
|
+
const match = /^([^\s/#]+\/[^\s/#]+)#([1-9]\d*)$/.exec(value);
|
|
240
|
+
if (!match)
|
|
241
|
+
throw new TrackingError(
|
|
242
|
+
`Invalid GitHub Work-Item '${value}'; expected owner/repo#123`
|
|
243
|
+
);
|
|
244
|
+
const repository = `${match[1]}`;
|
|
245
|
+
if (repository.toLowerCase() !== contract.repository.toLowerCase()) {
|
|
246
|
+
throw new TrackingError(
|
|
247
|
+
`Work-Item '${value}' is outside configured tracker repository ${contract.repository}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return `${contract.repository}#${match[2]}`;
|
|
251
|
+
}
|
|
252
|
+
const match = /^([A-Z][A-Z0-9]{1,9})-([1-9]\d*)$/.exec(value.toUpperCase());
|
|
253
|
+
if (!match)
|
|
254
|
+
throw new TrackingError(
|
|
255
|
+
`Invalid ${contract.provider} Work-Item '${value}'; expected KEY-123`
|
|
256
|
+
);
|
|
257
|
+
const expected =
|
|
258
|
+
contract.provider === "jira" ? contract.project : contract.teamKey;
|
|
259
|
+
if (match[1] !== expected) {
|
|
260
|
+
throw new TrackingError(
|
|
261
|
+
`Work-Item '${value}' is outside configured ${contract.provider} project ${expected}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return `${match[1]}-${match[2]}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function readState(optional = true) {
|
|
268
|
+
const file = statePath();
|
|
269
|
+
if (!existsSync(file)) {
|
|
270
|
+
if (optional) return undefined;
|
|
271
|
+
throw new TrackingError("No work item is bound to this worktree");
|
|
272
|
+
}
|
|
273
|
+
const state = readJson(file, true);
|
|
274
|
+
if (
|
|
275
|
+
state.version !== 1 ||
|
|
276
|
+
(state.branch !== null && typeof state.branch !== "string") ||
|
|
277
|
+
typeof state.provider !== "string" ||
|
|
278
|
+
typeof state.ref !== "string"
|
|
279
|
+
) {
|
|
280
|
+
throw new TrackingError(`Malformed work-item binding: ${file}`);
|
|
281
|
+
}
|
|
282
|
+
return state;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function assertStateBranch(state) {
|
|
286
|
+
const branch = currentBranch();
|
|
287
|
+
if (!branch)
|
|
288
|
+
throw new TrackingError(
|
|
289
|
+
"Cannot use a work-item binding from detached HEAD"
|
|
290
|
+
);
|
|
291
|
+
if (state.branch === null) {
|
|
292
|
+
throw new TrackingError(
|
|
293
|
+
"Work-item binding is pending branch attachment; run lisa-work-item.mjs attach-branch"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (state.branch !== branch) {
|
|
297
|
+
throw new TrackingError(
|
|
298
|
+
`Work-item binding belongs to branch '${state.branch}', not '${branch}'. Re-bind or attach it to this branch`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeState(ref, provider = trackerContract().provider, options = {}) {
|
|
304
|
+
const branch = currentBranch();
|
|
305
|
+
if (!branch && options.requireBranch)
|
|
306
|
+
throw new TrackingError(
|
|
307
|
+
"Create or check out a feature branch before binding a work item"
|
|
308
|
+
);
|
|
309
|
+
const file = statePath();
|
|
310
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
311
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(
|
|
314
|
+
temporary,
|
|
315
|
+
`${JSON.stringify({ version: 1, branch: branch || null, provider, ref }, null, 2)}\n`,
|
|
316
|
+
{
|
|
317
|
+
flag: "wx",
|
|
318
|
+
mode: 0o600,
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
chmodSync(temporary, 0o600);
|
|
322
|
+
renameSync(temporary, file);
|
|
323
|
+
} finally {
|
|
324
|
+
rmSync(temporary, { force: true });
|
|
325
|
+
}
|
|
326
|
+
return file;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseTrailers(message) {
|
|
330
|
+
const parsed = git(["interpret-trailers", "--parse"], { input: message });
|
|
331
|
+
return parsed
|
|
332
|
+
.split("\n")
|
|
333
|
+
.filter(Boolean)
|
|
334
|
+
.flatMap(line => {
|
|
335
|
+
const match = /^Work-Item:\s*(.+?)\s*$/i.exec(line);
|
|
336
|
+
return match ? [match[1]] : [];
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function exactWorkItem(message, contract = trackerContract()) {
|
|
341
|
+
const refs = parseTrailers(message);
|
|
342
|
+
if (refs.length !== 1) {
|
|
343
|
+
throw new TrackingError(
|
|
344
|
+
`Expected exactly one Work-Item trailer; found ${refs.length}`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return canonicalizeRef(refs[0], contract);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function messageSubject(message) {
|
|
351
|
+
return message.split(/\r?\n/, 1)[0] ?? "";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isMergeInProgress() {
|
|
355
|
+
return (
|
|
356
|
+
run("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
|
|
357
|
+
allowFailure: true,
|
|
358
|
+
}).status === 0
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function commitExemption(sha) {
|
|
363
|
+
const parents = git(["rev-list", "--parents", "-n", "1", sha]).split(/\s+/);
|
|
364
|
+
if (parents.length > 2) return "merge";
|
|
365
|
+
return RELEASE_SUBJECT.test(git(["show", "-s", "--format=%s", sha]))
|
|
366
|
+
? "release"
|
|
367
|
+
: undefined;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function safeJson(text, context) {
|
|
371
|
+
try {
|
|
372
|
+
return JSON.parse(text);
|
|
373
|
+
} catch {
|
|
374
|
+
throw new TrackingError(`${context} returned malformed JSON`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function namesFrom(value) {
|
|
379
|
+
if (!Array.isArray(value)) return [];
|
|
380
|
+
return value
|
|
381
|
+
.map(item => (typeof item === "string" ? item : item?.name))
|
|
382
|
+
.filter(name => typeof name === "string")
|
|
383
|
+
.map(name => name.toLowerCase());
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function assertRepoScope(ref, contract, labels, components = []) {
|
|
387
|
+
const expected = `repo:${contract.identityRepo}`.toLowerCase();
|
|
388
|
+
const labelNames = namesFrom(labels);
|
|
389
|
+
const componentNames = namesFrom(components);
|
|
390
|
+
if (
|
|
391
|
+
!labelNames.includes(expected) &&
|
|
392
|
+
!componentNames.includes(contract.identityRepo.toLowerCase())
|
|
393
|
+
) {
|
|
394
|
+
throw new TrackingError(
|
|
395
|
+
`Work item ${ref} is not scoped to repository ${contract.identityRepo}; require label ${expected}`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function assertClaimedLifecycle(ref, contract, currentRoles) {
|
|
401
|
+
const roles = namesFrom(currentRoles);
|
|
402
|
+
if (
|
|
403
|
+
contract.lifecycle.terminal &&
|
|
404
|
+
roles.includes(contract.lifecycle.terminal)
|
|
405
|
+
) {
|
|
406
|
+
throw new TrackingError(
|
|
407
|
+
`Work item ${ref} is in terminal lifecycle role ${contract.lifecycle.terminal}`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (!roles.some(role => contract.lifecycle.active.includes(role))) {
|
|
411
|
+
throw new TrackingError(
|
|
412
|
+
`Work item ${ref} is not claimed; require ${contract.lifecycle.claimed} or a later non-terminal lifecycle role`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function assertLeaf(ref, type, childStates = []) {
|
|
418
|
+
const normalizedType = String(type ?? "")
|
|
419
|
+
.replace(/^type:/i, "")
|
|
420
|
+
.trim()
|
|
421
|
+
.toLowerCase();
|
|
422
|
+
const openChildren = childStates.filter(
|
|
423
|
+
state =>
|
|
424
|
+
!["closed", "done", "completed", "canceled", "cancelled"].includes(
|
|
425
|
+
String(state ?? "").toLowerCase()
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
if (normalizedType === "epic" || openChildren.length > 0) {
|
|
429
|
+
throw new TrackingError(
|
|
430
|
+
`Work item ${ref} is a container; bind a claimed leaf with no open children`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function typeFromLabels(labels) {
|
|
436
|
+
return namesFrom(labels).find(name => name.startsWith("type:"));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function githubHierarchy(ref, contract, number) {
|
|
440
|
+
const [owner, repo] = contract.repository.split("/");
|
|
441
|
+
const query =
|
|
442
|
+
"query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){issue(number:$number){subIssues(first:100){nodes{state}}}}}";
|
|
443
|
+
const result = run(
|
|
444
|
+
"gh",
|
|
445
|
+
[
|
|
446
|
+
"api",
|
|
447
|
+
"graphql",
|
|
448
|
+
"-f",
|
|
449
|
+
`query=${query}`,
|
|
450
|
+
"-F",
|
|
451
|
+
`owner=${owner}`,
|
|
452
|
+
"-F",
|
|
453
|
+
`repo=${repo}`,
|
|
454
|
+
"-F",
|
|
455
|
+
`number=${number}`,
|
|
456
|
+
],
|
|
457
|
+
{ allowFailure: true }
|
|
458
|
+
);
|
|
459
|
+
if (result.status !== 0)
|
|
460
|
+
throw new TrackingError(`GitHub issue ${ref} hierarchy is inaccessible`);
|
|
461
|
+
const response = safeJson(result.stdout, `GitHub issue ${ref} hierarchy`);
|
|
462
|
+
const issue = response.data?.repository?.issue;
|
|
463
|
+
if (!issue || !Array.isArray(issue.subIssues?.nodes)) {
|
|
464
|
+
throw new TrackingError(
|
|
465
|
+
`GitHub issue ${ref} did not expose native sub-issue hierarchy`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
return issue.subIssues.nodes.map(child => child.state);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function githubIssue(ref, contract) {
|
|
472
|
+
const number = ref.slice(ref.lastIndexOf("#") + 1);
|
|
473
|
+
const result = run(
|
|
474
|
+
"gh",
|
|
475
|
+
[
|
|
476
|
+
"issue",
|
|
477
|
+
"view",
|
|
478
|
+
number,
|
|
479
|
+
"--repo",
|
|
480
|
+
contract.repository,
|
|
481
|
+
"--json",
|
|
482
|
+
"number,url,state,labels,comments,closedByPullRequestsReferences",
|
|
483
|
+
],
|
|
484
|
+
{
|
|
485
|
+
allowFailure: true,
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
if (result.status !== 0)
|
|
489
|
+
throw new TrackingError(
|
|
490
|
+
`GitHub issue ${ref} does not exist or is inaccessible`
|
|
491
|
+
);
|
|
492
|
+
const issue = safeJson(result.stdout, `GitHub issue ${ref}`);
|
|
493
|
+
if (String(issue.number) !== number)
|
|
494
|
+
throw new TrackingError(`GitHub returned the wrong issue for ${ref}`);
|
|
495
|
+
if (String(issue.state ?? "").toUpperCase() !== "OPEN") {
|
|
496
|
+
throw new TrackingError(
|
|
497
|
+
`GitHub issue ${ref} is closed; bind an open work item`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (!contract.repositoryIsIdentity) {
|
|
501
|
+
assertRepoScope(ref, contract, issue.labels);
|
|
502
|
+
}
|
|
503
|
+
assertClaimedLifecycle(ref, contract, issue.labels);
|
|
504
|
+
assertLeaf(
|
|
505
|
+
ref,
|
|
506
|
+
typeFromLabels(issue.labels),
|
|
507
|
+
githubHierarchy(ref, contract, number)
|
|
508
|
+
);
|
|
509
|
+
return issue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function jiraStatusCategory(issue) {
|
|
513
|
+
return String(
|
|
514
|
+
issue.fields?.status?.statusCategory?.key ??
|
|
515
|
+
issue.status?.statusCategory?.key ??
|
|
516
|
+
issue.statusCategory?.key ??
|
|
517
|
+
""
|
|
518
|
+
).toLowerCase();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function jiraCredentials(contract) {
|
|
522
|
+
const token = process.env.JIRA_API_TOKEN || process.env.ATLASSIAN_API_TOKEN;
|
|
523
|
+
const login = process.env.JIRA_LOGIN || contract.email;
|
|
524
|
+
const server = String(contract.site || process.env.JIRA_SERVER || "")
|
|
525
|
+
.replace(/^https?:\/\//, "")
|
|
526
|
+
.replace(/\/$/, "");
|
|
527
|
+
const baseUrl = contract.cloudId
|
|
528
|
+
? `https://api.atlassian.com/ex/jira/${encodeURIComponent(contract.cloudId)}`
|
|
529
|
+
: server
|
|
530
|
+
? `https://${server}`
|
|
531
|
+
: "";
|
|
532
|
+
return token && login && baseUrl ? { token, login, baseUrl } : undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function jiraIssue(ref, contract) {
|
|
536
|
+
const credentials = jiraCredentials(contract);
|
|
537
|
+
if (credentials) {
|
|
538
|
+
const url = `${credentials.baseUrl}/rest/api/3/issue/${encodeURIComponent(ref)}?fields=project,status,labels,components,issuetype,subtasks,comment`;
|
|
539
|
+
const result = run(
|
|
540
|
+
"curl",
|
|
541
|
+
[
|
|
542
|
+
"-fsS",
|
|
543
|
+
"-u",
|
|
544
|
+
`${credentials.login}:${credentials.token}`,
|
|
545
|
+
"-H",
|
|
546
|
+
"Accept: application/json",
|
|
547
|
+
url,
|
|
548
|
+
],
|
|
549
|
+
{ allowFailure: true }
|
|
550
|
+
);
|
|
551
|
+
if (result.status !== 0)
|
|
552
|
+
throw new TrackingError(
|
|
553
|
+
`Jira ticket ${ref} does not exist or is inaccessible`
|
|
554
|
+
);
|
|
555
|
+
const issue = safeJson(result.stdout, `Jira ticket ${ref}`);
|
|
556
|
+
if (
|
|
557
|
+
String(issue.key ?? "").toUpperCase() !== ref ||
|
|
558
|
+
String(issue.fields?.project?.key ?? "").toUpperCase() !==
|
|
559
|
+
contract.project
|
|
560
|
+
) {
|
|
561
|
+
throw new TrackingError(
|
|
562
|
+
`Jira ticket ${ref} is outside configured project ${contract.project}`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
const statusCategory = jiraStatusCategory(issue);
|
|
566
|
+
if (!statusCategory) {
|
|
567
|
+
throw new TrackingError(
|
|
568
|
+
`Jira ticket ${ref} did not expose a status category`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (statusCategory === "done") {
|
|
572
|
+
throw new TrackingError(
|
|
573
|
+
`Jira ticket ${ref} is done; bind an active work item`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
assertRepoScope(
|
|
577
|
+
ref,
|
|
578
|
+
contract,
|
|
579
|
+
issue.fields?.labels,
|
|
580
|
+
issue.fields?.components
|
|
581
|
+
);
|
|
582
|
+
assertClaimedLifecycle(ref, contract, [issue.fields?.status?.name]);
|
|
583
|
+
assertLeaf(
|
|
584
|
+
ref,
|
|
585
|
+
issue.fields?.issuetype?.name,
|
|
586
|
+
(issue.fields?.subtasks ?? []).map(child =>
|
|
587
|
+
jiraStatusCategory(child) === "done" ? "done" : "open"
|
|
588
|
+
)
|
|
589
|
+
);
|
|
590
|
+
return issue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (
|
|
594
|
+
run("sh", ["-c", "command -v acli >/dev/null 2>&1"], { allowFailure: true })
|
|
595
|
+
.status === 0
|
|
596
|
+
) {
|
|
597
|
+
if (!contract.site) {
|
|
598
|
+
throw new TrackingError(
|
|
599
|
+
"Jira acli validation requires atlassian.site so the active account can be identity-matched"
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const auth = run("acli", ["auth", "status"], { allowFailure: true });
|
|
603
|
+
if (
|
|
604
|
+
auth.status !== 0 ||
|
|
605
|
+
!auth.stdout.toLowerCase().includes(contract.site.toLowerCase())
|
|
606
|
+
) {
|
|
607
|
+
throw new TrackingError(
|
|
608
|
+
`Jira acli is not authenticated to configured site ${contract.site}`
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const result = run(
|
|
612
|
+
"acli",
|
|
613
|
+
["jira", "workitem", "view", ref, "--fields", "*all", "--json"],
|
|
614
|
+
{ allowFailure: true }
|
|
615
|
+
);
|
|
616
|
+
if (result.status !== 0)
|
|
617
|
+
throw new TrackingError(
|
|
618
|
+
`Jira ticket ${ref} does not exist or is inaccessible`
|
|
619
|
+
);
|
|
620
|
+
const issue = safeJson(result.stdout, `Jira ticket ${ref}`);
|
|
621
|
+
if (String(issue.key ?? "").toUpperCase() !== ref)
|
|
622
|
+
throw new TrackingError(`Jira returned the wrong ticket for ${ref}`);
|
|
623
|
+
const statusCategory = jiraStatusCategory(issue);
|
|
624
|
+
if (!statusCategory) {
|
|
625
|
+
throw new TrackingError(
|
|
626
|
+
`Jira ticket ${ref} did not expose a status category`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (statusCategory === "done") {
|
|
630
|
+
throw new TrackingError(
|
|
631
|
+
`Jira ticket ${ref} is done; bind an active work item`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
assertRepoScope(
|
|
635
|
+
ref,
|
|
636
|
+
contract,
|
|
637
|
+
issue.fields?.labels ?? issue.labels,
|
|
638
|
+
issue.fields?.components ?? issue.components
|
|
639
|
+
);
|
|
640
|
+
assertClaimedLifecycle(ref, contract, [
|
|
641
|
+
issue.fields?.status?.name ?? issue.status?.name,
|
|
642
|
+
]);
|
|
643
|
+
assertLeaf(
|
|
644
|
+
ref,
|
|
645
|
+
issue.fields?.issuetype?.name ?? issue.issueType?.name,
|
|
646
|
+
(issue.fields?.subtasks ?? issue.subtasks ?? []).map(child =>
|
|
647
|
+
jiraStatusCategory(child) === "done" ? "done" : "open"
|
|
648
|
+
)
|
|
649
|
+
);
|
|
650
|
+
return issue;
|
|
651
|
+
}
|
|
652
|
+
throw new TrackingError(
|
|
653
|
+
"Jira validation requires identity-matched acli or ATLASSIAN_API_TOKEN/JIRA_API_TOKEN with JIRA_LOGIN and atlassian.cloudId/site"
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function readLinearKey(workspace) {
|
|
658
|
+
if (process.env.LINEAR_API_KEY) return process.env.LINEAR_API_KEY;
|
|
659
|
+
const suffix = workspace.toLowerCase().replace(/-/g, "_");
|
|
660
|
+
if (process.env[`LINEAR_API_KEY_${suffix}`])
|
|
661
|
+
return process.env[`LINEAR_API_KEY_${suffix}`];
|
|
662
|
+
if (process.platform === "darwin") {
|
|
663
|
+
return (
|
|
664
|
+
run(
|
|
665
|
+
"security",
|
|
666
|
+
["find-generic-password", "-s", "lisa-linear", "-a", workspace, "-w"],
|
|
667
|
+
{ allowFailure: true }
|
|
668
|
+
).stdout.trim() || undefined
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function linearIssue(ref, contract) {
|
|
675
|
+
const token = readLinearKey(contract.workspace);
|
|
676
|
+
if (!token)
|
|
677
|
+
throw new TrackingError(
|
|
678
|
+
"Linear validation requires LINEAR_API_KEY or a lisa-linear keychain entry"
|
|
679
|
+
);
|
|
680
|
+
const query =
|
|
681
|
+
"query($id:String!){issue(id:$id){id identifier team{key} state{type} labels{nodes{name}} children{nodes{state{type}}} attachments{nodes{url}} comments{nodes{body}}}}";
|
|
682
|
+
const payload = JSON.stringify({ query, variables: { id: ref } });
|
|
683
|
+
const result = run(
|
|
684
|
+
"curl",
|
|
685
|
+
[
|
|
686
|
+
"-fsS",
|
|
687
|
+
"-X",
|
|
688
|
+
"POST",
|
|
689
|
+
"-H",
|
|
690
|
+
"Content-Type: application/json",
|
|
691
|
+
"-H",
|
|
692
|
+
`Authorization: ${token}`,
|
|
693
|
+
"--data-binary",
|
|
694
|
+
payload,
|
|
695
|
+
"https://api.linear.app/graphql",
|
|
696
|
+
],
|
|
697
|
+
{ allowFailure: true }
|
|
698
|
+
);
|
|
699
|
+
if (result.status !== 0)
|
|
700
|
+
throw new TrackingError(
|
|
701
|
+
`Linear issue ${ref} does not exist or is inaccessible`
|
|
702
|
+
);
|
|
703
|
+
const response = safeJson(result.stdout, `Linear issue ${ref}`);
|
|
704
|
+
if (Array.isArray(response.errors) && response.errors.length > 0)
|
|
705
|
+
throw new TrackingError(`Linear issue ${ref} validation failed`);
|
|
706
|
+
const issue = response.data?.issue;
|
|
707
|
+
if (
|
|
708
|
+
!issue ||
|
|
709
|
+
String(issue.identifier ?? "").toUpperCase() !== ref ||
|
|
710
|
+
String(issue.team?.key ?? "").toUpperCase() !== contract.teamKey
|
|
711
|
+
) {
|
|
712
|
+
throw new TrackingError(
|
|
713
|
+
`Linear issue ${ref} does not exist in configured team ${contract.teamKey}`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
const stateType = String(issue.state?.type ?? "").toLowerCase();
|
|
717
|
+
if (!stateType) {
|
|
718
|
+
throw new TrackingError(
|
|
719
|
+
`Linear issue ${ref} did not expose a workflow state`
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if (["completed", "canceled", "cancelled"].includes(stateType)) {
|
|
723
|
+
throw new TrackingError(
|
|
724
|
+
`Linear issue ${ref} is terminal; bind an active work item`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
assertRepoScope(ref, contract, issue.labels?.nodes);
|
|
728
|
+
assertClaimedLifecycle(ref, contract, issue.labels?.nodes);
|
|
729
|
+
assertLeaf(
|
|
730
|
+
ref,
|
|
731
|
+
typeFromLabels(issue.labels?.nodes),
|
|
732
|
+
(issue.children?.nodes ?? []).map(child => child.state?.type)
|
|
733
|
+
);
|
|
734
|
+
return issue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function validateLive(ref, contract = trackerContract()) {
|
|
738
|
+
if (contract.provider === "github") return githubIssue(ref, contract);
|
|
739
|
+
if (contract.provider === "jira") return jiraIssue(ref, contract);
|
|
740
|
+
return linearIssue(ref, contract);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function textContainsBacklink(value, prUrl) {
|
|
744
|
+
if (typeof value === "string")
|
|
745
|
+
return value.includes(MARKER) && value.includes(prUrl);
|
|
746
|
+
if (Array.isArray(value))
|
|
747
|
+
return value.some(item => textContainsBacklink(item, prUrl));
|
|
748
|
+
if (value && typeof value === "object")
|
|
749
|
+
return Object.values(value).some(item => textContainsBacklink(item, prUrl));
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function assertBacklink(
|
|
754
|
+
ref,
|
|
755
|
+
prUrl,
|
|
756
|
+
contract,
|
|
757
|
+
issue = validateLive(ref, contract)
|
|
758
|
+
) {
|
|
759
|
+
if (contract.provider === "github") {
|
|
760
|
+
const native = (issue.closedByPullRequestsReferences ?? []).some(
|
|
761
|
+
pr => pr.url === prUrl
|
|
762
|
+
);
|
|
763
|
+
const fallback = (issue.comments ?? []).some(comment =>
|
|
764
|
+
textContainsBacklink(comment.body, prUrl)
|
|
765
|
+
);
|
|
766
|
+
if (!native && !fallback)
|
|
767
|
+
throw new TrackingError(
|
|
768
|
+
`GitHub issue ${ref} has no verified backlink to ${prUrl}`
|
|
769
|
+
);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (contract.provider === "linear") {
|
|
773
|
+
const native = (issue.attachments?.nodes ?? []).some(
|
|
774
|
+
attachment => attachment.url === prUrl
|
|
775
|
+
);
|
|
776
|
+
const fallback = (issue.comments?.nodes ?? []).some(comment =>
|
|
777
|
+
textContainsBacklink(comment.body, prUrl)
|
|
778
|
+
);
|
|
779
|
+
if (!native && !fallback)
|
|
780
|
+
throw new TrackingError(
|
|
781
|
+
`Linear issue ${ref} has no verified backlink to ${prUrl}`
|
|
782
|
+
);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const fallback = textContainsBacklink(
|
|
786
|
+
issue.fields?.comment?.comments ?? issue.comments ?? [],
|
|
787
|
+
prUrl
|
|
788
|
+
);
|
|
789
|
+
if (fallback) return;
|
|
790
|
+
const credentials = jiraCredentials(contract);
|
|
791
|
+
if (credentials) {
|
|
792
|
+
const url = `${credentials.baseUrl}/rest/api/3/issue/${encodeURIComponent(ref)}/remotelink`;
|
|
793
|
+
const result = run(
|
|
794
|
+
"curl",
|
|
795
|
+
[
|
|
796
|
+
"-fsS",
|
|
797
|
+
"-u",
|
|
798
|
+
`${credentials.login}:${credentials.token}`,
|
|
799
|
+
"-H",
|
|
800
|
+
"Accept: application/json",
|
|
801
|
+
url,
|
|
802
|
+
],
|
|
803
|
+
{ allowFailure: true }
|
|
804
|
+
);
|
|
805
|
+
if (result.status === 0) {
|
|
806
|
+
const links = safeJson(result.stdout, `Jira remote links for ${ref}`);
|
|
807
|
+
if (
|
|
808
|
+
Array.isArray(links) &&
|
|
809
|
+
links.some(link => link.object?.url === prUrl)
|
|
810
|
+
)
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
throw new TrackingError(
|
|
815
|
+
`Jira ticket ${ref} has no verified backlink to ${prUrl}`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function assertStateMatches(ref, contract) {
|
|
820
|
+
const state = readState(true);
|
|
821
|
+
if (!state) return;
|
|
822
|
+
assertStateBranch(state);
|
|
823
|
+
if (
|
|
824
|
+
state.provider !== contract.provider ||
|
|
825
|
+
canonicalizeRef(state.ref, contract) !== ref
|
|
826
|
+
) {
|
|
827
|
+
throw new TrackingError(
|
|
828
|
+
`Work-Item ${ref} does not match this worktree's binding ${state.ref}`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function validateMessage(message, options = {}) {
|
|
834
|
+
if (options.allowInProgressMerge && isMergeInProgress())
|
|
835
|
+
return { exempt: "merge" };
|
|
836
|
+
if (RELEASE_SUBJECT.test(messageSubject(message)))
|
|
837
|
+
return { exempt: "release" };
|
|
838
|
+
const contract = trackerContract();
|
|
839
|
+
const ref = exactWorkItem(message, contract);
|
|
840
|
+
assertStateMatches(ref, contract);
|
|
841
|
+
const issue = validateLive(ref, contract);
|
|
842
|
+
return { ref, contract, issue };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function commitMessage(sha) {
|
|
846
|
+
return git(["show", "-s", "--format=%B", sha]);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function validateCommits(commits) {
|
|
850
|
+
const contract = trackerContract();
|
|
851
|
+
const refs = new Set();
|
|
852
|
+
const issues = new Map();
|
|
853
|
+
let relevant = 0;
|
|
854
|
+
let mergeExempt = 0;
|
|
855
|
+
let releaseExempt = 0;
|
|
856
|
+
for (const sha of new Set(commits)) {
|
|
857
|
+
const exemption = commitExemption(sha);
|
|
858
|
+
if (exemption === "merge") {
|
|
859
|
+
mergeExempt += 1;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (exemption === "release") {
|
|
863
|
+
releaseExempt += 1;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
relevant += 1;
|
|
867
|
+
const ref = exactWorkItem(commitMessage(sha), contract);
|
|
868
|
+
refs.add(ref);
|
|
869
|
+
if (!issues.has(ref)) issues.set(ref, validateLive(ref, contract));
|
|
870
|
+
}
|
|
871
|
+
if (refs.size > 1)
|
|
872
|
+
throw new TrackingError(
|
|
873
|
+
`Push/PR contains mixed Work-Item references: ${[...refs].join(", ")}`
|
|
874
|
+
);
|
|
875
|
+
const [ref] = refs;
|
|
876
|
+
if (ref) assertStateMatches(ref, contract);
|
|
877
|
+
return {
|
|
878
|
+
contract,
|
|
879
|
+
ref,
|
|
880
|
+
issue: ref ? issues.get(ref) : undefined,
|
|
881
|
+
mergeExempt,
|
|
882
|
+
releaseExempt,
|
|
883
|
+
relevant,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function parsePushLines(input, remote) {
|
|
888
|
+
const commits = [];
|
|
889
|
+
for (const line of input.trim().split(/\r?\n/).filter(Boolean)) {
|
|
890
|
+
const [, localOid, , remoteOid] = line.trim().split(/\s+/);
|
|
891
|
+
if (!localOid || ZERO_OID.test(localOid)) continue;
|
|
892
|
+
const args =
|
|
893
|
+
remoteOid && !ZERO_OID.test(remoteOid)
|
|
894
|
+
? ["rev-list", `${remoteOid}..${localOid}`]
|
|
895
|
+
: ["rev-list", localOid, "--not", `--remotes=${remote}`];
|
|
896
|
+
commits.push(...git(args).split("\n").filter(Boolean));
|
|
897
|
+
}
|
|
898
|
+
if (commits.length === 0 && input.trim() === "") {
|
|
899
|
+
commits.push(
|
|
900
|
+
...git(["rev-list", "HEAD", "--not", `--remotes=${remote}`])
|
|
901
|
+
.split("\n")
|
|
902
|
+
.filter(Boolean)
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return commits;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function prWorkItem(body, contract) {
|
|
909
|
+
const matches = String(body ?? "")
|
|
910
|
+
.split(/\r?\n/)
|
|
911
|
+
.flatMap(line => {
|
|
912
|
+
const match = /^Work-Item:\s*(.+?)\s*$/i.exec(line);
|
|
913
|
+
return match ? [match[1]] : [];
|
|
914
|
+
});
|
|
915
|
+
if (matches.length !== 1)
|
|
916
|
+
throw new TrackingError(
|
|
917
|
+
`Pull request must contain exactly one Work-Item line; found ${matches.length}`
|
|
918
|
+
);
|
|
919
|
+
return canonicalizeRef(matches[0], contract);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function validatePrData(result, prUrl, prBody) {
|
|
923
|
+
if (result.relevant === 0) {
|
|
924
|
+
if (result.releaseExempt > 0 && result.mergeExempt === 0) return;
|
|
925
|
+
throw new TrackingError(
|
|
926
|
+
"Pull request has no non-merge commit linked to a work item"
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
if (!result.ref)
|
|
930
|
+
throw new TrackingError(
|
|
931
|
+
"Pull request commits are not linked to a work item"
|
|
932
|
+
);
|
|
933
|
+
const bodyRef = prWorkItem(prBody, result.contract);
|
|
934
|
+
if (bodyRef !== result.ref)
|
|
935
|
+
throw new TrackingError(
|
|
936
|
+
`Pull request Work-Item ${bodyRef} does not match commit Work-Item ${result.ref}`
|
|
937
|
+
);
|
|
938
|
+
assertBacklink(result.ref, prUrl, result.contract, result.issue);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function currentRepository() {
|
|
942
|
+
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
|
|
943
|
+
const result = run("gh", ["repo", "view", "--json", "nameWithOwner"], {
|
|
944
|
+
allowFailure: true,
|
|
945
|
+
});
|
|
946
|
+
if (result.status !== 0) return undefined;
|
|
947
|
+
return safeJson(result.stdout, "GitHub repository").nameWithOwner;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function currentPullRequest(number, repository = currentRepository()) {
|
|
951
|
+
const args = ["pr", "view"];
|
|
952
|
+
if (number) args.push(String(number));
|
|
953
|
+
if (repository) args.push("--repo", repository);
|
|
954
|
+
args.push("--json", "url,body,state");
|
|
955
|
+
const result = run("gh", args, { allowFailure: true });
|
|
956
|
+
return result.status === 0
|
|
957
|
+
? safeJson(result.stdout, "GitHub pull request")
|
|
958
|
+
: undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function option(args, name, envName) {
|
|
962
|
+
const index = args.indexOf(name);
|
|
963
|
+
return index >= 0 ? args[index + 1] : process.env[envName];
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function bind(args) {
|
|
967
|
+
const contract = trackerContract();
|
|
968
|
+
const ref = canonicalizeRef(args[0], contract);
|
|
969
|
+
validateLive(ref, contract);
|
|
970
|
+
const file = writeState(ref, contract.provider);
|
|
971
|
+
console.log(`work-item bound: ${ref} (${file})`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function prepareCommitMessage(args) {
|
|
975
|
+
const [file, source = ""] = args;
|
|
976
|
+
if (!file)
|
|
977
|
+
throw new TrackingError(
|
|
978
|
+
"prepare-commit-msg requires the commit message file"
|
|
979
|
+
);
|
|
980
|
+
if (
|
|
981
|
+
source === "merge" ||
|
|
982
|
+
RELEASE_SUBJECT.test(messageSubject(readFileSync(file, "utf8")))
|
|
983
|
+
)
|
|
984
|
+
return;
|
|
985
|
+
const state = readState(true);
|
|
986
|
+
if (!state) return;
|
|
987
|
+
assertStateBranch(state);
|
|
988
|
+
const contract = trackerContract();
|
|
989
|
+
const ref = canonicalizeRef(state.ref, contract);
|
|
990
|
+
run("git", [
|
|
991
|
+
"interpret-trailers",
|
|
992
|
+
"--in-place",
|
|
993
|
+
"--if-exists=doNothing",
|
|
994
|
+
"--if-missing=add",
|
|
995
|
+
"--trailer",
|
|
996
|
+
`Work-Item: ${ref}`,
|
|
997
|
+
file,
|
|
998
|
+
]);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function validateCommit(args) {
|
|
1002
|
+
const file = args[0];
|
|
1003
|
+
if (!file)
|
|
1004
|
+
throw new TrackingError("validate-commit requires the commit message file");
|
|
1005
|
+
const result = validateMessage(readFileSync(file, "utf8"), {
|
|
1006
|
+
allowInProgressMerge: true,
|
|
1007
|
+
});
|
|
1008
|
+
console.log(`WORK_ITEM_TRACKING_OK ${result.exempt ?? result.ref}`);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function validatePush(args) {
|
|
1012
|
+
const remote = args[0] || "origin";
|
|
1013
|
+
const input = readFileSync(0, "utf8");
|
|
1014
|
+
const result = validateCommits(parsePushLines(input, remote));
|
|
1015
|
+
const pr = currentPullRequest();
|
|
1016
|
+
if (!pr) {
|
|
1017
|
+
console.log(
|
|
1018
|
+
`WORK_ITEM_TRACKING_OK ${result.relevant} commit(s); no pull request exists yet, CI will verify PR linkage`
|
|
1019
|
+
);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
validatePrData(result, pr.url, pr.body);
|
|
1023
|
+
console.log(
|
|
1024
|
+
`WORK_ITEM_TRACKING_OK ${result.relevant} commit(s), PR body, and tracker backlink`
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function validatePr(args) {
|
|
1029
|
+
const base = option(args, "--base", "LISA_PR_BASE_SHA");
|
|
1030
|
+
const head = option(args, "--head", "LISA_PR_HEAD_SHA") || "HEAD";
|
|
1031
|
+
if (!base)
|
|
1032
|
+
throw new TrackingError("validate-pr requires --base or LISA_PR_BASE_SHA");
|
|
1033
|
+
const commits = git(["rev-list", `${base}..${head}`])
|
|
1034
|
+
.split("\n")
|
|
1035
|
+
.filter(Boolean);
|
|
1036
|
+
const result = validateCommits(commits);
|
|
1037
|
+
const bodyFile = option(args, "--body-file", "LISA_PR_BODY_FILE");
|
|
1038
|
+
const prNumber = option(args, "--pr-number", "LISA_PR_NUMBER");
|
|
1039
|
+
const suppliedUrl =
|
|
1040
|
+
option(args, "--pr-url", "LISA_PR_URL") ??
|
|
1041
|
+
option(args, "--url", "LISA_PR_URL");
|
|
1042
|
+
const repository =
|
|
1043
|
+
option(args, "--repo", "GITHUB_REPOSITORY") ?? currentRepository();
|
|
1044
|
+
const fetched = bodyFile
|
|
1045
|
+
? undefined
|
|
1046
|
+
: currentPullRequest(prNumber, repository);
|
|
1047
|
+
const pr = bodyFile
|
|
1048
|
+
? { url: suppliedUrl, body: readFileSync(bodyFile, "utf8") }
|
|
1049
|
+
: fetched && { ...fetched, url: suppliedUrl ?? fetched.url };
|
|
1050
|
+
if (!pr?.url) {
|
|
1051
|
+
throw new TrackingError(
|
|
1052
|
+
"validate-pr requires --pr-number, or --pr-url/--url with --body-file, and an accessible GitHub PR"
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
validatePrData(result, pr.url, pr.body);
|
|
1056
|
+
console.log(
|
|
1057
|
+
`WORK_ITEM_TRACKING_OK ${result.relevant} commit(s), PR body, and tracker backlink`
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function main() {
|
|
1062
|
+
const [command, ...args] = process.argv.slice(2);
|
|
1063
|
+
if (command === "bind") return bind(args);
|
|
1064
|
+
if (command === "current")
|
|
1065
|
+
return console.log(JSON.stringify(readState(false), null, 2));
|
|
1066
|
+
if (command === "attach-branch") {
|
|
1067
|
+
const state = readState(false);
|
|
1068
|
+
const contract = trackerContract();
|
|
1069
|
+
if (state.provider !== contract.provider) {
|
|
1070
|
+
throw new TrackingError(
|
|
1071
|
+
`Work-item binding provider ${state.provider} does not match configured tracker ${contract.provider}`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
const ref = canonicalizeRef(state.ref, contract);
|
|
1075
|
+
validateLive(ref, contract);
|
|
1076
|
+
const file = writeState(ref, contract.provider, { requireBranch: true });
|
|
1077
|
+
return console.log(
|
|
1078
|
+
`work-item binding attached to ${currentBranch()} (${file})`
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
if (command === "clear") {
|
|
1082
|
+
rmSync(statePath(), { force: true });
|
|
1083
|
+
return console.log("work-item binding cleared");
|
|
1084
|
+
}
|
|
1085
|
+
if (command === "prepare-commit-msg") return prepareCommitMessage(args);
|
|
1086
|
+
if (command === "validate-commit") return validateCommit(args);
|
|
1087
|
+
if (command === "validate-push") return validatePush(args);
|
|
1088
|
+
if (command === "validate-pr") return validatePr(args);
|
|
1089
|
+
throw new TrackingError(
|
|
1090
|
+
"Usage: lisa-work-item.mjs bind|current|attach-branch|clear|prepare-commit-msg|validate-commit|validate-push|validate-pr"
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
try {
|
|
1095
|
+
await main();
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1098
|
+
console.error(
|
|
1099
|
+
`\n❌ Work-item tracking blocked this operation: ${detail}\n\n${GUIDANCE}\n`
|
|
1100
|
+
);
|
|
1101
|
+
process.exitCode = 1;
|
|
1102
|
+
}
|