Skip to content

feat(tracker): GitHub Projects feature parity — custom fields, saved views, filter grammar, roadmap, insights, workflows - #48

Open
shrijayan wants to merge 33 commits into
Platform-Collective:developfrom
shrijayan:feat/tracker-projects-parity
Open

shrijayan wants to merge 33 commits into
Platform-Collective:developfrom
shrijayan:feat/tracker-projects-parity

Conversation

@shrijayan

Copy link
Copy Markdown
Contributor

What

Brings /tracker/ to GitHub Projects feature parity, plus four extras GitHub does not have
(Calendar, Workload, nested grouping, and — in a later phase — an automation rule builder).

Design doc with the full gap analysis, the seven architecture decisions, the risk table and
per-phase implementation notes: docs/tracker-projects-parity/plan.md.

⚠️ Stacked on unmerged MCP work

This branch was created off feat/mcp-http-server, so the diff against develop also contains
11 MCP commits (pods/mcp, rush.json, dev/docker-compose.yaml, common/config).
Those have no PR of their own yet and are not part of this effort.

Reviewing this PR? Either

  • review only the tracker commits: git log d1a668cb2..HEAD, or
  • merge/raise the MCP PR first and this one will reduce to the tracker work automatically.

Scope corrections (verified against GitHub docs + live GraphQL schema)

Worth stating up front, because the naive reading of the feature list is wrong:

Assumed Actual
Many layouts (list/calendar/gantt/workload) Exactly 3: Table, Board, Roadmap. Insights is a separate top-level section, not a layout.
Current iteration, Next iteration, Prioritized backlog, In review, My items are GitHub defaults GitHub ships none of these. A new project gets one view of the chosen layout.
Custom trigger→action automations GitHub has no rule builder — only 2 built-in workflows + auto-add + auto-archive.
Many field types Exactly 6 custom types: Text, Number, Date, Single-select, Multi-select, Iteration.
"Stack by", Health/Priority/Estimate/Due date as built-ins, pie charts, CSV export None exist. Export is .tsv. 6 chart layouts, no pie.

Shipped

Parity phases 0–11

Phase Ships
0–1 Custom fields. ProjectField doc + Issue.customFields record + registry + CRUD UI + per-type editors + columns/filter/sort/group-by.
2 Saved views. View tabs, + New view, per-view columns/card fields/layout, unsaved-changes dot, rename/duplicate/delete/reorder, single getEffectiveViewConfig() source of truth.
3 Filter grammar. field: has: no: is: @me @today @current @next @previous, ranges a..b, wildcards, AND/OR + parentheses. One tokenizer, two consumers (server query + client eval for un-indexable custom fields).
4 Spreadsheet bulk edit. Cell multi-select, Shift+arrows, copy/paste, fill handle, Delete to clear, undo, row + column drag-reorder, row height.
5 Iterations. Field with 3 auto-created iterations, duration/breaks, @current/@next/@previous, board column field, rollups, bulk move.
6 Roadmap layout. Start/Target date or iteration fields, markers, Month/Quarter/Year zoom, drag-to-reschedule.
7 Board parity. Column field (select or iteration), swimlanes, advisory column limits, show/hide columns, card fields from the view config.
8 Hierarchy + slice + field sum. Sub-issue tree ≤8 levels with persisted expansion, slice-by panel, Σ number fields per group.
9 Insights. 6 chart layouts, config panel, persisted chart docs, archived items excluded.
10 Workflows. Done-on-close, auto-archive, auto-add-by-query, automation attribution, field-change webhook.
11 Project-level. Settings page with field sidebar, description + README, templates, status updates, item limit.

Extra phases 12–14 (reinstated by the author — GitHub has none of these)

Phase Ships
12 Calendar layout. Month / week / agenda.
13 Workload layout. Per-assignee capacity heatmap + reassignment.
14 Nested grouping. Multi-level group-by in Table (3 levels), Board (lane + sub-lane), Roadmap (2 levels). Pure builders with jest tests; collapsed state per viewer and per saved view.

Phase 15 (automation rule builder) is not in this PR — the field-change webhook from phase 10
is the parity-faithful route and it lands first.

The three decisions worth reviewing hardest

  1. Custom fields are a record, not attributes. The model is TxModel-fixed: core.class.Attribute
    docs are generated by the model build and pushed at server upgrade, so a user-defined field can
    never be a real attribute. Values therefore live in a Record<string, any> on Issue and custom-field
    filter/sort/group run client-side behind a configurable TRACKER_CUSTOM_FIELD_SCAN_LIMIT, which
    disables the operation with a visible error rather than silently truncating.
    Alternatives considered and rejected are in plan §3 D1.

  2. One filter tokenizer, two consumers. Server-side compiles the grammar to DocumentQuery;
    client-side evaluates the same tokens for custom fields the server cannot index. Sharing the
    tokenizer is what stops the two from drifting.

  3. Two sources of truth for view config, deliberately resolved. ViewOptions lives in
    localStorage; FilteredView is a server doc. Rule: an active saved view wins, localStorage is
    the fallback for ad-hoc state, and everything reads through one accessor.

Validation

Run locally against the 5 changed packages:

plugins/tracker-resources   tsc --noEmit clean   1215 tests pass (86 suites)
plugins/view-resources      tsc --noEmit clean    313 tests pass (16 suites)
models/tracker              tsc --noEmit clean      4 tests pass
plugins/view                tsc --noEmit clean
plugins/view-assets         lang key-parity test passes (14 locales)

1,533 tests, 0 failures. Locale parity is enforced by the existing
plugins/view-assets/src/__tests__/lang.test.ts, which fails the build if a new IntlString misses a
locale. No formatter was run (repo rule — it can corrupt files).

Known limitations (intentional, not oversights)

  • No historical Insights / burn-up charts. They need field-value history Huly does not have.
    Replaying DOMAIN_TX would work but degrade and pretend to be correct, so it was cut instead.
    Recorded as D6 in the plan.
  • Workspace-level roles only. Per-project roles are dropped (plan decision 5); project access is
    still the flat members: AccountUuid[].
  • No personal views. GitHub has none either — a saved view is visible to everyone with project access.
  • Custom-field filter/sort/group is client-side and bounded by the scan limit above.
  • Views carry no per-field ACLs, matching GitHub.

Review guidance

The interesting parts are the pure logic, which is small and heavily tested:

  • plugins/view-resources/src/filter/grammar/ — tokenizer, parser, compiler, evaluator
  • plugins/view-resources/src/nestedGroups.ts — group-by rows, empty-category rules
  • plugins/tracker-resources/src/grouping/ — nested bucket builder + level resolution
  • plugins/tracker-resources/src/iterations/, roadmap/, board/, insights/ — layout maths
  • server-plugins/tracker-resources/src/workflow/ — automation loop guards

The Svelte components are large but mostly presentational wiring over the above.

Model changes ship with migration steps in models/tracker/src/migration.ts; note that
builder.createDoc does not update already-stored viewlet docs, so
set-nested-group-depth exists to patch depth onto the pre-existing IssueList / IssueKanban /
IssueRoadmap viewlets.

