davinci-resolve-mcp 3.0.0 → 3.1.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 +144 -0
- package/README.md +12 -1
- package/README.zh-CN.md +6 -2
- package/docs/SKILL.md +10 -0
- package/docs/authoring/script-plugin-authoring.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/install_resolve_bridge.py +9 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +89 -4
- package/src/utils/destructive_hook.py +29 -2
- package/src/utils/execution_lifecycle.py +22 -10
- package/src/utils/issue_report.py +456 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,150 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v3.1.0 — "send this as a bug": issues drafted from chat
|
|
6
|
+
|
|
7
|
+
A new `resolve_control` action, `report_issue`. While working in any MCP client,
|
|
8
|
+
say "send this as a bug" or "send this as a feature request" and the assistant
|
|
9
|
+
drafts the GitHub issue for you.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`resolve_control(action="report_issue")`** drafts a bug report
|
|
14
|
+
(`kind="bug"`) or feature request (`kind="feature"`) from the conversation:
|
|
15
|
+
what happened, steps to reproduce, expected vs actual, and the failing
|
|
16
|
+
tool/action with its error. It attaches what a maintainer would otherwise
|
|
17
|
+
have to ask for in the thread: server version, Resolve build and edition,
|
|
18
|
+
connection mode (local scripting, network scripting or in-app bridge),
|
|
19
|
+
whether Resolve is running with a UI or headless, OS, and Python.
|
|
20
|
+
- **It files nothing.** It returns the draft plus a prefilled GitHub
|
|
21
|
+
`issues/new` link. You review the draft and submit it under your own
|
|
22
|
+
account, so no GitHub credential lives in the server and nothing is
|
|
23
|
+
published that you have not seen.
|
|
24
|
+
- **It never connects to or launches Resolve.** A report about a connection
|
|
25
|
+
that will not come up must not start one, so the environment is read only
|
|
26
|
+
from a handle the server already holds.
|
|
27
|
+
- **Every field is redacted before it reaches the draft:** absolute paths
|
|
28
|
+
(POSIX, Windows and UNC, including paths with spaces; the file extension is
|
|
29
|
+
kept, and the Blackmagic install locations and `~/.davinci-resolve-mcp` are
|
|
30
|
+
kept because they identify nothing), the local username, full name and
|
|
31
|
+
hostname, e-mail addresses, the control panel's `#token=` fragment, and
|
|
32
|
+
key-shaped secrets. Client or project names written as ordinary prose
|
|
33
|
+
cannot be recognised, so the result tells the assistant to have you check
|
|
34
|
+
the draft before submitting.
|
|
35
|
+
- Links over 8,000 characters shorten only the narrative. The environment
|
|
36
|
+
table always survives, and the full body is still returned to the chat.
|
|
37
|
+
- `bug_report` and `feature_request` issue templates, carrying the `bug` and
|
|
38
|
+
`enhancement` labels.
|
|
39
|
+
|
|
40
|
+
### Documentation
|
|
41
|
+
|
|
42
|
+
- README: new *Reporting Bugs and Requesting Features* section (with the
|
|
43
|
+
matching section in `README.zh-CN.md`); `docs/SKILL.md` documents when to
|
|
44
|
+
call the action and that the user submits; the server instructions now
|
|
45
|
+
mention it, so any MCP client can find it.
|
|
46
|
+
|
|
47
|
+
### Validation
|
|
48
|
+
|
|
49
|
+
- 27 new unit tests (`tests/test_issue_report.py`) cover redaction, layout,
|
|
50
|
+
link round-trip and truncation, the no-connect guarantee, and the
|
|
51
|
+
templates. The full suite passes: 3509 passed, 0 failed.
|
|
52
|
+
- Smoke-tested through the real MCP stdio protocol against `src/server.py`.
|
|
53
|
+
- No Resolve behaviour changed, so no live Resolve run was needed.
|
|
54
|
+
|
|
55
|
+
## What's New in v3.0.1 — project deletion is gated, and the open project is refused by default
|
|
56
|
+
|
|
57
|
+
A security fix, published as [GHSA-gmp7-qjp9-m7gm](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-gmp7-qjp9-m7gm).
|
|
58
|
+
One behaviour change existing callers may notice is called out below.
|
|
59
|
+
|
|
60
|
+
### Security
|
|
61
|
+
|
|
62
|
+
- **`project_manager delete` permanently deleted any named project — including
|
|
63
|
+
the one open in Resolve — without confirmation, and no gate saw it.** It runs
|
|
64
|
+
through `delete_project_safely`, a *reliability* helper that works around
|
|
65
|
+
DeleteProject's flakiness and session lock by closing the open project and
|
|
66
|
+
then deleting it. The `project_manager` tool carried no `@_destructive_op`,
|
|
67
|
+
`delete` was not registered, and the CRITICAL risk rule written for project
|
|
68
|
+
deletion named `delete_project`, an action no tool dispatches. So it matched
|
|
69
|
+
nothing: safe mode, dry-run refusal and the security audit log never saw a
|
|
70
|
+
project deletion.
|
|
71
|
+
- **Thirteen further deletes that the classifier already rated HIGH were not
|
|
72
|
+
enforced, for the same reason.** They are the three `render` deletes,
|
|
73
|
+
`render_presets.delete_burnin`, `gallery_stills.delete_stills`, `fusion_comp`
|
|
74
|
+
`delete_tool` and `delete_keyframe`, `timeline_item.delete_keyframe`, the three
|
|
75
|
+
`media_pool_item_markers` deletes, `project_settings.delete_color_group` and
|
|
76
|
+
`resolve_control.delete_user_preferences_preset`. Safe mode is enforced only
|
|
77
|
+
by the decorator, and only for registered actions, so these ratings were a
|
|
78
|
+
promise nothing kept.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **The raw `delete` now refuses the currently open project unless
|
|
83
|
+
`close_current=True`**, matching `safe_project_delete`. This is the one change
|
|
84
|
+
existing callers may notice: a call that used to close and delete the open
|
|
85
|
+
project now returns an error, until it passes `close_current=True`.
|
|
86
|
+
- All of the actions above are registered and their tools decorated. The project
|
|
87
|
+
delete is CRITICAL, and `safe_project_delete` and the thirteen others are HIGH,
|
|
88
|
+
so all are blocked while safe mode is on. An explicit dry run is refused
|
|
89
|
+
unless the action honours it natively; only `safe_project_delete` does, and
|
|
90
|
+
keeps working. Every call is audited. None of them archives the timeline,
|
|
91
|
+
except the keyframe deletes on `fusion_comp` and `timeline_item`, which change
|
|
92
|
+
timeline items and so still do.
|
|
93
|
+
- **`remove_motion_blur`** (on `folder` and `media_pool_item`) is **re-rated
|
|
94
|
+
MEDIUM**. It renders new media and never touches the source, and was already
|
|
95
|
+
confirm-gated for exactly that reason, but the `remove_` name-prefix rule had
|
|
96
|
+
rated it HIGH on its name alone. It is now audited, and not blocked by safe
|
|
97
|
+
mode.
|
|
98
|
+
- **The name-prefix rule is now a fallback for unlisted actions**: an explicit
|
|
99
|
+
lower rating wins. Before, it fired ahead of the LOW and MEDIUM tables and
|
|
100
|
+
could not be overridden. No existing rating changed except `remove_motion_blur`
|
|
101
|
+
— no LOW, MEDIUM or graph-LUT entry started with `delete_` or `remove_`.
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
|
|
105
|
+
- **Nine risk rules named actions that no tool dispatches, and so protected
|
|
106
|
+
nothing.** On `project_manager`: `delete_project`, now repointed at the real
|
|
107
|
+
`delete`, plus `close_project_without_saving` and `save_project_as`. On
|
|
108
|
+
`edit_engine`: `auto_cut_silence` and `ripple_trim`. On `timeline`: `cut_clip`,
|
|
109
|
+
`delete_clip_by_id`, `delete_markers` and `ripple_delete` — which is a CutList
|
|
110
|
+
entry kind inside `apply_cuts`, not an action. The rest were removed; since
|
|
111
|
+
they never matched, removing them changes nothing at runtime. An existing test
|
|
112
|
+
even asserted that the dead `delete_project` rule classified as CRITICAL — true
|
|
113
|
+
of a name no tool uses, and part of how it survived. It now tests the real
|
|
114
|
+
`delete`.
|
|
115
|
+
- **The bridge installer's Lua canary gave only the pre-21.1 diagnosis** — that
|
|
116
|
+
Resolve cannot find a Python 3. On free 21.1 that is wrong, because Python
|
|
117
|
+
scripting moved to Studio (#203). Its comments and printed output now give both
|
|
118
|
+
causes, the newer first (#219). The printed post-install guidance was already
|
|
119
|
+
corrected in v2.224.1.
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
|
|
123
|
+
- **`tests/test_write_enforcement_ratchet.py`** fails the suite in three cases:
|
|
124
|
+
- an action is rated destructive but not enforced;
|
|
125
|
+
- a risk rule names an action no tool dispatches;
|
|
126
|
+
- a new write-style action appears with neither a rating nor a registry entry.
|
|
127
|
+
|
|
128
|
+
The 144 unrated write-style actions that exist today are frozen as a backlog.
|
|
129
|
+
Rating one forces its removal from the list, so it can only shrink. This is
|
|
130
|
+
the second instance of this gap in two days, and the first time it cannot
|
|
131
|
+
come back unnoticed.
|
|
132
|
+
- `tests/test_project_delete_guard.py` pins the delete guard, its rating and its
|
|
133
|
+
enforcement.
|
|
134
|
+
|
|
135
|
+
### Documentation
|
|
136
|
+
|
|
137
|
+
- Removed a stale tool count from `docs/authoring/script-plugin-authoring.md`.
|
|
138
|
+
|
|
139
|
+
### Validation
|
|
140
|
+
|
|
141
|
+
- Full suite green: 3,482 passed, 1 skipped. Every static and drift gate is clean, including
|
|
142
|
+
the native-dry-run scan. That scan requires the native-dry-run list to match,
|
|
143
|
+
exactly, the registered actions whose handlers read `dry_run`, which is how
|
|
144
|
+
`safe_project_delete` was confirmed as the only one.
|
|
145
|
+
- No live Resolve run. The gating is decorator-level and verified offline. The
|
|
146
|
+
delete guard is tested against a fake project manager, deliberately:
|
|
147
|
+
exercising it live means deleting a real project.
|
|
148
|
+
|
|
5
149
|
## What's New in v3.0.0 — the server no longer executes caller-supplied code, and every plugin write is gated
|
|
6
150
|
|
|
7
151
|
**A breaking release.** Two public actions are removed. The rest of the change
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
|
@@ -410,6 +410,17 @@ Extension authoring references live in [docs/authoring](docs/authoring/). Resolv
|
|
|
410
410
|
|
|
411
411
|
Resolve 19.1.3 remains the compatibility baseline. Resolve 20.x scripting calls are additive, version-guarded, and live-tested on 20.3.2. Resolve 21.0 scripting additions (audio classification, speaker-detection transcription, IntelliSearch, slate analysis, motion-deblur, speech generation, session background-task control) are exposed behind runtime capability detection, so they stay inert on older builds and activate automatically on Resolve 21+. They are live-tested on Studio 21.0.2.4 — see the [Resolve 21 delta](docs/reference/api-coverage.md#resolve-21-delta-detail). Note that `AnalyzeForIntellisearch`, `AnalyzeForSlate` and `GenerateSpeech` each require a separately-downloaded AI Extras pack, and Resolve reports a missing pack inconsistently (some return `False`, others an error string), so these actions report `success: false` with the Resolve-supplied reason rather than guessing.
|
|
412
412
|
|
|
413
|
+
## Reporting Bugs and Requesting Features
|
|
414
|
+
|
|
415
|
+
Tell your assistant "send this as a bug" or "send this as a feature request". It
|
|
416
|
+
drafts a GitHub issue from the conversation, including the failing call and
|
|
417
|
+
its error, and attaches the server version, Resolve build, connection mode and
|
|
418
|
+
OS. Local paths, your username and anything that looks like a secret are
|
|
419
|
+
redacted. Nothing is filed for you: you get a prefilled link, review the
|
|
420
|
+
draft, and submit it on GitHub yourself. You can also
|
|
421
|
+
[open an issue](https://github.com/samuelgursky/davinci-resolve-mcp/issues/new/choose)
|
|
422
|
+
directly.
|
|
423
|
+
|
|
413
424
|
## Development
|
|
414
425
|
|
|
415
426
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v3.
|
|
15
|
+
> 本翻译对应 v3.1.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -269,6 +269,10 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
269
269
|
|
|
270
270
|
Resolve 19.1.3 仍是兼容性基线。Resolve 20.x 的脚本调用是增量式的、带版本护栏的,并已在 20.3.2 上实机测试。Resolve 21.0 新增的脚本能力(音频分类、说话人检测转写、IntelliSearch、场记板分析、运动去模糊、语音生成、会话后台任务控制)通过运行时能力检测暴露,在旧构建上保持沉默,在 Resolve 21+ 上自动激活。它们已在 Studio 21.0.2.4 上实机测试——见 [Resolve 21 增量明细](docs/reference/api-coverage.md#resolve-21-delta-detail)。注意 `AnalyzeForIntellisearch`、`AnalyzeForSlate` 和 `GenerateSpeech` 各自需要单独下载的 AI Extras 包,而 Resolve 报告缺包的方式不一致(有的返回 `False`,有的返回错误字符串),所以这些 action 会带着 Resolve 给出的原因报告 `success: false`,而不是瞎猜。
|
|
271
271
|
|
|
272
|
+
## 报告 Bug 与提出功能需求
|
|
273
|
+
|
|
274
|
+
对你的助手说"把这个作为 bug 发送"或"把这个作为功能需求发送"。它会根据对话起草一个 GitHub issue,包含失败的调用及其错误,并附上服务器版本、Resolve 构建、连接方式和操作系统。本地路径、你的用户名以及任何看起来像密钥的内容都会被脱敏。不会替你直接提交:你会拿到一个预填好的链接,检查草稿后在 GitHub 上自己提交。你也可以直接[新建 issue](https://github.com/samuelgursky/davinci-resolve-mcp/issues/new/choose)。
|
|
275
|
+
|
|
272
276
|
## 开发
|
|
273
277
|
|
|
274
278
|
```bash
|
package/docs/SKILL.md
CHANGED
|
@@ -735,6 +735,16 @@ Key actions:
|
|
|
735
735
|
Resolve API behavior (no connection needed); filter by substring
|
|
736
736
|
- `verification_stats` — readback-verification tally (verified/contradicted/
|
|
737
737
|
unverified) since server start (no connection needed)
|
|
738
|
+
- `report_issue(kind, title, summary, …)` — when the user says "send this as a
|
|
739
|
+
bug" or "…as a feature request", draft a GitHub issue for this server. Fill
|
|
740
|
+
it from the conversation (the failing tool/action and its error verbatim,
|
|
741
|
+
expected vs actual, steps). Server version, Resolve build, connection mode
|
|
742
|
+
and OS are attached; paths, usernames, e-mails and secrets are redacted. It
|
|
743
|
+
**files nothing**: show the user the draft, then hand them the returned
|
|
744
|
+
`url` to review and submit on GitHub. Redaction cannot catch client or
|
|
745
|
+
project names written as prose, so ask the user to check. Never call it
|
|
746
|
+
unprompted; offering once after a failure that looks like a server defect
|
|
747
|
+
is fine. No connection needed, and it never launches Resolve
|
|
738
748
|
- `get_page` / `open_page(page)` — read or switch the active page
|
|
739
749
|
- `get_keyframe_mode` / `set_keyframe_mode(mode)`
|
|
740
750
|
- `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
|
|
@@ -19,7 +19,7 @@ automation.
|
|
|
19
19
|
| Custom workflow you want as a permanent menu item | `script_plugin('install', ...)`, then the user runs it from Workspace → Scripts |
|
|
20
20
|
| Image-processing node for the Fusion page | `fuse_plugin` |
|
|
21
21
|
| Color-page programmable transform | `dctl` |
|
|
22
|
-
| Anything the
|
|
22
|
+
| Anything the wrapped Resolve API tools already cover | The wrapped tool — no scripting needed |
|
|
23
23
|
|
|
24
24
|
## Two template kinds
|
|
25
25
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "3.
|
|
40
|
+
VERSION = "3.1.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -288,7 +288,9 @@ _CANARY_NAME = "resolve_bridge_canary.lua"
|
|
|
288
288
|
|
|
289
289
|
_LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
|
|
290
290
|
-- If THIS appears under Workspace > Scripts but resolve_bridge_probe does not,
|
|
291
|
-
-- Resolve is listing Lua and silently skipping Python
|
|
291
|
+
-- Resolve is listing Lua and silently skipping Python. On Resolve 21.1+ FREE
|
|
292
|
+
-- that is expected: Python scripting moved to Studio (issue #203), and nothing
|
|
293
|
+
-- below will change it. On Studio, or 21.0.x and earlier, it cannot find a Python 3.
|
|
292
294
|
-- It looks at PYTHON3HOME, then /usr/local/bin/python3 -- and nowhere else, which
|
|
293
295
|
-- is why Homebrew, pyenv, uv and conda interpreters go unseen. Either point it at
|
|
294
296
|
-- the one you have (no sudo, but does NOT survive a reboot):
|
|
@@ -299,9 +301,12 @@ _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
|
|
|
299
301
|
-- already looks, which persists:
|
|
300
302
|
-- sudo ln -s "$(command -v python3)" /usr/local/bin/python3
|
|
301
303
|
-- A python.org build creates that symlink for you. Restart Resolve after.
|
|
302
|
-
print("Resolve is enumerating scripts. If the Python probe is missing
|
|
303
|
-
print("
|
|
304
|
-
print("
|
|
304
|
+
print("Resolve is enumerating scripts. If the Python probe is missing:")
|
|
305
|
+
print("- Resolve 21.1+ FREE: Python scripting moved to Studio, so .py scripts")
|
|
306
|
+
print(" no longer list at all (issue #203). No Python setting changes that.")
|
|
307
|
+
print("- Studio, or 21.0.x and earlier: Resolve cannot find a Python 3. Set")
|
|
308
|
+
print(" PYTHON3HOME with launchctl setenv, or install a python.org build.")
|
|
309
|
+
print(" Homebrew/pyenv/uv/conda are not looked at directly.")
|
|
305
310
|
"""
|
|
306
311
|
|
|
307
312
|
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "3.
|
|
90
|
+
VERSION = "3.1.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "3.
|
|
14
|
+
VERSION = "3.1.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -147,6 +147,7 @@ from src.utils.media_analysis_jobs import (
|
|
|
147
147
|
from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths
|
|
148
148
|
from src.utils.resolve_connection import connect_resolve
|
|
149
149
|
from src.utils import resolve_runtime as _resolve_runtime
|
|
150
|
+
from src.utils import issue_report as _issue_report
|
|
150
151
|
from src.utils.lut_paths import master_lut_dir, ensure_lut_in_master
|
|
151
152
|
from src.utils import fuse_templates, dctl_templates, script_templates
|
|
152
153
|
from src.utils.timeline_title_text import (
|
|
@@ -248,7 +249,10 @@ mcp = FastMCP(
|
|
|
248
249
|
"DAVINCI_RESOLVE_BRIDGE=1 only forces it), so a connection error does NOT mean "
|
|
249
250
|
"the free edition is unsupported — on Resolve 21.0.x. Resolve 21.1 moved Python "
|
|
250
251
|
"scripting to Studio and free 21.1 no longer lists Python scripts in that menu "
|
|
251
|
-
"(issue #203), so on 21.1+ a free-edition connection error may be final."
|
|
252
|
+
"(issue #203), so on 21.1+ a free-edition connection error may be final. "
|
|
253
|
+
"When the user asks to send something as a bug or feature request, draft it "
|
|
254
|
+
"with resolve_control(action='report_issue') — it returns a prefilled GitHub "
|
|
255
|
+
"issue link for the user to review and submit; nothing is filed for them."
|
|
252
256
|
),
|
|
253
257
|
)
|
|
254
258
|
|
|
@@ -16575,6 +16579,7 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
16575
16579
|
|
|
16576
16580
|
@mcp.tool()
|
|
16577
16581
|
@_guard_missing_params
|
|
16582
|
+
@_destructive_op("resolve_control")
|
|
16578
16583
|
def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
16579
16584
|
"""App-level DaVinci Resolve operations.
|
|
16580
16585
|
|
|
@@ -16618,6 +16623,23 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16618
16623
|
Omit symbol for every recorded gate this build does not clear.
|
|
16619
16624
|
verification_stats() -> {stats} — readback-verification tally
|
|
16620
16625
|
(verified/contradicted/unverified) since server start (no connection needed).
|
|
16626
|
+
report_issue(kind, title, summary, steps?, expected?, actual?, error?, tool?,
|
|
16627
|
+
tool_action?, use_case?, proposal?, include_environment?)
|
|
16628
|
+
-> {title, body, labels, url, url_truncated, redactions, submitted: false, next_step}
|
|
16629
|
+
— Draft a GitHub bug report (kind="bug") or feature request (kind="feature")
|
|
16630
|
+
for this MCP server. Call it when the user asks to send, report or file
|
|
16631
|
+
something as a bug or feature request ("send this as a bug"). Do not call
|
|
16632
|
+
it unprompted; you may OFFER once when a failure looks like a defect in
|
|
16633
|
+
this server rather than in the user's request. Write the fields from the
|
|
16634
|
+
conversation: the failing tool/action and its error verbatim, what the
|
|
16635
|
+
user expected, and steps that reproduce it. Server version, Resolve
|
|
16636
|
+
build, connection mode and OS are attached automatically
|
|
16637
|
+
(include_environment=false to omit); nothing connects to or launches
|
|
16638
|
+
Resolve. NOTHING IS FILED: show the user the title and body, then give
|
|
16639
|
+
them the url — the issue is created only when they open it and press
|
|
16640
|
+
Submit on GitHub. Paths, usernames, e-mails and secrets are redacted,
|
|
16641
|
+
but client or project names in plain prose are not: ask the user to
|
|
16642
|
+
check before submitting.
|
|
16621
16643
|
job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
|
|
16622
16644
|
— poll a background job started by a long op run with background=True
|
|
16623
16645
|
(no connection needed). status is running, done, or error.
|
|
@@ -16750,6 +16772,48 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
16750
16772
|
stats = _verification_stats()
|
|
16751
16773
|
return {"stats": stats, "note": "Counts since server start. A rising "
|
|
16752
16774
|
"'contradicted' count means the API reported success but a readback disagreed."}
|
|
16775
|
+
if action == "report_issue":
|
|
16776
|
+
# Drafts only — never files, never connects. See utils/issue_report.py.
|
|
16777
|
+
kind = _issue_report.normalize_kind(p.get("kind") or p.get("type"))
|
|
16778
|
+
if kind is None:
|
|
16779
|
+
return _err(
|
|
16780
|
+
"report_issue requires kind: 'bug' or 'feature'",
|
|
16781
|
+
code="INVALID_KIND",
|
|
16782
|
+
category="invalid_input",
|
|
16783
|
+
)
|
|
16784
|
+
title = str(p.get("title") or "").strip()
|
|
16785
|
+
summary = str(p.get("summary") or p.get("description") or p.get("body") or "").strip()
|
|
16786
|
+
if not title or not summary:
|
|
16787
|
+
return _err(
|
|
16788
|
+
"report_issue requires title and summary",
|
|
16789
|
+
code="MISSING_FIELDS",
|
|
16790
|
+
category="invalid_input",
|
|
16791
|
+
remediation="Write both from the conversation: a one-line title and "
|
|
16792
|
+
"a summary of what happened or what the user wants.",
|
|
16793
|
+
)
|
|
16794
|
+
environment = None
|
|
16795
|
+
if _setup_bool(p.get("include_environment", p.get("includeEnvironment")), True):
|
|
16796
|
+
environment = _issue_report.collect_environment(resolve, VERSION)
|
|
16797
|
+
draft = _issue_report.build_issue(
|
|
16798
|
+
kind,
|
|
16799
|
+
title,
|
|
16800
|
+
summary,
|
|
16801
|
+
steps=p.get("steps"),
|
|
16802
|
+
expected=p.get("expected"),
|
|
16803
|
+
actual=p.get("actual"),
|
|
16804
|
+
error=p.get("error"),
|
|
16805
|
+
tool=p.get("tool"),
|
|
16806
|
+
tool_action=p.get("tool_action") or p.get("failed_action"),
|
|
16807
|
+
use_case=p.get("use_case"),
|
|
16808
|
+
proposal=p.get("proposal"),
|
|
16809
|
+
environment=environment,
|
|
16810
|
+
)
|
|
16811
|
+
return {
|
|
16812
|
+
"success": True,
|
|
16813
|
+
**draft,
|
|
16814
|
+
"submitted": False,
|
|
16815
|
+
"next_step": _issue_report.next_step_guidance(draft["url_truncated"]),
|
|
16816
|
+
}
|
|
16753
16817
|
|
|
16754
16818
|
# Background-job polling is a registry read — no Resolve connection needed.
|
|
16755
16819
|
if action == "job_status":
|
|
@@ -17074,7 +17138,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
17074
17138
|
if err:
|
|
17075
17139
|
return _err(err)
|
|
17076
17140
|
return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
|
|
17077
|
-
return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
|
|
17141
|
+
return _unknown(action, ["is_studio","get_keyboard_presets","get_current_keyboard_preset","launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","report_issue","job_status","list_jobs","get_execution_trace","get_execution","list_recent_executions","begin_execution","end_execution","export_execution_report","clear_executions","inspect_operation","list_lifecycle_hooks","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","list_user_preferences_presets","save_user_preferences_preset","load_user_preferences_preset","delete_user_preferences_preset","import_user_preferences_preset","export_user_preferences_preset","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
|
|
17078
17142
|
|
|
17079
17143
|
|
|
17080
17144
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -18072,6 +18136,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
18072
18136
|
|
|
18073
18137
|
@mcp.tool()
|
|
18074
18138
|
@_guard_missing_params
|
|
18139
|
+
@_destructive_op("render_presets")
|
|
18075
18140
|
def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
18076
18141
|
"""Import/export render and burn-in presets.
|
|
18077
18142
|
|
|
@@ -19030,6 +19095,7 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
|
|
|
19030
19095
|
|
|
19031
19096
|
@mcp.tool()
|
|
19032
19097
|
@_guard_missing_params
|
|
19098
|
+
@_destructive_op("project_manager")
|
|
19033
19099
|
def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
19034
19100
|
"""Manage DaVinci Resolve projects.
|
|
19035
19101
|
|
|
@@ -19047,7 +19113,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
19047
19113
|
render and wedges Resolve's pipeline until restart (stuck
|
|
19048
19114
|
IsRenderingInProgress, 0% jobs, refused Quit). stop_render=true stops
|
|
19049
19115
|
the render, waits for the flag to clear, then closes.
|
|
19050
|
-
delete(name) -> {success}
|
|
19116
|
+
delete(name, close_current?) -> {success}
|
|
19051
19117
|
import_project(path, name?) -> {success}
|
|
19052
19118
|
export_project(name, path, with_stills_and_luts?) -> {success}
|
|
19053
19119
|
archive(name, path, src_media?, render_cache?, proxy_media?) -> {success}
|
|
@@ -19180,6 +19246,18 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
19180
19246
|
elif action == "delete":
|
|
19181
19247
|
if not p.get("name"):
|
|
19182
19248
|
return _err("delete requires name")
|
|
19249
|
+
# Refuse the open project unless the caller says so, as
|
|
19250
|
+
# safe_project_delete does. delete_project_safely closes and deletes the
|
|
19251
|
+
# current project without asking — the call that loses a project someone
|
|
19252
|
+
# is working in. If the current project cannot be read, DeleteProject on
|
|
19253
|
+
# an open project fails anyway (the session holds its lock).
|
|
19254
|
+
try:
|
|
19255
|
+
_open = pm.GetCurrentProject()
|
|
19256
|
+
_open_name = _open.GetName() if _open else None
|
|
19257
|
+
except Exception:
|
|
19258
|
+
_open_name = None
|
|
19259
|
+
if _open_name == p["name"] and not p.get("close_current", False):
|
|
19260
|
+
return _err("Refusing to delete the currently open project; pass close_current=True")
|
|
19183
19261
|
from src.utils.project_cleanup import delete_project_safely
|
|
19184
19262
|
deleted = delete_project_safely(pm, p["name"])
|
|
19185
19263
|
return {"success": bool(deleted.get("success")), "delete_detail": deleted}
|
|
@@ -19364,6 +19442,7 @@ def _setting_limitation(name: Any, obj: str = "Project") -> Optional[Dict[str, A
|
|
|
19364
19442
|
|
|
19365
19443
|
@mcp.tool()
|
|
19366
19444
|
@_guard_missing_params
|
|
19445
|
+
@_destructive_op("project_settings")
|
|
19367
19446
|
def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
19368
19447
|
"""Project metadata, settings, and color groups.
|
|
19369
19448
|
|
|
@@ -20340,6 +20419,7 @@ def _export_render_boundary_report(proj, p: Dict[str, Any]):
|
|
|
20340
20419
|
|
|
20341
20420
|
@mcp.tool()
|
|
20342
20421
|
@_guard_missing_params
|
|
20422
|
+
@_destructive_op("render")
|
|
20343
20423
|
def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
20344
20424
|
"""Render pipeline: jobs, presets, formats, codecs, and rendering.
|
|
20345
20425
|
|
|
@@ -21319,6 +21399,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21319
21399
|
|
|
21320
21400
|
@mcp.tool()
|
|
21321
21401
|
@_guard_missing_params
|
|
21402
|
+
@_destructive_op("folder")
|
|
21322
21403
|
def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
21323
21404
|
"""Operations on Media Pool folders.
|
|
21324
21405
|
|
|
@@ -21491,6 +21572,7 @@ def _keyed_get(getter, key):
|
|
|
21491
21572
|
|
|
21492
21573
|
@mcp.tool()
|
|
21493
21574
|
@_guard_missing_params
|
|
21575
|
+
@_destructive_op("media_pool_item")
|
|
21494
21576
|
def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
21495
21577
|
"""Operations on a media pool clip. Identify clip by clip_id.
|
|
21496
21578
|
|
|
@@ -21955,6 +22037,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
21955
22037
|
|
|
21956
22038
|
@mcp.tool()
|
|
21957
22039
|
@_guard_missing_params
|
|
22040
|
+
@_destructive_op("media_pool_item_markers")
|
|
21958
22041
|
def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
21959
22042
|
"""Markers and flags on media pool clips. Identify clip by clip_id.
|
|
21960
22043
|
|
|
@@ -28805,6 +28888,7 @@ def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, A
|
|
|
28805
28888
|
|
|
28806
28889
|
@mcp.tool()
|
|
28807
28890
|
@_guard_missing_params
|
|
28891
|
+
@_destructive_op("gallery_stills")
|
|
28808
28892
|
def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
28809
28893
|
"""Manage stills in gallery albums (best results on Color page).
|
|
28810
28894
|
|
|
@@ -30261,6 +30345,7 @@ def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
30261
30345
|
|
|
30262
30346
|
@mcp.tool()
|
|
30263
30347
|
@_guard_missing_params
|
|
30348
|
+
@_destructive_op("fusion_comp")
|
|
30264
30349
|
def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
30265
30350
|
"""Fusion composition node graph operations.
|
|
30266
30351
|
|
|
@@ -77,6 +77,24 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
|
77
77
|
"safe_install_extension",
|
|
78
78
|
"safe_remove_extension",
|
|
79
79
|
}),
|
|
80
|
+
# Deletes the classifier already rated HIGH (CRITICAL for the raw project
|
|
81
|
+
# delete) on tools that carried no @_destructive_op, so safe mode never saw
|
|
82
|
+
# them. Plus the 21.0 AI deblur, rated MEDIUM: it creates media, and is
|
|
83
|
+
# registered so it is audited and its dry run is honest.
|
|
84
|
+
"folder": frozenset({"remove_motion_blur"}),
|
|
85
|
+
"fusion_comp": frozenset({"delete_keyframe", "delete_tool"}),
|
|
86
|
+
"gallery_stills": frozenset({"delete_stills"}),
|
|
87
|
+
"media_pool_item": frozenset({"remove_motion_blur"}),
|
|
88
|
+
"media_pool_item_markers": frozenset({
|
|
89
|
+
"delete_at_frame",
|
|
90
|
+
"delete_by_color",
|
|
91
|
+
"delete_by_custom_data",
|
|
92
|
+
}),
|
|
93
|
+
"project_manager": frozenset({"delete", "safe_project_delete"}),
|
|
94
|
+
"project_settings": frozenset({"delete_color_group"}),
|
|
95
|
+
"render": frozenset({"delete_all_jobs", "delete_job", "delete_preset"}),
|
|
96
|
+
"render_presets": frozenset({"delete_burnin"}),
|
|
97
|
+
"resolve_control": frozenset({"delete_user_preferences_preset"}),
|
|
80
98
|
"media_pool": frozenset({
|
|
81
99
|
"delete_clips",
|
|
82
100
|
"delete_folders",
|
|
@@ -155,6 +173,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
|
155
173
|
"create_subtitles",
|
|
156
174
|
}),
|
|
157
175
|
"timeline_item": frozenset({
|
|
176
|
+
"delete_keyframe",
|
|
158
177
|
"set_output_blanking",
|
|
159
178
|
"set_use_timeline_for_output_blanking",
|
|
160
179
|
"set_clip_enabled",
|
|
@@ -250,7 +269,9 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
|
|
|
250
269
|
|
|
251
270
|
# ── Non-timeline write tools ────────────────────────────────────────────────
|
|
252
271
|
#
|
|
253
|
-
# Tools whose registered actions
|
|
272
|
+
# Tools whose registered actions do not mutate the working timeline: they write
|
|
273
|
+
# plugin folders, or act on projects, the render queue, presets, the gallery,
|
|
274
|
+
# pool items or app preferences. Every
|
|
254
275
|
# gate applies to them — safe mode, dry-run refusal, the audit log — but they
|
|
255
276
|
# skip version-on-mutate archiving, and skip resolving the versioning context
|
|
256
277
|
# at all: that goes through the project-root provider, which reaches Resolve,
|
|
@@ -258,7 +279,12 @@ NO_ARCHIVE_ON_KEYS: Dict[Tuple[str, str], frozenset] = {
|
|
|
258
279
|
# Resolve. `media_pool` has its own branch for the same reason; these differ in
|
|
259
280
|
# that there is no project state to log.
|
|
260
281
|
|
|
261
|
-
NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({
|
|
282
|
+
NON_TIMELINE_WRITE_TOOLS: frozenset = frozenset({
|
|
283
|
+
"dctl", "fuse_plugin", "script_plugin",
|
|
284
|
+
"folder", "gallery_stills", "media_pool_item", "media_pool_item_markers",
|
|
285
|
+
"project_manager", "project_settings", "render", "render_presets",
|
|
286
|
+
"resolve_control",
|
|
287
|
+
})
|
|
262
288
|
|
|
263
289
|
|
|
264
290
|
# ── Strict-mode allowlist ───────────────────────────────────────────────────
|
|
@@ -327,6 +353,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
|
|
|
327
353
|
("timeline_ai", "create_subtitles"),
|
|
328
354
|
("script_plugin", "safe_install_extension"),
|
|
329
355
|
("script_plugin", "safe_remove_extension"),
|
|
356
|
+
("project_manager", "safe_project_delete"),
|
|
330
357
|
})
|
|
331
358
|
|
|
332
359
|
|
|
@@ -125,8 +125,10 @@ class RiskClassificationHook(LifecycleHook):
|
|
|
125
125
|
name = "risk_classification"
|
|
126
126
|
|
|
127
127
|
_CRITICAL_ACTIONS: Set[Tuple[str, str]] = {
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
# Raw project delete. This rule used to name `delete_project`, which
|
|
129
|
+
# no tool dispatches, so it matched nothing and deleting a project
|
|
130
|
+
# passed every gate.
|
|
131
|
+
("project_manager", "delete"),
|
|
130
132
|
("media_pool", "delete_timelines"),
|
|
131
133
|
("media_pool", "delete_clips"),
|
|
132
134
|
}
|
|
@@ -140,15 +142,11 @@ class RiskClassificationHook(LifecycleHook):
|
|
|
140
142
|
("fuse_plugin", "remove"),
|
|
141
143
|
("script_plugin", "remove"),
|
|
142
144
|
("script_plugin", "safe_remove_extension"),
|
|
145
|
+
# Guarded delete: disposable `_mcp_` projects only, and the open one
|
|
146
|
+
# only with close_current=True — but still permanent.
|
|
147
|
+
("project_manager", "safe_project_delete"),
|
|
143
148
|
("timeline", "delete_clips"),
|
|
144
|
-
("timeline", "delete_clip_by_id"),
|
|
145
|
-
("timeline", "delete_markers"),
|
|
146
|
-
("timeline", "ripple_delete"),
|
|
147
|
-
("timeline", "cut_clip"),
|
|
148
149
|
("edit_engine", "execute_selects"),
|
|
149
|
-
("edit_engine", "auto_cut_silence"),
|
|
150
|
-
("edit_engine", "ripple_trim"),
|
|
151
|
-
("project_manager", "save_project_as"),
|
|
152
150
|
("media_pool", "delete_folders"),
|
|
153
151
|
("timeline", "delete_track"),
|
|
154
152
|
("timeline", "lift_range"),
|
|
@@ -264,6 +262,11 @@ class RiskClassificationHook(LifecycleHook):
|
|
|
264
262
|
("fuse_plugin", "install"),
|
|
265
263
|
("script_plugin", "install"),
|
|
266
264
|
("script_plugin", "safe_install_extension"),
|
|
265
|
+
# 21.0 AI deblur renders NEW media and never touches the source (it is
|
|
266
|
+
# confirm-token gated for that reason). The `remove_` prefix rule rated
|
|
267
|
+
# it HIGH on its name alone, which would make safe mode block a create.
|
|
268
|
+
("folder", "remove_motion_blur"),
|
|
269
|
+
("media_pool_item", "remove_motion_blur"),
|
|
267
270
|
# Additive edits that place content into an existing timeline. Nothing
|
|
268
271
|
# is deleted (`overwrite_range`, which does delete, is HIGH), but the
|
|
269
272
|
# timeline is no longer what it was.
|
|
@@ -378,7 +381,16 @@ class RiskClassificationHook(LifecycleHook):
|
|
|
378
381
|
radius = BlastRadius.PROJECT if "project" in tool_name else BlastRadius.TIMELINE
|
|
379
382
|
conf_required = True
|
|
380
383
|
reasons.append(f"Action '{action}' is permanently destructive across {radius.value}")
|
|
381
|
-
elif pair in cls._HIGH_RISK_ACTIONS or
|
|
384
|
+
elif pair in cls._HIGH_RISK_ACTIONS or (
|
|
385
|
+
# The name-prefix rule is a fallback for UNLISTED actions: an
|
|
386
|
+
# explicit lower rating wins. It used to fire first and so could
|
|
387
|
+
# not be overridden — `remove_motion_blur` creates media, yet read
|
|
388
|
+
# HIGH on its name.
|
|
389
|
+
pair not in cls._LOW_RISK_ACTIONS
|
|
390
|
+
and pair not in cls._MEDIUM_RISK_ACTIONS
|
|
391
|
+
and pair not in cls._GRAPH_LUT_ACTIONS
|
|
392
|
+
and (action.startswith("delete_") or action.startswith("remove_"))
|
|
393
|
+
):
|
|
382
394
|
level = RiskLevel.HIGH
|
|
383
395
|
destructive = True
|
|
384
396
|
if params.get("ripple", False):
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Draft a GitHub issue from inside a chat — bug reports and feature requests.
|
|
2
|
+
|
|
3
|
+
The user says "send this as a bug" (or "…as a feature request") and the agent
|
|
4
|
+
calls `resolve_control(action="report_issue")`. The server never files
|
|
5
|
+
anything. It writes the issue and returns a prefilled `issues/new` link; the
|
|
6
|
+
user reads the draft, opens the link, and presses Submit on GitHub under their
|
|
7
|
+
own account. That keeps three things true:
|
|
8
|
+
|
|
9
|
+
- no GitHub credential ever lives in the MCP server;
|
|
10
|
+
- nothing is published that the user has not seen;
|
|
11
|
+
- the report still carries the facts a maintainer would otherwise have to
|
|
12
|
+
ask for in the thread — server version, Resolve build and edition,
|
|
13
|
+
connection mode, OS — which is most of the value.
|
|
14
|
+
|
|
15
|
+
Redaction runs over every field before it reaches the draft: absolute paths,
|
|
16
|
+
the local username, full name and hostname, e-mail addresses, and anything
|
|
17
|
+
shaped like a secret. It is best-effort by construction — it cannot recognise
|
|
18
|
+
a client or project name typed as plain prose — so the result always tells
|
|
19
|
+
the agent to show the draft to the user before handing over the link.
|
|
20
|
+
|
|
21
|
+
Nothing here connects to Resolve. A report about a connection that will not
|
|
22
|
+
come up must not launch Resolve or wait on one, so the environment is read
|
|
23
|
+
from a handle the server already holds, or reported as "not connected".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import getpass
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import re
|
|
32
|
+
import socket
|
|
33
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
34
|
+
from urllib.parse import quote, urlencode
|
|
35
|
+
|
|
36
|
+
from .update_check import DEFAULT_REPO
|
|
37
|
+
|
|
38
|
+
#: GitHub answers `414 URI Too Long` somewhere past 8 KB and does not document
|
|
39
|
+
#: the exact limit. Stay under it; the full body is returned separately.
|
|
40
|
+
MAX_URL_CHARS = 8000
|
|
41
|
+
|
|
42
|
+
MAX_FIELD_CHARS = 4000
|
|
43
|
+
|
|
44
|
+
KINDS: Dict[str, Dict[str, str]] = {
|
|
45
|
+
"bug": {"template": "bug_report.md", "label": "bug"},
|
|
46
|
+
"feature": {"template": "feature_request.md", "label": "enhancement"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_KIND_ALIASES = {
|
|
50
|
+
"bug": "bug",
|
|
51
|
+
"bug_report": "bug",
|
|
52
|
+
"defect": "bug",
|
|
53
|
+
"issue": "bug",
|
|
54
|
+
"feature": "feature",
|
|
55
|
+
"feature_request": "feature",
|
|
56
|
+
"enhancement": "feature",
|
|
57
|
+
"request": "feature",
|
|
58
|
+
"idea": "feature",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Install and support locations that say which build is installed and where.
|
|
62
|
+
#: They are the same on every machine, so keeping them costs no privacy and
|
|
63
|
+
#: saves a round-trip on installer and connection bugs.
|
|
64
|
+
_KEEP_PATH_PREFIXES = (
|
|
65
|
+
"/Library/Application Support/Blackmagic Design",
|
|
66
|
+
"/Applications/DaVinci Resolve",
|
|
67
|
+
"/opt/resolve",
|
|
68
|
+
"~/.davinci-resolve-mcp",
|
|
69
|
+
"c:\\program files\\blackmagic design",
|
|
70
|
+
"c:\\programdata\\blackmagic design",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: Usernames that are also ordinary words. Redacting "user" or "editor" out of
|
|
74
|
+
#: prose mangles the report and protects nobody.
|
|
75
|
+
_COMMON_ACCOUNT_NAMES = {
|
|
76
|
+
"root", "user", "admin", "administrator", "guest", "test", "dev",
|
|
77
|
+
"editor", "video", "mac", "pc", "home", "owner", "localhost",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_TRUNCATION_NOTE = "\n\n_…truncated to fit the link — the full report is in the chat._"
|
|
81
|
+
|
|
82
|
+
_FOOTER = (
|
|
83
|
+
"\n\n---\n"
|
|
84
|
+
"<sub>Drafted in chat with the davinci-resolve-mcp `report_issue` action. "
|
|
85
|
+
"Local paths, the username and anything secret-shaped were redacted "
|
|
86
|
+
"automatically.</sub>\n"
|
|
87
|
+
"<!-- filed-via: davinci-resolve-mcp report_issue -->"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# Redaction
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
_SEGMENT = r"[^\s/\\\"'`<>|*?:]+"
|
|
95
|
+
# A directory segment may contain spaces ("/Volumes/My Drive/") because it is
|
|
96
|
+
# closed by the next slash. A final segment may only when it ends in a file
|
|
97
|
+
# extension ("A001 take 2.mov"); otherwise it stops at the first space, so
|
|
98
|
+
# "/tmp/a to /tmp/b" stays two paths. A space after a comma or before a `~`
|
|
99
|
+
# ends the path too — "x.log, see ~/y" is prose between two paths, not one.
|
|
100
|
+
_SPACED = r"(?<![,;]) (?!~)"
|
|
101
|
+
_DIR_SEGMENT = rf"{_SEGMENT}(?:{_SPACED}{_SEGMENT})*"
|
|
102
|
+
_LAST_SEGMENT = (
|
|
103
|
+
rf"(?:{_SEGMENT}(?:{_SPACED}{_SEGMENT})*\.[A-Za-z0-9]{{1,5}}(?![\w.])|{_SEGMENT})"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
_POSIX_PATH = re.compile(rf"(?<![\w:/.~\\-])(?:~/|/)(?:{_DIR_SEGMENT}/)+{_LAST_SEGMENT}")
|
|
107
|
+
_WINDOWS_PATH = re.compile(
|
|
108
|
+
rf"(?<![\w])(?:[A-Za-z]:[\\/]|\\\\{_SEGMENT}\\)(?:{_DIR_SEGMENT}[\\/])*{_LAST_SEGMENT}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
_SECRET_PATTERNS: Tuple[Tuple[re.Pattern, str], ...] = (
|
|
112
|
+
(re.compile(r"#token=[^\s&\"'`]+"), "#token=<redacted>"),
|
|
113
|
+
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]+=*"), "Bearer <redacted>"),
|
|
114
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{16,}"), "<redacted-key>"),
|
|
115
|
+
(re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), "<redacted-key>"),
|
|
116
|
+
(re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "<redacted-key>"),
|
|
117
|
+
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "<redacted-key>"),
|
|
118
|
+
(re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "<redacted-key>"),
|
|
119
|
+
(
|
|
120
|
+
re.compile(
|
|
121
|
+
r"(?i)\b(token|api[_-]?key|secret|password|passwd|authorization)"
|
|
122
|
+
r"(\s*[=:]\s*)(?!<redacted)[^\s,;&\"'`]+"
|
|
123
|
+
),
|
|
124
|
+
r"\1\2<redacted>",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _trailing_punctuation(match: str) -> Tuple[str, str]:
|
|
132
|
+
stripped = match.rstrip(".,;:)]}")
|
|
133
|
+
return stripped, match[len(stripped):]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _path_replacement(path: str, home: str = "") -> Tuple[str, bool]:
|
|
137
|
+
"""Return (replacement, redacted?) for one matched absolute path."""
|
|
138
|
+
if len(home) > 1 and path.lower().startswith(home.lower()):
|
|
139
|
+
path = "~" + path[len(home):].replace("\\", "/")
|
|
140
|
+
windows = re.match(r"^[A-Za-z]:|^\\\\", path)
|
|
141
|
+
comparable = path.lower().replace("/", "\\") if windows else path
|
|
142
|
+
for prefix in _KEEP_PATH_PREFIXES:
|
|
143
|
+
if comparable.startswith(prefix):
|
|
144
|
+
return path, False
|
|
145
|
+
last = re.split(r"[\\/]", path)[-1]
|
|
146
|
+
ext = re.search(r"\.([A-Za-z0-9]{1,5})$", last)
|
|
147
|
+
if ext and not ext.group(1).isdigit():
|
|
148
|
+
return f"<path>.{ext.group(1)}", True
|
|
149
|
+
return "<path>", True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def local_identity() -> Dict[str, Any]:
|
|
153
|
+
"""The strings that identify this machine and its user."""
|
|
154
|
+
identity: Dict[str, Any] = {"home": os.path.expanduser("~"), "names": []}
|
|
155
|
+
names: List[str] = []
|
|
156
|
+
try:
|
|
157
|
+
names.append(getpass.getuser())
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
import pwd # POSIX only
|
|
162
|
+
|
|
163
|
+
gecos = pwd.getpwuid(os.getuid()).pw_gecos.split(",")[0].strip()
|
|
164
|
+
if gecos:
|
|
165
|
+
names.append(gecos)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
try:
|
|
169
|
+
host = socket.gethostname().split(".")[0]
|
|
170
|
+
if host:
|
|
171
|
+
names.append(host)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
identity["names"] = names
|
|
175
|
+
return identity
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def redact(text: Any, identity: Optional[Dict[str, Any]] = None) -> Tuple[str, Dict[str, int]]:
|
|
179
|
+
"""Scrub one field. Returns (text, counts by kind)."""
|
|
180
|
+
counts = {"secrets": 0, "emails": 0, "paths": 0, "identity": 0}
|
|
181
|
+
if text is None:
|
|
182
|
+
return "", counts
|
|
183
|
+
out = str(text)
|
|
184
|
+
identity = identity if identity is not None else local_identity()
|
|
185
|
+
|
|
186
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
187
|
+
out, n = pattern.subn(replacement, out)
|
|
188
|
+
counts["secrets"] += n
|
|
189
|
+
|
|
190
|
+
out, n = _EMAIL.subn("<email>", out)
|
|
191
|
+
counts["emails"] += n
|
|
192
|
+
|
|
193
|
+
# The home directory is folded to `~` per path, not across the text first:
|
|
194
|
+
# on Windows that would turn C:\Users\name\… into ~\…, which no longer
|
|
195
|
+
# looks like an absolute path and would slip through unredacted.
|
|
196
|
+
home = identity.get("home") or ""
|
|
197
|
+
|
|
198
|
+
def _sub_path(match: re.Match) -> str:
|
|
199
|
+
path, tail = _trailing_punctuation(match.group(0))
|
|
200
|
+
replacement, redacted = _path_replacement(path, home)
|
|
201
|
+
if redacted:
|
|
202
|
+
counts["paths"] += 1
|
|
203
|
+
return replacement + tail
|
|
204
|
+
|
|
205
|
+
out = _WINDOWS_PATH.sub(_sub_path, out)
|
|
206
|
+
out = _POSIX_PATH.sub(_sub_path, out)
|
|
207
|
+
if len(home) > 1:
|
|
208
|
+
out = out.replace(home, "~")
|
|
209
|
+
|
|
210
|
+
# Letters-only boundaries, not \b: `\bname\b` misses "name_project" and
|
|
211
|
+
# "name2", because `_` and digits are word characters.
|
|
212
|
+
for name in sorted(set(identity.get("names") or []), key=len, reverse=True):
|
|
213
|
+
if len(name) < 3 or name.lower() in _COMMON_ACCOUNT_NAMES:
|
|
214
|
+
continue
|
|
215
|
+
pattern = re.compile(rf"(?<![A-Za-z]){re.escape(name)}(?![A-Za-z])", re.IGNORECASE)
|
|
216
|
+
out, n = pattern.subn("<user>", out)
|
|
217
|
+
counts["identity"] += n
|
|
218
|
+
return out, counts
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --------------------------------------------------------------------------
|
|
222
|
+
# Environment
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _connection_kind(handle: Any) -> str:
|
|
227
|
+
try:
|
|
228
|
+
from . import resolve_bridge_client
|
|
229
|
+
|
|
230
|
+
if isinstance(handle, resolve_bridge_client.BridgeProxy):
|
|
231
|
+
return "in-app bridge"
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
if os.environ.get("RESOLVE_SCRIPT_HOST"):
|
|
235
|
+
return "network scripting"
|
|
236
|
+
return "local scripting"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _os_description() -> str:
|
|
240
|
+
system = platform.system()
|
|
241
|
+
if system == "Darwin":
|
|
242
|
+
release = platform.mac_ver()[0] or platform.release()
|
|
243
|
+
return f"macOS {release} ({platform.machine()})"
|
|
244
|
+
if system == "Windows":
|
|
245
|
+
return f"Windows {platform.release()} ({platform.version()}, {platform.machine()})"
|
|
246
|
+
return f"{system} {platform.release()} ({platform.machine()})"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def collect_environment(resolve_handle: Any, mcp_version: str) -> Dict[str, str]:
|
|
250
|
+
"""What a maintainer needs to reproduce, read without connecting."""
|
|
251
|
+
env: Dict[str, str] = {"MCP server": mcp_version}
|
|
252
|
+
if resolve_handle is not None:
|
|
253
|
+
try:
|
|
254
|
+
env["DaVinci Resolve"] = (
|
|
255
|
+
f"{resolve_handle.GetProductName()} {resolve_handle.GetVersionString()}"
|
|
256
|
+
)
|
|
257
|
+
except Exception:
|
|
258
|
+
env["DaVinci Resolve"] = "connected, version unreadable"
|
|
259
|
+
env["Connection"] = _connection_kind(resolve_handle)
|
|
260
|
+
else:
|
|
261
|
+
env["DaVinci Resolve"] = "not connected"
|
|
262
|
+
env["Connection"] = "not connected"
|
|
263
|
+
try:
|
|
264
|
+
from . import resolve_runtime
|
|
265
|
+
|
|
266
|
+
mode = resolve_runtime.runtime_mode()
|
|
267
|
+
if not mode.get("running"):
|
|
268
|
+
env["Resolve process"] = "not running"
|
|
269
|
+
elif mode.get("headless") is True:
|
|
270
|
+
env["Resolve process"] = "running, headless (-nogui)"
|
|
271
|
+
elif mode.get("headless") is False:
|
|
272
|
+
env["Resolve process"] = "running, with UI"
|
|
273
|
+
else:
|
|
274
|
+
env["Resolve process"] = "running, mode unknown"
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
env["OS"] = _os_description()
|
|
278
|
+
env["Python"] = platform.python_version()
|
|
279
|
+
return env
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------
|
|
283
|
+
# Draft
|
|
284
|
+
# --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def normalize_kind(kind: Any) -> Optional[str]:
|
|
288
|
+
return _KIND_ALIASES.get(str(kind or "").strip().lower().replace("-", "_").replace(" ", "_"))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _clip(text: str, limit: int = MAX_FIELD_CHARS) -> str:
|
|
292
|
+
if len(text) <= limit:
|
|
293
|
+
return text
|
|
294
|
+
return text[:limit].rstrip() + "\n…(truncated)"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _steps_markdown(steps: Any) -> str:
|
|
298
|
+
if isinstance(steps, (list, tuple)):
|
|
299
|
+
return "\n".join(f"{i}. {s}" for i, s in enumerate(steps, 1) if str(s).strip())
|
|
300
|
+
return str(steps)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _fence(text: str) -> str:
|
|
304
|
+
fence = "```"
|
|
305
|
+
while fence in text:
|
|
306
|
+
fence += "`"
|
|
307
|
+
return f"{fence}\n{text}\n{fence}"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _environment_markdown(environment: Dict[str, str]) -> str:
|
|
311
|
+
rows = "\n".join(
|
|
312
|
+
f"| {key} | {str(value).replace('|', '/')} |" for key, value in environment.items()
|
|
313
|
+
)
|
|
314
|
+
return f"### Environment\n\n| | |\n|---|---|\n{rows}"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def issue_url(repo: str, kind: str, title: str, body: str) -> str:
|
|
318
|
+
query = urlencode(
|
|
319
|
+
{
|
|
320
|
+
"template": KINDS[kind]["template"],
|
|
321
|
+
"labels": KINDS[kind]["label"],
|
|
322
|
+
"title": title,
|
|
323
|
+
"body": body,
|
|
324
|
+
},
|
|
325
|
+
quote_via=quote,
|
|
326
|
+
)
|
|
327
|
+
return f"https://github.com/{repo}/issues/new?{query}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _fit_url(repo: str, kind: str, title: str, narrative: str, tail: str) -> Tuple[str, bool]:
|
|
331
|
+
"""Build the link, shortening only the narrative if the whole body won't fit.
|
|
332
|
+
|
|
333
|
+
The environment table and footer are the part a maintainer cannot ask the
|
|
334
|
+
reporter to reconstruct later, so they are never the part cut.
|
|
335
|
+
"""
|
|
336
|
+
url = issue_url(repo, kind, title, narrative + tail)
|
|
337
|
+
if len(url) <= MAX_URL_CHARS:
|
|
338
|
+
return url, False
|
|
339
|
+
lo, hi = 0, len(narrative)
|
|
340
|
+
best = issue_url(repo, kind, title, _TRUNCATION_NOTE.lstrip() + tail)
|
|
341
|
+
while lo <= hi:
|
|
342
|
+
mid = (lo + hi) // 2
|
|
343
|
+
candidate = issue_url(repo, kind, title, narrative[:mid].rstrip() + _TRUNCATION_NOTE + tail)
|
|
344
|
+
if len(candidate) <= MAX_URL_CHARS:
|
|
345
|
+
best, lo = candidate, mid + 1
|
|
346
|
+
else:
|
|
347
|
+
hi = mid - 1
|
|
348
|
+
return best, True
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def build_issue(
|
|
352
|
+
kind: str,
|
|
353
|
+
title: str,
|
|
354
|
+
summary: str,
|
|
355
|
+
*,
|
|
356
|
+
steps: Any = None,
|
|
357
|
+
expected: Any = None,
|
|
358
|
+
actual: Any = None,
|
|
359
|
+
error: Any = None,
|
|
360
|
+
tool: Any = None,
|
|
361
|
+
tool_action: Any = None,
|
|
362
|
+
use_case: Any = None,
|
|
363
|
+
proposal: Any = None,
|
|
364
|
+
environment: Optional[Dict[str, str]] = None,
|
|
365
|
+
identity: Optional[Dict[str, Any]] = None,
|
|
366
|
+
repo: str = DEFAULT_REPO,
|
|
367
|
+
) -> Dict[str, Any]:
|
|
368
|
+
"""Redact every field, lay out the issue, and build the prefilled link."""
|
|
369
|
+
identity = identity if identity is not None else local_identity()
|
|
370
|
+
totals = {"secrets": 0, "emails": 0, "paths": 0, "identity": 0}
|
|
371
|
+
|
|
372
|
+
def scrub(value: Any) -> str:
|
|
373
|
+
text, counts = redact(value, identity)
|
|
374
|
+
for key, n in counts.items():
|
|
375
|
+
totals[key] += n
|
|
376
|
+
return _clip(text.strip())
|
|
377
|
+
|
|
378
|
+
clean_title = scrub(title).replace("\n", " ")[:200]
|
|
379
|
+
sections: List[str] = []
|
|
380
|
+
|
|
381
|
+
def add(heading: str, value: Any, render=lambda s: s) -> None:
|
|
382
|
+
if value is None or (isinstance(value, (list, tuple)) and not value) or not str(value).strip():
|
|
383
|
+
return
|
|
384
|
+
if isinstance(value, (list, tuple)):
|
|
385
|
+
value = [scrub(v) for v in value]
|
|
386
|
+
sections.append(f"### {heading}\n\n{render(value)}")
|
|
387
|
+
else:
|
|
388
|
+
sections.append(f"### {heading}\n\n{render(scrub(value))}")
|
|
389
|
+
|
|
390
|
+
if kind == "bug":
|
|
391
|
+
add("What happened", summary)
|
|
392
|
+
add("Steps to reproduce", steps, _steps_markdown)
|
|
393
|
+
add("Expected", expected)
|
|
394
|
+
add("Actual", actual)
|
|
395
|
+
else:
|
|
396
|
+
add("What I'd like", summary)
|
|
397
|
+
add("What I was trying to do", use_case)
|
|
398
|
+
add("How it could work", proposal)
|
|
399
|
+
|
|
400
|
+
if tool or tool_action:
|
|
401
|
+
call = " → ".join(f"`{scrub(v)}`" for v in (tool, tool_action) if v)
|
|
402
|
+
sections.append(f"### {'Failing call' if kind == 'bug' else 'Related call'}\n\n{call}")
|
|
403
|
+
if error is not None and str(error).strip():
|
|
404
|
+
sections.append(_fence(scrub(error)))
|
|
405
|
+
|
|
406
|
+
narrative = "\n\n".join(sections)
|
|
407
|
+
tail = ""
|
|
408
|
+
if environment:
|
|
409
|
+
tail = "\n\n" + _environment_markdown(
|
|
410
|
+
{key: scrub(value) for key, value in environment.items()}
|
|
411
|
+
)
|
|
412
|
+
tail += _FOOTER
|
|
413
|
+
|
|
414
|
+
url, truncated = _fit_url(repo, kind, clean_title, narrative, tail)
|
|
415
|
+
return {
|
|
416
|
+
"kind": kind,
|
|
417
|
+
"repo": repo,
|
|
418
|
+
"title": clean_title,
|
|
419
|
+
"body": narrative + tail,
|
|
420
|
+
"labels": [KINDS[kind]["label"]],
|
|
421
|
+
"url": url,
|
|
422
|
+
"url_truncated": truncated,
|
|
423
|
+
"redactions": totals,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def next_step_guidance(truncated: bool) -> str:
|
|
428
|
+
text = (
|
|
429
|
+
"Nothing has been filed. Show the user the title and body above, then give "
|
|
430
|
+
"them the url: the issue is created only when they open it and press Submit "
|
|
431
|
+
"on GitHub under their own account (a GitHub account is required). "
|
|
432
|
+
"Redaction is best-effort — it removes paths, the local username, e-mail "
|
|
433
|
+
"addresses and secret-shaped strings, but cannot recognise a client, "
|
|
434
|
+
"project or person named in plain prose, so ask the user to check for "
|
|
435
|
+
"those before submitting."
|
|
436
|
+
)
|
|
437
|
+
if truncated:
|
|
438
|
+
text += (
|
|
439
|
+
" The body was too long for a link and the url carries a shortened "
|
|
440
|
+
"copy; tell the user to paste the full body from the chat into the "
|
|
441
|
+
"issue before submitting."
|
|
442
|
+
)
|
|
443
|
+
return text
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
__all__ = [
|
|
447
|
+
"KINDS",
|
|
448
|
+
"MAX_URL_CHARS",
|
|
449
|
+
"build_issue",
|
|
450
|
+
"collect_environment",
|
|
451
|
+
"issue_url",
|
|
452
|
+
"local_identity",
|
|
453
|
+
"next_step_guidance",
|
|
454
|
+
"normalize_kind",
|
|
455
|
+
"redact",
|
|
456
|
+
]
|