Skip to content

feat: enforce the plan's personal project limit - #624

Merged
scottlovegrove merged 3 commits into
mainfrom
scottlovegrove/personal-project-limit
Oct 31, 2026
Merged

scottlovegrove merged 3 commits into
mainfrom
scottlovegrove/personal-project-limit

Conversation

@scottlovegrove

@scottlovegrove scottlovegrove commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The API accepts a couple of personal projects past the plan limit (USER_LIMIT_BUFFER["PROJECTS"]) so offline clients can sync at the boundary. The Todoist apps check the real limit before writing, but td didn't, so td project create, td project unarchive and td project move --to-personal could take a Free account to seven projects. This is the same gap reported for the ChatGPT app in https://github.com/Doist/Issues/issues/21466.

  • ensurePersonalProjectCapacity (src/lib/project-limit.ts) calls the SDK's getPersonalProjectCapacity and throws PROJECT_LIMIT_REACHED with an archive/delete/upgrade hint when nothing is left.
  • project create, project unarchive (personal projects only) and project move --to-personal (active projects only) call it right before the write. Dry runs and the unconfirmed move preview don't.
  • wrapApiError maps the API's own refusal (403 MAX_PROJECTS_LIMIT_REACHED, reached only if a project is added between the check and the write) to PROJECT_LIMIT_REACHED. It previously fell through to AUTH_ERROR with a hint to check the API token.
  • getPersonalProjectCapacity is listed as a safe read so read-only tokens can still run the check, and gets a "Checking project limit..." spinner.

No commands or flags change, so SKILL_CONTENT is untouched.

Test plan

The API accepts a couple of personal projects past the plan limit so
offline clients can sync at the boundary, so td project create,
unarchive and move --to-personal could take a Free account to seven
projects. Each now checks the limit first with the SDK's
getPersonalProjectCapacity, as the Todoist apps do, and refuses with
PROJECT_LIMIT_REACHED. Unarchiving a workspace project and moving an
archived one are not checked, since neither adds an active personal
project.

When the API itself refuses a project (403 MAX_PROJECTS_LIMIT_REACHED),
the error now maps to PROJECT_LIMIT_REACHED instead of falling through
to AUTH_ERROR with a hint to check the API token.
@scottlovegrove scottlovegrove self-assigned this Oct 31, 2026
Brings in getPersonalProjectCapacity, which the project limit check uses.

@doistbot doistbot 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.

This PR adds a client-side personal project limit check before the three writes that can add an active personal project (project create, project unarchive, project move --to-personal), wires the check in correctly after dry-run/preview paths, maps the API's 403 limit tag to PROJECT_LIMIT_REACHED instead of an auth error, and registers getPersonalProjectCapacity as a safe read. Well-scoped and consistent throughout.

Few things worth tightening:

  • In ensurePersonalProjectCapacity, remaining === 0 only blocks accounts exactly at the limit; since the API buffer allows accounts to exceed the plan limit, remaining can be negative — use remaining <= 0 so any account without capacity is refused.
  • Add test coverage for the !project.isArchived exemption: moving an archived workspace project with --to-personal --yes should skip the capacity check even when remaining: 0, so removing that guard would fail a test.

I also left one optional follow-up note in the details below.

Optional follow-up note (1)
  • P3 src/commands/project/project.test.ts:2778: Dry-run skip coverage is incomplete. This test covers the no---yes preview path, but project move --to-personal --dry-run is untested, and project unarchive --dry-run (which returns before the new personal check) has no dry-run test at all. Add dry-run cases with the capacity mock set to remaining: 0, asserting the command resolves and getPersonalProjectCapacity is not called, so a check placed above the dry-run guards would be caught.

Share Feedback • Review Logs

Comment thread src/lib/project-limit.ts Resolved
Comment thread src/commands/project/project.test.ts Resolved
ensurePersonalProjectCapacity now refuses when remaining is zero or
less, so it does not rely on the SDK clamping remaining at zero for
accounts already over their limit.

Adds tests for moving an archived workspace project to personal at the
limit (no check), and for the unarchive and move dry runs (no check).
@scottlovegrove scottlovegrove added the 🚢 Ship PR requires passing checks before merge, but no independent human review. label Oct 31, 2026
@scottlovegrove
scottlovegrove merged commit 75dad5e into main Oct 31, 2026
6 checks passed
@scottlovegrove
scottlovegrove deleted the scottlovegrove/personal-project-limit branch October 31, 2026 13:17
doist-release-bot Bot added a commit that referenced this pull request Oct 31, 2026
## [5.9.0](v5.8.5...v5.9.0) (2026-10-31)

### Features

* enforce the plan's personal project limit ([#624](#624)) ([75dad5e](75dad5e))
@doist-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 5.9.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released 🚢 Ship PR requires passing checks before merge, but no independent human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants