Skip to content

Add GitHub Issues storage backend for plans MCP - #1023

Merged
dobesv merged 1 commit into
mainfrom
mcp-plans-github
Jul 10, 2026
Merged

Add GitHub Issues storage backend for plans MCP#1023
dobesv merged 1 commit into
mainfrom
mcp-plans-github

Conversation

@dobesv

@dobesv dobesv commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Refactors the harnx-mcp-plans crate into a shared core library and adds a new GitHub Issues-backed storage provider. The refactoring extracts the PlanStore trait and common tool handlers into harnx-mcp-plans-core, ensuring consistent behavior across different storage backends.

The new harnx-mcp-plans-github crate implements the PlanStore trait using GitHub Issues, mapping plans to issues, tasks to sub-issues, and notes to comments. Metadata is persisted via YAML front-matter in issue bodies. Features include PAT and GitHub App authentication, rate-limit handling with backoff, background retention, and JIRA cross-referencing.

Includes a comprehensive conformance test suite used to validate both filesystem and GitHub backends.

Issue: #949

Summary by CodeRabbit

  • New Features
    • Added a GitHub Issues–backed MCP server for managing plans, tasks, and notes (CRUD with pagination and body editing).
    • Supports GitHub Personal Access Tokens and GitHub App authentication.
    • Added configurable CLI/environment settings, stdio or HTTP service modes, rate-limit handling, and scheduled stale-plan retention.
  • Documentation
    • Updated README and integration docs with setup, configuration, and backend behavior details (labels, front-matter, dependencies, JIRA keys).
  • Chores
    • Included the new server in release archives and extended Docker binary verification; added example configuration.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a reusable plans/tasks/notes storage core and generic MCP server, then implements a GitHub Issues-backed backend with authentication, rate limiting, encoding, configuration, runtime serving, tests, documentation, and release packaging for a new binary.

Changes

Shared PlanStore core

Layer / File(s) Summary
Core models, storage contract, and MCP server
crates/harnx-mcp-plans-core/...
Adds domain models, the async PlanStore API, MCP parameter schemas, CRUD handlers, body edits, validation, dispatch, and diff responses.
Conformance suite
crates/harnx-mcp-plans-core/src/conformance.rs
Validates CRUD, pagination, filtering, isolation, body persistence, deletion, and error contracts across PlanStore implementations.

GitHub Issues-backed backend