shrijayan and others added 30 commits October 2, 2026 14:08
Exposes a Huly workspace to AI agents over MCP Streamable HTTP, as a regular
pod in the monorepo. It ships in the same Docker release as everything else and
is version-locked to the Huly API it talks to, because it consumes
@hcengineering/* as workspace dependencies.

Auth has two modes behind one Authenticator interface:

- configured (self-host): HULY_TOKEN or HULY_EMAIL+HULY_PASSWORD on the pod, so
  MCP clients need no Huly credential at all. This is what makes the endpoint
  usable from Claude Desktop.
- perRequest (multi-tenant): the client presents its own Huly API token.

Optional decorators: MCP_ALLOWED_TOKENS allowlist, and an MCP_READONLY clamp
applied outermost so it cannot be bypassed by a token flag.

Security:
- registers setApiTokenRevocationChecker at boot; without it verifyToken
  silently accepts revoked API tokens
- sessions are pinned to the account+workspace that created them, so a leaked
  Mcp-Session-Id is useless and a token swap returns 403
- guest tokens rejected, read-only tokens blocked from write tools
- never uses the system account, so writes stay permission-checked and
  attributed to the caller
- per-client rate limiting, since standalone pods get none from the platform
- refuses to boot when SECRET is unset, because server-token would otherwise
  verify every token against the literal string "secret"

No @modelcontextprotocol/sdk dependency: the server side of MCP is JSON-RPC 2.0
plus a small envelope, src/mcp/ is transport- and platform-agnostic and unit
tested, and the repo has no external AI SDK or zod today.

Tools: huly_search, huly_list_projects, huly_get_project, huly_list_issues,
huly_get_issue, huly_list_issue_statuses, huly_create_issue,
huly_update_issue, huly_add_issue_comment, huly_create_milestone,
huly_list_milestones, huly_list_tasks, huly_find_people, huly_create_person,
huly_list_spaces, huly_list_drives, huly_list_documents, huly_get_document.

Not modelled on ZubeidHendricks/huly-mcp: that project is a 6-commit demo with
hardcoded fixtures, a custom JSON-RPC dialect rather than MCP, no auth, and no
LICENSE file. Only its action inventory was used, as a feature checklist.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Body-parser rejections fell through errorHandler and surfaced as 500.
Adds regression tests, a README smoke-test section and an agentlog entry.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
A live run against a real workspace (huly v0.7.432 images) exposed defects
that typechecking and mocked unit tests could not:

- configured auth: an account-level token or email/password login is not
  bound to a workspace; now exchanged via selectWorkspace(HULY_WORKSPACE)
- create_issue: createDoc is illegal for AttachedDoc; use addCollection,
  number via the atomic project sequence $inc (fixes the max+1 race), and
  derive kind/default status from the project task type
- issue description was silently dropped; now stored through the collaborator
  service (COLLABORATOR_URL) and refused explicitly when it is not configured
- add_issue_comment: same AttachedDoc error, and the body must be rich text
- list_projects returned every tracker project twice and clobbered the issue
  key prefix, because task.class.Project is the base of tracker.class.Project
- find_people looked identities up by the wrong field and filtered after the
  page limit; matching now happens in the database
- list_issues/list_documents search used $regex (unsupported) and includeDone
  compared against a category name that does not exist
- get_issue/get_document returned raw ProseMirror JSON; now Markdown
- tools/list now hides write tools from read-only identities
- SECRET=secret is refused unless MCP_ALLOW_DEFAULT_SECRET=true, which the dev
  compose sets so the stack still starts

Adds 28 unit tests covering each of these. All 18 tools now pass a 38-check
end-to-end run against a live stack, both from source and from the built image.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
… args

- add huly_list_components, huly_create_component, huly_update_milestone
- huly_create_issue: parentIssueId (sub-issues, ancestor chain nearest first)
  and componentId; refusals happen before the project sequence is incremented
- huly_update_issue: componentId
- fix milestones: create/list used name/dueDate/done/project, but the model
  has label/targetDate/startDate/status and lives in the project space
- fix "null clears a field" being rejected by the validator: JsonSchema.type
  may now be an array, add nullableStringProp
- fix huly_get_issue returning no subtasks: sub-issues are attached to their
  parent, there is no `parent` field

Verified live against a local Huly stack (20 end-to-end checks) and by 111
unit tests.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
…ervice

- huly_get_workspace, huly_list_members (read)
- huly_update_workspace_name, huly_update_workspace_guest_settings,
  huly_set_member_role, huly_remove_member, huly_invite_member,
  huly_create_invite_link (write)
- calls go through a narrow AccountApi facade on the session, made with the
  caller's own workspace token so the account service enforces the role rules;
  Forbidden is reported as a sentence naming the action
- member names come from workspace Person docs (the account service's person
  lookup is service-only)
- deliberately not exposed: workspace deletion, API token minting/revoking,
  account merge/delete, password changes

Verified live with an Owner and a plain User against a local stack (14
checks) and by 124 unit tests.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
- huly_list_classes: discover queryable classes, filter by id substring,
  ancestor or kind
- huly_describe_class: fields, types, required/custom flags and collections
- huly_find: query any class with filters, sort, field projection and a total
  count; output is cut to 60k chars with a truncated flag
- huly_get_doc: fetch one document with rich-text fields as Markdown

Classes come from the client's Hierarchy, so there is no hard-coded list, and
every call runs as the caller so the transactor limits what is returned.

Verified live (17 checks, including a plain user seeing fewer issues than the
owner) and by 139 unit tests.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Adds huly_create_document / huly_update_document for pages, and
huly_create_doc / huly_update_doc / huly_delete_doc for any class that has a
write profile.

A write is only as good as the bookkeeping around it: the web client
initialises counters, derives kinds and ranks, and detaches references before a
delete. Letting an agent write an arbitrary class would skip all of that and
leave documents the UI misrenders. So a class is writable only when a profile
declares it, and every profile states the exact writable fields, which numeric
enums are exposed by name, and which space the document belongs in. Fields
outside a profile are refused rather than passed through.

Deletes go through caller-rights, which mirrors the ownership rule the web app
applies in the browser: the transactor would let any workspace member delete or
rename a space someone else owns, so the tools require a workspace Owner or the
creator of the document. This keeps an agent's reach no wider than the person
using the UI.

All five tools are readOnly: false, so MCP_READONLY and read-only tokens block
them through the existing registry gate.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
…ndings

Adds the @hcengineering/tags and @hcengineering/rank importers that
src/tools/write-profiles.ts needs. Without this every developer's
rush install would produce lockfile drift.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Review of Platform-Collective#43 raised three blockers, a licensing problem and some hygiene
items. This closes them.

Configured mode now requires a credential on the wire. It authenticates
nobody, so the pod's own account was reachable by anyone who could open
the port: loadConfig refuses to boot unless MCP_ALLOWED_TOKENS is set or
HOST is loopback. The allowlist runs before the configured authenticator,
so a rejected token never triggers a login attempt, and an absent one is
reported as `missing` so the client gets a WWW-Authenticate challenge.

Origin is validated before the cors() middleware. An unknown browser
origin gets 403 instead of a passing preflight, which closes the
DNS-rebinding path through the dev stack's published 4090. Requests with
no Origin header are untouched, so Claude Desktop and other non-browser
clients behave exactly as before. An empty list fails closed.

Read-only is decided per request rather than read from the cache. The
client cache was keyed by account:workspace and kept whichever identity
arrived first, so a full-access token could lend its workspaceToken and
permissions to a read-only token for the same workspace. The key now
includes the read-only flag, toolContext takes the caller's identity
explicitly, and a session can no longer be reused across privilege
classes.

Also: honour TRUST_PROXY so a reverse proxy does not collapse every
client into one rate-limit bucket, correct the rate limiter comment that
claimed account-based keying, replace the invalid copyright notice with
the SPDX identifier used on develop, drop the unused morgan dependency
and the svelte field, merge the duplicate HULY_WORKSPACE documentation,
remove the scratch log from the PR, and revert the unrelated language
change to the agent instruction files.

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Issue creation:
- derive the rank with makeRank instead of writing an empty string, so a
  new issue lands at the end of the project's order rather than relying on
  the rank sentinel being rewritten downstream
- validate the milestone and the assignee against the project and the
  workspace, the way the component id was already validated
- reserve the issue number before the description blob is written, so a
  failed reservation cannot leave an orphaned blob behind

Generic writes:
- page the detach sweep until no issue matches the query, and refuse the
  delete if it cannot finish, instead of stopping at 1000 and leaving
  dangling references
- union caller-supplied members and owners with the creator on teamspace
  create, so the creator can never be locked out of a space they made
- type-check pushed and pulled items against the model's element type

Endpoints:
- serve /api/v1/statistics only when MCP_STATS=true, defaulting to off
- drop authMode, readOnly and sessions from /api/v1/health and the
  landing page, which answer without a credential

Signed-off-by: Shrijayan <81805145+shrijayan@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ction

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nges tracking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Group by a field and then by another field, per view, with the levels
bounded by the layout and the collapsed state kept per viewer and per
saved view.

- Pure builders with jest tests next to them: grouping/nested.ts builds
  the nested buckets (any number of levels, explicit option order, "No
  <field>" last on every level, includeEmpty per level, stable path ids,
  flatten/visible/summarize/collapse helpers) and grouping/levels.ts
  resolves the levels (caps, de-duplication, skipping the board column
  field and deleted custom fields, "No grouping" ends the list).
  view-resources/nestedGroups.ts holds the shared list rows and the
  empty-category rules.
- Table: "Group by" / "Then by" rows (view.ThenBy in all 14 locales).
  Changing a level keeps the levels below it and drops only a key that
  became a duplicate; fixes an off-by-one that let the popup grow a
  fourth row in one session.
- Board: swimlanes in two levels (lane, then sub-lane). A drop on a
  sub-lane writes column + lane + sub-lane in one update.
- Roadmap: rows in two levels, field sums on every level. Replaces the
  single level roadmap/grouping.ts.
- View export lists the sub-lane keys between the lane and column keys.
- Model: groupDepth 3 for the table, 2 for the board and the roadmap.
  Stored viewlet docs are not updated by createDoc, so the upgrade step
  set-nested-group-depth of model-tracker applies it to the existing
  viewlets.

A stored single groupBy behaves exactly as before and the one-level list
code path is unchanged, so every other list and the sub-issues of an
issue are unaffected.

@ArtyomSavchenko ArtyomSavchenko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review — tracker ↔ GitHub Projects parity

Scope: tracker commits only (e43a9c6d..5f487eb4, 21 commits, 363 files, ~+55k). MCP commits are ignored — they're covered by a separate PR.

Verdict: not mergeable as is. The pure logic (filter grammar, layout maths, SSRF guard) is well written and well tested. The blockers are a red CI, server triggers that put data and performance at risk, and the plan's own note (plan.md §Phase 10, "Not verified") that nothing was run against a real server or in a browser.

Blockers

  1. CI is red, and it is a regression. On the PR head, uitest, uitest-pg and formatting fail, while all three are green on develop (fa7e2497). formatting fails on real eslint errors, not just prettier: plugins/tracker/src/projectStatus.ts:71, projectCopy.ts:156, iteration.ts:123 (number in a conditional), __tests__/workflow.test.ts:100-128, __tests__/project-status.test.ts:49. "No formatter was run" doesn't cover these.

  2. OnProjectItemLimit silently deletes newly created issues (server-plugins/tracker-resources/src/index.ts:246). Existing installations may already have projects with more than 50,000 items (imports, GitHub sync). After deploy, every new issue in such a project is removed right after it is created. There is no user-facing error, the parent's subIssues counter is not decremented, the sequence number is burned, and notifications have already gone out. On top of that, every issue creation now runs findAll(..., { total: true }), a COUNT over the project on a hot path. The limit should reject the create before it is written, or at least sit behind a flag. Compensating removal after the fact isn't acceptable.

  3. Lost updates on customFields. All values live in one Record, and every write replaces the whole record from the snapshot the writer saw. This applies to setIssueCustomFieldValue (projectFields/actions.ts:28), bulk edit, board move and roadmap reschedule, and on the server to OnIssueWorkflow, OnProjectFieldRemove and OnIterationRemove. Two users editing different fields of the same issue, or a user edit racing a workflow setting Status, will drop one of the changes. The plan doesn't discuss this. Please write per key (customFields.<key>) if the adapters support it, or at least merge against the fresh document on the server side.

  4. Hand-edited pnpm-lock.yaml. The plan says "edited by hand… run rush update to confirm". It needs a real rush update before merge.

Important

  • A webhook secret can be overridden by another user. newestSecret (webhook/trigger.ts:193) takes the newest ProjectWebhookSecret by webhook: id and never checks who created it. Secrets live in the author's personal space, so any workspace user who knows a webhook id can create a newer one. They then know the signing key and can forge deliveries to the receiver. Please restrict this to secrets whose createdBy is a project member or owner (ideally whoever last modified the webhook).
  • Webhook permissions are broad. Any member with write access can configure a webhook (plan.md:980). GitHub restricts this to admins. A webhook keeps exporting every issue change after its creator has been removed from the project. Consider limiting configuration to project owners.
  • Drafts are real Issue docs, so they show up in everything that queries tracker.class.Issue. Notifications, search and component/milestone lists are acknowledged in the plan (plan.md:1172). Unverified, and important: services/github/pod-github has no knowledge of isDraft on tracker issues, so a draft in a GitHub-connected project will likely be synced as a real GitHub issue with number 0. This needs checking before merge.
  • Heavy synchronous triggers.
    • OnProjectFieldRemove and OnIterationRemove load every issue in the project (up to 50k) with no projection and no customFields.<key> $exists filter, then emit up to 50k update txes in one sync batch.
    • replayAround (history.ts:25) reads the object's full tx history on every status change (OnIssueWorkflow, sync) and on every watched edit (webhooks).
    • Please add the filter and projection, and move these to async where possible.
  • A server package now depends on a UI package. server-tracker-resources gained a runtime dependency on @hcengineering/view-resources plus a deep import of src/filter/grammar (workflow/filter.ts:19). The grammar should move to a pure package (@hcengineering/view or a new one). Server bundling with this import is listed as not verified.
  • A global clientViewExtension in the shared List. It's one app-wide writable, set by IssuesView (:863) and cleared in onDestroy (:876). With two lists on screen, or a different mount/destroy order during navigation, the tracker extension can leak into other plugins' lists or be cleared under a live IssuesView. Passing it via context or props would be safer.

Minor

  • Build artifacts are committed: 8 *.d.ts.map files in plugins/view-resources/src/filter/grammar/, plus a dead plugins/tracker-resources/src/iterations/IterationPresenter.svelte.x.
  • isAutomationAuthor(..., core.account.System) skips every System-authored change, including integrations and migrations. As a result, "Done on close" won't fire when GitHub sync closes an issue. If that's intended, please document it.
  • IssuesView.svelte grows by ~1000 lines and RoadmapView.svelte is 1445 lines. "Mostly presentational wiring" no longer really holds.
  • PR size: 55k lines across 14 phases is hard to review meaningfully. Suggested split: (1) custom fields + saved views + filter grammar, (2) board/roadmap/iterations, (3) workflows + webhooks (server), (4) calendar/workload/nested grouping.

Done well

  • The webhook SSRF guard is thorough: the address is pinned at socket lookup so DNS rebinding can't get through, IPv4-mapped/NAT64/6to4/metadata addresses are covered, redirects aren't followed, and the response body isn't read.
  • The filter compiler splits server and client parts correctly, and LIKE metacharacters are left to client evaluation instead of being escaped.
  • isDraft: { $ne: true } is safe on Postgres, because the adapter's $ne handles missing fields.
  • Draft conversion uses apply + match.
  • The set-nested-group-depth migration is idempotent.

Generated by Claude Code

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.

2 participants