merge: feature/personal-tasks → preview#10
Merged
Conversation
added 30 commits
May 16, 2026 22:16
Re-based onto the lark-stable line (this branch builds the production ghcr.io/jobyinc/plane-*:lark-stable images). Same fix as preview PR #3 (commit 1cb3908): replace the non-durable 25h Redis dedup in lark_due_reminder_task with a durable LarkDueReminderLog row claimed atomically via get_or_create on a unique (issue,receiver,stage,reminder_date) constraint; claim released via hard delete on failure so retry still works. Mirrors EmailNotificationLog + WorkspaceMember soft-delete-unique idiom. Migration = 0123_larkduereminderlog depending on 0122_automationrule_automationrulerun (this branch's actual head; it has no 0123_workitemfield — that is custom-fields/preview only). Verified locally pytest 3/3 on the source branch. Repo CI runs no python test suite. Effect requires this image rebuilt + droplet pull + the 0123 migration applied.
…rly repeat DMs) Root cause: execute_rule_on_issue deduped only via a Redis key with DEDUP_TTL_SECONDS=5min, but evaluate_scheduled_automations_task runs HOURLY. 5-min TTL << 60-min cadence => the key was always expired by the next tick, so a due_soon rule re-fired for every in-window issue every hour (and didn't survive cache loss), re-sending the "You've been assigned"/escalation card dozens of times. The AutomationRuleRun table was audit-only, not consulted for dedup. Fix: for the scheduled due_soon path only, add a durable gate — skip if a prior SUCCESS AutomationRuleRun already exists for this (rule, issue). Each due_soon rule now notifies an issue exactly once, durably (survives Redis loss/restart by construction), mirroring the lark_due_reminder durable-dedup approach. Reuses the existing AutomationRuleRun ledger — no new model/migration. NOT bypassed by bypass_dedup (once-per-issue is a hard requirement; re-saving a rule must not re-DM notified issues). Event-driven rules don't set trigger_type=="due_soon" and keep the short Redis burst-dedup — unaffected. Verified: manage.py check 0 issues; gate present in live source. CI runs no python suite; authoritative proof is post-deploy AutomationRuleRun (SKIPPED_DEDUP, no repeated SUCCESS per rule+issue).
…ze migrations Option A: integrate custom-fields (PR #2, on preview) into feature/lark-oauth-provider so the prod lark-stable image carries the lark fixes + custom-fields as one release. Clean auto-merge (0 file conflicts). Migration graph linearized: - removed preview's duplicate 0124_larkduereminderlog (same LarkDueReminderLog model is already created by 0123_larkduereminderlog, which is applied in the prod DB) - renamed 0123_workitemfield_workitemfieldoption_workitemfieldvalue -> 0124_workitemfield_workitemfieldoption_workitemfieldvalue, dep re-pointed 0122 -> 0123_larkduereminderlog Result: linear 0122 -> 0123_larkduereminderlog -> 0124_workitemfield. makemigrations --check clean; manage.py check 0 issues; automation durable-once gate + lark_due_reminder durable dedup both intact.
…erlog The merge commit e93e94f renamed preview's workitemfield migration to 0124 but left its dependency at 0122_automationrule_automationrulerun. That made 0124_workitemfield and 0123_larkduereminderlog parallel leaf nodes, so the prod migrator crash-looped: CommandError: Conflicting migrations detected; multiple leaf nodes (0123_larkduereminderlog, 0124_workitemfield...) Repointing the dependency linearizes the graph (0122 -> 0123_larkduereminderlog -> 0124_workitemfield) so the migrator applies cleanly and the work_item_fields tables get created.
Asana-style list: drag a built-in column header's right edge to resize; width persists in display_filters.view_column_prefs (rides existing JSON — no schema migration, per-user, survives reload + syncs across devices). getListGridTemplateWithCustom takes an optional widths override keyed uniformly by built-in or custom column key; header + rows realign via the shared --list-cols var. Commit-on-pointer-up (live drag preview + custom-column resize + Asana borders/blue indicator follow in 1b).
Hover a column boundary -> a 6px blue line marks it (Plane's primary token is a near-black neutral, so an explicit blue is used for clear light/dark visibility). Drag -> a 2px blue guide line spans the whole list (bounds from the data-list-grid scroll container) and tracks the cursor, clamped at minWidth; the column snaps to it on pointer-up and the width persists (Increment 1 channel). Pure UI, no migration.
Subtle vertical separators between every column in the header and every row, aligned via the shared --list-cols template. Done at the grid container / display:contents wrapper level with a child selector ([&>*:not(:last-child)]:border-r border-subtle) so it's 3 spots not per-cell, and the actions column gets no trailing edge line (Asana parity). Pure UI; column gap retained (flush-line variant deferred).
getComputedDisplayFilters rebuilds displayFilters from a fixed set of known keys on every load, so view_column_prefs (the F1/F2 per-column order+width store) was silently dropped on every page load. Effect: resized/reordered columns 'snapped back' to defaults after refresh. This is why the committed F2 width feature (97574a3) only appeared to persist — the value lived in the in-session MobX observable; a cold reload re-fetched from the backend and this normalizer stripped it. One-line passthrough of view_column_prefs fixes F2 width persistence AND unblocks F1 order persistence. Backend already stores it raw (ProjectUserPropertySerializer fields=__all__); getEnabledDisplayFilters is identity — this normalizer was the only stripper.
F1 (4a): getVisibleListColumns honors persisted view_column_prefs.order (default-order fallback for unlisted/added columns, unchanged when no order set). columnOrder threaded default->ListGroup->IssueBlocksList-> IssueBlockRoot->IssueBlock (parallel to displayProperties) so header AND rows resolve the same order and stay aligned. Drag UI to set the order is the next increment (4b). Title column resize: a stable TITLE_COLUMN_KEY in view_column_prefs; the title track flexes minmax(min,1fr) by default (Asana Task-column behaviour, no regression) and switches to a fixed px width once the user drags it. ColumnResizeHandle now measures the actual rendered column box at pointer-down so a drag on the flexing title starts from its real width (equals currentWidth for fixed columns).
Built-in column headers are draggable (same pragmatic-drag-and-drop adapter as row drag); a blue bar on the target's near edge marks the drop; on drop the full reordered built-in sequence is persisted to view_column_prefs.order, so header + rows realign via getVisibleListColumns. Title column is pinned (not wrapped); the click-to-sort menu and the resize grip still work (drag only past a threshold; resize grip stops propagation). Custom-field column parity (reorder/resize/gridline) is the deferred 4c follow-up.
ColumnResizeHandle on both CustomColumnHeaderCell branches (admin / plain), persisting width into view_column_prefs.widths[customKey]. The width-apply plumbing already existed since Inc1 (getListGridTemplateWithCustom uses widths?.[c.key]); this only wires the grip + commit channel. Read-only views (no handleDisplayFilterUpdate) render no grip. Parity with built-in column resize.
- 4c-2: custom-field columns get drag-to-reorder parity with built-in columns (separate LIST_CUSTOM_COLUMN dnd group, persisted via view_column_prefs.order) - #4: custom column resize handle/blue line now reaches the column edge (w-full on both CustomColumnHeaderCell branch roots) - #5: group grey bar spans the full grid incl. custom columns - #1: group-by-card '+' sits right after the title (Asana-style) - a11y: list group-by-card collapse row + create button converted to semantic <button> (matches kanban header pattern; required by pre-commit oxlint --deny-warnings on the staged file)
Built-in and custom columns now share ONE ordered sequence and ONE drag group, so a custom field can be dragged before/intermixed with built-in columns (previously two separate dnd groups that could not cross). - list-columns.ts: TListColumnDescriptor union + getOrderedListColumns (interleaves built-in/custom by persisted view_column_prefs.order, unlisted keys fall back to default slots) + getUnifiedListGridTemplate; removed the now-orphaned getOrderedCustomColumns / getListGridTemplateWithCustom 2-group helpers - list-header-row.tsx: single map over the unified sequence, single reorder handler, single dnd type - block.tsx: single ordered cell render dispatched by kind - default.tsx: --list-cols grid built from the unified sequence - draggable-column-header.tsx: dropped the dndType split (4c-2 orphan) Back-compatible: an older order array that only reordered one group still renders identically (no migration, no jump for existing users). Title pinned first, actions pinned last (Asana parity).
Built-in column header dropdown gains a 'Hide field' item that toggles
the column's displayProperties off. Re-show is Plane's existing Display
dropdown (native display-properties round-trip — reversible with no new
UI, persists per user).
- base-list-root.tsx: handleDisplayPropertiesUpdate (DISPLAY_PROPERTIES
channel, mirrors handleDisplayFiltersUpdate)
- threaded List -> ListHeaderRow -> ListSortHeaderCell
- list-sort-header-cell.tsx: 'Hide field' menu item ->
handleDisplayPropertiesUpdate({ [getDisplayPropertyKey(column)]: false })
(mirrors the canonical display-properties.tsx toggle shape)
- i18n: common.actions.hide_field / show_field (en + zh-CN)
Custom-field columns unchanged here; their hide + re-show lands in B2.
…(Inc B2) Custom-field columns can be hidden per user and restored from the list UI (no Display-dropdown entry exists for custom fields, so hide must be reversible in-context — it is never one-way). - view-props.ts: TViewColumnPrefs.hidden?: string[] (rides display_filters JSON, per user, no migration) - list-columns.ts: getOrderedListColumns gains a hidden param (excludes hidden custom columns from header + rows + grid template uniformly); getHiddenCustomColumns helper for the re-show list - columnHidden threaded default -> list-group -> blocks-list -> block-root -> block (mirrors the proven columnOrder path; Inc A untouched) - list-header-row.tsx: hide/show callbacks (DISPLAY_FILTERS channel), onHide wired per custom header, hidden list + onShow to the + button - custom-column-header.tsx: menu now available to ALL users (Hide for everyone; Edit/Delete admin-only); + button became a menu listing hidden fields to restore (non-admins with nothing hidden = unchanged empty slot); header now mirrors the built-in ListSortHeaderCell exactly (full-width row, label + ChevronDownIcon, same tokens) - i18n hide_field/show_field already added in B1 (en + zh-CN) Built-in column hide stays on the native displayProperties channel (B1).
The Work item column (header cell, every row's first cell, and the group-header card) is pinned left while the rest of the columns scroll horizontally — Asana/spreadsheet parity, mirroring Plane's own Spreadsheet layout sticky-first-column pattern. - header first cell: sticky left-0 z-[4] bg-layer-1 (top-left corner above sticky-top header z-[3] and body sticky cells z-[1]) - row first cell: lg/md sticky left-0 z-[1], opaque bg mirroring the row resting/selected/dragging states; desktop-gated so the mobile stacked layout is untouched - group-header card wrapped in sticky left-0 w-max bg-layer-1 so the title/count/+ stays pinned while the full-width grey bar scrolls (does not regress #5) - bleed fix: row grid is items-center so the frozen cell was only content-height and scrolled cells bled through the row's py-3 band — added self-stretch + items-center so the opaque bg covers the full row height - 20px left gap (pl-5) on all three frozen containers so the text isn't flush to the viewport edge; absolute select checkbox unaffected (positions off the padding box)
Exposes project custom fields on the public (API-token) API, which previously had NO custom-field coverage — external integrations could not even discover the field schema. Read-only first increment: GET .../projects/<id>/fields/ list field defs GET .../projects/<id>/fields/<fid>/ retrieve one GET .../projects/<id>/fields/<fid>/options/ select options GET .../projects/<id>/issues/<iid>/field-values/ one issue's values GET .../projects/<id>/issue-field-values/ bulk (?issue_ids=) - mirrors the State public-endpoint pattern: BaseAPIView (APIKeyAuthentication) + ProjectEntityPermission + project-membership scoped querysets + use_read_replica - reuses the internal serialize_field_value helper (pure model logic) so the public payload shape matches the internal API exactly — no duplicated type->column mapping - purely additive: new serializer/view/url modules + 3 aggregator __init__ registrations; zero changes to existing endpoints, no new model/migration Verified locally: all 5 routes flip 404->401 after restart (registered + whole import chain clean), control endpoint (states) still 401 (no regression). Write paths (value upsert/clear, field CRUD) land in Inc2/Inc3.
External-agent auto-fill path — set or clear one custom field's value on a work item via the public (API-token) API: PUT .../issues/<iid>/field-values/<fid>/ set/replace value DELETE .../issues/<iid>/field-values/<fid>/ clear value - reuses the internal assign_field_value helper for typed coercion, so validation/column-mapping matches the internal API exactly (number rejects non-numeric -> 400, not 500) - hardened vs the internal version: verifies the work item actually belongs to this project/workspace before writing, so a token scoped to project A cannot attach a value to project B's issue - ProjectEntityPermission (same as State create/patch); additive only, no model/migration Verified locally end-to-end with a seeded API token: PUT number=8 -> 200 (value 8.0 persisted), GET reflects it, PUT 'abc' -> 400, DELETE -> 204, GET -> []. No regression to Inc1 reads or existing endpoints.
External tokens can now create/update/archive custom field schemas and their select options via the public API: POST .../fields/ create field PATCH .../fields/<fid>/ update field DELETE .../fields/<fid>/ archive (is_active=False) POST .../fields/<fid>/options/ create option PATCH .../fields/<fid>/options/<oid>/ update option DELETE .../fields/<fid>/options/<oid>/ archive option - schema mutations are project ADMIN only (ProjectAdminPermission via a per-method get_permissions mixin: reads = any member, writes = admin) mirroring the internal @allow_permission([ROLE.ADMIN]) gate so a leaked non-admin token cannot reshape the data model - reuses the Inc1 serializers (explicit field whitelists, read-only id/project/workspace/timestamps -> no mass-assignment); soft-delete archive (no hard data loss via API); querysets project/membership scoped (no cross-project access); option create verifies parent field in project - additive only, no model/migration Verified locally e2e with a seeded admin token: create/patch/delete field + option all succeed, DELETE soft-archives (is_active=False), controls + Inc1/Inc2 unaffected.
…Task2) Unifies labels, custom single/multi-select, priority and state into one solid-fill rounded pill with contrast text — replacing the prior mix of bordered chips, tiny colour dots and 12%-opacity tints. - label.tsx: shared LABEL_PILL_CLASS + labelPillStyle (solid bg, white text or near-black on light colours via @plane/utils luminance); reused everywhere so there is one source of truth - labels.tsx: LabelItem/Summary use the pill; 4px gap + flex-wrap between multiple label pills - work-item-field-cell.tsx: single_select / multi_select selected chips are now solid pills (was dot+text / colour+20% tint) - priority.tsx: all 3 button variants (Border/Background/Transparent) render a solid pill in the text mode using the semantic --priority-* colours; compact icon-only mode unchanged - state/base.tsx: text mode renders a solid pill from state.color (contained — neutralises the shared DropdownButton chrome via !-utils, no shared-component change) - label.ts: LABEL_COLOR_OPTIONS swapped to the vivid color-hex palette (#3be8b0/#1aafd0/#6a67ce/#ffb900/#fc636b); fixed an out-of-range LABEL_COLOR_OPTIONS[7] default that the smaller palette would break - pill vertical padding +5px (py-[7px]) for breathing room - priority/state: bounded eslint-disable for the pre-existing repo-wide ComboDropDown a11y lint (same rule the codebase already disables; surfaced only because these files are now staged) cf_local_stack.py seed changes (demo labels + palette + Profile fix) are intentionally NOT committed (untracked dev harness).
All projects/users default to Tabbed navigation instead of Accordion. - workspace.py: WorkspaceUserProperties.navigation_control_preference model default ACCORDION -> TABBED (new rows) - migration 0125: AlterField (matches the model) + a data migration flipping every existing row still on 'ACCORDION' to 'TABBED' so current users get it too (the model default never touches existing rows). Reverse is a no-op — we cannot tell which rows chose ACCORDION deliberately vs. via the old silent default, so we don't blanket-revert. - navigation-preferences.ts: frontend DEFAULT_PROJECT_PREFERENCES fallback ACCORDION -> TABBED (covers users with no prefs row yet) Backend change: requires deploy + running migration 0125 on the host to take effect in production. Verified locally — migration applies clean (0124 -> 0125, no leaf conflict).
Surfaces the existing Favorites feature into the project context menu (发布项目/复制链接/归档/设置). Favourite state is read from the favorite store's entityMap because the sidebar uses the partial project object (projects-API optimisation dropped is_favorite). Reuses useProject().add/removeProjectToFavorites + setPromiseToast, mirroring the card-view implementation. Pre-existing DnD lint (no-shadow on inner getData param, react-hooks/exhaustive-deps) suppressed with targeted disables at the actual violation nodes; not introduced by this change.
…roject Tasks no longer require picking a project. A per-user private "My Tasks" project is lazily created server-side (hidden from normal project lists, owner-only ADMIN member, Secret network) and reused so project-less tasks need NO issue-schema change — they get states, custom fields, layouts, etc. for free. Backend (Inc1): Project.is_personal + personal_owner + migration 0126; GET workspaces/<slug>/projects/personal/ get-or-create endpoint mirroring the create() bootstrap; exclude is_personal from the normal project list/list_detail (still reachable by pk for issue CRUD). Frontend (Inc2): projectService.getPersonalProject; sidebar "My Tasks" entry (workspace.ts + sidebar-item staticItems + ce icon); /my-tasks route resolves the personal project and redirects to its issues page (reuses 100% of existing issue UI + create flow); i18n en/zh. Verified end-to-end against the local stack: migration applied, endpoint 200 creating a correct bucket, idempotent, excluded from the normal project list.
Pre-existing bug, unrelated to the personal-tasks feature. The repo ships a requestIdleCallback/cancelIdleCallback polyfill in core/lib/polyfills but no module ever imported it, so it was dead code: WebKit/Safari < 17.4 crashed in any virtualized list (render-if-visible-HOC's unguarded window.requestIdleCallback). Fix: side-effect import "@/lib/polyfills" in app/entry.client.tsx so the existing polyfill runs before any component mounts — fixes it globally, no new code, HOC left untouched.
Lift the My Tasks bucket bootstrap out of ProjectViewSet.personal() into plane.app.services so an upcoming token-API endpoint can reuse the same logic to create personal projects on behalf of other workspace members. The session-API wrapper passes only owner (actor defaults to it), preserving prior behavior. Uses project.save(created_by_id=actor.id) instead of the original Project.objects.create(..., created_by=actor) so the helper honors the passed actor regardless of crum's current-user context. The inline predecessor relied on a request being in flight; that assumption would not hold for out-of-band callers (celery tasks, the upcoming token endpoint, or unit tests).
Gate token-API endpoints that act on behalf of workspace members other than the token's own owner (cross-user writes into personal projects, workspace-wide assignee scans). Reuses the existing DB-only is_service flag on APIToken — no new schema, no new self-grant API path. Wired by the upcoming personal-tasks and assigned-work-items viewsets.
APIKeyAuthentication.authenticate returns request.auth = api_token.token (the raw string), not the APIToken model. The original mock-based unit tests passed but the production check would have silently denied every service-tier token. Re-query the row from the header value instead — same pattern already in BaseAPIView.get_throttles. Also tighten with is_active=True to mirror the auth class. Tests rewritten against real APIToken rows.
System-tier endpoint at POST/PATCH /api/v1/workspaces/{slug}/personal-tasks/
that creates and updates work items in any workspace member's personal
"My Tasks" project on behalf of that member. Gated by IsSystemToken;
ordinary tokens are 403'd. Idempotency reuses the existing Issue
contract — duplicate (project, external_source, external_id) returns
409 + the existing work item id. PATCH may only touch work items whose
external_source matches the body, enforcing the §5 rule 2 privacy
boundary.
Adds APIActivityLog.acting_on_behalf_of (nullable UUID, indexed,
migration 0128) wired by APITokenLogMiddleware so every audit row for
these calls records the target member, making "system-on-behalf-of-X"
queryable downstream. The viewset sets the attribute on the underlying
Django request (not the DRF wrapper) so the middleware — which runs at
the Django layer — can read it.
The bucket get-or-create path comes from the C1 helper, which resolves
spec §12.4 lazy-create from the server side: a token call works even
when the target member has never opened "My Tasks" in the web UI.
11 contract tests cover: non-system 403, anon 401/403, missing-owner
400, missing-external-keys 400, owner-not-in-workspace 403, lazy-create
+ 201, duplicate 409+id, PATCH wrong-source 403, PATCH matching-source
200, PATCH nonexistent 404, audit row carries owner UUID as
acting_on_behalf_of.
System-tier endpoint at
GET /api/v1/workspaces/{slug}/assigned-work-items/?assignee=<uuid>
that returns the target user's assigned work items across the entire
workspace — both their personal "My Tasks" project AND any shared
project they're a member of. Privacy boundary is the assignee filter:
only items where the target is assigned appear; other members' items
and items in shared projects the target is not on do not leak.
This is the read side of spec §6 Q1 (a-extended) — no external_source
filter on the read path. The §5 rule 2 source-restricted boundary
stays on the write side (PATCH /personal-tasks/{id}/) where it
matters; reads need to be "everything assigned" so the agent-side
"today" view doesn't miss tasks the user created themselves.
Optional filters: state_group (CSV) and target_date_before (ISO).
Pagination uses BasePaginator cursor envelope. Audit row records the
query.assignee UUID via the same _acting_on_behalf_of hook the
personal-tasks endpoint uses.
8 contract tests cover non-system 403, anon 401, missing/malformed
assignee 400, cross personal+shared completeness, privacy boundary
(no leak), state_group filter, empty case, and audit row UUID.
Adds operation_id / summary / description / tags annotations to the three new endpoints (POST + PATCH personal-tasks, GET assigned-work-items) so drf-spectacular surfaces polished entries in the OpenAPI schema served at /api/v1/schema/. No CHANGELOG file in this fork — skipping that part of the original plan. Cross-repo skill mirror sync (skills-shared/joby-plane/tick-agent-reference.md §12.4 + SKILL.md bump) is a post-ship step, intentionally out of scope here.
Adds /personal-tasks/ POST+PATCH and /assigned-work-items/ GET under IsSystemToken gate. APIActivityLog.acting_on_behalf_of column (migration 0128) for audit. get_or_create_personal_project helper extracted from session API. 28 contract+unit tests passing.
| assign_field_value(value_row, field, request.data.get("value")) | ||
| except Exception as exc: | ||
| detail = getattr(exc, "detail", None) or str(exc) | ||
| return Response({"error": detail}, status=status.HTTP_400_BAD_REQUEST) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings the full `feature/personal-tasks` line into `preview` for deployment. ~28 commits spanning multiple feature epics that have stacked on this branch since it last synced with preview.
Major epics rolled up
Deploy notes
Test plan