dsh-jira-tasks 1.0.4 → 1.0.6

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.en.md CHANGED
@@ -4,12 +4,14 @@
4
4
 
5
5
  [中文](README.md) · **English**
6
6
 
7
- Shows the current JIRA project's **open / reopened** issues **assigned to the current user** below the DSH composer input. The JIRA base URL and token are read from credentials; the project key and JQL are **configured per workspace** and persisted.
7
+ Shows the current JIRA project's **open / reopened** issues **assigned to the current user** below the DSH composer input. The JIRA base URL and token are configured in **Settings → Plugins → JIRA** (`JIRA_BASE_URL` / `JIRA_API_TOKEN` act as fallback); the project key and JQL are **configured per workspace** and persisted.
8
8
 
9
9
  ## Features
10
10
 
11
11
  - 📋 Panel shown below the composer in both new and active sessions (aligned with the input width in new sessions)
12
12
  - 👤 Defaults to the current user (`assignee = currentUser()`) with status `开启 / 重新开启` (Open / Reopened)
13
+ - ⚙️ **Settings → Plugins → JIRA** edits the base URL and access token (the token is written to the credential store and never sent back to the browser)
14
+ - 🟢 The card auto-probes the connection and shows a status light: green = usable, red = unusable, grey = unconfigured; **Test connection** verifies unsaved drafts immediately
13
15
  - ⚙️ Project key and JQL are saved per workspace; unconfigured workspaces show "unconfigured"
14
16
  - 🔄 Auto-query on every new session, with a one-click refresh (⟳)
15
17
  - 🔗 Click an issue to open its JIRA detail in a new tab