Layer / File(s) Summary
Authentication and configuration
crates/harnx-mcp-plans-github/src/auth.rs, src/config.rs
Adds PAT/App authentication, GitHub-origin detection, CLI/environment parsing, and startup configuration.
GitHub transport and rate limiting
crates/harnx-mcp-plans-github/src/ratelimit.rs, src/client.rs
Adds authenticated REST/GraphQL operations, pagination, response mapping, and bounded retry handling.
Issue and comment codec
crates/harnx-mcp-plans-github/src/codec.rs
Maps plans, tasks, and notes to YAML front-matter plus Markdown or comments, including JIRA keys, dependencies, and metadata updates.
GitHubPlanStore implementation
crates/harnx-mcp-plans-github/src/store_github.rs, src/store_github/tests.rs
Implements labeled plans, sub-issue tasks, comment notes, membership checks, deduplication, pagination, body persistence, configurable deletion, and mocked API tests.
Runtime and integration validation
crates/harnx-mcp-plans-github/src/runtime.rs, src/main.rs, src/lib.rs, tests/*
Adds stdio/HTTP serving, retention, shutdown handling, exported APIs, conformance tests, GitHub-specific tests, and gated live E2E tests.

Workspace and release integration

Layer / File(s) Summary
Workspace, packaging, and documentation
Cargo.toml, .github/workflows/release.yaml, README.md, docs/solutions/..., example_config/...
Registers crates and dependencies, builds and archives the new binary, verifies extracted binaries, and documents installation, configuration, and backend behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • dobesv/harnx#349 — Relates to MCP tool call_template rendering used by the shared server.
  • dobesv/harnx#556 — Relates to release workflow binary packaging and extraction verification.
  • dobesv/harnx#656 — Relates to newline and append-body formatting in plan/task/note tool templates.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a GitHub Issues storage backend for the plans MCP.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mcp-plans-github

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@mergify

mergify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (9)
docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md-200-203 (1)

200-203: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Label the fenced example as TOML.

Markdownlint flags this block as an unlabeled fence (MD040). Since the snippet is TOML, the opening fence should specify toml.

Suggested fix
-```
+```toml
 # Cargo.toml
 jsonwebtoken = { version = "10", features = ["aws_lc_rs", "use_pem"] }
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
@docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md
around lines 200 - 203, The fenced example in the markdown note is unlabeled and
should be marked as TOML to satisfy markdownlint. Update the code block around
the Cargo.toml snippet to use a TOML fence, keeping the content unchanged; this
should be done in the docs markdown where the example appears.


</details>

<!-- cr-comment:v1:21882a823715c76ec1b75cab -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>crates/harnx-mcp-plans-github/src/ratelimit.rs-110-136 (1)</summary><blockquote>

`110-136`: _🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_

**Transient backoff granularity is lost, defeating the 250 ms base.**

`transient_backoff` produces sub-second durations (base 250 ms), but `sleep_or_rate_limited` only accepts whole seconds and the call at Line 117 does `wait.as_secs().max(1)`. So the first retries (250 ms, 500 ms) all round to `1s`, and the configured `base_transient_backoff` has no observable effect below ~1 s. Consider passing a `Duration` for the transient path (keeping the whole-second cap comparison against `max_wait_secs`).



<details>
<summary>♻️ Sketch: sleep by Duration for transient path</summary>

```diff
-                    if is_transient(status) && transient_retries < self.config.max_transient_retries
-                    {
-                        let wait = transient_backoff(
-                            self.config.base_transient_backoff,
-                            transient_retries,
-                        );
-                        transient_retries += 1;
-                        self.sleep_or_rate_limited(wait.as_secs().max(1)).await?;
-                        continue;
-                    }
+                    if is_transient(status) && transient_retries < self.config.max_transient_retries
+                    {
+                        let wait = transient_backoff(
+                            self.config.base_transient_backoff,
+                            transient_retries,
+                        );
+                        transient_retries += 1;
+                        if wait.as_secs() > self.config.max_wait_secs {
+                            return Err(StoreError::RateLimited {
+                                retry_after_secs: wait.as_secs().max(1),
+                            }
+                            .into());
+                        }
+                        self.sleeper.sleep(wait).await;
+                        continue;
+                    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/ratelimit.rs` around lines 110 - 136,
Transient retries are losing sub-second precision because the transient path in
the ratelimit logic rounds `transient_backoff` down to whole seconds before
calling `sleep_or_rate_limited`. Update the retry flow in `ratelimit.rs` so
`transient_backoff` is passed through as a `Duration` for transient sleeps,
while keeping the existing `max_wait_secs` limit check for rate-limited waits.
Adjust `sleep_or_rate_limited` (and its call sites in the retry loop) to
preserve the 250 ms base and only cap or reject excessive waits, rather than
forcing `wait.as_secs().max(1)`.
crates/harnx-mcp-plans-github/src/codec.rs-87-122 (1)

87-122: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

TaskFrontMatter::default().status diverges from the intended "open" default.

status uses #[serde(default = "default_status")], which only applies to missing-field deserialization — the derived Default impl (used as the fallback in parse_frontmatter on malformed YAML, line 682) still yields status: String::default() (i.e. ""), not "open". A task with malformed front-matter would silently decode with an empty status instead of the intended default.

🐛 Proposed fix
-#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "snake_case")]
 pub struct TaskFrontMatter {
     ...
 }
+
+impl Default for TaskFrontMatter {
+    fn default() -> Self {
+        Self {
+            client_id: None,
+            jira_key: None,
+            summary: None,
+            author: None,
+            assignee: None,
+            executor: None,
+            tags: Vec::new(),
+            status: default_status(),
+            dependencies: Vec::new(),
+            created_at: String::new(),
+            updated_at: None,
+        }
+    }
+}

Also applies to: 144-146, 666-685

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/codec.rs` around lines 87 - 122,
TaskFrontMatter::default() does not match the intended default status, so
malformed front-matter can fall back to an empty status instead of "open".
Update the Default behavior for TaskFrontMatter (or the fallback path used by
parse_frontmatter) so status is initialized via default_status, and make sure
the same "open" default is preserved when serde deserialization fails. Verify
the struct and the parse_frontmatter fallback both use the same default source
for status.
crates/harnx-mcp-plans-github/src/store_github.rs-478-503 (1)

478-503: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Thread jira_key through the public plan/task API. add_plan/add_task always encode with None, and PlanMetaUpdate/TaskMetaUpdate don’t expose a jira_key field, so the store can only preserve an existing key from title/front-matter — it can’t set or change one through the API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/store_github.rs` around lines 478 - 503,
The public plan/task API is not carrying jira_key through, so add it to the
plan/task metadata types and plumbing used by add_plan and add_task. Update
PlanMetaUpdate and TaskMetaUpdate to expose jira_key, pass it into
new_plan_to_issue/new_task_to_issue instead of always using None, and make sure
the corresponding conversion/build paths preserve and allow updating the key
consistently.
crates/harnx-mcp-plans-core/src/server/handlers.rs-283-293 (1)

283-293: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

AlreadyExists error messages show wrong identifiers.

In handle_add_task (line 287), the AlreadyExists error calls display_id(&body) where body is the task body text — not the task ID. The error message would display body content instead of the actual task ID. In handle_add_note (line 814), the same error uses a hardcoded empty string "" for the note ID, making the message unhelpful when the user supplied a custom ID.

In both cases, the ID is consumed by the NewTask/NewNote constructor before the error handler runs. The fix is to extract the display ID before moving the struct into the store call.

🐛 Proposed fix for handle_add_task
         let body = params.body.unwrap_or_default();
+        let task_id = id.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()[..8].to_string());
+        let task_id_display = display_id(&task_id);
         let new_task = NewTask {
-            id: id.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()[..8].to_string()),
+            id: task_id,
             title: params.title,
             summary: params.summary,
             author: params.author,
             assignee: params.assignee,
             executor: params.executor,
             tags: params.tags,
             status: params.status,
             dependencies: params.dependencies,
         };
         let task = self
             .store
             .add_task(&plan_id, new_task)
             .await
             .map_err(|err| match err {
                 StoreError::AlreadyExists => ErrorData::invalid_params(
                     format!(
                         "task '{}' already exists in plan '{}'",
-                        display_id(&body),
+                        task_id_display,
                         plan_name
                     ),
                     None,
                 ),
                 other => store_error_to_error_data(other),
             })?;

Proposed fix for handle_add_note

+        let note_id = id.unwrap_or_else(|| {
+            uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
+        });
+        let note_id_display = display_note_id(&note_id);
         let note = self
             .store
             .add_note(
                 &plan_id,
                 NewNote {
-                    id: id.unwrap_or_else(|| {
-                        uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
-                    }),
+                    id: note_id,
                     summary: params.summary,
                     author: params.author,
                 },
             )
             .await
             .map_err(|err| match err {
                 StoreError::AlreadyExists => ErrorData::invalid_params(
-                    format!("note '{}' already exists in plan '{}'", "", plan),
+                    format!("note '{}' already exists in plan '{}'", note_id_display, plan),
                     None,
                 ),
                 other => store_error_to_error_data(other),
             })?;

Also applies to: 812-818

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/server/handlers.rs` around lines 283 - 293,
The AlreadyExists handling in handle_add_task and handle_add_note is using the
wrong identifier because the task/note input is moved into the NewTask/NewNote
constructor before the error mapping runs. Capture the display ID from the
incoming body before passing it to the store call, then use that saved ID in the
StoreError::AlreadyExists branch instead of display_id(&body) or a hardcoded
empty string. Keep the fix localized around handle_add_task and handle_add_note
so the invalid_params message shows the actual user-supplied task/note ID.
crates/harnx-mcp-plans-core/src/server/handlers.rs-572-576 (1)

572-576: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

read_plan_body errors are silently swallowed in handle_update_plan.

unwrap_or_default() discards all errors from read_plan_body, including transient Backend failures. If the read fails, the body is treated as empty, and the subsequent write_plan_body overwrites the existing content with the edit applied to an empty string — potentially causing data loss.

Only NotFound should be swallowed (the plan may have been just created with an empty body); other errors should propagate.

🛡️ Proposed fix
-        let before_body = self
-            .store
-            .read_plan_body(&plan_id)
-            .await
-            .unwrap_or_default();
+        let before_body = match self.store.read_plan_body(&plan_id).await {
+            Ok(body) => body,
+            Err(StoreError::NotFound) => String::new(),
+            Err(err) => return Err(store_error_to_error_data(err)),
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/server/handlers.rs` around lines 572 - 576,
The `handle_update_plan` flow is swallowing all `read_plan_body` failures via
`unwrap_or_default()`, which can treat backend errors as an empty body and later
overwrite content incorrectly. Update the `before_body` read in
`handle_update_plan` to handle `read_plan_body` errors explicitly: allow only
`NotFound` to fall back to an empty string, and propagate any other error from
`self.store.read_plan_body(&plan_id).await` instead of defaulting. Keep the
existing `write_plan_body` logic unchanged so it only runs with a valid
`before_body`.
crates/harnx-mcp-plans-core/src/conformance.rs-514-541 (1)

514-541: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter tests pass vacuously on empty results.

assert!(open_tasks.items.iter().all(|t| t.status == "open")) returns true when items is empty, so the test never verifies that matching tasks are actually returned. The same applies to the tag filter assertion. A backend that silently returns zero items for every filter would pass.

🛡️ Proposed fix
     let open_tasks = store
         .list_tasks(
             &plan.id,
             TaskFilter {
                 status: Some("open".to_string()),
                 tag: None,
             },
             None,
         )
         .await
         .expect("list_tasks with status filter should succeed");
+    assert!(
+        open_tasks.items.iter().any(|t| t.status == "open"),
+        "status filter should return at least one matching task"
+    );
     assert!(open_tasks.items.iter().all(|t| t.status == "open"));

     let backend_tasks = store
         .list_tasks(
             &plan.id,
             TaskFilter {
                 status: None,
                 tag: Some("backend".to_string()),
             },
             None,
         )
         .await
         .expect("list_tasks with tag filter should succeed");
+    assert!(
+        backend_tasks
+            .items
+            .iter()
+            .any(|t| t.tags.contains(&"backend".to_string())),
+        "tag filter should return at least one matching task"
+    );
     assert!(backend_tasks
         .items
         .iter()
         .all(|t| t.tags.contains(&"backend".to_string())));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/conformance.rs` around lines 514 - 541, The
`list_tasks` filter checks in `conformance.rs` are vacuously true on empty
შედეგs, so they don’t verify that matching tasks are actually returned. Update
the `open_tasks` and `backend_tasks` assertions to first confirm the filtered
result sets are non-empty, then keep the existing per-item checks on
`Task.status` and `Task.tags` so `store.list_tasks`, `TaskFilter`, and the
related conformance tests fail if the backend returns zero matches.
crates/harnx-mcp-plans-core/src/conformance.rs-54-58 (1)

54-58: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc example for BackendCapabilities is missing rejects_invalid_create_ids.

The module doc example shows only two fields, but the actual struct at lines 70–75 has three. Anyone copying this example would get a compile error.

📝 Proposed fix
 //! run_conformance(
 //!     store,
 //!     BackendCapabilities {
 //!         preserves_client_id: true,
 //!         deletes_permanently: true,
+//!         rejects_invalid_create_ids: true,
 //!     },
 //! )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/conformance.rs` around lines 54 - 58, The
module doc example for BackendCapabilities is incomplete because it omits the
rejects_invalid_create_ids field shown by the actual BackendCapabilities struct.
Update the example in conformance.rs where BackendCapabilities is constructed so
it includes all current fields, including rejects_invalid_create_ids, and keep
the example consistent with the struct definition to avoid copy-paste compile
errors.
crates/harnx-mcp-plans-github/src/runtime.rs-254-266 (1)

254-266: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Retention is coupled to delete behavior. close_stale_plan returns early when delete_is_close is false, so --delete-behavior leave also disables stale-plan cleanup. If that’s intentional, document it in the Retention section; otherwise give retention its own toggle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/runtime.rs` around lines 254 - 266, The
stale-plan cleanup logic in close_stale_plan is currently gated by
delete_is_close, so retention behavior is unintentionally tied to delete
behavior. Update the GitHubPlanStore config path used by close_stale_plan so
stale-plan cleanup has its own explicit toggle, or if the coupling is intended,
document that relationship in the Retention section; use the close_stale_plan
function and config_ref access as the main places to adjust.
🧹 Nitpick comments (20)
crates/harnx-mcp-plans-github/src/client.rs (2)

459-485: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Flatten ensure_label to clear the CodeScene critical gate.

CI reports "Bumpy Road Ahead" for the nested match/if/match here. The logic is a get-then-create with two "already exists" tolerances; extracting the not-found and already-exists checks into small helpers (e.g. is_not_found(&err), is_already_exists(&err)) and using early returns would flatten the nesting and satisfy the gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/client.rs` around lines 459 - 485, The
ensure_label flow is too deeply nested, triggering the CodeScene gate; flatten
the get-then-create logic in client.rs by extracting the error-classification
checks into small helpers such as is_not_found and is_already_exists, then use
early returns in ensure_label instead of nested match/if blocks. Keep the
existing behavior for get_label and create_label, but make the control flow
linear by returning immediately on non-404 errors and tolerating the
already-exists create cases through the helpers.

Source: Pipeline failures


632-662: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Path segments and query values are not percent-encoded.

encode_path_segment only replaces /, and with_query interpolates values verbatim. Any label containing a space or reserved character (GitHub's own defaults include help wanted and good first issue) produces an invalid URL in get_label and in the labels filter of list_issues. Use a real percent-encoding routine (e.g. percent-encoding, or reqwest::Url query builder) for both paths and query values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/client.rs` around lines 632 - 662, Path
segments and query values in client.rs are being built without proper
percent-encoding, so endpoints like get_label and list_issues can produce
invalid URLs for labels with spaces or reserved characters. Update
encode_path_segment and with_query to use a real URL encoding approach (such as
a percent-encoding helper or reqwest::Url query handling) instead of manual
string replacement/interpolation. Make sure the fix is applied wherever label
names or other dynamic path/query values flow through absolute_url,
encode_path_segment, normalize_issue_state, and with_query.
crates/harnx-mcp-plans-github/src/ratelimit.rs (1)

286-390: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce the duplicated request-closure across tests (CI duplication gate).

retries_on_retry_after_then_succeeds, retries_on_reset_header_then_succeeds, and returns_rate_limited_when_wait_exceeds_threshold repeat the same send_rate_limited(..., || { client.clone(); url.clone(); async move { client.get(url).send().await.map_err(Into::into) } }) block. Extracting a small helper (e.g. send_get(&executor, &url)) would satisfy the CodeScene duplication advisory and shorten the tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/ratelimit.rs` around lines 286 - 390, The
three rate-limit tests duplicate the same GET request closure passed to
send_rate_limited, so extract that repeated logic into a small helper tied to
send_rate_limited and RequestContext::new, such as a reusable send_get helper
that takes the executor and URL. Update retries_on_retry_after_then_succeeds,
retries_on_reset_header_then_succeeds, and
returns_rate_limited_when_wait_exceeds_threshold to call the helper instead of
inlining the client.clone/url.clone async block.

Source: Pipeline failures

crates/harnx-mcp-plans-github/src/store_github/tests.rs (1)

939-956: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Codec-only test placed in the store's integration test module.

write_task_body_does_not_corrupt_front_matter exercises crate::codec::issue_to_task/task_meta_update_to_issue_body directly with no wiremock server usage; it would fit better alongside the other codec round-trip tests in codec.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/store_github/tests.rs` around lines 939 -
956, The test write_task_body_does_not_corrupt_front_matter is codec-only and
does not use any store_github or wiremock behavior, so move it out of the store
integration test module into the codec test area. Keep the assertions around
issue_to_task and task_meta_update_to_issue_body together with the other codec
round-trip coverage in codec.rs so the test lives with the symbols it exercises.
crates/harnx-mcp-plans-github/src/codec.rs (1)

724-728: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead code: rfc3339_to_timestamp is unused outside tests.

Marked #[allow(dead_code)] — either wire it into the store's error-mapping/decode path or drop it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/codec.rs` around lines 724 - 728, The
helper rfc3339_to_timestamp in codec.rs is only used by tests and is currently
suppressed with #[allow(dead_code)]. Either integrate it into the production
decode/error-mapping flow in the store path where RFC3339 timestamps are
converted to jiff::Timestamp, or remove the function entirely if it is no longer
needed. If you keep it, reference the existing Timestamp parsing logic so the
conversion happens through the same codec path instead of remaining test-only.
crates/harnx-mcp-plans-github/src/store_github.rs (2)

399-414: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

Fragile substring-based error classification.

map_github_error classifies errors by lowercasing the anyhow Display string and checking for substrings like "404", "422", "rate limit". Any change in wording from the client/HTTP layer (or a URL/body that happens to contain "404") silently miscategorizes errors as generic Backend, changing retry/observability behavior for callers.

Consider having GitHubClient return a typed error (e.g., an enum carrying the HTTP status code) instead of stringified anyhow::Error, so this mapping can match on structured data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/store_github.rs` around lines 399 - 414,
The error classification in map_github_error is too dependent on lowercased
display strings and substring checks, which can misroute GitHub failures. Update
GitHubClient and the error flow so the mapper receives structured error data
(such as a typed enum or explicit HTTP status code) and switch map_github_error
to match on those fields instead of parsing anyhow::Error text.

586-637: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Sub-issue JSON parsed as untyped serde_json::Value instead of a typed struct.

list_tasks (and find_task_membership) manually pull fields (number, title, body, created_at, updated_at) out of raw serde_json::Value via .get(...), unlike the typed IssueRecord/IssueComment used elsewhere in this file. A typed SubIssueRecord struct with #[serde(deserialize_with = ...)] as needed would reduce the risk of typos in field names silently producing None/skipped items.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/store_github.rs` around lines 586 - 637,
The sub-issue parsing in list_tasks is using raw serde_json::Value field lookups
instead of a typed record, which is fragile. Introduce a typed SubIssueRecord
(similar to IssueRecord/IssueComment in this module) and deserialize the GitHub
sub-issue payload into it in list_tasks and find_task_membership, using serde
helpers where needed for timestamps. Then update the task mapping to read fields
from the typed struct instead of .get(...) chains so missing or renamed fields
are caught by deserialization.
crates/harnx-mcp-plans-core/src/server/handlers.rs (2)

456-484: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared task-creation logic to reduce duplication.

The batch task creation loop (build_new_taskadd_taskwrite_task_body → collect successes/failures) is duplicated between handle_add_plan and handle_update_plan. CodeScene flagged this as code duplication. Extracting a helper would keep the two paths consistent and reduce maintenance burden.

♻️ Proposed refactor
+async fn create_tasks_batch<S: PlanStore>(
+    store: &S,
+    plan_id: &PlanId,
+    name: &str,
+    specs: &[TaskSpec],
+) -> (Vec<String>, Vec<String>) {
+    let mut created_ids = Vec::new();
+    let mut failures = Vec::new();
+    for spec in specs {
+        match build_new_task(name, spec) {
+            Ok((new_task, task_body)) => match store.add_task(plan_id, new_task).await {
+                Ok(task) => {
+                    if let Err(err) = store.write_task_body(plan_id, &task.id, &task_body).await {
+                        failures.push(format!(
+                            "{}: {}",
+                            display_id(&task.id),
+                            store_error_to_error_data(err).message
+                        ));
+                    } else {
+                        created_ids.push(display_id(&task.id));
+                    }
+                }
+                Err(StoreError::AlreadyExists) => {
+                    failures.push(format!("task already exists in plan '{}'", name))
+                }
+                Err(err) => {
+                    failures.push(store_error_to_error_data(err).message.to_string())
+                }
+            },
+            Err(err) => failures.push(err.message.to_string()),
+        }
+    }
+    (created_ids, failures)
+}

Then in both handle_add_plan and handle_update_plan, replace the loop with:

-        let mut created_task_ids = Vec::new();
-        let mut task_failures = Vec::new();
-        for spec in task_specs {
-            match build_new_task(&name, spec) {
-                Ok((new_task, task_body)) => match self.store.add_task(&plan_id, new_task).await {
-                    Ok(task) => {
-                        if let Err(err) = self
-                            .store
-                            .write_task_body(&plan_id, &task.id, &task_body)
-                            .await
-                        {
-                            task_failures.push(format!(
-                                "{}: {}",
-                                display_id(&task.id),
-                                store_error_to_error_data(err).message
-                            ));
-                        } else {
-                            created_task_ids.push(display_id(&task.id));
-                        }
-                    }
-                    Err(err) => {
-                        task_failures.push(store_error_to_error_data(err).message.to_string())
-                    }
-                },
-                Err(err) => task_failures.push(err.message.to_string()),
-            }
-        }
+        let (created_task_ids, task_failures) =
+            create_tasks_batch(self.store.as_ref(), &plan_id, &name, &task_specs).await;

Note: handle_add_plan's version is slightly different — it doesn't have the explicit StoreError::AlreadyExists arm. The extracted helper uses the handle_update_plan version (with the explicit arm), which is the more correct behavior.

Also applies to: 618-649

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/server/handlers.rs` around lines 456 - 484,
The batch task-creation flow is duplicated in handle_add_plan and
handle_update_plan, so extract the shared build_new_task → add_task →
write_task_body → collect successes/failures logic into a helper. Reuse that
helper from both handlers, keeping the handle_update_plan behavior as the source
of truth, including the explicit StoreError::AlreadyExists handling. Make sure
the helper preserves the existing created_task_ids and task_failures reporting
semantics and is easy to locate via build_new_task, add_task, write_task_body,
handle_add_plan, and handle_update_plan.

397-422: 🚀 Performance & Scalability | 🔵 Trivial

N+1 store calls in handle_list_plans.

For each plan, list_tasks and list_notes are called separately to compute counts. With the GitHub backend, this means 2N API requests for N plans, each subject to rate limiting. Consider adding a count_tasks/count_notes method or including counts in the Plan model / list_plans result to avoid per-plan round-trips.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/server/handlers.rs` around lines 397 - 422,
`handle_list_plans` is doing per-plan `list_tasks` and `list_notes` calls,
creating an N+1 request pattern. Update the `handle_list_plans` flow to avoid
counting via per-item round trips by adding bulk count support in the store
layer (for example `count_tasks`/`count_notes`) or by returning counts from
`list_plans`/the `Plan` model itself. Then use those precomputed counts when
building each entry with `plan_summary_json`.
crates/harnx-mcp-plans-core/src/server/mod.rs (1)

144-150: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove dead result_with_diff duplicate.

This result_with_diff (which wraps the diff in a markdown code fence) is marked #[allow(dead_code)] because handlers.rs defines its own result_with_diff (without the code fence) that shadows it via use super::*. Having two functions with the same name and different behavior is confusing for future maintainers.

♻️ Proposed fix
-#[allow(dead_code)]
-fn result_with_diff(summary: String, diff: String) -> Result<CallToolResult, ErrorData> {
-    if diff.is_empty() {
-        return result_text(summary);
-    }
-    result_text(format!("{summary}\n\n```diff\n{diff}\n```"))
-}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/server/mod.rs` around lines 144 - 150, Remove
the duplicate dead result_with_diff helper in server/mod.rs and keep only the
version actually used by handlers.rs via use super::*. The existing
#[allow(dead_code)] function that wraps diff output in a markdown code fence
should be deleted, and any remaining call sites should continue to use the
shared result_with_diff behavior from the module so there is a single source of
truth.
crates/harnx-mcp-plans-core/src/conformance.rs (2)

338-365: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

test_plan_duplicate_id and test_already_exists_errors are functionally identical.

Both create a plan, attempt a duplicate, assert AlreadyExists if it errors, and clean up. The only difference is the plan ID string. Consider removing one or merging them to reduce duplication flagged by CodeScene.

♻️ Proposed consolidation

Remove test_plan_duplicate_id (lines 338–365) and keep test_already_exists_errors (lines 941–968), which already covers the same contract. Then remove the call at line 89:

     test_plan_list_pagination(&store).await;
-    test_plan_duplicate_id(&store).await;

Also applies to: 941-968

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/conformance.rs` around lines 338 - 365,
`test_plan_duplicate_id` duplicates the behavior already covered by
`test_already_exists_errors`; remove the redundant test and keep the existing
one that asserts `StoreError::AlreadyExists` on duplicate `add_plan` calls.
Update the test suite wiring in the conformance module so only the remaining
`test_already_exists_errors` path is invoked, and keep the cleanup logic in that
test intact.

288-336: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

CodeScene flags "Bumpy Road Ahead" and Code Duplication in pagination tests.

The pagination verification pattern (fetch page 1, fetch page 2, assert no ID overlap) is repeated across test_plan_list_pagination, test_task_list_pagination, and test_note_list_pagination. Extracting a helper would reduce both the nesting depth and the duplication.

♻️ Proposed helper extraction
+async fn assert_pagination_no_overlap<S: PlanStore, T>(
+    store: &Arc<S>,
+    fetch_page: impl Fn(&Arc<S>, Option<PageToken>) -> Result<Page<T>, StoreError>,
+) where
+    T: AsRef<str>,
+{
+    let page1 = fetch_page(store, None).await.expect("first page should succeed");
+    assert!(!page1.items.is_empty(), "page 1 should have items");
+    if let Some(next) = page1.next {
+        let page2 = fetch_page(store, Some(next))
+            .await
+            .expect("page 2 should succeed");
+        let page1_ids: std::collections::HashSet<_> =
+            page1.items.iter().map(|item| item.as_ref()).collect();
+        for item in &page2.items {
+            assert!(
+                !page1_ids.contains(item.as_ref()),
+                "pagination pages should not overlap"
+            );
+        }
+    }
+}
+
 async fn test_plan_list_pagination<S: PlanStore>(store: &Arc<S>) {

Then each pagination test body simplifies to a single helper call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-core/src/conformance.rs` around lines 288 - 336, The
pagination assertion logic is duplicated across the list pagination tests,
including test_plan_list_pagination, test_task_list_pagination, and
test_note_list_pagination. Extract the repeated “fetch first page, fetch next
page, assert no item ID overlap” flow into a shared helper and have
test_plan_list_pagination call it, so the test bodies stay shallow and
consistent.

Source: Pipeline failures

crates/harnx-mcp-plans/src/store_fs.rs (1)

119-263: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer anyhow::Result over Result<T, String> for internal helpers.

validate_plan_name, validate_id, serialize_task/parse_task_frontmatter, serialize_plan/parse_plan_frontmatter, serialize_note/parse_note_frontmatter, write_task, write_plan_file, and write_note all return Result<T, String>, discarding error context/source chains that anyhow::Error would preserve. Call sites already wrap these into StoreError::Backend(anyhow::anyhow!(err)), so switching the helpers themselves to anyhow::Result<T> (using .context(...) instead of .map_err(|e| e.to_string())) would be more idiomatic and preserve better diagnostics.

As per coding guidelines, "Use anyhow::Result and anyhow::bail! for error handling throughout the Rust codebase."

♻️ Example refactor for one helper
-pub(crate) fn validate_plan_name(name: &str) -> Result<String, String> {
+pub(crate) fn validate_plan_name(name: &str) -> anyhow::Result<String> {
     let normalized = normalize_plan_name(name);
     if normalized.is_empty() {
-        return Err("plan name must not be empty".to_string());
+        anyhow::bail!("plan name must not be empty");
     }
     if normalized.contains('/') || normalized.contains('\\') || normalized.contains("..") {
-        return Err(format!(
+        anyhow::bail!(
             "plan name '{}' must not contain path separators or '..'",
             name
-        ));
+        );
     }
     Ok(normalized)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans/src/store_fs.rs` around lines 119 - 263, The helpers
in store_fs currently erase error context by returning Result<T, String>; update
validate_plan_name, validate_id, serialize_task/parse_task_frontmatter,
serialize_plan/parse_plan_frontmatter, serialize_note/parse_note_frontmatter,
write_task, write_plan_file, and write_note to use anyhow::Result instead.
Replace manual map_err(|err| err.to_string()) conversions with anyhow
context/bail handling so serde_yaml and std::fs failures preserve source
details, and keep the call sites compatible with StoreError::Backend.

Source: Coding guidelines

crates/harnx-mcp-plans-github/src/runtime.rs (1)

107-112: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Regex recompiled on every error.

redact_bearer_tokens compiles a new Regex on each call. Since it's only on error paths this is low-impact, but a std::sync::OnceLock/once_cell::sync::Lazy cached regex would avoid the repeated compilation cost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/runtime.rs` around lines 107 - 112, The
redact_bearer_tokens helper recompiles the same regex on every call, so cache it
instead of creating a new Regex each time. Update redact_bearer_tokens in
runtime.rs to use a shared OnceLock or once_cell::sync::Lazy for the compiled
pattern, and keep the replacement behavior unchanged.
crates/harnx-mcp-plans-github/tests/conformance_github.rs (1)

257-525: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

mount_mock_handlers flagged as Large Method by CodeScene.

The function registers ~10 independent wiremock endpoints sequentially. Splitting into per-resource helpers (e.g. mount_issue_endpoints, mount_comment_endpoints, mount_sub_issue_endpoints) would shrink it and address the advisory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/conformance_github.rs` around lines 257 -
525, `mount_mock_handlers` is doing too much by registering many unrelated
WireMock routes in one large method. Refactor it by extracting the endpoint
setup into smaller helper functions in the same test module, grouped by concern
such as `mount_issue_endpoints`, `mount_comment_endpoints`, and
`mount_sub_issue_endpoints`, and have `mount_mock_handlers` just orchestrate
those calls while preserving the existing behavior and shared `state` handling.

Source: Pipeline failures

crates/harnx-mcp-plans-github/tests/github_specific.rs (2)

431-466: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

jira_key_round_trips_in_title doesn't actually assert a round-trip.

The test creates a plan and only asserts !plan.id.is_empty(). It never checks that the JIRA key/title prefix was preserved (e.g. via plan.title/a jira_key field), so it wouldn't fail if JIRA round-tripping regressed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/github_specific.rs` around lines 431 -
466, The jira_key_round_trips_in_title test is too weak because it only checks
that add_plan returns a non-empty id and never verifies the JIRA prefix survived
the round-trip. Update this test in github_specific.rs to assert the created
plan returned by store.add_plan(NewPlan) preserves the expected title and/or
jira_key value from the mocked GitHub issue response. Use the jira_key and title
fields on the plan object (or whatever accessor is used in this test suite) so
the test fails if encoding/decoding of the JIRA key regresses.

24-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Substantial boilerplate duplication (matches the CodeScene "Code Duplication" gate).

create_test_store, mock_issue_response, and mock_plan_issue_response are duplicated near-verbatim in conformance_github.rs (and similar patterns recur across nearly every #[tokio::test] in this file: plan-issue GET mock + sub_issues GET mock + PATCH mock). Extracting these into a shared tests/common.rs (or a #[cfg(test)] support module reused via include!/a dev-dependency path) would remove the duplication CodeScene is flagging and make future test additions cheaper.

Also applies to: 45-78

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/github_specific.rs` around lines 24 - 40,
Extract the duplicated test setup into a shared helper module so the GitHub test
cases stop repeating near-identical boilerplate. Move create_test_store,
mock_issue_response, and mock_plan_issue_response into a reusable test support
location (for example a common test module under cfg(test)) and update the tests
in github_specific.rs to call those helpers instead of inlining the setup. Keep
the helpers named so they are easy to reuse across conformance_github.rs and the
other #[tokio::test] cases that set up the plan-issue GET, sub_issues GET, and
PATCH mocks.

Source: Pipeline failures

crates/harnx-mcp-plans-github/src/config.rs (1)

28-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

parse_from is flagged as a Complex Method by CodeScene.

The function mixes CLI-flag scanning, env fallbacks, per-field validation, and struct construction in one ~130-line body — matching the pipeline's "Complex Method" advisory. Consider extracting the while loop into a small RawArgs struct/parser and moving each field's env/CLI resolution (auth source, retention, plan label, delete behavior, rate limit) into dedicated helper functions, mirroring what's already done for app_auth_from_env.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/config.rs` around lines 28 - 160, The
parse_from function is too complex because it combines argument scanning,
environment fallback logic, validation, and final config assembly in one body.
Refactor it by extracting the CLI parsing loop into a small RawArgs-style parser
and moving each resolution step into focused helpers, especially for auth
source, retention_days, plan_label, delete_is_close, and max_wait_secs, similar
to app_auth_from_env. Keep parse_from as a thin orchestration method that calls
those helpers and then builds Self.

Source: Pipeline failures

crates/harnx-mcp-plans-github/README.md (1)

50-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Retention section doesn't mention the delete-behavior coupling.

Per the runtime.rs review, the retention pass only closes stale plans when delete_is_close/--delete-behavior is close. This isn't mentioned here — worth a note so operators using --delete-behavior leave know retention closing is also disabled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/README.md` around lines 50 - 51, The Retention
section is missing the coupling to delete behavior, so update the README text
near the Retention description to note that the background closing pass only
runs when `delete_is_close`/`--delete-behavior` is set to close. Mention that
with `--delete-behavior leave`, stale plans are not closed by retention, and
reference the existing retention logic in `runtime.rs` so operators can discover
the behavior from the documentation.
crates/harnx-mcp-plans-github/tests/live_e2e.rs (1)

96-375: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Repeated store/plan/task/note setup-and-teardown across the five live tests (CodeScene Code Duplication).

Each #[tokio::test] repeats: obtain store via create_live_store, create a plan (and often a task/note), then manually clean up on both success and error paths. A shared LiveTestContext helper (create-on-setup, Drop-based or explicit teardown) would remove this duplication and reduce the risk of a cleanup step being skipped on a new test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/live_e2e.rs` around lines 96 - 375, The
five live_e2e_* tests duplicate the same store initialization, plan creation,
and cleanup logic. Extract that setup/teardown into a shared helper such as a
LiveTestContext around create_live_store, unique_plan_id, and the delete_*
cleanup paths so the tests only describe their specific assertions. Reuse the
helper from live_e2e_create_plan, live_e2e_create_task, live_e2e_create_note,
live_e2e_pagination, and live_e2e_full_crud_cycle to remove repeated boilerplate
and centralize teardown.

Source: Pipeline failures


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6487ebd9-93c6-44ff-980e-25df8c48dda5

📥 Commits

Reviewing files that changed from the base of the PR and between 7e26a54 and 3d5ccd4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • .github/workflows/release.yaml
  • Cargo.toml
  • README.md
  • crates/harnx-mcp-plans-core/Cargo.toml
  • crates/harnx-mcp-plans-core/src/conformance.rs
  • crates/harnx-mcp-plans-core/src/lib.rs
  • crates/harnx-mcp-plans-core/src/model.rs
  • crates/harnx-mcp-plans-core/src/server/handler.rs
  • crates/harnx-mcp-plans-core/src/server/handlers.rs
  • crates/harnx-mcp-plans-core/src/server/mod.rs
  • crates/harnx-mcp-plans-core/src/server/params.rs
  • crates/harnx-mcp-plans-core/src/store.rs
  • crates/harnx-mcp-plans-github/.gitignore
  • crates/harnx-mcp-plans-github/Cargo.toml
  • crates/harnx-mcp-plans-github/README.md
  • crates/harnx-mcp-plans-github/src/auth.rs
  • crates/harnx-mcp-plans-github/src/client.rs
  • crates/harnx-mcp-plans-github/src/codec.rs
  • crates/harnx-mcp-plans-github/src/config.rs
  • crates/harnx-mcp-plans-github/src/lib.rs
  • crates/harnx-mcp-plans-github/src/main.rs
  • crates/harnx-mcp-plans-github/src/ratelimit.rs
  • crates/harnx-mcp-plans-github/src/runtime.rs
  • crates/harnx-mcp-plans-github/src/store_github.rs
  • crates/harnx-mcp-plans-github/src/store_github/tests.rs
  • crates/harnx-mcp-plans-github/tests/conformance_github.rs
  • crates/harnx-mcp-plans-github/tests/github_specific.rs
  • crates/harnx-mcp-plans-github/tests/live_e2e.rs
  • crates/harnx-mcp-plans/Cargo.toml
  • crates/harnx-mcp-plans/src/lib.rs
  • crates/harnx-mcp-plans/src/main.rs
  • crates/harnx-mcp-plans/src/server/handlers.rs
  • crates/harnx-mcp-plans/src/server/mod.rs
  • crates/harnx-mcp-plans/src/server/params.rs
  • crates/harnx-mcp-plans/src/server/store.rs
  • crates/harnx-mcp-plans/src/server/tests.rs
  • crates/harnx-mcp-plans/src/store_fs.rs
  • crates/harnx-mcp-plans/src/tests.rs
  • crates/harnx-mcp-plans/tests/conformance_fs.rs
  • docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md
  • example_config/mcp_servers/plans-github.yaml
💤 Files with no reviewable changes (4)
  • crates/harnx-mcp-plans/src/server/mod.rs
  • crates/harnx-mcp-plans/src/server/params.rs
  • crates/harnx-mcp-plans/src/server/handlers.rs
  • crates/harnx-mcp-plans/src/server/store.rs

Comment thread .github/workflows/release.yaml
Comment thread crates/harnx-mcp-plans-github/src/auth.rs
Comment on lines +245 to +283
pub fn issue_to_plan(
issue_number: u64,
title: &str,
body: Option<&str>,
created_at: Timestamp,
updated_at: Option<Timestamp>,
) -> DecodedPlan {
let body = body.unwrap_or("");
let (front, markdown_body) = parse_plan_frontmatter(body);

// Extract JIRA key from title if present
let (plan_title, jira_key_from_title) = extract_jira_key_from_title(title);

// Merge JIRA keys: prefer title prefix over front-matter
let jira_key = jira_key_from_title.or(front.jira_key);

// Create the Plan domain struct
// ID is the stringified issue number (authoritative)
// Note: front.client_id is preserved but not used as the domain ID
let plan = Plan {
id: issue_number.to_string(),
title: Some(plan_title),
summary: front.summary,
author: front.author,
assignee: front.assignee,
executor: front.executor,
git_branch: front.git_branch,
github_owner_repo: front.github_owner_repo,
created_at,
updated_at,
};

DecodedPlan {
plan,
body: markdown_body,
jira_key,
client_id: front.client_id,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Bundle decode args into a struct (CI gate failure).

CodeScene's advisory gate is failing on issue_to_plan for excess function arguments (5 params), and issue_to_task has the same shape with 6 params. Since RUSTFLAGS enforces -D warnings for clippy and this CodeScene gate is a separate CI check that's currently failing, this needs to be addressed before merge.

♻️ Suggested refactor: bundle GitHub issue metadata into a struct
+pub struct GithubIssueMeta<'a> {
+    pub number: u64,
+    pub title: &'a str,
+    pub body: Option<&'a str>,
+    pub created_at: Timestamp,
+    pub updated_at: Option<Timestamp>,
+}
+
-pub fn issue_to_plan(
-    issue_number: u64,
-    title: &str,
-    body: Option<&str>,
-    created_at: Timestamp,
-    updated_at: Option<Timestamp>,
-) -> DecodedPlan {
+pub fn issue_to_plan(meta: GithubIssueMeta<'_>) -> DecodedPlan {

Also applies to: 355-401

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/codec.rs` around lines 245 - 283, Refactor
the decode APIs to stop passing long argument lists: `issue_to_plan` (and the
matching `issue_to_task`) should accept a single metadata struct instead of
separate issue fields. Introduce a shared input type for the GitHub issue data,
update the call sites and any decode helpers in `codec.rs` to construct and pass
that struct, and keep the existing behavior inside `issue_to_plan` by reading
from the new grouped fields.

Source: Pipeline failures

Comment thread crates/harnx-mcp-plans-github/src/config.rs Outdated
Comment on lines +114 to +161
async fn run_stdio(store: Arc<GitHubPlanStore>, config: AppConfig) -> Result<()> {
eprintln!(
"harnx-mcp-plans-github v{}: starting (repo: {}/{}, label: {}, retention: {} days)",
env!("CARGO_PKG_VERSION"),
config.auth.repo.owner,
config.auth.repo.repo,
config.store.plan_label,
config.retention_days
);

let server = PlansServer::with_meta(store.clone(), GITHUB_SERVER_META);
let transport = rmcp::transport::stdio();
let service = server.serve(transport).await?;

if config.retention_days == 0 {
eprintln!("[retention] disabled");
service.waiting().await?;
return Ok(());
}

let mut retention_handle = tokio::spawn(retention_loop(store.clone(), config.retention_days));
let service_handle = tokio::spawn(async move { service.waiting().await });
tokio::pin!(service_handle);
let mut backoff = BASE_BACKOFF;

loop {
tokio::select! {
result = &mut *service_handle => {
retention_handle.abort();
result??;
break;
}
result = &mut retention_handle => {
match result {
Err(err) => {
eprintln!("[retention] task failed: {err}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
Ok(()) => backoff = BASE_BACKOFF,
}
retention_handle = tokio::spawn(retention_loop(store.clone(), config.retention_days));
}
}
}

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Duplicated retention-supervision loop between run_stdio and run_http (CodeScene Large Method in run_http).

The tokio::select! retention supervisor (spawn, backoff on panic, respawn, abort-on-exit) at lines 134-158 and 218-240 is essentially identical logic duplicated across both transports. Extracting a shared helper (e.g. supervise_with_retention<F>(store, retention_days, primary: F) -> Result<()> or a small RetentionSupervisor that both call) would remove the duplication and address the CodeScene "Large Method" gate on run_http.

Also applies to: 163-243

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/runtime.rs` around lines 114 - 161, The
retention-supervision logic is duplicated in both transport runners, so extract
the shared spawn/backoff/respawn/abort loop into a reusable helper or small
supervisor type and have both run_stdio and run_http call it. Keep the existing
behavior the same by moving the tokio::select! loop,
retention_handle/service_handle coordination, and backoff handling into a shared
function such as supervise_with_retention or a RetentionSupervisor, then let the
transport-specific setup only provide the primary service future.

Source: Pipeline failures

Comment on lines +652 to +698
async fn add_task(&self, plan: &PlanId, new_task: NewTask) -> Result<Task, StoreError> {
let plan_number = Self::parse_plan_id(plan)?;

self.ensure_issue_is_plan(plan_number).await?;
let current_sub_count = self.count_sub_issues(plan_number).await?;
if current_sub_count >= MAX_SUB_ISSUES {
return Err(StoreError::InvalidParams(format!(
"plan {} already has maximum number of sub-issues ({})",
plan, MAX_SUB_ISSUES
)));
}

let (title, body) = new_task_to_issue(plan, &new_task, None, "");

let issue = self
.client
.create_issue(CreateIssue {
title,
body: Some(body),
labels: Vec::new(),
})
.await
.map_err(map_github_error)?;

let task_number = issue.number;
let task_internal_id = issue.id;

self.client
.add_sub_issue(plan_number, task_internal_id)
.await
.map_err(map_github_error)?;

Ok(Task {
id: task_number.to_string(),
title: new_task.title,
summary: new_task.summary,
author: new_task.author,
assignee: new_task.assignee,
executor: new_task.executor,
tags: new_task.tags,
plan: plan.clone(),
status: new_task.status.unwrap_or_else(|| "open".to_string()),
created_at: Timestamp::now(),
updated_at: None,
dependencies: new_task.dependencies,
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

sed -n '620,730p' crates/harnx-mcp-plans-github/src/store_github.rs

Repository: dobesv/harnx

Length of output: 3585


🏁 Script executed:

rg -n "add_sub_issue|create_issue\(|delete_issue|close_issue|add_sub" crates -g '*.rs'

Repository: dobesv/harnx

Length of output: 1694


🏁 Script executed:

sed -n '180,430p' crates/harnx-mcp-plans-github/src/client.rs && printf '\n---\n' && sed -n '460,760p' crates/harnx-mcp-plans-github/src/store_github.rs

Repository: dobesv/harnx

Length of output: 18368


Clean up the created issue if add_sub_issue fails
In crates/harnx-mcp-plans-github/src/store_github.rs:668-681, add_task creates the issue first and then links it to the plan. If linking fails, the issue is left open but untracked, and retries can pile up more orphans. Close/delete the new issue on failure, or make the create+link step atomic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/store_github.rs` around lines 652 - 698,
The add_task flow in store_github creates a GitHub issue before calling
add_sub_issue, so a link failure leaves an orphaned issue behind. Update
add_task to clean up the newly created issue if add_sub_issue returns an error,
using the existing issue result from create_issue and map_github_error handling,
or otherwise make the create-and-link sequence effectively atomic.

Comment on lines +203 to +221
fn issue_body_from_request(body: &serde_json::Value) -> Option<String> {
body["body"].as_str().map(|s| s.to_string()).or_else(|| {
body["body"].as_array().map(|parts| {
let mut out = String::new();
for part in parts {
if let Some(s) = part.as_str() {
out.push_str(s);
} else if let Some(n) = part.as_u64() {
out.push_str(&n.to_string());
} else if let Some(map) = part.as_object() {
if let Some(v) = map.get("str").and_then(|v| v.as_str()) {
out.push_str(v);
}
}
}
out
})
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

issue_body_from_request flagged as "Bumpy Road Ahead" by CodeScene (critical gate).

The nested if let/else if let chain handling string/array/object body encodings is the source of the critical gate failure. Flattening via a match on part (or a small enum-based dispatch) would reduce nesting and satisfy the gate.

♻️ Possible simplification
-            for part in parts {
-                if let Some(s) = part.as_str() {
-                    out.push_str(s);
-                } else if let Some(n) = part.as_u64() {
-                    out.push_str(&n.to_string());
-                } else if let Some(map) = part.as_object() {
-                    if let Some(v) = map.get("str").and_then(|v| v.as_str()) {
-                        out.push_str(v);
-                    }
-                }
-            }
+            for part in parts {
+                match part {
+                    serde_json::Value::String(s) => out.push_str(s),
+                    serde_json::Value::Number(n) => out.push_str(&n.to_string()),
+                    serde_json::Value::Object(map) => {
+                        if let Some(v) = map.get("str").and_then(|v| v.as_str()) {
+                            out.push_str(v);
+                        }
+                    }
+                    _ => {}
+                }
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn issue_body_from_request(body: &serde_json::Value) -> Option<String> {
body["body"].as_str().map(|s| s.to_string()).or_else(|| {
body["body"].as_array().map(|parts| {
let mut out = String::new();
for part in parts {
if let Some(s) = part.as_str() {
out.push_str(s);
} else if let Some(n) = part.as_u64() {
out.push_str(&n.to_string());
} else if let Some(map) = part.as_object() {
if let Some(v) = map.get("str").and_then(|v| v.as_str()) {
out.push_str(v);
}
}
}
out
})
})
}
fn issue_body_from_request(body: &serde_json::Value) -> Option<String> {
body["body"].as_str().map(|s| s.to_string()).or_else(|| {
body["body"].as_array().map(|parts| {
let mut out = String::new();
for part in parts {
match part {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Number(n) => out.push_str(&n.to_string()),
serde_json::Value::Object(map) => {
if let Some(v) = map.get("str").and_then(|v| v.as_str()) {
out.push_str(v);
}
}
_ => {}
}
}
out
})
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/conformance_github.rs` around lines 203 -
221, The `issue_body_from_request` helper has a deeply nested `if let`/`else if
let` chain for parsing `body` array elements, which is triggering the CodeScene
gate. Refactor the array handling in `issue_body_from_request` to use a flatter
`match` on each `part` (or equivalent enum-style dispatch) so the string,
number, and object cases are handled at one level without nested branching.

Source: Pipeline failures

Comment thread crates/harnx-mcp-plans-github/tests/live_e2e.rs
Comment thread crates/harnx-mcp-plans/src/store_fs.rs Outdated
Comment on lines +548 to +1022
#[async_trait]
impl PlanStore for FilePlanStore {
async fn list_plans(&self, page: Option<PageToken>) -> Result<Page<Plan>, StoreError> {
let offset = parse_offset(page)?;
let mut plans = Vec::new();
for dir in plan_dirs(&self.dir) {
let Some(name) = dir.file_name().and_then(OsStr::to_str) else {
continue;
};
let normalized = normalize_plan_name(name);
let plan_path = plan_file_path(&self.dir, &normalized);
let record = if plan_path.exists() {
let content = std::fs::read_to_string(&plan_path)
.map_err(|err| StoreError::Backend(err.into()))?;
let fallback_created_at = std::fs::metadata(&plan_path)
.and_then(|metadata| metadata.modified())
.ok()
.map(system_time_to_rfc3339)
.transpose()?;
parse_plan_record_from_content(&content, &normalized, fallback_created_at)?
} else {
PlanRecord {
front: PlanFrontMatter {
id: normalized.clone(),
created_at: String::new(),
..Default::default()
},
body: String::new(),
}
};
plans.push(plan_record_to_domain(record)?);
}
let total = plans.len();
let items = plans.into_iter().skip(offset).collect::<Vec<_>>();
Ok(Page {
items,
next: next_page_token(total, total),
})
}

async fn get_plan(&self, plan: &PlanId) -> Result<Plan, StoreError> {
let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = plan_file_path(&self.dir, &name);
let content = if path.exists() {
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?
} else if plan_dir(&self.dir, &name).exists() {
String::new()
} else {
return Err(StoreError::NotFound);
};
let fallback_created_at = if path.exists() {
std::fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok()
.map(system_time_to_rfc3339)
.transpose()?
} else {
None
};
let record = parse_plan_record_from_content(&content, &name, fallback_created_at)?;
plan_record_to_domain(PlanRecord {
front: record.front,
body: String::new(),
})
}

async fn add_plan(&self, new_plan: NewPlan) -> Result<Plan, StoreError> {
let name = validate_plan_name(&new_plan.id).map_err(StoreError::InvalidParams)?;
let dir = plan_dir(&self.dir, &name);
if dir.exists() {
return Err(StoreError::AlreadyExists);
}
std::fs::create_dir_all(&dir).map_err(|err| StoreError::Backend(err.into()))?;

let record = PlanRecord {
front: PlanFrontMatter {
id: name.clone(),
title: new_plan.title,
summary: new_plan.summary,
author: new_plan.author,
assignee: new_plan.assignee,
executor: new_plan.executor,
git_branch: new_plan.git_branch,
github_owner_repo: new_plan.github_owner_repo,
created_at: now_iso(),
updated_at: None,
},
body: String::new(),
};
let serialized =
serialize_plan(&record).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
write_plan_file(&plan_file_path(&self.dir, &name), &serialized)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
plan_record_to_domain(record)
}

async fn update_plan_meta(
&self,
plan: &PlanId,
update: PlanMetaUpdate,
) -> Result<Plan, StoreError> {
let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let dir = plan_dir(&self.dir, &name);
std::fs::create_dir_all(&dir).map_err(|err| StoreError::Backend(err.into()))?;
let path = plan_file_path(&self.dir, &name);
let (existing, body) = if path.exists() {
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (front, body) = parse_plan_frontmatter(&content, &name)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
(front, body)
} else {
(
PlanFrontMatter {
id: name.clone(),
created_at: now_iso(),
..Default::default()
},
String::new(),
)
};
let record = PlanRecord {
front: PlanFrontMatter {
id: name.clone(),
title: update.title.or(existing.title),
summary: update.summary.or(existing.summary),
author: update.author.or(existing.author),
assignee: update.assignee.or(existing.assignee),
executor: update.executor.or(existing.executor),
git_branch: update.git_branch.or(existing.git_branch),
github_owner_repo: update.github_owner_repo.or(existing.github_owner_repo),
created_at: if existing.created_at.is_empty() {
now_iso()
} else {
existing.created_at
},
updated_at: Some(now_iso()),
},
body,
};
let serialized =
serialize_plan(&record).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
write_plan_file(&path, &serialized)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
plan_record_to_domain(record)
}

async fn delete_plan(&self, plan: &PlanId) -> Result<(), StoreError> {
let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let dir = plan_dir(&self.dir, &name);
if !dir.exists() {
return Err(StoreError::NotFound);
}
std::fs::remove_dir_all(&dir).map_err(|err| StoreError::Backend(err.into()))?;
Ok(())
}

async fn read_plan_body(&self, plan: &PlanId) -> Result<String, StoreError> {
let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = plan_file_path(&self.dir, &name);
let body = if path.exists() {
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?
} else if plan_dir(&self.dir, &name).exists() {
String::new()
} else {
return Err(StoreError::NotFound);
};
let (_, content) = parse_plan_frontmatter(&body, &name)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
Ok(content)
}

async fn write_plan_body(&self, plan: &PlanId, body: &str) -> Result<(), StoreError> {
let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let dir = plan_dir(&self.dir, &name);
std::fs::create_dir_all(&dir).map_err(|err| StoreError::Backend(err.into()))?;
let path = plan_file_path(&self.dir, &name);
let existing = if path.exists() {
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (front, _) = parse_plan_frontmatter(&content, &name)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
front
} else {
PlanFrontMatter {
id: name.clone(),
created_at: now_iso(),
..Default::default()
}
};
let record = PlanRecord {
front: existing,
body: body.to_string(),
};
let serialized =
serialize_plan(&record).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
write_plan_file(&path, &serialized).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))
}

async fn list_tasks(
&self,
plan: &PlanId,
filter: TaskFilter,
page: Option<PageToken>,
) -> Result<Page<Task>, StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let offset = parse_offset(page)?;
let tasks = list_tasks_scan(
&self.dir,
Some(&plan_name),
filter.tag.as_deref(),
filter.status.as_deref(),
);
let total = tasks.len();
let items = tasks
.into_iter()
.skip(offset)
.map(task_record_to_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Page {
items,
next: next_page_token(total, total),
})
}

async fn get_task(&self, plan: &PlanId, task: &String) -> Result<Task, StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let task = read_task(&self.dir, &plan_name, task).map_err(|_| StoreError::NotFound)?;
task_record_to_domain(task)
}

async fn add_task(&self, plan: &PlanId, new_task: NewTask) -> Result<Task, StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let plan_path = plan_dir(&self.dir, &plan_name);
if !plan_path.exists() {
std::fs::create_dir_all(&plan_path).map_err(|err| StoreError::Backend(err.into()))?;
}
let id = validate_id(&new_task.id).map_err(StoreError::InvalidId)?;
if task_file_path(&self.dir, &plan_name, &id).exists() {
return Err(StoreError::AlreadyExists);
}
let now = now_iso();
let task = TaskRecord {
front: TaskFrontMatter {
id: id.clone(),
title: new_task.title,
summary: new_task.summary,
author: new_task.author,
assignee: new_task.assignee,
executor: new_task.executor,
tags: new_task.tags,
plan: plan_name.clone(),
status: new_task.status.unwrap_or_else(default_open_status),
created_at: now,
updated_at: None,
dependencies: new_task.dependencies,
},
body: String::new(),
};
write_task(&self.dir, &task).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
task_record_to_domain(task)
}

async fn update_task_meta(
&self,
plan: &PlanId,
task: &String,
update: TaskMetaUpdate,
) -> Result<Task, StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let mut task = read_task(&self.dir, &plan_name, task).map_err(|_| StoreError::NotFound)?;
if let Some(title) = update.title {
task.front.title = title;
}
if let Some(summary) = update.summary {
task.front.summary = Some(summary);
}
if let Some(author) = update.author {
task.front.author = Some(author);
}
if let Some(assignee) = update.assignee {
task.front.assignee = Some(assignee);
}
if let Some(executor) = update.executor {
task.front.executor = Some(executor);
}
if let Some(tags) = update.tags {
task.front.tags = tags;
}
if let Some(status) = update.status {
task.front.status = status;
}
if let Some(dependencies) = update.dependencies {
task.front.dependencies = dependencies;
}
task.front.updated_at = Some(now_iso());
write_task(&self.dir, &task).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
task_record_to_domain(task)
}

async fn delete_task(&self, plan: &PlanId, task: &String) -> Result<(), StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = task_file_path(&self.dir, &plan_name, task);
if !path.exists() {
return Err(StoreError::NotFound);
}
std::fs::remove_file(path).map_err(|err| StoreError::Backend(err.into()))?;
Ok(())
}

async fn read_task_body(&self, plan: &PlanId, task: &String) -> Result<String, StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
Ok(read_task(&self.dir, &plan_name, task)
.map_err(|_| StoreError::NotFound)?
.body)
}

async fn write_task_body(
&self,
plan: &PlanId,
task: &String,
body: &str,
) -> Result<(), StoreError> {
let plan_name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let mut task = read_task(&self.dir, &plan_name, task).map_err(|_| StoreError::NotFound)?;
task.body = body.to_string();
write_task(&self.dir, &task).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))
}

async fn list_notes(
&self,
plan: &PlanId,
page: Option<PageToken>,
) -> Result<Page<Note>, StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let offset = parse_offset(page)?;
let mut notes = Vec::new();
let dir = notes_dir(&self.dir, &plan);
if dir.exists() {
let mut entries = std::fs::read_dir(&dir)
.map_err(|err| StoreError::Backend(err.into()))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(OsStr::to_str) == Some("md"))
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let content = std::fs::read_to_string(&path)
.map_err(|err| StoreError::Backend(err.into()))?;
let (front, body) = parse_note_frontmatter(&content)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
notes.push(note_record_to_domain(NoteRecord { front, body })?);
}
}
let total = notes.len();
let items = notes.into_iter().skip(offset).collect::<Vec<_>>();
Ok(Page {
items,
next: next_page_token(total, total),
})
}

async fn get_note(&self, plan: &PlanId, note: &String) -> Result<Note, StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = note_file_path(&self.dir, &plan, note);
if !path.exists() {
return Err(StoreError::NotFound);
}
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (front, body) = parse_note_frontmatter(&content)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
let _ = body;
note_record_to_domain(NoteRecord {
front,
body: String::new(),
})
}

async fn add_note(&self, plan: &PlanId, new_note: NewNote) -> Result<Note, StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
std::fs::create_dir_all(notes_dir(&self.dir, &plan))
.map_err(|err| StoreError::Backend(err.into()))?;
let id = validate_id(&new_note.id).map_err(StoreError::InvalidId)?;
if note_file_path(&self.dir, &plan, &id).exists() {
return Err(StoreError::AlreadyExists);
}
let note = NoteRecord {
front: NoteFrontMatter {
id,
summary: new_note.summary,
author: new_note.author,
created_at: now_iso(),
updated_at: None,
},
body: String::new(),
};
write_note(&self.dir, &plan, &note)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
note_record_to_domain(note)
}

async fn update_note_meta(
&self,
plan: &PlanId,
note: &String,
update: NoteMetaUpdate,
) -> Result<Note, StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = note_file_path(&self.dir, &plan, note);
if !path.exists() {
return Err(StoreError::NotFound);
}
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (mut front, body) = parse_note_frontmatter(&content)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
if let Some(summary) = update.summary {
front.summary = Some(summary);
}
if let Some(author) = update.author {
front.author = Some(author);
}
front.updated_at = Some(now_iso());
let note = NoteRecord { front, body };
write_note(&self.dir, &plan, &note)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
note_record_to_domain(note)
}

async fn delete_note(&self, plan: &PlanId, note: &String) -> Result<(), StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = note_file_path(&self.dir, &plan, note);
if !path.exists() {
return Err(StoreError::NotFound);
}
std::fs::remove_file(path).map_err(|err| StoreError::Backend(err.into()))?;
Ok(())
}

async fn read_note_body(&self, plan: &PlanId, note: &String) -> Result<String, StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = note_file_path(&self.dir, &plan, note);
if !path.exists() {
return Err(StoreError::NotFound);
}
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (_, body) = parse_note_frontmatter(&content)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
Ok(body)
}

async fn write_note_body(
&self,
plan: &PlanId,
note: &String,
body: &str,
) -> Result<(), StoreError> {
let plan = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
let path = note_file_path(&self.dir, &plan, note);
if !path.exists() {
return Err(StoreError::NotFound);
}
let content =
std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?;
let (front, _) = parse_note_frontmatter(&content)
.map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
let note = NoteRecord {
front,
body: body.to_string(),
};
write_note(&self.dir, &plan, &note).map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Blocking filesystem I/O inside async PlanStore methods.

Every method in this impl PlanStore for FilePlanStore block (list_plans, get_plan, add_plan, update_plan_meta, delete_plan, read_plan_body, write_plan_body, and the task/note equivalents) calls std::fs::* synchronously from an async fn. These block the Tokio worker thread for the duration of each disk operation, stealing capacity from all other tasks scheduled on that thread — including concurrent MCP tool calls and the background cleanup_loop, which is shared on the same runtime (see main.rs). Notably, run_cleanup_pass/plan_last_activity in this same file correctly offload their std::fs calls via tokio::task::spawn_blocking; the CRUD path should follow the same pattern (or switch to tokio::fs for simple read/write calls).

As per coding guidelines, "All I/O must be async via Tokio using async fn and .await."

🚀 Example fix pattern (apply throughout the impl block)
     async fn read_plan_body(&self, plan: &PlanId) -> Result<String, StoreError> {
         let name = validate_plan_name(plan).map_err(StoreError::InvalidParams)?;
-        let path = plan_file_path(&self.dir, &name);
-        let body = if path.exists() {
-            std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))?
-        } else if plan_dir(&self.dir, &name).exists() {
-            String::new()
-        } else {
-            return Err(StoreError::NotFound);
-        };
+        let path = plan_file_path(&self.dir, &name);
+        let dir_path = plan_dir(&self.dir, &name);
+        let body = tokio::task::spawn_blocking(move || -> Result<String, StoreError> {
+            if path.exists() {
+                std::fs::read_to_string(&path).map_err(|err| StoreError::Backend(err.into()))
+            } else if dir_path.exists() {
+                Ok(String::new())
+            } else {
+                Err(StoreError::NotFound)
+            }
+        })
+        .await
+        .map_err(|err| StoreError::Backend(err.into()))??;
         let (_, content) = parse_plan_frontmatter(&body, &name)
             .map_err(|err| StoreError::Backend(anyhow::anyhow!(err)))?;
         Ok(content)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans/src/store_fs.rs` around lines 548 - 1022, The async
PlanStore CRUD methods are doing blocking std::fs I/O on Tokio worker threads,
which can stall concurrent MCP requests and cleanup work. Update the
FilePlanStore impl methods such as list_plans, get_plan, add_plan,
update_plan_meta, delete_plan, read_plan_body, write_plan_body, and the
task/note equivalents to use tokio::fs or offload filesystem work with
tokio::task::spawn_blocking, following the same async pattern already used by
run_cleanup_pass/plan_last_activity in this module.

Source: Coding guidelines

Comment thread crates/harnx-mcp-plans/src/store_fs.rs Outdated
@dobesv
dobesv force-pushed the mcp-plans-github branch from 3d5ccd4 to 2e28a0c Compare July 9, 2026 23:59
codescene-delta-analysis[bot]

This comment was marked as outdated.

@dobesv
dobesv force-pushed the mcp-plans-github branch from 2e28a0c to 4f106ce Compare July 10, 2026 00:04
codescene-delta-analysis[bot]

This comment was marked as outdated.

@dobesv
dobesv force-pushed the mcp-plans-github branch from 4f106ce to 18c5d19 Compare July 10, 2026 03:59
codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
crates/harnx-mcp-plans-github/README.md (1)

22-25: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Warn about PAT exposure through --token.

Command-line arguments may be visible in process listings and shell history. Prefer GITHUB_TOKEN in the documented example, or clearly warn operators to use the flag only in controlled environments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/README.md` around lines 22 - 25, Update the
options documentation near the `--token` entry to warn that command-line tokens
can appear in process listings and shell history; make the documented usage
example prefer `GITHUB_TOKEN`, and state that `--token` should only be used in
controlled environments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/harnx-mcp-plans-github/README.md`:
- Around line 30-32: Update the HTTP options documentation to clearly state that
Streamable HTTP is unauthenticated and must remain bound to loopback by default;
either remove or strongly caution against using `--host 0.0.0.0`, and document
that wider exposure requires an authenticated proxy boundary protecting plan
mutations.

In `@crates/harnx-mcp-plans-github/src/config.rs`:
- Around line 262-286: run_git_with_timeout currently performs synchronous
process and timeout waits during parse_from_env_and_args startup, blocking Tokio
workers. Refactor the git detection path, including parse_from_env_and_args and
run_git_with_timeout, to use Tokio-safe execution: either run the synchronous
command through tokio::task::spawn_blocking or replace it with
tokio::process::Command and an async tokio::time::timeout, propagating timeout,
spawn, and output errors appropriately.

In
`@docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md`:
- Around line 207-210: Update the Cargo.toml fenced code block in the
documentation to use the toml language identifier by changing the opening fence
to ```toml, preserving the existing dependency snippet.
- Around line 243-257: Update the documented atomic-create fix to use Tokio
APIs: make the enclosing operation async, replace std::fs::create_dir with
tokio::fs::create_dir(...).await while preserving AlreadyExists handling, and
use tokio::fs::OpenOptions with create_new(true) plus await for task/note files.
Remove blocking std::fs calls from the example.
- Around line 54-70: Update the PlanStore example to use async fn for every
store operation, including list_plans, get_plan, add_plan, update_plan_meta,
delete_plan, read_plan_body, and write_plan_body, matching the actual trait
contract; alternatively label the snippet explicitly as pseudocode if it is
intentionally illustrative.

In `@example_config/mcp_servers/plans-github.yaml`:
- Around line 15-16: Remove the literal token placeholder from the example
configuration near GITHUB_TOKEN, and reference the environment variable
indirectly or leave the setting unset with a prominent warning not to commit
real secrets. Preserve the configuration’s guidance for supplying a personal
access token securely.

---

Nitpick comments:
In `@crates/harnx-mcp-plans-github/README.md`:
- Around line 22-25: Update the options documentation near the `--token` entry
to warn that command-line tokens can appear in process listings and shell
history; make the documented usage example prefer `GITHUB_TOKEN`, and state that
`--token` should only be used in controlled environments.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f24d1ba-6ef3-4570-9e45-b656f49f0437

📥 Commits

Reviewing files that changed from the base of the PR and between 3d5ccd4 and 18c5d19.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (30)
  • .github/workflows/release.yaml
  • Cargo.toml
  • README.md
  • crates/harnx-mcp-plans-core/Cargo.toml
  • crates/harnx-mcp-plans-core/src/conformance.rs
  • crates/harnx-mcp-plans-core/src/lib.rs
  • crates/harnx-mcp-plans-core/src/model.rs
  • crates/harnx-mcp-plans-core/src/server/handler.rs
  • crates/harnx-mcp-plans-core/src/server/handlers.rs
  • crates/harnx-mcp-plans-core/src/server/mod.rs
  • crates/harnx-mcp-plans-core/src/server/params.rs
  • crates/harnx-mcp-plans-core/src/store.rs
  • crates/harnx-mcp-plans-github/.gitignore
  • crates/harnx-mcp-plans-github/Cargo.toml
  • crates/harnx-mcp-plans-github/README.md
  • crates/harnx-mcp-plans-github/src/auth.rs
  • crates/harnx-mcp-plans-github/src/client.rs
  • crates/harnx-mcp-plans-github/src/codec.rs
  • crates/harnx-mcp-plans-github/src/config.rs
  • crates/harnx-mcp-plans-github/src/lib.rs
  • crates/harnx-mcp-plans-github/src/main.rs
  • crates/harnx-mcp-plans-github/src/ratelimit.rs
  • crates/harnx-mcp-plans-github/src/runtime.rs
  • crates/harnx-mcp-plans-github/src/store_github.rs
  • crates/harnx-mcp-plans-github/src/store_github/tests.rs
  • crates/harnx-mcp-plans-github/tests/conformance_github.rs
  • crates/harnx-mcp-plans-github/tests/github_specific.rs
  • crates/harnx-mcp-plans-github/tests/live_e2e.rs
  • docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md
  • example_config/mcp_servers/plans-github.yaml
✅ Files skipped from review due to trivial changes (2)
  • crates/harnx-mcp-plans-github/.gitignore
  • README.md
🚧 Files skipped from review as they are similar to previous changes (23)
  • crates/harnx-mcp-plans-core/Cargo.toml
  • crates/harnx-mcp-plans-github/src/main.rs
  • crates/harnx-mcp-plans-core/src/lib.rs
  • crates/harnx-mcp-plans-github/Cargo.toml
  • crates/harnx-mcp-plans-core/src/model.rs
  • .github/workflows/release.yaml
  • crates/harnx-mcp-plans-core/src/server/mod.rs
  • crates/harnx-mcp-plans-core/src/server/handler.rs
  • crates/harnx-mcp-plans-core/src/store.rs
  • crates/harnx-mcp-plans-github/src/lib.rs
  • crates/harnx-mcp-plans-github/tests/live_e2e.rs
  • Cargo.toml
  • crates/harnx-mcp-plans-core/src/conformance.rs
  • crates/harnx-mcp-plans-core/src/server/params.rs
  • crates/harnx-mcp-plans-github/src/runtime.rs
  • crates/harnx-mcp-plans-github/src/store_github.rs
  • crates/harnx-mcp-plans-github/tests/conformance_github.rs
  • crates/harnx-mcp-plans-github/src/ratelimit.rs
  • crates/harnx-mcp-plans-github/tests/github_specific.rs
  • crates/harnx-mcp-plans-core/src/server/handlers.rs
  • crates/harnx-mcp-plans-github/src/codec.rs
  • crates/harnx-mcp-plans-github/src/client.rs
  • crates/harnx-mcp-plans-github/src/store_github/tests.rs

Comment on lines +30 to +32
| `--http` | - | - | Serve MCP over Streamable HTTP instead of stdio. |
| `--host <addr>` | - | `127.0.0.1` | Bind address for HTTP mode. Set explicitly to `0.0.0.0` or another interface for wider exposure. |
| `--port <N>` | - | `3000` | Bind port for HTTP mode. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'Authorization|Bearer|auth|middleware|StreamableHttp|0\.0\.0\.0' \
  crates/harnx-mcp-plans-github/src

Repository: dobesv/harnx

Length of output: 9552


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runtime.rs outline =="
ast-grep outline crates/harnx-mcp-plans-github/src/runtime.rs --view expanded || true

echo
echo "== runtime.rs relevant lines =="
sed -n '140,230p' crates/harnx-mcp-plans-github/src/runtime.rs

echo
echo "== README lines around HTTP options =="
sed -n '20,40p' crates/harnx-mcp-plans-github/README.md

echo
echo "== search for HTTP auth / middleware / transport config =="
rg -n 'StreamableHttpServerConfig|StreamableHttpService|middleware|Authorization|bearer|auth|authenticate|token|proxy|0\.0\.0\.0|127\.0\.0\.1' crates/harnx-mcp-plans-github/src crates/harnx-mcp-plans-github/README.md

Repository: dobesv/harnx

Length of output: 22631


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Cargo.toml dependency versions =="
sed -n '1,220p' crates/harnx-mcp-plans-github/Cargo.toml

echo
echo "== runtime.rs lines 160-210 =="
sed -n '160,210p' crates/harnx-mcp-plans-github/src/runtime.rs

echo
echo "== lib.rs exports =="
sed -n '1,80p' crates/harnx-mcp-plans-github/src/lib.rs

echo
echo "== search for any inbound request checks in MCP server code =="
rg -n 'Authorization|auth.*header|header.*auth|SessionManager|NeverSessionManager|Require.*Auth|reject.*request|middleware|tower::Service|axum::middleware|Extension<|State<' crates/harnx-mcp-plans-github/src

Repository: dobesv/harnx

Length of output: 4447


Keep HTTP loopback-only unless auth is added
crates/harnx-mcp-plans-github/README.md:30-32 The Streamable HTTP path is unauthenticated (NeverSessionManager), so --host 0.0.0.0 would expose plan mutations to any reachable client. Keep the default loopback-only guidance or document the required authenticated proxy boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/README.md` around lines 30 - 32, Update the
HTTP options documentation to clearly state that Streamable HTTP is
unauthenticated and must remain bound to loopback by default; either remove or
strongly caution against using `--host 0.0.0.0`, and document that wider
exposure requires an authenticated proxy boundary protecting plan mutations.

Comment thread crates/harnx-mcp-plans-github/src/config.rs
Comment on lines +207 to +210
```
# Cargo.toml
jsonwebtoken = { version = "10", features = ["aws_lc_rs", "use_pem"] }
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the Cargo.toml fence.

Use toml after the opening fence so Markdown lint passes and syntax highlighting is correct.

-```
+```toml
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 207-207: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md`
around lines 207 - 210, Update the Cargo.toml fenced code block in the
documentation to use the toml language identifier by changing the opening fence
to ```toml, preserving the existing dependency snippet.

Source: Linters/SAST tools

Comment on lines +243 to +257
**Fix (Commit 7f2d1bde):** Atomic exclusive create on leaf directory:

```rust
let dir = plan_dir(&self.dir, &name);
match std::fs::create_dir(&dir) { // NOT create_dir_all!
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(StoreError::AlreadyExists);
}
Err(err) => return Err(StoreError::Backend(err.into())),
}
// Parent dirs created separately with create_dir_all if needed
```

For task/note files, use `OpenOptions::create_new(true)` — fails if file exists.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Use Tokio filesystem APIs in the documented atomic-create fix.

The example uses blocking std::fs::create_dir; replace it with tokio::fs::create_dir(...).await and the Tokio OpenOptions equivalent for file creation.

As per coding guidelines, all I/O must be async via Tokio using async fn and .await.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md`
around lines 243 - 257, Update the documented atomic-create fix to use Tokio
APIs: make the enclosing operation async, replace std::fs::create_dir with
tokio::fs::create_dir(...).await while preserving AlreadyExists handling, and
use tokio::fs::OpenOptions with create_new(true) plus await for task/note files.
Remove blocking std::fs calls from the example.

Source: Coding guidelines

Comment thread example_config/mcp_servers/plans-github.yaml Outdated
Introduce a GitHub Issues-backed storage backend for the plans MCP server,
extracting the backend-agnostic layer into a shared `harnx-mcp-plans-core`
crate (PlanStore trait + handler/schema/tool layer + conformance suite) and
adding `harnx-mcp-plans-github` (issue=plan, sub-issue=task, comment=note;
YAML front-matter metadata; PAT + GitHub App auth; rate-limit-aware executor;
delete=close + retention; read-side dedupe; pagination without Search API).
The filesystem backend is migrated to `FilePlanStore` behind the same trait
with existing behavior preserved.

Repo auto-detection: the target GitHub repo is detected from the git `origin`
remote of the working directory (github.com only) — there is no `--repo`
flag or `GITHUB_OWNER_REPO`. Startup fails fast if not in a git repo, no
origin, non-github origin, or unparseable. Git invoked via fixed argv with a
bounded timeout.

Hardening from PR review:
- HTTP: keep rmcp default host allowlist and default bind to 127.0.0.1
  (drop disable_allowed_hosts) to prevent DNS-rebinding exposure.
- config: normalize base_url trailing slash consistently.
- FilePlanStore: fix TOCTOU on create (atomic exclusive create) and
  lost-update races via per-entity async locks across all RMW/delete paths.

Issue: #949
@dobesv
dobesv force-pushed the mcp-plans-github branch from 18c5d19 to 247cdb1 Compare July 10, 2026 17:25
@dobesv

dobesv commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Review comment responses (Atlas)

Force-pushed 247cdb1b addressing the actionable CodeRabbit findings from the latest pass. Summary per thread:

Fixed in this push:

  • config.rs / main.rsAvoid blocking the Tokio runtime during startup: main no longer uses #[tokio::main]. Config parsing (which runs the synchronous git origin-detection) now happens before the Tokio runtime is built, so the blocking git call can't stall a worker thread. Runtime is created explicitly via tokio::runtime::Runtime::new()? + block_on.
  • example_config/mcp_servers/plans-github.yamlAvoid embedding token placeholders: removed the literal GITHUB_TOKEN: "your_..." placeholder; the token is now referenced as an operator-supplied env var (commented, not hardcoded).
  • docs/solutions/… — added the toml language identifier to the Cargo.toml fence, and made the illustrative PlanStore trait example use async fn to match the real #[async_trait] signatures.

Already addressed in the current code (comments re-anchored from an earlier commit snapshot):

  • runtime.rs host allowlist / 0.0.0.0disable_allowed_hosts() was already removed and DEFAULT_HOST is 127.0.0.1.
  • auth.rs AuthConfig::from_env — that method (and AppAuthConfig::from_env) was already removed; no production path reads GITHUB_OWNER_REPO.
  • config.rs:95 base_url normalization — already normalized (trim_end_matches('/')) on both paths.
  • store_fs.rs TOCTOU / lost-update races — already fixed (atomic exclusive create + per-entity async locks across all RMW/delete paths).
  • live_e2e.rs hardcoded owner/repo — already fixed; the client is constructed with the env-derived &owner, &repo.

Deferred (non-blocking follow-ups, tracked in the solution doc's "Known Limitations"):

  • CodeScene maintainability gates (dedupe/sort consolidation in store_github.rs, retention-loop dedup in runtime.rs, codec.rs/conformance_github.rs method shape) — cosmetic health metrics, no behavioral impact.
  • store_fs.rs blocking std::fs in async — pre-existing whole-file pattern; candidate for a tokio::fs/spawn_blocking pass.
  • store_github.rs orphan-issue cleanup if add_sub_issue fails after issue creation (GitHub has no transaction) — orphan is visible/recoverable.
  • README.md HTTP loopback/auth note — the server already defaults to loopback with the rmcp host allowlist enabled; broader exposure is an explicit operator opt-in.

CI note: the previous failing run was a pre-existing flaky harnx-runtime test (nats_worker::tests::remote_header_matches_local_header_source_of_truth / session-dump snapshot), unrelated to this PR — our diff touches zero harnx-runtime files, and main itself is currently red on the same suite. The re-push triggers a fresh run.

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Failed
New code is healthy (14 new files with code health below 10.00)
Enforce critical code health rules (6 files with Bumpy Road Ahead, Low Cohesion, Deep, Nested Complexity)
Enforce advisory code health rules (14 files with Code Duplication, Large Method, Complex Method, Excess Number of Function Arguments, Complex Conditional, String Heavy Function Arguments)

Our agent can fix these. Install it.

Gates Passed
3 Quality Gates Passed

Reason for failure
New code is healthy Violations Code Health Impact
handlers.rs 7 rules 5.66 Suppress
store_github.rs 2 rules 7.11 Suppress
conformance.rs 3 rules 7.17 Suppress
config.rs 5 rules 8.13 Suppress
tests.rs 3 rules 8.28 Suppress
conformance_github.rs 3 rules 8.34 Suppress
client.rs 2 rules 8.68 Suppress
mod.rs 2 rules 8.82 Suppress
live_e2e.rs 2 rules 9.00 Suppress
ratelimit.rs 1 rule 9.39 Suppress
github_specific.rs 1 rule 9.39 Suppress
runtime.rs 1 rule 9.59 Suppress
handler.rs 1 rule 9.60 Suppress
codec.rs 1 rule 9.69 Suppress
Enforce critical code health rules Violations Code Health Impact
handlers.rs 3 critical rules 5.66 Suppress
store_github.rs 1 critical rule 7.11 Suppress
conformance.rs 1 critical rule 7.17 Suppress
config.rs 1 critical rule 8.13 Suppress
conformance_github.rs 1 critical rule 8.34 Suppress
client.rs 1 critical rule 8.68 Suppress
Enforce advisory code health rules Violations Code Health Impact
handlers.rs 4 advisory rules 5.66 Suppress
store_github.rs 1 advisory rule 7.11 Suppress
conformance.rs 2 advisory rules 7.17 Suppress
config.rs 4 advisory rules 8.13 Suppress
tests.rs 3 advisory rules 8.28 Suppress
conformance_github.rs 2 advisory rules 8.34 Suppress
client.rs 1 advisory rule 8.68 Suppress
mod.rs 2 advisory rules 8.82 Suppress
live_e2e.rs 2 advisory rules 9.00 Suppress
ratelimit.rs 1 advisory rule 9.39 Suppress
github_specific.rs 1 advisory rule 9.39 Suppress
runtime.rs 1 advisory rule 9.59 Suppress
handler.rs 1 advisory rule 9.60 Suppress
codec.rs 1 advisory rule 9.69 Suppress

See analysis details in CodeScene

Quality Gate Profile: Pay Down Tech Debt
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/harnx-mcp-plans-github/tests/github_specific.rs (1)

45-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate mock_issue_response and mock_plan_issue_response to resolve CodeScene duplication.

The two helpers differ only by the labels field. A single function with an optional labels parameter eliminates the duplication flagged by CodeScene while keeping call sites readable.

♻️ Proposed consolidation
 fn mock_issue_response(id: u64, number: u64, title: &str, body: &str) -> String {
+    mock_issue_response_with_labels(id, number, title, body, None)
+}
+
+fn mock_plan_issue_response(id: u64, number: u64, title: &str, body: &str) -> String {
+    mock_issue_response_with_labels(
+        id,
+        number,
+        title,
+        body,
+        Some(vec![serde_json::json!({"id": 1, "name": "harnx-plan"})]),
+    )
+}
+
+fn mock_issue_response_with_labels(
+    id: u64,
+    number: u64,
+    title: &str,
+    body: &str,
+    labels: Option<Vec<serde_json::Value>>,
+) -> String {
     let created = Timestamp::now().to_string();
     serde_json::json!({
         "id": id,
         "number": number,
         "title": title,
         "body": body,
         "node_id": format!("I_kwDOA{}", id),
         "state": "open",
+        ...(labels.map_or(serde_json::Value::Null, |l| serde_json::Value::Array(l))),
         "created_at": created,
         "updated_at": created,
         "comments": 0
     })
     .to_string()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/tests/github_specific.rs` around lines 45 - 78,
Consolidate mock_issue_response and mock_plan_issue_response into one helper by
adding an optional labels parameter to mock_issue_response and conditionally
including the labels field in its JSON output. Update all existing call sites to
pass labels when creating plan issues and None otherwise, then remove
mock_plan_issue_response.

Source: Pipeline failures

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/harnx-mcp-plans-github/src/config.rs`:
- Around line 110-113: The base URL construction in the configuration
initialization bypasses validation performed by auth.rs::normalize_base_url.
Apply normalize_base_url to the selected argument/environment/default URL before
constructing AuthConfig, propagate a clear configuration error when
normalization fails, and add tests covering malformed URLs and unsupported
schemes for both affected paths.
- Around line 288-303: Make app_auth_from_env asynchronous and await
crate::auth::load_private_key, which must use tokio::fs::read_to_string for file
paths instead of std::fs::read_to_string. Propagate async through every caller
of app_auth_from_env and the configuration entrypoint, updating call sites to
await the resulting futures while preserving existing error context.

---

Nitpick comments:
In `@crates/harnx-mcp-plans-github/tests/github_specific.rs`:
- Around line 45-78: Consolidate mock_issue_response and
mock_plan_issue_response into one helper by adding an optional labels parameter
to mock_issue_response and conditionally including the labels field in its JSON
output. Update all existing call sites to pass labels when creating plan issues
and None otherwise, then remove mock_plan_issue_response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc8916f7-cfeb-4cd8-bd89-709182649312

📥 Commits

Reviewing files that changed from the base of the PR and between 18c5d19 and 247cdb1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (30)
  • .github/workflows/release.yaml
  • Cargo.toml
  • README.md
  • crates/harnx-mcp-plans-core/Cargo.toml
  • crates/harnx-mcp-plans-core/src/conformance.rs
  • crates/harnx-mcp-plans-core/src/lib.rs
  • crates/harnx-mcp-plans-core/src/model.rs
  • crates/harnx-mcp-plans-core/src/server/handler.rs
  • crates/harnx-mcp-plans-core/src/server/handlers.rs
  • crates/harnx-mcp-plans-core/src/server/mod.rs
  • crates/harnx-mcp-plans-core/src/server/params.rs
  • crates/harnx-mcp-plans-core/src/store.rs
  • crates/harnx-mcp-plans-github/.gitignore
  • crates/harnx-mcp-plans-github/Cargo.toml
  • crates/harnx-mcp-plans-github/README.md
  • crates/harnx-mcp-plans-github/src/auth.rs
  • crates/harnx-mcp-plans-github/src/client.rs
  • crates/harnx-mcp-plans-github/src/codec.rs
  • crates/harnx-mcp-plans-github/src/config.rs
  • crates/harnx-mcp-plans-github/src/lib.rs
  • crates/harnx-mcp-plans-github/src/main.rs
  • crates/harnx-mcp-plans-github/src/ratelimit.rs
  • crates/harnx-mcp-plans-github/src/runtime.rs
  • crates/harnx-mcp-plans-github/src/store_github.rs
  • crates/harnx-mcp-plans-github/src/store_github/tests.rs
  • crates/harnx-mcp-plans-github/tests/conformance_github.rs
  • crates/harnx-mcp-plans-github/tests/github_specific.rs
  • crates/harnx-mcp-plans-github/tests/live_e2e.rs
  • docs/solutions/integration-issues/github-issues-storage-backend-2026-07-08.md
  • example_config/mcp_servers/plans-github.yaml
✅ Files skipped from review due to trivial changes (3)
  • crates/harnx-mcp-plans-core/Cargo.toml
  • crates/harnx-mcp-plans-github/.gitignore
  • README.md
🚧 Files skipped from review as they are similar to previous changes (21)
  • example_config/mcp_servers/plans-github.yaml
  • crates/harnx-mcp-plans-github/Cargo.toml
  • crates/harnx-mcp-plans-github/src/lib.rs
  • crates/harnx-mcp-plans-core/src/lib.rs
  • crates/harnx-mcp-plans-core/src/server/handler.rs
  • .github/workflows/release.yaml
  • Cargo.toml
  • crates/harnx-mcp-plans-github/tests/conformance_github.rs
  • crates/harnx-mcp-plans-core/src/conformance.rs
  • crates/harnx-mcp-plans-core/src/store.rs
  • crates/harnx-mcp-plans-core/src/server/params.rs
  • crates/harnx-mcp-plans-core/src/server/handlers.rs
  • crates/harnx-mcp-plans-github/tests/live_e2e.rs
  • crates/harnx-mcp-plans-core/src/model.rs
  • crates/harnx-mcp-plans-github/src/ratelimit.rs
  • crates/harnx-mcp-plans-github/src/runtime.rs
  • crates/harnx-mcp-plans-github/src/store_github/tests.rs
  • crates/harnx-mcp-plans-github/src/client.rs
  • crates/harnx-mcp-plans-github/src/store_github.rs
  • crates/harnx-mcp-plans-github/src/codec.rs
  • crates/harnx-mcp-plans-core/src/server/mod.rs

Comment on lines +110 to +113
let base_url = first_non_empty(base_url_arg, env("GITHUB_API_URL"))
.unwrap_or_else(|| DEFAULT_GITHUB_API_URL.to_string())
.trim_end_matches('/')
.to_string();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the API base URL, not just its trailing slash.

This path directly constructs AuthConfig, accepting malformed URLs and unsupported schemes that auth.rs::normalize_base_url rejects. Reuse that normalizer and add invalid URL/scheme tests.

Also applies to: 423-435

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/config.rs` around lines 110 - 113, The base
URL construction in the configuration initialization bypasses validation
performed by auth.rs::normalize_base_url. Apply normalize_base_url to the
selected argument/environment/default URL before constructing AuthConfig,
propagate a clear configuration error when normalization fails, and add tests
covering malformed URLs and unsupported schemes for both affected paths.

Comment on lines +288 to +303
fn app_auth_from_env<F>(env: &F) -> Result<Option<AppAuthConfig>>
where
F: Fn(&str) -> Option<String>,
{
let app_id = first_non_empty(None, env("GITHUB_APP_ID"));
let private_key = first_non_empty(None, env("GITHUB_APP_PRIVATE_KEY"));
let installation_id = first_non_empty(None, env("GITHUB_APP_INSTALLATION_ID"));

match (app_id, private_key, installation_id) {
(None, None, None) => Ok(None),
(Some(app_id), Some(private_key), Some(installation_id)) => Ok(Some(AppAuthConfig {
app_id,
private_key_pem: crate::auth::load_private_key(&private_key)
.context("load GitHub App private key")?,
installation_id,
})),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Load GitHub App private keys asynchronously.

auth::load_private_key uses std::fs::read_to_string when given a path. Convert it to tokio::fs::read_to_string and propagate async through the configuration entrypoint.

As per coding guidelines, “All I/O must be async via Tokio using async fn and .await.” <coding_guidelines>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/harnx-mcp-plans-github/src/config.rs` around lines 288 - 303, Make
app_auth_from_env asynchronous and await crate::auth::load_private_key, which
must use tokio::fs::read_to_string for file paths instead of
std::fs::read_to_string. Propagate async through every caller of
app_auth_from_env and the configuration entrypoint, updating call sites to await
the resulting futures while preserving existing error context.

Source: Coding guidelines

@dobesv
dobesv merged commit 2798789 into main Jul 10, 2026
6 of 7 checks passed
@dobesv
dobesv deleted the mcp-plans-github branch July 10, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant