@dreki-gg/pi-jira 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +64 -0
- package/extensions/jira/acli.ts +166 -0
- package/extensions/jira/config.ts +119 -0
- package/extensions/jira/format.ts +220 -0
- package/extensions/jira/index.ts +325 -0
- package/extensions/jira/output.ts +17 -0
- package/package.json +57 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @dreki-gg/pi-jira
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- New extension: Jira tools backed by an authenticated Atlassian CLI (`acli`)
|
|
8
|
+
session. `jira_view` pulls a ticket's description and comments into context,
|
|
9
|
+
`jira_search` runs JQL queries, `jira_comments` reads a ticket's thread, and
|
|
10
|
+
`jira_comment` posts a concise comment back. Every read tool returns a compact
|
|
11
|
+
digest inline and writes the full, untruncated output to a temp file (so long
|
|
12
|
+
descriptions and comment threads survive harness truncation — the agent reads
|
|
13
|
+
the file). A `/jira` command reports auth and config status, and `.pi/jira.json`
|
|
14
|
+
supports `acliPath`, `defaultProject`, `viewFields`, and `searchLimit`.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @dreki-gg/pi-jira
|
|
2
|
+
|
|
3
|
+
Jira tools for [pi](https://github.com/earendil-works/pi-coding-agent) that ride on an **authenticated Atlassian CLI (`acli`) session** — pull ticket context into the agent and post concise comments back. No API tokens to manage in pi; auth lives entirely in your `acli` session.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `jira_view` | Fetch a work item (description + comments) for context. Returns a compact digest inline and writes the full ticket to a temp file for `read`. |
|
|
10
|
+
| `jira_search` | Search work items with JQL. Returns a compact list of keys, statuses, and summaries. |
|
|
11
|
+
| `jira_comments` | List the comment thread on a work item. |
|
|
12
|
+
| `jira_comment` | Post a concise comment to a work item. |
|
|
13
|
+
|
|
14
|
+
There is also a `/jira` command that reports auth + config status.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
### 1. Install and authenticate the Atlassian CLI
|
|
19
|
+
|
|
20
|
+
Install [`acli`](https://developer.atlassian.com/cloud/acli/) and log in **once** in your terminal:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
acli jira auth login
|
|
24
|
+
acli jira auth status # confirm you have an active session
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
pi reuses that session — it never sees your credentials.
|
|
28
|
+
|
|
29
|
+
### 2. Install the extension
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install npm:@dreki-gg/pi-jira
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
On session start the extension runs `acli jira auth status`; if there is no active session it warns you to run `acli jira auth login`.
|
|
36
|
+
|
|
37
|
+
## Project config (optional)
|
|
38
|
+
|
|
39
|
+
Create `.pi/jira.json` in your project root:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"acliPath": "acli",
|
|
44
|
+
"defaultProject": "PROJ",
|
|
45
|
+
"viewFields": "summary,issuetype,status,priority,assignee,reporter,labels,components,parent,description,comment,created,updated",
|
|
46
|
+
"searchLimit": 25
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Field | Default | Purpose |
|
|
51
|
+
|-------|---------|---------|
|
|
52
|
+
| `acliPath` | `"acli"` | Path/name of the `acli` binary. Set this if `acli` is not on `PATH`. |
|
|
53
|
+
| `defaultProject` | _(none)_ | Lets you pass a bare number (e.g. `123` → `PROJ-123`). |
|
|
54
|
+
| `viewFields` | curated set | Fields requested by `jira_view` (`acli` `--fields` syntax; `*all` for everything). |
|
|
55
|
+
| `searchLimit` | `25` | Default max results for `jira_search`. |
|
|
56
|
+
|
|
57
|
+
## How context flows
|
|
58
|
+
|
|
59
|
+
**Invariant: every read tool returns a compact digest inline and writes the full, untruncated output to a temp file whose path is in the response.** Inline content can be truncated by the harness, so the on-disk copy is the source of truth — `read` the file path instead of re-fetching. Atlassian Document Format is flattened to text in both. Which tools write files, the digest shape, and the filename pattern are owned by `extensions/jira/index.ts` + `format.ts`, not restated here.
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- Comments are visible to your whole team. The agent is instructed to keep them concise and to confirm wording before posting unless you explicitly asked it to post.
|
|
64
|
+
- Runs on Node.js (pi loads extensions via jiti) and shells out to `acli` with `child_process.execFile` — comment bodies and JQL are passed as discrete argv entries, so there is no shell-injection surface.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Argument builders (pure — unit tested without spawning acli)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export function buildAuthStatusArgs(): string[] {
|
|
11
|
+
return ['jira', 'auth', 'status'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildViewArgs(key: string, fields: string): string[] {
|
|
15
|
+
return ['jira', 'workitem', 'view', key, '--fields', fields, '--json'];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchInput {
|
|
19
|
+
jql: string;
|
|
20
|
+
limit: number;
|
|
21
|
+
fields?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildSearchArgs(input: SearchInput): string[] {
|
|
25
|
+
const args = ['jira', 'workitem', 'search', '--jql', input.jql, '--limit', String(input.limit)];
|
|
26
|
+
if (input.fields) {
|
|
27
|
+
args.push('--fields', input.fields);
|
|
28
|
+
}
|
|
29
|
+
args.push('--json');
|
|
30
|
+
return args;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildCommentListArgs(key: string, limit: number): string[] {
|
|
34
|
+
return ['jira', 'workitem', 'comment', 'list', '--key', key, '--limit', String(limit), '--json'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildCommentCreateArgs(key: string, body: string): string[] {
|
|
38
|
+
return ['jira', 'workitem', 'comment', 'create', '--key', key, '--body', body, '--json'];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Execution
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface AcliResult {
|
|
46
|
+
stdout: string;
|
|
47
|
+
stderr: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AcliError extends Error {
|
|
51
|
+
readonly code: 'binary_not_found' | 'not_authenticated' | 'command_failed';
|
|
52
|
+
readonly stderr: string;
|
|
53
|
+
readonly exitCode?: number;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
code: AcliError['code'],
|
|
57
|
+
message: string,
|
|
58
|
+
options: { stderr?: string; exitCode?: number } = {},
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'AcliError';
|
|
62
|
+
this.code = code;
|
|
63
|
+
this.stderr = options.stderr ?? '';
|
|
64
|
+
this.exitCode = options.exitCode;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Recognises the auth-failure signature in acli stderr output. */
|
|
69
|
+
export function looksUnauthenticated(text: string): boolean {
|
|
70
|
+
const t = text.toLowerCase();
|
|
71
|
+
return (
|
|
72
|
+
t.includes('not authenticated') ||
|
|
73
|
+
t.includes('please authenticate') ||
|
|
74
|
+
t.includes('no active session') ||
|
|
75
|
+
t.includes('auth login') ||
|
|
76
|
+
t.includes('unauthorized') ||
|
|
77
|
+
t.includes('401')
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Runs the acli binary and returns stdout/stderr. Maps the common failure
|
|
83
|
+
* modes (missing binary, auth expiry) onto a typed {@link AcliError}.
|
|
84
|
+
*/
|
|
85
|
+
export async function runAcli(
|
|
86
|
+
bin: string,
|
|
87
|
+
args: string[],
|
|
88
|
+
signal?: AbortSignal,
|
|
89
|
+
): Promise<AcliResult> {
|
|
90
|
+
try {
|
|
91
|
+
const { stdout, stderr } = await execFileAsync(bin, args, {
|
|
92
|
+
signal,
|
|
93
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
94
|
+
encoding: 'utf-8',
|
|
95
|
+
});
|
|
96
|
+
return { stdout, stderr };
|
|
97
|
+
} catch (err: unknown) {
|
|
98
|
+
const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: unknown };
|
|
99
|
+
|
|
100
|
+
if (e.code === 'ENOENT') {
|
|
101
|
+
throw new AcliError(
|
|
102
|
+
'binary_not_found',
|
|
103
|
+
`Atlassian CLI not found (tried "${bin}"). Install acli and ensure it is on PATH, or set "acliPath" in .pi/jira.json.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stderr = e.stderr ?? '';
|
|
108
|
+
const stdout = e.stdout ?? '';
|
|
109
|
+
const combined = `${stderr}\n${stdout}`;
|
|
110
|
+
|
|
111
|
+
if (looksUnauthenticated(combined)) {
|
|
112
|
+
throw new AcliError(
|
|
113
|
+
'not_authenticated',
|
|
114
|
+
'No authenticated Atlassian CLI session. Run `acli jira auth login` in your terminal, then retry.',
|
|
115
|
+
{ stderr },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const exitCode = typeof e.code === 'number' ? e.code : undefined;
|
|
120
|
+
const detail = (stderr.trim() || stdout.trim() || e.message || 'unknown error').slice(0, 2000);
|
|
121
|
+
throw new AcliError('command_failed', `acli command failed: ${detail}`, { stderr, exitCode });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Verifies an authenticated acli session via `acli jira auth status`.
|
|
127
|
+
* Returns a flat status object rather than throwing, so the extension can
|
|
128
|
+
* surface a friendly warning without aborting startup.
|
|
129
|
+
*/
|
|
130
|
+
export async function checkAuth(
|
|
131
|
+
bin: string,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<{ authenticated: boolean; message: string }> {
|
|
134
|
+
try {
|
|
135
|
+
const { stdout, stderr } = await runAcli(bin, buildAuthStatusArgs(), signal);
|
|
136
|
+
const text = `${stdout}\n${stderr}`.trim();
|
|
137
|
+
if (looksUnauthenticated(text)) {
|
|
138
|
+
return { authenticated: false, message: text || 'No active session.' };
|
|
139
|
+
}
|
|
140
|
+
return { authenticated: true, message: text || 'Authenticated.' };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof AcliError) {
|
|
143
|
+
return { authenticated: false, message: err.message };
|
|
144
|
+
}
|
|
145
|
+
return { authenticated: false, message: (err as Error).message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Parses acli `--json` stdout, tolerating leading/trailing non-JSON noise. */
|
|
150
|
+
export function parseJsonOutput<T = unknown>(stdout: string): T {
|
|
151
|
+
const trimmed = stdout.trim();
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(trimmed) as T;
|
|
154
|
+
} catch {
|
|
155
|
+
// acli occasionally prefixes a status line before the JSON payload.
|
|
156
|
+
const start = trimmed.search(/[[{]/);
|
|
157
|
+
if (start > 0) {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(trimmed.slice(start)) as T;
|
|
160
|
+
} catch {
|
|
161
|
+
/* fall through */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw new AcliError('command_failed', 'Could not parse acli JSON output.');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Project config (.pi/jira.json)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface JiraProjectConfig {
|
|
9
|
+
/** Path/name of the Atlassian CLI binary. Default "acli". */
|
|
10
|
+
acliPath: string;
|
|
11
|
+
/** Default project key, used to qualify bare issue numbers and JQL. */
|
|
12
|
+
defaultProject?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Comma-separated field set passed to `acli jira workitem view --fields`.
|
|
15
|
+
* Tuned for agent context (description + comments) rather than acli defaults.
|
|
16
|
+
*/
|
|
17
|
+
viewFields: string;
|
|
18
|
+
/** Default max results for `jira_search`. Default 25. */
|
|
19
|
+
searchLimit: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RawConfig {
|
|
23
|
+
acliPath?: unknown;
|
|
24
|
+
defaultProject?: unknown;
|
|
25
|
+
viewFields?: unknown;
|
|
26
|
+
searchLimit?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Curated field set: rich enough for context without dumping every custom field. */
|
|
30
|
+
export const DEFAULT_VIEW_FIELDS =
|
|
31
|
+
'summary,issuetype,status,priority,assignee,reporter,labels,components,parent,description,comment,created,updated';
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_CONFIG: JiraProjectConfig = {
|
|
34
|
+
acliPath: 'acli',
|
|
35
|
+
viewFields: DEFAULT_VIEW_FIELDS,
|
|
36
|
+
searchLimit: 25,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Loads and validates `.pi/jira.json` from the project root.
|
|
41
|
+
* Returns defaults if the file does not exist. Throws on invalid JSON or
|
|
42
|
+
* invalid field types.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadProjectConfig(cwd: string): Promise<JiraProjectConfig> {
|
|
45
|
+
const configPath = join(cwd, '.pi', 'jira.json');
|
|
46
|
+
|
|
47
|
+
let raw: string;
|
|
48
|
+
try {
|
|
49
|
+
raw = await readFile(configPath, 'utf-8');
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
52
|
+
return { ...DEFAULT_CONFIG };
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Failed to read ${configPath}: ${(err as Error).message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed: RawConfig;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw) as RawConfig;
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`Invalid JSON in ${configPath}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
65
|
+
throw new Error(`${configPath} must be a JSON object`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return validateConfig(parsed, configPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateConfig(raw: RawConfig, configPath: string): JiraProjectConfig {
|
|
72
|
+
const config: JiraProjectConfig = { ...DEFAULT_CONFIG };
|
|
73
|
+
|
|
74
|
+
if (raw.acliPath !== undefined) {
|
|
75
|
+
if (typeof raw.acliPath !== 'string' || raw.acliPath.trim() === '') {
|
|
76
|
+
throw new Error(`${configPath}: "acliPath" must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
config.acliPath = raw.acliPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (raw.defaultProject !== undefined) {
|
|
82
|
+
if (typeof raw.defaultProject !== 'string') {
|
|
83
|
+
throw new Error(`${configPath}: "defaultProject" must be a string`);
|
|
84
|
+
}
|
|
85
|
+
config.defaultProject = raw.defaultProject;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (raw.viewFields !== undefined) {
|
|
89
|
+
if (typeof raw.viewFields !== 'string' || raw.viewFields.trim() === '') {
|
|
90
|
+
throw new Error(`${configPath}: "viewFields" must be a non-empty string`);
|
|
91
|
+
}
|
|
92
|
+
config.viewFields = raw.viewFields;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (raw.searchLimit !== undefined) {
|
|
96
|
+
if (
|
|
97
|
+
typeof raw.searchLimit !== 'number' ||
|
|
98
|
+
!Number.isInteger(raw.searchLimit) ||
|
|
99
|
+
raw.searchLimit < 1
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(`${configPath}: "searchLimit" must be a positive integer`);
|
|
102
|
+
}
|
|
103
|
+
config.searchLimit = raw.searchLimit;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return config;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Qualifies a bare issue number with the default project (e.g. "123" -> "PROJ-123").
|
|
111
|
+
* Leaves already-qualified keys (containing a dash) untouched.
|
|
112
|
+
*/
|
|
113
|
+
export function qualifyKey(key: string, defaultProject?: string): string {
|
|
114
|
+
const trimmed = key.trim();
|
|
115
|
+
if (/^\d+$/.test(trimmed) && defaultProject) {
|
|
116
|
+
return `${defaultProject}-${trimmed}`;
|
|
117
|
+
}
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Jira JSON shapes (loose — acli surfaces the Jira REST issue shape)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
interface NamedRef {
|
|
6
|
+
name?: string;
|
|
7
|
+
displayName?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface AdfNode {
|
|
11
|
+
type?: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
content?: AdfNode[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface JiraComment {
|
|
17
|
+
id?: string;
|
|
18
|
+
author?: NamedRef;
|
|
19
|
+
created?: string;
|
|
20
|
+
updated?: string;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JiraIssue {
|
|
25
|
+
key?: string;
|
|
26
|
+
fields?: {
|
|
27
|
+
summary?: string;
|
|
28
|
+
issuetype?: NamedRef;
|
|
29
|
+
status?: NamedRef;
|
|
30
|
+
priority?: NamedRef;
|
|
31
|
+
assignee?: NamedRef | null;
|
|
32
|
+
reporter?: NamedRef | null;
|
|
33
|
+
labels?: string[];
|
|
34
|
+
components?: NamedRef[];
|
|
35
|
+
parent?: JiraIssue;
|
|
36
|
+
description?: unknown;
|
|
37
|
+
created?: string;
|
|
38
|
+
updated?: string;
|
|
39
|
+
comment?: { comments?: JiraComment[]; total?: number };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// ADF (Atlassian Document Format) → plain text
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Flattens an ADF document, plain string, or null into readable text. */
|
|
48
|
+
export function adfToText(value: unknown): string {
|
|
49
|
+
if (value == null) return '';
|
|
50
|
+
if (typeof value === 'string') return value;
|
|
51
|
+
if (typeof value !== 'object') return String(value);
|
|
52
|
+
|
|
53
|
+
const node = value as AdfNode;
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
|
|
56
|
+
const walk = (n: AdfNode): void => {
|
|
57
|
+
if (!n || typeof n !== 'object') return;
|
|
58
|
+
if (typeof n.text === 'string') parts.push(n.text);
|
|
59
|
+
if (n.type === 'hardBreak') parts.push('\n');
|
|
60
|
+
if (Array.isArray(n.content)) {
|
|
61
|
+
for (const child of n.content) walk(child);
|
|
62
|
+
// Paragraph / list-item boundaries become line breaks.
|
|
63
|
+
if (n.type === 'paragraph' || n.type === 'listItem' || n.type === 'heading') {
|
|
64
|
+
parts.push('\n');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
walk(node);
|
|
70
|
+
return parts
|
|
71
|
+
.join('')
|
|
72
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
73
|
+
.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function name(ref: NamedRef | null | undefined, fallback = '—'): string {
|
|
77
|
+
return ref?.displayName ?? ref?.name ?? fallback;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function truncate(text: string, max: number): string {
|
|
81
|
+
if (text.length <= max) return text;
|
|
82
|
+
return `${text.slice(0, max).trimEnd()}…`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Single work item
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Compact inline digest returned to the model (cheap on tokens). */
|
|
90
|
+
export function formatWorkItemDigest(issue: JiraIssue, fullFile?: string): string {
|
|
91
|
+
const f = issue.fields ?? {};
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
lines.push(`${issue.key ?? '(no key)'} — ${f.summary ?? '(no summary)'}`);
|
|
94
|
+
lines.push(
|
|
95
|
+
`Type: ${name(f.issuetype)} | Status: ${name(f.status)} | Priority: ${name(f.priority)} | Assignee: ${name(f.assignee)}`,
|
|
96
|
+
);
|
|
97
|
+
if (f.labels?.length) lines.push(`Labels: ${f.labels.join(', ')}`);
|
|
98
|
+
if (f.parent?.key) lines.push(`Parent: ${f.parent.key} — ${f.parent.fields?.summary ?? ''}`);
|
|
99
|
+
|
|
100
|
+
const desc = adfToText(f.description);
|
|
101
|
+
if (desc) lines.push('', truncate(desc, 600));
|
|
102
|
+
|
|
103
|
+
const comments = f.comment?.comments ?? [];
|
|
104
|
+
const total = f.comment?.total ?? comments.length;
|
|
105
|
+
if (total > 0) lines.push('', `Comments: ${total} (full thread in file)`);
|
|
106
|
+
|
|
107
|
+
if (fullFile) lines.push('', `Full ticket written to: ${fullFile}`);
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Full, untruncated markdown written to a temp file for the agent to read. */
|
|
112
|
+
export function formatWorkItemFull(issue: JiraIssue): string {
|
|
113
|
+
const f = issue.fields ?? {};
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
lines.push(`# ${issue.key ?? ''} — ${f.summary ?? ''}`, '');
|
|
116
|
+
lines.push(`- Type: ${name(f.issuetype)}`);
|
|
117
|
+
lines.push(`- Status: ${name(f.status)}`);
|
|
118
|
+
lines.push(`- Priority: ${name(f.priority)}`);
|
|
119
|
+
lines.push(`- Assignee: ${name(f.assignee)}`);
|
|
120
|
+
lines.push(`- Reporter: ${name(f.reporter)}`);
|
|
121
|
+
if (f.labels?.length) lines.push(`- Labels: ${f.labels.join(', ')}`);
|
|
122
|
+
if (f.components?.length)
|
|
123
|
+
lines.push(`- Components: ${f.components.map((c) => name(c)).join(', ')}`);
|
|
124
|
+
if (f.parent?.key) lines.push(`- Parent: ${f.parent.key} — ${f.parent.fields?.summary ?? ''}`);
|
|
125
|
+
if (f.created) lines.push(`- Created: ${f.created}`);
|
|
126
|
+
if (f.updated) lines.push(`- Updated: ${f.updated}`);
|
|
127
|
+
|
|
128
|
+
const desc = adfToText(f.description);
|
|
129
|
+
lines.push('', '## Description', '', desc || '_(no description)_');
|
|
130
|
+
|
|
131
|
+
const comments = f.comment?.comments ?? [];
|
|
132
|
+
if (comments.length > 0) {
|
|
133
|
+
lines.push('', `## Comments (${f.comment?.total ?? comments.length})`, '');
|
|
134
|
+
lines.push(formatComments(comments));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Comments
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export function formatComments(comments: JiraComment[]): string {
|
|
145
|
+
if (comments.length === 0) return '_(no comments)_';
|
|
146
|
+
return comments
|
|
147
|
+
.map((c) => {
|
|
148
|
+
const who = name(c.author);
|
|
149
|
+
const when = c.updated ?? c.created ?? '';
|
|
150
|
+
const body = adfToText(c.body);
|
|
151
|
+
return `### ${who}${when ? ` · ${when}` : ''}${c.id ? ` · #${c.id}` : ''}\n\n${body}`;
|
|
152
|
+
})
|
|
153
|
+
.join('\n\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compact comment digest for inline tool output: most recent few comments,
|
|
158
|
+
* each body truncated. The full thread lives in the temp file.
|
|
159
|
+
*/
|
|
160
|
+
export function formatCommentsDigest(
|
|
161
|
+
comments: JiraComment[],
|
|
162
|
+
key: string,
|
|
163
|
+
fullFile?: string,
|
|
164
|
+
preview = 3,
|
|
165
|
+
): string {
|
|
166
|
+
if (comments.length === 0) return `${key}: no comments.`;
|
|
167
|
+
const recent = comments.slice(-preview);
|
|
168
|
+
const lines = [`${key}: ${comments.length} comment(s). Most recent ${recent.length}:`, ''];
|
|
169
|
+
for (const c of recent) {
|
|
170
|
+
const who = name(c.author);
|
|
171
|
+
const when = c.updated ?? c.created ?? '';
|
|
172
|
+
lines.push(`- ${who}${when ? ` · ${when}` : ''}: ${truncate(adfToText(c.body), 200)}`);
|
|
173
|
+
}
|
|
174
|
+
if (fullFile) lines.push('', `Full thread written to: ${fullFile}`);
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Full, untruncated comment thread written to a temp file. */
|
|
179
|
+
export function formatCommentsFull(comments: JiraComment[], key: string): string {
|
|
180
|
+
return `# Comments — ${key} (${comments.length})\n\n${formatComments(comments)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Search results
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** Pulls the issues array out of acli's search payload (shape varies). */
|
|
188
|
+
export function extractIssues(payload: unknown): JiraIssue[] {
|
|
189
|
+
if (Array.isArray(payload)) return payload as JiraIssue[];
|
|
190
|
+
if (payload && typeof payload === 'object') {
|
|
191
|
+
const obj = payload as Record<string, unknown>;
|
|
192
|
+
for (const key of ['issues', 'workItems', 'results', 'data']) {
|
|
193
|
+
if (Array.isArray(obj[key])) return obj[key] as JiraIssue[];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function formatSearchDigest(issues: JiraIssue[], fullFile?: string): string {
|
|
200
|
+
if (issues.length === 0) return 'No matching work items.';
|
|
201
|
+
const lines = issues.map((issue) => {
|
|
202
|
+
const f = issue.fields ?? {};
|
|
203
|
+
return `- ${issue.key ?? '?'} [${name(f.status, '?')}] ${truncate(f.summary ?? '', 100)}${
|
|
204
|
+
f.assignee ? ` (@${name(f.assignee)})` : ''
|
|
205
|
+
}`;
|
|
206
|
+
});
|
|
207
|
+
const out = [`${issues.length} work item(s):`, ...lines];
|
|
208
|
+
if (fullFile) out.push('', `Full results (with descriptions) written to: ${fullFile}`);
|
|
209
|
+
return out.join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Full search results written to a temp file — one rendered block per item. */
|
|
213
|
+
export function formatSearchFull(issues: JiraIssue[]): string {
|
|
214
|
+
if (issues.length === 0) return 'No matching work items.';
|
|
215
|
+
return [
|
|
216
|
+
`# Search results (${issues.length})`,
|
|
217
|
+
'',
|
|
218
|
+
...issues.map((issue) => formatWorkItemFull(issue)),
|
|
219
|
+
].join('\n\n---\n\n');
|
|
220
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Type } from 'typebox';
|
|
3
|
+
import { type JiraProjectConfig, DEFAULT_CONFIG, loadProjectConfig, qualifyKey } from './config.js';
|
|
4
|
+
import {
|
|
5
|
+
AcliError,
|
|
6
|
+
buildCommentCreateArgs,
|
|
7
|
+
buildCommentListArgs,
|
|
8
|
+
buildSearchArgs,
|
|
9
|
+
buildViewArgs,
|
|
10
|
+
checkAuth,
|
|
11
|
+
parseJsonOutput,
|
|
12
|
+
runAcli,
|
|
13
|
+
} from './acli.js';
|
|
14
|
+
import {
|
|
15
|
+
type JiraComment,
|
|
16
|
+
type JiraIssue,
|
|
17
|
+
extractIssues,
|
|
18
|
+
formatCommentsDigest,
|
|
19
|
+
formatCommentsFull,
|
|
20
|
+
formatSearchDigest,
|
|
21
|
+
formatSearchFull,
|
|
22
|
+
formatWorkItemDigest,
|
|
23
|
+
formatWorkItemFull,
|
|
24
|
+
} from './format.js';
|
|
25
|
+
import { writeContextFile } from './output.js';
|
|
26
|
+
|
|
27
|
+
const TOOL_GUIDELINES = [
|
|
28
|
+
'Use `jira_view` to pull a Jira ticket into context before working on it. Pass the key (e.g. "PROJ-123"); a bare number works when a defaultProject is set in .pi/jira.json.',
|
|
29
|
+
'The inline `jira_view` output is a compact digest. The full ticket — complete description and every comment — is written to a temp file whose path is in the response. To read the whole thing, use the `read` tool on that file instead of re-fetching.',
|
|
30
|
+
'Use `jira_search` with JQL to find tickets (e.g. "project = PROJ AND status = \'In Progress\'", "assignee = currentUser()"). It returns a compact list and writes the full results (with descriptions) to a temp file whose path is in the response.',
|
|
31
|
+
"Use `jira_comments` to read a ticket's comment thread. The inline output is a digest of the most recent comments; the complete thread is written to a temp file. Comment bodies and ticket fields can be truncated inline, so when you need the full text, `read` the temp file path rather than re-fetching.",
|
|
32
|
+
'Use `jira_comment` to post a comment. Keep comments concise and factual — a short status update or finding, not a wall of text. Unless the user explicitly asked you to post, confirm the exact wording with them first; comments are visible to the whole team and cannot be unsent cleanly.',
|
|
33
|
+
'All tools require an authenticated Atlassian CLI session. If a tool reports it is not authenticated, tell the user to run `acli jira auth login` in their terminal.',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default function jiraExtension(pi: ExtensionAPI) {
|
|
37
|
+
let config: JiraProjectConfig = { ...DEFAULT_CONFIG };
|
|
38
|
+
|
|
39
|
+
async function ensureConfig(cwd?: string): Promise<JiraProjectConfig> {
|
|
40
|
+
if (cwd) {
|
|
41
|
+
try {
|
|
42
|
+
config = await loadProjectConfig(cwd);
|
|
43
|
+
} catch {
|
|
44
|
+
/* keep last-known / defaults */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function acliFailure(err: unknown) {
|
|
51
|
+
if (err instanceof AcliError) {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text' as const, text: `❌ ${err.message}` }],
|
|
54
|
+
details: { error: err.code, exitCode: err.exitCode } as Record<string, unknown>,
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text' as const, text: `❌ ${(err as Error).message}` }],
|
|
60
|
+
details: { error: 'unknown' } as Record<string, unknown>,
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
66
|
+
config = await loadProjectConfig(ctx.cwd).catch(() => ({ ...DEFAULT_CONFIG }));
|
|
67
|
+
const auth = await checkAuth(config.acliPath);
|
|
68
|
+
if (!auth.authenticated) {
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
`Jira: no authenticated Atlassian CLI session. Run \`acli jira auth login\` to enable Jira tools. (${auth.message})`,
|
|
71
|
+
'warning',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
// jira_view
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
pi.registerTool({
|
|
80
|
+
name: 'jira_view',
|
|
81
|
+
label: 'Jira View Ticket',
|
|
82
|
+
description:
|
|
83
|
+
'Fetch a Jira work item (description + comments) for context via the Atlassian CLI. Returns a compact digest inline and writes the full ticket to a temp file.',
|
|
84
|
+
promptSnippet: 'Pull a Jira ticket into context (description + comments)',
|
|
85
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
86
|
+
parameters: Type.Object({
|
|
87
|
+
key: Type.String({
|
|
88
|
+
description:
|
|
89
|
+
'Work item key (e.g. "PROJ-123"). A bare number works if defaultProject is set.',
|
|
90
|
+
}),
|
|
91
|
+
fields: Type.Optional(
|
|
92
|
+
Type.String({
|
|
93
|
+
description:
|
|
94
|
+
'Comma-separated acli field set to override the configured default (e.g. "summary,status,comment", "*all").',
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
}),
|
|
98
|
+
async execute(
|
|
99
|
+
_id: string,
|
|
100
|
+
params: { key: string; fields?: string },
|
|
101
|
+
signal?: AbortSignal,
|
|
102
|
+
_onUpdate?: unknown,
|
|
103
|
+
ctx?: { cwd: string },
|
|
104
|
+
) {
|
|
105
|
+
const cfg = await ensureConfig(ctx?.cwd);
|
|
106
|
+
const key = qualifyKey(params.key, cfg.defaultProject);
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await runAcli(
|
|
109
|
+
cfg.acliPath,
|
|
110
|
+
buildViewArgs(key, params.fields ?? cfg.viewFields),
|
|
111
|
+
signal,
|
|
112
|
+
);
|
|
113
|
+
const issue = parseJsonOutput<JiraIssue>(stdout);
|
|
114
|
+
const full = formatWorkItemFull(issue);
|
|
115
|
+
let file: string | undefined;
|
|
116
|
+
try {
|
|
117
|
+
file = await writeContextFile(full, key.toLowerCase());
|
|
118
|
+
} catch {
|
|
119
|
+
file = undefined;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: 'text' as const, text: formatWorkItemDigest(issue, file) }],
|
|
123
|
+
details: { key, contextFile: file } as Record<string, unknown>,
|
|
124
|
+
};
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return acliFailure(err);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
// jira_search
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
pi.registerTool({
|
|
135
|
+
name: 'jira_search',
|
|
136
|
+
label: 'Jira Search',
|
|
137
|
+
description:
|
|
138
|
+
'Search Jira work items with JQL via the Atlassian CLI. Returns a compact list of matching keys, statuses, and summaries.',
|
|
139
|
+
promptSnippet: 'Search Jira work items with JQL',
|
|
140
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
141
|
+
parameters: Type.Object({
|
|
142
|
+
jql: Type.String({
|
|
143
|
+
description:
|
|
144
|
+
'JQL query (e.g. "project = PROJ AND status = \'In Progress\'", "assignee = currentUser() ORDER BY updated DESC").',
|
|
145
|
+
}),
|
|
146
|
+
limit: Type.Optional(
|
|
147
|
+
Type.Number({
|
|
148
|
+
description: 'Max results (1-100). Defaults to searchLimit from .pi/jira.json or 25.',
|
|
149
|
+
minimum: 1,
|
|
150
|
+
maximum: 100,
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
}),
|
|
154
|
+
async execute(
|
|
155
|
+
_id: string,
|
|
156
|
+
params: { jql: string; limit?: number },
|
|
157
|
+
signal?: AbortSignal,
|
|
158
|
+
_onUpdate?: unknown,
|
|
159
|
+
ctx?: { cwd: string },
|
|
160
|
+
) {
|
|
161
|
+
const cfg = await ensureConfig(ctx?.cwd);
|
|
162
|
+
try {
|
|
163
|
+
const { stdout } = await runAcli(
|
|
164
|
+
cfg.acliPath,
|
|
165
|
+
buildSearchArgs({ jql: params.jql, limit: params.limit ?? cfg.searchLimit }),
|
|
166
|
+
signal,
|
|
167
|
+
);
|
|
168
|
+
const issues = extractIssues(parseJsonOutput(stdout));
|
|
169
|
+
let file: string | undefined;
|
|
170
|
+
if (issues.length > 0) {
|
|
171
|
+
try {
|
|
172
|
+
file = await writeContextFile(formatSearchFull(issues), 'search');
|
|
173
|
+
} catch {
|
|
174
|
+
file = undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: 'text' as const, text: formatSearchDigest(issues, file) }],
|
|
179
|
+
details: {
|
|
180
|
+
count: issues.length,
|
|
181
|
+
keys: issues.map((i) => i.key).filter(Boolean),
|
|
182
|
+
contextFile: file,
|
|
183
|
+
} as Record<string, unknown>,
|
|
184
|
+
};
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return acliFailure(err);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// jira_comments
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
pi.registerTool({
|
|
195
|
+
name: 'jira_comments',
|
|
196
|
+
label: 'Jira List Comments',
|
|
197
|
+
description: 'List the comment thread on a Jira work item via the Atlassian CLI.',
|
|
198
|
+
promptSnippet: 'Read the comment thread on a Jira ticket',
|
|
199
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
200
|
+
parameters: Type.Object({
|
|
201
|
+
key: Type.String({ description: 'Work item key (e.g. "PROJ-123").' }),
|
|
202
|
+
limit: Type.Optional(
|
|
203
|
+
Type.Number({
|
|
204
|
+
description: 'Max comments to return (1-100). Default 50.',
|
|
205
|
+
minimum: 1,
|
|
206
|
+
maximum: 100,
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
}),
|
|
210
|
+
async execute(
|
|
211
|
+
_id: string,
|
|
212
|
+
params: { key: string; limit?: number },
|
|
213
|
+
signal?: AbortSignal,
|
|
214
|
+
_onUpdate?: unknown,
|
|
215
|
+
ctx?: { cwd: string },
|
|
216
|
+
) {
|
|
217
|
+
const cfg = await ensureConfig(ctx?.cwd);
|
|
218
|
+
const key = qualifyKey(params.key, cfg.defaultProject);
|
|
219
|
+
try {
|
|
220
|
+
const { stdout } = await runAcli(
|
|
221
|
+
cfg.acliPath,
|
|
222
|
+
buildCommentListArgs(key, params.limit ?? 50),
|
|
223
|
+
signal,
|
|
224
|
+
);
|
|
225
|
+
const payload = parseJsonOutput<{ comments?: JiraComment[] } | JiraComment[]>(stdout);
|
|
226
|
+
const comments = Array.isArray(payload) ? payload : (payload.comments ?? []);
|
|
227
|
+
let file: string | undefined;
|
|
228
|
+
if (comments.length > 0) {
|
|
229
|
+
try {
|
|
230
|
+
file = await writeContextFile(
|
|
231
|
+
formatCommentsFull(comments, key),
|
|
232
|
+
`${key.toLowerCase()}-comments`,
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
file = undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: 'text' as const, text: formatCommentsDigest(comments, key, file) }],
|
|
240
|
+
details: { key, count: comments.length, contextFile: file } as Record<string, unknown>,
|
|
241
|
+
};
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return acliFailure(err);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// -------------------------------------------------------------------------
|
|
249
|
+
// jira_comment (write)
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
pi.registerTool({
|
|
252
|
+
name: 'jira_comment',
|
|
253
|
+
label: 'Jira Add Comment',
|
|
254
|
+
description:
|
|
255
|
+
'Post a comment to a Jira work item via the Atlassian CLI. Keep comments concise and factual. Visible to the whole team.',
|
|
256
|
+
promptSnippet: 'Post a concise comment to a Jira ticket',
|
|
257
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
258
|
+
parameters: Type.Object({
|
|
259
|
+
key: Type.String({ description: 'Work item key (e.g. "PROJ-123").' }),
|
|
260
|
+
body: Type.String({
|
|
261
|
+
description: 'Comment text (plain text). Keep it short and to the point.',
|
|
262
|
+
}),
|
|
263
|
+
}),
|
|
264
|
+
async execute(
|
|
265
|
+
_id: string,
|
|
266
|
+
params: { key: string; body: string },
|
|
267
|
+
signal?: AbortSignal,
|
|
268
|
+
_onUpdate?: unknown,
|
|
269
|
+
ctx?: { cwd: string },
|
|
270
|
+
) {
|
|
271
|
+
const cfg = await ensureConfig(ctx?.cwd);
|
|
272
|
+
const key = qualifyKey(params.key, cfg.defaultProject);
|
|
273
|
+
if (!params.body.trim()) {
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: 'text' as const, text: '❌ Comment body is empty.' }],
|
|
276
|
+
details: { error: 'empty_body' } as Record<string, unknown>,
|
|
277
|
+
isError: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await runAcli(cfg.acliPath, buildCommentCreateArgs(key, params.body), signal);
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: 'text' as const, text: `✅ Comment added to ${key}.` }],
|
|
284
|
+
details: { key } as Record<string, unknown>,
|
|
285
|
+
};
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return acliFailure(err);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// -------------------------------------------------------------------------
|
|
293
|
+
// /jira status command
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
pi.registerCommand('jira', {
|
|
296
|
+
description: 'Show Jira (Atlassian CLI) configuration and auth status',
|
|
297
|
+
handler: async (
|
|
298
|
+
_args: string,
|
|
299
|
+
ctx: {
|
|
300
|
+
cwd: string;
|
|
301
|
+
hasUI: boolean;
|
|
302
|
+
ui: { notify(message: string, level: 'info' | 'warning' | 'error'): void };
|
|
303
|
+
},
|
|
304
|
+
) => {
|
|
305
|
+
const cfg = await ensureConfig(ctx.cwd);
|
|
306
|
+
const auth = await checkAuth(cfg.acliPath);
|
|
307
|
+
const lines = [
|
|
308
|
+
'Jira Extension Status',
|
|
309
|
+
'',
|
|
310
|
+
`Auth: ${auth.authenticated ? '✅ Authenticated' : '❌ Not authenticated'}`,
|
|
311
|
+
` ${auth.message.split('\n')[0]}`,
|
|
312
|
+
'',
|
|
313
|
+
'Config (.pi/jira.json):',
|
|
314
|
+
` acli path: ${cfg.acliPath}`,
|
|
315
|
+
` Default project: ${cfg.defaultProject ?? '(not set)'}`,
|
|
316
|
+
` View fields: ${cfg.viewFields}`,
|
|
317
|
+
` Search limit: ${cfg.searchLimit}`,
|
|
318
|
+
];
|
|
319
|
+
if (!auth.authenticated) {
|
|
320
|
+
lines.push('', 'Run `acli jira auth login` in your terminal to authenticate.');
|
|
321
|
+
}
|
|
322
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join('\n'), 'info');
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Writes the full (untruncated) ticket context to a temp file the agent can
|
|
7
|
+
* read with the `read` tool, keeping the inline tool response compact.
|
|
8
|
+
*
|
|
9
|
+
* `prefix` controls the filename stem (e.g. "ticket" → `ticket-<ts>.md`).
|
|
10
|
+
* Returns the absolute path to the written file.
|
|
11
|
+
*/
|
|
12
|
+
export async function writeContextFile(content: string, prefix = 'ticket'): Promise<string> {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'pi-jira-'));
|
|
14
|
+
const path = join(dir, `${prefix}-${Date.now()}.md`);
|
|
15
|
+
await writeFile(path, content, 'utf-8');
|
|
16
|
+
return path;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dreki-gg/pi-jira",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Jira tools for pi via an authenticated Atlassian CLI (acli) session — pull ticket context and write concise comments",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"jira",
|
|
9
|
+
"atlassian",
|
|
10
|
+
"acli"
|
|
11
|
+
],
|
|
12
|
+
"author": "Juan Albarran <jalbarrandev@gmail.com>",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/dreki-gg/pi-extensions",
|
|
17
|
+
"directory": "packages/jira"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"files": [
|
|
21
|
+
"extensions",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"package.json"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"lint": "oxlint extensions test",
|
|
30
|
+
"format": "oxfmt --write extensions test",
|
|
31
|
+
"format:check": "oxfmt --check extensions test"
|
|
32
|
+
},
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions/jira"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "24",
|
|
40
|
+
"bun-types": "^1.3.14",
|
|
41
|
+
"oxfmt": "^0.43.0",
|
|
42
|
+
"oxlint": "^1.58.0",
|
|
43
|
+
"typescript": "^6.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"typebox": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|