@@ -48,7 +50,22 @@ In a DSH session, use the Cordis tools: `cordis_define` (`kind: new`, `idPrefix:
48
50
 
49
51
  ### 1. JIRA base URL and token
50
52
 
51
- Write to `$DSH_HOME/.credentials.yaml` (recommended, hot-reloaded), or export environment variables before launching DSH:
53
+ Open **Settings Plugins Plugin configuration JIRA** and fill in:
54
+
55
+ - **JIRA base URL**: e.g. `http://jira.example.com/` (stored in the user settings document and read back by the form)
56
+ - **Access token / PAT**: written to the credential store (`$DSH_HOME/.credentials.yaml`); the browser only ever sees "configured", never the token itself
57
+
58
+ Leaving the token blank on save keeps the existing one; clearing the address on save removes the override and falls back to the environment. Auth is auto-detected: tokens containing `:` use Basic, otherwise Bearer (JIRA PAT).
59
+
60
+ #### Connection test
61
+
62
+ The card's footer carries a status light and a **Test connection** button:
63
+
64
+ - Opening the card **auto-probes** once (against JIRA `/rest/api/2/myself`), and saving re-probes
65
+ - **Green** = address and token work (the current user is shown); **red** = unusable (JIRA's reason, e.g. 401, is shown); **grey** = address or token not configured
66
+ - **Test connection** probes what is currently in the fields, saved or not, so you can check before saving
67
+
68
+ Environment variables / credentials still work as a **fallback** (used when the settings card is empty), hot-reloaded without a restart:
52
69
 
53
70
  ```yaml
54
71
  JIRA_BASE_URL: "http://jira.example.com/"
@@ -57,7 +74,6 @@ JIRA_API_TOKEN: "<PAT or user:token>"
57
74
 
58
75
  - Base URL aliases: `JIRA_BASE_URL` / `JIRA_URL`
59
76
  - Token aliases: `JIRA_API_TOKEN` / `JIRA_TOKEN`
60
- - Auth is auto-detected: tokens containing `:` use Basic, otherwise Bearer (JIRA PAT)
61
77
 
62
78
  ### 2. Project key and JQL (per workspace)
63
79
 
@@ -86,7 +102,8 @@ dsh plugin --profile web remove dsh-jira-tasks
86
102
 
87
103
  | Message | Fix |
88
104
  |---|---|
89
- | JIRA_BASE_URL not configured | Credentials missing — see "Configuration 1" above |
105
+ | JIRA base URL not configured | Address missing — see "Configuration 1" above |
106
+ | JIRA token not configured | Token missing — see "Configuration 1" above |
90
107
  | 401 … | Invalid token or wrong auth scheme; verify with `curl -H "Authorization: Bearer <token>" <base>/rest/api/2/myself` |
91
108
  | Cannot parse JIRA response | Network / proxy issue, curl produced no output |
92
109
  </details>
@@ -107,15 +124,16 @@ dsh plugin --profile web remove dsh-jira-tasks
107
124
  ┌─────────── Browser (Client) ───────────┐ ┌──────────── Host ──────────────┐
108
125
  │ conversation.composer.dock (active) │ │ webServer route /jira/api/search │
109
126
  │ conversation.input.dock (new, order:99) │ │ ↓ │
110
- │ ↓ on mount/refresh fetch POST │ │ credentials.resolve(JIRA_*)
111
- │ render: list / error / unconfigured │ │ subprocess.spawn(curl …)
112
- │ localStorage per-workspace config │ │ stdout JSON
127
+ │ ↓ on mount/refresh fetch POST │ │ settings.get("jira-tasks")
128
+ │ render: list / error / unconfigured │ │ credentials.resolve(JIRA_*)
129
+ │ localStorage per-workspace key/JQL │ │ subprocess.spawn(curl …)
130
+ │ settings.plugin.item (Settings card) │ │ ↓ stdout JSON │
113
131
  └────────────────────────────────────────────┘ │ parse issues → {ok,issues} │
114
132
  └────────────────────────────────┘
115
133
  ```
116
134
 
117
- - **Host**: registers a `webServer` route `POST /jira/api/search`; credentials resolved via the `credentials` service (env / `$DSH_HOME/.credentials.yaml`, hot-reloaded); queries run through `subprocess` spawning `curl` directly, with the auth header passed via stdin (`--config -`) so the token never appears in argv.
118
- - **Client**: a standard `window.__ModuleLoader__.load({ id, factory })` web bundle; registers `conversation.composer.dock` (active sessions) and `conversation.input.dock` (new sessions, flex `order: 99` below the input, aligned width).
135
+ - **Host**: registers the `jira-tasks` settings namespace (`baseUrl`, readable) and a `webServer` route `POST /jira/api/search`; the address comes from the settings document, the token from the `credentials` service (Settings card / env / `$DSH_HOME/.credentials.yaml`, hot-reloaded); queries run through `subprocess` spawning `curl` directly, with the auth header passed via stdin (`--config -`) so the token never appears in argv.
136
+ - **Client**: a standard `window.__ModuleLoader__.load({ id, factory })` web bundle; registers `conversation.composer.dock` (active sessions) and `conversation.input.dock` (new sessions, flex `order: 99` below the input, aligned width), plus `settings.plugin.item` (`key: "jira-tasks"`) for the Settings card — the address is written through `settingsScope` and the token through `remote.credentials`.
119
137
  - **Why not the `shell` service**: `shell` wraps commands with `sandbox-exec`, which is broken on some macOS versions (`sandbox_apply: Operation not permitted`); `subprocess` is the raw process seam without this issue.
120
138
  - **New-session display**: the DSH shell does not render `composer.dock` during the hero (blank session) phase, so the plugin also registers `input.dock` and de-duplicates by "session has messages".
121
139
 
@@ -126,7 +144,7 @@ dsh plugin --profile web remove dsh-jira-tasks
126
144
  | Persistence | Lost on restart | Survives restart |
127
145
  | Client→Host | `host.call` / `harness.handle` | `webServer` route + `fetch` |
128
146
  | Client bundle | Injected per session | `/plugins/dsh-jira-tasks/client.js` |
129
- | Config / credentials | Same `localStorage` key, same `.credentials.yaml` | Identical |
147
+ | Config / credentials | Env / `.credentials.yaml` only (no Settings card) | Settings card + same `.credentials.yaml` fallback |
130
148
  </details>
131
149
 
132
150
  ## License
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **中文** · [English](README.en.md)
6
6
 
7
- 在 DSH 会话**输入框下方**展示当前 JIRA 项目**指派给当前用户**的「开启 / 重新开启」任务列表。JIRA 地址与令牌从凭据读取;项目 Key 与 JQL **按工作区配置**并持久化。
7
+ 在 DSH 会话**输入框下方**展示当前 JIRA 项目**指派给当前用户**的「开启 / 重新开启」任务列表。JIRA 地址与令牌在**设置 → 插件 → JIRA** 中配置(`JIRA_BASE_URL` / `JIRA_API_TOKEN` 作为回退);项目 Key 与 JQL **按工作区配置**并持久化。
8
8
 
9
9
  ## 功能
10
10
 
@@ -14,14 +14,18 @@
14
14
  <img width="1972" height="746" alt="image" src="https://github.com/user-attachments/assets/9f543e04-a5ad-4b86-85c8-baa2de26f44e" />
15
15
 
16
16
  - 👤 默认仅显示当前用户(`assignee = currentUser()`)的「开启 / 重新开启」任务
17
+ - ⚙️ **设置 → 插件 → JIRA** 配置 JIRA 地址与访问令牌(令牌写入凭据存储,不回传前端)
18
+ - 🟢 设置卡片自动探测连接并显示状态灯:绿=可用、红=不可用、灰=未配置;点「测试连接」可用**未保存的草稿值**即时验证
17
19
  - ⚙️ 项目 Key 与 JQL 按工作区保存;未配置的工作区显示"未配置"
18
20
  - 🔄 打开新会话自动查询,面板内支持一键刷新(⟳)
21
+ - 🧭 点击标题可**收起 / 展开**面板;标题徽标显示查询命中的任务总数(列表最多展示 50 条,按更新时间倒序)
22
+ - 🏷️ 每条任务附状态徽章(按状态分类着色:新建 / 进行中 / 其他)、优先级与类型标签
19
23
  - 🔗 任务可点击,在新标签页打开 JIRA 详情
20
24
  - 🎨 颜色使用 DSH 主题令牌,浅色 / 深色主题自适应
21
25
 
22
26
  ## 安装
23
27
 
24
- 包已发布到 npm
28
+ 包已发布到公共 npm(`dsh-jira-tasks`);仓库的发布工作流同时把同一版本发布到 GitHub Packages(作用域包名 `@liu3734/dsh-jira-tasks`)。从 npm 安装(推荐):
25
29
 
26
30
  ```bash
27
31
  dsh plugin --profile web add dsh-jira-tasks
@@ -29,6 +33,8 @@ dsh plugin --profile web add dsh-jira-tasks
29
33
 
30
34
  **重启 DSH** 后生效。
31
35
 
36
+ > 若改用 GitHub Packages 源:先在 profile 的 `.npmrc` 配置 `@liu3734:registry=https://npm.pkg.github.com/` 及读取令牌,再执行 `dsh plugin --profile web add @liu3734/dsh-jira-tasks`。
37
+
32
38
  <details>
33
39
  <summary>手动安装(不使用 npm)</summary>
34
40
 
@@ -52,7 +58,22 @@ dsh plugin --profile web add dsh-jira-tasks
52
58
 
53
59
  ### 1. JIRA 地址与令牌
54
60
 
55
- 写入 `$DSH_HOME/.credentials.yaml`(推荐,热加载无需重启),或启动 DSH 前导出环境变量:
61
+ 打开 **设置 插件 → 插件配置 → JIRA**,填写:
62
+
63
+ - **JIRA 地址**:如 `http://jira.example.com/`(存入用户设置文档,可在界面回读)
64
+ - **访问令牌 / PAT**:写入凭据存储(`$DSH_HOME/.credentials.yaml`),前端只显示"已配置",不回传令牌本身
65
+
66
+ 留空并保存会保持已有令牌不变;地址留空并保存则清除设置项,回退到环境变量。认证自动识别:令牌含 `:` 用 Basic,否则用 Bearer(JIRA PAT)。
67
+
68
+ #### 连接测试
69
+
70
+ 卡片底部有连接状态灯与 **测试连接** 按钮:
71
+
72
+ - 打开卡片时会**自动探测**一次(请求 JIRA `/rest/api/2/myself`),保存后也会自动重测
73
+ - **绿** = 地址与令牌可用(并显示当前登录用户);**红** = 不可用(显示 JIRA 返回的原因,如 401 认证失败);**灰** = 地址或令牌未配置
74
+ - 点 **测试连接** 会用**当前输入框里的内容**(未保存也可)立即测试,方便改完再存
75
+
76
+ 以下环境变量 / 凭据仍作为**回退**(设置页未配置时生效,兼容旧部署),热加载无需重启:
56
77
 
57
78
  ```yaml
58
79
  JIRA_BASE_URL: "http://jira.example.com/"
@@ -61,7 +82,6 @@ JIRA_API_TOKEN: "<PAT 或 user:token>"
61
82
 
62
83
  - 地址别名:`JIRA_BASE_URL` / `JIRA_URL`
63
84
  - 令牌别名:`JIRA_API_TOKEN` / `JIRA_TOKEN`
64
- - 认证自动识别:令牌含 `:` 用 Basic,否则用 Bearer(JIRA PAT)
65
85
 
66
86
  ### 2. 项目 Key 与 JQL(按工作区)
67
87
 
@@ -88,11 +108,15 @@ dsh plugin --profile web remove dsh-jira-tasks
88
108
  <details>
89
109
  <summary>面板显示「查询失败」</summary>
90
110
 
111
+ 面板中的提示位于「查询失败:」之后,与下列文案对应:
112
+
91
113
  | 提示 | 处理 |
92
114
  |---|---|
93
- | 未配置环境变量 JIRA_BASE_URL | 凭据未写入,见上文「配置 1」 |
115
+ | 未设置项目 Key | 面板未配置项目,点标题右侧 填写项目 Key |
116
+ | 未配置 JIRA 地址(设置 → 插件 → JIRA,或环境变量 JIRA_BASE_URL) | 地址未写入,见上文「配置 1」 |
117
+ | 未配置 JIRA 令牌(设置 → 插件 → JIRA,或环境变量 JIRA_API_TOKEN) | 令牌未写入,见上文「配置 1」 |
94
118
  | 401 … | 令牌无效或认证方式不对;先 `curl -H "Authorization: Bearer <token>" <base>/rest/api/2/myself` 验证 |
95
- | 无法解析 JIRA 响应 | 网络 / 代理问题,curl 无输出 |
119
+ | 无法解析 JIRA 响应:… | 网络 / 代理问题,curl 无输出 |
96
120
  </details>
97
121
 
98
122
  <details>
@@ -108,20 +132,21 @@ dsh plugin --profile web remove dsh-jira-tasks
108
132
  <summary>点击展开</summary>
109
133
 
110
134
  ```
111
- ┌─────────── 浏览器(Client) ───────────┐ ┌──────────── Host ──────────────┐
112
- │ conversation.composer.dock(活跃会话) │ webServer 路由 /jira/api/search
113
- │ conversation.input.dock(新会话, order:99)│ │ ↓
114
- 挂载时/刷新时 fetch POST credentials.resolve(JIRA_*)
115
- 渲染:任务列表 / 错误 / 未配置 subprocess.spawn(curl …)
116
- localStorage 按工作区存取配置 stdout JSON
117
- └────────────────────────────────────────────┘ 解析 issues 返回 {ok,issues}
118
- └────────────────────────────────┘
135
+ ┌────────────────────────────────────────┐ ┌────────────────────────────────────┐
136
+ │ conversation.composer.dock(活跃会话) │ webServer 路由 /jira/api/search
137
+ │ conversation.input.dock(新会话)
138
+ 面板 hero 布局(flex order:99) settings.get("jira-tasks").baseUrl
139
+ 挂载 / 刷新时 fetch POST credentials.resolve(JIRA_API_TOKEN)
140
+ 渲染:任务列表 / 错误 / 未配置 subprocess.spawn(curl …)
141
+ localStorage 按工作区存取项目 Key/JQL │ │ stdout JSON
142
+ │ settings.plugin.item(设置页卡片) │ │ 解析 issues → 返回 {ok,issues} │
143
+ └────────────────────────────────────────┘ └────────────────────────────────────┘
119
144
  ```
120
145
 
121
- - **Host**:注册 `webServer` 路由 `POST /jira/api/search`;凭据经 `credentials` 服务解析(环境变量 / `$DSH_HOME/.credentials.yaml`,热加载);查询用 `subprocess` 直接 `spawn curl`,认证头经 stdin(`--config -`)传入,令牌不进入命令行参数。
122
- - **Client**:`window.__ModuleLoader__.load({ id, factory })` 标准 web bundle;注册 `conversation.composer.dock`(活跃会话)与 `conversation.input.dock`(新会话,flex `order: 99` 置于输入框下方、与输入框等宽)。
146
+ - **Host**:注册 `settings` 命名空间 `jira-tasks`(`baseUrl`,可读)与 `webServer` 路由 `POST /jira/api/search`;地址优先读设置文档,令牌经 `credentials` 服务解析(设置页写入 / 环境变量 / `$DSH_HOME/.credentials.yaml`,热加载);查询用 `subprocess` 直接 `spawn curl`,认证头经 stdin(`--config -`)传入,令牌不进入命令行参数。
147
+ - **Client**:`window.__ModuleLoader__.load({ id, factory })` 标准 web bundle;注册 `conversation.composer.dock`(活跃会话,注册 `order: 5`)与 `conversation.input.dock`(新会话,注册 `order: 10`)。新会话面板走 hero 布局:面板元素自身 `flex order: 99` 排在输入框之后下方,并以 `--dsh-composer-side-clearance` / `--dsh-composer-card-max-width` 与输入卡等宽。另注册 `settings.plugin.item`(`key: "jira-tasks"`)作为设置页卡片:地址经 `settingsScope` 写入命名空间,令牌经 `remote.credentials` 写入凭据存储。
123
148
  - **为什么不用 `shell` 服务**:`shell` 会套 `sandbox-exec`,部分 macOS 上不可用(`sandbox_apply: Operation not permitted`);`subprocess` 是原始进程缝,无此问题。
124
- - **新会话显示**:DSH 壳在 hero(空白会话)阶段不渲染 `composer.dock`,故额外注册 `input.dock`,并用「会话是否已有消息」去重,避免双份面板。
149
+ - **新会话显示**:DSH 壳在 hero(空白会话)阶段不渲染 `composer.dock`,故额外注册 `input.dock`,并用「会话是否已有消息」去重(新版 DSH 依据 `SessionSnapshot.blank`),避免双份面板。
125
150
 
126
151
  **与动态插件版的差异**
127
152
 
@@ -130,7 +155,7 @@ dsh plugin --profile web remove dsh-jira-tasks
130
155
  | 持久性 | 重启丢失 | 重启保留 |
131
156
  | Client→Host 通信 | `host.call` / `harness.handle` | `webServer` 路由 + `fetch` |
132
157
  | 客户端 bundle | 会话内注入 | `/plugins/dsh-jira-tasks/client.js` |
133
- | 配置 / 凭据 | 同一 `localStorage` 键、同一 `.credentials.yaml` | 完全相同 |
158
+ | 配置 / 凭据 | 仅环境变量 / `.credentials.yaml`(无设置页卡片) | 设置页卡片 + 同一 `.credentials.yaml` 回退 |
134
159
  </details>
135
160
 
136
161
  ## License
package/lib/client.js CHANGED
@@ -43,7 +43,16 @@ window.__ModuleLoader__.load({
43
43
  + ".jt-btn:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.1));opacity:1}"
44
44
  + ".jt-btn-primary{background:var(--dsw-alias-button-primary-fill, #2f6fed);border-color:transparent;color:var(--dsw-alias-label-primary-foreground, #fff)}"
45
45
  + ".jt-btn-primary:hover{background:var(--dsw-alias-button-primary-hover, #3a7bfd)}"
46
- + ".jt-linklike{background:none;border:none;padding:0;cursor:pointer;color:var(--dsw-alias-brand-primary, #2f6fed);font-size:12px;text-decoration:underline}";
46
+ + ".jt-linklike{background:none;border:none;padding:0;cursor:pointer;color:var(--dsw-alias-brand-primary, #2f6fed);font-size:12px;text-decoration:underline}"
47
+ + ".jt-set-card{box-sizing:border-box;list-style:none;margin:0;border:1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.28));background:var(--dsw-alias-bg-layer-1, rgba(127,127,127,.05));border-radius:10px;padding:10px 12px}"
48
+ + ".jt-set-head{display:flex;flex-direction:column;gap:2px;margin-bottom:8px}"
49
+ + ".jt-set-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary, #222)}"
50
+ + ".jt-set-desc{font-size:12px;color:var(--dsw-alias-label-secondary, #666);line-height:1.6}"
51
+ + ".jt-probe{display:flex;align-items:center;gap:6px;margin:0 0 8px;font-size:12px;color:var(--dsw-alias-label-secondary, #666);line-height:1.6;word-break:break-word}"
52
+ + ".jt-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-label-secondary, #999)}"
53
+ + ".jt-dot-ok{background:var(--dsw-alias-state-success-primary, #1a9d4f)}"
54
+ + ".jt-dot-fail{background:var(--dsw-alias-state-error-primary, #d93026)}"
55
+ + ".jt-dot-testing{background:var(--dsw-alias-state-warn-primary, #b76e00)}";
47
56
 
48
57
  var STORAGE_KEY = "dsh.jiraTasks.config.v1";
49
58
 
@@ -103,6 +112,15 @@ window.__ModuleLoader__.load({
103
112
  }).then(function (r) { return r.json(); });
104
113
  }
105
114
 
115
+ // 探测 JIRA 地址/令牌可用性;可选传入未保存的草稿值。
116
+ function testConnection(payload) {
117
+ return fetch("/jira/api/test", {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify(payload || {})
121
+ }).then(function (r) { return r.json(); });
122
+ }
123
+
106
124
  function JiraTasksDock(props) {
107
125
  var blankOnly = !!(props && props.blankOnly === true);
108
126
  var useWorkspaces = props && typeof props.useWorkspaces === "function" ? props.useWorkspaces : null;
@@ -264,6 +282,185 @@ window.__ModuleLoader__.load({
264
282
  );
265
283
  }
266
284
 
285
+ // ---- 设置页卡片:设置 → 插件 → JIRA ----
286
+ // 地址存 settings 命名空间(可读);令牌写 credentials(永不随响应回传)。
287
+ var SETTINGS_NS = "jira-tasks";
288
+ var TOKEN_REF = "JIRA_API_TOKEN";
289
+ var TOKEN_REFS = ["JIRA_API_TOKEN", "JIRA_TOKEN"];
290
+
291
+ function useScopeSnapshot(scope) {
292
+ var state = React.useState(function () { return scope.getSnapshot(); });
293
+ var snapshot = state[0], setSnapshot = state[1];
294
+ React.useEffect(function () {
295
+ var update = function () { setSnapshot(scope.getSnapshot()); };
296
+ var off = scope.subscribe(update);
297
+ update();
298
+ return off;
299
+ }, []);
300
+ return snapshot;
301
+ }
302
+
303
+ function createJiraSettingsCard(scope, credentials) {
304
+ function readTokenInfo() {
305
+ return credentials.describe(TOKEN_REFS).then(function (res) {
306
+ if (!res || !res.ok || !res.value) return { configured: false, writable: false };
307
+ for (var i = 0; i < TOKEN_REFS.length; i++) {
308
+ var info = res.value[TOKEN_REFS[i]];
309
+ if (info && info.configured) return { configured: true, writable: info.writable !== false };
310
+ }
311
+ var primary = res.value[TOKEN_REF];
312
+ return { configured: false, writable: primary ? primary.writable !== false : true };
313
+ }).catch(function () {
314
+ return { configured: false, writable: false };
315
+ });
316
+ }
317
+
318
+ return function JiraSettingsCard() {
319
+ var snapshot = useScopeSnapshot(scope);
320
+ var tokenInfoState = React.useState(null);
321
+ var tokenInfo = tokenInfoState[0], setTokenInfo = tokenInfoState[1];
322
+ var baseEditState = React.useState(undefined);
323
+ var baseEdit = baseEditState[0], setBaseEdit = baseEditState[1];
324
+ var tokenDraftState = React.useState("");
325
+ var tokenDraft = tokenDraftState[0], setTokenDraft = tokenDraftState[1];
326
+ var busyState = React.useState(false);
327
+ var busy = busyState[0], setBusy = busyState[1];
328
+ var failedState = React.useState("");
329
+ var failed = failedState[0], setFailed = failedState[1];
330
+ var probeState = React.useState({ phase: "idle" });
331
+ var probe = probeState[0], setProbe = probeState[1];
332
+ var probeGen = React.useRef(0);
333
+
334
+ React.useEffect(function () {
335
+ var alive = true;
336
+ readTokenInfo().then(function (info) { if (alive) setTokenInfo(info); });
337
+ return function () { alive = false; };
338
+ }, []);
339
+
340
+ // 自动测试:命名空间就绪后、以及已保存配置变更(含保存后)时各探测一次。
341
+ React.useEffect(function () {
342
+ if (snapshot.status !== "ready") return;
343
+ runProbe({});
344
+ }, [snapshot.status, snapshot.revision]);
345
+
346
+ var storedBase = snapshot.value && typeof snapshot.value.baseUrl === "string" ? snapshot.value.baseUrl : "";
347
+ var baseValue = baseEdit === undefined ? storedBase : baseEdit;
348
+ var dirty = (baseEdit !== undefined && baseEdit !== storedBase) || (tokenDraft || "").length > 0;
349
+ var writable = snapshot.writable !== false;
350
+
351
+ // 命名空间未挂载时不显示,避免留下一张无法操作的卡片。
352
+ if (snapshot.status === "unavailable") return null;
353
+
354
+ function save() {
355
+ if (busy || !dirty) return;
356
+ setBusy(true);
357
+ setFailed("");
358
+ var tasks = [];
359
+ if (baseEdit !== undefined && baseEdit !== storedBase) {
360
+ tasks.push(baseEdit === "" ? scope.unset("baseUrl") : scope.set("baseUrl", baseEdit));
361
+ }
362
+ var token = (tokenDraft || "").trim();
363
+ if (token) {
364
+ tasks.push(credentials.set(TOKEN_REF, token).then(function (res) {
365
+ if (res && res.ok === false) {
366
+ var err = res.error;
367
+ throw new Error((err && (err.message || err.code)) || "令牌保存失败");
368
+ }
369
+ }));
370
+ }
371
+ Promise.all(tasks).then(function () {
372
+ return readTokenInfo();
373
+ }).then(function (info) {
374
+ setTokenInfo(info);
375
+ setBaseEdit(undefined);
376
+ setTokenDraft("");
377
+ setBusy(false);
378
+ }).catch(function (err) {
379
+ setBusy(false);
380
+ setFailed(String((err && err.message) || err));
381
+ });
382
+ }
383
+
384
+ function discard() {
385
+ setBaseEdit(undefined);
386
+ setTokenDraft("");
387
+ setFailed("");
388
+ }
389
+
390
+ // 探测连接:payload 传入即测草稿,传空对象则测已保存/环境配置。
391
+ function runProbe(payload) {
392
+ var gen = ++probeGen.current;
393
+ setProbe({ phase: "testing", message: "正在测试连接…" });
394
+ testConnection(payload).then(function (res) {
395
+ if (gen !== probeGen.current) return;
396
+ if (res && res.ok) setProbe({ phase: "ok", message: "可用" + (res.user ? ":" + res.user : "") });
397
+ else if (res && res.code === "unconfigured") setProbe({ phase: "idle", message: "未配置地址或令牌" });
398
+ else setProbe({ phase: "fail", message: (res && res.error) || "不可用" });
399
+ }).catch(function (err) {
400
+ if (gen !== probeGen.current) return;
401
+ setProbe({ phase: "fail", message: String((err && err.message) || err) });
402
+ });
403
+ }
404
+
405
+ var tokenHint = tokenInfo === null
406
+ ? "正在读取令牌状态…"
407
+ : (tokenInfo.configured ? "令牌已配置;留空并保存可保持不变。" : "令牌未配置。含 “:” 时用 Basic,否则用 Bearer。");
408
+ var dotClass = probe.phase === "ok" ? "jt-dot jt-dot-ok"
409
+ : probe.phase === "fail" ? "jt-dot jt-dot-fail"
410
+ : probe.phase === "testing" ? "jt-dot jt-dot-testing" : "jt-dot";
411
+
412
+ return h("li", { className: "jt-set-card" },
413
+ h("div", { className: "jt-set-head" },
414
+ h("span", { className: "jt-set-title" }, "JIRA"),
415
+ h("span", { className: "jt-set-desc" }, "JIRA 地址与访问令牌。项目 Key / JQL 仍按工作区在会话面板的 ⚙ 中配置。")
416
+ ),
417
+ h("div", { className: "jt-field" },
418
+ h("label", { htmlFor: "jira-tasks-base-url" }, "JIRA 地址"),
419
+ h("input", {
420
+ id: "jira-tasks-base-url",
421
+ className: "jt-input",
422
+ value: baseValue,
423
+ placeholder: "http://jira.example.com/",
424
+ disabled: !writable || busy,
425
+ onChange: function (e) { setBaseEdit(e.target.value); },
426
+ onKeyDown: function (e) { if (e.key === "Enter") save(); }
427
+ })
428
+ ),
429
+ h("div", { className: "jt-field" },
430
+ h("label", { htmlFor: "jira-tasks-token" }, "访问令牌 / PAT"),
431
+ h("input", {
432
+ id: "jira-tasks-token",
433
+ className: "jt-input",
434
+ type: "password",
435
+ value: tokenDraft,
436
+ placeholder: tokenInfo && tokenInfo.configured ? "已配置,留空保持不变" : "粘贴令牌",
437
+ autoComplete: "off",
438
+ disabled: !writable || busy,
439
+ onChange: function (e) { setTokenDraft(e.target.value); },
440
+ onKeyDown: function (e) { if (e.key === "Enter") save(); }
441
+ })
442
+ ),
443
+ h("div", { className: "jt-hint", style: { marginBottom: "8px" } }, tokenHint),
444
+ h("div", { className: "jt-probe", role: "status" },
445
+ h("span", { className: dotClass, "aria-hidden": "true" }),
446
+ h("span", null, probe.message || "未测试")
447
+ ),
448
+ failed ? h("div", { className: "jt-error", style: { marginBottom: "8px" } }, "保存失败:" + failed) : null,
449
+ h("div", { className: "jt-buttons" },
450
+ h("button", {
451
+ className: "jt-btn",
452
+ style: { marginRight: "auto" },
453
+ disabled: busy || probe.phase === "testing",
454
+ title: "用当前填写的内容测试 JIRA 地址与令牌",
455
+ onClick: function () { runProbe({ baseUrl: baseValue, token: (tokenDraft || "").trim() }); }
456
+ }, probe.phase === "testing" ? "测试中…" : "测试连接"),
457
+ h("button", { className: "jt-btn", disabled: !dirty || busy, onClick: discard }, "放弃"),
458
+ h("button", { className: "jt-btn jt-btn-primary", disabled: !dirty || busy || !writable, onClick: save }, busy ? "保存中…" : "保存")
459
+ )
460
+ );
461
+ };
462
+ }
463
+
267
464
  function apply(ctx) {
268
465
  // ctx.effect(cb) 会立即执行 cb 并把其返回值作为卸载时的清理函数,
269
466
  // 所以样式注入必须放在回调内、返回移除函数,否则标签刚插入就被删除。
@@ -289,9 +486,19 @@ window.__ModuleLoader__.load({
289
486
  return h(JiraTasksDock, Object.assign({}, props, { blankOnly: true }));
290
487
  });
291
488
  });
489
+
490
+ // 设置页卡片:宿主注册了 jira-tasks 命名空间时,由插件配置 Tab 分发到本卡片。
491
+ var settingsScope = ctx.get("settingsScope");
492
+ var remote = ctx.get("remote");
493
+ if (settingsScope && remote && remote.credentials) {
494
+ var card = createJiraSettingsCard(settingsScope.bind({ namespace: SETTINGS_NS }), remote.credentials);
495
+ slots.inject("settings.plugin.item", function () {
496
+ return slots.register({ name: "settings.plugin.item", key: SETTINGS_NS }, card);
497
+ });
498
+ }
292
499
  }
293
500
 
294
- exports.inject = ["slots"];
501
+ exports.inject = ["slots", "settingsScope", "remote", "remote.credentials"];
295
502
  exports.apply = apply;
296
503
  return module.exports;
297
504
  }
package/lib/index.js CHANGED
@@ -1,9 +1,25 @@
1
1
  /**
2
2
  * dsh-jira-tasks — HOST half (persistent profile plugin).
3
- * Serves POST /jira/api/search: reads JIRA_BASE_URL / JIRA_API_TOKEN via the
4
- * credentials service, queries JIRA /rest/api/2/search through subprocess+curl
5
- * (auth header via stdin --config -), returns normalized issue JSON.
3
+ * Serves POST /jira/api/search: reads the JIRA base URL from the `jira-tasks`
4
+ * settings namespace and the token from the credentials service (falling back
5
+ * to JIRA_BASE_URL / JIRA_API_TOKEN for existing setups), queries JIRA
6
+ * /rest/api/2/search through subprocess+curl (auth header via stdin
7
+ * --config -), returns normalized issue JSON.
6
8
  */
9
+ import z from '@deepseek-ai/schemastery';
10
+
11
+ /** Settings namespace exposing the editable connection fields in the GUI. */
12
+ const SETTINGS_NS = 'jira-tasks';
13
+
14
+ /**
15
+ * Durable connection schema. `baseUrl` is a plain readable field; the token is
16
+ * deliberately NOT stored here — it stays in the credentials store (write-only
17
+ * from the client) so no secret ever lands in settings.yaml.
18
+ */
19
+ const JiraSettingsSchema = z.object({
20
+ baseUrl: z.string().default('').description('JIRA 地址,例如 http://jira.example.com/')
21
+ });
22
+
7
23
  export default {
8
24
  inject: ['subprocess', 'credentials', 'sandboxPolicy', 'webServer'],
9
25
  apply(ctx) {
@@ -39,6 +55,24 @@ export default {
39
55
  return undefined;
40
56
  }
41
57
 
58
+ // Register the GUI-editable section when the optional settings service is
59
+ // composed; without it the plugin keeps working from the composition/env.
60
+ ctx.inject(['settings'], (settingsCtx) => {
61
+ settingsCtx.settings.register(SETTINGS_NS, JiraSettingsSchema);
62
+ });
63
+
64
+ /** Base URL from the settings document, or '' when unset/unavailable. */
65
+ function settingsBaseUrl() {
66
+ try {
67
+ const settings = ctx.get('settings');
68
+ if (!settings) return '';
69
+ const section = settings.get(SETTINGS_NS);
70
+ return section && typeof section.baseUrl === 'string' ? section.baseUrl.trim() : '';
71
+ } catch (e) {
72
+ return '';
73
+ }
74
+ }
75
+
42
76
  function buildAuthHeader(token) {
43
77
  if (token.indexOf(':') !== -1) return 'Basic ' + Buffer.from(token, 'utf-8').toString('base64');
44
78
  return 'Bearer ' + token;
@@ -83,14 +117,57 @@ export default {
83
117
  return { exitCode: outcome.exitCode, stdout, stderr };
84
118
  }
85
119
 
120
+ // 从 JIRA 错误响应体/curl stderr 中提取可读信息。
121
+ function errorTextFrom(body, stderr, fallback) {
122
+ let message = (stderr || '').trim() || fallback;
123
+ try {
124
+ const parsed = JSON.parse(body);
125
+ if (parsed.errorMessages && parsed.errorMessages.length) message = parsed.errorMessages.join(';');
126
+ else if (parsed.message) message = parsed.message;
127
+ } catch (e) { /* body 非 JSON */ }
128
+ return message;
129
+ }
130
+
131
+ // 连通性/鉴权探测:GET /rest/api/2/myself,stdout 末尾追加 HTTP 状态码。
132
+ async function probeJira(baseUrl, token) {
133
+ const auth = buildAuthHeader(token);
134
+ const cleanBase = baseUrl.replace(/\/+$/, '');
135
+ let curl;
136
+ try {
137
+ curl = await subprocess.resolveExecutable('curl');
138
+ } catch (e) {
139
+ curl = '/usr/bin/curl';
140
+ }
141
+ const handle = subprocess.spawn({
142
+ argv: [
143
+ curl, '-sS', '--max-time', '15',
144
+ '-H', 'Accept: application/json',
145
+ '--config', '-',
146
+ '-w', '\n%{http_code}',
147
+ cleanBase + '/rest/api/2/myself'
148
+ ],
149
+ cwd: workspaceRoot,
150
+ stdio: {
151
+ stdin: { data: 'header = "Authorization: ' + auth + '"\n' },
152
+ stdout: { maxBytes: 1024 * 1024, spill: { maxBytes: 4 * 1024 * 1024 } },
153
+ stderr: { maxBytes: 256 * 1024, spill: { maxBytes: 1024 * 1024 } }
154
+ },
155
+ graceMs: 5000
156
+ });
157
+ const outcome = await handle.done;
158
+ const stdout = (handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : '') || '';
159
+ const stderr = (handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : '') || '';
160
+ return { exitCode: outcome.exitCode, stdout, stderr };
161
+ }
162
+
86
163
  async function handleSearch(args) {
87
164
  const projectKey = String((args && args.projectKey) || '').trim();
88
165
  if (!projectKey) return { ok: false, error: '未设置项目 Key' };
89
166
  try {
90
- const baseUrl = await resolveFirst(['JIRA_BASE_URL', 'JIRA_URL']);
91
- if (!baseUrl) return { ok: false, error: '未配置环境变量 JIRA_BASE_URL(或 JIRA_URL)' };
167
+ const baseUrl = settingsBaseUrl() || await resolveFirst(['JIRA_BASE_URL', 'JIRA_URL']);
168
+ if (!baseUrl) return { ok: false, error: '未配置 JIRA 地址(设置 → 插件 → JIRA,或环境变量 JIRA_BASE_URL)' };
92
169
  const token = await resolveFirst(['JIRA_API_TOKEN', 'JIRA_TOKEN']);
93
- if (!token) return { ok: false, error: '未配置环境变量 JIRA_API_TOKEN(或 JIRA_TOKEN)' };
170
+ if (!token) return { ok: false, error: '未配置 JIRA 令牌(设置 → 插件 → JIRA,或环境变量 JIRA_API_TOKEN)' };
94
171
 
95
172
  const jql = buildJql(args && args.jql ? String(args.jql) : '', projectKey);
96
173
  const raw = await queryJira(baseUrl, token, jql);
@@ -98,13 +175,7 @@ export default {
98
175
  const stderr = raw.stderr;
99
176
 
100
177
  if (raw.exitCode !== 0) {
101
- let message = stderr.trim() || ('curl 退出码 ' + String(raw.exitCode));
102
- try {
103
- const parsed = JSON.parse(stdout);
104
- if (parsed.errorMessages && parsed.errorMessages.length) message = parsed.errorMessages.join(';');
105
- else if (parsed.message) message = parsed.message;
106
- } catch (e) { /* stdout 非 JSON */ }
107
- return { ok: false, error: message };
178
+ return { ok: false, error: errorTextFrom(stdout, stderr, 'curl 退出码 ' + String(raw.exitCode)) };
108
179
  }
109
180
 
110
181
  let data;
@@ -135,6 +206,43 @@ export default {
135
206
  }
136
207
  }
137
208
 
209
+ // 设置卡片“测试连接”:用草稿值(未保存也可测)或已保存/环境配置探测 JIRA。
210
+ async function handleTest(args) {
211
+ try {
212
+ const draftBase = args && typeof args.baseUrl === 'string' ? args.baseUrl.trim() : '';
213
+ const draftToken = args && typeof args.token === 'string' ? args.token.trim() : '';
214
+ const baseUrl = draftBase || settingsBaseUrl() || await resolveFirst(['JIRA_BASE_URL', 'JIRA_URL']);
215
+ if (!baseUrl) return { ok: false, code: 'unconfigured', error: '未配置 JIRA 地址' };
216
+ const token = draftToken || await resolveFirst(['JIRA_API_TOKEN', 'JIRA_TOKEN']);
217
+ if (!token) return { ok: false, code: 'unconfigured', error: '未配置 JIRA 令牌' };
218
+
219
+ const raw = await probeJira(baseUrl, token);
220
+ if (raw.exitCode !== 0) {
221
+ return { ok: false, code: 'network', error: (raw.stderr || '').trim() || ('curl 退出码 ' + String(raw.exitCode)) };
222
+ }
223
+ const nl = raw.stdout.lastIndexOf('\n');
224
+ const body = nl === -1 ? raw.stdout : raw.stdout.slice(0, nl);
225
+ const httpCode = Number((nl === -1 ? '' : raw.stdout.slice(nl + 1)).trim()) || 0;
226
+
227
+ if (httpCode >= 200 && httpCode < 300) {
228
+ let data = {};
229
+ try { data = JSON.parse(body); } catch (e) { /* 空/非 JSON 也视为连通 */ }
230
+ return {
231
+ ok: true,
232
+ baseUrl: baseUrl.replace(/\/+$/, ''),
233
+ user: data.displayName || data.name || data.key || ''
234
+ };
235
+ }
236
+ return {
237
+ ok: false,
238
+ code: httpCode === 401 || httpCode === 403 ? 'auth' : 'http',
239
+ error: errorTextFrom(body, raw.stderr, 'HTTP ' + String(httpCode))
240
+ };
241
+ } catch (err) {
242
+ return { ok: false, code: 'error', error: String((err && err.message) || err) };
243
+ }
244
+ }
245
+
138
246
  if (webServer && typeof webServer.register === 'function') {
139
247
  webServer.register({
140
248
  kind: 'exact',
@@ -147,6 +255,17 @@ export default {
147
255
  }
148
256
  }
149
257
  });
258
+ webServer.register({
259
+ kind: 'exact',
260
+ path: '/jira/api/test',
261
+ handler: async (req, res) => {
262
+ try {
263
+ sendJson(res, await handleTest(await readBody(req)));
264
+ } catch (e) {
265
+ sendJson(res, { ok: false, code: 'error', error: String((e && e.message) || e) });
266
+ }
267
+ }
268
+ });
150
269
  }
151
270
  }
152
271
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jira-tasks",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "JIRA open tasks panel for DeepSeek Harness (DSH): shows the current user's open/reopened issues below the composer, per-workspace project key, persistent profile bundle.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,6 +9,9 @@
9
9
  "./client": "./lib/client.js",
10
10
  "./package.json": "./package.json"
11
11
  },
12
+ "dependencies": {
13
+ "@deepseek-ai/schemastery": "^3.18.2"
14
+ },
12
15
  "files": [
13
16
  "lib",
14
17
  "cordis.patch.yml",
@@ -36,7 +39,9 @@
36
39
  "client": {
37
40
  "platform": "web",
38
41
  "inject": [
39
- "@deepseek-ai/dsh-client-ui-conversation"
42
+ "@deepseek-ai/dsh-client-ui-conversation",
43
+ "@deepseek-ai/dsh-client-ui-settings",
44
+ "@deepseek-ai/dsh-api-remotes"
40
45
  ]
41
46
  }
42
47
  }