davinci-resolve-mcp 3.0.1 → 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 CHANGED
@@ -2,6 +2,56 @@
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
+
5
55
  ## What's New in v3.0.1 — project deletion is gated, and the open project is refused by default
6
56
 
7
57
  A security fix, published as [GHSA-gmp7-qjp9-m7gm](https://github.com/samuelgursky/davinci-resolve-mcp/security/advisories/GHSA-gmp7-qjp9-m7gm).
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-3.0.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-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
- [![Version](https://img.shields.io/badge/version-3.0.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-3.1.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(377%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v3.0.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "3.0.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "3.0.1"
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.0.1"
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
 
@@ -16619,6 +16623,23 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16619
16623
  Omit symbol for every recorded gate this build does not clear.
16620
16624
  verification_stats() -> {stats} — readback-verification tally
16621
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.
16622
16643
  job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
16623
16644
  — poll a background job started by a long op run with background=True
16624
16645
  (no connection needed). status is running, done, or error.
@@ -16751,6 +16772,48 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
16751
16772
  stats = _verification_stats()
16752
16773
  return {"stats": stats, "note": "Counts since server start. A rising "
16753
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
+ }
16754
16817
 
16755
16818
  # Background-job polling is a registry read — no Resolve connection needed.
16756
16819
  if action == "job_status":
@@ -17075,7 +17138,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17075
17138
  if err:
17076
17139
  return _err(err)
17077
17140
  return {"success": bool(r.ExportUserPreferencesPreset(clean["name"], clean["path"]))}
17078
- 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"])
17079
17142
 
17080
17143
 
17081
17144
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -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
+ ]