From 5ae278c6a5d3eb57d122dda2a07cacabed06c2e4 Mon Sep 17 00:00:00 2001 From: Surya Prashanth Date: Sun, 23 Aug 2026 02:00:56 +0530 Subject: [PATCH] =?UTF-8?q?feat(v2):=20add=20the=20complete=20api=5Fv2=20s?= =?UTF-8?q?urface=20=E2=80=94=20120=20resource=20groups,=20406=20operation?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.v2` exposes every api_v2 operation through a single chained form rooted at the workspace, mirroring the API's own scope tree: ws = client.v2.workspace("acme") # zero-I/O locator proj = ws.project("ENG") # key or UUID proj.work_items.create(WorkItemWrite(name="Fix login bug", state="Todo")) ws.work_items.retrieve_by_identifier("ENG-12") ws.wiki.pages.create(PageWrite(name="Runbook")) # public page -> default collection client.v2.users.me() # the six non-workspace operations - Kernel: transport with RFC 9457 errors, offset/cursor envelopes with a stall guard, ?fields/?expand/?order_by validated per operation against the golden, upsert, bulk create/update/delete with per-row results, find_by_name, custom verb actions, scope-bound resources (`V2Resource(transport, **scope)`). - Spec-generated constants (`scripts/generate_v2_constants.py`) for all 406 operations; every implemented operation is declared in exactly one resource's `operations` map and a two-way coverage test enforces 406/406. - Method set is identical to @makeplane/plane-node-sdk (snake_case vs camelCase). - Offline tests under tests/v2 (responses); live tests under tests/v2/integration skip without PLANE_BASE_URL/PLANE_API_KEY/WORKSPACE_SLUG. - CI: .github/workflows/test.yml runs the offline suite; a secret-gated `v2-golden-drift` job regenerates the constants against plane-ee's golden. - Version 0.3.0. v1 surface untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015XXZ9CT96T1dZoiSYmtiNe --- .github/workflows/test.yml | 97 + CLAUDE.md | 22 +- README.md | 115 + plane/__init__.py | 6 + plane/api/v2/__init__.py | 124 + plane/api/v2/_generated/__init__.py | 0 plane/api/v2/_generated/constants.py | 5860 +++++++++++++++++ plane/api/v2/_kernel/__init__.py | 0 plane/api/v2/_kernel/errors.py | 84 + plane/api/v2/_kernel/pagination.py | 51 + plane/api/v2/_kernel/resource.py | 282 + plane/api/v2/_kernel/transport.py | 71 + plane/api/v2/artifacts.py | 52 + plane/api/v2/assets.py | 111 + plane/api/v2/audit_logs.py | 49 + plane/api/v2/automations/__init__.py | 155 + plane/api/v2/automations/activities.py | 98 + plane/api/v2/automations/edges.py | 125 + plane/api/v2/automations/nodes.py | 160 + plane/api/v2/collections/__init__.py | 93 + plane/api/v2/collections/members.py | 55 + plane/api/v2/collections/pages.py | 56 + plane/api/v2/customer_properties.py | 70 + plane/api/v2/customers/__init__.py | 7 + plane/api/v2/customers/customers.py | 93 + plane/api/v2/customers/property_values.py | 37 + plane/api/v2/customers/requests.py | 64 + plane/api/v2/cycles.py | 132 + plane/api/v2/estimates/__init__.py | 100 + plane/api/v2/estimates/points.py | 100 + plane/api/v2/features.py | 57 + plane/api/v2/group_sync/__init__.py | 26 + plane/api/v2/group_sync/config.py | 32 + plane/api/v2/group_sync/project_mappings.py | 58 + plane/api/v2/group_sync/workspace_mappings.py | 65 + plane/api/v2/initiatives/__init__.py | 6 + plane/api/v2/initiatives/initiatives.py | 116 + plane/api/v2/initiatives/labels.py | 60 + plane/api/v2/intakes.py | 52 + plane/api/v2/invitations.py | 79 + plane/api/v2/labels.py | 74 + plane/api/v2/members.py | 111 + plane/api/v2/milestones.py | 99 + plane/api/v2/modules.py | 111 + plane/api/v2/pages.py | 150 + plane/api/v2/permission_schemes.py | 49 + plane/api/v2/permissions.py | 38 + plane/api/v2/project.py | 58 + plane/api/v2/projects.py | 144 + plane/api/v2/releases/__init__.py | 139 + plane/api/v2/releases/changelog.py | 36 + plane/api/v2/releases/comments.py | 65 + plane/api/v2/releases/labels.py | 57 + plane/api/v2/releases/links.py | 60 + plane/api/v2/releases/tags.py | 65 + plane/api/v2/roles.py | 58 + plane/api/v2/states.py | 74 + plane/api/v2/stickies.py | 59 + plane/api/v2/teamspaces.py | 69 + plane/api/v2/users.py | 23 + plane/api/v2/views/__init__.py | 9 + plane/api/v2/views/project.py | 61 + plane/api/v2/views/workspace.py | 62 + plane/api/v2/webhook_logs.py | 51 + plane/api/v2/webhooks.py | 77 + plane/api/v2/wiki.py | 18 + plane/api/v2/work_item_properties/__init__.py | 122 + plane/api/v2/work_item_properties/contexts.py | 77 + plane/api/v2/work_item_properties/options.py | 56 + .../work_item_properties/workspace_options.py | 56 + .../api/v2/work_item_relation_definitions.py | 88 + plane/api/v2/work_item_templates/__init__.py | 7 + plane/api/v2/work_item_templates/project.py | 82 + plane/api/v2/work_item_templates/workspace.py | 65 + plane/api/v2/work_item_types/__init__.py | 166 + plane/api/v2/work_item_types/properties.py | 138 + plane/api/v2/work_items/__init__.py | 156 + plane/api/v2/work_items/activities.py | 65 + plane/api/v2/work_items/attachments.py | 83 + plane/api/v2/work_items/comments.py | 113 + plane/api/v2/work_items/dependencies.py | 36 + plane/api/v2/work_items/links.py | 60 + plane/api/v2/work_items/relations.py | 36 + plane/api/v2/work_items/worklogs.py | 75 + plane/api/v2/work_items/workspace.py | 62 + plane/api/v2/workflows/__init__.py | 7 + plane/api/v2/workflows/states.py | 67 + plane/api/v2/workflows/transitions.py | 70 + plane/api/v2/workflows/workflows.py | 57 + plane/api/v2/worklogs.py | 26 + plane/api/v2/workspace.py | 75 + plane/client/plane_client.py | 4 + plane/config.py | 3 + plane/models/v2/__init__.py | 587 ++ plane/models/v2/artifacts.py | 76 + plane/models/v2/assets.py | 121 + plane/models/v2/audit_logs.py | 54 + plane/models/v2/automations.py | 181 + plane/models/v2/collections.py | 132 + plane/models/v2/common.py | 97 + plane/models/v2/customer_properties.py | 91 + plane/models/v2/customers.py | 152 + plane/models/v2/cycle_actions.py | 43 + plane/models/v2/cycles.py | 58 + plane/models/v2/estimates.py | 90 + plane/models/v2/features.py | 68 + plane/models/v2/group_sync.py | 98 + plane/models/v2/initiatives.py | 121 + plane/models/v2/intakes.py | 68 + plane/models/v2/invitations.py | 39 + plane/models/v2/labels.py | 44 + plane/models/v2/members.py | 47 + plane/models/v2/milestone_work_items.py | 29 + plane/models/v2/milestones.py | 43 + plane/models/v2/module_work_items.py | 28 + plane/models/v2/modules.py | 67 + plane/models/v2/pages.py | 77 + plane/models/v2/permission_schemes.py | 26 + plane/models/v2/permissions.py | 11 + plane/models/v2/project_role_distribution.py | 35 + plane/models/v2/projects.py | 139 + plane/models/v2/releases.py | 250 + plane/models/v2/roles.py | 21 + plane/models/v2/states.py | 55 + plane/models/v2/stickies.py | 47 + plane/models/v2/teamspaces.py | 45 + plane/models/v2/users.py | 23 + plane/models/v2/views.py | 71 + plane/models/v2/webhook_logs.py | 28 + plane/models/v2/webhooks.py | 69 + plane/models/v2/work_item_properties.py | 208 + .../v2/work_item_relation_definitions.py | 59 + plane/models/v2/work_item_templates.py | 76 + plane/models/v2/work_item_types.py | 131 + plane/models/v2/work_items.py | 328 + plane/models/v2/workflows.py | 127 + plane/models/v2/worklogs_summary.py | 17 + pyproject.toml | 2 +- scripts/generate_v2_constants.py | 143 + tests/v2/__init__.py | 0 tests/v2/conftest.py | 8 + tests/v2/fixtures/__init__.py | 0 tests/v2/fixtures/_hidden/__init__.py | 0 tests/v2/fixtures/_hidden/resource.py | 13 + tests/v2/fixtures/nested/__init__.py | 0 tests/v2/fixtures/nested/deep/__init__.py | 0 tests/v2/fixtures/nested/deep/resource.py | 13 + tests/v2/integration/__init__.py | 0 tests/v2/integration/conftest.py | 133 + tests/v2/integration/helpers.py | 101 + tests/v2/integration/test_artifacts.py | 27 + tests/v2/integration/test_assets.py | 99 + tests/v2/integration/test_audit_logs.py | 40 + tests/v2/integration/test_automations.py | 356 + tests/v2/integration/test_bulk.py | 160 + tests/v2/integration/test_collections.py | 191 + tests/v2/integration/test_crud.py | 167 + .../integration/test_customer_properties.py | 54 + tests/v2/integration/test_customers.py | 144 + tests/v2/integration/test_cycle_actions.py | 139 + tests/v2/integration/test_errors.py | 41 + tests/v2/integration/test_estimates.py | 168 + tests/v2/integration/test_features.py | 69 + tests/v2/integration/test_find_one.py | 47 + tests/v2/integration/test_full_scenario.py | 164 + tests/v2/integration/test_group_sync.py | 120 + tests/v2/integration/test_initiatives.py | 106 + tests/v2/integration/test_intakes.py | 50 + tests/v2/integration/test_invitations.py | 64 + tests/v2/integration/test_members.py | 70 + .../integration/test_milestone_work_items.py | 60 + .../v2/integration/test_module_work_items.py | 61 + tests/v2/integration/test_pages.py | 164 + tests/v2/integration/test_pagination.py | 88 + .../v2/integration/test_permission_schemes.py | 37 + tests/v2/integration/test_permissions.py | 30 + .../test_project_work_item_types_flow.py | 179 + tests/v2/integration/test_projects.py | 139 + tests/v2/integration/test_releases.py | 323 + tests/v2/integration/test_roles.py | 38 + tests/v2/integration/test_scope_parity.py | 78 + tests/v2/integration/test_stickies.py | 51 + tests/v2/integration/test_teamspaces.py | 54 + tests/v2/integration/test_upsert.py | 41 + tests/v2/integration/test_users.py | 22 + tests/v2/integration/test_views.py | 68 + tests/v2/integration/test_webhook_logs.py | 59 + tests/v2/integration/test_webhooks.py | 78 + .../integration/test_work_item_properties.py | 303 + .../test_work_item_relation_definitions.py | 67 + .../test_work_item_sub_resources.py | 296 + .../integration/test_work_item_templates.py | 109 + tests/v2/integration/test_work_item_types.py | 219 + tests/v2/integration/test_work_items.py | 234 + tests/v2/integration/test_workflows.py | 102 + tests/v2/integration/test_worklogs_summary.py | 37 + .../test_workspace_work_item_types_flow.py | 164 + .../integration/test_workspace_work_items.py | 56 + tests/v2/test_artifacts_resource.py | 82 + tests/v2/test_assets_resource.py | 172 + tests/v2/test_audit_logs_resource.py | 76 + tests/v2/test_automations_resource.py | 397 ++ tests/v2/test_bulk.py | 166 + tests/v2/test_collections_resource.py | 207 + tests/v2/test_customer_properties_resource.py | 84 + tests/v2/test_customers_resource.py | 155 + tests/v2/test_cycles_resource.py | 158 + tests/v2/test_errors.py | 43 + tests/v2/test_estimates_resource.py | 267 + tests/v2/test_features_resource.py | 64 + tests/v2/test_find_one.py | 79 + tests/v2/test_generated_constants.py | 26 + tests/v2/test_group_sync_resource.py | 146 + tests/v2/test_initiatives_resource.py | 145 + tests/v2/test_intakes_resource.py | 70 + tests/v2/test_invitations_resource.py | 88 + tests/v2/test_labels_resource.py | 108 + tests/v2/test_live_smoke.py | 51 + tests/v2/test_locators.py | 148 + tests/v2/test_members_resource.py | 139 + tests/v2/test_milestones_resource.py | 148 + tests/v2/test_modules_resource.py | 150 + tests/v2/test_operations_coverage.py | 109 + tests/v2/test_pages_resource.py | 166 + tests/v2/test_pagination.py | 140 + tests/v2/test_permission_schemes_resource.py | 71 + tests/v2/test_permissions_resource.py | 61 + tests/v2/test_projects_resource.py | 220 + tests/v2/test_releases_resource.py | 375 ++ tests/v2/test_resource.py | 293 + tests/v2/test_roles_resource.py | 69 + tests/v2/test_states_resource.py | 70 + tests/v2/test_stickies_resource.py | 66 + tests/v2/test_teamspaces_resource.py | 75 + tests/v2/test_transport.py | 84 + tests/v2/test_users_resource.py | 55 + tests/v2/test_views_resource.py | 88 + tests/v2/test_webhook_logs_resource.py | 66 + tests/v2/test_webhooks_resource.py | 111 + .../v2/test_work_item_properties_resource.py | 353 + ...work_item_relation_definitions_resource.py | 86 + tests/v2/test_work_item_templates_resource.py | 135 + tests/v2/test_work_item_types_resource.py | 336 + tests/v2/test_work_items_resource.py | 443 ++ tests/v2/test_workflows_resource.py | 158 + tests/v2/test_worklogs_summary_resource.py | 44 + .../v2/test_workspace_work_items_resource.py | 81 + 247 files changed, 29434 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 plane/api/v2/__init__.py create mode 100644 plane/api/v2/_generated/__init__.py create mode 100644 plane/api/v2/_generated/constants.py create mode 100644 plane/api/v2/_kernel/__init__.py create mode 100644 plane/api/v2/_kernel/errors.py create mode 100644 plane/api/v2/_kernel/pagination.py create mode 100644 plane/api/v2/_kernel/resource.py create mode 100644 plane/api/v2/_kernel/transport.py create mode 100644 plane/api/v2/artifacts.py create mode 100644 plane/api/v2/assets.py create mode 100644 plane/api/v2/audit_logs.py create mode 100644 plane/api/v2/automations/__init__.py create mode 100644 plane/api/v2/automations/activities.py create mode 100644 plane/api/v2/automations/edges.py create mode 100644 plane/api/v2/automations/nodes.py create mode 100644 plane/api/v2/collections/__init__.py create mode 100644 plane/api/v2/collections/members.py create mode 100644 plane/api/v2/collections/pages.py create mode 100644 plane/api/v2/customer_properties.py create mode 100644 plane/api/v2/customers/__init__.py create mode 100644 plane/api/v2/customers/customers.py create mode 100644 plane/api/v2/customers/property_values.py create mode 100644 plane/api/v2/customers/requests.py create mode 100644 plane/api/v2/cycles.py create mode 100644 plane/api/v2/estimates/__init__.py create mode 100644 plane/api/v2/estimates/points.py create mode 100644 plane/api/v2/features.py create mode 100644 plane/api/v2/group_sync/__init__.py create mode 100644 plane/api/v2/group_sync/config.py create mode 100644 plane/api/v2/group_sync/project_mappings.py create mode 100644 plane/api/v2/group_sync/workspace_mappings.py create mode 100644 plane/api/v2/initiatives/__init__.py create mode 100644 plane/api/v2/initiatives/initiatives.py create mode 100644 plane/api/v2/initiatives/labels.py create mode 100644 plane/api/v2/intakes.py create mode 100644 plane/api/v2/invitations.py create mode 100644 plane/api/v2/labels.py create mode 100644 plane/api/v2/members.py create mode 100644 plane/api/v2/milestones.py create mode 100644 plane/api/v2/modules.py create mode 100644 plane/api/v2/pages.py create mode 100644 plane/api/v2/permission_schemes.py create mode 100644 plane/api/v2/permissions.py create mode 100644 plane/api/v2/project.py create mode 100644 plane/api/v2/projects.py create mode 100644 plane/api/v2/releases/__init__.py create mode 100644 plane/api/v2/releases/changelog.py create mode 100644 plane/api/v2/releases/comments.py create mode 100644 plane/api/v2/releases/labels.py create mode 100644 plane/api/v2/releases/links.py create mode 100644 plane/api/v2/releases/tags.py create mode 100644 plane/api/v2/roles.py create mode 100644 plane/api/v2/states.py create mode 100644 plane/api/v2/stickies.py create mode 100644 plane/api/v2/teamspaces.py create mode 100644 plane/api/v2/users.py create mode 100644 plane/api/v2/views/__init__.py create mode 100644 plane/api/v2/views/project.py create mode 100644 plane/api/v2/views/workspace.py create mode 100644 plane/api/v2/webhook_logs.py create mode 100644 plane/api/v2/webhooks.py create mode 100644 plane/api/v2/wiki.py create mode 100644 plane/api/v2/work_item_properties/__init__.py create mode 100644 plane/api/v2/work_item_properties/contexts.py create mode 100644 plane/api/v2/work_item_properties/options.py create mode 100644 plane/api/v2/work_item_properties/workspace_options.py create mode 100644 plane/api/v2/work_item_relation_definitions.py create mode 100644 plane/api/v2/work_item_templates/__init__.py create mode 100644 plane/api/v2/work_item_templates/project.py create mode 100644 plane/api/v2/work_item_templates/workspace.py create mode 100644 plane/api/v2/work_item_types/__init__.py create mode 100644 plane/api/v2/work_item_types/properties.py create mode 100644 plane/api/v2/work_items/__init__.py create mode 100644 plane/api/v2/work_items/activities.py create mode 100644 plane/api/v2/work_items/attachments.py create mode 100644 plane/api/v2/work_items/comments.py create mode 100644 plane/api/v2/work_items/dependencies.py create mode 100644 plane/api/v2/work_items/links.py create mode 100644 plane/api/v2/work_items/relations.py create mode 100644 plane/api/v2/work_items/worklogs.py create mode 100644 plane/api/v2/work_items/workspace.py create mode 100644 plane/api/v2/workflows/__init__.py create mode 100644 plane/api/v2/workflows/states.py create mode 100644 plane/api/v2/workflows/transitions.py create mode 100644 plane/api/v2/workflows/workflows.py create mode 100644 plane/api/v2/worklogs.py create mode 100644 plane/api/v2/workspace.py create mode 100644 plane/models/v2/__init__.py create mode 100644 plane/models/v2/artifacts.py create mode 100644 plane/models/v2/assets.py create mode 100644 plane/models/v2/audit_logs.py create mode 100644 plane/models/v2/automations.py create mode 100644 plane/models/v2/collections.py create mode 100644 plane/models/v2/common.py create mode 100644 plane/models/v2/customer_properties.py create mode 100644 plane/models/v2/customers.py create mode 100644 plane/models/v2/cycle_actions.py create mode 100644 plane/models/v2/cycles.py create mode 100644 plane/models/v2/estimates.py create mode 100644 plane/models/v2/features.py create mode 100644 plane/models/v2/group_sync.py create mode 100644 plane/models/v2/initiatives.py create mode 100644 plane/models/v2/intakes.py create mode 100644 plane/models/v2/invitations.py create mode 100644 plane/models/v2/labels.py create mode 100644 plane/models/v2/members.py create mode 100644 plane/models/v2/milestone_work_items.py create mode 100644 plane/models/v2/milestones.py create mode 100644 plane/models/v2/module_work_items.py create mode 100644 plane/models/v2/modules.py create mode 100644 plane/models/v2/pages.py create mode 100644 plane/models/v2/permission_schemes.py create mode 100644 plane/models/v2/permissions.py create mode 100644 plane/models/v2/project_role_distribution.py create mode 100644 plane/models/v2/projects.py create mode 100644 plane/models/v2/releases.py create mode 100644 plane/models/v2/roles.py create mode 100644 plane/models/v2/states.py create mode 100644 plane/models/v2/stickies.py create mode 100644 plane/models/v2/teamspaces.py create mode 100644 plane/models/v2/users.py create mode 100644 plane/models/v2/views.py create mode 100644 plane/models/v2/webhook_logs.py create mode 100644 plane/models/v2/webhooks.py create mode 100644 plane/models/v2/work_item_properties.py create mode 100644 plane/models/v2/work_item_relation_definitions.py create mode 100644 plane/models/v2/work_item_templates.py create mode 100644 plane/models/v2/work_item_types.py create mode 100644 plane/models/v2/work_items.py create mode 100644 plane/models/v2/workflows.py create mode 100644 plane/models/v2/worklogs_summary.py create mode 100644 scripts/generate_v2_constants.py create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/conftest.py create mode 100644 tests/v2/fixtures/__init__.py create mode 100644 tests/v2/fixtures/_hidden/__init__.py create mode 100644 tests/v2/fixtures/_hidden/resource.py create mode 100644 tests/v2/fixtures/nested/__init__.py create mode 100644 tests/v2/fixtures/nested/deep/__init__.py create mode 100644 tests/v2/fixtures/nested/deep/resource.py create mode 100644 tests/v2/integration/__init__.py create mode 100644 tests/v2/integration/conftest.py create mode 100644 tests/v2/integration/helpers.py create mode 100644 tests/v2/integration/test_artifacts.py create mode 100644 tests/v2/integration/test_assets.py create mode 100644 tests/v2/integration/test_audit_logs.py create mode 100644 tests/v2/integration/test_automations.py create mode 100644 tests/v2/integration/test_bulk.py create mode 100644 tests/v2/integration/test_collections.py create mode 100644 tests/v2/integration/test_crud.py create mode 100644 tests/v2/integration/test_customer_properties.py create mode 100644 tests/v2/integration/test_customers.py create mode 100644 tests/v2/integration/test_cycle_actions.py create mode 100644 tests/v2/integration/test_errors.py create mode 100644 tests/v2/integration/test_estimates.py create mode 100644 tests/v2/integration/test_features.py create mode 100644 tests/v2/integration/test_find_one.py create mode 100644 tests/v2/integration/test_full_scenario.py create mode 100644 tests/v2/integration/test_group_sync.py create mode 100644 tests/v2/integration/test_initiatives.py create mode 100644 tests/v2/integration/test_intakes.py create mode 100644 tests/v2/integration/test_invitations.py create mode 100644 tests/v2/integration/test_members.py create mode 100644 tests/v2/integration/test_milestone_work_items.py create mode 100644 tests/v2/integration/test_module_work_items.py create mode 100644 tests/v2/integration/test_pages.py create mode 100644 tests/v2/integration/test_pagination.py create mode 100644 tests/v2/integration/test_permission_schemes.py create mode 100644 tests/v2/integration/test_permissions.py create mode 100644 tests/v2/integration/test_project_work_item_types_flow.py create mode 100644 tests/v2/integration/test_projects.py create mode 100644 tests/v2/integration/test_releases.py create mode 100644 tests/v2/integration/test_roles.py create mode 100644 tests/v2/integration/test_scope_parity.py create mode 100644 tests/v2/integration/test_stickies.py create mode 100644 tests/v2/integration/test_teamspaces.py create mode 100644 tests/v2/integration/test_upsert.py create mode 100644 tests/v2/integration/test_users.py create mode 100644 tests/v2/integration/test_views.py create mode 100644 tests/v2/integration/test_webhook_logs.py create mode 100644 tests/v2/integration/test_webhooks.py create mode 100644 tests/v2/integration/test_work_item_properties.py create mode 100644 tests/v2/integration/test_work_item_relation_definitions.py create mode 100644 tests/v2/integration/test_work_item_sub_resources.py create mode 100644 tests/v2/integration/test_work_item_templates.py create mode 100644 tests/v2/integration/test_work_item_types.py create mode 100644 tests/v2/integration/test_work_items.py create mode 100644 tests/v2/integration/test_workflows.py create mode 100644 tests/v2/integration/test_worklogs_summary.py create mode 100644 tests/v2/integration/test_workspace_work_item_types_flow.py create mode 100644 tests/v2/integration/test_workspace_work_items.py create mode 100644 tests/v2/test_artifacts_resource.py create mode 100644 tests/v2/test_assets_resource.py create mode 100644 tests/v2/test_audit_logs_resource.py create mode 100644 tests/v2/test_automations_resource.py create mode 100644 tests/v2/test_bulk.py create mode 100644 tests/v2/test_collections_resource.py create mode 100644 tests/v2/test_customer_properties_resource.py create mode 100644 tests/v2/test_customers_resource.py create mode 100644 tests/v2/test_cycles_resource.py create mode 100644 tests/v2/test_errors.py create mode 100644 tests/v2/test_estimates_resource.py create mode 100644 tests/v2/test_features_resource.py create mode 100644 tests/v2/test_find_one.py create mode 100644 tests/v2/test_generated_constants.py create mode 100644 tests/v2/test_group_sync_resource.py create mode 100644 tests/v2/test_initiatives_resource.py create mode 100644 tests/v2/test_intakes_resource.py create mode 100644 tests/v2/test_invitations_resource.py create mode 100644 tests/v2/test_labels_resource.py create mode 100644 tests/v2/test_live_smoke.py create mode 100644 tests/v2/test_locators.py create mode 100644 tests/v2/test_members_resource.py create mode 100644 tests/v2/test_milestones_resource.py create mode 100644 tests/v2/test_modules_resource.py create mode 100644 tests/v2/test_operations_coverage.py create mode 100644 tests/v2/test_pages_resource.py create mode 100644 tests/v2/test_pagination.py create mode 100644 tests/v2/test_permission_schemes_resource.py create mode 100644 tests/v2/test_permissions_resource.py create mode 100644 tests/v2/test_projects_resource.py create mode 100644 tests/v2/test_releases_resource.py create mode 100644 tests/v2/test_resource.py create mode 100644 tests/v2/test_roles_resource.py create mode 100644 tests/v2/test_states_resource.py create mode 100644 tests/v2/test_stickies_resource.py create mode 100644 tests/v2/test_teamspaces_resource.py create mode 100644 tests/v2/test_transport.py create mode 100644 tests/v2/test_users_resource.py create mode 100644 tests/v2/test_views_resource.py create mode 100644 tests/v2/test_webhook_logs_resource.py create mode 100644 tests/v2/test_webhooks_resource.py create mode 100644 tests/v2/test_work_item_properties_resource.py create mode 100644 tests/v2/test_work_item_relation_definitions_resource.py create mode 100644 tests/v2/test_work_item_templates_resource.py create mode 100644 tests/v2/test_work_item_types_resource.py create mode 100644 tests/v2/test_work_items_resource.py create mode 100644 tests/v2/test_workflows_resource.py create mode 100644 tests/v2/test_worklogs_summary_resource.py create mode 100644 tests/v2/test_workspace_work_items_resource.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a4ab751 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,97 @@ +name: Test Python SDK + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -e . + pip install -r requirements.txt + + - name: Lint (scoped to api_v2) + # Scoped to v2: the v1 tree has 28 pre-existing ruff errors and is not gated yet. + run: ruff check plane/api/v2 plane/models/v2 tests/v2 + + - name: Unit tests (tests/v2, excluding integration) + run: pytest tests/v2 --ignore=tests/v2/integration -q + + - name: Integration tests (tests/v2/integration) + # No PLANE_* env vars are set in CI, so every test here must skip via the + # repo's env-var skip gate rather than run against a live API. A run that + # isn't all-skips here means the skip gate itself is broken. + run: pytest tests/v2/integration -q + + # mypy is intentionally NOT run in this workflow: the codebase currently has 56 + # pre-existing mypy errors under `--strict` (see pyproject.toml's [tool.mypy]). + # Add a mypy step once that baseline is cleaned up. + + v2-golden-drift: + needs: check-secrets + if: ${{ needs.check-secrets.outputs.has_token == 'true' }} + runs-on: ubuntu-latest + # Runs inside plane-python-sdk/ with plane-ee as a sibling dir: the generator records + # its golden path verbatim in the output header, so the relative spelling must match + # the committed one (`../plane-ee/apps/api/plane/api_v2/core/schema/openapi`). + defaults: + run: + working-directory: plane-python-sdk + steps: + - uses: actions/checkout@v4 + with: + path: plane-python-sdk + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + # black is required by scripts/generate_v2_constants.py to format its output + # so the regenerated file matches the committed one byte-for-byte. + run: pip install -r requirements.txt + + - name: Checkout plane-ee (api_v2 OpenAPI golden) + # PLANE_EE_CHECKOUT_TOKEN: fine-grained PAT with read access to makeplane/plane-ee. + # Without it this job is skipped (visibly), not failed. + uses: actions/checkout@v4 + with: + repository: makeplane/plane-ee + ref: preview + token: ${{ secrets.PLANE_EE_CHECKOUT_TOKEN }} + sparse-checkout: apps/api/plane/api_v2/core/schema/openapi + sparse-checkout-cone-mode: false + path: plane-ee + + - name: Check generated v2 constants against the api_v2 golden + run: | + python scripts/generate_v2_constants.py ../plane-ee/apps/api/plane/api_v2/core/schema/openapi + git diff --exit-code plane/api/v2/_generated/constants.py + + check-secrets: + runs-on: ubuntu-latest + outputs: + has_token: ${{ steps.check.outputs.has_token }} + steps: + - name: Check whether PLANE_EE_CHECKOUT_TOKEN is configured + # `secrets` isn't reliably available in a job-level `if:` on every runner + # context, so export the check as a step output here instead and gate the + # v2-golden-drift job on that output. + id: check + run: echo "has_token=${{ secrets.PLANE_EE_CHECKOUT_TOKEN != '' }}" >> "$GITHUB_OUTPUT" diff --git a/CLAUDE.md b/CLAUDE.md index 8789c1b..525e09f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Plane Python SDK (`plane-sdk` on PyPI, v0.2.4) — a synchronous, type-annotated Python client for the Plane API. Built on `requests` + `pydantic` v2, targeting Python 3.10+. +Plane Python SDK (`plane-sdk` on PyPI, v0.3.0) — a synchronous, type-annotated Python client for the Plane API. Built on `requests` + `pydantic` v2, targeting Python 3.10+. ## Common Commands @@ -69,6 +69,26 @@ PlaneClient - `plane/client/` — `PlaneClient` (API key / access token auth) and `OAuthClient` (OAuth 2.0 flows). - `plane/errors/` — `PlaneError` → `HttpError`, `ConfigurationError`. - `plane/config.py` — `Configuration` and `RetryConfig` dataclasses. +- `plane/api/v2/` — the v2 surface. The chain is the only public form: + `client.v2.workspace(slug)` (`Workspace`, `plane/api/v2/workspace.py`) and + `.project(project)` (`Project`, `plane/api/v2/project.py`) are zero-I/O locators + that bind `slug`/`project_id` once; every v2 resource hangs off one of them as a + plain attribute (`.wiki` on `Workspace` is itself a small locator, `Wiki` in + `plane/api/v2/wiki.py`, holding `.pages`/`.collections`). `client.v2.users` / + `.user_assets` are the only resources kept directly on `V2Namespace` (the 6 + operations with no workspace in their path). `_kernel/` holds the shared + machinery: `V2Resource.__init__(transport, **scope)` stores the bound scope, + and `_collection_url`/`_detail_url` merge it with any explicitly passed path + params (explicit wins) — a resource constructed with no scope (most offline + tests do this) behaves exactly as if every path param were passed per call, so + a resource's methods work identically whether or not it was reached through + the chain. No public v2 method takes `workspace_slug`/`project` parameters — + the locator supplies both; leaf ids (`work_item_id`, `release_id`, ...) stay as + the first positional argument. `_generated/constants.py` is produced by + `scripts/generate_v2_constants.py` from the api_v2 OpenAPI golden and must + never be hand-edited. +- `plane/models/v2/` — v2 pydantic models. Read models mark every field except `id` + optional, because `?fields=` and collection deferral can omit any of them. ### Sub-resource pattern diff --git a/README.md b/README.md index 9635e65..e9e4a4e 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,121 @@ work_items = client.work_items.list( ) ``` +## API v2 + +`client.v2` reaches the v2 surface. v1 resources on the client are unchanged. + +The chain is the **only** public form: bind a workspace once +(`client.v2.workspace(slug)`), then a project inside it once more +(`.project(project)`) — both are zero-I/O locators, not requests. Every v2 +resource hangs off one of the two as a plain attribute; nothing takes a +`workspace_slug`/`project` parameter directly, because the scope you bound already +supplies it. `client.v2.users` and `client.v2.user_assets` are the only exceptions +— the 6 v2 operations with no workspace in their path stay directly on `client.v2`. + +```python +from plane import PlaneClient +from plane.models.v2 import CreateState + +client = PlaneClient(base_url="https://api.plane.so", api_key="...") + +# Bind once -- "acme" is a workspace slug, "ENG" a project key (a UUID works too) +eng = client.v2.workspace("acme").project("ENG") + +# Projects address by key; states resolve by name +todo = eng.states.find_by_name("Todo") + +# Ask for only the fields you need +for state in eng.states.iterate(fields=["id", "name"]): + print(state.id, state.name) + +eng.states.create(CreateState(name="In Review", color="#4ECDC4")) + +# Batches report per row; partial success is the default +result = eng.states.bulk_create([CreateState(name="QA", color="#fff")]) +result.raise_for_failures() +``` + +Sparse responses mean every read field except `id` is optional — check for `None` +rather than assuming a field is present. + +`eng.labels` follows the same shape as `eng.states`: `list`, `iterate`, `retrieve`, +`find_by_name`, `create`, `update`, `delete`, `upsert`, `bulk_create`, `bulk_update`, +`bulk_delete`. Models: `State`, `CreateState`, `UpdateState`, `Label`, `CreateLabel`, +`UpdateLabel`, `BulkWriteResponse`, `OffsetPage`, `CursorPage`, all importable from +`plane.models.v2`. + +`eng.cycles`, `eng.modules` and `eng.milestones` offer the same CRUD/upsert/bulk +surface. Milestones' identifying field is `title`, not `name`, but `find_by_name` +still takes a `name` argument — the API's own list filter aliases `?name=` to the +`title` column. Models: `Cycle`, `CreateCycle`, `UpdateCycle`, `Module`, `CreateModule`, +`UpdateModule`, `ModuleStatus`, `Milestone`, `CreateMilestone`, `UpdateMilestone`, all +importable from `plane.models.v2`. + +Wiki resources are workspace-scoped, reached under `.wiki`: + +```python +from plane.models.v2 import CreatePage + +ws = client.v2.workspace("acme") + +# A public page created without `collection_id` lands in the workspace's default +# ("General") collection server-side; private pages need an explicit private one. +handbook = ws.wiki.collections.find_by_name("Engineering handbook") +ws.wiki.pages.create(CreatePage(name="Runbook", collection_id=handbook.id)) +ws.wiki.collections.default() # the default collection, resolved by `is_default` +``` + +Work items follow the same pattern, and readable identifiers come first: + +```python +from plane.models.v2 import CreateWorkItem + +eng = client.v2.workspace("acme").project("ENG") +item = eng.work_items.create(CreateWorkItem(name="Fix login bug", state="Todo", labels=["bug"])) +eng.work_items.comments.list(item.id) + +# By human key, with no project needed -- `ws.work_items` spans every project +ws.work_items.retrieve_by_identifier("ENG-12") +``` + +Every other resource hangs off the same two locators with the same shape -- +`ws.members`, `ws.releases.comments`, `eng.cycles`, `eng.work_item_types.properties`, +... -- and none of them take `workspace_slug`/`project` arguments: the locator +supplies both. + +Errors from `client.v2` calls raise `PlaneAPIError` (RFC 9457 problem detail — +`.status`, `.type`, `.code`, `.detail`, `.errors`), and `find_by_name` raises +`NoMatchFound` or `MultipleMatchesFound` when it can't resolve to exactly one row. +All three, plus `FieldError` (the shape of one entry in `.errors`), are re-exported +from both `plane.api.v2` and the top-level `plane` package: + +```python +from plane.api.v2 import MultipleMatchesFound, NoMatchFound, PlaneAPIError + +# or, equivalently: +# from plane import MultipleMatchesFound, NoMatchFound, PlaneAPIError + +try: + state = eng.states.find_by_name("Todo") +except NoMatchFound: + ... +except MultipleMatchesFound: + ... + +try: + eng.states.create(CreateState(name="", color="#fff")) +except PlaneAPIError as e: + print(e.status, e.code, e.detail) +``` + +Bulk writes (`bulk_create`, `bulk_update`, `bulk_delete`) cap at 50 rows per call and +answer HTTP 200 even when some rows fail — call `result.raise_for_failures()` to turn +partial failure into an exception, or inspect `result.failures` yourself. An empty +batch is rejected client-side with a `ValueError` before any request is sent — the API +itself 400s on `[]` (every bulk schema requires at least one row), so the client mirrors +that instead of round-tripping a request guaranteed to fail. + ## Architecture ### Client Structure diff --git a/plane/__init__.py b/plane/__init__.py index e8f613c..20e6d5f 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -13,6 +13,7 @@ from .api.stickies import Stickies from .api.teamspaces import Teamspaces from .api.users import Users +from .api.v2 import FieldError, MultipleMatchesFound, NoMatchFound, PlaneAPIError, V2Namespace from .api.work_item_properties import WorkItemProperties from .api.work_item_relation_definitions import WorkItemRelationDefinitions from .api.work_item_type_governance import WorkItemTypeGovernance @@ -102,6 +103,11 @@ "PlaneClient", "OAuthClient", "Configuration", + "V2Namespace", + "PlaneAPIError", + "NoMatchFound", + "MultipleMatchesFound", + "FieldError", "AgentRuns", "WorkItems", "WorkItemTypes", diff --git a/plane/api/v2/__init__.py b/plane/api/v2/__init__.py new file mode 100644 index 0000000..2b102aa --- /dev/null +++ b/plane/api/v2/__init__.py @@ -0,0 +1,124 @@ +"""api_v2 surface. Reached as `client.v2`. +Chain `.workspace(slug)`/`.project(project)` (zero-I/O locators) for most +resources; 6 have no workspace in their path and sit on `V2Namespace` directly.""" + +from ...config import Configuration +from ._kernel.errors import FieldError, MultipleMatchesFound, NoMatchFound, PlaneAPIError +from ._kernel.transport import V2Transport +from .artifacts import Artifacts +from .assets import UserAssets, WorkspaceAssets +from .audit_logs import AuditLogs +from .automations import ProjectAutomations, WorkspaceAutomations +from .collections import CollectionMembers, CollectionPages, Collections +from .customer_properties import CustomerProperties +from .customers import Customers +from .cycles import Cycles +from .estimates import Estimates +from .features import ProjectFeatures, WorkspaceFeatures +from .group_sync import GroupSync +from .initiatives import Initiatives +from .intakes import Intakes +from .invitations import Invitations +from .labels import Labels +from .members import ProjectMembers, WorkspaceMembers +from .milestones import Milestones +from .modules import Modules +from .pages import ProjectPages, WikiPages +from .permission_schemes import PermissionSchemes +from .permissions import ProjectPermissions, WorkspacePermissions +from .project import Project +from .projects import Projects +from .releases import Releases +from .roles import Roles +from .states import States +from .stickies import Stickies +from .teamspaces import Teamspaces +from .users import Users +from .views import ProjectViews, WorkspaceViews +from .webhooks import Webhooks +from .wiki import Wiki +from .work_item_properties import WorkItemProperties, WorkspaceWorkItemProperties +from .work_item_relation_definitions import WorkItemRelationDefinitions +from .work_item_templates import ProjectWorkItemTemplates, WorkspaceWorkItemTemplates +from .work_item_types import WorkItemTypes, WorkspaceWorkItemTypes +from .work_items import WorkItems, WorkspaceWorkItems +from .workflows import Workflows +from .worklogs import ProjectWorklogs +from .workspace import Workspace + + +class V2Namespace: + """The 6 non-workspace-scoped operations, plus the workspace locator. + + Everything else is reached via `.workspace(slug)` (`Workspace`) and `.project(project)`.""" + + def __init__(self, config: Configuration) -> None: + self.transport = V2Transport(config) + self.users = Users(self.transport) + self.user_assets = UserAssets(self.transport) + + def workspace(self, workspace_slug: str) -> Workspace: + """Bind a workspace. Makes no request.""" + return Workspace(self.transport, workspace_slug) + + +__all__ = [ + "Artifacts", + "AuditLogs", + "ProjectAutomations", + "WorkspaceAutomations", + "CollectionMembers", + "CollectionPages", + "Collections", + "Customers", + "CustomerProperties", + "Cycles", + "Estimates", + "FieldError", + "GroupSync", + "Initiatives", + "Intakes", + "Invitations", + "Labels", + "Milestones", + "Modules", + "MultipleMatchesFound", + "NoMatchFound", + "PermissionSchemes", + "ProjectPermissions", + "WorkspacePermissions", + "PlaneAPIError", + "Project", + "ProjectFeatures", + "ProjectMembers", + "ProjectPages", + "ProjectViews", + "Projects", + "ProjectWorklogs", + "Releases", + "Roles", + "States", + "Stickies", + "Teamspaces", + "UserAssets", + "Users", + "V2Namespace", + "Webhooks", + "Wiki", + "WikiPages", + "WorkItemProperties", + "WorkItemRelationDefinitions", + "WorkItems", + "ProjectWorkItemTemplates", + "WorkspaceWorkItemTemplates", + "WorkItemTypes", + "Workflows", + "Workspace", + "WorkspaceAssets", + "WorkspaceFeatures", + "WorkspaceMembers", + "WorkspaceViews", + "WorkspaceWorkItemProperties", + "WorkspaceWorkItemTypes", + "WorkspaceWorkItems", +] diff --git a/plane/api/v2/_generated/__init__.py b/plane/api/v2/_generated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plane/api/v2/_generated/constants.py b/plane/api/v2/_generated/constants.py new file mode 100644 index 0000000..3fb7755 --- /dev/null +++ b/plane/api/v2/_generated/constants.py @@ -0,0 +1,5860 @@ +"""Generated from the api_v2 OpenAPI golden (version 2.0.0) -- never hand-edited. +Source: ../plane-ee/apps/api/plane/api_v2/core/schema/openapi +Regenerate: python scripts/generate_v2_constants.py /api_v2/core/schema/openapi""" + +API_VERSION = "2.0.0" + +BULK_MAX_ITEMS = 50 + +OPERATION_IDS: frozenset[str] = frozenset( + [ + "activities_list", + "activities_retrieve", + "assets_create", + "assets_destroy", + "assets_list", + "assets_partial_update", + "assets_retrieve", + "attachments_create", + "attachments_destroy", + "attachments_list", + "attachments_partial_update", + "attachments_retrieve", + "audit_logs_list", + "audit_logs_retrieve", + "collections_members", + "collections_members_list", + "collections_pages", + "collections_pages_search", + "comments_create", + "comments_destroy", + "comments_list", + "comments_partial_update", + "comments_retrieve", + "customer_properties_create", + "customer_properties_destroy", + "customer_properties_list", + "customer_properties_partial_update", + "customer_properties_retrieve", + "customer_property_values_create", + "customer_property_values_list", + "customer_requests_create", + "customer_requests_destroy", + "customer_requests_list", + "customer_requests_partial_update", + "customer_requests_retrieve", + "customers_create", + "customers_destroy", + "customers_list", + "customers_partial_update", + "customers_retrieve", + "customers_upsert", + "customers_work_items", + "cycles_bulk_create", + "cycles_bulk_delete", + "cycles_bulk_update", + "cycles_create", + "cycles_destroy", + "cycles_list", + "cycles_partial_update", + "cycles_retrieve", + "cycles_transfer", + "cycles_upsert", + "cycles_work_items_manage", + "estimate_points_bulk_create", + "estimate_points_bulk_delete", + "estimate_points_bulk_update", + "estimate_points_create", + "estimate_points_destroy", + "estimate_points_list", + "estimate_points_partial_update", + "estimate_points_retrieve", + "estimate_points_upsert", + "estimates_bulk_create", + "estimates_bulk_delete", + "estimates_bulk_update", + "estimates_create", + "estimates_destroy", + "estimates_list", + "estimates_partial_update", + "estimates_retrieve", + "estimates_upsert", + "group_sync_config_retrieve", + "group_sync_config_update", + "group_sync_project_mappings_create", + "group_sync_project_mappings_destroy", + "group_sync_project_mappings_list", + "group_sync_project_mappings_retrieve", + "group_sync_project_mappings_update", + "group_sync_workspace_mappings_create", + "group_sync_workspace_mappings_destroy", + "group_sync_workspace_mappings_list", + "group_sync_workspace_mappings_retrieve", + "group_sync_workspace_mappings_update", + "initiative_labels_create", + "initiative_labels_destroy", + "initiative_labels_list", + "initiative_labels_partial_update", + "initiative_labels_retrieve", + "initiatives_create", + "initiatives_destroy", + "initiatives_labels", + "initiatives_list", + "initiatives_partial_update", + "initiatives_projects", + "initiatives_retrieve", + "initiatives_work_items", + "intakes_create", + "intakes_destroy", + "intakes_list", + "intakes_partial_update", + "intakes_retrieve", + "labels_bulk_create", + "labels_bulk_delete", + "labels_bulk_update", + "labels_create", + "labels_destroy", + "labels_list", + "labels_partial_update", + "labels_retrieve", + "labels_upsert", + "links_create", + "links_destroy", + "links_list", + "links_partial_update", + "links_retrieve", + "members_bulk", + "members_create", + "members_destroy", + "members_list", + "members_retrieve", + "milestones_bulk_create", + "milestones_bulk_delete", + "milestones_bulk_update", + "milestones_create", + "milestones_destroy", + "milestones_list", + "milestones_partial_update", + "milestones_retrieve", + "milestones_upsert", + "milestones_work_items", + "modules_bulk_create", + "modules_bulk_delete", + "modules_bulk_update", + "modules_create", + "modules_destroy", + "modules_list", + "modules_partial_update", + "modules_retrieve", + "modules_upsert", + "modules_work_items_manage", + "pages_create", + "pages_destroy", + "pages_list", + "pages_partial_update", + "pages_retrieve", + "permission_schemes_list", + "permission_schemes_retrieve", + "project_automation_activities_list", + "project_automation_activities_retrieve", + "project_automation_edges_create", + "project_automation_edges_destroy", + "project_automation_edges_list", + "project_automation_edges_partial_update", + "project_automation_edges_retrieve", + "project_automation_nodes_create", + "project_automation_nodes_destroy", + "project_automation_nodes_list", + "project_automation_nodes_partial_update", + "project_automation_nodes_regenerate_webhook_secret", + "project_automation_nodes_retrieve", + "project_automations_create", + "project_automations_destroy", + "project_automations_list", + "project_automations_partial_update", + "project_automations_retrieve", + "project_automations_status", + "project_features_retrieve", + "project_features_update", + "project_members_create", + "project_members_destroy", + "project_members_list", + "project_members_partial_update", + "project_members_retrieve", + "project_pages_create", + "project_pages_destroy", + "project_pages_list", + "project_pages_partial_update", + "project_pages_retrieve", + "project_role_distribution", + "project_views_create", + "project_views_destroy", + "project_views_list", + "project_views_partial_update", + "project_views_retrieve", + "project_work_item_templates_create", + "project_work_item_templates_destroy", + "project_work_item_templates_list", + "project_work_item_templates_partial_update", + "project_work_item_templates_retrieve", + "project_worklogs_summary", + "projects_archive", + "projects_bulk_create", + "projects_bulk_update", + "projects_create", + "projects_destroy", + "projects_list", + "projects_partial_update", + "projects_retrieve", + "projects_summary", + "projects_unarchive", + "projects_upsert", + "release_comments_create", + "release_comments_destroy", + "release_comments_list", + "release_comments_partial_update", + "release_comments_retrieve", + "release_labels_create", + "release_labels_destroy", + "release_labels_list", + "release_labels_partial_update", + "release_labels_retrieve", + "release_links_create", + "release_links_destroy", + "release_links_list", + "release_links_partial_update", + "release_links_retrieve", + "release_tags_create", + "release_tags_destroy", + "release_tags_list", + "release_tags_partial_update", + "release_tags_retrieve", + "releases_changelog_partial_update", + "releases_changelog_retrieve", + "releases_create", + "releases_destroy", + "releases_labels", + "releases_list", + "releases_partial_update", + "releases_retrieve", + "releases_work_items", + "roles_list", + "roles_retrieve", + "states_bulk_create", + "states_bulk_delete", + "states_bulk_update", + "states_create", + "states_destroy", + "states_list", + "states_partial_update", + "states_retrieve", + "states_upsert", + "stickies_create", + "stickies_destroy", + "stickies_list", + "stickies_partial_update", + "stickies_retrieve", + "teamspaces_create", + "teamspaces_destroy", + "teamspaces_list", + "teamspaces_partial_update", + "teamspaces_retrieve", + "user_assets_create", + "user_assets_destroy", + "user_assets_list", + "user_assets_partial_update", + "user_assets_retrieve", + "users_me_retrieve", + "webhook_logs_list", + "webhook_logs_retrieve", + "webhooks_create", + "webhooks_destroy", + "webhooks_list", + "webhooks_partial_update", + "webhooks_regenerate", + "webhooks_retrieve", + "work_item_comments_bulk_create", + "work_item_comments_bulk_delete", + "work_item_comments_bulk_update", + "work_item_comments_upsert", + "work_item_dependencies_create", + "work_item_dependencies_destroy", + "work_item_dependencies_list", + "work_item_properties_create", + "work_item_properties_destroy", + "work_item_properties_list", + "work_item_properties_partial_update", + "work_item_properties_retrieve", + "work_item_property_contexts_create", + "work_item_property_contexts_destroy", + "work_item_property_contexts_list", + "work_item_property_contexts_partial_update", + "work_item_property_contexts_retrieve", + "work_item_property_options_create", + "work_item_property_options_destroy", + "work_item_property_options_list", + "work_item_property_options_partial_update", + "work_item_property_options_retrieve", + "work_item_relation_definitions_create", + "work_item_relation_definitions_destroy", + "work_item_relation_definitions_list", + "work_item_relation_definitions_partial_update", + "work_item_relation_definitions_retrieve", + "work_item_relations_create", + "work_item_relations_destroy", + "work_item_relations_list", + "work_item_type_properties_attach", + "work_item_type_properties_detach", + "work_item_type_properties_list", + "work_item_type_properties_retrieve", + "work_item_types_create", + "work_item_types_destroy", + "work_item_types_enable", + "work_item_types_import", + "work_item_types_list", + "work_item_types_mark_default", + "work_item_types_partial_update", + "work_item_types_retrieve", + "work_item_types_schema", + "work_items_archive", + "work_items_bulk_create", + "work_items_bulk_delete", + "work_items_bulk_update", + "work_items_create", + "work_items_destroy", + "work_items_list", + "work_items_partial_update", + "work_items_retrieve", + "work_items_retrieve_by_identifier", + "work_items_unarchive", + "work_items_upsert", + "work_items_use", + "workflow_states_create", + "workflow_states_destroy", + "workflow_states_list", + "workflow_states_partial_update", + "workflow_states_retrieve", + "workflow_transitions_create", + "workflow_transitions_destroy", + "workflow_transitions_list", + "workflow_transitions_partial_update", + "workflow_transitions_retrieve", + "workflows_create", + "workflows_destroy", + "workflows_list", + "workflows_partial_update", + "workflows_retrieve", + "worklogs_create", + "worklogs_destroy", + "worklogs_list", + "worklogs_partial_update", + "worklogs_retrieve", + "workspace_automation_activities_list", + "workspace_automation_activities_retrieve", + "workspace_automation_edges_create", + "workspace_automation_edges_destroy", + "workspace_automation_edges_list", + "workspace_automation_edges_partial_update", + "workspace_automation_edges_retrieve", + "workspace_automation_nodes_create", + "workspace_automation_nodes_destroy", + "workspace_automation_nodes_list", + "workspace_automation_nodes_partial_update", + "workspace_automation_nodes_regenerate_webhook_secret", + "workspace_automation_nodes_retrieve", + "workspace_automations_create", + "workspace_automations_destroy", + "workspace_automations_list", + "workspace_automations_partial_update", + "workspace_automations_retrieve", + "workspace_automations_status", + "workspace_features_retrieve", + "workspace_features_update", + "workspace_members_list", + "workspace_members_remove", + "workspace_pages_create", + "workspace_pages_destroy", + "workspace_pages_list", + "workspace_pages_partial_update", + "workspace_pages_retrieve", + "workspace_views_create", + "workspace_views_destroy", + "workspace_views_list", + "workspace_views_partial_update", + "workspace_views_retrieve", + "workspace_work_item_properties_create", + "workspace_work_item_properties_destroy", + "workspace_work_item_properties_list", + "workspace_work_item_properties_partial_update", + "workspace_work_item_properties_retrieve", + "workspace_work_item_property_options_create", + "workspace_work_item_property_options_destroy", + "workspace_work_item_property_options_list", + "workspace_work_item_property_options_partial_update", + "workspace_work_item_property_options_retrieve", + "workspace_work_item_templates_create", + "workspace_work_item_templates_destroy", + "workspace_work_item_templates_list", + "workspace_work_item_templates_partial_update", + "workspace_work_item_templates_retrieve", + "workspace_work_item_type_properties_attach", + "workspace_work_item_type_properties_detach", + "workspace_work_item_type_properties_list", + "workspace_work_item_type_properties_retrieve", + "workspace_work_item_types_create", + "workspace_work_item_types_destroy", + "workspace_work_item_types_list", + "workspace_work_item_types_mark_default", + "workspace_work_item_types_partial_update", + "workspace_work_item_types_retrieve", + "workspace_work_items_list", + "workspaces_artifacts_create", + "workspaces_artifacts_publish_create", + "workspaces_artifacts_retrieve", + "workspaces_artifacts_update_partial_update", + "workspaces_permissions_me_retrieve", + "workspaces_projects_permissions_me_retrieve", + ] +) + +FIELDS: dict[str, frozenset[str]] = { + "activities_list": frozenset( + [ + "actor_id", + "all", + "comment", + "created_at", + "duration", + "epoch", + "external_id", + "external_source", + "field", + "id", + "issue_comment_id", + "new_identifier_id", + "new_value", + "old_identifier_id", + "old_value", + "verb", + "work_item_id", + ] + ), + "activities_retrieve": frozenset( + [ + "actor_id", + "all", + "comment", + "created_at", + "duration", + "epoch", + "external_id", + "external_source", + "field", + "id", + "issue_comment_id", + "new_identifier_id", + "new_value", + "old_identifier_id", + "old_value", + "verb", + "work_item_id", + ] + ), + "assets_create": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + ] + ), + "assets_destroy": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + ] + ), + "assets_list": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + ] + ), + "assets_partial_update": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + ] + ), + "assets_retrieve": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + ] + ), + "attachments_create": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + "work_item_id", + ] + ), + "attachments_destroy": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + "work_item_id", + ] + ), + "attachments_list": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + "work_item_id", + ] + ), + "attachments_partial_update": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + "work_item_id", + ] + ), + "attachments_retrieve": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "is_uploaded", + "name", + "size", + "work_item_id", + ] + ), + "audit_logs_list": frozenset( + [ + "actor_display_name", + "actor_email", + "actor_id", + "actor_type", + "all", + "category", + "created_at", + "event_id", + "event_name", + "id", + "ip_address", + "metadata", + "new_value", + "old_value", + "outcome", + "project_id", + "reason", + "sequence_number", + "source", + "target_display_name", + "target_id", + "target_type", + "user_agent", + "workspace_id", + ] + ), + "audit_logs_retrieve": frozenset( + [ + "actor_display_name", + "actor_email", + "actor_id", + "actor_type", + "all", + "category", + "created_at", + "event_id", + "event_name", + "id", + "ip_address", + "metadata", + "new_value", + "old_value", + "outcome", + "project_id", + "reason", + "sequence_number", + "source", + "target_display_name", + "target_id", + "target_type", + "user_agent", + "workspace_id", + ] + ), + "collections_members_list": frozenset( + [ + "access", + "all", + "collection_id", + "created_at", + "created_by_id", + "id", + "member_id", + "source", + ] + ), + "collections_pages_search": frozenset(["all", "id", "logo_props", "name"]), + "comments_create": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "comments_destroy": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "comments_list": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "comments_partial_update": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "comments_retrieve": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "customer_properties_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "sort_order", + "validation_rules", + ] + ), + "customer_properties_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "sort_order", + "validation_rules", + ] + ), + "customer_properties_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "sort_order", + "validation_rules", + ] + ), + "customer_properties_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "sort_order", + "validation_rules", + ] + ), + "customer_properties_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "sort_order", + "validation_rules", + ] + ), + "customer_requests_create": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "customer_id", + "description", + "description_html", + "id", + "link", + "name", + ] + ), + "customer_requests_destroy": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "customer_id", + "description", + "description_html", + "id", + "link", + "name", + ] + ), + "customer_requests_list": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "customer_id", + "description", + "description_html", + "id", + "link", + "name", + ] + ), + "customer_requests_partial_update": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "customer_id", + "description", + "description_html", + "id", + "link", + "name", + ] + ), + "customer_requests_retrieve": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "customer_id", + "description", + "description_html", + "id", + "link", + "name", + ] + ), + "customers_create": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "customers_destroy": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "customers_list": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "customers_partial_update": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "customers_retrieve": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "customers_upsert": frozenset( + [ + "all", + "archived_at", + "contract_status", + "created_at", + "created_by_id", + "customer_request_count", + "description", + "description_html", + "domain", + "email", + "employees", + "external_id", + "external_source", + "id", + "logo_asset_id", + "logo_props", + "logo_url", + "name", + "revenue", + "stage", + "website_url", + ] + ), + "cycles_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "cycles_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "cycles_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "cycles_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "cycles_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "cycles_upsert": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "end_date", + "external_id", + "external_source", + "id", + "logo_props", + "name", + "owned_by_id", + "sort_order", + "start_date", + "timezone", + ] + ), + "estimate_points_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimate_points_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimate_points_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimate_points_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimate_points_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimate_points_upsert": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "estimate_id", + "external_id", + "external_source", + "id", + "key", + "value", + ] + ), + "estimates_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "estimates_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "estimates_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "estimates_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "estimates_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "estimates_upsert": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "last_used", + "name", + "type", + ] + ), + "group_sync_project_mappings_create": frozenset( + ["all", "all_projects", "created_at", "id", "idp_group_name", "project_id", "role_slug"] + ), + "group_sync_project_mappings_destroy": frozenset( + ["all", "all_projects", "created_at", "id", "idp_group_name", "project_id", "role_slug"] + ), + "group_sync_project_mappings_list": frozenset( + ["all", "all_projects", "created_at", "id", "idp_group_name", "project_id", "role_slug"] + ), + "group_sync_project_mappings_retrieve": frozenset( + ["all", "all_projects", "created_at", "id", "idp_group_name", "project_id", "role_slug"] + ), + "group_sync_project_mappings_update": frozenset( + ["all", "all_projects", "created_at", "id", "idp_group_name", "project_id", "role_slug"] + ), + "group_sync_workspace_mappings_create": frozenset( + ["all", "created_at", "id", "idp_group_name", "role_slug"] + ), + "group_sync_workspace_mappings_destroy": frozenset( + ["all", "created_at", "id", "idp_group_name", "role_slug"] + ), + "group_sync_workspace_mappings_list": frozenset( + ["all", "created_at", "id", "idp_group_name", "role_slug"] + ), + "group_sync_workspace_mappings_retrieve": frozenset( + ["all", "created_at", "id", "idp_group_name", "role_slug"] + ), + "group_sync_workspace_mappings_update": frozenset( + ["all", "created_at", "id", "idp_group_name", "role_slug"] + ), + "initiative_labels_create": frozenset( + ["all", "color", "created_at", "created_by_id", "description", "id", "name", "sort_order"] + ), + "initiative_labels_destroy": frozenset( + ["all", "color", "created_at", "created_by_id", "description", "id", "name", "sort_order"] + ), + "initiative_labels_list": frozenset( + ["all", "color", "created_at", "created_by_id", "description", "id", "name", "sort_order"] + ), + "initiative_labels_partial_update": frozenset( + ["all", "color", "created_at", "created_by_id", "description", "id", "name", "sort_order"] + ), + "initiative_labels_retrieve": frozenset( + ["all", "color", "created_at", "created_by_id", "description", "id", "name", "sort_order"] + ), + "initiatives_create": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "description_html", + "end_date", + "id", + "label_ids", + "lead_id", + "logo_props", + "name", + "project_ids", + "start_date", + "state", + ] + ), + "initiatives_destroy": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "description_html", + "end_date", + "id", + "label_ids", + "lead_id", + "logo_props", + "name", + "project_ids", + "start_date", + "state", + ] + ), + "initiatives_list": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "description_html", + "end_date", + "id", + "label_ids", + "lead_id", + "logo_props", + "name", + "project_ids", + "start_date", + "state", + ] + ), + "initiatives_partial_update": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "description_html", + "end_date", + "id", + "label_ids", + "lead_id", + "logo_props", + "name", + "project_ids", + "start_date", + "state", + ] + ), + "initiatives_retrieve": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "description_html", + "end_date", + "id", + "label_ids", + "lead_id", + "logo_props", + "name", + "project_ids", + "start_date", + "state", + ] + ), + "intakes_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "duplicate_to_id", + "external_id", + "external_source", + "id", + "intake_id", + "name", + "priority", + "snoozed_till", + "source", + "source_email", + "state_id", + "status", + "work_item_id", + ] + ), + "intakes_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "duplicate_to_id", + "external_id", + "external_source", + "id", + "intake_id", + "name", + "priority", + "snoozed_till", + "source", + "source_email", + "state_id", + "status", + "work_item_id", + ] + ), + "intakes_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "duplicate_to_id", + "external_id", + "external_source", + "id", + "intake_id", + "name", + "priority", + "snoozed_till", + "source", + "source_email", + "state_id", + "status", + "work_item_id", + ] + ), + "intakes_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "duplicate_to_id", + "external_id", + "external_source", + "id", + "intake_id", + "name", + "priority", + "snoozed_till", + "source", + "source_email", + "state_id", + "status", + "work_item_id", + ] + ), + "intakes_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "duplicate_to_id", + "external_id", + "external_source", + "id", + "intake_id", + "name", + "priority", + "snoozed_till", + "source", + "source_email", + "state_id", + "status", + "work_item_id", + ] + ), + "labels_create": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "labels_destroy": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "labels_list": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "labels_partial_update": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "labels_retrieve": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "labels_upsert": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "name", + "parent_id", + "sort_order", + ] + ), + "links_create": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "title", "url", "work_item_id"] + ), + "links_destroy": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "title", "url", "work_item_id"] + ), + "links_list": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "title", "url", "work_item_id"] + ), + "links_partial_update": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "title", "url", "work_item_id"] + ), + "links_retrieve": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "title", "url", "work_item_id"] + ), + "members_bulk": frozenset( + [ + "accepted", + "all", + "created_at", + "created_by_id", + "email", + "id", + "message", + "responded_at", + "role", + ] + ), + "members_create": frozenset( + [ + "accepted", + "all", + "created_at", + "created_by_id", + "email", + "id", + "message", + "responded_at", + "role", + ] + ), + "members_destroy": frozenset( + [ + "accepted", + "all", + "created_at", + "created_by_id", + "email", + "id", + "message", + "responded_at", + "role", + ] + ), + "members_list": frozenset( + [ + "accepted", + "all", + "created_at", + "created_by_id", + "email", + "id", + "message", + "responded_at", + "role", + ] + ), + "members_retrieve": frozenset( + [ + "accepted", + "all", + "created_at", + "created_by_id", + "email", + "id", + "message", + "responded_at", + "role", + ] + ), + "milestones_create": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "milestones_destroy": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "milestones_list": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "milestones_partial_update": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "milestones_retrieve": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "milestones_upsert": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "external_id", + "external_source", + "id", + "target_date", + "title", + ] + ), + "modules_create": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "modules_destroy": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "modules_list": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "modules_partial_update": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "modules_retrieve": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "modules_upsert": frozenset( + [ + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "sort_order", + "start_date", + "status", + "target_date", + ] + ), + "pages_create": frozenset( + [ + "access", + "all", + "created_at", + "created_by_id", + "id", + "is_default", + "is_global", + "logo_props", + "name", + "owned_by_id", + "page_ids", + "sort_order", + ] + ), + "pages_destroy": frozenset( + [ + "access", + "all", + "created_at", + "created_by_id", + "id", + "is_default", + "is_global", + "logo_props", + "name", + "owned_by_id", + "page_ids", + "sort_order", + ] + ), + "pages_list": frozenset( + [ + "access", + "all", + "created_at", + "created_by_id", + "id", + "is_default", + "is_global", + "logo_props", + "name", + "owned_by_id", + "page_ids", + "sort_order", + ] + ), + "pages_partial_update": frozenset( + [ + "access", + "all", + "created_at", + "created_by_id", + "id", + "is_default", + "is_global", + "logo_props", + "name", + "owned_by_id", + "page_ids", + "sort_order", + ] + ), + "pages_retrieve": frozenset( + [ + "access", + "all", + "created_at", + "created_by_id", + "id", + "is_default", + "is_global", + "logo_props", + "name", + "owned_by_id", + "page_ids", + "sort_order", + ] + ), + "permission_schemes_list": frozenset( + [ + "all", + "description", + "id", + "is_system", + "name", + "namespace", + "permissions", + "slug", + "sort_order", + ] + ), + "permission_schemes_retrieve": frozenset( + [ + "all", + "description", + "id", + "is_system", + "name", + "namespace", + "permissions", + "slug", + "sort_order", + ] + ), + "project_automation_activities_list": frozenset( + [ + "actor_id", + "all", + "automation_edge_id", + "automation_id", + "automation_node_id", + "automation_run_id", + "automation_scope", + "automation_version_id", + "created_at", + "epoch", + "field", + "id", + "new_identifier", + "new_value", + "node_execution_id", + "old_identifier", + "old_value", + "verb", + ] + ), + "project_automation_activities_retrieve": frozenset( + [ + "actor_id", + "all", + "automation_edge_id", + "automation_id", + "automation_node_id", + "automation_run_id", + "automation_scope", + "automation_version_id", + "created_at", + "epoch", + "field", + "id", + "new_identifier", + "new_value", + "node_execution_id", + "old_identifier", + "old_value", + "verb", + ] + ), + "project_automation_edges_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "project_automation_edges_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "project_automation_edges_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "project_automation_edges_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "project_automation_edges_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "project_automation_nodes_create": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "project_automation_nodes_destroy": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "project_automation_nodes_list": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "project_automation_nodes_partial_update": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "project_automation_nodes_retrieve": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "project_automations_create": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "project_automations_destroy": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "project_automations_list": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "project_automations_partial_update": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "project_automations_retrieve": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "project_members_create": frozenset(["all", "id", "member_id", "role"]), + "project_members_destroy": frozenset(["all", "id", "member_id", "role"]), + "project_members_list": frozenset(["all", "id", "member_id", "role"]), + "project_members_partial_update": frozenset(["all", "id", "member_id", "role"]), + "project_members_retrieve": frozenset(["all", "id", "member_id", "role"]), + "project_pages_create": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "project_pages_destroy": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "project_pages_list": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "project_pages_partial_update": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "project_pages_retrieve": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "project_views_create": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "project_views_destroy": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "project_views_list": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "project_views_partial_update": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "project_views_retrieve": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "project_work_item_templates_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "project_work_item_templates_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "project_work_item_templates_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "project_work_item_templates_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "project_work_item_templates_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "projects_create": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "projects_destroy": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "projects_list": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "projects_partial_update": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "projects_retrieve": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "projects_upsert": frozenset( + [ + "all", + "archive_in", + "archived_at", + "close_in", + "cover_image", + "cover_image_url", + "created_at", + "created_by_id", + "cycle_view", + "default_assignee_id", + "default_state_id", + "description", + "emoji", + "estimate_id", + "external_id", + "external_source", + "guest_view_all_features", + "icon_prop", + "id", + "identifier", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "logo_props", + "module_view", + "name", + "network", + "page_view", + "priority", + "project_lead_id", + "start_date", + "state_id", + "target_date", + "timezone", + ] + ), + "release_comments_create": frozenset( + [ + "all", + "comment_html", + "comment_id", + "created_at", + "created_by_id", + "edited_at", + "id", + "is_hidden", + "is_resolved", + "parent_id", + "release_id", + ] + ), + "release_comments_destroy": frozenset( + [ + "all", + "comment_html", + "comment_id", + "created_at", + "created_by_id", + "edited_at", + "id", + "is_hidden", + "is_resolved", + "parent_id", + "release_id", + ] + ), + "release_comments_list": frozenset( + [ + "all", + "comment_html", + "comment_id", + "created_at", + "created_by_id", + "edited_at", + "id", + "is_hidden", + "is_resolved", + "parent_id", + "release_id", + ] + ), + "release_comments_partial_update": frozenset( + [ + "all", + "comment_html", + "comment_id", + "created_at", + "created_by_id", + "edited_at", + "id", + "is_hidden", + "is_resolved", + "parent_id", + "release_id", + ] + ), + "release_comments_retrieve": frozenset( + [ + "all", + "comment_html", + "comment_id", + "created_at", + "created_by_id", + "edited_at", + "id", + "is_hidden", + "is_resolved", + "parent_id", + "release_id", + ] + ), + "release_labels_create": frozenset( + ["all", "color", "created_at", "created_by_id", "id", "name", "sort_order"] + ), + "release_labels_destroy": frozenset( + ["all", "color", "created_at", "created_by_id", "id", "name", "sort_order"] + ), + "release_labels_list": frozenset( + ["all", "color", "created_at", "created_by_id", "id", "name", "sort_order"] + ), + "release_labels_partial_update": frozenset( + ["all", "color", "created_at", "created_by_id", "id", "name", "sort_order"] + ), + "release_labels_retrieve": frozenset( + ["all", "color", "created_at", "created_by_id", "id", "name", "sort_order"] + ), + "release_links_create": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "release_id", "title", "url"] + ), + "release_links_destroy": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "release_id", "title", "url"] + ), + "release_links_list": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "release_id", "title", "url"] + ), + "release_links_partial_update": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "release_id", "title", "url"] + ), + "release_links_retrieve": frozenset( + ["all", "created_at", "created_by_id", "id", "metadata", "release_id", "title", "url"] + ), + "release_tags_create": frozenset( + [ + "all", + "commit_hash", + "created_at", + "created_by_id", + "description", + "git_tag", + "id", + "version", + ] + ), + "release_tags_destroy": frozenset( + [ + "all", + "commit_hash", + "created_at", + "created_by_id", + "description", + "git_tag", + "id", + "version", + ] + ), + "release_tags_list": frozenset( + [ + "all", + "commit_hash", + "created_at", + "created_by_id", + "description", + "git_tag", + "id", + "version", + ] + ), + "release_tags_partial_update": frozenset( + [ + "all", + "commit_hash", + "created_at", + "created_by_id", + "description", + "git_tag", + "id", + "version", + ] + ), + "release_tags_retrieve": frozenset( + [ + "all", + "commit_hash", + "created_at", + "created_by_id", + "description", + "git_tag", + "id", + "version", + ] + ), + "releases_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "description_id", + "external_id", + "external_source", + "id", + "is_latest", + "is_prerelease", + "label_ids", + "lead_id", + "name", + "release_date", + "status", + "tag_id", + "target_date", + ] + ), + "releases_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "description_id", + "external_id", + "external_source", + "id", + "is_latest", + "is_prerelease", + "label_ids", + "lead_id", + "name", + "release_date", + "status", + "tag_id", + "target_date", + ] + ), + "releases_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "description_id", + "external_id", + "external_source", + "id", + "is_latest", + "is_prerelease", + "label_ids", + "lead_id", + "name", + "release_date", + "status", + "tag_id", + "target_date", + ] + ), + "releases_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "description_id", + "external_id", + "external_source", + "id", + "is_latest", + "is_prerelease", + "label_ids", + "lead_id", + "name", + "release_date", + "status", + "tag_id", + "target_date", + ] + ), + "releases_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "description_id", + "external_id", + "external_source", + "id", + "is_latest", + "is_prerelease", + "label_ids", + "lead_id", + "name", + "release_date", + "status", + "tag_id", + "target_date", + ] + ), + "roles_list": frozenset( + ["all", "description", "id", "is_system", "level", "name", "namespace", "slug", "status"] + ), + "roles_retrieve": frozenset( + ["all", "description", "id", "is_system", "level", "name", "namespace", "slug", "status"] + ), + "states_create": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "states_destroy": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "states_list": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "states_partial_update": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "states_retrieve": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "states_upsert": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "group", + "id", + "is_default", + "is_triage", + "name", + "sequence", + ] + ), + "stickies_create": frozenset( + [ + "all", + "background_color", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "id", + "logo_props", + "name", + "owner_id", + "sort_order", + ] + ), + "stickies_destroy": frozenset( + [ + "all", + "background_color", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "id", + "logo_props", + "name", + "owner_id", + "sort_order", + ] + ), + "stickies_list": frozenset( + [ + "all", + "background_color", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "id", + "logo_props", + "name", + "owner_id", + "sort_order", + ] + ), + "stickies_partial_update": frozenset( + [ + "all", + "background_color", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "id", + "logo_props", + "name", + "owner_id", + "sort_order", + ] + ), + "stickies_retrieve": frozenset( + [ + "all", + "background_color", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "id", + "logo_props", + "name", + "owner_id", + "sort_order", + ] + ), + "teamspaces_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "project_ids", + ] + ), + "teamspaces_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "project_ids", + ] + ), + "teamspaces_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "project_ids", + ] + ), + "teamspaces_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "project_ids", + ] + ), + "teamspaces_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "lead_id", + "logo_props", + "member_ids", + "name", + "project_ids", + ] + ), + "user_assets_create": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "id", + "is_uploaded", + "name", + "size", + "user_id", + ] + ), + "user_assets_destroy": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "id", + "is_uploaded", + "name", + "size", + "user_id", + ] + ), + "user_assets_list": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "id", + "is_uploaded", + "name", + "size", + "user_id", + ] + ), + "user_assets_partial_update": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "id", + "is_uploaded", + "name", + "size", + "user_id", + ] + ), + "user_assets_retrieve": frozenset( + [ + "all", + "asset_url", + "attributes", + "content_type", + "created_at", + "created_by_id", + "entity_type", + "id", + "is_uploaded", + "name", + "size", + "user_id", + ] + ), + "webhook_logs_list": frozenset( + [ + "all", + "created_at", + "duration_ms", + "error_message", + "event_type", + "id", + "request_body", + "request_headers", + "request_method", + "response_body", + "response_headers", + "response_status", + "retry_count", + "status_text", + "webhook_id", + ] + ), + "webhook_logs_retrieve": frozenset( + [ + "all", + "created_at", + "duration_ms", + "error_message", + "event_type", + "id", + "request_body", + "request_headers", + "request_method", + "response_body", + "response_headers", + "response_status", + "retry_count", + "status_text", + "webhook_id", + ] + ), + "webhooks_create": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "url", + "version", + ] + ), + "webhooks_destroy": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "url", + "version", + ] + ), + "webhooks_list": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "url", + "version", + ] + ), + "webhooks_partial_update": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "url", + "version", + ] + ), + "webhooks_regenerate": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "secret_key", + "url", + "version", + ] + ), + "webhooks_retrieve": frozenset( + [ + "all", + "content_type", + "created_at", + "created_by_id", + "id", + "is_active", + "name", + "scopes", + "url", + "version", + ] + ), + "work_item_comments_upsert": frozenset( + [ + "access", + "actor_id", + "all", + "comment_html", + "comment_stripped", + "created_at", + "created_by_id", + "edited_at", + "external_id", + "external_source", + "id", + "work_item_id", + ] + ), + "work_item_properties_create": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_properties_destroy": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_properties_list": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_properties_partial_update": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_properties_retrieve": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_property_contexts_create": frozenset( + [ + "all", + "applies_to_all_projects", + "applies_to_all_work_item_types", + "created_at", + "default_value", + "external_id", + "external_source", + "id", + "is_default", + "is_multi", + "is_required", + "issue_type_ids", + "name", + "options", + "project_ids", + "settings", + "sort_order", + ] + ), + "work_item_property_contexts_destroy": frozenset( + [ + "all", + "applies_to_all_projects", + "applies_to_all_work_item_types", + "created_at", + "default_value", + "external_id", + "external_source", + "id", + "is_default", + "is_multi", + "is_required", + "issue_type_ids", + "name", + "options", + "project_ids", + "settings", + "sort_order", + ] + ), + "work_item_property_contexts_list": frozenset( + [ + "all", + "applies_to_all_projects", + "applies_to_all_work_item_types", + "created_at", + "default_value", + "external_id", + "external_source", + "id", + "is_default", + "is_multi", + "is_required", + "issue_type_ids", + "name", + "options", + "project_ids", + "settings", + "sort_order", + ] + ), + "work_item_property_contexts_partial_update": frozenset( + [ + "all", + "applies_to_all_projects", + "applies_to_all_work_item_types", + "created_at", + "default_value", + "external_id", + "external_source", + "id", + "is_default", + "is_multi", + "is_required", + "issue_type_ids", + "name", + "options", + "project_ids", + "settings", + "sort_order", + ] + ), + "work_item_property_contexts_retrieve": frozenset( + [ + "all", + "applies_to_all_projects", + "applies_to_all_work_item_types", + "created_at", + "default_value", + "external_id", + "external_source", + "id", + "is_default", + "is_multi", + "is_required", + "issue_type_ids", + "name", + "options", + "project_ids", + "settings", + "sort_order", + ] + ), + "work_item_relation_definitions_create": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "inward", + "is_active", + "is_default", + "logo_props", + "name", + "outward", + "sort_order", + ] + ), + "work_item_relation_definitions_destroy": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "inward", + "is_active", + "is_default", + "logo_props", + "name", + "outward", + "sort_order", + ] + ), + "work_item_relation_definitions_list": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "inward", + "is_active", + "is_default", + "logo_props", + "name", + "outward", + "sort_order", + ] + ), + "work_item_relation_definitions_partial_update": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "inward", + "is_active", + "is_default", + "logo_props", + "name", + "outward", + "sort_order", + ] + ), + "work_item_relation_definitions_retrieve": frozenset( + [ + "all", + "color", + "created_at", + "created_by_id", + "description", + "external_id", + "external_source", + "id", + "inward", + "is_active", + "is_default", + "logo_props", + "name", + "outward", + "sort_order", + ] + ), + "work_item_type_properties_list": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_type_properties_retrieve": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "work_item_types_create": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_destroy": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_enable": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_list": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_mark_default": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_partial_update": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_item_types_retrieve": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "work_items_archive": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_create": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_destroy": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_list": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_partial_update": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_retrieve": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_retrieve_by_identifier": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_unarchive": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_upsert": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "work_items_use": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "custom_fields", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), + "workflow_states_create": frozenset( + [ + "all", + "allow_issue_creation", + "created_at", + "created_by_id", + "id", + "is_default", + "state_id", + "type", + "workflow_id", + ] + ), + "workflow_states_destroy": frozenset( + [ + "all", + "allow_issue_creation", + "created_at", + "created_by_id", + "id", + "is_default", + "state_id", + "type", + "workflow_id", + ] + ), + "workflow_states_list": frozenset( + [ + "all", + "allow_issue_creation", + "created_at", + "created_by_id", + "id", + "is_default", + "state_id", + "type", + "workflow_id", + ] + ), + "workflow_states_partial_update": frozenset( + [ + "all", + "allow_issue_creation", + "created_at", + "created_by_id", + "id", + "is_default", + "state_id", + "type", + "workflow_id", + ] + ), + "workflow_states_retrieve": frozenset( + [ + "all", + "allow_issue_creation", + "created_at", + "created_by_id", + "id", + "is_default", + "state_id", + "type", + "workflow_id", + ] + ), + "workflow_transitions_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "id", + "member_ids", + "rejection_state_id", + "required_approvals", + "transition_state_id", + "workflow_state_id", + ] + ), + "workflow_transitions_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "id", + "member_ids", + "rejection_state_id", + "required_approvals", + "transition_state_id", + "workflow_state_id", + ] + ), + "workflow_transitions_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "id", + "member_ids", + "rejection_state_id", + "required_approvals", + "transition_state_id", + "workflow_state_id", + ] + ), + "workflow_transitions_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "id", + "member_ids", + "rejection_state_id", + "required_approvals", + "transition_state_id", + "workflow_state_id", + ] + ), + "workflow_transitions_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "id", + "member_ids", + "rejection_state_id", + "required_approvals", + "transition_state_id", + "workflow_state_id", + ] + ), + "workflows_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "id", + "is_active", + "is_default", + "name", + "work_item_type_ids", + ] + ), + "workflows_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "id", + "is_active", + "is_default", + "name", + "work_item_type_ids", + ] + ), + "workflows_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "id", + "is_active", + "is_default", + "name", + "work_item_type_ids", + ] + ), + "workflows_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "id", + "is_active", + "is_default", + "name", + "work_item_type_ids", + ] + ), + "workflows_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "id", + "is_active", + "is_default", + "name", + "work_item_type_ids", + ] + ), + "worklogs_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "duration", + "id", + "logged_by_id", + "updated_at", + "work_item_id", + ] + ), + "worklogs_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "duration", + "id", + "logged_by_id", + "updated_at", + "work_item_id", + ] + ), + "worklogs_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "duration", + "id", + "logged_by_id", + "updated_at", + "work_item_id", + ] + ), + "worklogs_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "duration", + "id", + "logged_by_id", + "updated_at", + "work_item_id", + ] + ), + "worklogs_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description", + "duration", + "id", + "logged_by_id", + "updated_at", + "work_item_id", + ] + ), + "workspace_automation_activities_list": frozenset( + [ + "actor_id", + "all", + "automation_edge_id", + "automation_id", + "automation_node_id", + "automation_run_id", + "automation_scope", + "automation_version_id", + "created_at", + "epoch", + "field", + "id", + "new_identifier", + "new_value", + "node_execution_id", + "old_identifier", + "old_value", + "verb", + ] + ), + "workspace_automation_activities_retrieve": frozenset( + [ + "actor_id", + "all", + "automation_edge_id", + "automation_id", + "automation_node_id", + "automation_run_id", + "automation_scope", + "automation_version_id", + "created_at", + "epoch", + "field", + "id", + "new_identifier", + "new_value", + "node_execution_id", + "old_identifier", + "old_value", + "verb", + ] + ), + "workspace_automation_edges_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "workspace_automation_edges_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "workspace_automation_edges_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "workspace_automation_edges_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "workspace_automation_edges_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "execution_order", + "id", + "source_node_id", + "target_node_id", + "updated_at", + "version_id", + ] + ), + "workspace_automation_nodes_create": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "workspace_automation_nodes_destroy": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "workspace_automation_nodes_list": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "workspace_automation_nodes_partial_update": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "workspace_automation_nodes_retrieve": frozenset( + [ + "all", + "config", + "created_at", + "created_by_id", + "handler_name", + "id", + "is_enabled", + "last_triggered_at", + "name", + "next_scheduled_at", + "node_type", + "updated_at", + "version_id", + ] + ), + "workspace_automations_create": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "workspace_automations_destroy": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "workspace_automations_list": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "workspace_automations_partial_update": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "workspace_automations_retrieve": frozenset( + [ + "all", + "bot_user_id", + "created_at", + "created_by_id", + "current_version_id", + "description", + "id", + "is_enabled", + "is_global", + "last_run_at", + "name", + "project_ids", + "run_count", + "scope", + "status", + "updated_at", + ] + ), + "workspace_members_list": frozenset(["all", "id", "member_id", "role"]), + "workspace_pages_create": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "workspace_pages_destroy": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "workspace_pages_list": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "workspace_pages_partial_update": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "workspace_pages_retrieve": frozenset( + [ + "access", + "all", + "archived_at", + "collection_id", + "color", + "created_at", + "created_by_id", + "description_html", + "description_stripped", + "external_id", + "external_source", + "id", + "is_global", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "parent_id", + "sort_order", + "view_props", + ] + ), + "workspace_views_create": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "workspace_views_destroy": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "workspace_views_list": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "workspace_views_partial_update": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "workspace_views_retrieve": frozenset( + [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "description", + "display_filters", + "display_properties", + "filters", + "id", + "is_locked", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "query", + "sort_order", + ] + ), + "workspace_work_item_properties_create": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_properties_destroy": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_properties_list": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_properties_partial_update": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_properties_retrieve": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_templates_create": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "workspace_work_item_templates_destroy": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "workspace_work_item_templates_list": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "workspace_work_item_templates_partial_update": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "workspace_work_item_templates_retrieve": frozenset( + [ + "all", + "created_at", + "created_by_id", + "description_html", + "id", + "is_published", + "name", + "short_description", + "short_id", + "slug", + "template_data", + "template_type", + ] + ), + "workspace_work_item_type_properties_list": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_type_properties_retrieve": frozenset( + [ + "all", + "created_at", + "default_value", + "description", + "display_name", + "external_id", + "external_source", + "id", + "is_active", + "is_multi", + "is_required", + "logo_props", + "name", + "options", + "property_type", + "relation_type", + "settings", + "validation_rules", + ] + ), + "workspace_work_item_types_create": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_item_types_destroy": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_item_types_list": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_item_types_mark_default": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_item_types_partial_update": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_item_types_retrieve": frozenset( + [ + "all", + "created_at", + "description", + "id", + "is_active", + "is_default", + "is_epic", + "level", + "logo_props", + "name", + ] + ), + "workspace_work_items_list": frozenset( + [ + "all", + "archived_at", + "assignee_ids", + "created_at", + "created_by_id", + "cycle_id", + "id", + "identifier", + "is_draft", + "label_ids", + "module_ids", + "name", + "parent_id", + "priority", + "project_id", + "sequence_id", + "start_date", + "state_id", + "target_date", + "type_id", + ] + ), +} + +ORDER_BY: dict[str, frozenset[str]] = { + "activities_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "assets_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "attachments_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "audit_logs_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "comments_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "customer_properties_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "customer_requests_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "customers_list": frozenset(["-created_at", "-id", "-name", "created_at", "id", "name"]), + "cycles_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "estimate_points_list": frozenset(["-created_at", "-id", "-key", "created_at", "id", "key"]), + "estimates_list": frozenset(["-created_at", "-id", "-name", "created_at", "id", "name"]), + "group_sync_project_mappings_list": frozenset( + ["-created_at", "-id", "-idp_group_name", "created_at", "id", "idp_group_name"] + ), + "group_sync_workspace_mappings_list": frozenset( + ["-created_at", "-id", "-idp_group_name", "created_at", "id", "idp_group_name"] + ), + "initiative_labels_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "initiatives_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "intakes_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "labels_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "links_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "members_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "milestones_list": frozenset( + ["-created_at", "-id", "-target_date", "created_at", "id", "target_date"] + ), + "modules_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "pages_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "permission_schemes_list": frozenset( + ["-id", "-name", "-namespace", "-sort_order", "id", "name", "namespace", "sort_order"] + ), + "project_automation_activities_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "project_automation_edges_list": frozenset( + ["-created_at", "-execution_order", "-id", "created_at", "execution_order", "id"] + ), + "project_automation_nodes_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "project_automations_list": frozenset( + ["-created_at", "-id", "-name", "created_at", "id", "name"] + ), + "project_members_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "project_pages_list": frozenset( + ["-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at"] + ), + "project_views_list": frozenset( + [ + "-created_at", + "-id", + "-sort_order", + "-updated_at", + "created_at", + "id", + "sort_order", + "updated_at", + ] + ), + "project_work_item_templates_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "projects_list": frozenset(["-created_at", "-id", "-name", "created_at", "id", "name"]), + "release_comments_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "release_labels_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "release_links_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "release_tags_list": frozenset( + ["-created_at", "-id", "-version", "created_at", "id", "version"] + ), + "releases_list": frozenset( + [ + "-created_at", + "-id", + "-name", + "-release_date", + "-target_date", + "created_at", + "id", + "name", + "release_date", + "target_date", + ] + ), + "roles_list": frozenset( + ["-id", "-level", "-name", "-namespace", "id", "level", "name", "namespace"] + ), + "states_list": frozenset(["-created_at", "-id", "-sequence", "created_at", "id", "sequence"]), + "stickies_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "teamspaces_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "user_assets_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "webhook_logs_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "webhooks_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "work_item_properties_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "work_item_property_contexts_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "work_item_property_options_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "work_item_type_properties_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "work_item_types_list": frozenset( + ["-created_at", "-id", "-level", "-name", "created_at", "id", "level", "name"] + ), + "work_items_list": frozenset( + [ + "-created_at", + "-id", + "-priority", + "-sequence_id", + "-sort_order", + "-state_group", + "-updated_at", + "created_at", + "id", + "priority", + "sequence_id", + "sort_order", + "state_group", + "updated_at", + ] + ), + "workflow_states_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workflow_transitions_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workflows_list": frozenset(["-created_at", "-id", "-name", "created_at", "id", "name"]), + "worklogs_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workspace_automation_activities_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workspace_automation_edges_list": frozenset( + ["-created_at", "-execution_order", "-id", "created_at", "execution_order", "id"] + ), + "workspace_automation_nodes_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workspace_automations_list": frozenset( + ["-created_at", "-id", "-name", "created_at", "id", "name"] + ), + "workspace_members_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workspace_pages_list": frozenset( + ["-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at"] + ), + "workspace_views_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "workspace_work_item_properties_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "workspace_work_item_property_options_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "workspace_work_item_templates_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "workspace_work_item_type_properties_list": frozenset( + ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] + ), + "workspace_work_item_types_list": frozenset( + ["-created_at", "-id", "-level", "-name", "created_at", "id", "level", "name"] + ), + "workspace_work_items_list": frozenset( + [ + "-created_at", + "-id", + "-priority", + "-sequence_id", + "-sort_order", + "-state_group", + "-updated_at", + "created_at", + "id", + "priority", + "sequence_id", + "sort_order", + "state_group", + "updated_at", + ] + ), +} + +EXPAND: dict[str, frozenset[str]] = { + "activities_list": frozenset(["actor"]), + "activities_retrieve": frozenset(["actor"]), + "collections_members_list": frozenset(["member"]), + "comments_create": frozenset(["actor"]), + "comments_destroy": frozenset(["actor"]), + "comments_list": frozenset(["actor"]), + "comments_partial_update": frozenset(["actor"]), + "comments_retrieve": frozenset(["actor"]), + "cycles_create": frozenset(["owned_by"]), + "cycles_destroy": frozenset(["owned_by"]), + "cycles_list": frozenset(["owned_by"]), + "cycles_partial_update": frozenset(["owned_by"]), + "cycles_retrieve": frozenset(["owned_by"]), + "cycles_upsert": frozenset(["owned_by"]), + "estimates_create": frozenset(["points"]), + "estimates_destroy": frozenset(["points"]), + "estimates_list": frozenset(["points"]), + "estimates_partial_update": frozenset(["points"]), + "estimates_retrieve": frozenset(["points"]), + "estimates_upsert": frozenset(["points"]), + "initiatives_create": frozenset(["lead"]), + "initiatives_destroy": frozenset(["lead"]), + "initiatives_list": frozenset(["lead"]), + "initiatives_partial_update": frozenset(["lead"]), + "initiatives_retrieve": frozenset(["lead"]), + "modules_create": frozenset(["lead", "members"]), + "modules_destroy": frozenset(["lead", "members"]), + "modules_list": frozenset(["lead", "members"]), + "modules_partial_update": frozenset(["lead", "members"]), + "modules_retrieve": frozenset(["lead", "members"]), + "modules_upsert": frozenset(["lead", "members"]), + "pages_create": frozenset(["owned_by"]), + "pages_destroy": frozenset(["owned_by"]), + "pages_list": frozenset(["owned_by"]), + "pages_partial_update": frozenset(["owned_by"]), + "pages_retrieve": frozenset(["owned_by"]), + "project_members_create": frozenset(["member"]), + "project_members_destroy": frozenset(["member"]), + "project_members_list": frozenset(["member"]), + "project_members_partial_update": frozenset(["member"]), + "project_members_retrieve": frozenset(["member"]), + "project_pages_create": frozenset(["owned_by", "parent"]), + "project_pages_destroy": frozenset(["owned_by", "parent"]), + "project_pages_list": frozenset(["owned_by", "parent"]), + "project_pages_partial_update": frozenset(["owned_by", "parent"]), + "project_pages_retrieve": frozenset(["owned_by", "parent"]), + "project_views_create": frozenset(["owned_by"]), + "project_views_destroy": frozenset(["owned_by"]), + "project_views_list": frozenset(["owned_by"]), + "project_views_partial_update": frozenset(["owned_by"]), + "project_views_retrieve": frozenset(["owned_by"]), + "projects_create": frozenset(["default_assignee", "project_lead"]), + "projects_destroy": frozenset(["default_assignee", "project_lead"]), + "projects_list": frozenset(["default_assignee", "project_lead"]), + "projects_partial_update": frozenset(["default_assignee", "project_lead"]), + "projects_retrieve": frozenset(["default_assignee", "project_lead"]), + "projects_upsert": frozenset(["default_assignee", "project_lead"]), + "releases_create": frozenset(["lead", "tag"]), + "releases_destroy": frozenset(["lead", "tag"]), + "releases_list": frozenset(["lead", "tag"]), + "releases_partial_update": frozenset(["lead", "tag"]), + "releases_retrieve": frozenset(["lead", "tag"]), + "teamspaces_create": frozenset(["lead"]), + "teamspaces_destroy": frozenset(["lead"]), + "teamspaces_list": frozenset(["lead"]), + "teamspaces_partial_update": frozenset(["lead"]), + "teamspaces_retrieve": frozenset(["lead"]), + "work_item_comments_upsert": frozenset(["actor"]), + "work_items_archive": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_create": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_destroy": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_list": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_partial_update": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_retrieve": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_retrieve_by_identifier": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_unarchive": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_upsert": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "work_items_use": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), + "worklogs_create": frozenset(["logged_by"]), + "worklogs_destroy": frozenset(["logged_by"]), + "worklogs_list": frozenset(["logged_by"]), + "worklogs_partial_update": frozenset(["logged_by"]), + "worklogs_retrieve": frozenset(["logged_by"]), + "workspace_members_list": frozenset(["member"]), + "workspace_pages_create": frozenset(["owned_by", "parent"]), + "workspace_pages_destroy": frozenset(["owned_by", "parent"]), + "workspace_pages_list": frozenset(["owned_by", "parent"]), + "workspace_pages_partial_update": frozenset(["owned_by", "parent"]), + "workspace_pages_retrieve": frozenset(["owned_by", "parent"]), + "workspace_views_create": frozenset(["owned_by"]), + "workspace_views_destroy": frozenset(["owned_by"]), + "workspace_views_list": frozenset(["owned_by"]), + "workspace_views_partial_update": frozenset(["owned_by"]), + "workspace_views_retrieve": frozenset(["owned_by"]), + "workspace_work_items_list": frozenset( + ["assignees", "cycle", "labels", "modules", "parent", "state", "type"] + ), +} + +ERROR_CODES: frozenset[str] = frozenset( + [ + "batch_failed", + "conflict", + "count_pagination_disabled", + "forbidden", + "governance_migration_in_progress", + "invalid_request", + "listing_authorization_misconfigured", + "method_not_allowed", + "not_acceptable", + "not_found", + "ordering_not_cursor_eligible", + "payload_too_large", + "payment_required", + "rate_limited", + "server_error", + "service_unavailable", + "states_managed_at_workspace", + "unauthorized", + "unsupported_media_type", + "work_item_types_managed_at_project", + "work_item_types_managed_at_workspace", + "workflow_transition_denied", + ] +) diff --git a/plane/api/v2/_kernel/__init__.py b/plane/api/v2/_kernel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plane/api/v2/_kernel/errors.py b/plane/api/v2/_kernel/errors.py new file mode 100644 index 0000000..f6d3ba4 --- /dev/null +++ b/plane/api/v2/_kernel/errors.py @@ -0,0 +1,84 @@ +"""RFC 9457 problem+json errors for api_v2.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, ValidationError + +from ....errors import PlaneError + + +class FieldError(BaseModel): + """One entry of a ValidationProblemDetail `errors` array.""" + + model_config = ConfigDict(extra="allow") + + field: str + message: str + + +class PlaneAPIError(PlaneError): + """A non-2xx api_v2 response, parsed from `application/problem+json`. + + `code` is an open vocabulary; compare it as a plain string, not an enum.""" + + def __init__( + self, + *, + status: int, + type: str, + code: str, + detail: str, + title: str | None = None, + errors: list[FieldError] | None = None, + response: Any = None, + ) -> None: + super().__init__(f"HTTP {status} {code}: {detail}", status_code=status) + self.status = status + self.type = type + self.code = code + self.detail = detail + self.title = title + self.errors = errors + self.response = response + + @classmethod + def from_payload(cls, status: int, payload: Any) -> "PlaneAPIError": + """Build from a parsed body; tolerates a non-problem body (proxy/gateway).""" + if not isinstance(payload, dict): + return cls( + status=status, + type="server_error", + code="server_error", + detail=str(payload) if payload else "Request failed.", + response=payload, + ) + raw_errors = payload.get("errors") + errors: list[FieldError] | None = None + if isinstance(raw_errors, list): + parsed: list[FieldError] = [] + for item in raw_errors: + try: + parsed.append(FieldError.model_validate(item)) + except ValidationError: + # A malformed `errors` entry must not lose the real HTTP status + # (e.g. a proxy/gateway echoing a plain string instead of the + # `{field, message}` shape) -- drop it and keep going. + continue + errors = parsed or None + return cls( + status=payload.get("status", status), + type=payload.get("type", "server_error"), + code=payload.get("code", "server_error"), + detail=payload.get("detail", "Request failed."), + title=payload.get("title"), + errors=errors, + response=payload, + ) + + +class NoMatchFound(PlaneError): + """An identity lookup (`find_by_name`) matched nothing.""" + + +class MultipleMatchesFound(PlaneError): + """An identity lookup matched more than one row.""" diff --git a/plane/api/v2/_kernel/pagination.py b/plane/api/v2/_kernel/pagination.py new file mode 100644 index 0000000..7a362f9 --- /dev/null +++ b/plane/api/v2/_kernel/pagination.py @@ -0,0 +1,51 @@ +"""Envelope discrimination and auto-paging.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Any, TypeVar + +from pydantic import BaseModel + +from ....errors import PlaneError +from ....models.v2.common import CursorPage, OffsetPage + +T = TypeVar("T", bound=BaseModel) + +Page = OffsetPage[T] | CursorPage[T] + + +def parse_page(payload: Any, model: type[T]) -> Page[T]: + """Discriminate on `pagination.style` — the API's own marker.""" + style = (payload or {}).get("pagination", {}).get("style") + if style == "cursor": + return CursorPage[model].model_validate(payload) # type: ignore[valid-type] + return OffsetPage[model].model_validate(payload) # type: ignore[valid-type] + + +def iterate(fetch: Callable[[dict[str, Any]], Page[T]], params: dict[str, Any]) -> Iterator[T]: + """Yield every row across pages, following whichever envelope came back. + + Raises `PlaneError` if the server echoes back the same offset/cursor (stalled-pager guard).""" + current = dict(params) + while True: + page = fetch(current) + yield from page.data + if isinstance(page, CursorPage): + if not page.has_more or not page.next_cursor: + return + if page.next_cursor == current.get("cursor"): + raise PlaneError( + f"Pagination stalled: server returned next_cursor={page.next_cursor!r} " + "for the same cursor. Refusing to loop." + ) + current["cursor"] = page.next_cursor + else: + if page.next is None: + return + if "offset" in current and page.next == current["offset"]: + raise PlaneError( + f"Pagination stalled: server returned next={page.next} " + "for the same offset. Refusing to loop." + ) + current["offset"] = page.next diff --git a/plane/api/v2/_kernel/resource.py b/plane/api/v2/_kernel/resource.py new file mode 100644 index 0000000..5e8b252 --- /dev/null +++ b/plane/api/v2/_kernel/resource.py @@ -0,0 +1,282 @@ +"""Generic CRUD kernel. A resource declares a path and a model; the kernel does the rest.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from typing import Any, ClassVar, Generic, TypeVar +from urllib.parse import quote + +from pydantic import BaseModel + +from ....models.v2.common import BulkWriteResponse +from .._generated.constants import BULK_MAX_ITEMS, EXPAND, FIELDS, ORDER_BY +from .errors import MultipleMatchesFound, NoMatchFound +from .pagination import Page, iterate, parse_page +from .transport import V2Transport + +TRead = TypeVar("TRead", bound=BaseModel) +TWrite = TypeVar("TWrite", bound=BaseModel) +TPatch = TypeVar("TPatch", bound=BaseModel) + + +def encode_fields(operation_id: str, fields: Any) -> str: + """`["id", "name"]` -> `"id,name"`, rejecting names the operation does not offer.""" + if isinstance(fields, str): + requested = [part.strip() for part in fields.split(",") if part.strip()] + else: + requested = [str(part) for part in fields] + allowed = FIELDS.get(operation_id) + if allowed is not None: + unknown = [name for name in requested if name not in allowed] + if unknown: + raise ValueError( + f"Unknown field(s) for {operation_id}: {', '.join(unknown)}. " + f"Allowed: {', '.join(sorted(allowed))}." + ) + return ",".join(requested) + + +def encode_expand(operation_id: str, expand: Any) -> str: + """`["state", "labels"]` -> `"state,labels"`, rejecting relations the operation + does not expand (same shape as `encode_fields`, per-operation enum).""" + if isinstance(expand, str): + requested = [part.strip() for part in expand.split(",") if part.strip()] + else: + requested = [str(part) for part in expand] + allowed = EXPAND.get(operation_id) + if allowed is not None: + unknown = [name for name in requested if name not in allowed] + if unknown: + raise ValueError( + f"Unknown expand value(s) for {operation_id}: {', '.join(unknown)}. " + f"Allowed: {', '.join(sorted(allowed))}." + ) + return ",".join(requested) + + +class V2Resource(Generic[TRead, TWrite, TPatch]): + """Base for every api_v2 resource; subclasses set `path`, `model`, and `operations` + (action name -> operationId, for `?fields=`/`?expand=` validation).""" + + path: ClassVar[str] + model: ClassVar[type[BaseModel]] + operations: ClassVar[dict[str, str]] + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + """`scope` is the path parameters this resource was bound to at construction + (e.g. `slug="acme"`); a resource with no scope requires them per call instead.""" + self.transport = transport + self._scope: dict[str, Any] = scope + + # URL + params + def _collection_url(self, **path_params: Any) -> str: + """Build the collection URL, percent-encoding every path param. + + `safe=""` stops a param value from injecting extra URL segments.""" + merged = {**self._scope, **path_params} + encoded = {key: quote(str(value), safe="") for key, value in merged.items()} + return self.path.format(**encoded) + + def _detail_url(self, pk: Any, **path_params: Any) -> str: + base = self._collection_url(**path_params) + return f"{base}{quote(str(pk), safe='')}/" + + def _query(self, params: Mapping[str, Any] | None, *, action: str) -> dict[str, Any]: + """Drop Nones, join list values, and validate `fields`/`expand`/`order_by` + against the golden, keyed by `action`'s own operationId.""" + prepared: dict[str, Any] = {} + operation_id = self.operations.get(action) + for key, value in (params or {}).items(): + if value is None: + continue + if key == "fields": + if operation_id is None: + raise ValueError( + f"{type(self).__name__}.operations has no entry for {action!r}; " + "cannot validate `fields` for this action." + ) + prepared[key] = encode_fields(operation_id, value) + elif key == "expand": + if operation_id is None: + raise ValueError( + f"{type(self).__name__}.operations has no entry for {action!r}; " + "cannot validate `expand` for this action." + ) + prepared[key] = encode_expand(operation_id, value) + elif key == "order_by": + allowed = ORDER_BY.get(operation_id) if operation_id is not None else None + if isinstance(value, list | tuple | set): + requested = [str(item) for item in value] + if allowed: + unknown = [item for item in requested if item not in allowed] + if unknown: + raise ValueError( + f"Unknown order_by {', '.join(unknown)} for {operation_id}. " + f"Allowed: {', '.join(sorted(allowed))}." + ) + prepared[key] = ",".join(requested) + else: + if allowed and value not in allowed: + raise ValueError( + f"Unknown order_by {value!r} for {operation_id}. " + f"Allowed: {', '.join(sorted(allowed))}." + ) + prepared[key] = value + elif isinstance(value, list | tuple | set): + prepared[key] = ",".join(str(item) for item in value) + else: + prepared[key] = value + return prepared + + # Actions + def _list(self, *, params: Mapping[str, Any] | None = None, **path_params: Any) -> Page[TRead]: + payload = self.transport.request( + "GET", + self._collection_url(**path_params), + params=self._query(params, action="list"), + ) + return parse_page(payload, self.model) # type: ignore[arg-type] + + def _iter( + self, *, params: Mapping[str, Any] | None = None, **path_params: Any + ) -> Iterator[TRead]: + url = self._collection_url(**path_params) + + def fetch(query: dict[str, Any]) -> Page[TRead]: + payload = self.transport.request("GET", url, params=query) + return parse_page(payload, self.model) # type: ignore[arg-type] + + return iterate(fetch, self._query(params, action="list")) + + def _retrieve( + self, *, pk: Any, params: Mapping[str, Any] | None = None, **path_params: Any + ) -> TRead: + payload = self.transport.request( + "GET", + self._detail_url(pk, **path_params), + params=self._query(params, action="retrieve"), + ) + return self.model.model_validate(payload) # type: ignore[return-value] + + def _create( + self, data: TWrite, *, params: Mapping[str, Any] | None = None, **path_params: Any + ) -> TRead: + payload = self.transport.request( + "POST", + self._collection_url(**path_params), + params=self._query(params, action="create"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) # type: ignore[return-value] + + def _update( + self, data: TPatch, *, pk: Any, params: Mapping[str, Any] | None = None, **path_params: Any + ) -> TRead: + payload = self.transport.request( + "PATCH", + self._detail_url(pk, **path_params), + params=self._query(params, action="update"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) # type: ignore[return-value] + + def _delete(self, *, pk: Any, **path_params: Any) -> None: + self.transport.request("DELETE", self._detail_url(pk, **path_params)) + return None + + def _upsert( + self, data: TWrite, *, params: Mapping[str, Any] | None = None, **path_params: Any + ) -> TRead: + """Create, or reconcile an existing row on (external_source, external_id).""" + payload = self.transport.request( + "POST", + f"{self._collection_url(**path_params)}upsert/", + params=self._query(params, action="upsert"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) # type: ignore[return-value] + + def _action( + self, + name: str, + *, + pk: Any, + data: BaseModel | None = None, + params: Mapping[str, Any] | None = None, + **path_params: Any, + ) -> TRead: + """POST a custom single-row verb action (`{detail_url}{name}/`), parsing the + row as `model`; `name` also keys `operations` for `fields`/`expand` validation.""" + payload = self.transport.request( + "POST", + f"{self._detail_url(pk, **path_params)}{name}/", + params=self._query(params, action=name), + json=data.model_dump(mode="json", exclude_none=True) if data is not None else None, + ) + return self.model.model_validate(payload) # type: ignore[return-value] + + def _batch(self, action: str, body: dict[str, Any], **path_params: Any) -> BulkWriteResponse: + payload = self.transport.request( + "POST", f"{self._collection_url(**path_params)}{action}/", json=body + ) + return BulkWriteResponse.model_validate(payload) + + @staticmethod + def _check_cap(count: int, *, empty_detail: str) -> None: + """Reject an empty or over-cap batch before it reaches the wire. + + `empty_detail` echoes the API's own per-endpoint wording for the empty case.""" + if count == 0: + raise ValueError(empty_detail) + if count > BULK_MAX_ITEMS: + raise ValueError(f"At most {BULK_MAX_ITEMS} items per call (received {count}).") + + def _bulk_create( + self, items: list[TWrite], *, all_or_none: bool = False, **path_params: Any + ) -> BulkWriteResponse: + self._check_cap(len(items), empty_detail="Provide a non-empty list of write bodies.") + return self._batch( + "bulk-create", + { + "items": [item.model_dump(mode="json", exclude_none=True) for item in items], + "all_or_none": all_or_none, + }, + **path_params, + ) + + def _bulk_update( + self, items: list[Mapping[str, Any]], *, all_or_none: bool = False, **path_params: Any + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`. Human keys are rejected.""" + self._check_cap( + len(items), empty_detail="Provide a non-empty list of write bodies, each with an id." + ) + return self._batch( + "bulk-update", + {"items": [dict(item) for item in items], "all_or_none": all_or_none}, + **path_params, + ) + + def _bulk_delete( + self, ids: list[str], *, all_or_none: bool = False, **path_params: Any + ) -> BulkWriteResponse: + self._check_cap(len(ids), empty_detail="Provide a non-empty list of ids.") + return self._batch( + "bulk-delete", {"ids": list(ids), "all_or_none": all_or_none}, **path_params + ) + + def _find_one(self, *, filters: Mapping[str, Any], **path_params: Any) -> TRead: + """Resolve exactly one row by identity filter, or raise. + + Requests `per_page=2` to distinguish "no match" from "ambiguous" in one call.""" + page = self._list(params={**filters, "per_page": 2, "count": False}, **path_params) + described = ", ".join(f"{key}={value!r}" for key, value in filters.items()) + if not page.data: + raise NoMatchFound(f"No {type(self).__name__} matched {described}.") + if len(page.data) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched {described}; " + f"use the id instead, or list with the " + f"same filter to see every match." + ) + return page.data[0] diff --git a/plane/api/v2/_kernel/transport.py b/plane/api/v2/_kernel/transport.py new file mode 100644 index 0000000..869394a --- /dev/null +++ b/plane/api/v2/_kernel/transport.py @@ -0,0 +1,71 @@ +"""HTTP plumbing for api_v2: URL building, auth, retry, problem+json decoding.""" + +from collections.abc import Mapping +from typing import Any + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from ....config import Configuration +from .errors import PlaneAPIError + +API_V2_PREFIX = "/api/v2" + + +class V2Transport: + """One session shared by every v2 resource on a client.""" + + def __init__(self, config: Configuration) -> None: + self.config = config + self.session = requests.Session() + if config.retry: + retry = Retry( + total=config.retry.total, + backoff_factor=config.retry.backoff_factor, + status_forcelist=list(config.retry.status_forcelist), + allowed_methods=config.retry.allowed_methods, + respect_retry_after_header=True, + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + def request( + self, + method: str, + path: str, + *, + params: Mapping[str, Any] | None = None, + json: Any = None, + ) -> Any: + response = self.session.request( + method, + f"{self.config.api_root}{API_V2_PREFIX}{path}", + headers=self._headers(), + params=params, + json=json, + timeout=self.config.timeout, + ) + return self._handle(response) + + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if self.config.api_key: + headers["X-Api-Key"] = self.config.api_key + if self.config.access_token: + headers["Authorization"] = f"Bearer {self.config.access_token}" + return headers + + def _handle(self, response: requests.Response) -> Any: + if response.status_code == 204 or not response.content: + if 200 <= response.status_code < 300: + return None + if 200 <= response.status_code < 300: + return response.json() + try: + payload: Any = response.json() + except ValueError: + payload = response.text + raise PlaneAPIError.from_payload(response.status_code, payload) diff --git a/plane/api/v2/artifacts.py b/plane/api/v2/artifacts.py new file mode 100644 index 0000000..2adb36b --- /dev/null +++ b/plane/api/v2/artifacts.py @@ -0,0 +1,52 @@ +"""Artifacts (api_v2) -- the Plane Intelligence generate/publish/host flow. +Only `create`/`retrieve`/`publish`/`update` exist; each returns a different envelope.""" + +from __future__ import annotations + +from ...models.v2.artifacts import ( + Artifact, + ArtifactDetail, + ArtifactPublish, + ArtifactUpdated, + CreateArtifact, + UpdateArtifactUpdate, +) +from ._kernel.resource import V2Resource + + +class Artifacts(V2Resource[ArtifactDetail, CreateArtifact, UpdateArtifactUpdate]): + path = "/workspaces/{slug}/artifacts/" + model = ArtifactDetail + operations = { + "create": "workspaces_artifacts_create", + "retrieve": "workspaces_artifacts_retrieve", + "publish": "workspaces_artifacts_publish_create", + "update": "workspaces_artifacts_update_partial_update", + } + + def create(self, data: CreateArtifact) -> Artifact: + payload = self.transport.request( + "POST", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return Artifact.model_validate(payload) + + def retrieve(self, artifact_id: str) -> ArtifactDetail: + """Metadata + the current version's HTML.""" + return self._retrieve(pk=artifact_id) + + def publish(self, artifact_id: str) -> ArtifactPublish: + """Publish (anchor) an artifact for public hosting. No request body.""" + payload = self.transport.request("POST", f"{self._detail_url(artifact_id)}publish/") + return ArtifactPublish.model_validate(payload) + + def update(self, artifact_id: str, data: UpdateArtifactUpdate) -> ArtifactUpdated: + """Append a new HTML version (each call creates the next version wholesale + -- there is no true partial update).""" + payload = self.transport.request( + "PATCH", + f"{self._detail_url(artifact_id)}update/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return ArtifactUpdated.model_validate(payload) diff --git a/plane/api/v2/assets.py b/plane/api/v2/assets.py new file mode 100644 index 0000000..0072255 --- /dev/null +++ b/plane/api/v2/assets.py @@ -0,0 +1,111 @@ +"""Assets (api_v2) -- two-step S3 upload flow, workspace- and user-scoped. +Golden's `create` response shape and `update` body are wrong; workspace envelope +confirmed live, user envelope modeled from schema only.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.assets import ( + CreateUserAsset, + CreateWorkspaceAsset, + UserAsset, + UserAssetConfirm, + UserAssetUploadResult, + WorkspaceAsset, + WorkspaceAssetConfirm, + WorkspaceAssetUploadResult, +) +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + +__all__ = ["UserAssets", "WorkspaceAssets"] + + +class WorkspaceAssets(V2Resource[WorkspaceAsset, CreateWorkspaceAsset, WorkspaceAssetConfirm]): + path = "/workspaces/{slug}/assets/" + model = WorkspaceAsset + operations = { + "list": "assets_list", + "retrieve": "assets_retrieve", + "create": "assets_create", + "update": "assets_partial_update", + "delete": "assets_destroy", + } + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkspaceAsset]: + """One page of workspace assets.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkspaceAsset]: + """Every workspace asset, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, asset_id: str, *, fields: Sequence[str] | None = None + ) -> WorkspaceAsset: + return self._retrieve(pk=asset_id, params={"fields": fields}) + + def create(self, data: CreateWorkspaceAsset) -> WorkspaceAssetUploadResult: + """Registers the asset's metadata and returns presigned upload instructions; + no `fields` param (a sparse response could drop `upload_data`).""" + payload = self.transport.request( + "POST", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return WorkspaceAssetUploadResult.model_validate(payload) + + def update(self, asset_id: str, data: WorkspaceAssetConfirm) -> WorkspaceAsset: + return self._update(data, pk=asset_id) + + def delete(self, asset_id: str) -> None: + return self._delete(pk=asset_id) + + +class UserAssets(V2Resource[UserAsset, CreateUserAsset, UserAssetConfirm]): + """The calling principal's own avatar/cover assets. Not workspace-scoped -- + no method here takes a workspace slug, unlike the rest of api_v2.""" + + path = "/users/me/assets/" + model = UserAsset + operations = { + "list": "user_assets_list", + "retrieve": "user_assets_retrieve", + "create": "user_assets_create", + "update": "user_assets_partial_update", + "delete": "user_assets_destroy", + } + + def list(self, *, fields: Sequence[str] | None = None, **filters: Any) -> Page[UserAsset]: + """One page of the calling principal's assets.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[UserAsset]: + """Every asset belonging to the calling principal, following pages + automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, asset_id: str, *, fields: Sequence[str] | None = None) -> UserAsset: + return self._retrieve(pk=asset_id, params={"fields": fields}) + + def create(self, data: CreateUserAsset) -> UserAssetUploadResult: + """Registers the asset's metadata and returns presigned upload + instructions (see the module docstring for the contract caveat).""" + payload = self.transport.request( + "POST", self._collection_url(), json=data.model_dump(mode="json", exclude_none=True) + ) + return UserAssetUploadResult.model_validate(payload) + + def update(self, asset_id: str, data: UserAssetConfirm) -> UserAsset: + return self._update(data, pk=asset_id) + + def delete(self, asset_id: str) -> None: + return self._delete(pk=asset_id) diff --git a/plane/api/v2/audit_logs.py b/plane/api/v2/audit_logs.py new file mode 100644 index 0000000..58284ee --- /dev/null +++ b/plane/api/v2/audit_logs.py @@ -0,0 +1,49 @@ +"""Workspace audit logs (api_v2). Read-only, cursor- or offset-paginated.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.audit_logs import AuditLog +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +# No write/patch DTO exists for audit logs -- the type parameters are filled +# with the read model itself as an unused placeholder (see PermissionSchemes). +class AuditLogs(V2Resource[AuditLog, AuditLog, AuditLog]): + path = "/workspaces/{slug}/audit-logs/" + model = AuditLog + operations = { + "list": "audit_logs_list", + "retrieve": "audit_logs_retrieve", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AuditLog]: + """One page of audit log entries. + + `**filters` covers `actor_id`, `category`, `outcome`, `created_after`, `search`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AuditLog]: + """Every audit log entry, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + log_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AuditLog: + return self._retrieve(pk=log_id, params={"fields": fields}) diff --git a/plane/api/v2/automations/__init__.py b/plane/api/v2/automations/__init__.py new file mode 100644 index 0000000..f568d78 --- /dev/null +++ b/plane/api/v2/automations/__init__.py @@ -0,0 +1,155 @@ +"""Automations (api_v2). Project- and workspace-scoped families have their own +path templates/operationIds, hence separate `ProjectAutomations`/`WorkspaceAutomations` +classes; `set_status` (204, no body) is the only way to enable/disable one.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.automations import ( + Automation, + CreateAutomation, + SetAutomationStatus, + UpdateAutomation, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .activities import ProjectAutomationActivities, WorkspaceAutomationActivities +from .edges import ProjectAutomationEdges, WorkspaceAutomationEdges +from .nodes import ProjectAutomationNodes, WorkspaceAutomationNodes + +__all__ = [ + "ProjectAutomationActivities", + "ProjectAutomationEdges", + "ProjectAutomationNodes", + "ProjectAutomations", + "WorkspaceAutomationActivities", + "WorkspaceAutomationEdges", + "WorkspaceAutomationNodes", + "WorkspaceAutomations", +] + + +class ProjectAutomations(V2Resource[Automation, CreateAutomation, UpdateAutomation]): + path = "/workspaces/{slug}/projects/{project_id}/automations/" + model = Automation + operations = { + "list": "project_automations_list", + "retrieve": "project_automations_retrieve", + "create": "project_automations_create", + "update": "project_automations_partial_update", + "delete": "project_automations_destroy", + "set_status": "project_automations_status", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.edges = ProjectAutomationEdges(transport, **self._scope) + self.nodes = ProjectAutomationNodes(transport, **self._scope) + self.activities = ProjectAutomationActivities(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[Automation]: + """One page of automations in this project. + + `**filters` covers `is_enabled`, `is_global`, `name`, `scope`, `status`, `search`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Automation]: + """Every automation in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, automation_id: str, *, fields: Sequence[str] | None = None + ) -> Automation: + return self._retrieve(pk=automation_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Automation: + """The one automation with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateAutomation) -> Automation: + return self._create(data) + + def update(self, automation_id: str, data: UpdateAutomation) -> Automation: + return self._update(data, pk=automation_id) + + def delete(self, automation_id: str) -> None: + return self._delete(pk=automation_id) + + def set_status(self, automation_id: str, *, is_enabled: bool) -> None: + """Enable/disable a project automation (and move it out of draft). 204, + no row returned -- unlike most custom actions, there is nothing to parse.""" + data = SetAutomationStatus(is_enabled=is_enabled) + self.transport.request( + "POST", + f"{self._detail_url(automation_id)}status/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return None + + +class WorkspaceAutomations(V2Resource[Automation, CreateAutomation, UpdateAutomation]): + path = "/workspaces/{slug}/automations/" + model = Automation + operations = { + "list": "workspace_automations_list", + "retrieve": "workspace_automations_retrieve", + "create": "workspace_automations_create", + "update": "workspace_automations_partial_update", + "delete": "workspace_automations_destroy", + "set_status": "workspace_automations_status", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.edges = WorkspaceAutomationEdges(transport, **self._scope) + self.nodes = WorkspaceAutomationNodes(transport, **self._scope) + self.activities = WorkspaceAutomationActivities(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[Automation]: + """One page of workspace-level (global, not-project-tied) automations.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Automation]: + """Every workspace-level automation, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, automation_id: str, *, fields: Sequence[str] | None = None + ) -> Automation: + return self._retrieve(pk=automation_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Automation: + """The one automation with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateAutomation) -> Automation: + return self._create(data) + + def update(self, automation_id: str, data: UpdateAutomation) -> Automation: + return self._update(data, pk=automation_id) + + def delete(self, automation_id: str) -> None: + return self._delete(pk=automation_id) + + def set_status(self, automation_id: str, *, is_enabled: bool) -> None: + """Enable/disable a workspace automation (and move it out of draft). 204, + no row returned.""" + data = SetAutomationStatus(is_enabled=is_enabled) + self.transport.request( + "POST", + f"{self._detail_url(automation_id)}status/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return None + diff --git a/plane/api/v2/automations/activities.py b/plane/api/v2/automations/activities.py new file mode 100644 index 0000000..f7724ae --- /dev/null +++ b/plane/api/v2/automations/activities.py @@ -0,0 +1,98 @@ +"""Automation activities (api_v2). Read-only -- generated as a side effect of +automation writes/runs, never written directly. Project- and workspace-scoped +families mirror `ProjectAutomations`/`WorkspaceAutomations`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.automations import AutomationActivity +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ProjectAutomationActivities( + V2Resource[AutomationActivity, AutomationActivity, AutomationActivity] +): + path = "/workspaces/{slug}/projects/{project_id}/automations/{automation_id}/activities/" + model = AutomationActivity + operations = { + "list": "project_automation_activities_list", + "retrieve": "project_automation_activities_retrieve", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationActivity]: + """One page of activity entries on a project automation. + + `**filters` covers `verb`, `field`, `created_at__gt`, etc.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationActivity]: + """Every activity entry on a project automation, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + activity_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationActivity: + return self._retrieve( + pk=activity_id, automation_id=automation_id, params={"fields": fields} + ) + + +class WorkspaceAutomationActivities( + V2Resource[AutomationActivity, AutomationActivity, AutomationActivity] +): + path = "/workspaces/{slug}/automations/{automation_id}/activities/" + model = AutomationActivity + operations = { + "list": "workspace_automation_activities_list", + "retrieve": "workspace_automation_activities_retrieve", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationActivity]: + """One page of activity entries on a workspace automation.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationActivity]: + """Every activity entry on a workspace automation, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + activity_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationActivity: + return self._retrieve( + pk=activity_id, automation_id=automation_id, params={"fields": fields} + ) diff --git a/plane/api/v2/automations/edges.py b/plane/api/v2/automations/edges.py new file mode 100644 index 0000000..b9a59cb --- /dev/null +++ b/plane/api/v2/automations/edges.py @@ -0,0 +1,125 @@ +"""Automation edges (api_v2). An edge connects two nodes (`source_node_id` -> +`target_node_id`) in an automation's graph. Project- and workspace-scoped +families mirror `ProjectAutomations`/`WorkspaceAutomations`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.automations import AutomationEdge, CreateAutomationEdge, UpdateAutomationEdge +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ProjectAutomationEdges( + V2Resource[AutomationEdge, CreateAutomationEdge, UpdateAutomationEdge] +): + path = "/workspaces/{slug}/projects/{project_id}/automations/{automation_id}/edges/" + model = AutomationEdge + operations = { + "list": "project_automation_edges_list", + "retrieve": "project_automation_edges_retrieve", + "create": "project_automation_edges_create", + "update": "project_automation_edges_partial_update", + "delete": "project_automation_edges_destroy", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationEdge]: + """One page of edges in a project automation's graph. + + `**filters` covers `source_node_id`/`target_node_id`.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationEdge]: + """Every edge in a project automation's graph, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + edge_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationEdge: + return self._retrieve(pk=edge_id, automation_id=automation_id, params={"fields": fields}) + + def create(self, automation_id: str, data: CreateAutomationEdge) -> AutomationEdge: + return self._create(data, automation_id=automation_id) + + def update( + self, + automation_id: str, + edge_id: str, + data: UpdateAutomationEdge, + ) -> AutomationEdge: + return self._update(data, pk=edge_id, automation_id=automation_id) + + def delete(self, automation_id: str, edge_id: str) -> None: + return self._delete(pk=edge_id, automation_id=automation_id) + + +class WorkspaceAutomationEdges( + V2Resource[AutomationEdge, CreateAutomationEdge, UpdateAutomationEdge] +): + path = "/workspaces/{slug}/automations/{automation_id}/edges/" + model = AutomationEdge + operations = { + "list": "workspace_automation_edges_list", + "retrieve": "workspace_automation_edges_retrieve", + "create": "workspace_automation_edges_create", + "update": "workspace_automation_edges_partial_update", + "delete": "workspace_automation_edges_destroy", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationEdge]: + """One page of edges in a workspace automation's graph.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationEdge]: + """Every edge in a workspace automation's graph, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + edge_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationEdge: + return self._retrieve(pk=edge_id, automation_id=automation_id, params={"fields": fields}) + + def create(self, automation_id: str, data: CreateAutomationEdge) -> AutomationEdge: + return self._create(data, automation_id=automation_id) + + def update( + self, automation_id: str, edge_id: str, data: UpdateAutomationEdge + ) -> AutomationEdge: + return self._update(data, pk=edge_id, automation_id=automation_id) + + def delete(self, automation_id: str, edge_id: str) -> None: + return self._delete(pk=edge_id, automation_id=automation_id) diff --git a/plane/api/v2/automations/nodes.py b/plane/api/v2/automations/nodes.py new file mode 100644 index 0000000..c49f829 --- /dev/null +++ b/plane/api/v2/automations/nodes.py @@ -0,0 +1,160 @@ +"""Automation nodes (api_v2). A node is one trigger/action/condition step in an +automation's graph. Project- and workspace-scoped families mirror +`ProjectAutomations`/`WorkspaceAutomations`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.automations import ( + AutomationNode, + AutomationWebhookSecret, + CreateAutomationNode, + UpdateAutomationNode, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ProjectAutomationNodes( + V2Resource[AutomationNode, CreateAutomationNode, UpdateAutomationNode] +): + path = "/workspaces/{slug}/projects/{project_id}/automations/{automation_id}/nodes/" + model = AutomationNode + operations = { + "list": "project_automation_nodes_list", + "retrieve": "project_automation_nodes_retrieve", + "create": "project_automation_nodes_create", + "update": "project_automation_nodes_partial_update", + "delete": "project_automation_nodes_destroy", + "regenerate_webhook_secret": "project_automation_nodes_regenerate_webhook_secret", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationNode]: + """One page of nodes in a project automation's graph. + + `**filters` covers `handler_name`, `is_enabled`, `name`, `node_type`, `search`.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationNode]: + """Every node in a project automation's graph, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + node_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationNode: + return self._retrieve(pk=node_id, automation_id=automation_id, params={"fields": fields}) + + def find_by_name(self, automation_id: str, name: str) -> AutomationNode: + """The one node with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}, automation_id=automation_id) + + def create(self, automation_id: str, data: CreateAutomationNode) -> AutomationNode: + return self._create(data, automation_id=automation_id) + + def update( + self, + automation_id: str, + node_id: str, + data: UpdateAutomationNode, + ) -> AutomationNode: + return self._update(data, pk=node_id, automation_id=automation_id) + + def delete(self, automation_id: str, node_id: str) -> None: + return self._delete(pk=node_id, automation_id=automation_id) + + def regenerate_webhook_secret( + self, automation_id: str, node_id: str + ) -> AutomationWebhookSecret: + """Rotate the webhook secret for a webhook-trigger node, returning the new one. + + Not built on `_action`: the response model here isn't this resource's own `self.model`.""" + detail_url = self._detail_url(node_id, automation_id=automation_id) + payload = self.transport.request("POST", f"{detail_url}regenerate-webhook-secret/") + return AutomationWebhookSecret.model_validate(payload or {}) + + +class WorkspaceAutomationNodes( + V2Resource[AutomationNode, CreateAutomationNode, UpdateAutomationNode] +): + path = "/workspaces/{slug}/automations/{automation_id}/nodes/" + model = AutomationNode + operations = { + "list": "workspace_automation_nodes_list", + "retrieve": "workspace_automation_nodes_retrieve", + "create": "workspace_automation_nodes_create", + "update": "workspace_automation_nodes_partial_update", + "delete": "workspace_automation_nodes_destroy", + "regenerate_webhook_secret": "workspace_automation_nodes_regenerate_webhook_secret", + } + + def list( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[AutomationNode]: + """One page of nodes in a workspace automation's graph.""" + return self._list(automation_id=automation_id, params={"fields": fields, **filters}) + + def iterate( + self, + automation_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[AutomationNode]: + """Every node in a workspace automation's graph, following pages automatically.""" + return self._iter(automation_id=automation_id, params={"fields": fields, **filters}) + + def retrieve( + self, + automation_id: str, + node_id: str, + *, + fields: Sequence[str] | None = None, + ) -> AutomationNode: + return self._retrieve(pk=node_id, automation_id=automation_id, params={"fields": fields}) + + def find_by_name(self, automation_id: str, name: str) -> AutomationNode: + """The one node with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}, automation_id=automation_id) + + def create(self, automation_id: str, data: CreateAutomationNode) -> AutomationNode: + return self._create(data, automation_id=automation_id) + + def update( + self, automation_id: str, node_id: str, data: UpdateAutomationNode + ) -> AutomationNode: + return self._update(data, pk=node_id, automation_id=automation_id) + + def delete(self, automation_id: str, node_id: str) -> None: + return self._delete(pk=node_id, automation_id=automation_id) + + def regenerate_webhook_secret( + self, automation_id: str, node_id: str + ) -> AutomationWebhookSecret: + """Rotate the webhook secret for a webhook-trigger node, returning the new + one. See `ProjectAutomationNodes.regenerate_webhook_secret` for why this + bypasses `_action`.""" + detail_url = self._detail_url(node_id, automation_id=automation_id) + payload = self.transport.request("POST", f"{detail_url}regenerate-webhook-secret/") + return AutomationWebhookSecret.model_validate(payload or {}) diff --git a/plane/api/v2/collections/__init__.py b/plane/api/v2/collections/__init__.py new file mode 100644 index 0000000..117863d --- /dev/null +++ b/plane/api/v2/collections/__init__.py @@ -0,0 +1,93 @@ +"""Collections (api_v2) -- wiki collections: named groups of workspace pages +with their own membership. Golden `operationId` prefix is `pages_*`; do not +confuse this with the top-level `plane.api.v2.pages` resource.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.collections import Collection, CreateCollection, UpdateCollection +from .._kernel.errors import MultipleMatchesFound, NoMatchFound +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .members import CollectionMembers +from .pages import CollectionPages + +__all__ = ["CollectionMembers", "CollectionPages", "Collections"] + + +class Collections(V2Resource[Collection, CreateCollection, UpdateCollection]): + path = "/workspaces/{slug}/collections/" + model = Collection + operations = { + "list": "pages_list", + "retrieve": "pages_retrieve", + "create": "pages_create", + "update": "pages_partial_update", + "delete": "pages_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.members = CollectionMembers(transport, **self._scope) + self.pages = CollectionPages(transport, **self._scope) + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Collection]: + """One page of collections in the workspace.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Collection]: + """Every collection in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + collection_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Collection: + return self._retrieve(pk=collection_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Collection: + """The one collection with this name; raises if none or several match. + Filters client-side: no `?name=` filter; a server-side one is silently + ignored (confirmed live).""" + matches = [row for row in self.iterate() if row.name == name] + if not matches: + raise NoMatchFound(f"No Collections matched name={name!r}.") + if len(matches) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched name={name!r}; " + f"use the id instead, or list to see every match." + ) + return matches[0] + + def default(self) -> Collection: + """The workspace's default (General) collection, where a new wiki page + lands when `CreatePage.collection_id` is omitted. Resolved via the + golden's `?is_default=` list filter, not a client-side scan.""" + return self._find_one(filters={"is_default": True}) + + def create(self, data: CreateCollection) -> Collection: + return self._create(data) + + def update(self, collection_id: str, data: UpdateCollection) -> Collection: + return self._update(data, pk=collection_id) + + def delete(self, collection_id: str) -> None: + return self._delete(pk=collection_id) diff --git a/plane/api/v2/collections/members.py b/plane/api/v2/collections/members.py new file mode 100644 index 0000000..c6a1e0b --- /dev/null +++ b/plane/api/v2/collections/members.py @@ -0,0 +1,55 @@ +"""Collection members (api_v2) -- membership on a wiki collection. + +`list` is unpaginated: the golden documents a bare `CollectionMember`, parsed as a plain array.""" + +from __future__ import annotations + +import builtins +from collections.abc import Sequence + +from ....models.v2.collections import ( + CollectionMember, + CollectionMembersManage, + CollectionMembersManageResult, +) +from .._kernel.resource import V2Resource + +__all__ = ["CollectionMembers"] + + +class CollectionMembers( + V2Resource[CollectionMember, CollectionMembersManage, CollectionMembersManage] +): + path = "/workspaces/{slug}/collections/{collection_id}/members/" + model = CollectionMember + operations = { + "list": "collections_members_list", + "manage": "collections_members", + } + + def list( + self, + collection_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> builtins.list[CollectionMember]: + """Every member row on a collection. Unpaginated -- see the module + docstring for why this parses a plain array rather than an envelope.""" + payload = self.transport.request( + "GET", + self._collection_url(collection_id=collection_id), + params=self._query({"fields": fields, "expand": expand}, action="list"), + ) + return [CollectionMember.model_validate(row) for row in payload] + + def manage( + self, collection_id: str, data: CollectionMembersManage + ) -> CollectionMembersManageResult: + """Bulk grant/revoke workspace-member access to a collection.""" + payload = self.transport.request( + "POST", + self._collection_url(collection_id=collection_id), + json=data.model_dump(mode="json", exclude_none=True), + ) + return CollectionMembersManageResult.model_validate(payload) diff --git a/plane/api/v2/collections/pages.py b/plane/api/v2/collections/pages.py new file mode 100644 index 0000000..45b63ca --- /dev/null +++ b/plane/api/v2/collections/pages.py @@ -0,0 +1,56 @@ +"""Collection pages (api_v2) -- page membership and search on a wiki collection. +**`search` is confirmed unreliable**: it collides with the collection list filter, +404ing as if `collection_id` didn't exist (plane-ee bug).""" + +from __future__ import annotations + +import builtins +from collections.abc import Sequence + +from ....models.v2.collections import ( + CollectionPageSearch, + CollectionPagesManage, + CollectionPagesManageResult, +) +from .._kernel.resource import V2Resource + +__all__ = ["CollectionPages"] + + +class CollectionPages( + V2Resource[CollectionPageSearch, CollectionPagesManage, CollectionPagesManage] +): + path = "/workspaces/{slug}/collections/{collection_id}/" + model = CollectionPageSearch + operations = { + "search": "collections_pages_search", + "manage": "collections_pages", + } + + def manage( + self, collection_id: str, data: CollectionPagesManage + ) -> CollectionPagesManageResult: + """Bulk add/remove pages from a collection.""" + payload = self.transport.request( + "POST", + f"{self._collection_url(collection_id=collection_id)}pages/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return CollectionPagesManageResult.model_validate(payload) + + def search( + self, + collection_id: str, + *, + search: str | None = None, + fields: Sequence[str] | None = None, + ) -> builtins.list[CollectionPageSearch]: + """Lite rows for pages NOT already in this collection -- see the module + docstring for the confirmed live server bug around `search`.""" + base = self._collection_url(collection_id=collection_id) + payload = self.transport.request( + "GET", + f"{base}pages-search/", + params=self._query({"search": search, "fields": fields}, action="search"), + ) + return [CollectionPageSearch.model_validate(row) for row in payload] diff --git a/plane/api/v2/customer_properties.py b/plane/api/v2/customer_properties.py new file mode 100644 index 0000000..186dcfe --- /dev/null +++ b/plane/api/v2/customer_properties.py @@ -0,0 +1,70 @@ +"""Customer properties (api_v2) -- custom-field definitions on the Customer object. +Flat CRUD; no bulk or upsert (the golden offers neither for this resource).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.customer_properties import ( + CreateCustomerProperty, + CustomerProperty, + UpdateCustomerProperty, +) +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class CustomerProperties( + V2Resource[CustomerProperty, CreateCustomerProperty, UpdateCustomerProperty] +): + path = "/workspaces/{slug}/customer-properties/" + model = CustomerProperty + operations = { + "list": "customer_properties_list", + "retrieve": "customer_properties_retrieve", + "create": "customer_properties_create", + "update": "customer_properties_partial_update", + "delete": "customer_properties_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[CustomerProperty]: + """One page of customer properties. `**filters` covers the golden's query + filters directly, e.g. `is_active=True`, `property_type="OPTION"`, + `name="Tier"`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[CustomerProperty]: + """Every customer property, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + property_id: str, + *, + fields: Sequence[str] | None = None, + ) -> CustomerProperty: + return self._retrieve(pk=property_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> CustomerProperty: + """The one customer property with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateCustomerProperty) -> CustomerProperty: + return self._create(data) + + def update(self, property_id: str, data: UpdateCustomerProperty) -> CustomerProperty: + return self._update(data, pk=property_id) + + def delete(self, property_id: str) -> None: + return self._delete(pk=property_id) diff --git a/plane/api/v2/customers/__init__.py b/plane/api/v2/customers/__init__.py new file mode 100644 index 0000000..4ac2653 --- /dev/null +++ b/plane/api/v2/customers/__init__.py @@ -0,0 +1,7 @@ +"""Customers (api_v2) -- see `customers.py` for the family docstring.""" + +from .customers import Customers +from .property_values import CustomerPropertyValues +from .requests import CustomerRequests + +__all__ = ["Customers", "CustomerPropertyValues", "CustomerRequests"] diff --git a/plane/api/v2/customers/customers.py b/plane/api/v2/customers/customers.py new file mode 100644 index 0000000..4f6861c --- /dev/null +++ b/plane/api/v2/customers/customers.py @@ -0,0 +1,93 @@ +"""Customers (api_v2) -- workspace-scoped, unlike states/labels/work items. +`manage_work_items` returns the changed ids, not a `Customer`, so it bypasses `_action`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.customers import ( + CreateCustomer, + Customer, + CustomerWorkItemManageRequest, + CustomerWorkItemManageResponse, + UpdateCustomer, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .property_values import CustomerPropertyValues +from .requests import CustomerRequests + +__all__ = ["Customers", "CustomerPropertyValues", "CustomerRequests"] + + +class Customers(V2Resource[Customer, CreateCustomer, UpdateCustomer]): + path = "/workspaces/{slug}/customers/" + model = Customer + operations = { + "list": "customers_list", + "retrieve": "customers_retrieve", + "create": "customers_create", + "update": "customers_partial_update", + "upsert": "customers_upsert", + "delete": "customers_destroy", + "manage_work_items": "customers_work_items", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.requests = CustomerRequests(transport, **self._scope) + self.property_values = CustomerPropertyValues(transport, **self._scope) + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Customer]: + """One page of customers in a workspace. + + `**filters` covers `name`, `domain`, `stage`, `contract_status`, `search`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Customer]: + """Every customer in a workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, customer_id: str, *, fields: Sequence[str] | None = None) -> Customer: + return self._retrieve(pk=customer_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Customer: + """The one customer with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateCustomer) -> Customer: + return self._create(data) + + def update(self, customer_id: str, data: UpdateCustomer) -> Customer: + return self._update(data, pk=customer_id) + + def delete(self, customer_id: str) -> None: + return self._delete(pk=customer_id) + + def upsert(self, data: CreateCustomer) -> Customer: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def manage_work_items( + self, customer_id: str, data: CustomerWorkItemManageRequest + ) -> CustomerWorkItemManageResponse: + """Link (`data.add`) or unlink (`data.remove`) work items on a customer, + returning the ids actually changed.""" + payload = self.transport.request( + "POST", + f"{self._detail_url(customer_id)}work-items/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return CustomerWorkItemManageResponse.model_validate(payload) diff --git a/plane/api/v2/customers/property_values.py b/plane/api/v2/customers/property_values.py new file mode 100644 index 0000000..7488cb3 --- /dev/null +++ b/plane/api/v2/customers/property_values.py @@ -0,0 +1,37 @@ +"""Customer property values (api_v2). `list`/`create` return a mapping keyed by +customer property id, not a paginated collection; no per-row retrieve/update/delete +exists (golden exposes only bulk list/set). See `WorkItemRelations` for the same shape.""" + +from __future__ import annotations + +from ....models.v2.customers import CreateCustomerPropertyValues, CustomerPropertyValueMap +from .._kernel.resource import V2Resource + + +class CustomerPropertyValues( + V2Resource[CustomerPropertyValueMap, CreateCustomerPropertyValues, CreateCustomerPropertyValues] +): + path = "/workspaces/{slug}/customers/{customer_id}/property-values/" + model = CustomerPropertyValueMap + operations = { + "list": "customer_property_values_list", + "create": "customer_property_values_create", + } + + def list(self, customer_id: str) -> CustomerPropertyValueMap: + """Every property value set on a customer, keyed by property id. + Properties with no value set are absent from the mapping.""" + payload = self.transport.request( + "GET", self._collection_url(customer_id=customer_id) + ) + return self.model.model_validate(payload) + + def create(self, customer_id: str, data: CreateCustomerPropertyValues) -> None: + """Bulk-set several of a customer's property values at once (upsert; + properties absent from `data.values` are untouched).""" + self.transport.request( + "POST", + self._collection_url(customer_id=customer_id), + json=data.model_dump(mode="json", exclude_none=True), + ) + return None diff --git a/plane/api/v2/customers/requests.py b/plane/api/v2/customers/requests.py new file mode 100644 index 0000000..03c58c0 --- /dev/null +++ b/plane/api/v2/customers/requests.py @@ -0,0 +1,64 @@ +"""Customer requests (api_v2). `work_item_ids` on `CreateCustomerRequest`/ +`UpdateCustomerRequest` links the request to work items but is write-only, +not echoed back on `CustomerRequest`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.customers import CreateCustomerRequest, CustomerRequest, UpdateCustomerRequest +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class CustomerRequests(V2Resource[CustomerRequest, CreateCustomerRequest, UpdateCustomerRequest]): + path = "/workspaces/{slug}/customers/{customer_id}/requests/" + model = CustomerRequest + operations = { + "list": "customer_requests_list", + "retrieve": "customer_requests_retrieve", + "create": "customer_requests_create", + "update": "customer_requests_partial_update", + "delete": "customer_requests_destroy", + } + + def list( + self, + customer_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[CustomerRequest]: + """One page of requests raised by a customer.""" + return self._list(customer_id=customer_id, params={"fields": fields, **filters}) + + def iterate( + self, + customer_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[CustomerRequest]: + """Every request raised by a customer, following pages automatically.""" + return self._iter(customer_id=customer_id, params={"fields": fields, **filters}) + + def retrieve( + self, + customer_id: str, + pk: str, + *, + fields: Sequence[str] | None = None, + ) -> CustomerRequest: + return self._retrieve(pk=pk, customer_id=customer_id, params={"fields": fields}) + + def create(self, customer_id: str, data: CreateCustomerRequest) -> CustomerRequest: + return self._create(data, customer_id=customer_id) + + def update( + self, customer_id: str, pk: str, data: UpdateCustomerRequest + ) -> CustomerRequest: + return self._update(data, pk=pk, customer_id=customer_id) + + def delete(self, customer_id: str, pk: str) -> None: + return self._delete(pk=pk, customer_id=customer_id) diff --git a/plane/api/v2/cycles.py b/plane/api/v2/cycles.py new file mode 100644 index 0000000..e605d00 --- /dev/null +++ b/plane/api/v2/cycles.py @@ -0,0 +1,132 @@ +"""Project cycles (api_v2). `transfer`/`manage_work_items` don't fit the CRUD +request/response shape `V2Resource` generates, so they build requests directly, +but stay plain methods on `Cycles` rather than a separate sub-resource.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.cycle_actions import ( + CycleTransfer, + CycleTransferResult, + CycleWorkItemManage, + CycleWorkItemManageResult, +) +from ...models.v2.cycles import CreateCycle, Cycle, UpdateCycle +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Cycles(V2Resource[Cycle, CreateCycle, UpdateCycle]): + path = "/workspaces/{slug}/projects/{project_id}/cycles/" + model = Cycle + operations = { + "list": "cycles_list", + "retrieve": "cycles_retrieve", + "create": "cycles_create", + "update": "cycles_partial_update", + "upsert": "cycles_upsert", + "delete": "cycles_destroy", + "bulk_create": "cycles_bulk_create", + "bulk_update": "cycles_bulk_update", + "bulk_delete": "cycles_bulk_delete", + "transfer": "cycles_transfer", + "manage_work_items": "cycles_work_items_manage", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Cycle]: + """One page of cycles in this project.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Cycle]: + """Every cycle, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + cycle_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Cycle: + return self._retrieve(pk=cycle_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Cycle: + """The one cycle with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateCycle) -> Cycle: + return self._create(data) + + def update(self, cycle_id: str, data: UpdateCycle) -> Cycle: + return self._update(data, pk=cycle_id) + + def delete(self, cycle_id: str) -> None: + return self._delete(pk=cycle_id) + + def upsert(self, data: CreateCycle) -> Cycle: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateCycle], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) + + # -- Custom actions ------------------------------------------------------ + + def transfer(self, cycle_id: str, new_cycle_id: str) -> CycleTransferResult: + """Move `cycle_id`'s incomplete work items into `new_cycle_id`. The source + cycle must already be completed (server-enforced); the destination must + exist in the same project.""" + data = CycleTransfer(new_cycle_id=new_cycle_id) + payload = self.transport.request( + "POST", + f"{self._detail_url(cycle_id)}transfer/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return CycleTransferResult.model_validate(payload) + + def manage_work_items( + self, + cycle_id: str, + *, + add: builtins.list[str] | None = None, + remove: builtins.list[str] | None = None, + ) -> CycleWorkItemManageResult: + """Bulk set-style cycle membership: `add` moves work items into this + cycle (re-homing from any other cycle), `remove` takes them out. Provide + at least one of `add`/`remove` (up to 100 ids each).""" + data = CycleWorkItemManage(add=add, remove=remove) + payload = self.transport.request( + "POST", + f"{self._detail_url(cycle_id)}work-items/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return CycleWorkItemManageResult.model_validate(payload) diff --git a/plane/api/v2/estimates/__init__.py b/plane/api/v2/estimates/__init__.py new file mode 100644 index 0000000..4453664 --- /dev/null +++ b/plane/api/v2/estimates/__init__.py @@ -0,0 +1,100 @@ +"""Estimates (api_v2) -- project-scoped, with nested estimate points. +Mirrors `states`/`labels` (CRUD + upsert + bulk), plus `expand=["points"]`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ....models.v2.common import BulkWriteResponse +from ....models.v2.estimates import CreateEstimate, Estimate, UpdateEstimate +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .points import EstimatePoints + +__all__ = ["EstimatePoints", "Estimates"] + + +class Estimates(V2Resource[Estimate, CreateEstimate, UpdateEstimate]): + path = "/workspaces/{slug}/projects/{project_id}/estimates/" + model = Estimate + operations = { + "list": "estimates_list", + "retrieve": "estimates_retrieve", + "create": "estimates_create", + "update": "estimates_partial_update", + "upsert": "estimates_upsert", + "delete": "estimates_destroy", + "bulk_create": "estimates_bulk_create", + "bulk_update": "estimates_bulk_update", + "bulk_delete": "estimates_bulk_delete", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.points = EstimatePoints(transport, **self._scope) + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Estimate]: + """One page of estimates in this project. Pass `expand=["points"]` to + inline each estimate's points.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Estimate]: + """Every estimate in this project, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + estimate_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Estimate: + return self._retrieve(pk=estimate_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Estimate: + """The one estimate with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateEstimate) -> Estimate: + return self._create(data) + + def update(self, estimate_id: str, data: UpdateEstimate) -> Estimate: + return self._update(data, pk=estimate_id) + + def delete(self, estimate_id: str) -> None: + return self._delete(pk=estimate_id) + + def upsert(self, data: CreateEstimate) -> Estimate: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateEstimate], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) diff --git a/plane/api/v2/estimates/points.py b/plane/api/v2/estimates/points.py new file mode 100644 index 0000000..1836700 --- /dev/null +++ b/plane/api/v2/estimates/points.py @@ -0,0 +1,100 @@ +"""Estimate points (api_v2) -- nested under a project-scoped estimate.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ....models.v2.common import BulkWriteResponse +from ....models.v2.estimates import CreateEstimatePoint, EstimatePoint, UpdateEstimatePoint +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class EstimatePoints(V2Resource[EstimatePoint, CreateEstimatePoint, UpdateEstimatePoint]): + path = "/workspaces/{slug}/projects/{project_id}/estimates/{estimate_id}/points/" + model = EstimatePoint + operations = { + "list": "estimate_points_list", + "retrieve": "estimate_points_retrieve", + "create": "estimate_points_create", + "update": "estimate_points_partial_update", + "upsert": "estimate_points_upsert", + "delete": "estimate_points_destroy", + "bulk_create": "estimate_points_bulk_create", + "bulk_update": "estimate_points_bulk_update", + "bulk_delete": "estimate_points_bulk_delete", + } + + def list( + self, + estimate_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[EstimatePoint]: + """One page of points belonging to an estimate.""" + return self._list(estimate_id=estimate_id, params={"fields": fields, **filters}) + + def iterate( + self, + estimate_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[EstimatePoint]: + """Every point belonging to an estimate, following pages automatically.""" + return self._iter(estimate_id=estimate_id, params={"fields": fields, **filters}) + + def retrieve( + self, + estimate_id: str, + point_id: str, + *, + fields: Sequence[str] | None = None, + ) -> EstimatePoint: + return self._retrieve(pk=point_id, estimate_id=estimate_id, params={"fields": fields}) + + def create(self, estimate_id: str, data: CreateEstimatePoint) -> EstimatePoint: + return self._create(data, estimate_id=estimate_id) + + def update( + self, estimate_id: str, point_id: str, data: UpdateEstimatePoint + ) -> EstimatePoint: + return self._update(data, pk=point_id, estimate_id=estimate_id) + + def delete(self, estimate_id: str, point_id: str) -> None: + return self._delete(pk=point_id, estimate_id=estimate_id) + + def upsert(self, estimate_id: str, data: CreateEstimatePoint) -> EstimatePoint: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data, estimate_id=estimate_id) + + def bulk_create( + self, + estimate_id: str, + items: builtins.list[CreateEstimatePoint], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none, estimate_id=estimate_id) + + def bulk_update( + self, + estimate_id: str, + items: builtins.list[Mapping[str, Any]], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none, estimate_id=estimate_id) + + def bulk_delete( + self, + estimate_id: str, + ids: builtins.list[str], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none, estimate_id=estimate_id) diff --git a/plane/api/v2/features.py b/plane/api/v2/features.py new file mode 100644 index 0000000..81dced2 --- /dev/null +++ b/plane/api/v2/features.py @@ -0,0 +1,57 @@ +"""Feature toggles (api_v2) -- two GET/PATCH singletons, no `id` in the URL: the +row IS the collection, so these hit the collection URL directly rather than +`_retrieve`/`_update` (which both require a `pk` to append).""" + +from __future__ import annotations + +from ...models.v2.features import ( + ProjectFeature, + UpdateProjectFeature, + UpdateWorkspaceFeature, + WorkspaceFeature, +) +from ._kernel.resource import V2Resource + + +class WorkspaceFeatures( + V2Resource[WorkspaceFeature, UpdateWorkspaceFeature, UpdateWorkspaceFeature] +): + path = "/workspaces/{slug}/features/" + model = WorkspaceFeature + operations = { + "retrieve": "workspace_features_retrieve", + "update": "workspace_features_update", + } + + def retrieve(self) -> WorkspaceFeature: + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) + + def update(self, data: UpdateWorkspaceFeature) -> WorkspaceFeature: + payload = self.transport.request( + "PATCH", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) + + +class ProjectFeatures(V2Resource[ProjectFeature, UpdateProjectFeature, UpdateProjectFeature]): + path = "/workspaces/{slug}/projects/{project_id}/features/" + model = ProjectFeature + operations = { + "retrieve": "project_features_retrieve", + "update": "project_features_update", + } + + def retrieve(self) -> ProjectFeature: + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) + + def update(self, data: UpdateProjectFeature) -> ProjectFeature: + payload = self.transport.request( + "PATCH", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) diff --git a/plane/api/v2/group_sync/__init__.py b/plane/api/v2/group_sync/__init__.py new file mode 100644 index 0000000..47162e4 --- /dev/null +++ b/plane/api/v2/group_sync/__init__.py @@ -0,0 +1,26 @@ +"""Group sync (api_v2) -- IdP group -> role/project mapping. `.config` is a +`get`/`update` singleton; `.project_mappings`/`.workspace_mappings` are plain +CRUD (no upsert or bulk).""" + +from __future__ import annotations + +from typing import Any + +from .._kernel.transport import V2Transport +from .config import GroupSyncConfigResource +from .project_mappings import GroupSyncProjectMappings +from .workspace_mappings import GroupSyncWorkspaceMappings + +__all__ = [ + "GroupSync", + "GroupSyncConfigResource", + "GroupSyncProjectMappings", + "GroupSyncWorkspaceMappings", +] + + +class GroupSync: + def __init__(self, transport: V2Transport, **scope: Any) -> None: + self.config = GroupSyncConfigResource(transport, **scope) + self.project_mappings = GroupSyncProjectMappings(transport, **scope) + self.workspace_mappings = GroupSyncWorkspaceMappings(transport, **scope) diff --git a/plane/api/v2/group_sync/config.py b/plane/api/v2/group_sync/config.py new file mode 100644 index 0000000..ad1ff56 --- /dev/null +++ b/plane/api/v2/group_sync/config.py @@ -0,0 +1,32 @@ +"""IdP group-sync configuration (api_v2). Workspace-wide singleton.""" + +from __future__ import annotations + +from ....models.v2.group_sync import GroupSyncConfig, UpdateGroupSyncConfig +from .._kernel.resource import V2Resource + + +class GroupSyncConfigResource( + V2Resource[GroupSyncConfig, UpdateGroupSyncConfig, UpdateGroupSyncConfig] +): + """One row per workspace, no create/delete/list -- only `get`/`update` against + a fixed path, so both hit the collection URL directly, not `_retrieve`/`_update`.""" + + path = "/workspaces/{slug}/group-sync/config/" + model = GroupSyncConfig + operations = { + "retrieve": "group_sync_config_retrieve", + "update": "group_sync_config_update", + } + + def get(self) -> GroupSyncConfig: + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) + + def update(self, data: UpdateGroupSyncConfig) -> GroupSyncConfig: + payload = self.transport.request( + "PATCH", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) diff --git a/plane/api/v2/group_sync/project_mappings.py b/plane/api/v2/group_sync/project_mappings.py new file mode 100644 index 0000000..fa3b078 --- /dev/null +++ b/plane/api/v2/group_sync/project_mappings.py @@ -0,0 +1,58 @@ +"""Group sync project mappings (api_v2): IdP group -> project (or every +project) + role.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.group_sync import CreateGroupMapping, GroupMapping, UpdateGroupMapping +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class GroupSyncProjectMappings(V2Resource[GroupMapping, CreateGroupMapping, UpdateGroupMapping]): + path = "/workspaces/{slug}/group-sync/project-mappings/" + model = GroupMapping + operations = { + "list": "group_sync_project_mappings_list", + "retrieve": "group_sync_project_mappings_retrieve", + "create": "group_sync_project_mappings_create", + "update": "group_sync_project_mappings_update", + "delete": "group_sync_project_mappings_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[GroupMapping]: + """One page of project mappings in the workspace.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[GroupMapping]: + """Every project mapping in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + mapping_id: str, + *, + fields: Sequence[str] | None = None, + ) -> GroupMapping: + return self._retrieve(pk=mapping_id, params={"fields": fields}) + + def create(self, data: CreateGroupMapping) -> GroupMapping: + return self._create(data) + + def update(self, mapping_id: str, data: UpdateGroupMapping) -> GroupMapping: + return self._update(data, pk=mapping_id) + + def delete(self, mapping_id: str) -> None: + return self._delete(pk=mapping_id) diff --git a/plane/api/v2/group_sync/workspace_mappings.py b/plane/api/v2/group_sync/workspace_mappings.py new file mode 100644 index 0000000..0b5d56d --- /dev/null +++ b/plane/api/v2/group_sync/workspace_mappings.py @@ -0,0 +1,65 @@ +"""Group sync workspace mappings (api_v2): IdP group -> workspace role.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.group_sync import ( + CreateWorkspaceGroupMapping, + UpdateWorkspaceGroupMapping, + WorkspaceGroupMapping, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class GroupSyncWorkspaceMappings( + V2Resource[WorkspaceGroupMapping, CreateWorkspaceGroupMapping, UpdateWorkspaceGroupMapping] +): + path = "/workspaces/{slug}/group-sync/workspace-mappings/" + model = WorkspaceGroupMapping + operations = { + "list": "group_sync_workspace_mappings_list", + "retrieve": "group_sync_workspace_mappings_retrieve", + "create": "group_sync_workspace_mappings_create", + "update": "group_sync_workspace_mappings_update", + "delete": "group_sync_workspace_mappings_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkspaceGroupMapping]: + """One page of workspace mappings in the workspace.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkspaceGroupMapping]: + """Every workspace mapping, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + mapping_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkspaceGroupMapping: + return self._retrieve(pk=mapping_id, params={"fields": fields}) + + def create(self, data: CreateWorkspaceGroupMapping) -> WorkspaceGroupMapping: + return self._create(data) + + def update( + self, mapping_id: str, data: UpdateWorkspaceGroupMapping + ) -> WorkspaceGroupMapping: + return self._update(data, pk=mapping_id) + + def delete(self, mapping_id: str) -> None: + return self._delete(pk=mapping_id) diff --git a/plane/api/v2/initiatives/__init__.py b/plane/api/v2/initiatives/__init__.py new file mode 100644 index 0000000..5a03945 --- /dev/null +++ b/plane/api/v2/initiatives/__init__.py @@ -0,0 +1,6 @@ +"""Initiatives (api_v2) -- see `initiatives.py` for the family docstring.""" + +from .initiatives import Initiatives +from .labels import InitiativeLabels + +__all__ = ["InitiativeLabels", "Initiatives"] diff --git a/plane/api/v2/initiatives/initiatives.py b/plane/api/v2/initiatives/initiatives.py new file mode 100644 index 0000000..0a07b7f --- /dev/null +++ b/plane/api/v2/initiatives/initiatives.py @@ -0,0 +1,116 @@ +"""Initiatives (api_v2) -- workspace-scoped, unlike states/labels/work items. +`manage_labels`/`manage_projects`/`manage_work_items` return changed ids, not an +`Initiative`, so they bypass `_action`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.initiatives import ( + CreateInitiative, + Initiative, + InitiativeChildManageRequest, + InitiativeChildManageResponse, + UpdateInitiative, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .labels import InitiativeLabels + +__all__ = ["InitiativeLabels", "Initiatives"] + + +class Initiatives(V2Resource[Initiative, CreateInitiative, UpdateInitiative]): + path = "/workspaces/{slug}/initiatives/" + model = Initiative + operations = { + "list": "initiatives_list", + "retrieve": "initiatives_retrieve", + "create": "initiatives_create", + "update": "initiatives_partial_update", + "delete": "initiatives_destroy", + "manage_labels": "initiatives_labels", + "manage_projects": "initiatives_projects", + "manage_work_items": "initiatives_work_items", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.labels = InitiativeLabels(transport, **self._scope) + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Initiative]: + """One page of initiatives in a workspace. + + `**filters` covers `lead_id`, `state`/`state__in`, `search`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Initiative]: + """Every initiative in a workspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + pk: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Initiative: + return self._retrieve(pk=pk, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Initiative: + """The one initiative with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateInitiative) -> Initiative: + return self._create(data) + + def update(self, pk: str, data: UpdateInitiative) -> Initiative: + return self._update(data, pk=pk) + + def delete(self, pk: str) -> None: + return self._delete(pk=pk) + + def _manage_child( + self, action: str, pk: str, data: InitiativeChildManageRequest + ) -> InitiativeChildManageResponse: + payload = self.transport.request( + "POST", + f"{self._detail_url(pk)}{action}/", + json=data.model_dump(mode="json", exclude_none=True), + ) + return InitiativeChildManageResponse.model_validate(payload) + + def manage_labels( + self, pk: str, data: InitiativeChildManageRequest + ) -> InitiativeChildManageResponse: + """Attach (`data.add`) or detach (`data.remove`) initiative labels on this + initiative, returning the ids actually changed.""" + return self._manage_child("labels", pk, data) + + def manage_projects( + self, pk: str, data: InitiativeChildManageRequest + ) -> InitiativeChildManageResponse: + """Add (`data.add`) or remove (`data.remove`) projects on this initiative, + returning the ids actually changed.""" + return self._manage_child("projects", pk, data) + + def manage_work_items( + self, pk: str, data: InitiativeChildManageRequest + ) -> InitiativeChildManageResponse: + """Link (`data.add`) or unlink (`data.remove`) work items on this + initiative, returning the ids actually changed.""" + return self._manage_child("work-items", pk, data) diff --git a/plane/api/v2/initiatives/labels.py b/plane/api/v2/initiatives/labels.py new file mode 100644 index 0000000..3bc09a9 --- /dev/null +++ b/plane/api/v2/initiatives/labels.py @@ -0,0 +1,60 @@ +"""Initiative labels (api_v2) -- a workspace-level taxonomy, *not* nested under +an initiative id despite living at `initiatives.labels`. To attach/detach a +label to one initiative, see `Initiatives.manage_labels`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.initiatives import CreateInitiativeLabel, InitiativeLabel, UpdateInitiativeLabel +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class InitiativeLabels(V2Resource[InitiativeLabel, CreateInitiativeLabel, UpdateInitiativeLabel]): + path = "/workspaces/{slug}/initiatives/labels/" + model = InitiativeLabel + operations = { + "list": "initiative_labels_list", + "retrieve": "initiative_labels_retrieve", + "create": "initiative_labels_create", + "update": "initiative_labels_partial_update", + "delete": "initiative_labels_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[InitiativeLabel]: + """One page of initiative labels in a workspace.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[InitiativeLabel]: + """Every initiative label in a workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, pk: str, *, fields: Sequence[str] | None = None + ) -> InitiativeLabel: + return self._retrieve(pk=pk, params={"fields": fields}) + + def find_by_name(self, name: str) -> InitiativeLabel: + """The one initiative label with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateInitiativeLabel) -> InitiativeLabel: + return self._create(data) + + def update(self, pk: str, data: UpdateInitiativeLabel) -> InitiativeLabel: + return self._update(data, pk=pk) + + def delete(self, pk: str) -> None: + return self._delete(pk=pk) diff --git a/plane/api/v2/intakes.py b/plane/api/v2/intakes.py new file mode 100644 index 0000000..86d9c42 --- /dev/null +++ b/plane/api/v2/intakes.py @@ -0,0 +1,52 @@ +"""Intake work items (api_v2) -- project-scoped triage queue. Model is +`IntakeWorkItem`, never `IntakeIssue`. PATCH folds v1's separate status endpoint: +set `status`/`snoozed_till`/`duplicate_to_id` in the same call as any other field.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.intakes import CreateIntakeWorkItem, IntakeWorkItem, UpdateIntakeWorkItem +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Intakes(V2Resource[IntakeWorkItem, CreateIntakeWorkItem, UpdateIntakeWorkItem]): + path = "/workspaces/{slug}/projects/{project_id}/intake-issues/" + model = IntakeWorkItem + operations = { + "list": "intakes_list", + "retrieve": "intakes_retrieve", + "create": "intakes_create", + "update": "intakes_partial_update", + "delete": "intakes_destroy", + } + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[IntakeWorkItem]: + """One page of intake work items in this project. `**filters` covers the + golden's query filters directly, e.g. `status=1`, `status__in=[-2, 0]`, + `work_item_id=...`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[IntakeWorkItem]: + """Every intake work item in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, intake_work_item_id: str, *, fields: Sequence[str] | None = None + ) -> IntakeWorkItem: + return self._retrieve(pk=intake_work_item_id, params={"fields": fields}) + + def create(self, data: CreateIntakeWorkItem) -> IntakeWorkItem: + return self._create(data) + + def update(self, intake_work_item_id: str, data: UpdateIntakeWorkItem) -> IntakeWorkItem: + return self._update(data, pk=intake_work_item_id) + + def delete(self, intake_work_item_id: str) -> None: + return self._delete(pk=intake_work_item_id) diff --git a/plane/api/v2/invitations.py b/plane/api/v2/invitations.py new file mode 100644 index 0000000..30026d4 --- /dev/null +++ b/plane/api/v2/invitations.py @@ -0,0 +1,79 @@ +"""Workspace invitations (api_v2). No PATCH; only `list`/`retrieve`/`create`/ +`delete` plus `bulk`. Golden documents `bulk`'s 201 as one `WorkspaceInvite`, but +the view returns the whole created list -- this SDK follows the live behavior.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.invitations import ( + BulkCreateWorkspaceInvites, + CreateWorkspaceInvite, + WorkspaceInvite, +) +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Invitations(V2Resource[WorkspaceInvite, CreateWorkspaceInvite, CreateWorkspaceInvite]): + path = "/workspaces/{slug}/invitations/" + model = WorkspaceInvite + operations = { + "list": "members_list", + "retrieve": "members_retrieve", + "create": "members_create", + "bulk": "members_bulk", + "delete": "members_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkspaceInvite]: + """One page of pending/accepted invitations. `**filters` covers the + golden's query filters directly, e.g. `accepted=False`, `email="a@b.com"`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkspaceInvite]: + """Every invitation, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + invitation_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkspaceInvite: + return self._retrieve(pk=invitation_id, params={"fields": fields}) + + def create(self, data: CreateWorkspaceInvite) -> WorkspaceInvite: + return self._create(data) + + def delete(self, invitation_id: str) -> None: + return self._delete(pk=invitation_id) + + def bulk( + self, + data: BulkCreateWorkspaceInvites, + *, + fields: Sequence[str] | None = None, + ) -> builtins.list[WorkspaceInvite]: + """Create up to 100 invitations in one call. Emails already invited are + silently skipped server-side (not re-sent, not errored).""" + payload = self.transport.request( + "POST", + f"{self._collection_url()}bulk/", + params=self._query({"fields": fields}, action="bulk"), + json=data.model_dump(mode="json", exclude_none=True), + ) + rows = payload if isinstance(payload, list) else [payload] + return [WorkspaceInvite.model_validate(row) for row in rows] diff --git a/plane/api/v2/labels.py b/plane/api/v2/labels.py new file mode 100644 index 0000000..9950fb9 --- /dev/null +++ b/plane/api/v2/labels.py @@ -0,0 +1,74 @@ +"""Project labels (api_v2).""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.labels import CreateLabel, Label, UpdateLabel +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Labels(V2Resource[Label, CreateLabel, UpdateLabel]): + path = "/workspaces/{slug}/projects/{project_id}/labels/" + model = Label + operations = { + "list": "labels_list", + "retrieve": "labels_retrieve", + "create": "labels_create", + "update": "labels_partial_update", + "upsert": "labels_upsert", + "delete": "labels_destroy", + "bulk_create": "labels_bulk_create", + "bulk_update": "labels_bulk_update", + "bulk_delete": "labels_bulk_delete", + } + + def list(self, *, fields: Sequence[str] | None = None, **filters: Any) -> Page[Label]: + """One page of labels in this project.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Label]: + """Every label, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, label_id: str, *, fields: Sequence[str] | None = None) -> Label: + return self._retrieve(pk=label_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Label: + """The one label with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateLabel) -> Label: + return self._create(data) + + def update(self, label_id: str, data: UpdateLabel) -> Label: + return self._update(data, pk=label_id) + + def delete(self, label_id: str) -> None: + return self._delete(pk=label_id) + + def upsert(self, data: CreateLabel) -> Label: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateLabel], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) diff --git a/plane/api/v2/members.py b/plane/api/v2/members.py new file mode 100644 index 0000000..16a3c77 --- /dev/null +++ b/plane/api/v2/members.py @@ -0,0 +1,111 @@ +"""Members (api_v2) -- project rosters (full CRUD) and the workspace roster +(list + remove-by-email). Workspace removal is `POST …/members/remove/` keyed +on `email` (v1 parity), not a detail DELETE keyed on a row id.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.members import ( + CreateProjectMember, + Member, + UpdateProjectMember, + WorkspaceMemberRemove, +) +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + +__all__ = ["ProjectMembers", "WorkspaceMembers"] + + +class ProjectMembers(V2Resource[Member, CreateProjectMember, UpdateProjectMember]): + path = "/workspaces/{slug}/projects/{project_id}/members/" + model = Member + operations = { + "list": "project_members_list", + "retrieve": "project_members_retrieve", + "create": "project_members_create", + "update": "project_members_partial_update", + "delete": "project_members_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Member]: + """One page of this project's roster.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Member]: + """Every row on this project's roster, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + member_row_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Member: + """Fetch by the membership row's own id (not the member's user id).""" + return self._retrieve(pk=member_row_id, params={"fields": fields, "expand": expand}) + + def create(self, data: CreateProjectMember) -> Member: + """Add a member to the project.""" + return self._create(data) + + def update(self, member_row_id: str, data: UpdateProjectMember) -> Member: + """Change a member's role.""" + return self._update(data, pk=member_row_id) + + def delete(self, member_row_id: str) -> None: + return self._delete(pk=member_row_id) + + +class WorkspaceMembers(V2Resource[Member, CreateProjectMember, UpdateProjectMember]): + """The workspace roster. List-only plus `remove` -- see the module docstring.""" + + path = "/workspaces/{slug}/members/" + model = Member + operations = { + "list": "workspace_members_list", + "remove": "workspace_members_remove", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Member]: + """One page of the workspace roster.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Member]: + """Every row on the workspace roster, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def remove(self, data: WorkspaceMemberRemove) -> None: + """Remove a member from the workspace by email (v1 parity) -- + soft-deactivates and cascades out of every project. Not addressed by a + row id; the response is 204 with no body.""" + url = f"{self._collection_url()}remove/" + self.transport.request("POST", url, json=data.model_dump(mode="json", exclude_none=True)) + return None diff --git a/plane/api/v2/milestones.py b/plane/api/v2/milestones.py new file mode 100644 index 0000000..a8d69f8 --- /dev/null +++ b/plane/api/v2/milestones.py @@ -0,0 +1,99 @@ +"""Project milestones (api_v2). Golden calls the identifying field `title`, but +the list filter is still `?name=` (aliased server-side); `find_by_name` keeps +the same `name` parameter every other resource uses.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.milestone_work_items import ( + MilestoneWorkItemManageRequest, + MilestoneWorkItemManageResponse, +) +from ...models.v2.milestones import CreateMilestone, Milestone, UpdateMilestone +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Milestones(V2Resource[Milestone, CreateMilestone, UpdateMilestone]): + path = "/workspaces/{slug}/projects/{project_id}/milestones/" + model = Milestone + operations = { + "list": "milestones_list", + "retrieve": "milestones_retrieve", + "create": "milestones_create", + "update": "milestones_partial_update", + "upsert": "milestones_upsert", + "delete": "milestones_destroy", + "bulk_create": "milestones_bulk_create", + "bulk_update": "milestones_bulk_update", + "bulk_delete": "milestones_bulk_delete", + "manage_work_items": "milestones_work_items", + } + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[Milestone]: + """One page of milestones in this project.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Milestone]: + """Every milestone, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, milestone_id: str, *, fields: Sequence[str] | None = None) -> Milestone: + return self._retrieve(pk=milestone_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Milestone: + """The one milestone whose title matches this name; raises if none or + several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateMilestone) -> Milestone: + return self._create(data) + + def update(self, milestone_id: str, data: UpdateMilestone) -> Milestone: + return self._update(data, pk=milestone_id) + + def delete(self, milestone_id: str) -> None: + return self._delete(pk=milestone_id) + + def upsert(self, data: CreateMilestone) -> Milestone: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateMilestone], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) + + # -- Custom actions ------------------------------------------------------ + + def manage_work_items( + self, milestone_id: str, data: MilestoneWorkItemManageRequest + ) -> MilestoneWorkItemManageResponse: + """Add and/or remove work items on this milestone, returning the ids + actually changed. Built directly since the response isn't this resource's `model`.""" + payload = self.transport.request( + "POST", + f"{self._detail_url(milestone_id)}work-items/", + params=self._query(None, action="manage_work_items"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return MilestoneWorkItemManageResponse.model_validate(payload) diff --git a/plane/api/v2/modules.py b/plane/api/v2/modules.py new file mode 100644 index 0000000..3c31042 --- /dev/null +++ b/plane/api/v2/modules.py @@ -0,0 +1,111 @@ +"""Project modules (api_v2). Module membership (`member_ids`) is read-only: +the golden's `ModuleWriteRequest` has no writable member field.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.module_work_items import ( + ModuleWorkItemManageRequest, + ModuleWorkItemManageResponse, +) +from ...models.v2.modules import CreateModule, Module, UpdateModule +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Modules(V2Resource[Module, CreateModule, UpdateModule]): + path = "/workspaces/{slug}/projects/{project_id}/modules/" + model = Module + operations = { + "list": "modules_list", + "retrieve": "modules_retrieve", + "create": "modules_create", + "update": "modules_partial_update", + "upsert": "modules_upsert", + "delete": "modules_destroy", + "bulk_create": "modules_bulk_create", + "bulk_update": "modules_bulk_update", + "bulk_delete": "modules_bulk_delete", + "manage_work_items": "modules_work_items_manage", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Module]: + """One page of modules in this project.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Module]: + """Every module, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + module_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Module: + return self._retrieve(pk=module_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Module: + """The one module with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateModule) -> Module: + return self._create(data) + + def update(self, module_id: str, data: UpdateModule) -> Module: + return self._update(data, pk=module_id) + + def delete(self, module_id: str) -> None: + return self._delete(pk=module_id) + + def upsert(self, data: CreateModule) -> Module: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateModule], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) + + # -- Custom actions ------------------------------------------------------ + + def manage_work_items( + self, module_id: str, data: ModuleWorkItemManageRequest + ) -> ModuleWorkItemManageResponse: + """Add and/or remove work items on this module, returning the ids + actually changed. Built directly since the response isn't this resource's `model`.""" + payload = self.transport.request( + "POST", + f"{self._detail_url(module_id)}work-items/", + params=self._query(None, action="manage_work_items"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return ModuleWorkItemManageResponse.model_validate(payload) diff --git a/plane/api/v2/pages.py b/plane/api/v2/pages.py new file mode 100644 index 0000000..2de5123 --- /dev/null +++ b/plane/api/v2/pages.py @@ -0,0 +1,150 @@ +"""Pages (api_v2) -- project-scoped (`ProjectPages`) and workspace-scoped wiki +(`WikiPages`) pages; same shape, different path, neither offers upsert/bulk. +A wiki page can belong to a `Collection` (`collection_id`).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.pages import CreatePage, UpdatePage +from ...models.v2.pages import Page as PageModel +from ._kernel.errors import MultipleMatchesFound, NoMatchFound +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + +__all__ = ["ProjectPages", "WikiPages"] + + +class ProjectPages(V2Resource[PageModel, CreatePage, UpdatePage]): + path = "/workspaces/{slug}/projects/{project_id}/pages/" + model = PageModel + operations = { + "list": "project_pages_list", + "retrieve": "project_pages_retrieve", + "create": "project_pages_create", + "update": "project_pages_partial_update", + "delete": "project_pages_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[PageModel]: + """One page of pages in this project.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[PageModel]: + """Every page in this project, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + page_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> PageModel: + return self._retrieve(pk=page_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> PageModel: + """The one page with this name; raises if none or several match. + + Filters client-side: no `?name=` in the golden; live it returned every page (confirmed).""" + matches = [row for row in self.iterate() if row.name == name] + if not matches: + raise NoMatchFound(f"No ProjectPages matched name={name!r}.") + if len(matches) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched name={name!r}; " + f"use the id instead, or list to see every match." + ) + return matches[0] + + def create(self, data: CreatePage) -> PageModel: + return self._create(data) + + def update(self, page_id: str, data: UpdatePage) -> PageModel: + return self._update(data, pk=page_id) + + def delete(self, page_id: str) -> None: + return self._delete(pk=page_id) + + +class WikiPages(V2Resource[PageModel, CreatePage, UpdatePage]): + """Workspace-scoped wiki pages (`is_global`). Same shape as `ProjectPages`, + a different path -- no project id anywhere. Reached as `ws.wiki.pages`.""" + + path = "/workspaces/{slug}/pages/" + model = PageModel + operations = { + "list": "workspace_pages_list", + "retrieve": "workspace_pages_retrieve", + "create": "workspace_pages_create", + "update": "workspace_pages_partial_update", + "delete": "workspace_pages_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[PageModel]: + """One page of workspace wiki pages.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[PageModel]: + """Every workspace wiki page, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + page_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> PageModel: + return self._retrieve(pk=page_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> PageModel: + """The one wiki page with this name; raises if none or several match. + + Filters client-side -- see `ProjectPages.find_by_name` (same confirmed gap).""" + matches = [row for row in self.iterate() if row.name == name] + if not matches: + raise NoMatchFound(f"No WikiPages matched name={name!r}.") + if len(matches) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched name={name!r}; " + f"use the id instead, or list to see every match." + ) + return matches[0] + + def create(self, data: CreatePage) -> PageModel: + """Create a workspace wiki page. Omitting `data.collection_id` auto-assigns + the default (public) collection (confirmed live) -- a private page needs an + explicit private `collection_id`.""" + return self._create(data) + + def update(self, page_id: str, data: UpdatePage) -> PageModel: + return self._update(data, pk=page_id) + + def delete(self, page_id: str) -> None: + return self._delete(pk=page_id) diff --git a/plane/api/v2/permission_schemes.py b/plane/api/v2/permission_schemes.py new file mode 100644 index 0000000..08a3cce --- /dev/null +++ b/plane/api/v2/permission_schemes.py @@ -0,0 +1,49 @@ +"""Workspace permission schemes (api_v2). Read-only -- system + custom schemes, +no create/update/delete on this endpoint.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.permission_schemes import PermissionScheme +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +# No write/patch DTO exists for permission schemes -- the type parameters are +# filled with the read model itself as an unused placeholder (see the work_items +# build's documented kernel gap). +class PermissionSchemes(V2Resource[PermissionScheme, PermissionScheme, PermissionScheme]): + path = "/workspaces/{slug}/permission-schemes/" + model = PermissionScheme + operations = { + "list": "permission_schemes_list", + "retrieve": "permission_schemes_retrieve", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[PermissionScheme]: + """One page of permission schemes (system + custom) in the workspace.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[PermissionScheme]: + """Every permission scheme, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + scheme_id: str, + *, + fields: Sequence[str] | None = None, + ) -> PermissionScheme: + return self._retrieve(pk=scheme_id, params={"fields": fields}) diff --git a/plane/api/v2/permissions.py b/plane/api/v2/permissions.py new file mode 100644 index 0000000..8a8b542 --- /dev/null +++ b/plane/api/v2/permissions.py @@ -0,0 +1,38 @@ +"""Effective permissions (api_v2) -- the calling principal's own grants for a +workspace or a project. Read-only, two GET endpoints. Split into two classes +(rather than one with a `project_me()` method) since `V2Resource` has one `path`.""" + +from __future__ import annotations + +from ...models.v2.permissions import EffectivePermissions +from ._kernel.resource import V2Resource + + +class WorkspacePermissions( + V2Resource[EffectivePermissions, EffectivePermissions, EffectivePermissions] +): + path = "/workspaces/{slug}/permissions/me/" + model = EffectivePermissions + operations = { + "me": "workspaces_permissions_me_retrieve", + } + + def me(self) -> EffectivePermissions: + """The caller's effective permissions across the whole workspace.""" + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) + + +class ProjectPermissions( + V2Resource[EffectivePermissions, EffectivePermissions, EffectivePermissions] +): + path = "/workspaces/{slug}/projects/{project_id}/permissions/me/" + model = EffectivePermissions + operations = { + "me": "workspaces_projects_permissions_me_retrieve", + } + + def me(self) -> EffectivePermissions: + """The caller's effective permissions scoped to this project.""" + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) diff --git a/plane/api/v2/project.py b/plane/api/v2/project.py new file mode 100644 index 0000000..fb1ad70 --- /dev/null +++ b/plane/api/v2/project.py @@ -0,0 +1,58 @@ +"""Project locator: every project-scoped api_v2 resource, bound to one workspace +slug and one project. Constructed by `Workspace.project(...)`; makes no request.""" + +from __future__ import annotations + +from ._kernel.transport import V2Transport +from .automations import ProjectAutomations +from .cycles import Cycles +from .estimates import Estimates +from .features import ProjectFeatures +from .intakes import Intakes +from .labels import Labels +from .members import ProjectMembers +from .milestones import Milestones +from .modules import Modules +from .pages import ProjectPages +from .permissions import ProjectPermissions +from .states import States +from .views import ProjectViews +from .work_item_properties import WorkItemProperties +from .work_item_templates import ProjectWorkItemTemplates +from .work_item_types import WorkItemTypes +from .work_items import WorkItems +from .workflows import Workflows +from .worklogs import ProjectWorklogs + + +class Project: + """A project bound inside a workspace. Makes no request to construct.""" + + def __init__(self, transport: V2Transport, slug: str, project_id: str) -> None: + self.transport = transport + self.slug = slug + self.project_id = project_id + + self.work_items = WorkItems(transport, slug=slug, project_id=project_id) + self.cycles = Cycles(transport, slug=slug, project_id=project_id) + self.modules = Modules(transport, slug=slug, project_id=project_id) + self.milestones = Milestones(transport, slug=slug, project_id=project_id) + self.states = States(transport, slug=slug, project_id=project_id) + self.labels = Labels(transport, slug=slug, project_id=project_id) + self.members = ProjectMembers(transport, slug=slug, project_id=project_id) + self.pages = ProjectPages(transport, slug=slug, project_id=project_id) + self.views = ProjectViews(transport, slug=slug, project_id=project_id) + self.features = ProjectFeatures(transport, slug=slug, project_id=project_id) + self.permissions = ProjectPermissions(transport, slug=slug, project_id=project_id) + self.intakes = Intakes(transport, slug=slug, project_id=project_id) + self.estimates = Estimates(transport, slug=slug, project_id=project_id) + self.work_item_types = WorkItemTypes(transport, slug=slug, project_id=project_id) + self.work_item_properties = WorkItemProperties( + transport, slug=slug, project_id=project_id + ) + self.work_item_templates = ProjectWorkItemTemplates( + transport, slug=slug, project_id=project_id + ) + self.workflows = Workflows(transport, slug=slug, project_id=project_id) + self.automations = ProjectAutomations(transport, slug=slug, project_id=project_id) + self.worklogs = ProjectWorklogs(transport, slug=slug, project_id=project_id) diff --git a/plane/api/v2/projects.py b/plane/api/v2/projects.py new file mode 100644 index 0000000..d673266 --- /dev/null +++ b/plane/api/v2/projects.py @@ -0,0 +1,144 @@ +"""Projects (api_v2). `project` accepts a UUID or its bare identifier (e.g. +`"ENG"`) everywhere -- no separate lookup needed. Adds `archive`/`unarchive`/ +`summary`/`role_distribution`; no `bulk_delete` (delete cascades everything in it).""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any +from urllib.parse import quote + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.project_role_distribution import ProjectRoleDistribution +from ...models.v2.projects import CreateProject, Project, ProjectSummary, UpdateProject +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Projects(V2Resource[Project, CreateProject, UpdateProject]): + path = "/workspaces/{slug}/projects/" + model = Project + operations = { + "list": "projects_list", + "retrieve": "projects_retrieve", + "create": "projects_create", + "update": "projects_partial_update", + "upsert": "projects_upsert", + "delete": "projects_destroy", + "bulk_create": "projects_bulk_create", + "bulk_update": "projects_bulk_update", + "archive": "projects_archive", + "unarchive": "projects_unarchive", + "summary": "projects_summary", + "role_distribution": "project_role_distribution", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Project]: + """One page of projects in the workspace. + + `**filters` covers `name`, `identifier`, `network`, `priority`, `is_archived`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Project]: + """Every project in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + project: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Project: + """Fetch a project. `project` accepts a UUID or its bare identifier + (e.g. `"ENG"`) -- api_v2's flagship readable-identifier resource: no + separate lookup is needed to go from a known key to a UUID.""" + return self._retrieve(pk=project, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Project: + """The one project with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateProject) -> Project: + return self._create(data) + + def update(self, project: str, data: UpdateProject) -> Project: + """`project` accepts a UUID or its bare identifier (e.g. `"ENG"`).""" + return self._update(data, pk=project) + + def delete(self, project: str) -> None: + """`project` accepts a UUID or its bare identifier (e.g. `"ENG"`). + Deleting a project cascades its work items, cycles, modules, pages and + members.""" + return self._delete(pk=project) + + def upsert(self, data: CreateProject) -> Project: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, + items: builtins.list[CreateProject], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, + items: builtins.list[Mapping[str, Any]], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`. No `bulk_delete` + exists for projects -- use `delete` per project.""" + return self._bulk_update(items, all_or_none=all_or_none) + + # -- Custom actions ----------------------------------------------------------- + # None go through `_action`: response shapes/bodies don't match `self.model`. + + def archive(self, project: str) -> None: + """Archive a project. 204, no response body.""" + self.transport.request("POST", f"{self._detail_url(project)}archive/") + return None + + def unarchive(self, project: str) -> None: + """Restore an archived project. 204, no response body.""" + self.transport.request("POST", f"{self._detail_url(project)}unarchive/") + return None + + def summary(self, project: str, *, counts: Sequence[str] | None = None) -> ProjectSummary: + """Project identity plus resource counts (v1 summary parity). + + `counts` narrows the response to specific count keys; omit for all of them.""" + params: dict[str, Any] = {} + if counts is not None: + params["counts"] = ",".join(counts) if not isinstance(counts, str) else counts + payload = self.transport.request( + "GET", f"{self._detail_url(project)}summary/", params=params or None + ) + return ProjectSummary.model_validate(payload) + + def role_distribution(self) -> ProjectRoleDistribution: + """Workspace-wide counts of members per project role. A single read-only + report, not a paginated collection -- one object per workspace, no `id`.""" + payload = self.transport.request("GET", self._role_distribution_url()) + return ProjectRoleDistribution.model_validate(payload) + + def _role_distribution_url(self) -> str: + # A sibling path of this resource's own collection URL, not a sub-path of it. + slug = self._scope["slug"] + return f"/workspaces/{quote(str(slug), safe='')}/project-role-distribution/" diff --git a/plane/api/v2/releases/__init__.py b/plane/api/v2/releases/__init__.py new file mode 100644 index 0000000..78e1c5c --- /dev/null +++ b/plane/api/v2/releases/__init__.py @@ -0,0 +1,139 @@ +"""Releases (api_v2) -- workspace-scoped, not project-scoped. Beyond CRUD: +`manage_labels`/`manage_work_items`, a `.changelog` singleton, catalog siblings +`.labels`/`.tags`, and nested `.comments`/`.links` (see `tags.py` for a golden/server mismatch).""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.releases import ( + CreateRelease, + Release, + ReleaseChildManageRequest, + ReleaseChildManageResult, + UpdateRelease, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .changelog import ReleaseChangelogResource +from .comments import ReleaseComments +from .labels import ReleaseLabels +from .links import ReleaseLinks +from .tags import ReleaseTags + +__all__ = [ + "ReleaseChangelogResource", + "ReleaseComments", + "ReleaseLabels", + "ReleaseLinks", + "ReleaseTags", + "Releases", +] + + +class Releases(V2Resource[Release, CreateRelease, UpdateRelease]): + path = "/workspaces/{slug}/releases/" + model = Release + operations = { + "list": "releases_list", + "retrieve": "releases_retrieve", + "create": "releases_create", + "update": "releases_partial_update", + "delete": "releases_destroy", + "manage_labels": "releases_labels", + "manage_work_items": "releases_work_items", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.comments = ReleaseComments(transport, **self._scope) + self.links = ReleaseLinks(transport, **self._scope) + self.labels = ReleaseLabels(transport, **self._scope) + self.tags = ReleaseTags(transport, **self._scope) + self.changelog = ReleaseChangelogResource(transport, **self._scope) + + # -- Workspace-scoped CRUD ---------------------------------------------------- + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Release]: + """One page of releases in the workspace. + + `**filters` covers `status`, `lead_id`, `tag_id`, `is_latest`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Release]: + """Every release in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + release_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Release: + return self._retrieve(pk=release_id, params={"fields": fields, "expand": expand}) + + def find_by_name(self, name: str) -> Release: + """The one release with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateRelease) -> Release: + return self._create(data) + + def update(self, release_id: str, data: UpdateRelease) -> Release: + return self._update(data, pk=release_id) + + def delete(self, release_id: str) -> None: + return self._delete(pk=release_id) + + # -- Child membership manage (labels / work items) ----------------------------- + # Response is `{added, removed}`, not a `Release`, so these bypass `_action`. + + def manage_labels( + self, + release_id: str, + *, + add: builtins.list[str] | None = None, + remove: builtins.list[str] | None = None, + ) -> ReleaseChildManageResult: + """Attach and/or detach release labels (from the workspace catalog) on + this release in one call. Returns the ids actually added/removed.""" + body = ReleaseChildManageRequest(add=add, remove=remove) + payload = self.transport.request( + "POST", + f"{self._detail_url(release_id)}labels/", + json=body.model_dump(mode="json", exclude_none=True), + ) + return ReleaseChildManageResult.model_validate(payload) + + def manage_work_items( + self, + release_id: str, + *, + add: builtins.list[str] | None = None, + remove: builtins.list[str] | None = None, + ) -> ReleaseChildManageResult: + """Attach and/or detach work items on this release in one call. Returns + the ids actually added/removed.""" + body = ReleaseChildManageRequest(add=add, remove=remove) + payload = self.transport.request( + "POST", + f"{self._detail_url(release_id)}work-items/", + json=body.model_dump(mode="json", exclude_none=True), + ) + return ReleaseChildManageResult.model_validate(payload) diff --git a/plane/api/v2/releases/changelog.py b/plane/api/v2/releases/changelog.py new file mode 100644 index 0000000..2d8a934 --- /dev/null +++ b/plane/api/v2/releases/changelog.py @@ -0,0 +1,36 @@ +"""Release changelog (api_v2) -- one singleton per release, nested under a release id. +Named `ReleaseChangelogResource` to avoid clashing with the `ReleaseChangelog` model.""" + +from __future__ import annotations + +from ....models.v2.releases import ReleaseChangelog, UpdateReleaseChangelog +from .._kernel.resource import V2Resource + +__all__ = ["ReleaseChangelogResource"] + + +class ReleaseChangelogResource( + V2Resource[ReleaseChangelog, ReleaseChangelog, UpdateReleaseChangelog] +): + """`releases.changelog` -- one per release, created implicitly (no create/delete).""" + + path = "/workspaces/{slug}/releases/{release_id}/changelog/" + model = ReleaseChangelog + operations = { + "retrieve": "releases_changelog_retrieve", + "update": "releases_changelog_partial_update", + } + + def retrieve(self, release_id: str) -> ReleaseChangelog: + """The release's changelog. One per release, created implicitly with + it -- there is no create/delete.""" + payload = self.transport.request("GET", self._collection_url(release_id=release_id)) + return self.model.model_validate(payload) + + def update(self, release_id: str, data: UpdateReleaseChangelog) -> ReleaseChangelog: + payload = self.transport.request( + "PATCH", + self._collection_url(release_id=release_id), + json=data.model_dump(mode="json", exclude_none=True), + ) + return self.model.model_validate(payload) diff --git a/plane/api/v2/releases/comments.py b/plane/api/v2/releases/comments.py new file mode 100644 index 0000000..a4f24a1 --- /dev/null +++ b/plane/api/v2/releases/comments.py @@ -0,0 +1,65 @@ +"""Release comments (api_v2) -- nested under a release.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.releases import CreateReleaseComment, ReleaseComment, UpdateReleaseComment +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ReleaseComments(V2Resource[ReleaseComment, CreateReleaseComment, UpdateReleaseComment]): + path = "/workspaces/{slug}/releases/{release_id}/comments/" + model = ReleaseComment + operations = { + "list": "release_comments_list", + "retrieve": "release_comments_retrieve", + "create": "release_comments_create", + "update": "release_comments_partial_update", + "delete": "release_comments_destroy", + } + + def list( + self, + release_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[ReleaseComment]: + """One page of comments on a release.""" + return self._list(release_id=release_id, params={"fields": fields, **filters}) + + def iterate( + self, + release_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[ReleaseComment]: + """Every comment on a release, following pages automatically.""" + return self._iter(release_id=release_id, params={"fields": fields, **filters}) + + def retrieve( + self, + release_id: str, + comment_id: str, + *, + fields: Sequence[str] | None = None, + ) -> ReleaseComment: + return self._retrieve(pk=comment_id, release_id=release_id, params={"fields": fields}) + + def create(self, release_id: str, data: CreateReleaseComment) -> ReleaseComment: + return self._create(data, release_id=release_id) + + def update( + self, + release_id: str, + comment_id: str, + data: UpdateReleaseComment, + ) -> ReleaseComment: + return self._update(data, pk=comment_id, release_id=release_id) + + def delete(self, release_id: str, comment_id: str) -> None: + return self._delete(pk=comment_id, release_id=release_id) diff --git a/plane/api/v2/releases/labels.py b/plane/api/v2/releases/labels.py new file mode 100644 index 0000000..56d0319 --- /dev/null +++ b/plane/api/v2/releases/labels.py @@ -0,0 +1,57 @@ +"""Release label catalog (api_v2). Workspace-level, distinct from the +per-release association (`Releases.manage_labels`).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.releases import CreateReleaseLabel, ReleaseLabel, UpdateReleaseLabel +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ReleaseLabels(V2Resource[ReleaseLabel, CreateReleaseLabel, UpdateReleaseLabel]): + path = "/workspaces/{slug}/releases/labels/" + model = ReleaseLabel + operations = { + "list": "release_labels_list", + "retrieve": "release_labels_retrieve", + "create": "release_labels_create", + "update": "release_labels_partial_update", + "delete": "release_labels_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[ReleaseLabel]: + """One page of the workspace's release-label catalog.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[ReleaseLabel]: + """Every release label in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, label_id: str, *, fields: Sequence[str] | None = None) -> ReleaseLabel: + return self._retrieve(pk=label_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> ReleaseLabel: + """The one release label with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateReleaseLabel) -> ReleaseLabel: + return self._create(data) + + def update(self, label_id: str, data: UpdateReleaseLabel) -> ReleaseLabel: + return self._update(data, pk=label_id) + + def delete(self, label_id: str) -> None: + return self._delete(pk=label_id) diff --git a/plane/api/v2/releases/links.py b/plane/api/v2/releases/links.py new file mode 100644 index 0000000..5eba7b4 --- /dev/null +++ b/plane/api/v2/releases/links.py @@ -0,0 +1,60 @@ +"""Release links (api_v2) -- nested under a release.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.releases import CreateReleaseLink, ReleaseLink, UpdateReleaseLink +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ReleaseLinks(V2Resource[ReleaseLink, CreateReleaseLink, UpdateReleaseLink]): + path = "/workspaces/{slug}/releases/{release_id}/links/" + model = ReleaseLink + operations = { + "list": "release_links_list", + "retrieve": "release_links_retrieve", + "create": "release_links_create", + "update": "release_links_partial_update", + "delete": "release_links_destroy", + } + + def list( + self, + release_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[ReleaseLink]: + """One page of links on a release.""" + return self._list(release_id=release_id, params={"fields": fields, **filters}) + + def iterate( + self, + release_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[ReleaseLink]: + """Every link on a release, following pages automatically.""" + return self._iter(release_id=release_id, params={"fields": fields, **filters}) + + def retrieve( + self, + release_id: str, + link_id: str, + *, + fields: Sequence[str] | None = None, + ) -> ReleaseLink: + return self._retrieve(pk=link_id, release_id=release_id, params={"fields": fields}) + + def create(self, release_id: str, data: CreateReleaseLink) -> ReleaseLink: + return self._create(data, release_id=release_id) + + def update(self, release_id: str, link_id: str, data: UpdateReleaseLink) -> ReleaseLink: + return self._update(data, pk=link_id, release_id=release_id) + + def delete(self, release_id: str, link_id: str) -> None: + return self._delete(pk=link_id, release_id=release_id) diff --git a/plane/api/v2/releases/tags.py b/plane/api/v2/releases/tags.py new file mode 100644 index 0000000..05381f3 --- /dev/null +++ b/plane/api/v2/releases/tags.py @@ -0,0 +1,65 @@ +"""Release tag catalog (api_v2). Workspace-level catalog of release tags. +**Golden/server mismatch (confirmed live):** golden implies `tag_id` accepts +`version:`; it 404s -- resolve via `find_by_version` first.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.releases import CreateReleaseTag, ReleaseTag, UpdateReleaseTag +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ReleaseTags(V2Resource[ReleaseTag, CreateReleaseTag, UpdateReleaseTag]): + path = "/workspaces/{slug}/releases/tags/" + model = ReleaseTag + operations = { + "list": "release_tags_list", + "retrieve": "release_tags_retrieve", + "create": "release_tags_create", + "update": "release_tags_partial_update", + "delete": "release_tags_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[ReleaseTag]: + """One page of the workspace's release-tag catalog.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[ReleaseTag]: + """Every release tag in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, tag_id: str, *, fields: Sequence[str] | None = None) -> ReleaseTag: + """`tag_id` is the tag's UUID -- see the module docstring for why a + `version:` form is not accepted despite the golden's summary + implying it is.""" + return self._retrieve(pk=tag_id, params={"fields": fields}) + + def find_by_version(self, version: str) -> ReleaseTag: + """The one release tag with this version; raises if none or several + match. The way to resolve a version to an id -- see the module + docstring on why `retrieve(f"version:{v}")` is not.""" + return self._find_one(filters={"version": version}) + + def create(self, data: CreateReleaseTag) -> ReleaseTag: + return self._create(data) + + def update(self, tag_id: str, data: UpdateReleaseTag) -> ReleaseTag: + """`tag_id` is the tag's UUID -- see the module docstring.""" + return self._update(data, pk=tag_id) + + def delete(self, tag_id: str) -> None: + """`tag_id` is the tag's UUID -- see the module docstring.""" + return self._delete(pk=tag_id) diff --git a/plane/api/v2/roles.py b/plane/api/v2/roles.py new file mode 100644 index 0000000..1d821c0 --- /dev/null +++ b/plane/api/v2/roles.py @@ -0,0 +1,58 @@ +"""Roles (api_v2) -- system + custom roles for the workspace. Read-only: list + +retrieve only, no write surface (full role CRUD lives only in the app UI).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.roles import Role +from ._kernel.errors import MultipleMatchesFound, NoMatchFound +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Roles(V2Resource[Role, Role, Role]): + path = "/workspaces/{slug}/roles/" + model = Role + operations = { + "list": "roles_list", + "retrieve": "roles_retrieve", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Role]: + """One page of roles. `**filters` covers the golden's query filters + directly, e.g. `namespace="workspace"`, `is_system=True`, `search="admin"`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Role]: + """Every role, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, role_id: str, *, fields: Sequence[str] | None = None) -> Role: + return self._retrieve(pk=role_id, params={"fields": fields}) + + def find_by_name(self, name: str, *, namespace: str | None = None) -> Role: + """The one role with this name; raises if none or several match. Filters + client-side (no `?name=`); names are unique only *within* a namespace, so + pass `namespace` or expect `MultipleMatchesFound`.""" + filters: dict[str, Any] = {"namespace": namespace} if namespace else {} + matches = [row for row in self.iterate(**filters) if row.name == name] + if not matches: + raise NoMatchFound(f"No Roles matched name={name!r}.") + if len(matches) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched name={name!r}; " + f"use the id instead, or list to see every match." + ) + return matches[0] diff --git a/plane/api/v2/states.py b/plane/api/v2/states.py new file mode 100644 index 0000000..3dc9bc5 --- /dev/null +++ b/plane/api/v2/states.py @@ -0,0 +1,74 @@ +"""Project states (api_v2).""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ...models.v2.common import BulkWriteResponse +from ...models.v2.states import CreateState, State, UpdateState +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class States(V2Resource[State, CreateState, UpdateState]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = State + operations = { + "list": "states_list", + "retrieve": "states_retrieve", + "create": "states_create", + "update": "states_partial_update", + "upsert": "states_upsert", + "delete": "states_destroy", + "bulk_create": "states_bulk_create", + "bulk_update": "states_bulk_update", + "bulk_delete": "states_bulk_delete", + } + + def list(self, *, fields: Sequence[str] | None = None, **filters: Any) -> Page[State]: + """One page of states in this project.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[State]: + """Every state, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, state_id: str, *, fields: Sequence[str] | None = None) -> State: + return self._retrieve(pk=state_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> State: + """The one state with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateState) -> State: + return self._create(data) + + def update(self, state_id: str, data: UpdateState) -> State: + return self._update(data, pk=state_id) + + def delete(self, state_id: str) -> None: + return self._delete(pk=state_id) + + def upsert(self, data: CreateState) -> State: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateState], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) diff --git a/plane/api/v2/stickies.py b/plane/api/v2/stickies.py new file mode 100644 index 0000000..dc3dd51 --- /dev/null +++ b/plane/api/v2/stickies.py @@ -0,0 +1,59 @@ +"""Sticky notes (api_v2) -- a workspace member's personal sticky notes. Flat +CRUD; rows are implicitly owner-scoped server-side (a member only sees their own).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.stickies import CreateSticky, Sticky, UpdateSticky +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Stickies(V2Resource[Sticky, CreateSticky, UpdateSticky]): + path = "/workspaces/{slug}/stickies/" + model = Sticky + operations = { + "list": "stickies_list", + "retrieve": "stickies_retrieve", + "create": "stickies_create", + "update": "stickies_partial_update", + "delete": "stickies_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Sticky]: + """One page of stickies. `**filters` covers the golden's query filters + directly, e.g. `color="#fff"`, `owner_id=...`, `search="todo"`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Sticky]: + """Every sticky, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + sticky_id: str, + *, + fields: Sequence[str] | None = None, + ) -> Sticky: + return self._retrieve(pk=sticky_id, params={"fields": fields}) + + def create(self, data: CreateSticky) -> Sticky: + return self._create(data) + + def update(self, sticky_id: str, data: UpdateSticky) -> Sticky: + return self._update(data, pk=sticky_id) + + def delete(self, sticky_id: str) -> None: + return self._delete(pk=sticky_id) diff --git a/plane/api/v2/teamspaces.py b/plane/api/v2/teamspaces.py new file mode 100644 index 0000000..9dd497d --- /dev/null +++ b/plane/api/v2/teamspaces.py @@ -0,0 +1,69 @@ +"""Teamspaces (api_v2) -- a named grouping of members + projects. Flat CRUD +(no bulk, no upsert). `expand` accepts `lead`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.teamspaces import CreateTeamspace, Teamspace, UpdateTeamspace +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class Teamspaces(V2Resource[Teamspace, CreateTeamspace, UpdateTeamspace]): + path = "/workspaces/{slug}/teamspaces/" + model = Teamspace + operations = { + "list": "teamspaces_list", + "retrieve": "teamspaces_retrieve", + "create": "teamspaces_create", + "update": "teamspaces_partial_update", + "delete": "teamspaces_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[Teamspace]: + """One page of teamspaces. `**filters` covers the golden's query filters + directly, e.g. `lead_id=...`, `name="Platform"`, `search="plat"`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[Teamspace]: + """Every teamspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + teamspace_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> Teamspace: + return self._retrieve( + pk=teamspace_id, + params={"fields": fields, "expand": expand}, + ) + + def find_by_name(self, name: str) -> Teamspace: + """The one teamspace with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateTeamspace) -> Teamspace: + return self._create(data) + + def update(self, teamspace_id: str, data: UpdateTeamspace) -> Teamspace: + return self._update(data, pk=teamspace_id) + + def delete(self, teamspace_id: str) -> None: + return self._delete(pk=teamspace_id) diff --git a/plane/api/v2/users.py b/plane/api/v2/users.py new file mode 100644 index 0000000..1b9ae01 --- /dev/null +++ b/plane/api/v2/users.py @@ -0,0 +1,23 @@ +"""The calling principal (api_v2). Workspace-less singleton -- `path` has no +`{slug}` segment at all, unlike every other v2 resource.""" + +from __future__ import annotations + +from ...models.v2.users import WhoAmI +from ._kernel.resource import V2Resource + + +class Users(V2Resource[WhoAmI, WhoAmI, WhoAmI]): + path = "/users/me/" + model = WhoAmI + # No `fields=` support on this operation -- it is not in the golden's FIELDS + # map at all. + operations = { + "me": "users_me_retrieve", + } + + def me(self) -> WhoAmI: + """The authenticated principal behind the current request. OAuth tokens + need a read scope (API keys bypass scope checks).""" + payload = self.transport.request("GET", self._collection_url()) + return self.model.model_validate(payload) diff --git a/plane/api/v2/views/__init__.py b/plane/api/v2/views/__init__.py new file mode 100644 index 0000000..e830a74 --- /dev/null +++ b/plane/api/v2/views/__init__.py @@ -0,0 +1,9 @@ +"""Saved views (api_v2). `ProjectViews`/`WorkspaceViews` are project- and +workspace-scoped filters/layouts -- different path templates, same read/write shape.""" + +from __future__ import annotations + +from .project import ProjectViews +from .workspace import WorkspaceViews + +__all__ = ["ProjectViews", "WorkspaceViews"] diff --git a/plane/api/v2/views/project.py b/plane/api/v2/views/project.py new file mode 100644 index 0000000..73fdacb --- /dev/null +++ b/plane/api/v2/views/project.py @@ -0,0 +1,61 @@ +"""Project-scoped saved views (api_v2).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.views import CreateView, UpdateView, View +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ProjectViews(V2Resource[View, CreateView, UpdateView]): + path = "/workspaces/{slug}/projects/{project_id}/views/" + model = View + operations = { + "list": "project_views_list", + "retrieve": "project_views_retrieve", + "create": "project_views_create", + "update": "project_views_partial_update", + "delete": "project_views_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[View]: + """One page of views in this project. `**filters` covers `access`, + `is_locked`, `name`, `owned_by_id`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[View]: + """Every view in this project, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + view_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> View: + return self._retrieve(pk=view_id, params={"fields": fields, "expand": expand}) + + def create(self, data: CreateView) -> View: + return self._create(data) + + def update(self, view_id: str, data: UpdateView) -> View: + return self._update(data, pk=view_id) + + def delete(self, view_id: str) -> None: + return self._delete(pk=view_id) diff --git a/plane/api/v2/views/workspace.py b/plane/api/v2/views/workspace.py new file mode 100644 index 0000000..20f4388 --- /dev/null +++ b/plane/api/v2/views/workspace.py @@ -0,0 +1,62 @@ +"""Workspace-scoped saved views (api_v2). Project is NULL for these rows -- +different path template than `ProjectViews`, same read/write shape.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.views import CreateView, UpdateView, View +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkspaceViews(V2Resource[View, CreateView, UpdateView]): + path = "/workspaces/{slug}/views/" + model = View + operations = { + "list": "workspace_views_list", + "retrieve": "workspace_views_retrieve", + "create": "workspace_views_create", + "update": "workspace_views_partial_update", + "delete": "workspace_views_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[View]: + """One page of workspace-level views. `**filters` covers `access`, + `is_locked`, `name`, `owned_by_id`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[View]: + """Every workspace-level view, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + view_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> View: + return self._retrieve(pk=view_id, params={"fields": fields, "expand": expand}) + + def create(self, data: CreateView) -> View: + return self._create(data) + + def update(self, view_id: str, data: UpdateView) -> View: + return self._update(data, pk=view_id) + + def delete(self, view_id: str) -> None: + return self._delete(pk=view_id) diff --git a/plane/api/v2/webhook_logs.py b/plane/api/v2/webhook_logs.py new file mode 100644 index 0000000..7f1efa6 --- /dev/null +++ b/plane/api/v2/webhook_logs.py @@ -0,0 +1,51 @@ +"""Webhook delivery logs (api_v2). Read-only, nested under a webhook -- the +parent webhook must live in the same workspace (404 otherwise).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.webhook_logs import WebhookLog +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +# No write/patch DTO exists for webhook logs -- the type parameters are filled +# with the read model itself as an unused placeholder (see PermissionSchemes). +class WebhookLogs(V2Resource[WebhookLog, WebhookLog, WebhookLog]): + path = "/workspaces/{slug}/webhook-logs/{webhook_id}/" + model = WebhookLog + operations = { + "list": "webhook_logs_list", + "retrieve": "webhook_logs_retrieve", + } + + def list( + self, + webhook_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WebhookLog]: + """One page of delivery logs for a webhook.""" + return self._list(webhook_id=webhook_id, params={"fields": fields, **filters}) + + def iterate( + self, + webhook_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WebhookLog]: + """Every delivery log for a webhook, following pages automatically.""" + return self._iter(webhook_id=webhook_id, params={"fields": fields, **filters}) + + def retrieve( + self, + webhook_id: str, + log_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WebhookLog: + return self._retrieve(pk=log_id, webhook_id=webhook_id, params={"fields": fields}) diff --git a/plane/api/v2/webhooks.py b/plane/api/v2/webhooks.py new file mode 100644 index 0000000..a6dd455 --- /dev/null +++ b/plane/api/v2/webhooks.py @@ -0,0 +1,77 @@ +"""Webhooks (api_v2) -- workspace outbound webhook subscriptions. `create`/ +`regenerate` return `WebhookCreateResult` (carries `secret_key` once; see that +model's docstring for the golden mismatch). Delivery history is `webhooks.logs`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.webhooks import CreateWebhook, UpdateWebhook, Webhook, WebhookCreateResult +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource +from ._kernel.transport import V2Transport +from .webhook_logs import WebhookLogs + +__all__ = ["Webhooks"] + + +class Webhooks(V2Resource[Webhook, CreateWebhook, UpdateWebhook]): + path = "/workspaces/{slug}/webhooks/" + model = Webhook + operations = { + "list": "webhooks_list", + "retrieve": "webhooks_retrieve", + "create": "webhooks_create", + "update": "webhooks_partial_update", + "regenerate": "webhooks_regenerate", + "delete": "webhooks_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.logs = WebhookLogs(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[Webhook]: + """One page of the workspace's webhooks.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Webhook]: + """Every webhook in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, webhook_id: str, *, fields: Sequence[str] | None = None + ) -> Webhook: + return self._retrieve(pk=webhook_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> Webhook: + """The one webhook with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateWebhook) -> WebhookCreateResult: + """Create a webhook. The response carries `secret_key` once -- store + it; it cannot be retrieved again (only regenerated, which mints a new + one). See the module docstring for why no `fields` param is exposed.""" + payload = self.transport.request( + "POST", + self._collection_url(), + json=data.model_dump(mode="json", exclude_none=True), + ) + return WebhookCreateResult.model_validate(payload) + + def update(self, webhook_id: str, data: UpdateWebhook) -> Webhook: + return self._update(data, pk=webhook_id) + + def delete(self, webhook_id: str) -> None: + return self._delete(pk=webhook_id) + + def regenerate(self, webhook_id: str) -> WebhookCreateResult: + """Mint a new secret for this webhook, returning it once (see + `create`).""" + payload = self.transport.request("POST", f"{self._detail_url(webhook_id)}regenerate/") + return WebhookCreateResult.model_validate(payload) diff --git a/plane/api/v2/wiki.py b/plane/api/v2/wiki.py new file mode 100644 index 0000000..0ddabf7 --- /dev/null +++ b/plane/api/v2/wiki.py @@ -0,0 +1,18 @@ +"""`Wiki` -- the workspace's wiki container: pages and collections. +Constructing it makes no request.""" + +from __future__ import annotations + +from ._kernel.transport import V2Transport +from .collections import Collections +from .pages import WikiPages + + +class Wiki: + """A workspace's wiki, bound to `slug`. Makes no request to construct.""" + + def __init__(self, transport: V2Transport, slug: str) -> None: + self.transport = transport + self.slug = slug + self.pages = WikiPages(transport, slug=slug) + self.collections = Collections(transport, slug=slug) diff --git a/plane/api/v2/work_item_properties/__init__.py b/plane/api/v2/work_item_properties/__init__.py new file mode 100644 index 0000000..eef9f42 --- /dev/null +++ b/plane/api/v2/work_item_properties/__init__.py @@ -0,0 +1,122 @@ +"""Work item properties (api_v2): custom field definitions on work items. +`WorkItemProperties` (project-scoped) and `WorkspaceWorkItemProperties` are +independent resources, each with its own `.options`; the workspace one also has `.contexts`.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_properties import ( + CreateWorkItemProperty, + UpdateWorkItemProperty, + WorkItemProperty, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .contexts import WorkItemPropertyContexts +from .options import WorkItemPropertyOptions +from .workspace_options import WorkspaceWorkItemPropertyOptions + +__all__ = [ + "WorkItemPropertyContexts", + "WorkItemPropertyOptions", + "WorkItemProperties", + "WorkspaceWorkItemPropertyOptions", + "WorkspaceWorkItemProperties", +] + + +class WorkItemProperties( + V2Resource[WorkItemProperty, CreateWorkItemProperty, UpdateWorkItemProperty] +): + """Project-scoped property definitions.""" + + path = "/workspaces/{slug}/projects/{project_id}/work-item-properties/" + model = WorkItemProperty + operations = { + "list": "work_item_properties_list", + "retrieve": "work_item_properties_retrieve", + "create": "work_item_properties_create", + "update": "work_item_properties_partial_update", + "delete": "work_item_properties_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.options = WorkItemPropertyOptions(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkItemProperty]: + """One page of this project's property definitions.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkItemProperty]: + """Every property definition in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, property_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemProperty: + return self._retrieve(pk=property_id, params={"fields": fields}) + + def create(self, data: CreateWorkItemProperty) -> WorkItemProperty: + return self._create(data) + + def update(self, property_id: str, data: UpdateWorkItemProperty) -> WorkItemProperty: + return self._update(data, pk=property_id) + + def delete(self, property_id: str) -> None: + return self._delete(pk=property_id) + + +class WorkspaceWorkItemProperties( + V2Resource[WorkItemProperty, CreateWorkItemProperty, UpdateWorkItemProperty] +): + """Workspace-scoped property definitions -- not project-scoped, distinct from + `WorkItemProperties` in the golden (own operationIds, own path).""" + + path = "/workspaces/{slug}/work-item-properties/" + model = WorkItemProperty + operations = { + "list": "workspace_work_item_properties_list", + "retrieve": "workspace_work_item_properties_retrieve", + "create": "workspace_work_item_properties_create", + "update": "workspace_work_item_properties_partial_update", + "delete": "workspace_work_item_properties_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.contexts = WorkItemPropertyContexts(transport, **self._scope) + self.options = WorkspaceWorkItemPropertyOptions(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkItemProperty]: + """One page of this workspace's property definitions.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkItemProperty]: + """Every property definition in this workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, property_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemProperty: + return self._retrieve(pk=property_id, params={"fields": fields}) + + def create(self, data: CreateWorkItemProperty) -> WorkItemProperty: + return self._create(data) + + def update(self, property_id: str, data: UpdateWorkItemProperty) -> WorkItemProperty: + return self._update(data, pk=property_id) + + def delete(self, property_id: str) -> None: + return self._delete(pk=property_id) diff --git a/plane/api/v2/work_item_properties/contexts.py b/plane/api/v2/work_item_properties/contexts.py new file mode 100644 index 0000000..a5001b2 --- /dev/null +++ b/plane/api/v2/work_item_properties/contexts.py @@ -0,0 +1,77 @@ +"""Contexts on a workspace-scoped work item property (api_v2). A context is a +per-scope override: which projects/work item types it applies to, and its +default value/requiredness there.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_properties import ( + CreateWorkItemPropertyContext, + UpdateWorkItemPropertyContext, + WorkItemPropertyContext, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemPropertyContexts( + V2Resource[ + WorkItemPropertyContext, CreateWorkItemPropertyContext, UpdateWorkItemPropertyContext + ] +): + path = "/workspaces/{slug}/work-item-properties/{property_id}/contexts/" + model = WorkItemPropertyContext + operations = { + "list": "work_item_property_contexts_list", + "retrieve": "work_item_property_contexts_retrieve", + "create": "work_item_property_contexts_create", + "update": "work_item_property_contexts_partial_update", + "delete": "work_item_property_contexts_destroy", + } + + def list( + self, + property_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemPropertyContext]: + """One page of a workspace property's contexts.""" + return self._list(property_id=property_id, params={"fields": fields, **filters}) + + def iterate( + self, + property_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemPropertyContext]: + """Every context on a workspace property, following pages automatically.""" + return self._iter(property_id=property_id, params={"fields": fields, **filters}) + + def retrieve( + self, + property_id: str, + context_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemPropertyContext: + return self._retrieve(pk=context_id, property_id=property_id, params={"fields": fields}) + + def create( + self, property_id: str, data: CreateWorkItemPropertyContext + ) -> WorkItemPropertyContext: + return self._create(data, property_id=property_id) + + def update( + self, + property_id: str, + context_id: str, + data: UpdateWorkItemPropertyContext, + ) -> WorkItemPropertyContext: + return self._update(data, pk=context_id, property_id=property_id) + + def delete(self, property_id: str, context_id: str) -> None: + return self._delete(pk=context_id, property_id=property_id) diff --git a/plane/api/v2/work_item_properties/options.py b/plane/api/v2/work_item_properties/options.py new file mode 100644 index 0000000..e16e586 --- /dev/null +++ b/plane/api/v2/work_item_properties/options.py @@ -0,0 +1,56 @@ +"""Options on a project-scoped work item property (api_v2). Only meaningful for +OPTION-type properties. No `?fields=` here -- options always return in full.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ....models.v2.work_item_properties import ( + CreateWorkItemPropertyOption, + UpdateWorkItemPropertyOption, + WorkItemPropertyOption, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemPropertyOptions( + V2Resource[WorkItemPropertyOption, CreateWorkItemPropertyOption, UpdateWorkItemPropertyOption] +): + path = "/workspaces/{slug}/projects/{project_id}/work-item-properties/{property_id}/options/" + model = WorkItemPropertyOption + operations = { + "list": "work_item_property_options_list", + "retrieve": "work_item_property_options_retrieve", + "create": "work_item_property_options_create", + "update": "work_item_property_options_partial_update", + "delete": "work_item_property_options_destroy", + } + + def list(self, property_id: str, **filters: Any) -> Page[WorkItemPropertyOption]: + """One page of a property's options.""" + return self._list(property_id=property_id, params=filters) + + def iterate(self, property_id: str, **filters: Any) -> Iterator[WorkItemPropertyOption]: + """Every option on a property, following pages automatically.""" + return self._iter(property_id=property_id, params=filters) + + def retrieve(self, property_id: str, option_id: str) -> WorkItemPropertyOption: + return self._retrieve(pk=option_id, property_id=property_id) + + def create( + self, property_id: str, data: CreateWorkItemPropertyOption + ) -> WorkItemPropertyOption: + return self._create(data, property_id=property_id) + + def update( + self, + property_id: str, + option_id: str, + data: UpdateWorkItemPropertyOption, + ) -> WorkItemPropertyOption: + return self._update(data, pk=option_id, property_id=property_id) + + def delete(self, property_id: str, option_id: str) -> None: + return self._delete(pk=option_id, property_id=property_id) diff --git a/plane/api/v2/work_item_properties/workspace_options.py b/plane/api/v2/work_item_properties/workspace_options.py new file mode 100644 index 0000000..2f9be6b --- /dev/null +++ b/plane/api/v2/work_item_properties/workspace_options.py @@ -0,0 +1,56 @@ +"""Options on a workspace-scoped work item property (api_v2). Only meaningful for +OPTION-type properties; like the project-scoped sibling, no `?fields=` here.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from ....models.v2.work_item_properties import ( + CreateWorkItemPropertyOption, + UpdateWorkItemPropertyOption, + WorkItemPropertyOption, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkspaceWorkItemPropertyOptions( + V2Resource[WorkItemPropertyOption, CreateWorkItemPropertyOption, UpdateWorkItemPropertyOption] +): + path = "/workspaces/{slug}/work-item-properties/{property_id}/options/" + model = WorkItemPropertyOption + operations = { + "list": "workspace_work_item_property_options_list", + "retrieve": "workspace_work_item_property_options_retrieve", + "create": "workspace_work_item_property_options_create", + "update": "workspace_work_item_property_options_partial_update", + "delete": "workspace_work_item_property_options_destroy", + } + + def list(self, property_id: str, **filters: Any) -> Page[WorkItemPropertyOption]: + """One page of a workspace property's options.""" + return self._list(property_id=property_id, params=filters) + + def iterate(self, property_id: str, **filters: Any) -> Iterator[WorkItemPropertyOption]: + """Every option on a workspace property, following pages automatically.""" + return self._iter(property_id=property_id, params=filters) + + def retrieve(self, property_id: str, option_id: str) -> WorkItemPropertyOption: + return self._retrieve(pk=option_id, property_id=property_id) + + def create( + self, property_id: str, data: CreateWorkItemPropertyOption + ) -> WorkItemPropertyOption: + return self._create(data, property_id=property_id) + + def update( + self, + property_id: str, + option_id: str, + data: UpdateWorkItemPropertyOption, + ) -> WorkItemPropertyOption: + return self._update(data, pk=option_id, property_id=property_id) + + def delete(self, property_id: str, option_id: str) -> None: + return self._delete(pk=option_id, property_id=property_id) diff --git a/plane/api/v2/work_item_relation_definitions.py b/plane/api/v2/work_item_relation_definitions.py new file mode 100644 index 0000000..8524acc --- /dev/null +++ b/plane/api/v2/work_item_relation_definitions.py @@ -0,0 +1,88 @@ +"""Work item relation definitions (api_v2) -- the workspace-defined direction pairs +(e.g. `blocks`/`blocked_by`) that `work_items.relations` addresses by name. +`is_default` is system-managed; writes to a seeded default 400/403 server-side.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ...models.v2.work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, + WorkItemRelationDefinition, +) +from ._kernel.errors import MultipleMatchesFound, NoMatchFound +from ._kernel.pagination import Page +from ._kernel.resource import V2Resource + + +class WorkItemRelationDefinitions( + V2Resource[ + WorkItemRelationDefinition, + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, + ] +): + path = "/workspaces/{slug}/work-item-relation-definitions/" + model = WorkItemRelationDefinition + operations = { + "list": "work_item_relation_definitions_list", + "retrieve": "work_item_relation_definitions_retrieve", + "create": "work_item_relation_definitions_create", + "update": "work_item_relation_definitions_partial_update", + "delete": "work_item_relation_definitions_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemRelationDefinition]: + """One page of relation definitions.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemRelationDefinition]: + """Every relation definition, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + definition_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemRelationDefinition: + return self._retrieve(pk=definition_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> WorkItemRelationDefinition: + """The one relation definition with this name; raises if none or several + match. Filters client-side: no `?name=` filter exists; row count is + always small, so a full scan is cheap.""" + matches = [row for row in self.iterate() if row.name == name] + if not matches: + raise NoMatchFound(f"No WorkItemRelationDefinitions matched name={name!r}.") + if len(matches) > 1: + raise MultipleMatchesFound( + f"Multiple rows matched name={name!r}; " + f"use the id instead, or list to see every match." + ) + return matches[0] + + def create(self, data: CreateWorkItemRelationDefinition) -> WorkItemRelationDefinition: + return self._create(data) + + def update( + self, + definition_id: str, + data: UpdateWorkItemRelationDefinition, + ) -> WorkItemRelationDefinition: + return self._update(data, pk=definition_id) + + def delete(self, definition_id: str) -> None: + return self._delete(pk=definition_id) diff --git a/plane/api/v2/work_item_templates/__init__.py b/plane/api/v2/work_item_templates/__init__.py new file mode 100644 index 0000000..5fe1f4c --- /dev/null +++ b/plane/api/v2/work_item_templates/__init__.py @@ -0,0 +1,7 @@ +"""Work item templates (api_v2). `ProjectWorkItemTemplates` adds `use` (instantiate +a work item from the template); `WorkspaceWorkItemTemplates` has no `use`.""" + +from .project import ProjectWorkItemTemplates +from .workspace import WorkspaceWorkItemTemplates + +__all__ = ["ProjectWorkItemTemplates", "WorkspaceWorkItemTemplates"] diff --git a/plane/api/v2/work_item_templates/project.py b/plane/api/v2/work_item_templates/project.py new file mode 100644 index 0000000..23aa9e5 --- /dev/null +++ b/plane/api/v2/work_item_templates/project.py @@ -0,0 +1,82 @@ +"""Project-scoped work item templates (api_v2).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplate, + WorkItemTemplateUse, +) +from ....models.v2.work_items import WorkItem +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class ProjectWorkItemTemplates( + V2Resource[WorkItemTemplate, CreateWorkItemTemplate, UpdateWorkItemTemplate] +): + """Project-scoped work item templates, plus `use` (instantiate a work item + from a template in one call).""" + + path = "/workspaces/{slug}/projects/{project_id}/work-item-templates/" + model = WorkItemTemplate + operations = { + "list": "project_work_item_templates_list", + "retrieve": "project_work_item_templates_retrieve", + "create": "project_work_item_templates_create", + "update": "project_work_item_templates_partial_update", + "use": "work_items_use", + "delete": "project_work_item_templates_destroy", + } + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkItemTemplate]: + """One page of templates in this project. `**filters` covers + `is_published`, `short_id`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkItemTemplate]: + """Every template in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, template_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemTemplate: + return self._retrieve(pk=template_id, params={"fields": fields}) + + def create(self, data: CreateWorkItemTemplate) -> WorkItemTemplate: + return self._create(data) + + def update(self, template_id: str, data: UpdateWorkItemTemplate) -> WorkItemTemplate: + return self._update(data, pk=template_id) + + def delete(self, template_id: str) -> None: + return self._delete(pk=template_id) + + # -- Custom action -------------------------------------------------------- + # Not an `_action` call: `use` returns a `WorkItem`, not this resource's own `model`. + + def use( + self, + template_id: str, + data: WorkItemTemplateUse | None = None, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItem: + """Instantiate a work item from this template. `data` optionally overrides + `name`/`project_id`; omit for the template's own values.""" + payload = self.transport.request( + "POST", + f"{self._detail_url(template_id)}use/", + params=self._query({"fields": fields, "expand": expand}, action="use"), + json=data.model_dump(mode="json", exclude_none=True) if data is not None else None, + ) + return WorkItem.model_validate(payload) diff --git a/plane/api/v2/work_item_templates/workspace.py b/plane/api/v2/work_item_templates/workspace.py new file mode 100644 index 0000000..8d6e714 --- /dev/null +++ b/plane/api/v2/work_item_templates/workspace.py @@ -0,0 +1,65 @@ +"""Workspace-scoped work item templates (api_v2). No `use` action here -- it is +only offered on the project-scoped variant (`ProjectWorkItemTemplates.use`).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplate, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkspaceWorkItemTemplates( + V2Resource[WorkItemTemplate, CreateWorkItemTemplate, UpdateWorkItemTemplate] +): + path = "/workspaces/{slug}/work-item-templates/" + model = WorkItemTemplate + operations = { + "list": "workspace_work_item_templates_list", + "retrieve": "workspace_work_item_templates_retrieve", + "create": "workspace_work_item_templates_create", + "update": "workspace_work_item_templates_partial_update", + "delete": "workspace_work_item_templates_destroy", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemTemplate]: + """One page of workspace-level templates. `**filters` covers + `is_published`, `short_id`.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemTemplate]: + """Every workspace-level template, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, + template_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemTemplate: + return self._retrieve(pk=template_id, params={"fields": fields}) + + def create(self, data: CreateWorkItemTemplate) -> WorkItemTemplate: + return self._create(data) + + def update(self, template_id: str, data: UpdateWorkItemTemplate) -> WorkItemTemplate: + return self._update(data, pk=template_id) + + def delete(self, template_id: str) -> None: + return self._delete(pk=template_id) diff --git a/plane/api/v2/work_item_types/__init__.py b/plane/api/v2/work_item_types/__init__.py new file mode 100644 index 0000000..60ec494 --- /dev/null +++ b/plane/api/v2/work_item_types/__init__.py @@ -0,0 +1,166 @@ +"""Work item types (api_v2) -- `WorkItemTypes` (project-scoped) and +`WorkspaceWorkItemTypes` are a different path template, not a filtered view of +each other. Both expose `.properties`; `enable`/`import_types`/`schema` are project-only.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_types import ( + CreateWorkItemType, + UpdateWorkItemType, + WorkItemType, + WorkItemTypeImport, + WorkItemTypeSchema, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .properties import WorkItemTypeProperties, WorkspaceWorkItemTypeProperties + +__all__ = [ + "WorkItemTypeProperties", + "WorkItemTypes", + "WorkspaceWorkItemTypeProperties", + "WorkspaceWorkItemTypes", +] + + +class WorkItemTypes(V2Resource[WorkItemType, CreateWorkItemType, UpdateWorkItemType]): + path = "/workspaces/{slug}/projects/{project_id}/work-item-types/" + model = WorkItemType + operations = { + "list": "work_item_types_list", + "retrieve": "work_item_types_retrieve", + "create": "work_item_types_create", + "update": "work_item_types_partial_update", + "enable": "work_item_types_enable", + "mark-default": "work_item_types_mark_default", + "delete": "work_item_types_destroy", + "import_types": "work_item_types_import", + "schema": "work_item_types_schema", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.properties = WorkItemTypeProperties(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkItemType]: + """One page of work item types in this project.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkItemType]: + """Every work item type in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve( + self, type_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemType: + return self._retrieve(pk=type_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> WorkItemType: + """The one work item type with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateWorkItemType) -> WorkItemType: + return self._create(data) + + def update(self, type_id: str, data: UpdateWorkItemType) -> WorkItemType: + return self._update(data, pk=type_id) + + def delete(self, type_id: str) -> None: + return self._delete(pk=type_id) + + def enable(self, *, fields: Sequence[str] | None = None) -> WorkItemType: + """Enable this project's epic/system work item type (idempotent). + Collection-level, not per-row -- there is no `{pk}` in + `.../work-item-types/enable/`.""" + payload = self.transport.request( + "POST", + f"{self._collection_url()}enable/", + params=self._query({"fields": fields}, action="enable"), + ) + return self.model.model_validate(payload) + + def import_types(self, type_ids: builtins.list[str]) -> None: + """Import the given (global/system) work item type ids into this project.""" + self.transport.request( + "POST", + f"{self._collection_url()}import/", + json=WorkItemTypeImport(work_item_types=list(type_ids)).model_dump( + mode="json", exclude_none=True + ), + ) + return None + + def mark_default( + self, type_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemType: + """Make this the project's default work item type for new work items.""" + return self._action("mark-default", pk=type_id, params={"fields": fields}) + + def schema(self, type_id: str, *, include: str | None = None) -> WorkItemTypeSchema: + """The type's writable standard fields (with options inline) plus its + custom properties -- what a client needs to build a create/update form.""" + params = {"include": include} if include is not None else None + payload = self.transport.request( + "GET", f"{self._detail_url(type_id)}schema/", params=params + ) + return WorkItemTypeSchema.model_validate(payload) + + +class WorkspaceWorkItemTypes(V2Resource[WorkItemType, CreateWorkItemType, UpdateWorkItemType]): + path = "/workspaces/{slug}/work-item-types/" + model = WorkItemType + operations = { + "list": "workspace_work_item_types_list", + "retrieve": "workspace_work_item_types_retrieve", + "create": "workspace_work_item_types_create", + "update": "workspace_work_item_types_partial_update", + "mark-default": "workspace_work_item_types_mark_default", + "delete": "workspace_work_item_types_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.properties = WorkspaceWorkItemTypeProperties(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[WorkItemType]: + """One page of work item types across the whole workspace.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[WorkItemType]: + """Every work item type in the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, type_id: str, *, fields: Sequence[str] | None = None) -> WorkItemType: + return self._retrieve(pk=type_id, params={"fields": fields}) + + def find_by_name(self, name: str) -> WorkItemType: + """The one workspace-level type with this name; raises if none or several match.""" + return self._find_one(filters={"name": name}) + + def create(self, data: CreateWorkItemType) -> WorkItemType: + return self._create(data) + + def update(self, type_id: str, data: UpdateWorkItemType) -> WorkItemType: + return self._update(data, pk=type_id) + + def delete(self, type_id: str) -> None: + return self._delete(pk=type_id) + + def mark_default( + self, type_id: str, *, fields: Sequence[str] | None = None + ) -> WorkItemType: + """Make this the workspace's default work item type.""" + return self._action("mark-default", pk=type_id, params={"fields": fields}) diff --git a/plane/api/v2/work_item_types/properties.py b/plane/api/v2/work_item_types/properties.py new file mode 100644 index 0000000..fa7ff03 --- /dev/null +++ b/plane/api/v2/work_item_types/properties.py @@ -0,0 +1,138 @@ +"""Custom properties attached to a work item type (project- and workspace-scoped). +`attach` POSTs to the *collection* URL, returning `{"properties": [...]}`, not a +`WorkItemProperty` row; `detach` is a plain `_delete` by property id.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_item_types import ( + WorkItemProperty, + WorkItemPropertyAttach, + WorkItemPropertyAttachResult, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemTypeProperties( + V2Resource[WorkItemProperty, WorkItemPropertyAttach, WorkItemPropertyAttach] +): + path = "/workspaces/{slug}/projects/{project_id}/work-item-types/{type_id}/properties/" + model = WorkItemProperty + operations = { + "list": "work_item_type_properties_list", + "retrieve": "work_item_type_properties_retrieve", + "attach": "work_item_type_properties_attach", + "detach": "work_item_type_properties_detach", + } + + def list( + self, + type_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemProperty]: + """One page of custom properties attached to a work item type.""" + return self._list(type_id=type_id, params={"fields": fields, **filters}) + + def iterate( + self, + type_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemProperty]: + """Every custom property attached to a work item type, following pages + automatically.""" + return self._iter(type_id=type_id, params={"fields": fields, **filters}) + + def retrieve( + self, + type_id: str, + property_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemProperty: + return self._retrieve(pk=property_id, type_id=type_id, params={"fields": fields}) + + def attach( + self, type_id: str, property_ids: Sequence[str] + ) -> WorkItemPropertyAttachResult: + """Attach existing property definitions to this work item type.""" + payload = self.transport.request( + "POST", + self._collection_url(type_id=type_id), + json=WorkItemPropertyAttach(properties=list(property_ids)).model_dump( + exclude_none=True + ), + ) + return WorkItemPropertyAttachResult.model_validate(payload) + + def detach(self, type_id: str, property_id: str) -> None: + """Detach a property definition from this work item type.""" + return self._delete(pk=property_id, type_id=type_id) + + +class WorkspaceWorkItemTypeProperties( + V2Resource[WorkItemProperty, WorkItemPropertyAttach, WorkItemPropertyAttach] +): + path = "/workspaces/{slug}/work-item-types/{type_id}/properties/" + model = WorkItemProperty + operations = { + "list": "workspace_work_item_type_properties_list", + "retrieve": "workspace_work_item_type_properties_retrieve", + "attach": "workspace_work_item_type_properties_attach", + "detach": "workspace_work_item_type_properties_detach", + } + + def list( + self, + type_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemProperty]: + """One page of custom properties attached to a workspace-level work item + type.""" + return self._list(type_id=type_id, params={"fields": fields, **filters}) + + def iterate( + self, + type_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemProperty]: + """Every custom property attached to a workspace-level work item type, + following pages automatically.""" + return self._iter(type_id=type_id, params={"fields": fields, **filters}) + + def retrieve( + self, + type_id: str, + property_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemProperty: + return self._retrieve(pk=property_id, type_id=type_id, params={"fields": fields}) + + def attach( + self, type_id: str, property_ids: Sequence[str] + ) -> WorkItemPropertyAttachResult: + """Attach existing property definitions to this workspace-level work item + type.""" + payload = self.transport.request( + "POST", + self._collection_url(type_id=type_id), + json=WorkItemPropertyAttach(properties=list(property_ids)).model_dump( + exclude_none=True + ), + ) + return WorkItemPropertyAttachResult.model_validate(payload) + + def detach(self, type_id: str, property_id: str) -> None: + """Detach a property definition from this workspace-level work item type.""" + return self._delete(pk=property_id, type_id=type_id) diff --git a/plane/api/v2/work_items/__init__.py b/plane/api/v2/work_items/__init__.py new file mode 100644 index 0000000..d35b5a6 --- /dev/null +++ b/plane/api/v2/work_items/__init__.py @@ -0,0 +1,156 @@ +"""Work items (api_v2) -- the hardest, and largest, api_v2 resource family. +Project-scoped, with `archive`/`unarchive` plus nested `.comments`/`.attachments`/ +`.links`/`.worklogs`/`.activities`/`.relations`/`.dependencies`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ....models.v2.common import BulkWriteResponse +from ....models.v2.work_items import CreateWorkItem, UpdateWorkItem, WorkItem +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .activities import WorkItemActivities +from .attachments import WorkItemAttachments +from .comments import WorkItemComments +from .dependencies import WorkItemDependencies +from .links import WorkItemLinks +from .relations import WorkItemRelations +from .worklogs import WorkItemWorklogs +from .workspace import WorkspaceWorkItems + +__all__ = [ + "WorkItemActivities", + "WorkItemAttachments", + "WorkItemComments", + "WorkItemDependencies", + "WorkItemLinks", + "WorkItemRelations", + "WorkItemWorklogs", + "WorkItems", + "WorkspaceWorkItems", +] + + +class WorkItems(V2Resource[WorkItem, CreateWorkItem, UpdateWorkItem]): + path = "/workspaces/{slug}/projects/{project_id}/work-items/" + model = WorkItem + operations = { + "list": "work_items_list", + "retrieve": "work_items_retrieve", + "create": "work_items_create", + "update": "work_items_partial_update", + "upsert": "work_items_upsert", + "archive": "work_items_archive", + "unarchive": "work_items_unarchive", + "delete": "work_items_destroy", + "bulk_create": "work_items_bulk_create", + "bulk_update": "work_items_bulk_update", + "bulk_delete": "work_items_bulk_delete", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.comments = WorkItemComments(transport, **self._scope) + self.attachments = WorkItemAttachments(transport, **self._scope) + self.links = WorkItemLinks(transport, **self._scope) + self.worklogs = WorkItemWorklogs(transport, **self._scope) + self.activities = WorkItemActivities(transport, **self._scope) + self.relations = WorkItemRelations(transport, **self._scope) + self.dependencies = WorkItemDependencies(transport, **self._scope) + + # -- CRUD --------------------------------------------------------------- + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItem]: + """One page of work items in this project. + + `**filters` covers `state_id`, `priority`, `assignee_id__in`, `state_group`, `search`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItem]: + """Every work item in this project, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItem: + """Fetch by UUID. Prefer `ws.work_items.retrieve_by_identifier` when you + have the human-readable key (e.g. `"ENG-12"`) instead.""" + return self._retrieve(pk=work_item_id, params={"fields": fields, "expand": expand}) + + def create(self, data: CreateWorkItem) -> WorkItem: + """Create a work item. Prefer readable fields over ids where you have them + (e.g. `CreateWorkItem(name=..., state="Todo", labels=["bug"])`).""" + return self._create(data) + + def update(self, work_item_id: str, data: UpdateWorkItem) -> WorkItem: + return self._update(data, pk=work_item_id) + + def delete(self, work_item_id: str) -> None: + return self._delete(pk=work_item_id) + + def upsert(self, data: CreateWorkItem) -> WorkItem: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data) + + def bulk_create( + self, items: builtins.list[CreateWorkItem], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none) + + def bulk_update( + self, items: builtins.list[Mapping[str, Any]], *, all_or_none: bool = False + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none) + + def bulk_delete( + self, ids: builtins.list[str], *, all_or_none: bool = False + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none) + + # -- Custom verb actions ------------------------------------------------------ + + def archive( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItem: + """Archive a work item, returning it. Only work items in a completed or + cancelled state can be archived (server-enforced).""" + return self._action( + "archive", pk=work_item_id, params={"fields": fields, "expand": expand} + ) + + def unarchive( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItem: + """Restore an archived work item to active status, returning it.""" + return self._action( + "unarchive", pk=work_item_id, params={"fields": fields, "expand": expand} + ) diff --git a/plane/api/v2/work_items/activities.py b/plane/api/v2/work_items/activities.py new file mode 100644 index 0000000..01af606 --- /dev/null +++ b/plane/api/v2/work_items/activities.py @@ -0,0 +1,65 @@ +"""Work item activities (api_v2). Read-only -- the API never lets a caller write +an activity row directly; they are generated as a side effect of other writes.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_items import WorkItemActivity +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +# No write/patch DTO exists for activities -- the type parameters are filled with +# the read model itself as an unused placeholder: the kernel has no read-only +# resource shape. +class WorkItemActivities(V2Resource[WorkItemActivity, WorkItemActivity, WorkItemActivity]): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/activities/" + model = WorkItemActivity + operations = { + "list": "activities_list", + "retrieve": "activities_retrieve", + } + + def list( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemActivity]: + """One page of activity entries on a work item.""" + return self._list( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def iterate( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemActivity]: + """Every activity entry on a work item, following pages automatically.""" + return self._iter( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def retrieve( + self, + work_item_id: str, + activity_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItemActivity: + return self._retrieve( + pk=activity_id, + work_item_id=work_item_id, + params={"fields": fields, "expand": expand}, + ) diff --git a/plane/api/v2/work_items/attachments.py b/plane/api/v2/work_items/attachments.py new file mode 100644 index 0000000..6bf6d92 --- /dev/null +++ b/plane/api/v2/work_items/attachments.py @@ -0,0 +1,83 @@ +"""Work item attachments (api_v2). `create` returns presigned upload instructions; +`update(..., WorkItemAttachmentConfirm(is_uploaded=True))` marks it uploaded. +This resource only covers the metadata lifecycle, not the upload itself.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_items import ( + CreateWorkItemAttachment, + WorkItemAttachment, + WorkItemAttachmentConfirm, + WorkItemAttachmentUploadResult, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemAttachments( + V2Resource[WorkItemAttachment, CreateWorkItemAttachment, WorkItemAttachmentConfirm] +): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/attachments/" + model = WorkItemAttachment + operations = { + "list": "attachments_list", + "retrieve": "attachments_retrieve", + "create": "attachments_create", + "update": "attachments_partial_update", + "delete": "attachments_destroy", + } + + def list( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemAttachment]: + """One page of attachments on a work item.""" + return self._list(work_item_id=work_item_id, params={"fields": fields, **filters}) + + def iterate( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemAttachment]: + """Every attachment on a work item, following pages automatically.""" + return self._iter(work_item_id=work_item_id, params={"fields": fields, **filters}) + + def retrieve( + self, + work_item_id: str, + attachment_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemAttachment: + return self._retrieve( + pk=attachment_id, work_item_id=work_item_id, params={"fields": fields} + ) + + def create( + self, work_item_id: str, data: CreateWorkItemAttachment + ) -> WorkItemAttachmentUploadResult: + """Registers the metadata and returns presigned upload instructions. + Golden documents a bare `WorkItemAttachment`; live server returns this richer envelope.""" + payload = self.transport.request( + "POST", + self._collection_url(work_item_id=work_item_id), + params=self._query(None, action="create"), + json=data.model_dump(mode="json", exclude_none=True), + ) + return WorkItemAttachmentUploadResult.model_validate(payload) + + def update( + self, work_item_id: str, attachment_id: str, data: WorkItemAttachmentConfirm + ) -> WorkItemAttachment: + return self._update(data, pk=attachment_id, work_item_id=work_item_id) + + def delete(self, work_item_id: str, attachment_id: str) -> None: + return self._delete(pk=attachment_id, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/comments.py b/plane/api/v2/work_items/comments.py new file mode 100644 index 0000000..236f26d --- /dev/null +++ b/plane/api/v2/work_items/comments.py @@ -0,0 +1,113 @@ +"""Work item comments (api_v2).""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from ....models.v2.common import BulkWriteResponse +from ....models.v2.work_items import CreateWorkItemComment, UpdateWorkItemComment, WorkItemComment +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemComments(V2Resource[WorkItemComment, CreateWorkItemComment, UpdateWorkItemComment]): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/comments/" + model = WorkItemComment + operations = { + "list": "comments_list", + "retrieve": "comments_retrieve", + "create": "comments_create", + "update": "comments_partial_update", + "upsert": "work_item_comments_upsert", + "delete": "comments_destroy", + "bulk_create": "work_item_comments_bulk_create", + "bulk_update": "work_item_comments_bulk_update", + "bulk_delete": "work_item_comments_bulk_delete", + } + + def list( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemComment]: + """One page of comments on a work item.""" + return self._list( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def iterate( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemComment]: + """Every comment on a work item, following pages automatically.""" + return self._iter( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def retrieve( + self, + work_item_id: str, + comment_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItemComment: + return self._retrieve( + pk=comment_id, + work_item_id=work_item_id, + params={"fields": fields, "expand": expand}, + ) + + def create(self, work_item_id: str, data: CreateWorkItemComment) -> WorkItemComment: + return self._create(data, work_item_id=work_item_id) + + def update( + self, work_item_id: str, comment_id: str, data: UpdateWorkItemComment + ) -> WorkItemComment: + return self._update(data, pk=comment_id, work_item_id=work_item_id) + + def delete(self, work_item_id: str, comment_id: str) -> None: + return self._delete(pk=comment_id, work_item_id=work_item_id) + + def upsert(self, work_item_id: str, data: CreateWorkItemComment) -> WorkItemComment: + """Reconciles on (external_source, external_id) when both are set.""" + return self._upsert(data, work_item_id=work_item_id) + + def bulk_create( + self, + work_item_id: str, + items: builtins.list[CreateWorkItemComment], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + return self._bulk_create(items, all_or_none=all_or_none, work_item_id=work_item_id) + + def bulk_update( + self, + work_item_id: str, + items: builtins.list[Mapping[str, Any]], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + """Each item is `{"id": , ...fields to change}`.""" + return self._bulk_update(items, all_or_none=all_or_none, work_item_id=work_item_id) + + def bulk_delete( + self, + work_item_id: str, + ids: builtins.list[str], + *, + all_or_none: bool = False, + ) -> BulkWriteResponse: + return self._bulk_delete(ids, all_or_none=all_or_none, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/dependencies.py b/plane/api/v2/work_items/dependencies.py new file mode 100644 index 0000000..08402d9 --- /dev/null +++ b/plane/api/v2/work_items/dependencies.py @@ -0,0 +1,36 @@ +"""Work item dependencies (api_v2). Same non-paginated, dict-shaped `list`/`create` +as `WorkItemRelations`, but the six directions are fixed by the API, not a +per-workspace label set.""" + +from __future__ import annotations + +from ....models.v2.work_items import WorkItemDependencyCreate, WorkItemDependencyList +from .._kernel.resource import V2Resource + + +class WorkItemDependencies( + V2Resource[WorkItemDependencyList, WorkItemDependencyCreate, WorkItemDependencyCreate] +): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/dependencies/" + model = WorkItemDependencyList + operations = { + "list": "work_item_dependencies_list", + "create": "work_item_dependencies_create", + "delete": "work_item_dependencies_destroy", + } + + def list(self, work_item_id: str) -> WorkItemDependencyList: + """Every related work item id, grouped by dependency direction.""" + payload = self.transport.request( + "GET", self._collection_url(work_item_id=work_item_id) + ) + return self.model.model_validate(payload) + + def create( + self, work_item_id: str, data: WorkItemDependencyCreate + ) -> WorkItemDependencyList: + return self._create(data, work_item_id=work_item_id) + + def delete(self, work_item_id: str, related_work_item_id: str) -> None: + """Delete by the *related* work item's id -- see `WorkItemRelations.delete`.""" + return self._delete(pk=related_work_item_id, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/links.py b/plane/api/v2/work_items/links.py new file mode 100644 index 0000000..def0d52 --- /dev/null +++ b/plane/api/v2/work_items/links.py @@ -0,0 +1,60 @@ +"""Work item links (api_v2).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_items import CreateWorkItemLink, UpdateWorkItemLink, WorkItemLink +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemLinks(V2Resource[WorkItemLink, CreateWorkItemLink, UpdateWorkItemLink]): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/links/" + model = WorkItemLink + operations = { + "list": "links_list", + "retrieve": "links_retrieve", + "create": "links_create", + "update": "links_partial_update", + "delete": "links_destroy", + } + + def list( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemLink]: + """One page of links on a work item.""" + return self._list(work_item_id=work_item_id, params={"fields": fields, **filters}) + + def iterate( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemLink]: + """Every link on a work item, following pages automatically.""" + return self._iter(work_item_id=work_item_id, params={"fields": fields, **filters}) + + def retrieve( + self, + work_item_id: str, + link_id: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkItemLink: + return self._retrieve(pk=link_id, work_item_id=work_item_id, params={"fields": fields}) + + def create(self, work_item_id: str, data: CreateWorkItemLink) -> WorkItemLink: + return self._create(data, work_item_id=work_item_id) + + def update(self, work_item_id: str, link_id: str, data: UpdateWorkItemLink) -> WorkItemLink: + return self._update(data, pk=link_id, work_item_id=work_item_id) + + def delete(self, work_item_id: str, link_id: str) -> None: + return self._delete(pk=link_id, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/relations.py b/plane/api/v2/work_items/relations.py new file mode 100644 index 0000000..b90f9d9 --- /dev/null +++ b/plane/api/v2/work_items/relations.py @@ -0,0 +1,36 @@ +"""Work item relations (api_v2). `list`/`create` return a single dict-shaped +object, not a paginated collection -- directions are a per-workspace-defined +dynamic label set (see `work_item_relation_definitions`).""" + +from __future__ import annotations + +from ....models.v2.work_items import WorkItemRelationCreate, WorkItemRelationList +from .._kernel.resource import V2Resource + + +class WorkItemRelations( + V2Resource[WorkItemRelationList, WorkItemRelationCreate, WorkItemRelationCreate] +): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/relations/" + model = WorkItemRelationList + operations = { + "list": "work_item_relations_list", + "create": "work_item_relations_create", + "delete": "work_item_relations_destroy", + } + + def list(self, work_item_id: str) -> WorkItemRelationList: + """Every related work item id, grouped by relation-definition direction.""" + payload = self.transport.request( + "GET", self._collection_url(work_item_id=work_item_id) + ) + return self.model.model_validate(payload) + + def create(self, work_item_id: str, data: WorkItemRelationCreate) -> WorkItemRelationList: + return self._create(data, work_item_id=work_item_id) + + def delete(self, work_item_id: str, related_work_item_id: str) -> None: + """Delete by the *related* work item's id, not a relation-row id -- the API + has no separate id for a relation; the pair `(work_item_id, + related_work_item_id)` identifies which one to remove.""" + return self._delete(pk=related_work_item_id, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/worklogs.py b/plane/api/v2/work_items/worklogs.py new file mode 100644 index 0000000..cd227e4 --- /dev/null +++ b/plane/api/v2/work_items/worklogs.py @@ -0,0 +1,75 @@ +"""Work item worklogs (api_v2).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_items import CreateWorkItemWorklog, UpdateWorkItemWorklog, WorkItemWorklog +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkItemWorklogs(V2Resource[WorkItemWorklog, CreateWorkItemWorklog, UpdateWorkItemWorklog]): + path = "/workspaces/{slug}/projects/{project_id}/work-items/{work_item_id}/worklogs/" + model = WorkItemWorklog + operations = { + "list": "worklogs_list", + "retrieve": "worklogs_retrieve", + "create": "worklogs_create", + "update": "worklogs_partial_update", + "delete": "worklogs_destroy", + } + + def list( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItemWorklog]: + """One page of worklogs on a work item.""" + return self._list( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def iterate( + self, + work_item_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItemWorklog]: + """Every worklog on a work item, following pages automatically.""" + return self._iter( + work_item_id=work_item_id, + params={"fields": fields, "expand": expand, **filters}, + ) + + def retrieve( + self, + work_item_id: str, + worklog_id: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItemWorklog: + return self._retrieve( + pk=worklog_id, + work_item_id=work_item_id, + params={"fields": fields, "expand": expand}, + ) + + def create(self, work_item_id: str, data: CreateWorkItemWorklog) -> WorkItemWorklog: + return self._create(data, work_item_id=work_item_id) + + def update( + self, work_item_id: str, worklog_id: str, data: UpdateWorkItemWorklog + ) -> WorkItemWorklog: + return self._update(data, pk=worklog_id, work_item_id=work_item_id) + + def delete(self, work_item_id: str, worklog_id: str) -> None: + return self._delete(pk=worklog_id, work_item_id=work_item_id) diff --git a/plane/api/v2/work_items/workspace.py b/plane/api/v2/work_items/workspace.py new file mode 100644 index 0000000..851f724 --- /dev/null +++ b/plane/api/v2/work_items/workspace.py @@ -0,0 +1,62 @@ +"""Workspace-wide work item listing (api_v2). No project id in the path, unlike +`proj.work_items`. Also carries `retrieve_by_identifier` (by human-readable key, +e.g. `"ENG-12"`); list-only, no create/update/delete.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.work_items import CreateWorkItem, UpdateWorkItem, WorkItem +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + +__all__ = ["WorkspaceWorkItems"] + + +class WorkspaceWorkItems(V2Resource[WorkItem, CreateWorkItem, UpdateWorkItem]): + path = "/workspaces/{slug}/work-items/" + model = WorkItem + operations = { + "list": "workspace_work_items_list", + "retrieve_by_identifier": "work_items_retrieve_by_identifier", + } + + def list( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkItem]: + """One page of work items across every project in the workspace the caller + can view. Same filters as `WorkItems.list`, plus `project_id`/`project_id__in`.""" + return self._list(params={"fields": fields, "expand": expand, **filters}) + + def iterate( + self, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkItem]: + """Every work item across the workspace, following pages automatically.""" + return self._iter(params={"fields": fields, "expand": expand, **filters}) + + def retrieve_by_identifier( + self, + identifier: str, + *, + fields: Sequence[str] | None = None, + expand: Sequence[str] | None = None, + ) -> WorkItem: + """Fetch a work item by its human-readable key (e.g. `"ENG-12"`), with no + project id needed -- the readable-identifier flagship of api_v2.""" + payload = self.transport.request( + "GET", + self._detail_url(identifier), + params=self._query( + {"fields": fields, "expand": expand}, action="retrieve_by_identifier" + ), + ) + return self.model.model_validate(payload) diff --git a/plane/api/v2/workflows/__init__.py b/plane/api/v2/workflows/__init__.py new file mode 100644 index 0000000..d9a8f39 --- /dev/null +++ b/plane/api/v2/workflows/__init__.py @@ -0,0 +1,7 @@ +"""Workflows (api_v2) -- see `workflows.py` for the family docstring.""" + +from .states import WorkflowStates +from .transitions import WorkflowTransitions +from .workflows import Workflows + +__all__ = ["WorkflowStates", "WorkflowTransitions", "Workflows"] diff --git a/plane/api/v2/workflows/states.py b/plane/api/v2/workflows/states.py new file mode 100644 index 0000000..194bf80 --- /dev/null +++ b/plane/api/v2/workflows/states.py @@ -0,0 +1,67 @@ +"""States attached to a workflow (api_v2). `attach` bulk-attaches existing project +states; `update`/`delete` operate on the per-workflow membership row, not the +underlying project `State` itself.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.workflows import UpdateWorkflowState, WorkflowState, WorkflowStateCreate +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkflowStates(V2Resource[WorkflowState, WorkflowStateCreate, UpdateWorkflowState]): + path = "/workspaces/{slug}/projects/{project_id}/workflows/{workflow_id}/states/" + model = WorkflowState + operations = { + "list": "workflow_states_list", + "retrieve": "workflow_states_retrieve", + "create": "workflow_states_create", + "update": "workflow_states_partial_update", + "delete": "workflow_states_destroy", + } + + def list( + self, + workflow_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkflowState]: + """One page of states attached to a workflow.""" + return self._list(workflow_id=workflow_id, params={"fields": fields, **filters}) + + def iterate( + self, + workflow_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkflowState]: + """Every state attached to a workflow, following pages automatically.""" + return self._iter(workflow_id=workflow_id, params={"fields": fields, **filters}) + + def retrieve( + self, + workflow_id: str, + pk: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkflowState: + return self._retrieve(pk=pk, workflow_id=workflow_id, params={"fields": fields}) + + def attach(self, workflow_id: str, data: WorkflowStateCreate) -> WorkflowState: + """Bulk-attach existing project states by id (`state_ids`). Named `attach`, + not `create`: the golden's op posts ids and returns an array, not a row creation.""" + return self._create(data, workflow_id=workflow_id) + + def update(self, workflow_id: str, pk: str, data: UpdateWorkflowState) -> WorkflowState: + """Update the per-workflow membership row (`type`/`is_default`/ + `allow_issue_creation`) -- not the underlying project `State`.""" + return self._update(data, pk=pk, workflow_id=workflow_id) + + def delete(self, workflow_id: str, pk: str) -> None: + """Detach a state from this workflow.""" + return self._delete(pk=pk, workflow_id=workflow_id) diff --git a/plane/api/v2/workflows/transitions.py b/plane/api/v2/workflows/transitions.py new file mode 100644 index 0000000..bb70571 --- /dev/null +++ b/plane/api/v2/workflows/transitions.py @@ -0,0 +1,70 @@ +"""State transitions attached to a workflow (api_v2). On create, `state_id` is the +source, `transition_state_id` the target, with an optional `rejection_state_id` +and approval gating (`required_approvals`, `member_ids`).""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.workflows import ( + CreateWorkflowTransition, + UpdateWorkflowTransition, + WorkflowTransition, +) +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource + + +class WorkflowTransitions( + V2Resource[WorkflowTransition, CreateWorkflowTransition, UpdateWorkflowTransition] +): + path = "/workspaces/{slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions/" + model = WorkflowTransition + operations = { + "list": "workflow_transitions_list", + "retrieve": "workflow_transitions_retrieve", + "create": "workflow_transitions_create", + "update": "workflow_transitions_partial_update", + "delete": "workflow_transitions_destroy", + } + + def list( + self, + workflow_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Page[WorkflowTransition]: + """One page of transitions attached to a workflow.""" + return self._list(workflow_id=workflow_id, params={"fields": fields, **filters}) + + def iterate( + self, + workflow_id: str, + *, + fields: Sequence[str] | None = None, + **filters: Any, + ) -> Iterator[WorkflowTransition]: + """Every transition attached to a workflow, following pages automatically.""" + return self._iter(workflow_id=workflow_id, params={"fields": fields, **filters}) + + def retrieve( + self, + workflow_id: str, + pk: str, + *, + fields: Sequence[str] | None = None, + ) -> WorkflowTransition: + return self._retrieve(pk=pk, workflow_id=workflow_id, params={"fields": fields}) + + def create(self, workflow_id: str, data: CreateWorkflowTransition) -> WorkflowTransition: + return self._create(data, workflow_id=workflow_id) + + def update( + self, workflow_id: str, pk: str, data: UpdateWorkflowTransition + ) -> WorkflowTransition: + return self._update(data, pk=pk, workflow_id=workflow_id) + + def delete(self, workflow_id: str, pk: str) -> None: + return self._delete(pk=pk, workflow_id=workflow_id) diff --git a/plane/api/v2/workflows/workflows.py b/plane/api/v2/workflows/workflows.py new file mode 100644 index 0000000..1fc3bb3 --- /dev/null +++ b/plane/api/v2/workflows/workflows.py @@ -0,0 +1,57 @@ +"""Workflows (api_v2) -- a project's per-work-item-type workflow configuration, +with nested `.states`/`.transitions`. No `upsert`/bulk operations -- CRUD-only.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from ....models.v2.workflows import CreateWorkflow, UpdateWorkflow, Workflow +from .._kernel.pagination import Page +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .states import WorkflowStates +from .transitions import WorkflowTransitions + +__all__ = ["WorkflowStates", "WorkflowTransitions", "Workflows"] + + +class Workflows(V2Resource[Workflow, CreateWorkflow, UpdateWorkflow]): + path = "/workspaces/{slug}/projects/{project_id}/workflows/" + model = Workflow + operations = { + "list": "workflows_list", + "retrieve": "workflows_retrieve", + "create": "workflows_create", + "update": "workflows_partial_update", + "delete": "workflows_destroy", + } + + def __init__(self, transport: V2Transport, **scope: Any) -> None: + super().__init__(transport, **scope) + self.states = WorkflowStates(transport, **self._scope) + self.transitions = WorkflowTransitions(transport, **self._scope) + + def list( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Page[Workflow]: + """One page of workflows in this project.""" + return self._list(params={"fields": fields, **filters}) + + def iterate( + self, *, fields: Sequence[str] | None = None, **filters: Any + ) -> Iterator[Workflow]: + """Every workflow in this project, following pages automatically.""" + return self._iter(params={"fields": fields, **filters}) + + def retrieve(self, workflow_id: str, *, fields: Sequence[str] | None = None) -> Workflow: + return self._retrieve(pk=workflow_id, params={"fields": fields}) + + def create(self, data: CreateWorkflow) -> Workflow: + return self._create(data) + + def update(self, workflow_id: str, data: UpdateWorkflow) -> Workflow: + return self._update(data, pk=workflow_id) + + def delete(self, workflow_id: str) -> None: + return self._delete(pk=workflow_id) diff --git a/plane/api/v2/worklogs.py b/plane/api/v2/worklogs.py new file mode 100644 index 0000000..fec9d6b --- /dev/null +++ b/plane/api/v2/worklogs.py @@ -0,0 +1,26 @@ +"""Project worklog duration summary (api_v2). Single read-only report endpoint, +not a paginated collection: response is a bare JSON array, one row per work item.""" + +from __future__ import annotations + +import builtins + +from ...models.v2.worklogs_summary import WorklogSummaryEntry +from ._kernel.resource import V2Resource + + +class ProjectWorklogs(V2Resource[WorklogSummaryEntry, WorklogSummaryEntry, WorklogSummaryEntry]): + path = "/workspaces/{slug}/projects/{project_id}/worklogs/summary/" + model = WorklogSummaryEntry + # No `fields=` support on this operation -- it is not in the golden's FIELDS + # map at all. + operations = { + "summary": "project_worklogs_summary", + } + + def summary(self) -> builtins.list[WorklogSummaryEntry]: + """Per-work-item duration totals for this project. + + Returns a plain array, not an enveloped payload, so parsed by hand here.""" + payload = self.transport.request("GET", self._collection_url()) + return [self.model.model_validate(row) for row in payload] diff --git a/plane/api/v2/workspace.py b/plane/api/v2/workspace.py new file mode 100644 index 0000000..bbf15d2 --- /dev/null +++ b/plane/api/v2/workspace.py @@ -0,0 +1,75 @@ +"""`Workspace` -- the root scope, bound once. Constructing it makes no request: +it builds every workspace-level resource, each already bound with `slug`.""" + +from __future__ import annotations + +from ._kernel.transport import V2Transport +from .artifacts import Artifacts +from .assets import WorkspaceAssets +from .audit_logs import AuditLogs +from .automations import WorkspaceAutomations +from .customer_properties import CustomerProperties +from .customers import Customers +from .features import WorkspaceFeatures +from .group_sync import GroupSync +from .initiatives import Initiatives +from .invitations import Invitations +from .members import WorkspaceMembers +from .permission_schemes import PermissionSchemes +from .permissions import WorkspacePermissions +from .project import Project +from .projects import Projects +from .releases import Releases +from .roles import Roles +from .stickies import Stickies +from .teamspaces import Teamspaces +from .views import WorkspaceViews +from .webhooks import Webhooks +from .wiki import Wiki +from .work_item_properties import WorkspaceWorkItemProperties +from .work_item_relation_definitions import WorkItemRelationDefinitions +from .work_item_templates import WorkspaceWorkItemTemplates +from .work_item_types import WorkspaceWorkItemTypes +from .work_items import WorkspaceWorkItems + + +class Workspace: + """A workspace bound by slug. Makes no request to construct.""" + + def __init__(self, transport: V2Transport, slug: str) -> None: + self.transport = transport + self.slug = slug + + self.projects = Projects(transport, slug=slug) + self.members = WorkspaceMembers(transport, slug=slug) + self.invitations = Invitations(transport, slug=slug) + self.roles = Roles(transport, slug=slug) + self.permission_schemes = PermissionSchemes(transport, slug=slug) + self.permissions = WorkspacePermissions(transport, slug=slug) + self.features = WorkspaceFeatures(transport, slug=slug) + self.audit_logs = AuditLogs(transport, slug=slug) + self.views = WorkspaceViews(transport, slug=slug) + self.work_items = WorkspaceWorkItems(transport, slug=slug) + self.work_item_types = WorkspaceWorkItemTypes(transport, slug=slug) + self.work_item_properties = WorkspaceWorkItemProperties(transport, slug=slug) + self.work_item_relation_definitions = WorkItemRelationDefinitions( + transport, slug=slug + ) + self.work_item_templates = WorkspaceWorkItemTemplates(transport, slug=slug) + self.group_sync = GroupSync(transport, slug=slug) + self.automations = WorkspaceAutomations(transport, slug=slug) + self.assets = WorkspaceAssets(transport, slug=slug) + self.artifacts = Artifacts(transport, slug=slug) + self.webhooks = Webhooks(transport, slug=slug) + self.stickies = Stickies(transport, slug=slug) + self.teamspaces = Teamspaces(transport, slug=slug) + self.customers = Customers(transport, slug=slug) + self.customer_properties = CustomerProperties(transport, slug=slug) + self.initiatives = Initiatives(transport, slug=slug) + self.releases = Releases(transport, slug=slug) + self.wiki = Wiki(transport, slug=slug) + + def project(self, project: str) -> Project: + """Bind a project inside this workspace. Makes no request. `project` + accepts a project id or its key (e.g. `"ENG"`).""" + return Project(self.transport, self.slug, project) diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 9907cf7..ac8b765 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -18,6 +18,7 @@ from ..api.stickies import Stickies from ..api.teamspaces import Teamspaces from ..api.users import Users +from ..api.v2 import V2Namespace from ..api.work_item_properties import WorkItemProperties from ..api.work_item_relation_definitions import WorkItemRelationDefinitions from ..api.work_item_type_governance import WorkItemTypeGovernance @@ -59,6 +60,9 @@ def __init__( access_token=access_token, ) + # api_v2 surface. v1 resources below are unchanged. + self.v2 = V2Namespace(self.config) + self.users = Users(self.config) self.workspaces = Workspaces(self.config) self.projects = Projects(self.config) diff --git a/plane/config.py b/plane/config.py index 00136ed..dd09fe2 100644 --- a/plane/config.py +++ b/plane/config.py @@ -34,6 +34,9 @@ def __init__( ) self.base_path = base_path.rstrip("/") + "/api/v1" + # Raw origin with no version suffix. v1 keeps `base_path`; v2 resources + # build their own `/api/v2` prefix from this. + self.api_root = base_path.rstrip("/") self.api_key = api_key self.access_token = access_token self.timeout = timeout diff --git a/plane/models/v2/__init__.py b/plane/models/v2/__init__.py new file mode 100644 index 0000000..31ba810 --- /dev/null +++ b/plane/models/v2/__init__.py @@ -0,0 +1,587 @@ +from .artifacts import ( + Artifact, + ArtifactDetail, + ArtifactPublish, + ArtifactUpdated, + CreateArtifact, + DataMode, + UpdateArtifactUpdate, +) +from .assets import ( + CreateUserAsset, + CreateWorkspaceAsset, + UserAsset, + UserAssetConfirm, + UserAssetEntityType, + UserAssetUploadResult, + WorkspaceAsset, + WorkspaceAssetConfirm, + WorkspaceAssetUploadResult, +) +from .audit_logs import ( + ActorType, + AuditCategory, + AuditLog, + AuditLogSource, + AuditOutcome, +) +from .automations import ( + Automation, + AutomationActivity, + AutomationEdge, + AutomationNode, + AutomationStatus, + AutomationWebhookSecret, + CreateAutomation, + CreateAutomationEdge, + CreateAutomationNode, + NodeType, + SetAutomationStatus, + UpdateAutomation, + UpdateAutomationEdge, + UpdateAutomationNode, +) +from .collections import ( + Collection, + CollectionAccess, + CollectionMember, + CollectionMemberAccess, + CollectionMemberAdd, + CollectionMembersManage, + CollectionMembersManageResult, + CollectionMemberSource, + CollectionPageSearch, + CollectionPagesManage, + CollectionPagesManageResult, + CreateCollection, + UpdateCollection, +) +from .common import ( + BulkRowFailure, + BulkRowSuccess, + BulkWriteResponse, + CursorPage, + OffsetPage, +) +from .customer_properties import ( + CreateCustomerProperty, + CreateCustomerPropertyOption, + CustomerProperty, + CustomerPropertyRelationType, + CustomerPropertyType, + UpdateCustomerProperty, +) +from .customers import ( + CreateCustomer, + CreateCustomerPropertyValues, + CreateCustomerRequest, + Customer, + CustomerPropertyValueMap, + CustomerRequest, + CustomerWorkItemManageRequest, + CustomerWorkItemManageResponse, + UpdateCustomer, + UpdateCustomerRequest, +) +from .cycle_actions import ( + CycleTransfer, + CycleTransferResult, + CycleWorkItemManage, + CycleWorkItemManageResult, +) +from .cycles import ( + CreateCycle, + Cycle, + UpdateCycle, +) +from .estimates import ( + CreateEstimate, + CreateEstimatePoint, + Estimate, + EstimatePoint, + EstimateType, + UpdateEstimate, + UpdateEstimatePoint, +) +from .features import ( + ProjectFeature, + UpdateProjectFeature, + UpdateWorkspaceFeature, + WorkspaceFeature, +) +from .group_sync import ( + CreateGroupMapping, + CreateWorkspaceGroupMapping, + GroupMapping, + GroupSyncConfig, + UpdateGroupMapping, + UpdateGroupSyncConfig, + UpdateWorkspaceGroupMapping, + WorkspaceGroupMapping, +) +from .initiatives import ( + CreateInitiative, + CreateInitiativeLabel, + Initiative, + InitiativeChildManageRequest, + InitiativeChildManageResponse, + InitiativeLabel, + InitiativeState, + UpdateInitiative, + UpdateInitiativeLabel, +) +from .intakes import ( + CreateIntakeWorkItem, + IntakeWorkItem, + IntakeWorkItemPriority, + IntakeWorkItemStatus, + UpdateIntakeWorkItem, +) +from .invitations import ( + BulkCreateWorkspaceInvites, + CreateWorkspaceInvite, + WorkspaceInvite, +) +from .labels import ( + CreateLabel, + Label, + UpdateLabel, +) +from .members import ( + CreateProjectMember, + Member, + UpdateProjectMember, + WorkspaceMemberRemove, +) +from .milestone_work_items import ( + MilestoneWorkItemManageRequest, + MilestoneWorkItemManageResponse, +) +from .milestones import ( + CreateMilestone, + Milestone, + UpdateMilestone, +) +from .module_work_items import ( + ModuleWorkItemManageRequest, + ModuleWorkItemManageResponse, +) +from .modules import ( + CreateModule, + Module, + ModuleStatus, + UpdateModule, +) +from .pages import ( + CreatePage, + Page, + PageAccess, + UpdatePage, +) +from .permission_schemes import ( + Namespace, + PermissionScheme, +) +from .permissions import EffectivePermissions +from .project_role_distribution import ( + ProjectRoleDistribution, + ProjectRoleDistributionEntry, +) +from .projects import ( + CreateProject, + Network, + Project, + ProjectSummary, + UpdateProject, +) +from .releases import ( + CreateRelease, + CreateReleaseComment, + CreateReleaseLabel, + CreateReleaseLink, + CreateReleaseTag, + Release, + ReleaseChangelog, + ReleaseChildManageRequest, + ReleaseChildManageResult, + ReleaseComment, + ReleaseLabel, + ReleaseLink, + ReleaseStatus, + ReleaseTag, + UpdateRelease, + UpdateReleaseChangelog, + UpdateReleaseComment, + UpdateReleaseLabel, + UpdateReleaseLink, + UpdateReleaseTag, +) +from .roles import ( + Role, + RoleNamespace, + RoleStatus, +) +from .states import ( + CreateState, + State, + StateGroup, + UpdateState, +) +from .stickies import ( + CreateSticky, + Sticky, + UpdateSticky, +) +from .teamspaces import ( + CreateTeamspace, + Teamspace, + UpdateTeamspace, +) +from .users import ( + PrincipalKind, + WhoAmI, +) +from .views import ( + Access, + CreateView, + UpdateView, + View, +) +from .webhook_logs import WebhookLog +from .webhooks import ( + CreateWebhook, + UpdateWebhook, + Webhook, + WebhookContentType, + WebhookCreateResult, +) +from .work_item_properties import ( + CreateWorkItemProperty, + CreateWorkItemPropertyContext, + CreateWorkItemPropertyOption, + UpdateWorkItemProperty, + UpdateWorkItemPropertyContext, + UpdateWorkItemPropertyOption, + WorkItemProperty, + WorkItemPropertyContext, + WorkItemPropertyContextOptionInput, + WorkItemPropertyOption, + WorkItemPropertyRelationType, + WorkItemPropertyType, +) +from .work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, + WorkItemRelationDefinition, +) +from .work_item_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplate, + WorkItemTemplateData, + WorkItemTemplateUse, +) +from .work_item_types import ( + CreateWorkItemType, + UpdateWorkItemType, + WorkItemPropertyAttach, + WorkItemPropertyAttachResult, + WorkItemType, + WorkItemTypeImport, + WorkItemTypeSchema, +) +from .work_item_types import ( + WorkItemProperty as WorkItemTypeProperty, +) +from .work_items import ( + CommentAccess, + CreateWorkItem, + CreateWorkItemAttachment, + CreateWorkItemComment, + CreateWorkItemLink, + CreateWorkItemWorklog, + DependencyRelationType, + Priority, + UpdateWorkItem, + UpdateWorkItemComment, + UpdateWorkItemLink, + UpdateWorkItemWorklog, + WorkItem, + WorkItemActivity, + WorkItemAttachment, + WorkItemAttachmentConfirm, + WorkItemAttachmentUploadResult, + WorkItemComment, + WorkItemDependencyCreate, + WorkItemDependencyList, + WorkItemLink, + WorkItemRelationCreate, + WorkItemRelationList, + WorkItemWorklog, +) +from .workflows import ( + CreateWorkflow, + CreateWorkflowTransition, + UpdateWorkflow, + UpdateWorkflowState, + UpdateWorkflowTransition, + Workflow, + WorkflowState, + WorkflowStateCreate, + WorkflowTransition, +) +from .worklogs_summary import WorklogSummaryEntry + +__all__ = [ + "Access", + "ActorType", + "Artifact", + "ArtifactDetail", + "ArtifactPublish", + "UpdateArtifactUpdate", + "ArtifactUpdated", + "CreateArtifact", + "AuditCategory", + "AuditLog", + "AuditLogSource", + "AuditOutcome", + "Automation", + "AutomationActivity", + "AutomationEdge", + "UpdateAutomationEdge", + "CreateAutomationEdge", + "AutomationNode", + "UpdateAutomationNode", + "CreateAutomationNode", + "UpdateAutomation", + "AutomationStatus", + "SetAutomationStatus", + "AutomationWebhookSecret", + "CreateAutomation", + "BulkRowFailure", + "BulkRowSuccess", + "BulkWriteResponse", + "Collection", + "CollectionAccess", + "CollectionMember", + "CollectionMemberAccess", + "CollectionMemberAdd", + "CollectionMemberSource", + "CollectionMembersManage", + "CollectionMembersManageResult", + "CollectionPageSearch", + "CollectionPagesManage", + "CollectionPagesManageResult", + "UpdateCollection", + "CreateCollection", + "CommentAccess", + "CursorPage", + "Customer", + "UpdateCustomer", + "CustomerProperty", + "CreateCustomerPropertyOption", + "UpdateCustomerProperty", + "CustomerPropertyRelationType", + "CustomerPropertyType", + "CustomerPropertyValueMap", + "CreateCustomerPropertyValues", + "CreateCustomerProperty", + "CustomerRequest", + "UpdateCustomerRequest", + "CreateCustomerRequest", + "CustomerWorkItemManageRequest", + "CustomerWorkItemManageResponse", + "CreateCustomer", + "Cycle", + "UpdateCycle", + "CycleTransferResult", + "CycleTransfer", + "CycleWorkItemManageResult", + "CycleWorkItemManage", + "CreateCycle", + "DataMode", + "DependencyRelationType", + "EffectivePermissions", + "Estimate", + "UpdateEstimate", + "EstimatePoint", + "UpdateEstimatePoint", + "CreateEstimatePoint", + "EstimateType", + "CreateEstimate", + "GroupMapping", + "UpdateGroupMapping", + "CreateGroupMapping", + "GroupSyncConfig", + "UpdateGroupSyncConfig", + "Initiative", + "InitiativeChildManageRequest", + "InitiativeChildManageResponse", + "InitiativeLabel", + "UpdateInitiativeLabel", + "CreateInitiativeLabel", + "UpdateInitiative", + "InitiativeState", + "CreateInitiative", + "IntakeWorkItem", + "UpdateIntakeWorkItem", + "IntakeWorkItemPriority", + "IntakeWorkItemStatus", + "CreateIntakeWorkItem", + "Label", + "UpdateLabel", + "CreateLabel", + "Member", + "Milestone", + "UpdateMilestone", + "MilestoneWorkItemManageRequest", + "MilestoneWorkItemManageResponse", + "CreateMilestone", + "Module", + "UpdateModule", + "ModuleStatus", + "ModuleWorkItemManageRequest", + "ModuleWorkItemManageResponse", + "CreateModule", + "Namespace", + "Network", + "NodeType", + "OffsetPage", + "Page", + "PageAccess", + "UpdatePage", + "CreatePage", + "PermissionScheme", + "PrincipalKind", + "Priority", + "Project", + "ProjectFeature", + "UpdateProjectFeature", + "UpdateProjectMember", + "CreateProjectMember", + "UpdateProject", + "ProjectRoleDistribution", + "ProjectRoleDistributionEntry", + "ProjectSummary", + "CreateProject", + "Release", + "ReleaseChangelog", + "UpdateReleaseChangelog", + "ReleaseChildManageRequest", + "ReleaseChildManageResult", + "ReleaseComment", + "UpdateReleaseComment", + "CreateReleaseComment", + "ReleaseLabel", + "UpdateReleaseLabel", + "CreateReleaseLabel", + "ReleaseLink", + "UpdateReleaseLink", + "CreateReleaseLink", + "UpdateRelease", + "ReleaseStatus", + "ReleaseTag", + "UpdateReleaseTag", + "CreateReleaseTag", + "CreateRelease", + "Role", + "RoleNamespace", + "RoleStatus", + "State", + "StateGroup", + "UpdateState", + "CreateState", + "Sticky", + "UpdateSticky", + "CreateSticky", + "Teamspace", + "UpdateTeamspace", + "CreateTeamspace", + "UserAsset", + "UserAssetConfirm", + "UserAssetEntityType", + "UserAssetUploadResult", + "CreateUserAsset", + "View", + "UpdateView", + "CreateView", + "Webhook", + "WebhookContentType", + "WebhookCreateResult", + "WebhookLog", + "UpdateWebhook", + "CreateWebhook", + "WhoAmI", + "WorkItem", + "WorkItemActivity", + "WorkItemAttachment", + "WorkItemAttachmentConfirm", + "WorkItemAttachmentUploadResult", + "CreateWorkItemAttachment", + "WorkItemComment", + "UpdateWorkItemComment", + "CreateWorkItemComment", + "WorkItemDependencyCreate", + "WorkItemDependencyList", + "WorkItemLink", + "UpdateWorkItemLink", + "CreateWorkItemLink", + "UpdateWorkItem", + "WorkItemProperty", + "WorkItemPropertyAttach", + "WorkItemPropertyAttachResult", + "WorkItemPropertyContext", + "WorkItemPropertyContextOptionInput", + "UpdateWorkItemPropertyContext", + "CreateWorkItemPropertyContext", + "WorkItemPropertyOption", + "UpdateWorkItemPropertyOption", + "CreateWorkItemPropertyOption", + "UpdateWorkItemProperty", + "WorkItemPropertyRelationType", + "WorkItemPropertyType", + "CreateWorkItemProperty", + "WorkItemRelationCreate", + "WorkItemRelationDefinition", + "UpdateWorkItemRelationDefinition", + "CreateWorkItemRelationDefinition", + "WorkItemRelationList", + "WorkItemTemplate", + "WorkItemTemplateData", + "UpdateWorkItemTemplate", + "WorkItemTemplateUse", + "CreateWorkItemTemplate", + "WorkItemType", + "WorkItemTypeImport", + "UpdateWorkItemType", + "WorkItemTypeProperty", + "WorkItemTypeSchema", + "CreateWorkItemType", + "WorkItemWorklog", + "UpdateWorkItemWorklog", + "CreateWorkItemWorklog", + "CreateWorkItem", + "Workflow", + "UpdateWorkflow", + "WorkflowState", + "WorkflowStateCreate", + "UpdateWorkflowState", + "WorkflowTransition", + "UpdateWorkflowTransition", + "CreateWorkflowTransition", + "CreateWorkflow", + "WorklogSummaryEntry", + "WorkspaceAsset", + "WorkspaceAssetConfirm", + "WorkspaceAssetUploadResult", + "CreateWorkspaceAsset", + "WorkspaceFeature", + "UpdateWorkspaceFeature", + "WorkspaceGroupMapping", + "UpdateWorkspaceGroupMapping", + "CreateWorkspaceGroupMapping", + "WorkspaceInvite", + "BulkCreateWorkspaceInvites", + "CreateWorkspaceInvite", + "WorkspaceMemberRemove", +] diff --git a/plane/models/v2/artifacts.py b/plane/models/v2/artifacts.py new file mode 100644 index 0000000..81f09c9 --- /dev/null +++ b/plane/models/v2/artifacts.py @@ -0,0 +1,76 @@ +"""Artifact models for api_v2 -- `create`/`retrieve`/`publish`/`update` each return a differently- +shaped envelope; every read field but `id` is optional.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +DataMode = Literal["snapshot", "live"] + + +class Artifact(BaseModel): + """Returned by `create`.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + current_version: int | None = None + is_published: bool | None = None + anchor: str | None = None + data_mode: DataMode | None = None + + +class ArtifactDetail(BaseModel): + """Returned by `retrieve`.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + data_mode: DataMode | None = None + current_version: int | None = None + html: str | None = None + + +class ArtifactPublish(BaseModel): + """Returned by `publish`. No `id` -- this row has none.""" + + model_config = ConfigDict(extra="allow") + + anchor: str | None = None + is_active: bool | None = None + + +class ArtifactUpdated(BaseModel): + """Returned by `update`.""" + + model_config = ConfigDict(extra="allow") + + id: str + current_version: int | None = None + data_mode: DataMode | None = None + + +class CreateArtifact(BaseModel): + """POST body for `create`. `html` and `name` are required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + html: str + description: str | None = None + prompt: str | None = None + project: str | None = None + data_mode: DataMode | None = None + + +class UpdateArtifactUpdate(BaseModel): + """PATCH body for `update` -- appends a new HTML version; golden marks `html` optional but the + live view 400s without it.""" + + model_config = ConfigDict(extra="ignore") + + html: str | None = None + prompt: str | None = None diff --git a/plane/models/v2/assets.py b/plane/models/v2/assets.py new file mode 100644 index 0000000..7b6fa1e --- /dev/null +++ b/plane/models/v2/assets.py @@ -0,0 +1,121 @@ +"""Asset models for api_v2 (two-step S3 upload flow). Confirmed live: `create` returns +`{upload_data, asset_id, asset_url, asset}` (not the golden's bare +`WorkspaceAsset`/`UserAsset`); `asset_url` is nullable.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +UserAssetEntityType = Literal["USER_AVATAR", "USER_COVER"] + + +# -- Workspace assets ------------------------------------------------------------- + + +class WorkspaceAsset(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + asset_url: str | None = None + attributes: dict[str, object] | None = None + content_type: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + entity_type: str | None = None + external_id: str | None = None + external_source: str | None = None + is_uploaded: bool | None = None + name: str | None = None + size: float | None = None + + +class CreateWorkspaceAsset(BaseModel): + """POST body. Registers the asset's metadata; the response's `upload_data` + (see `WorkspaceAssetUploadResult`) is where the caller then PUTs the file's + bytes, and a follow-up PATCH (`WorkspaceAssetConfirm`) marks it uploaded.""" + + model_config = ConfigDict(extra="ignore") + + name: str + size: int + entity_type: str | None = None + """Free-form; the golden defaults this server-side to `"WORK_ITEM_IMPORT"`.""" + type: str | None = None + """MIME type; the golden defaults this server-side to + `"application/octet-stream"`.""" + + +class WorkspaceAssetConfirm(BaseModel): + """PATCH body -- confirms the out-of-band upload completed. See the module + docstring: the golden has no documented request body for this operation.""" + + model_config = ConfigDict(extra="ignore") + + is_uploaded: bool | None = None + + +class WorkspaceAssetUploadResult(BaseModel): + """The real response to `POST .../assets/` (confirmed live): `{upload_data, asset_id, + asset_url, asset}`; `asset_url` may legitimately be null.""" + + model_config = ConfigDict(extra="allow") + + asset_id: str + asset_url: str | None = None + upload_data: dict[str, object] + asset: WorkspaceAsset + + +# -- User assets (avatar/cover, not workspace-scoped) ---------------------------- + + +class UserAsset(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + asset_url: str | None = None + attributes: dict[str, object] | None = None + content_type: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + entity_type: str | None = None + is_uploaded: bool | None = None + name: str | None = None + size: float | None = None + user_id: str | None = None + + +class CreateUserAsset(BaseModel): + """POST body. `entity_type` picks avatar vs cover.""" + + model_config = ConfigDict(extra="ignore") + + entity_type: UserAssetEntityType + name: str + size: int + type: str | None = None + """MIME type; the golden defaults this server-side to `"image/jpeg"`.""" + + +class UserAssetConfirm(BaseModel): + """PATCH body -- confirms the out-of-band upload completed. See the module + docstring: the golden has no documented request body for this operation.""" + + model_config = ConfigDict(extra="ignore") + + is_uploaded: bool | None = None + + +class UserAssetUploadResult(BaseModel): + """The likely response to `POST /users/me/assets/`, modeled by analogy to + `WorkspaceAssetUploadResult`; not independently verified live.""" + + model_config = ConfigDict(extra="allow") + + asset_id: str + asset_url: str + upload_data: dict[str, object] + asset: UserAsset diff --git a/plane/models/v2/audit_logs.py b/plane/models/v2/audit_logs.py new file mode 100644 index 0000000..6446b55 --- /dev/null +++ b/plane/models/v2/audit_logs.py @@ -0,0 +1,54 @@ +"""Audit log models for api_v2.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +ActorType = Literal["user", "api_token", "system", "anonymous"] +AuditCategory = Literal[ + "auth", + "member", + "role", + "settings", + "integration", + "webhook", + "security", + "instance", + "project", +] +AuditOutcome = Literal["success", "failure"] +AuditLogSource = Literal["platform", "api", "graphql", "auth", "system"] + + +class AuditLog(BaseModel): + """A workspace audit-log entry; read-only, every field but `id` optional, hash-chain internals + omitted.""" + + model_config = ConfigDict(extra="allow") + + id: str + actor_display_name: str | None = None + actor_email: str | None = None + actor_id: str | None = None + actor_type: ActorType | None = None + category: AuditCategory | None = None + created_at: datetime | None = None + event_id: str | None = None + event_name: str | None = None + ip_address: str | None = None + metadata: Any | None = None + new_value: Any | None = None + old_value: Any | None = None + outcome: AuditOutcome | None = None + project_id: str | None = None + reason: str | None = None + sequence_number: int | None = None + source: AuditLogSource | None = None + target_display_name: str | None = None + target_id: str | None = None + target_type: str | None = None + user_agent: str | None = None + workspace_id: str | None = None diff --git a/plane/models/v2/automations.py b/plane/models/v2/automations.py new file mode 100644 index 0000000..5126ec6 --- /dev/null +++ b/plane/models/v2/automations.py @@ -0,0 +1,181 @@ +"""Automation models for api_v2; edited through scoped write requests +(`AutomationWriteRequest`/`AutomationNodeWriteRequest`/etc.), not one unified PATCH.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +AutomationStatus = Literal["draft", "published", "disabled"] +NodeType = Literal["trigger", "action", "condition"] + + +# -- Automations ---------------------------------------------------------------- + + +class Automation(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + bot_user_id: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + current_version_id: str | None = None + description: str | None = None + is_enabled: bool | None = None + is_global: bool | None = None + last_run_at: datetime | None = None + name: str | None = None + project_ids: list[str] | None = None + run_count: int | None = None + scope: str | None = None + """What entity this automation runs on (e.g. `"WorkItem"`, `"Cycle"`, `"Module"`).""" + status: AutomationStatus | None = None + updated_at: datetime | None = None + + +class CreateAutomation(BaseModel): + """POST body for the automation shell. Status/enable are owned by + `set_status`, not this DTO -- see the golden's own note on + `AutomationWriteRequest`.""" + + model_config = ConfigDict(extra="ignore") + + name: str + scope: str + description: str | None = None + project_ids: list[str] | None = None + + +class UpdateAutomation(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + scope: str | None = None + description: str | None = None + project_ids: list[str] | None = None + + +class SetAutomationStatus(BaseModel): + """Body for the `status` action -- the only way to enable/disable an + automation or move it out of draft.""" + + model_config = ConfigDict(extra="ignore") + + is_enabled: bool + + +# -- Edges ------------------------------------------------------------------------ + + +class AutomationEdge(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + created_at: datetime | None = None + created_by_id: str | None = None + execution_order: int | None = None + """Order for evaluation when multiple edges leave the same node.""" + source_node_id: str | None = None + target_node_id: str | None = None + updated_at: datetime | None = None + version_id: str | None = None + + +class CreateAutomationEdge(BaseModel): + model_config = ConfigDict(extra="ignore") + + source_node_id: str + target_node_id: str + execution_order: int | None = None + + +class UpdateAutomationEdge(BaseModel): + model_config = ConfigDict(extra="ignore") + + source_node_id: str | None = None + target_node_id: str | None = None + execution_order: int | None = None + + +# -- Nodes ------------------------------------------------------------------------ + + +class AutomationNode(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + config: Any = None + """Node-specific configuration and parameters -- a free-form JSON blob, shape + depends on `handler_name`; the golden declares no fixed schema for it.""" + created_at: datetime | None = None + created_by_id: str | None = None + handler_name: str | None = None + """e.g. `"record_created"`, `"send_email"`.""" + is_enabled: bool | None = None + last_triggered_at: datetime | None = None + """Last time this scheduled trigger was dispatched.""" + name: str | None = None + next_scheduled_at: datetime | None = None + """Next scheduled execution time (UTC). Only for scheduled triggers.""" + node_type: NodeType | None = None + updated_at: datetime | None = None + version_id: str | None = None + + +class CreateAutomationNode(BaseModel): + model_config = ConfigDict(extra="ignore") + + handler_name: str + name: str + node_type: NodeType + config: Any = None + is_enabled: bool | None = None + + +class UpdateAutomationNode(BaseModel): + model_config = ConfigDict(extra="ignore") + + handler_name: str | None = None + name: str | None = None + node_type: NodeType | None = None + config: Any = None + is_enabled: bool | None = None + + +class AutomationWebhookSecret(BaseModel): + """Response of the `regenerate_webhook_secret` action on a node. Not itself a + row with an `id` -- the golden's own schema for it declares only `secret`.""" + + model_config = ConfigDict(extra="allow") + + secret: str | None = None + + +# -- Activities (read-only) ------------------------------------------------------ + + +class AutomationActivity(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + actor_id: str | None = None + automation_edge_id: str | None = None + automation_id: str | None = None + automation_node_id: str | None = None + automation_run_id: str | None = None + automation_scope: str | None = None + automation_version_id: str | None = None + created_at: datetime | None = None + epoch: float | None = None + field: str | None = None + new_identifier: str | None = None + new_value: str | None = None + node_execution_id: str | None = None + old_identifier: str | None = None + old_value: str | None = None + verb: str | None = None diff --git a/plane/models/v2/collections.py b/plane/models/v2/collections.py new file mode 100644 index 0000000..e739c40 --- /dev/null +++ b/plane/models/v2/collections.py @@ -0,0 +1,132 @@ +"""Collection models for api_v2 -- named groups of workspace pages; `CreateCollection.name` is +optional, matching the golden's write schema.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +CollectionAccess = Literal[0, 1] # 0 = Public, 1 = Private +CollectionMemberAccess = Literal[0, 1, 2] # 0 = View, 1 = Comment, 2 = Edit +CollectionMemberSource = Literal["manual", "group_sync"] + + +class Collection(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + access: CollectionAccess | None = None + created_at: datetime | None = None + created_by_id: str | None = None + is_default: bool | None = None + is_global: bool | None = None + logo_props: dict[str, object] | None = None + name: str | None = None + owned_by_id: str | None = None + page_ids: list[str] | None = None + sort_order: float | None = None + + +class CreateCollection(BaseModel): + """POST body. See the module docstring: the golden requires nothing here.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + access: CollectionAccess | None = None + is_default: bool | None = None + is_global: bool | None = None + logo_props: dict[str, object] | None = None + sort_order: float | None = None + + +class UpdateCollection(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + access: CollectionAccess | None = None + is_default: bool | None = None + is_global: bool | None = None + logo_props: dict[str, object] | None = None + sort_order: float | None = None + + +# -- Members ---------------------------------------------------------------------- + + +class CollectionMember(BaseModel): + """Sparse collection membership row -- `member_id` + access level.""" + + model_config = ConfigDict(extra="allow") + + id: str + access: CollectionMemberAccess | None = None + collection_id: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + member_id: str | None = None + source: CollectionMemberSource | None = None + + +class CollectionMemberAdd(BaseModel): + """One `add` entry for `CollectionMembers.manage` -- `member_id` plus an + optional access level (defaults to View server-side).""" + + model_config = ConfigDict(extra="ignore") + + member_id: str + access: CollectionMemberAccess | None = None + + +class CollectionMembersManage(BaseModel): + """POST body for `.../collections/{id}/members/` (O5-style): `add` grants + (or updates) listed workspace members; `remove` drops them by member id. + Re-adding an existing member with a different `access` updates that row.""" + + model_config = ConfigDict(extra="ignore") + + add: list[CollectionMemberAdd] | None = None + remove: list[str] | None = None + + +class CollectionMembersManageResult(BaseModel): + """Member user ids actually added/updated and removed (idempotent no-ops + omitted from `removed`).""" + + model_config = ConfigDict(extra="allow") + + added: list[str] = [] + removed: list[str] = [] + + +# -- Pages -------------------------------------------------------------------------- + + +class CollectionPagesManage(BaseModel): + """POST body for `.../collections/{id}/pages/`: page ids to add/remove.""" + + model_config = ConfigDict(extra="ignore") + + add: list[str] | None = None + remove: list[str] | None = None + + +class CollectionPagesManageResult(BaseModel): + model_config = ConfigDict(extra="allow") + + added: list[str] = [] + removed: list[str] = [] + + +class CollectionPageSearch(BaseModel): + """Lite page shape returned by `CollectionPages.search`.""" + + model_config = ConfigDict(extra="allow") + + id: str + logo_props: dict[str, object] | None = None + name: str | None = None diff --git a/plane/models/v2/common.py b/plane/models/v2/common.py new file mode 100644 index 0000000..ca1a574 --- /dev/null +++ b/plane/models/v2/common.py @@ -0,0 +1,97 @@ +"""Envelopes and shared payloads for api_v2 responses.""" + +from typing import Generic, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +from ...api.v2._kernel.errors import FieldError, PlaneAPIError + +T = TypeVar("T", bound=BaseModel) + + +class OffsetStyle(BaseModel): + style: Literal["offset"] + + +class CursorStyle(BaseModel): + style: Literal["cursor"] + + +class OffsetPage(BaseModel, Generic[T]): + """Default list envelope. `total_count` is absent when the caller sent ?count=false.""" + + model_config = ConfigDict(extra="allow") + + data: list[T] + pagination: OffsetStyle + next: int | None = None + previous: int | None = None + total_count: int | None = None + + +class CursorPage(BaseModel, Generic[T]): + """Keyset envelope, returned when the caller sent ?paginate=cursor.""" + + model_config = ConfigDict(extra="allow") + + data: list[T] + pagination: CursorStyle + has_more: bool = False + next_cursor: str | None = None + + +class BulkRowSuccess(BaseModel): + """A row that landed. `result` says which write happened.""" + + model_config = ConfigDict(extra="allow") + + index: int + result: Literal["created", "updated", "deleted"] + id: str + + +class BulkRowFailure(BaseModel): + """A row that did not land, carrying its own problem detail.""" + + model_config = ConfigDict(extra="allow") + + index: int + result: Literal["failed"] + type: str = "invalid_request" + code: str = "invalid_request" + detail: str = "" + errors: list["FieldError"] | None = None + id: str | None = None + + +class BulkWriteResponse(BaseModel): + """Always HTTP 200 -- read `results` for what happened; `all_or_none=True` raises + `PlaneAPIError` (409) instead.""" + + model_config = ConfigDict(extra="allow") + + results: list[BulkRowSuccess | BulkRowFailure] = Field(default_factory=list) + succeeded: int = 0 + failed: int = 0 + + @property + def failures(self) -> list[BulkRowFailure]: + return [row for row in self.results if isinstance(row, BulkRowFailure)] + + def raise_for_failures(self) -> None: + """Raise if any row failed. Opt-in, because partial success is the default.""" + failures = self.failures + if not failures: + return + first = failures[0] + raise PlaneAPIError( + status=200, + type=first.type, + code=first.code, + detail=f"{len(failures)} of {len(self.results)} rows failed: {first.detail}", + errors=first.errors, + response=self.model_dump(), + ) + + +BulkRowFailure.model_rebuild() diff --git a/plane/models/v2/customer_properties.py b/plane/models/v2/customer_properties.py new file mode 100644 index 0000000..9f507ac --- /dev/null +++ b/plane/models/v2/customer_properties.py @@ -0,0 +1,91 @@ +"""Customer property models for api_v2 -- custom-field definitions on Customer; every read field +but `id` is optional.""" + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +CustomerPropertyType = Literal[ + "TEXT", "DATETIME", "DECIMAL", "BOOLEAN", "OPTION", "RELATION", "URL", "EMAIL", "FILE" +] +CustomerPropertyRelationType = Literal["ISSUE", "USER"] + + +class CreateCustomerPropertyOption(BaseModel): + """One entry of `CreateCustomerProperty.options` / `UpdateCustomerProperty.options` + (write-only -- the read side returns `options` as an opaque list).""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + external_id: str | None = None + external_source: str | None = None + is_default: bool | None = None + + +class CustomerProperty(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + display_name: str | None = None + description: str | None = None + property_type: CustomerPropertyType | None = None + relation_type: CustomerPropertyRelationType | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + default_value: list[str] | None = None + options: list[Any] | None = None + logo_props: Any | None = None + settings: Any | None = None + validation_rules: Any | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateCustomerProperty(BaseModel): + """POST body. `display_name` and `property_type` are required by the API.""" + + model_config = ConfigDict(extra="ignore") + + display_name: str + property_type: CustomerPropertyType + description: str | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + default_value: list[str] | None = None + options: list[CreateCustomerPropertyOption] | None = None + relation_type: CustomerPropertyRelationType | None = None + settings: Any | None = None + logo_props: Any | None = None + validation_rules: Any | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateCustomerProperty(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + display_name: str | None = None + property_type: CustomerPropertyType | None = None + description: str | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + default_value: list[str] | None = None + options: list[CreateCustomerPropertyOption] | None = None + relation_type: CustomerPropertyRelationType | None = None + settings: Any | None = None + logo_props: Any | None = None + validation_rules: Any | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/customers.py b/plane/models/v2/customers.py new file mode 100644 index 0000000..fa87bdd --- /dev/null +++ b/plane/models/v2/customers.py @@ -0,0 +1,152 @@ +"""Customer models for api_v2; unlike work items, every write field here is id-based with no +readable-name companions.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class Customer(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: Any | None = None + description_html: str | None = None + domain: str | None = None + email: str | None = None + employees: int | None = None + revenue: str | None = None + stage: str | None = None + contract_status: str | None = None + website_url: str | None = None + logo_asset_id: str | None = None + logo_props: Any | None = None + logo_url: str | None = None + customer_request_count: int | None = None + archived_at: datetime | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateCustomer(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: Any | None = None + description_html: str | None = None + domain: str | None = None + email: str | None = None + employees: int | None = None + revenue: str | None = None + stage: str | None = None + contract_status: str | None = None + website_url: str | None = None + logo_props: Any | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateCustomer(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: Any | None = None + description_html: str | None = None + domain: str | None = None + email: str | None = None + employees: int | None = None + revenue: str | None = None + stage: str | None = None + contract_status: str | None = None + website_url: str | None = None + logo_props: Any | None = None + external_id: str | None = None + external_source: str | None = None + + +class CustomerRequest(BaseModel): + """A request a customer has raised, optionally linked to work items via + `CreateCustomerRequest.work_item_ids` (write-only -- not echoed back here).""" + + model_config = ConfigDict(extra="allow") + + id: str + customer_id: str | None = None + name: str | None = None + description: Any | None = None + description_html: str | None = None + link: str | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateCustomerRequest(BaseModel): + """POST body; `name` is required, `work_item_ids` links work items on create (write-only).""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: Any | None = None + description_html: str | None = None + link: str | None = None + work_item_ids: list[str] | None = None + + +class UpdateCustomerRequest(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: Any | None = None + description_html: str | None = None + link: str | None = None + work_item_ids: list[str] | None = None + + +class CustomerPropertyValueMap(BaseModel): + """Property values keyed by property id (dynamic mapping, access via `.model_extra`); every + value serializes as `list[str]`.""" + + model_config = ConfigDict(extra="allow") + + +class CreateCustomerPropertyValues(BaseModel): + """POST body for `customer_property_values_create`: bulk-set. Acts as an + upsert -- the properties named are given these values, replacing any they + already held; properties absent from `values` are untouched.""" + + model_config = ConfigDict(extra="ignore") + + values: dict[str, list[str]] + + +class CustomerWorkItemManageRequest(BaseModel): + """POST body for `Customers.manage_work_items`: work item ids to link/unlink + from this customer. Both lists are optional; omit either to leave that side + unchanged.""" + + model_config = ConfigDict(extra="ignore") + + add: list[str] | None = None + remove: list[str] | None = None + + +class CustomerWorkItemManageResponse(BaseModel): + """The ids actually added/removed by a `Customers.manage_work_items` call.""" + + model_config = ConfigDict(extra="allow") + + added: list[str] = [] + removed: list[str] = [] diff --git a/plane/models/v2/cycle_actions.py b/plane/models/v2/cycle_actions.py new file mode 100644 index 0000000..43b4a57 --- /dev/null +++ b/plane/models/v2/cycle_actions.py @@ -0,0 +1,43 @@ +"""Models for the two cycle custom actions out of scope of `plane.models.v2.cycles` +(`transfer` and `manage_work_items` -- see `plane/api/v2/cycles.py`'s docstring).""" + +from pydantic import BaseModel, ConfigDict, Field + + +class CycleTransfer(BaseModel): + """POST body for `.../cycles/{id}/transfer/` -- move the source cycle's + incomplete work items into `new_cycle_id` (the source cycle must already be + completed).""" + + model_config = ConfigDict(extra="ignore") + + new_cycle_id: str + + +class CycleTransferResult(BaseModel): + """Response of `transfer` -- echoes back the destination cycle id.""" + + model_config = ConfigDict(extra="allow") + + new_cycle_id: str | None = None + + +class CycleWorkItemManage(BaseModel): + """POST body for `.../cycles/{id}/work-items/` -- bulk set-style membership: + `add` moves the listed work items into this cycle (re-homing from any other + cycle), `remove` takes them out. Both optional; provide at least one.""" + + model_config = ConfigDict(extra="ignore") + + add: list[str] | None = Field(default=None, max_length=100) + remove: list[str] | None = Field(default=None, max_length=100) + + +class CycleWorkItemManageResult(BaseModel): + """Response of `manage_work_items` -- the work-item ids actually added and + removed (idempotent no-ops are omitted).""" + + model_config = ConfigDict(extra="allow") + + added: list[str] | None = None + removed: list[str] | None = None diff --git a/plane/models/v2/cycles.py b/plane/models/v2/cycles.py new file mode 100644 index 0000000..f9ac506 --- /dev/null +++ b/plane/models/v2/cycles.py @@ -0,0 +1,58 @@ +"""Cycle models for api_v2; every read field but `id` is optional.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Cycle(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + owned_by_id: str | None = None + sort_order: float | None = None + timezone: str | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateCycle(BaseModel): + """POST body. `name` is required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + """Must not be earlier than `start_date`.""" + sort_order: float | None = None + timezone: str | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateCycle(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + sort_order: float | None = None + timezone: str | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/estimates.py b/plane/models/v2/estimates.py new file mode 100644 index 0000000..dbcfc70 --- /dev/null +++ b/plane/models/v2/estimates.py @@ -0,0 +1,90 @@ +"""Estimate + estimate point models for api_v2; every read field but `id` is optional.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +EstimateType = Literal["categories", "points", "time"] + + +class EstimatePoint(BaseModel): + """One point/category/time value belonging to an estimate.""" + + model_config = ConfigDict(extra="allow") + + id: str + key: int | None = None + value: str | None = None + description: str | None = None + estimate_id: str | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class Estimate(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + type: EstimateType | None = None + last_used: bool | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + points: list[EstimatePoint] | None = None + """Populated only when the request set `expand=["points"]`.""" + + +class CreateEstimate(BaseModel): + """POST body. `name` is required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + type: EstimateType | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateEstimate(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + type: EstimateType | None = None + external_id: str | None = None + external_source: str | None = None + + +class CreateEstimatePoint(BaseModel): + """POST body. `value` is required by the API.""" + + model_config = ConfigDict(extra="ignore") + + value: str + key: int | None = None + description: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateEstimatePoint(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + value: str | None = None + key: int | None = None + description: str | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/features.py b/plane/models/v2/features.py new file mode 100644 index 0000000..7530e15 --- /dev/null +++ b/plane/models/v2/features.py @@ -0,0 +1,68 @@ +"""Feature-toggle models for api_v2 (GET/PATCH singletons); `ProjectFeature` has no `id` field, +unlike `WorkspaceFeature`.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WorkspaceFeature(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + is_customer_enabled: bool | None = None + is_initiative_enabled: bool | None = None + is_member_project_creation_enabled: bool | None = None + is_pi_enabled: bool | None = None + is_project_grouping_enabled: bool | None = None + is_release_enabled: bool | None = None + is_state_duration_enabled: bool | None = None + is_teams_enabled: bool | None = None + is_wiki_enabled: bool | None = None + is_work_item_types_enabled: bool | None = None + is_workitem_hierarchy_enabled: bool | None = None + work_item_type_default_level: int | None = None + created_at: datetime | None = None + + +class UpdateWorkspaceFeature(BaseModel): + model_config = ConfigDict(extra="ignore") + + is_customer_enabled: bool | None = None + is_initiative_enabled: bool | None = None + is_member_project_creation_enabled: bool | None = None + is_pi_enabled: bool | None = None + is_project_grouping_enabled: bool | None = None + is_release_enabled: bool | None = None + is_state_duration_enabled: bool | None = None + is_teams_enabled: bool | None = None + is_wiki_enabled: bool | None = None + is_work_item_types_enabled: bool | None = None + is_workitem_hierarchy_enabled: bool | None = None + work_item_type_default_level: int | None = None + + +class ProjectFeature(BaseModel): + """No `id` field -- the golden schema has none for this singleton.""" + + model_config = ConfigDict(extra="allow") + + is_automated_cycle_enabled: bool | None = None + is_epic_enabled: bool | None = None + is_manually_start_end_cycles_enabled: bool | None = None + is_milestone_enabled: bool | None = None + is_parallel_cycles_enabled: bool | None = None + is_project_updates_enabled: bool | None = None + is_workflow_enabled: bool | None = None + + +class UpdateProjectFeature(BaseModel): + model_config = ConfigDict(extra="ignore") + + is_automated_cycle_enabled: bool | None = None + is_epic_enabled: bool | None = None + is_manually_start_end_cycles_enabled: bool | None = None + is_milestone_enabled: bool | None = None + is_parallel_cycles_enabled: bool | None = None + is_project_updates_enabled: bool | None = None + is_workflow_enabled: bool | None = None diff --git a/plane/models/v2/group_sync.py b/plane/models/v2/group_sync.py new file mode 100644 index 0000000..1bc6fc8 --- /dev/null +++ b/plane/models/v2/group_sync.py @@ -0,0 +1,98 @@ +"""Group sync models for api_v2 (IdP group -> role/project mapping); `GroupSyncConfig` is a +workspace-wide singleton.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class GroupSyncConfig(BaseModel): + """Workspace-wide IdP group sync settings. Exactly one row per workspace.""" + + model_config = ConfigDict(extra="allow") + + id: str + group_attribute_key: str | None = None + is_enabled: bool | None = None + sync_on_login: bool | None = None + sync_offline: bool | None = None + auto_remove: bool | None = None + default_workspace_role_slug: str | None = None + + +class UpdateGroupSyncConfig(BaseModel): + """PATCH body -- every field optional. No POST/create for this singleton.""" + + model_config = ConfigDict(extra="ignore") + + group_attribute_key: str | None = None + is_enabled: bool | None = None + sync_on_login: bool | None = None + sync_offline: bool | None = None + auto_remove: bool | None = None + default_workspace_role_slug: str | None = None + + +class GroupMapping(BaseModel): + """IdP group -> project (or every project) + role. Project-level mapping.""" + + model_config = ConfigDict(extra="allow") + + id: str + idp_group_name: str | None = None + project_id: str | None = None + all_projects: bool | None = None + role_slug: str | None = None + created_at: datetime | None = None + + +class CreateGroupMapping(BaseModel): + """POST body; `idp_group_name`/`role_slug` required, set `all_projects=True` to map every + project instead of one `project_id`.""" + + model_config = ConfigDict(extra="ignore") + + idp_group_name: str + role_slug: str + project_id: str | None = None + all_projects: bool | None = None + + +class UpdateGroupMapping(BaseModel): + """PATCH body -- every field optional.""" + + model_config = ConfigDict(extra="ignore") + + idp_group_name: str | None = None + role_slug: str | None = None + project_id: str | None = None + all_projects: bool | None = None + + +class WorkspaceGroupMapping(BaseModel): + """IdP group -> workspace role. Workspace-level mapping (no project).""" + + model_config = ConfigDict(extra="allow") + + id: str + idp_group_name: str | None = None + role_slug: str | None = None + created_at: datetime | None = None + + +class CreateWorkspaceGroupMapping(BaseModel): + """POST body. `idp_group_name` and `role_slug` are both required.""" + + model_config = ConfigDict(extra="ignore") + + idp_group_name: str + role_slug: str + + +class UpdateWorkspaceGroupMapping(BaseModel): + """PATCH body -- every field optional.""" + + model_config = ConfigDict(extra="ignore") + + idp_group_name: str | None = None + role_slug: str | None = None diff --git a/plane/models/v2/initiatives.py b/plane/models/v2/initiatives.py new file mode 100644 index 0000000..f078b5a --- /dev/null +++ b/plane/models/v2/initiatives.py @@ -0,0 +1,121 @@ +"""Initiative models for api_v2; `InitiativeLabel` is workspace-level (not nested under an +initiative) -- use `Initiatives.manage_labels` to attach/detach.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +InitiativeState = Literal["DRAFT", "PLANNED", "ACTIVE", "COMPLETED", "CLOSED"] + + +class Initiative(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + description_html: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + state: InitiativeState | None = None + lead_id: str | None = None + label_ids: list[str] | None = None + project_ids: list[str] | None = None + logo_props: Any | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateInitiative(BaseModel): + """POST body. `name` is the only field the API requires. `end_date` must not + be earlier than `start_date` (server-enforced).""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + description_html: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + state: InitiativeState | None = None + lead_id: str | None = None + project_ids: list[str] | None = None + logo_props: Any | None = None + + +class UpdateInitiative(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + description_html: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + state: InitiativeState | None = None + lead_id: str | None = None + project_ids: list[str] | None = None + logo_props: Any | None = None + + +class InitiativeLabel(BaseModel): + """A workspace-level initiative label -- see the module docstring for why this + is not nested under an initiative id.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + color: str | None = None + description: str | None = None + sort_order: float | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateInitiativeLabel(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + color: str | None = None + description: str | None = None + sort_order: float | None = None + + +class UpdateInitiativeLabel(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + color: str | None = None + description: str | None = None + sort_order: float | None = None + + +class InitiativeChildManageRequest(BaseModel): + """POST body shared by `Initiatives.manage_labels` / `.manage_projects` / + `.manage_work_items`: ids to add/remove from that child collection. Both + lists are optional; omit either to leave that side unchanged.""" + + model_config = ConfigDict(extra="ignore") + + add: list[str] | None = None + remove: list[str] | None = None + + +class InitiativeChildManageResponse(BaseModel): + """The ids actually added/removed by one of the `Initiatives` manage-child + actions.""" + + model_config = ConfigDict(extra="allow") + + added: list[str] = [] + removed: list[str] = [] diff --git a/plane/models/v2/intakes.py b/plane/models/v2/intakes.py new file mode 100644 index 0000000..1ff0123 --- /dev/null +++ b/plane/models/v2/intakes.py @@ -0,0 +1,68 @@ +"""Intake work item models for api_v2; named `IntakeWorkItem*` (golden's `IntakeIssue`), `status` +is an int enum (-2..2: pending/rejected/snoozed/accepted/duplicate).""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +IntakeWorkItemPriority = Literal["none", "low", "medium", "high", "urgent"] +IntakeWorkItemStatus = Literal[-2, -1, 0, 1, 2] + + +class IntakeWorkItem(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description_html: str | None = None + priority: str | None = None + status: IntakeWorkItemStatus | None = None + snoozed_till: datetime | None = None + duplicate_to_id: str | None = None + source: str | None = None + source_email: str | None = None + state_id: str | None = None + work_item_id: str | None = None + intake_id: str | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateIntakeWorkItem(BaseModel): + """POST body. See module docstring: the golden marks every field optional here, + though the server requires `name` to actually create a row.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description_html: str | None = None + priority: IntakeWorkItemPriority | None = None + status: IntakeWorkItemStatus | None = None + snoozed_till: datetime | None = None + duplicate_to_id: str | None = None + source: str | None = None + source_email: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateIntakeWorkItem(BaseModel): + """PATCH body -- every field optional. Folds v1's separate status endpoint: + set `status`/`snoozed_till`/`duplicate_to_id` in the same call as any other + field.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description_html: str | None = None + priority: IntakeWorkItemPriority | None = None + status: IntakeWorkItemStatus | None = None + snoozed_till: datetime | None = None + duplicate_to_id: str | None = None + source: str | None = None + source_email: str | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/invitations.py b/plane/models/v2/invitations.py new file mode 100644 index 0000000..aa29efc --- /dev/null +++ b/plane/models/v2/invitations.py @@ -0,0 +1,39 @@ +"""Workspace invitation models for api_v2; read-only + create + bulk-create only -- no PATCH +(invites are accepted or deleted, never edited).""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class WorkspaceInvite(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + email: str | None = None + accepted: bool | None = None + message: str | None = None + role: str | None = None + responded_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateWorkspaceInvite(BaseModel): + """POST body. `email` is required; `role` defaults to `"member"` server-side.""" + + model_config = ConfigDict(extra="ignore") + + email: str + message: str | None = None + role: str | None = None + + +class BulkCreateWorkspaceInvites(BaseModel): + """POST body for `.../invitations/bulk/` -- one call for up to 100 emails.""" + + model_config = ConfigDict(extra="ignore") + + emails: list[str] = Field(min_length=1, max_length=100) + message: str | None = None + role: str | None = None diff --git a/plane/models/v2/labels.py b/plane/models/v2/labels.py new file mode 100644 index 0000000..839732d --- /dev/null +++ b/plane/models/v2/labels.py @@ -0,0 +1,44 @@ +"""Label models for api_v2. Read fields are optional for the same reason as states.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Label(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + color: str | None = None + description: str | None = None + parent_id: str | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateLabel(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str + color: str | None = None + description: str | None = None + parent_id: str | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateLabel(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str | None = None + color: str | None = None + description: str | None = None + parent_id: str | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/members.py b/plane/models/v2/members.py new file mode 100644 index 0000000..3866b8d --- /dev/null +++ b/plane/models/v2/members.py @@ -0,0 +1,47 @@ +"""Member models for api_v2; `Member` backs both `ProjectMembers` and `WorkspaceMembers` +(structurally identical schemas).""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class Member(BaseModel): + """A roster row. `role` is a slug from `role_ref` -- context-correct + (workspace `"member"` vs project `"contributor"`, custom roles, `"owner"`); + null only for legacy rows.""" + + model_config = ConfigDict(extra="allow") + + id: str + member_id: str | None = None + role: str | None = None + + +class CreateProjectMember(BaseModel): + """POST body to add a member to a project. The golden names no required + field, but `member_id` is what makes the call meaningful.""" + + model_config = ConfigDict(extra="ignore") + + member_id: str | None = None + role: str | None = None + + +class UpdateProjectMember(BaseModel): + """PATCH body -- change a project member's role. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + member_id: str | None = None + role: str | None = None + + +class WorkspaceMemberRemove(BaseModel): + """POST body for `WorkspaceMembers.remove` (v1 parity) -- removes by email, + not by member row id; `remove_seat` additionally frees the license seat.""" + + model_config = ConfigDict(extra="ignore") + + email: str + remove_seat: bool = False diff --git a/plane/models/v2/milestone_work_items.py b/plane/models/v2/milestone_work_items.py new file mode 100644 index 0000000..87f798a --- /dev/null +++ b/plane/models/v2/milestone_work_items.py @@ -0,0 +1,29 @@ +"""Milestone work-item link-management models; `milestones_work_items` is a single POST action, not +part of milestone CRUD.""" + +from __future__ import annotations + +import builtins + +from pydantic import BaseModel, ConfigDict, Field + + +class MilestoneWorkItemManageRequest(BaseModel): + """POST body for `.../milestones/{id}/work-items/`: `add` links the listed + work item ids to this milestone, `remove` unlinks them. Both are optional and + may be used together in one call.""" + + model_config = ConfigDict(extra="ignore") + + add: builtins.list[str] = Field(default_factory=list) + remove: builtins.list[str] = Field(default_factory=list) + + +class MilestoneWorkItemManageResponse(BaseModel): + """The work item ids actually added/removed -- idempotent no-ops (e.g. adding + a work item already linked) are omitted from both lists.""" + + model_config = ConfigDict(extra="allow") + + added: builtins.list[str] + removed: builtins.list[str] diff --git a/plane/models/v2/milestones.py b/plane/models/v2/milestones.py new file mode 100644 index 0000000..2b580f7 --- /dev/null +++ b/plane/models/v2/milestones.py @@ -0,0 +1,43 @@ +"""Milestone models for api_v2; the golden's identifying field is `title`, but `?name=` is the list +filter (aliased server-side).""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class Milestone(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + title: str | None = None + target_date: date | None = None + external_id: str | None = None + external_source: str | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateMilestone(BaseModel): + """POST body. `title` is required by the API.""" + + model_config = ConfigDict(extra="ignore") + + title: str + target_date: date | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateMilestone(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + title: str | None = None + target_date: date | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/module_work_items.py b/plane/models/v2/module_work_items.py new file mode 100644 index 0000000..fb03983 --- /dev/null +++ b/plane/models/v2/module_work_items.py @@ -0,0 +1,28 @@ +"""Module work-item link-management models; `modules_work_items_manage` is a single POST action, +not part of module CRUD.""" + +from __future__ import annotations + +import builtins + +from pydantic import BaseModel, ConfigDict, Field + + +class ModuleWorkItemManageRequest(BaseModel): + """POST body for `.../modules/{id}/work-items/`: `add`/`remove` operate only on this module, + each list capped at 100 ids.""" + + model_config = ConfigDict(extra="ignore") + + add: builtins.list[str] = Field(default_factory=list, max_length=100) + remove: builtins.list[str] = Field(default_factory=list, max_length=100) + + +class ModuleWorkItemManageResponse(BaseModel): + """The work item ids actually added/removed -- idempotent no-ops (e.g. adding + a work item already in the module) are omitted from both lists.""" + + model_config = ConfigDict(extra="allow") + + added: builtins.list[str] + removed: builtins.list[str] diff --git a/plane/models/v2/modules.py b/plane/models/v2/modules.py new file mode 100644 index 0000000..150d9b7 --- /dev/null +++ b/plane/models/v2/modules.py @@ -0,0 +1,67 @@ +"""Module models for api_v2; `member_ids` is read-only -- module membership management is out of +scope for this resource.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +ModuleStatus = Literal["backlog", "planned", "in-progress", "paused", "completed", "cancelled"] + + +class Module(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + start_date: date | None = None + target_date: date | None = None + status: ModuleStatus | None = None + lead_id: str | None = None + member_ids: list[str] | None = None + sort_order: float | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateModule(BaseModel): + """POST body. `name` is required by the API; `status` defaults server-side to + `"planned"` when omitted.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + start_date: date | None = None + target_date: date | None = None + """Must not be earlier than `start_date`.""" + status: ModuleStatus | None = None + lead_id: str | None = None + sort_order: float | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateModule(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + start_date: date | None = None + target_date: date | None = None + status: ModuleStatus | None = None + lead_id: str | None = None + sort_order: float | None = None + logo_props: dict[str, object] | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/pages.py b/plane/models/v2/pages.py new file mode 100644 index 0000000..d27e8f1 --- /dev/null +++ b/plane/models/v2/pages.py @@ -0,0 +1,77 @@ +"""Page models for api_v2; `Page` backs both project-scoped and workspace-scoped (wiki) pages via +`ProjectPages`/`WikiPages`.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +PageAccess = Literal[0, 1] # 0 = Public, 1 = Private + + +class Page(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + access: PageAccess | None = None + archived_at: datetime | None = None + collection_id: str | None = None + color: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + description_html: str | None = None + description_stripped: str | None = None + external_id: str | None = None + external_source: str | None = None + is_global: bool | None = None + is_locked: bool | None = None + logo_props: dict[str, object] | None = None + name: str | None = None + owned_by_id: str | None = None + parent_id: str | None = None + sort_order: float | None = None + view_props: dict[str, object] | None = None + + +class CreatePage(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + access: PageAccess | None = None + archived_at: datetime | None = None + collection_id: str | None = None + """Adds the page to a collection on create; omitting `collection_id` auto-assigns the public + default collection, so private pages need an explicit private one.""" + color: str | None = None + description_html: str | None = None + external_id: str | None = None + external_source: str | None = None + is_locked: bool | None = None + logo_props: dict[str, object] | None = None + parent_id: str | None = None + sort_order: float | None = None + view_props: dict[str, object] | None = None + + +class UpdatePage(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + access: PageAccess | None = None + archived_at: datetime | None = None + collection_id: str | None = None + color: str | None = None + description_html: str | None = None + external_id: str | None = None + external_source: str | None = None + is_locked: bool | None = None + logo_props: dict[str, object] | None = None + parent_id: str | None = None + sort_order: float | None = None + view_props: dict[str, object] | None = None diff --git a/plane/models/v2/permission_schemes.py b/plane/models/v2/permission_schemes.py new file mode 100644 index 0000000..631a109 --- /dev/null +++ b/plane/models/v2/permission_schemes.py @@ -0,0 +1,26 @@ +"""Permission scheme models for api_v2.""" + +from __future__ import annotations + +import builtins +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +Namespace = Literal["instance", "workspace", "project"] + + +class PermissionScheme(BaseModel): + """A system or custom permission scheme (v1 external parity); read-only, every field but `id` + optional.""" + + model_config = ConfigDict(extra="allow") + + id: str + description: str | None = None + is_system: bool | None = None + name: str | None = None + namespace: Namespace | None = None + permissions: builtins.list[str] | None = None + slug: str | None = None + sort_order: int | None = None diff --git a/plane/models/v2/permissions.py b/plane/models/v2/permissions.py new file mode 100644 index 0000000..aa0020c --- /dev/null +++ b/plane/models/v2/permissions.py @@ -0,0 +1,11 @@ +"""Effective-permissions model for api_v2 -- the calling principal's own grants for +a workspace or a project. Read-only, no `id` (this row has none).""" + +from pydantic import BaseModel, ConfigDict + + +class EffectivePermissions(BaseModel): + model_config = ConfigDict(extra="allow") + + relation: str | None = None + permission_grants: list[str] | None = None diff --git a/plane/models/v2/project_role_distribution.py b/plane/models/v2/project_role_distribution.py new file mode 100644 index 0000000..aad6c34 --- /dev/null +++ b/plane/models/v2/project_role_distribution.py @@ -0,0 +1,35 @@ +"""Workspace-wide project role distribution model for api_v2 +(`GET /workspaces/{slug}/project-role-distribution/`). +""" + +from __future__ import annotations + +import builtins + +from pydantic import BaseModel, ConfigDict + + +class ProjectRoleDistributionEntry(BaseModel): + """Membership counts for one project role; no `id` field, `role_id` nullable (e.g. a deleted + role) is the closest thing to identity.""" + + model_config = ConfigDict(extra="allow") + + role_id: str | None = None + name: str | None = None + slug: str | None = None + level: int | None = None + is_system: bool | None = None + membership_count: int + distinct_member_count: int + + +class ProjectRoleDistribution(BaseModel): + """Has no `id` field -- this is a single computed report, not an addressable + row. Every field is required per the golden, with no `fields=` support.""" + + model_config = ConfigDict(extra="allow") + + roles: builtins.list[ProjectRoleDistributionEntry] + total_distinct_members: int + total_memberships: int diff --git a/plane/models/v2/projects.py b/plane/models/v2/projects.py new file mode 100644 index 0000000..33b379d --- /dev/null +++ b/plane/models/v2/projects.py @@ -0,0 +1,139 @@ +"""Project models for api_v2; every foreign key (`default_assignee_id`, `project_lead_id`, etc.) is +id-only, no readable-name counterpart.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +Priority = Literal["none", "low", "medium", "high", "urgent"] +Network = Literal[0, 2] +"""0 = Secret (private), 2 = Public.""" + + +class Project(BaseModel): + """A project. `identifier` (e.g. `"ENG"`) is the human-readable key that every + detail route on `Projects` also accepts in place of the UUID `id` -- see + `Projects.retrieve`.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + identifier: str | None = None + network: Network | None = None + priority: Priority | None = None + emoji: str | None = None + icon_prop: dict[str, object] | None = None + cover_image: str | None = None + cover_image_url: str | None = None + logo_props: dict[str, object] | None = None + module_view: bool | None = None + cycle_view: bool | None = None + issue_views_view: bool | None = None + page_view: bool | None = None + intake_view: bool | None = None + guest_view_all_features: bool | None = None + is_time_tracking_enabled: bool | None = None + is_issue_type_enabled: bool | None = None + start_date: datetime | None = None + target_date: datetime | None = None + archive_in: int | None = None + close_in: int | None = None + default_assignee_id: str | None = None + project_lead_id: str | None = None + default_state_id: str | None = None + estimate_id: str | None = None + state_id: str | None = None + timezone: str | None = None + external_id: str | None = None + external_source: str | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateProject(BaseModel): + """POST body; `identifier`/`name` required; `default_state_id`/`estimate_id` only apply once + the project exists (PATCH).""" + + model_config = ConfigDict(extra="ignore") + + identifier: str + name: str + description: str | None = None + network: Network | None = None + priority: Priority | None = None + emoji: str | None = None + icon_prop: dict[str, object] | None = None + cover_image: str | None = None + logo_props: dict[str, object] | None = None + module_view: bool | None = None + cycle_view: bool | None = None + issue_views_view: bool | None = None + page_view: bool | None = None + intake_view: bool | None = None + guest_view_all_features: bool | None = None + is_time_tracking_enabled: bool | None = None + is_issue_type_enabled: bool | None = None + start_date: datetime | None = None + target_date: datetime | None = None + """Must not be earlier than `start_date`.""" + archive_in: int | None = None + close_in: int | None = None + default_assignee_id: str | None = None + project_lead_id: str | None = None + default_state_id: str | None = None + estimate_id: str | None = None + state_id: str | None = None + timezone: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateProject(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + identifier: str | None = None + name: str | None = None + description: str | None = None + network: Network | None = None + priority: Priority | None = None + emoji: str | None = None + icon_prop: dict[str, object] | None = None + cover_image: str | None = None + logo_props: dict[str, object] | None = None + module_view: bool | None = None + cycle_view: bool | None = None + issue_views_view: bool | None = None + page_view: bool | None = None + intake_view: bool | None = None + guest_view_all_features: bool | None = None + is_time_tracking_enabled: bool | None = None + is_issue_type_enabled: bool | None = None + start_date: datetime | None = None + target_date: datetime | None = None + archive_in: int | None = None + close_in: int | None = None + default_assignee_id: str | None = None + project_lead_id: str | None = None + default_state_id: str | None = None + estimate_id: str | None = None + state_id: str | None = None + timezone: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class ProjectSummary(BaseModel): + """`GET .../projects/{pk}/summary/` -- identity plus optional resource counts.""" + + model_config = ConfigDict(extra="allow") + + id: str + identifier: str + name: str + counts: dict[str, int] diff --git a/plane/models/v2/releases.py b/plane/models/v2/releases.py new file mode 100644 index 0000000..415eb88 --- /dev/null +++ b/plane/models/v2/releases.py @@ -0,0 +1,250 @@ +"""Release models for api_v2 (workspace-scoped); label/tag catalogs are distinct from the per- +release association (`ReleaseChildManageRequest`).""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +ReleaseStatus = Literal["unreleased", "released", "cancelled"] + + +class Release(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + status: ReleaseStatus | None = None + description_html: str | None = None + description_id: str | None = None + release_date: date | None = None + target_date: date | None = None + is_latest: bool | None = None + is_prerelease: bool | None = None + lead_id: str | None = None + tag_id: str | None = None + label_ids: list[str] | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateRelease(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + status: ReleaseStatus | None = None + description_html: str | None = None + description_json: object | None = None + release_date: date | None = None + target_date: date | None = None + is_latest: bool | None = None + is_prerelease: bool | None = None + lead_id: str | None = None + tag_id: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateRelease(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + status: ReleaseStatus | None = None + description_html: str | None = None + description_json: object | None = None + release_date: date | None = None + target_date: date | None = None + is_latest: bool | None = None + is_prerelease: bool | None = None + lead_id: str | None = None + tag_id: str | None = None + external_id: str | None = None + external_source: str | None = None + + +# -- Labels (workspace-level catalog) -------------------------------------------- + + +class ReleaseLabel(BaseModel): + """A workspace-level release-label catalog entry -- not the per-release + association (see `Releases.manage_labels`).""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + color: str | None = None + sort_order: int | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateReleaseLabel(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str + color: str | None = None + sort_order: int | None = None + + +class UpdateReleaseLabel(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str | None = None + color: str | None = None + sort_order: int | None = None + + +# -- Tags (workspace-level catalog) ---------------------------------------------- + + +class ReleaseTag(BaseModel): + """A workspace-level release-tag catalog entry; detail routes accept either its UUID or a + `version:`-prefixed lookup.""" + + model_config = ConfigDict(extra="allow") + + id: str + version: str | None = None + description: str | None = None + git_tag: str | None = None + commit_hash: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateReleaseTag(BaseModel): + model_config = ConfigDict(extra="ignore") + + version: str + description: str | None = None + git_tag: str | None = None + commit_hash: str | None = None + + +class UpdateReleaseTag(BaseModel): + model_config = ConfigDict(extra="ignore") + + version: str | None = None + description: str | None = None + git_tag: str | None = None + commit_hash: str | None = None + + +# -- Comments (nested under a release) ------------------------------------------- + + +class ReleaseComment(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + release_id: str | None = None + comment_id: str | None = None + comment_html: str | None = None + is_resolved: bool | None = None + is_hidden: bool | None = None + parent_id: str | None = None + edited_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateReleaseComment(BaseModel): + model_config = ConfigDict(extra="ignore") + + comment_html: str + is_resolved: bool | None = None + parent_id: str | None = None + + +class UpdateReleaseComment(BaseModel): + model_config = ConfigDict(extra="ignore") + + comment_html: str | None = None + is_resolved: bool | None = None + parent_id: str | None = None + + +# -- Links (nested under a release) ---------------------------------------------- + + +class ReleaseLink(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + release_id: str | None = None + title: str | None = None + url: str | None = None + metadata: dict[str, object] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateReleaseLink(BaseModel): + model_config = ConfigDict(extra="ignore") + + title: str + url: str + metadata: dict[str, object] | None = None + + +class UpdateReleaseLink(BaseModel): + model_config = ConfigDict(extra="ignore") + + title: str | None = None + url: str | None = None + metadata: dict[str, object] | None = None + + +# -- Changelog (singleton nested under a release) -------------------------------- + + +class ReleaseChangelog(BaseModel): + """One changelog per release, created implicitly with the release. There is + no create/delete -- only `Releases.changelog.retrieve`/`.update`.""" + + model_config = ConfigDict(extra="allow") + + id: str + release_id: str | None = None + changelog_id: str | None = None + description_html: str | None = None + description_json: object | None = None + + +class UpdateReleaseChangelog(BaseModel): + model_config = ConfigDict(extra="ignore") + + description_html: str | None = None + description_json: object | None = None + + +# -- Child membership manage (labels / work items) -------------------------------- + + +class ReleaseChildManageRequest(BaseModel): + """Body for `Releases.manage_labels`/`.manage_work_items`: ids to attach + and/or detach on the release in one call.""" + + model_config = ConfigDict(extra="ignore") + + add: list[str] | None = None + remove: list[str] | None = None + + +class ReleaseChildManageResult(BaseModel): + """The ids actually added/removed by a `manage_labels`/`manage_work_items` + call.""" + + model_config = ConfigDict(extra="allow") + + added: list[str] = [] + removed: list[str] = [] diff --git a/plane/models/v2/roles.py b/plane/models/v2/roles.py new file mode 100644 index 0000000..0fc4fc2 --- /dev/null +++ b/plane/models/v2/roles.py @@ -0,0 +1,21 @@ +"""Role models for api_v2. Read-only: list + retrieve only, no write surface.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +RoleNamespace = Literal["instance", "workspace", "project"] +RoleStatus = Literal["active", "inactive"] + + +class Role(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + slug: str | None = None + description: str | None = None + level: int | None = None + namespace: RoleNamespace | None = None + status: RoleStatus | None = None + is_system: bool | None = None diff --git a/plane/models/v2/states.py b/plane/models/v2/states.py new file mode 100644 index 0000000..a6b4a55 --- /dev/null +++ b/plane/models/v2/states.py @@ -0,0 +1,55 @@ +"""State models for api_v2; every read field but `id` is optional.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +StateGroup = Literal["backlog", "unstarted", "started", "completed", "cancelled", "triage"] + + +class State(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + color: str | None = None + description: str | None = None + group: StateGroup | None = None + is_default: bool | None = None + is_triage: bool | None = None + sequence: float | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateState(BaseModel): + """POST body. `name` and `color` are required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + color: str + description: str | None = None + group: StateGroup | None = None + is_default: bool | None = None + sequence: float | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateState(BaseModel): + """PATCH body — every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + color: str | None = None + description: str | None = None + group: StateGroup | None = None + is_default: bool | None = None + sequence: float | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/stickies.py b/plane/models/v2/stickies.py new file mode 100644 index 0000000..40ad1c3 --- /dev/null +++ b/plane/models/v2/stickies.py @@ -0,0 +1,47 @@ +"""Sticky-note models for api_v2 (a workspace member's personal sticky notes).""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Sticky(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description_html: str | None = None + description_stripped: str | None = None + color: str | None = None + background_color: str | None = None + logo_props: object | None = None + owner_id: str | None = None + sort_order: float | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateSticky(BaseModel): + """POST body. Every field is optional -- an empty body creates a blank sticky.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description_html: str | None = None + color: str | None = None + background_color: str | None = None + logo_props: object | None = None + sort_order: float | None = None + + +class UpdateSticky(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description_html: str | None = None + color: str | None = None + background_color: str | None = None + logo_props: object | None = None + sort_order: float | None = None diff --git a/plane/models/v2/teamspaces.py b/plane/models/v2/teamspaces.py new file mode 100644 index 0000000..99d184c --- /dev/null +++ b/plane/models/v2/teamspaces.py @@ -0,0 +1,45 @@ +"""Teamspace models for api_v2 (a named grouping of members + projects).""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Teamspace(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description_html: str | None = None + lead_id: str | None = None + logo_props: object | None = None + member_ids: list[str] | None = None + project_ids: list[str] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateTeamspace(BaseModel): + """POST body. `name` is required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description_html: str | None = None + lead_id: str | None = None + logo_props: object | None = None + member_ids: list[str] | None = None + project_ids: list[str] | None = None + + +class UpdateTeamspace(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description_html: str | None = None + lead_id: str | None = None + logo_props: object | None = None + member_ids: list[str] | None = None + project_ids: list[str] | None = None diff --git a/plane/models/v2/users.py b/plane/models/v2/users.py new file mode 100644 index 0000000..6ad3313 --- /dev/null +++ b/plane/models/v2/users.py @@ -0,0 +1,23 @@ +"""Calling-principal model for api_v2 (`GET /users/me/`).""" + +from __future__ import annotations + +import builtins +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +PrincipalKind = Literal["oauth", "api_key", "other"] + + +class WhoAmI(BaseModel): + """The authenticated principal (API key or OAuth token, not necessarily human); + `principal_kind`/`scopes` distinguish the two.""" + + model_config = ConfigDict(extra="allow") + + id: str + display_name: str | None = None + email: str | None = None + principal_kind: PrincipalKind | None = None + scopes: builtins.list[str] | None = None diff --git a/plane/models/v2/views.py b/plane/models/v2/views.py new file mode 100644 index 0000000..875b30f --- /dev/null +++ b/plane/models/v2/views.py @@ -0,0 +1,71 @@ +"""Saved-view models for api_v2; named `View`/`CreateView`/`UpdateView` here though the golden's +schema name is `IssueView`.""" + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +Access = Literal[0, 1] +"""0 = Private, 1 = Public.""" + + +class View(BaseModel): + """A saved filter/layout. Project-scoped (`Views.project`) or + workspace-scoped (`Views.workspace`, where the underlying project is NULL) -- + same shape either way.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + query: object | None = None + filters: object | None = None + display_filters: object | None = None + display_properties: object | None = None + pql_filters: object | None = None + logo_props: dict[str, object] | None = None + is_locked: bool | None = None + access: Access | None = None + sort_order: float | None = None + owned_by_id: str | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateView(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + query: object | None = None + filters: object | None = None + display_filters: object | None = None + display_properties: object | None = None + pql_filters: object | None = None + logo_props: dict[str, object] | None = None + is_locked: bool | None = None + access: Access | None = None + sort_order: float | None = None + + +class UpdateView(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + query: object | None = None + filters: object | None = None + display_filters: object | None = None + display_properties: object | None = None + pql_filters: object | None = None + logo_props: dict[str, object] | None = None + is_locked: bool | None = None + access: Access | None = None + sort_order: float | None = None diff --git a/plane/models/v2/webhook_logs.py b/plane/models/v2/webhook_logs.py new file mode 100644 index 0000000..9c5c153 --- /dev/null +++ b/plane/models/v2/webhook_logs.py @@ -0,0 +1,28 @@ +"""Webhook delivery log models for api_v2.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WebhookLog(BaseModel): + """A webhook delivery attempt (`WebhookEvent`); read-only, every field but `id` optional.""" + + model_config = ConfigDict(extra="allow") + + id: str + created_at: datetime | None = None + duration_ms: int | None = None + error_message: str | None = None + event_type: str | None = None + request_body: str | None = None + request_headers: str | None = None + request_method: str | None = None + response_body: str | None = None + response_headers: str | None = None + response_status: str | None = None + retry_count: int | None = None + status_text: str | None = None + webhook_id: str | None = None diff --git a/plane/models/v2/webhooks.py b/plane/models/v2/webhooks.py new file mode 100644 index 0000000..74c0683 --- /dev/null +++ b/plane/models/v2/webhooks.py @@ -0,0 +1,69 @@ +"""Webhook models for api_v2.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +WebhookContentType = Literal["application/json", "application/x-www-form-urlencoded"] + + +class Webhook(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + content_type: WebhookContentType | None = None + created_at: datetime | None = None + created_by_id: str | None = None + is_active: bool | None = None + name: str | None = None + scopes: list[str] | None = None + url: str | None = None + version: str | None = None + + +class CreateWebhook(BaseModel): + """POST body. The golden names no required field for `WebhookWriteRequest`, + but `url` is what makes the call meaningful.""" + + model_config = ConfigDict(extra="ignore") + + url: str | None = None + content_type: WebhookContentType | None = None + is_active: bool | None = None + name: str | None = None + scopes: list[str] | None = None + version: str | None = None + + +class UpdateWebhook(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + url: str | None = None + content_type: WebhookContentType | None = None + is_active: bool | None = None + name: str | None = None + scopes: list[str] | None = None + version: str | None = None + + +class WebhookCreateResult(BaseModel): + """Response to `create`/`regenerate`; carries `secret_key` once and never again -- modeled on + the golden's `WebhookCreateResponse` component, not independently verified live.""" + + model_config = ConfigDict(extra="allow") + + id: str + secret_key: str + content_type: WebhookContentType | None = None + created_at: datetime | None = None + created_by_id: str | None = None + is_active: bool | None = None + name: str | None = None + scopes: list[str] | None = None + url: str | None = None + version: str | None = None diff --git a/plane/models/v2/work_item_properties.py b/plane/models/v2/work_item_properties.py new file mode 100644 index 0000000..df06a0f --- /dev/null +++ b/plane/models/v2/work_item_properties.py @@ -0,0 +1,208 @@ +"""Work item property models for api_v2 -- properties/options/contexts split project-scoped vs +workspace-scoped; `issue_type_ids` is the golden's own upstream field name.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +WorkItemPropertyType = Literal[ + "TEXT", + "DATETIME", + "DECIMAL", + "BOOLEAN", + "OPTION", + "RELATION", + "URL", + "EMAIL", + "FILE", + "FORMULA", +] +WorkItemPropertyRelationType = Literal["ISSUE", "USER", "RELEASE", "RICH_TEXT"] + + +class WorkItemProperty(BaseModel): + """A custom property definition. `options` is inlined for OPTION-type + properties -- see also `WorkItemPropertyOptions` for managing them directly.""" + + model_config = ConfigDict(extra="allow") + + id: str + created_at: datetime | None = None + default_value: list[str] | None = None + description: str | None = None + display_name: str | None = None + external_id: str | None = None + external_source: str | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + logo_props: Any | None = None + name: str | None = None + options: list[Any] | None = None + property_type: WorkItemPropertyType | None = None + relation_type: WorkItemPropertyRelationType | None = None + settings: Any | None = None + validation_rules: Any | None = None + + +class CreateWorkItemPropertyOption(BaseModel): + """POST body for a standalone option, or one entry of + `CreateWorkItemProperty.options` seeding OPTION values on create.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + external_id: str | None = None + external_source: str | None = None + is_default: bool | None = None + + +class CreateWorkItemProperty(BaseModel): + """POST body; `display_name`/`property_type` required, `options` (write-only) seeds OPTION + values on create.""" + + model_config = ConfigDict(extra="ignore") + + display_name: str + property_type: WorkItemPropertyType + default_value: list[str] | None = None + description: str | None = None + external_id: str | None = None + external_source: str | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + options: list[CreateWorkItemPropertyOption] | None = None + relation_type: WorkItemPropertyRelationType | None = None + settings: Any | None = None + validation_rules: Any | None = None + + +class UpdateWorkItemProperty(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + default_value: list[str] | None = None + description: str | None = None + display_name: str | None = None + external_id: str | None = None + external_source: str | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + options: list[CreateWorkItemPropertyOption] | None = None + property_type: WorkItemPropertyType | None = None + relation_type: WorkItemPropertyRelationType | None = None + settings: Any | None = None + validation_rules: Any | None = None + + +class WorkItemPropertyOption(BaseModel): + """An OPTION-type property's choice.""" + + model_config = ConfigDict(extra="allow") + + id: str + description: str | None = None + external_id: str | None = None + external_source: str | None = None + is_default: bool | None = None + name: str | None = None + sort_order: float | None = None + + +class UpdateWorkItemPropertyOption(BaseModel): + """PATCH body -- every field optional.""" + + model_config = ConfigDict(extra="ignore") + + description: str | None = None + external_id: str | None = None + external_source: str | None = None + is_default: bool | None = None + name: str | None = None + + +class WorkItemPropertyContext(BaseModel): + """Read shape: a workspace property's per-scope override (which projects and + work item types it applies to, plus its default value/requiredness there) with + the resolved project/type/option lists inlined.""" + + model_config = ConfigDict(extra="allow") + + id: str + applies_to_all_projects: bool | None = None + applies_to_all_work_item_types: bool | None = None + created_at: datetime | None = None + default_value: list[str] | None = None + external_id: str | None = None + external_source: str | None = None + is_default: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + issue_type_ids: list[Any] | None = None # golden's own field name; pass-through + name: str | None = None + options: list[Any] | None = None + project_ids: list[Any] | None = None + settings: Any | None = None + sort_order: float | None = None + + +class WorkItemPropertyContextOptionInput(BaseModel): + """One entry of `CreateWorkItemPropertyContext.options`. `id` reconciles an + existing option; omit it to create a new one from `name`.""" + + model_config = ConfigDict(extra="ignore") + + id: str | None = None + name: str | None = None + description: str | None = None + is_default: bool | None = None + sort_order: float | None = None + + +class CreateWorkItemPropertyContext(BaseModel): + """POST body. `issue_type_ids` (golden's own field name) and `project_ids` are + write-only id lists; leave both unset, or set the matching `applies_to_all_*` + flag, to apply everywhere.""" + + model_config = ConfigDict(extra="ignore") + + applies_to_all_projects: bool | None = None + applies_to_all_work_item_types: bool | None = None + default_value: list[str] | None = None + external_id: str | None = None + external_source: str | None = None + is_multi: bool | None = None + is_required: bool | None = None + issue_type_ids: list[str] | None = None + name: str | None = None + options: list[WorkItemPropertyContextOptionInput] | None = None + project_ids: list[str] | None = None + settings: Any | None = None + sort_order: float | None = None + + +class UpdateWorkItemPropertyContext(BaseModel): + """PATCH body -- same shape as `CreateWorkItemPropertyContext`; v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + applies_to_all_projects: bool | None = None + applies_to_all_work_item_types: bool | None = None + default_value: list[str] | None = None + external_id: str | None = None + external_source: str | None = None + is_multi: bool | None = None + is_required: bool | None = None + issue_type_ids: list[str] | None = None + name: str | None = None + options: list[WorkItemPropertyContextOptionInput] | None = None + project_ids: list[str] | None = None + settings: Any | None = None + sort_order: float | None = None diff --git a/plane/models/v2/work_item_relation_definitions.py b/plane/models/v2/work_item_relation_definitions.py new file mode 100644 index 0000000..7f91488 --- /dev/null +++ b/plane/models/v2/work_item_relation_definitions.py @@ -0,0 +1,59 @@ +"""Work item relation definitions (e.g. `blocks`/`blocked_by`); `is_default` is system-managed and +absent from write DTOs.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WorkItemRelationDefinition(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + inward: str | None = None + outward: str | None = None + color: str | None = None + description: str | None = None + is_active: bool | None = None + is_default: bool | None = None + logo_props: object | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateWorkItemRelationDefinition(BaseModel): + """POST body. `name`, `inward` and `outward` are required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + inward: str + outward: str + color: str | None = None + description: str | None = None + is_active: bool | None = None + logo_props: object | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateWorkItemRelationDefinition(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + inward: str | None = None + outward: str | None = None + color: str | None = None + description: str | None = None + is_active: bool | None = None + logo_props: object | None = None + sort_order: float | None = None + external_id: str | None = None + external_source: str | None = None diff --git a/plane/models/v2/work_item_templates.py b/plane/models/v2/work_item_templates.py new file mode 100644 index 0000000..7179b2c --- /dev/null +++ b/plane/models/v2/work_item_templates.py @@ -0,0 +1,76 @@ +"""Work item template models for api_v2; every read field but `id` is optional.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from .work_items import Priority + + +class WorkItemTemplateData(BaseModel): + """Seed payload for a template's work item; + `assignees`/`labels`/`modules`/`properties`/`state`/`type` are schemaless, left as opaque + JSON.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description_html: str | None = None + priority: Priority | None = None + assignees: object | None = None + labels: object | None = None + modules: object | None = None + properties: object | None = None + state: object | None = None + type: object | None = None + + +class WorkItemTemplate(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description_html: str | None = None + short_description: str | None = None + short_id: str | None = None + slug: str | None = None + template_type: str | None = None + template_data: dict[str, object] | None = None + is_published: bool | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateWorkItemTemplate(BaseModel): + """POST body. `name` and `template_data` are required by the API.""" + + model_config = ConfigDict(extra="ignore") + + name: str + template_data: WorkItemTemplateData + description_html: str | None = None + short_description: str | None = None + is_published: bool | None = None + + +class UpdateWorkItemTemplate(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + template_data: WorkItemTemplateData | None = None + description_html: str | None = None + short_description: str | None = None + is_published: bool | None = None + + +class WorkItemTemplateUse(BaseModel): + """Body for `POST .../work-item-templates/{id}/use/` -- optional overrides. + Both fields are optional; omit either to take the template's own value (and, + for `project_id`, the project already named in the URL).""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + project_id: str | None = None diff --git a/plane/models/v2/work_item_types.py b/plane/models/v2/work_item_types.py new file mode 100644 index 0000000..4be1bde --- /dev/null +++ b/plane/models/v2/work_item_types.py @@ -0,0 +1,131 @@ +"""Work item type + property models for api_v2; `WorkItemType` carries no +`external_id`/`external_source` output fields despite the write DTO accepting them.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +WorkItemPropertyType = Literal[ + "TEXT", + "DATETIME", + "DECIMAL", + "BOOLEAN", + "OPTION", + "RELATION", + "URL", + "EMAIL", + "FILE", + "FORMULA", +] +WorkItemPropertyRelationType = Literal["ISSUE", "USER", "RELEASE", "RICH_TEXT"] + + +class WorkItemType(BaseModel): + """Read-only work item type -- authored in the app/admin; v2 only reads it so a + caller can pick a `type_id` and fetch its writable-field schema via `schema()`.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + is_active: bool | None = None + is_default: bool | None = None + is_epic: bool | None = None + level: float | None = None + logo_props: Any | None = None + created_at: datetime | None = None + + +class CreateWorkItemType(BaseModel): + """POST body; only name/description/is_active/external_id/external_source are client-writable + -- `logo_props`/`is_default`/`is_epic`/`level` are server-managed.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + is_active: bool | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateWorkItemType(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + is_active: bool | None = None + external_id: str | None = None + external_source: str | None = None + + +class WorkItemTypeImport(BaseModel): + """Body for `work_item_types_import`: ids of the (global/system) work item + types to enable on this project.""" + + model_config = ConfigDict(extra="ignore") + + work_item_types: list[str] + + +class WorkItemTypeSchema(BaseModel): + """The `schema` action's response: writable standard fields plus custom properties for building + a create/update form; every field is defensively optional.""" + + model_config = ConfigDict(extra="allow") + + type_id: str | None = None + type_name: str | None = None + type_description: str | None = None + type_logo_props: Any | None = None + fields: Any | None = None + custom_fields: Any | None = None + + +class WorkItemProperty(BaseModel): + """Read-only custom property definition attached to a work item type. + `options` is inlined for OPTION-type properties.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + display_name: str | None = None + description: str | None = None + property_type: WorkItemPropertyType | None = None + relation_type: WorkItemPropertyRelationType | None = None + is_active: bool | None = None + is_multi: bool | None = None + is_required: bool | None = None + default_value: list[str] | None = None + options: list[Any] | None = None + settings: Any | None = None + validation_rules: Any | None = None + logo_props: Any | None = None + external_id: str | None = None + external_source: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class WorkItemPropertyAttach(BaseModel): + """Body for attaching one or more existing property definitions to a work item + type (project- or workspace-scoped -- same shape both places).""" + + model_config = ConfigDict(extra="ignore") + + properties: list[str] + + +class WorkItemPropertyAttachResult(BaseModel): + """Response of an attach call: the full set of property ids now attached.""" + + model_config = ConfigDict(extra="allow") + + properties: list[str] = Field(default_factory=list) diff --git a/plane/models/v2/work_items.py b/plane/models/v2/work_items.py new file mode 100644 index 0000000..668dc37 --- /dev/null +++ b/plane/models/v2/work_items.py @@ -0,0 +1,328 @@ +"""Work item models for api_v2; `CreateWorkItem`/`UpdateWorkItem` accept both `*_id`/`*_ids` and +readable-name forms for state/assignees/labels/parent/type -- prefer readable.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +Priority = Literal["none", "low", "medium", "high", "urgent"] +CommentAccess = Literal["INTERNAL", "EXTERNAL"] +DependencyRelationType = Literal[ + "blocked_by", "blocking", "start_before", "start_after", "finish_before", "finish_after" +] + + +class WorkItem(BaseModel): + """A work item. `identifier` (e.g. `"ENG-12"`) is the human-readable key -- + see `WorkItems.retrieve_by_identifier` to fetch by it directly.""" + + model_config = ConfigDict(extra="allow") + + id: str + archived_at: datetime | None = None + assignee_ids: list[str] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + custom_fields: dict[str, object] | None = None + cycle_id: str | None = None + identifier: str | None = None + is_draft: bool | None = None + label_ids: list[str] | None = None + module_ids: list[str] | None = None + name: str | None = None + parent_id: str | None = None + priority: Priority | None = None + project_id: str | None = None + sequence_id: int | None = None + start_date: date | None = None + state_id: str | None = None + target_date: date | None = None + type_id: str | None = None + + +class CreateWorkItem(BaseModel): + """POST body; `name` is the only required field -- prefer readable fields (`state`, + `assignees`, `labels`, `parent`, `type`) over their id counterparts.""" + + model_config = ConfigDict(extra="ignore") + + name: str + assignee_ids: list[str] | None = None + assignees: list[str] | None = None + """Readable form of `assignee_ids`: member emails (or other identifying values + the API resolves).""" + custom_fields: dict[str, object] | None = None + cycle_id: str | None = None + description_html: str | None = None + estimate: str | None = None + estimate_point_id: str | None = None + external_id: str | None = None + external_source: str | None = None + label_ids: list[str] | None = None + labels: list[str] | None = None + """Readable form of `label_ids`: label names.""" + module_ids: list[str] | None = None + parent: str | None = None + """Readable form of `parent_id`: the parent work item's `identifier` (e.g. + `"ENG-3"`).""" + parent_id: str | None = None + priority: Priority | None = None + start_date: date | None = None + state: str | None = None + """Readable form of `state_id`: the state's name.""" + state_id: str | None = None + target_date: date | None = None + type: str | None = None + """Readable form of `type_id`: the work item type's name.""" + type_id: str | None = None + + +class UpdateWorkItem(BaseModel): + """PATCH body -- every field optional. v2 has no PUT. Same readable/`*_id` + field pairs as `CreateWorkItem`.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + assignee_ids: list[str] | None = None + assignees: list[str] | None = None + custom_fields: dict[str, object] | None = None + cycle_id: str | None = None + description_html: str | None = None + estimate: str | None = None + estimate_point_id: str | None = None + external_id: str | None = None + external_source: str | None = None + label_ids: list[str] | None = None + labels: list[str] | None = None + module_ids: list[str] | None = None + parent: str | None = None + parent_id: str | None = None + priority: Priority | None = None + start_date: date | None = None + state: str | None = None + state_id: str | None = None + target_date: date | None = None + type: str | None = None + type_id: str | None = None + + +# -- Comments ----------------------------------------------------------------- + + +class WorkItemComment(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + access: CommentAccess | None = None + actor_id: str | None = None + comment_html: str | None = None + comment_stripped: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + edited_at: datetime | None = None + external_id: str | None = None + external_source: str | None = None + work_item_id: str | None = None + + +class CreateWorkItemComment(BaseModel): + model_config = ConfigDict(extra="ignore") + + comment_html: str + access: CommentAccess | None = None + external_id: str | None = None + external_source: str | None = None + + +class UpdateWorkItemComment(BaseModel): + model_config = ConfigDict(extra="ignore") + + comment_html: str | None = None + access: CommentAccess | None = None + external_id: str | None = None + external_source: str | None = None + + +# -- Attachments ---------------------------------------------------------------- + + +class WorkItemAttachment(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + asset_url: str | None = None + attributes: dict[str, object] | None = None + content_type: str | None = None + created_at: datetime | None = None + created_by_id: str | None = None + external_id: str | None = None + external_source: str | None = None + is_uploaded: bool | None = None + name: str | None = None + size: float | None = None + work_item_id: str | None = None + + +class CreateWorkItemAttachment(BaseModel): + """POST body. This registers the attachment's metadata (name, size, content + type); the returned `asset_url` is where the caller then uploads the file + itself, and a follow-up PATCH (`WorkItemAttachmentConfirm`) marks it uploaded.""" + + model_config = ConfigDict(extra="ignore") + + name: str + size: int + type: str | None = None + external_id: str | None = None + external_source: str | None = None + + +class WorkItemAttachmentConfirm(BaseModel): + """PATCH body -- confirms the out-of-band upload completed.""" + + model_config = ConfigDict(extra="ignore") + + is_uploaded: bool | None = None + + +class WorkItemAttachmentUploadResult(BaseModel): + """The real response to `POST .../attachments/`: the golden documents a bare + `WorkItemAttachment`, but the live server returns `{upload_data, attachment}`.""" + + model_config = ConfigDict(extra="allow") + + asset_id: str + asset_url: str + upload_data: dict[str, object] + attachment: WorkItemAttachment + + +# -- Links ------------------------------------------------------------------ + + +class WorkItemLink(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + created_at: datetime | None = None + created_by_id: str | None = None + metadata: dict[str, object] | None = None + title: str | None = None + url: str | None = None + work_item_id: str | None = None + + +class CreateWorkItemLink(BaseModel): + model_config = ConfigDict(extra="ignore") + + url: str + title: str | None = None + metadata: dict[str, object] | None = None + + +class UpdateWorkItemLink(BaseModel): + model_config = ConfigDict(extra="ignore") + + url: str | None = None + title: str | None = None + metadata: dict[str, object] | None = None + + +# -- Worklogs ----------------------------------------------------------------- + + +class WorkItemWorklog(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + created_at: datetime | None = None + created_by_id: str | None = None + description: str | None = None + duration: int | None = None + logged_by_id: str | None = None + updated_at: datetime | None = None + work_item_id: str | None = None + + +class CreateWorkItemWorklog(BaseModel): + model_config = ConfigDict(extra="ignore") + + duration: int + description: str | None = None + + +class UpdateWorkItemWorklog(BaseModel): + model_config = ConfigDict(extra="ignore") + + duration: int | None = None + description: str | None = None + + +# -- Activities (read-only) ------------------------------------------------------ + + +class WorkItemActivity(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + actor_id: str | None = None + comment: str | None = None + created_at: datetime | None = None + duration: int | None = None + epoch: float | None = None + external_id: str | None = None + external_source: str | None = None + field: str | None = None + issue_comment_id: str | None = None # upstream field name, passed through verbatim + new_identifier_id: str | None = None + new_value: str | None = None + old_identifier_id: str | None = None + old_value: str | None = None + verb: str | None = None + work_item_id: str | None = None + + +# -- Relations ---------------------------------------------------------------- + + +class WorkItemRelationList(BaseModel): + """Related work item ids grouped by direction label (dynamic mapping, access via + `.model_extra`); each value is a `list[str]` of ids.""" + + model_config = ConfigDict(extra="allow") + + +class WorkItemRelationCreate(BaseModel): + model_config = ConfigDict(extra="ignore") + + direction: str + relation_definition_id: str + work_item_ids: list[str] + + +# -- Dependencies --------------------------------------------------------------- + + +class WorkItemDependencyList(BaseModel): + """The six fixed dependency directions. Always present in the response, each + a (possibly empty) list of related work item ids.""" + + model_config = ConfigDict(extra="allow") + + blocked_by: list[str] = [] + blocking: list[str] = [] + start_after: list[str] = [] + start_before: list[str] = [] + finish_after: list[str] = [] + finish_before: list[str] = [] + + +class WorkItemDependencyCreate(BaseModel): + model_config = ConfigDict(extra="ignore") + + relation_type: DependencyRelationType + work_item_ids: list[str] diff --git a/plane/models/v2/workflows.py b/plane/models/v2/workflows.py new file mode 100644 index 0000000..b88b1a8 --- /dev/null +++ b/plane/models/v2/workflows.py @@ -0,0 +1,127 @@ +"""Workflow models for api_v2. Golden `$ref` mismatch: `workflow_states_create` accepts +`{state_ids}` per prose, but `WorkflowStateWriteRequest` only declares +`type`/`is_default`/`allow_issue_creation` -- modeled on v1's `AttachWorkflowStates` instead.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Workflow(BaseModel): + """A project's work-item-type workflow configuration.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + description: str | None = None + is_active: bool | None = None + is_default: bool | None = None + work_item_type_ids: list[str] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateWorkflow(BaseModel): + """POST body. `name` is the only field the API requires.""" + + model_config = ConfigDict(extra="ignore") + + name: str + description: str | None = None + is_active: bool | None = None + work_item_type_ids: list[str] | None = None + + +class UpdateWorkflow(BaseModel): + """PATCH body -- every field optional. v2 has no PUT.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = None + description: str | None = None + is_active: bool | None = None + work_item_type_ids: list[str] | None = None + + +class WorkflowState(BaseModel): + """A project `State` attached to a workflow, plus this workflow's own flags + for it. `state_id` is the underlying project State's id (matching v1).""" + + model_config = ConfigDict(extra="allow") + + id: str + workflow_id: str | None = None + state_id: str | None = None + type: str | None = None + is_default: bool | None = None + allow_issue_creation: bool | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class WorkflowStateCreate(BaseModel): + """POST body for `workflow_states_create`: bulk-attach existing project states + to this workflow by id. See the module docstring for why this does not match + the golden's own `WorkflowStateWriteRequest` component.""" + + model_config = ConfigDict(extra="ignore") + + state_ids: list[str] + + +class UpdateWorkflowState(BaseModel): + """PATCH body for an already-attached workflow state (type / is_default / + allow_issue_creation) -- every field optional.""" + + model_config = ConfigDict(extra="ignore") + + type: str | None = None + is_default: bool | None = None + allow_issue_creation: bool | None = None + + +class WorkflowTransition(BaseModel): + """One allowed transition out of a workflow state, with optional approval + gating (`required_approvals`, `member_ids`) and an optional rejection target.""" + + model_config = ConfigDict(extra="allow") + + id: str + workflow_state_id: str | None = None + transition_state_id: str | None = None + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + + +class CreateWorkflowTransition(BaseModel): + """POST body. `state_id` identifies the source workflow state (project State + id, matching v1) and is effectively required on create, though the golden does + not mark any field of this shared create/patch schema as required.""" + + model_config = ConfigDict(extra="ignore") + + state_id: str | None = None + transition_state_id: str | None = None + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None + + +class UpdateWorkflowTransition(BaseModel): + """PATCH body -- every field optional. Same shape as `CreateWorkflowTransition` + (the golden reuses one schema, `PatchedWorkflowTransitionWriteRequest`, for + both create and patch).""" + + model_config = ConfigDict(extra="ignore") + + state_id: str | None = None + transition_state_id: str | None = None + rejection_state_id: str | None = None + required_approvals: int | None = None + member_ids: list[str] | None = None diff --git a/plane/models/v2/worklogs_summary.py b/plane/models/v2/worklogs_summary.py new file mode 100644 index 0000000..ff88066 --- /dev/null +++ b/plane/models/v2/worklogs_summary.py @@ -0,0 +1,17 @@ +"""Per-work-item worklog duration totals for api_v2 +(`GET .../projects/{project_id}/worklogs/summary/`). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class WorklogSummaryEntry(BaseModel): + """One summary row; `work_item_id` is the only identity (no `id` field), and both fields are + required (no `fields=` support).""" + + model_config = ConfigDict(extra="allow") + + work_item_id: str + duration: int diff --git a/pyproject.toml b/pyproject.toml index 4183980..e830dd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.23" +version = "0.3.0" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/generate_v2_constants.py b/scripts/generate_v2_constants.py new file mode 100644 index 0000000..020dfee --- /dev/null +++ b/scripts/generate_v2_constants.py @@ -0,0 +1,143 @@ +"""Regenerates `plane/api/v2/_generated/constants.py` from the api_v2 OpenAPI golden; rerun via +`python scripts/generate_v2_constants.py `.""" + +import json +import pathlib +import sys +from typing import Any + +HEADER_TEMPLATE = '''\ +"""Generated from the api_v2 OpenAPI golden (version {api_version}) -- never hand-edited. +Source: {source_dir} +Regenerate: python scripts/generate_v2_constants.py /api_v2/core/schema/openapi""" +''' + +LINE_LENGTH = 100 + + +def _format(source: str) -> str: + """Format generated source with Black so the committed file matches what this + command alone produces — no separate formatting step for a maintainer to forget. + """ + try: + import black + except ImportError as exc: + raise SystemExit( + "generate_v2_constants: black is required to format the generated file " + "but is not installed in this interpreter. Run `pip install -r requirements.txt` " + "and retry." + ) from exc + + mode = black.Mode(line_length=LINE_LENGTH) + try: + return black.format_str(source, mode=mode) + except Exception as exc: + raise SystemExit(f"generate_v2_constants: black failed to format output: {exc}") from exc + + +def main(openapi_dir: str) -> None: + root = pathlib.Path(openapi_dir) + + root_document = json.loads((root / "root.json").read_text()) + api_version = root_document.get("info", {}).get("version") + if not api_version: + raise SystemExit( + "generate_v2_constants: root.json has no info.version — " + "the golden's shape changed, update this generator before trusting its output." + ) + + paths: dict[str, dict[str, Any]] = {} + for shard in sorted((root / "paths").glob("*.json")): + document = json.loads(shard.read_text()) + paths.update(document.get("paths", document)) + + operation_ids: set[str] = set() + fields: dict[str, list[str]] = {} + order_by: dict[str, list[str]] = {} + expand: dict[str, list[str]] = {} + for operations in paths.values(): + for method, operation in operations.items(): + if method not in {"get", "post", "patch", "delete", "put"}: + continue + operation_id = operation.get("operationId") + if not operation_id: + continue + operation_ids.add(operation_id) + for parameter in operation.get("parameters", []): + enum = parameter.get("schema", {}).get("enum") + if not enum: + continue + if parameter["name"] == "fields": + fields[operation_id] = sorted(enum) + elif parameter["name"] == "order_by": + order_by[operation_id] = sorted(enum) + elif parameter["name"] == "expand": + expand[operation_id] = sorted(enum) + + components = json.loads((root / "components.json").read_text())["schemas"] + + delete_request = components.get("BulkDeleteRequest") + if delete_request is None: + raise SystemExit( + "generate_v2_constants: components.json has no 'BulkDeleteRequest' schema — " + "the golden's shape changed, update this generator before trusting its output." + ) + bulk_max = delete_request.get("properties", {}).get("ids", {}).get("maxItems") + if bulk_max is None: + raise SystemExit( + "generate_v2_constants: BulkDeleteRequest.properties.ids.maxItems is missing — " + "the golden's shape changed, update this generator before trusting its output." + ) + + problem_detail = components.get("ProblemDetail") + if problem_detail is None: + raise SystemExit( + "generate_v2_constants: components.json has no 'ProblemDetail' schema — " + "the golden's shape changed, update this generator before trusting its output." + ) + codes = sorted(problem_detail.get("properties", {}).get("code", {}).get("x-enum", [])) + if not codes: + raise SystemExit( + "generate_v2_constants: ProblemDetail.properties.code.x-enum is empty — " + "the golden's shape changed, update this generator before trusting its output." + ) + + # Record the argument as passed, not resolved to an absolute path: this keeps + # regeneration byte-identical regardless of the caller's cwd, and matches the + # relative form the module's own usage example recommends. + header = HEADER_TEMPLATE.format(source_dir=openapi_dir, api_version=api_version) + lines = [ + header, + f"API_VERSION = {api_version!r}\n\n", + f"BULK_MAX_ITEMS = {bulk_max}\n\n", + "OPERATION_IDS: frozenset[str] = frozenset(\n", + f" {sorted(operation_ids)!r}\n", + ")\n\n", + "FIELDS: dict[str, frozenset[str]] = {\n", + ] + for operation_id, values in sorted(fields.items()): + lines.append(f" {operation_id!r}: frozenset({values!r}),\n") + lines.append("}\n\nORDER_BY: dict[str, frozenset[str]] = {\n") + for operation_id, values in sorted(order_by.items()): + lines.append(f" {operation_id!r}: frozenset({values!r}),\n") + lines.append("}\n\nEXPAND: dict[str, frozenset[str]] = {\n") + for operation_id, values in sorted(expand.items()): + lines.append(f" {operation_id!r}: frozenset({values!r}),\n") + lines.append("}\n\n") + lines.append(f"ERROR_CODES: frozenset[str] = frozenset({codes!r})\n") + + content = _format("".join(lines)) + + target = pathlib.Path(__file__).parent.parent / "plane" / "api" / "v2" / "_generated" + target.mkdir(parents=True, exist_ok=True) + (target / "__init__.py").write_text("") + (target / "constants.py").write_text(content) + print( + f"wrote {len(operation_ids)} operation ids, {len(fields)} field enums, " + f"{len(order_by)} order_by enums, {len(expand)} expand enums, bulk_max={bulk_max}, " + f"{len(codes)} error codes, api_version={api_version}" + ) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py new file mode 100644 index 0000000..3907fb2 --- /dev/null +++ b/tests/v2/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from plane.config import Configuration + + +@pytest.fixture +def config() -> Configuration: + return Configuration(base_path="https://api.example.com", api_key="secret") diff --git a/tests/v2/fixtures/__init__.py b/tests/v2/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/fixtures/_hidden/__init__.py b/tests/v2/fixtures/_hidden/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/fixtures/_hidden/resource.py b/tests/v2/fixtures/_hidden/resource.py new file mode 100644 index 0000000..30467d5 --- /dev/null +++ b/tests/v2/fixtures/_hidden/resource.py @@ -0,0 +1,13 @@ +"""Dummy `V2Resource` under an underscore-prefixed subpackage; must stay excluded from discovery +even when the walk recurses.""" + +from __future__ import annotations + +from plane.api.v2._kernel.resource import V2Resource +from plane.models.v2.states import CreateState, State, UpdateState + + +class ShouldNotBeDiscovered(V2Resource[State, CreateState, UpdateState]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = State + operations = {"list": "not_a_real_operation_id"} diff --git a/tests/v2/fixtures/nested/__init__.py b/tests/v2/fixtures/nested/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/fixtures/nested/deep/__init__.py b/tests/v2/fixtures/nested/deep/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/fixtures/nested/deep/resource.py b/tests/v2/fixtures/nested/deep/resource.py new file mode 100644 index 0000000..bff2dc1 --- /dev/null +++ b/tests/v2/fixtures/nested/deep/resource.py @@ -0,0 +1,13 @@ +"""Dummy `V2Resource` nested two packages deep; proves discovery recurses via +`pkgutil.walk_packages`, not just the top level.""" + +from __future__ import annotations + +from plane.api.v2._kernel.resource import V2Resource +from plane.models.v2.states import CreateState, State, UpdateState + + +class DummyNestedResource(V2Resource[State, CreateState, UpdateState]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = State + operations = {"list": "states_list"} diff --git a/tests/v2/integration/__init__.py b/tests/v2/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/integration/conftest.py b/tests/v2/integration/conftest.py new file mode 100644 index 0000000..c69cbb6 --- /dev/null +++ b/tests/v2/integration/conftest.py @@ -0,0 +1,133 @@ +"""Fixtures for the live v2 integration suite; every fixture skips (never fails) +when its required env var is absent, so tests collect cleanly with no credentials.""" + +from __future__ import annotations + +import os +import random +import re +import string +import time +from collections.abc import Callable, Iterator +from datetime import datetime, timezone +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import SPECS, ResourceSpec, unique_name + +_RETRY_AFTER_RE = re.compile(r"(\d+(?:\.\d+)?)\s*seconds?", re.IGNORECASE) + + +def _short_suffix(length: int = 6) -> str: + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) + + +def _install_rate_limit_retry(transport: Any, max_attempts: int = 8) -> None: + """Retries every request on HTTP 429, sleeping for the server's own + Retry-After hint (falls back to 15s) -- the API key's free-plan rate + limit is otherwise exceeded within seconds by this suite's volume.""" + original_request: Callable[..., Any] = transport.request + + def request_with_retry(*args: Any, **kwargs: Any) -> Any: + for attempt in range(max_attempts): + try: + return original_request(*args, **kwargs) + except PlaneAPIError as exc: + if exc.status != 429 or attempt == max_attempts - 1: + raise + match = _RETRY_AFTER_RE.search(exc.detail or "") + delay = float(match.group(1)) + 1.0 if match else 15.0 + time.sleep(delay) + raise AssertionError("unreachable") # pragma: no cover + + transport.request = request_with_retry + + +@pytest.fixture(scope="session") +def base_url() -> str: + value = os.getenv("PLANE_BASE_URL") + if not value: + pytest.skip("PLANE_BASE_URL not set; skipping live v2 integration tests") + return value + + +@pytest.fixture(scope="session") +def api_key() -> str: + value = os.getenv("PLANE_API_KEY") + if not value: + pytest.skip("PLANE_API_KEY not set; skipping live v2 integration tests") + return value + + +@pytest.fixture(scope="session") +def workspace_slug() -> str: + value = os.getenv("WORKSPACE_SLUG") + if not value: + pytest.skip("WORKSPACE_SLUG not set; skipping live v2 integration tests") + return value + + +@pytest.fixture(scope="session") +def client(base_url: str, api_key: str) -> PlaneClient: + plane_client = PlaneClient(base_url=base_url, api_key=api_key) + _install_rate_limit_retry(plane_client.v2.transport) + return plane_client + + +@pytest.fixture(scope="session") +def project(client: PlaneClient, workspace_slug: str) -> Iterator[dict[str, Any]]: + """Session-shared project seeded with 5 default states and no labels; + `is_time_tracking_enabled`/`intake_view` are set explicitly -- omitting either + 404s/400s worklog and intake calls elsewhere in this suite (verified live).""" + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + identifier = f"I{_short_suffix()}" # the server caps identifiers at 10 chars + payload = { + "name": f"SDK v2 IT {timestamp}", + "identifier": identifier, + "is_time_tracking_enabled": True, + "intake_view": True, + } + created: dict[str, Any] = client.v2.transport.request( + "POST", f"/workspaces/{workspace_slug}/projects/", json=payload + ) + yield created + try: + client.v2.transport.request( + "DELETE", f"/workspaces/{workspace_slug}/projects/{created['id']}/" + ) + except Exception: + pass + + +@pytest.fixture(scope="session") +def project_id(project: dict[str, Any]) -> str: + return str(project["id"]) + + +@pytest.fixture(scope="session") +def project_key(project: dict[str, Any]) -> str: + return str(project["identifier"]) + + +@pytest.fixture(params=sorted(SPECS), ids=sorted(SPECS)) +def spec(request: pytest.FixtureRequest) -> ResourceSpec: + """Parametrizes a test over both `states` and `labels`.""" + return SPECS[request.param] + + +@pytest.fixture +def work_item(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """Throwaway work item in the shared project; files needing different + setup define their own, shadowing this one.""" + work_items = client.v2.workspace(workspace_slug).project(project_id).work_items + created = work_items.create(CreateWorkItem(name=unique_name("work-item"))) + yield created + try: + work_items.delete(created.id) + except Exception: + pass diff --git a/tests/v2/integration/helpers.py b/tests/v2/integration/helpers.py new file mode 100644 index 0000000..c655a26 --- /dev/null +++ b/tests/v2/integration/helpers.py @@ -0,0 +1,101 @@ +"""Harness shared by CRUD-style tests across states/labels/cycles/modules/milestones +via `SPECS`. `CONVERTED_SPECS` is the subset whose bound, zero-argument methods +actually work live today; the rest still take `(workspace_slug, project, ...)`.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +from plane.models.v2.cycles import CreateCycle, Cycle, UpdateCycle +from plane.models.v2.labels import CreateLabel, Label, UpdateLabel +from plane.models.v2.milestones import CreateMilestone, Milestone, UpdateMilestone +from plane.models.v2.modules import CreateModule, Module, UpdateModule +from plane.models.v2.states import CreateState, State, UpdateState + +DEFAULT_COLOR = "#336699" + + +def unique_name(prefix: str) -> str: + """Unique name; state/label names are unique per project + (case-sensitively) on the server.""" + return f"{prefix}-{uuid.uuid4().hex[:10]}" + + +@dataclass(frozen=True) +class ResourceSpec: + """Everything a generic test needs to drive one v2 resource by name.""" + + key: str # attribute name on `Project` (see `ResourceSpec.ops`) + write_model: type[Any] + patch_model: type[Any] + read_model: type[Any] + name_field: str = "name" # the write/read model's own identifying field + has_color: bool = False # only states/labels carry a `color` field + converted: bool = True # False for cycles/modules/milestones -- see module docstring + + def make_write(self, name: str, **overrides: Any) -> Any: + body: dict[str, Any] = {self.name_field: name} + if self.has_color: + body["color"] = DEFAULT_COLOR + body.update(overrides) + return self.write_model(**body) + + def make_patch(self, **fields: Any) -> Any: + return self.patch_model(**fields) + + def make_patch_name(self, value: str) -> Any: + """A patch that renames the row, whatever the underlying field is called.""" + return self.patch_model(**{self.name_field: value}) + + def ops(self, client: Any, workspace_slug: str, project: str) -> Any: + """The chained resource for this spec -- the only way to reach it now + that the flat form is gone. `project` accepts a project id or its key.""" + scope = client.v2.workspace(workspace_slug).project(project) + return getattr(scope, self.key) + + +SPECS: dict[str, ResourceSpec] = { + "states": ResourceSpec( + key="states", + write_model=CreateState, + patch_model=UpdateState, + read_model=State, + has_color=True, + ), + "labels": ResourceSpec( + key="labels", + write_model=CreateLabel, + patch_model=UpdateLabel, + read_model=Label, + has_color=True, + ), + "cycles": ResourceSpec( + key="cycles", + write_model=CreateCycle, + patch_model=UpdateCycle, + read_model=Cycle, + converted=False, + ), + "modules": ResourceSpec( + key="modules", + write_model=CreateModule, + patch_model=UpdateModule, + read_model=Module, + converted=False, + ), + "milestones": ResourceSpec( + key="milestones", + write_model=CreateMilestone, + patch_model=UpdateMilestone, + read_model=Milestone, + name_field="title", + converted=False, + ), +} + +# The subset whose classes have actually dropped `(workspace_slug, project, ...)` +# from every method (this phase's exemplars) -- `ops()` only works zero-argument +# for these today. C1 should widen this set as it converts cycles/modules/milestones. +CONVERTED_SPECS: dict[str, ResourceSpec] = {k: v for k, v in SPECS.items() if v.converted} diff --git a/tests/v2/integration/test_artifacts.py b/tests/v2/integration/test_artifacts.py new file mode 100644 index 0000000..9f1394b --- /dev/null +++ b/tests/v2/integration/test_artifacts.py @@ -0,0 +1,27 @@ +"""Artifacts against a real server; requires the `APPLETS` flag and workspace +admin/owner. No delete op in the golden, so created artifacts are permanent +test litter -- acceptable for this POC-scope resource; not verified live yet.""" + +from __future__ import annotations + +from plane.api.v2.artifacts import Artifacts +from plane.client import PlaneClient +from plane.models.v2.artifacts import CreateArtifact, UpdateArtifactUpdate + + +def test_create_retrieve_update_publish(client: PlaneClient, workspace_slug: str) -> None: + artifacts: Artifacts = client.v2.workspace(workspace_slug).artifacts + + created = artifacts.create(CreateArtifact(name="SDK IT artifact", html="

v1

")) + assert created.current_version == 1 + assert created.is_published is False + + detail = artifacts.retrieve(created.id) + assert detail.html == "

v1

" + + updated = artifacts.update(created.id, UpdateArtifactUpdate(html="

v2

")) + assert updated.current_version == 2 + + published = artifacts.publish(created.id) + assert published.is_active is True + assert published.anchor diff --git a/tests/v2/integration/test_assets.py b/tests/v2/integration/test_assets.py new file mode 100644 index 0000000..843aa06 --- /dev/null +++ b/tests/v2/integration/test_assets.py @@ -0,0 +1,99 @@ +"""`WorkspaceAssets`/`UserAssets` against a real server; each gets its own suite +since both are two-step S3 upload flows with no plain name+color shape. Only +the metadata lifecycle is exercised here, not the actual S3 PUT.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2.assets import UserAssets, WorkspaceAssets +from plane.client import PlaneClient +from plane.models.v2.assets import ( + CreateUserAsset, + CreateWorkspaceAsset, + UserAssetConfirm, + WorkspaceAssetConfirm, +) + +from .helpers import unique_name + + +@pytest.fixture +def workspace_assets(client: PlaneClient, workspace_slug: str) -> WorkspaceAssets: + return client.v2.workspace(workspace_slug).assets + + +@pytest.fixture +def user_assets(client: PlaneClient) -> UserAssets: + return client.v2.user_assets + + +class TestWorkspaceAssets: + def test_create_returns_presigned_upload_data( + self, workspace_assets: WorkspaceAssets + ) -> None: + result = workspace_assets.create( + CreateWorkspaceAsset(name=f"{unique_name('asset')}.txt", size=11) + ) + try: + assert result.asset_id + # `asset_url` is null here (confirmed live): `FileAsset.asset_url` only + # populates for a fixed `entity_type` allowlist, and an unset `entity_type` + # defaults server-side to `WORK_ITEM_IMPORT`, which isn't on that list. + assert result.asset_url is None + assert result.upload_data # the contract question this test pins + assert result.asset.id == result.asset_id + finally: + workspace_assets.delete(result.asset_id) + + def test_confirm_then_retrieve_reflects_is_uploaded( + self, workspace_assets: WorkspaceAssets + ) -> None: + created = workspace_assets.create( + CreateWorkspaceAsset(name=f"{unique_name('asset')}.txt", size=11) + ) + try: + confirmed = workspace_assets.update( + created.asset_id, WorkspaceAssetConfirm(is_uploaded=True) + ) + assert confirmed.is_uploaded is True + + fetched = workspace_assets.retrieve(created.asset_id) + assert fetched.is_uploaded is True + finally: + workspace_assets.delete(created.asset_id) + + def test_list_includes_the_created_asset(self, workspace_assets: WorkspaceAssets) -> None: + created = workspace_assets.create( + CreateWorkspaceAsset(name=f"{unique_name('asset')}.txt", size=11) + ) + try: + ids = {row.id for row in workspace_assets.list().data} + assert created.asset_id in ids + finally: + workspace_assets.delete(created.asset_id) + + +class TestUserAssets: + def test_create_and_confirm_avatar(self, user_assets: UserAssets) -> None: + created = user_assets.create( + CreateUserAsset(entity_type="USER_AVATAR", name=f"{unique_name('avatar')}.png", size=11) + ) + try: + assert created.asset_id + assert created.upload_data + + confirmed = user_assets.update(created.asset_id, UserAssetConfirm(is_uploaded=True)) + assert confirmed.is_uploaded is True + finally: + user_assets.delete(created.asset_id) + + def test_list_is_not_workspace_scoped(self, user_assets: UserAssets) -> None: + created = user_assets.create( + CreateUserAsset(entity_type="USER_AVATAR", name=f"{unique_name('avatar')}.png", size=11) + ) + try: + ids = {row.id for row in user_assets.list().data} + assert created.asset_id in ids + finally: + user_assets.delete(created.asset_id) diff --git a/tests/v2/integration/test_audit_logs.py b/tests/v2/integration/test_audit_logs.py new file mode 100644 index 0000000..611cf82 --- /dev/null +++ b/tests/v2/integration/test_audit_logs.py @@ -0,0 +1,40 @@ +"""Live coverage for `ws.audit_logs`, reached through the chain +(`client.v2.workspace(slug).audit_logs`). Offline coverage lives in +`tests/v2/test_audit_logs_resource.py`.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from plane.api.v2.audit_logs import AuditLogs +from plane.client import PlaneClient + + +@pytest.fixture +def audit_logs(client: PlaneClient, workspace_slug: str) -> AuditLogs: + return client.v2.workspace(workspace_slug).audit_logs + + +class TestAuditLogs: + """Offset pagination is refused here (`count_pagination_disabled`, + `AuditLogViewSet.count_styles_enabled=False`) -- every list call in this + class must pass `paginate="cursor"`.""" + + def test_list_reflects_workspace_activity(self, audit_logs: AuditLogs, work_item: Any) -> None: + """Creating a work item (via the `work_item` fixture) is itself an + audited action -- the list should not be empty afterwards.""" + page = audit_logs.list(per_page=50, paginate="cursor") + assert page.data + + def test_retrieve_round_trips_a_listed_entry( + self, audit_logs: AuditLogs, work_item: Any + ) -> None: + first = audit_logs.list(per_page=1, paginate="cursor").data[0] + fetched = audit_logs.retrieve(first.id) + assert fetched.id == first.id + + def test_category_filter_narrows_results(self, audit_logs: AuditLogs) -> None: + page = audit_logs.list(category="project", per_page=50, paginate="cursor") + assert all(row.category == "project" for row in page.data) diff --git a/tests/v2/integration/test_automations.py b/tests/v2/integration/test_automations.py new file mode 100644 index 0000000..d52bd4a --- /dev/null +++ b/tests/v2/integration/test_automations.py @@ -0,0 +1,356 @@ +"""Live coverage for `proj.automations`/`ws.automations`; not parametrized +through `helpers.SPECS` (separate scopes, sub-resources, custom actions). +Not verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.automations import ProjectAutomations, WorkspaceAutomations +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.client import PlaneClient +from plane.models.v2.automations import ( + CreateAutomation, + CreateAutomationEdge, + CreateAutomationNode, + UpdateAutomation, + UpdateAutomationNode, +) + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def ws(client: PlaneClient, workspace_slug: str) -> Workspace: + return client.v2.workspace(workspace_slug) + + +@pytest.fixture +def project_automations(proj: Project) -> ProjectAutomations: + return proj.automations + + +@pytest.fixture +def workspace_automations(ws: Workspace) -> WorkspaceAutomations: + return ws.automations + + +@pytest.fixture +def project_automation(project_automations: ProjectAutomations) -> Iterator[Any]: + """One freshly created project automation, deleted afterwards.""" + created = project_automations.create( + CreateAutomation(name=unique_name("automation"), scope="work-item") + ) + yield created + try: + project_automations.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def workspace_automation(workspace_automations: WorkspaceAutomations) -> Iterator[Any]: + """One freshly created workspace (global) automation, deleted afterwards.""" + created = workspace_automations.create( + CreateAutomation(name=unique_name("automation"), scope="work-item") + ) + yield created + try: + workspace_automations.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def project_trigger_node( + project_automations: ProjectAutomations, project_automation: Any +) -> Iterator[Any]: + """One trigger node on `project_automation`, deleted afterwards.""" + created = project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="record_created", name=unique_name("node"), node_type="trigger" + ), + ) + yield created + try: + project_automations.nodes.delete(project_automation.id, created.id) + except Exception: + pass + + +class TestProjectAutomationsCrud: + def test_list_returns_a_page(self, project_automations: ProjectAutomations) -> None: + page = project_automations.list() + assert isinstance(page.data, list) + + def test_list_by_project_key_agrees_with_list_by_id( + self, + ws: Workspace, + project_id: str, + project_key: str, + project_automation: Any, + ) -> None: + by_id = {row.id for row in ws.project(project_id).automations.list().data} + by_key = {row.id for row in ws.project(project_key).automations.list().data} + assert project_automation.id in by_id + assert by_id == by_key + + def test_create_returns_the_written_fields( + self, project_automations: ProjectAutomations + ) -> None: + name = unique_name("automation") + created = project_automations.create(CreateAutomation(name=name, scope="work-item")) + try: + assert created.name == name + assert created.scope == "work-item" + assert created.status == "draft" # server default until `set_status` publishes it + assert created.id + finally: + project_automations.delete(created.id) + + def test_retrieve_returns_the_created_row( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + fetched = project_automations.retrieve(project_automation.id) + assert fetched.id == project_automation.id + assert fetched.name == project_automation.name + + def test_retrieve_with_fields_is_sparse( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + fetched = project_automations.retrieve(project_automation.id, fields=["id", "name"]) + assert fetched.id == project_automation.id + assert fetched.scope is None + + def test_find_by_name( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + found = project_automations.find_by_name(project_automation.name) + assert found.id == project_automation.id + + def test_patch_updates_only_the_given_fields( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + new_name = unique_name("automation-renamed") + updated = project_automations.update(project_automation.id, UpdateAutomation(name=new_name)) + assert updated.id == project_automation.id + assert updated.name == new_name + assert updated.scope == project_automation.scope # untouched field survives the PATCH + + def test_delete_then_retrieve_404s(self, project_automations: ProjectAutomations) -> None: + created = project_automations.create( + CreateAutomation(name=unique_name("automation"), scope="work-item") + ) + project_automations.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + project_automations.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_set_status_enables_and_moves_out_of_draft( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + assert project_automation.status == "draft" + # The server refuses to enable an automation with no trigger/action + # ("Automation cannot be enabled until it has at least one trigger and + # one action.") -- seed the minimum graph first. + project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="record_created", name=unique_name("trigger"), node_type="trigger" + ), + ) + project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="add_comment", + name=unique_name("action"), + node_type="action", + config={"comment_text": "Automated comment"}, + ), + ) + result = project_automations.set_status(project_automation.id, is_enabled=True) + assert result is None + refetched = project_automations.retrieve(project_automation.id) + assert refetched.is_enabled is True + assert refetched.status != "draft" + + +class TestWorkspaceAutomationsCrud: + def test_list_returns_a_page_and_excludes_project_scope( + self, workspace_automations: WorkspaceAutomations + ) -> None: + page = workspace_automations.list() + assert isinstance(page.data, list) + + def test_create_retrieve_delete_round_trip( + self, workspace_automations: WorkspaceAutomations + ) -> None: + name = unique_name("automation") + created = workspace_automations.create(CreateAutomation(name=name, scope="work-item")) + try: + fetched = workspace_automations.retrieve(created.id) + assert fetched.name == name + assert fetched.scope == "work-item" + finally: + workspace_automations.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + workspace_automations.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_set_status_disables( + self, workspace_automations: WorkspaceAutomations, workspace_automation: Any + ) -> None: + # Same enable precondition as the project-scoped test: at least one + # trigger and one action. + workspace_automations.nodes.create( + workspace_automation.id, + CreateAutomationNode( + handler_name="record_created", name=unique_name("trigger"), node_type="trigger" + ), + ) + workspace_automations.nodes.create( + workspace_automation.id, + CreateAutomationNode( + handler_name="add_comment", + name=unique_name("action"), + node_type="action", + config={"comment_text": "Automated comment"}, + ), + ) + workspace_automations.set_status(workspace_automation.id, is_enabled=True) + result = workspace_automations.set_status(workspace_automation.id, is_enabled=False) + assert result is None + refetched = workspace_automations.retrieve(workspace_automation.id) + assert refetched.is_enabled is False + + +class TestProjectAutomationNodes: + def test_create_list_retrieve( + self, + project_automations: ProjectAutomations, + project_automation: Any, + project_trigger_node: Any, + ) -> None: + page = project_automations.nodes.list(project_automation.id) + assert any(node.id == project_trigger_node.id for node in page.data) + + fetched = project_automations.nodes.retrieve(project_automation.id, project_trigger_node.id) + assert fetched.id == project_trigger_node.id + assert fetched.node_type == "trigger" + + def test_update_and_delete( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + created = project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="record_created", name=unique_name("node"), node_type="trigger" + ), + ) + updated = project_automations.nodes.update( + project_automation.id, created.id, UpdateAutomationNode(is_enabled=False) + ) + assert updated.is_enabled is False + + project_automations.nodes.delete(project_automation.id, created.id) + with pytest.raises(PlaneAPIError) as exc_info: + project_automations.nodes.retrieve(project_automation.id, created.id) + assert exc_info.value.status == 404 + + def test_regenerate_webhook_secret_returns_a_new_secret( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + """`regenerate_webhook_secret` only accepts a `send_webhook` action + node -- any other handler 400s "Only send_webhook nodes can rotate + webhook secrets." (no separate trigger handler exists).""" + node = project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="send_webhook", + name=unique_name("webhook-node"), + node_type="action", + config={"url": "https://example.com/webhook-test", "secret": "test-secret-value"}, + ), + ) + try: + first = project_automations.nodes.regenerate_webhook_secret( + project_automation.id, node.id + ) + second = project_automations.nodes.regenerate_webhook_secret( + project_automation.id, node.id + ) + assert first.secret + assert second.secret + assert first.secret != second.secret # rotation actually rotates + finally: + project_automations.nodes.delete(project_automation.id, node.id) + + +class TestProjectAutomationEdges: + def test_create_connects_two_nodes_and_lists( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + source = project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="record_created", name=unique_name("source"), node_type="trigger" + ), + ) + target = project_automations.nodes.create( + project_automation.id, + CreateAutomationNode( + handler_name="add_comment", + name=unique_name("target"), + node_type="action", + config={"comment_text": "Automated comment"}, + ), + ) + try: + edge = project_automations.edges.create( + project_automation.id, + CreateAutomationEdge(source_node_id=source.id, target_node_id=target.id), + ) + try: + assert edge.source_node_id == source.id + assert edge.target_node_id == target.id + + page = project_automations.edges.list( + project_automation.id, source_node_id=source.id + ) + assert any(row.id == edge.id for row in page.data) + + fetched = project_automations.edges.retrieve(project_automation.id, edge.id) + assert fetched.id == edge.id + finally: + project_automations.edges.delete(project_automation.id, edge.id) + with pytest.raises(PlaneAPIError) as exc_info: + project_automations.edges.retrieve(project_automation.id, edge.id) + assert exc_info.value.status == 404 + finally: + project_automations.nodes.delete(project_automation.id, source.id) + project_automations.nodes.delete(project_automation.id, target.id) + + +class TestAutomationActivities: + def test_project_activities_list_returns_a_page( + self, project_automations: ProjectAutomations, project_automation: Any + ) -> None: + page = project_automations.activities.list(project_automation.id) + assert isinstance(page.data, list) + + def test_workspace_activities_list_returns_a_page( + self, workspace_automations: WorkspaceAutomations, workspace_automation: Any + ) -> None: + page = workspace_automations.activities.list(workspace_automation.id) + assert isinstance(page.data, list) diff --git a/tests/v2/integration/test_bulk.py b/tests/v2/integration/test_bulk.py new file mode 100644 index 0000000..cd69864 --- /dev/null +++ b/tests/v2/integration/test_bulk.py @@ -0,0 +1,160 @@ +"""bulk_create/bulk_update/bulk_delete against a real server: all-success, +partial failure (200 with per-row detail), and `all_or_none=True` turning +that into a 409, using a real name conflict rather than a pydantic reject.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient + +from .helpers import ResourceSpec, unique_name + + +class TestBulkCreate: + def test_all_rows_succeed( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + items = [spec.make_write(unique_name(f"{spec.key}-bc")) for _ in range(3)] + result = ops.bulk_create(items) + try: + assert result.succeeded == 3 + assert result.failed == 0 + assert result.failures == [] + finally: + ops.bulk_delete([row.id for row in result.results]) + + def test_partial_failure_reports_the_failing_row( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + taken_name = unique_name(f"{spec.key}-taken") + existing = ops.create(spec.make_write(taken_name)) + good_name = unique_name(f"{spec.key}-ok") + try: + result = ops.bulk_create([spec.make_write(good_name), spec.make_write(taken_name)]) + assert result.succeeded == 1 + assert result.failed == 1 + assert len(result.failures) == 1 + failure = result.failures[0] + assert failure.index == 1 + assert failure.code # a real code, e.g. "conflict" + assert failure.detail + + with pytest.raises(PlaneAPIError, match="1 of 2 rows failed"): + result.raise_for_failures() + + good_row = next(r for r in result.results if r.result != "failed") + ops.delete(good_row.id) + finally: + ops.delete(existing.id) + + def test_all_or_none_turns_a_failing_row_into_409( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + taken_name = unique_name(f"{spec.key}-taken-aon") + existing = ops.create(spec.make_write(taken_name)) + good_name = unique_name(f"{spec.key}-ok-aon") + try: + with pytest.raises(PlaneAPIError) as exc_info: + ops.bulk_create( + [spec.make_write(good_name), spec.make_write(taken_name)], + all_or_none=True, + ) + assert exc_info.value.status == 409 + assert exc_info.value.code == "batch_failed" + + # Nothing was applied: the good row must not exist. + page = ops.list(name=good_name) + assert page.data == [] + finally: + ops.delete(existing.id) + + +class TestBulkUpdate: + def test_all_rows_succeed( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + created = ops.bulk_create( + [spec.make_write(unique_name(f"{spec.key}-bu")) for _ in range(2)] + ) + ids = [row.id for row in created.results] + try: + result = ops.bulk_update( + [{"id": pk, spec.name_field: unique_name(f"{spec.key}-bu-renamed")} for pk in ids] + ) + assert result.succeeded == 2 + assert result.failed == 0 + finally: + ops.bulk_delete(ids) + + def test_partial_failure_on_unknown_id( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + created = ops.create(spec.make_write(unique_name(f"{spec.key}-bu-ok"))) + missing_id = "00000000-0000-0000-0000-000000000000" + try: + result = ops.bulk_update( + [ + {"id": created.id, spec.name_field: unique_name(f"{spec.key}-bu-renamed")}, + {"id": missing_id, spec.name_field: "irrelevant"}, + ] + ) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.failures[0].index == 1 + assert result.failures[0].code + + with pytest.raises(PlaneAPIError): + result.raise_for_failures() + finally: + ops.delete(created.id) + + +class TestBulkDelete: + def test_all_rows_succeed( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + created = ops.bulk_create( + [spec.make_write(unique_name(f"{spec.key}-bd")) for _ in range(2)] + ) + ids = [row.id for row in created.results] + result = ops.bulk_delete(ids) + assert result.succeeded == 2 + assert result.failed == 0 + + def test_partial_failure_on_unknown_id( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + created = ops.create(spec.make_write(unique_name(f"{spec.key}-bd-ok"))) + missing_id = "00000000-0000-0000-0000-000000000000" + result = ops.bulk_delete([created.id, missing_id]) + assert result.succeeded == 1 + assert result.failed == 1 + assert result.failures[0].code + + +class TestBulkEmptyBatchRejectedClientSide: + """The known bug fix, proven live: the client must reject `[]` itself rather + than round-trip a request the server always 400s on `minItems: 1`.""" + + def test_bulk_create_empty_batch_never_hits_the_network( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + with pytest.raises(ValueError, match="non-empty"): + ops.bulk_create([]) + + def test_bulk_delete_empty_batch_never_hits_the_network( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + with pytest.raises(ValueError, match="non-empty"): + ops.bulk_delete([]) diff --git a/tests/v2/integration/test_collections.py b/tests/v2/integration/test_collections.py new file mode 100644 index 0000000..9ab5186 --- /dev/null +++ b/tests/v2/integration/test_collections.py @@ -0,0 +1,191 @@ +"""`Collections` against a real server, reached as +`client.v2.workspace(slug).wiki.collections`. Covers CRUD, `default()`, and the +`members`/`pages` sub-resources (a page belongs to only one collection, confirmed live).""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.collections import Collections +from plane.api.v2.members import WorkspaceMembers +from plane.api.v2.pages import WikiPages +from plane.client import PlaneClient +from plane.models.v2.collections import ( + CollectionMemberAdd, + CollectionMembersManage, + CollectionPagesManage, + CreateCollection, + UpdateCollection, +) +from plane.models.v2.pages import CreatePage, UpdatePage + +from .helpers import unique_name + + +def _archive_workspace_page(workspace_pages: WikiPages, page_id: str) -> None: + """Archives a page before deletion -- the server 400s otherwise; see `test_pages.py`.""" + workspace_pages.update( + page_id, UpdatePage(archived_at=datetime.now(timezone.utc), is_locked=False) + ) + + +@pytest.fixture +def collections(client: PlaneClient, workspace_slug: str) -> Collections: + return client.v2.workspace(workspace_slug).wiki.collections + + +@pytest.fixture +def workspace_pages(client: PlaneClient, workspace_slug: str) -> WikiPages: + return client.v2.workspace(workspace_slug).wiki.pages + + +@pytest.fixture +def workspace_members(client: PlaneClient, workspace_slug: str) -> WorkspaceMembers: + return client.v2.workspace(workspace_slug).members + + +class TestCRUD: + def test_create_then_retrieve(self, collections: Collections) -> None: + name = unique_name("collection") + created = collections.create(CreateCollection(name=name)) + try: + assert created.name == name + + fetched = collections.retrieve(created.id) + assert fetched.id == created.id + finally: + collections.delete(created.id) + + def test_patch_updates_only_the_given_fields(self, collections: Collections) -> None: + created = collections.create(CreateCollection(name=unique_name("coll"))) + try: + new_name = unique_name("coll-renamed") + updated = collections.update(created.id, UpdateCollection(name=new_name)) + assert updated.name == new_name + finally: + collections.delete(created.id) + + def test_delete_then_retrieve_404s(self, collections: Collections) -> None: + created = collections.create(CreateCollection(name=unique_name("coll"))) + collections.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + collections.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_find_by_name(self, collections: Collections) -> None: + name = unique_name("coll") + created = collections.create(CreateCollection(name=name)) + try: + assert collections.find_by_name(name).id == created.id + finally: + collections.delete(created.id) + + def test_default_resolves_the_workspaces_default_collection( + self, collections: Collections + ) -> None: + """Every workspace has exactly one default (General) collection -- + confirmed live: a fresh page with no `collection_id` always lands in + one (see the module docstring's API-quirk note).""" + default = collections.default() + assert default.is_default is True + + +class TestPagesSubResource: + def test_pages_manage_add_then_remove( + self, + collections: Collections, + workspace_pages: WikiPages, + ) -> None: + collection = collections.create(CreateCollection(name=unique_name("coll"))) + # `collection_id` at create time, not `pages.manage(add=...)` afterward + # -- see the module docstring's API quirk note. + page = workspace_pages.create( + CreatePage(name=unique_name("coll-page"), collection_id=collection.id) + ) + try: + after_create = collections.retrieve(collection.id) + assert page.id in (after_create.page_ids or []) + + removed = collections.pages.manage( + collection.id, CollectionPagesManage(remove=[page.id]) + ) + assert page.id in removed.removed + + after_remove = collections.retrieve(collection.id) + assert page.id not in (after_remove.page_ids or []) + + # The page now has no active collection at all, so `add` genuinely + # has somewhere to insert into (no unique-constraint conflict). + added = collections.pages.manage(collection.id, CollectionPagesManage(add=[page.id])) + assert page.id in added.added + + after_add = collections.retrieve(collection.id) + assert page.id in (after_add.page_ids or []) + finally: + _archive_workspace_page(workspace_pages, page.id) + workspace_pages.delete(page.id) + collections.delete(collection.id) + + def test_pages_search_returns_a_plain_list( + self, + collections: Collections, + workspace_pages: WikiPages, + ) -> None: + """`pages-search` returns a bare JSON array of pages NOT yet in the + collection (confirmed live) and caps out at a fixed default limit rather + than paginating; the collection's own name overlaps the search term too.""" + page_name = unique_name("coll-page") + collection = collections.create(CreateCollection(name=f"coll-{page_name}")) + page = workspace_pages.create(CreatePage(name=page_name)) + try: + results = collections.pages.search(collection.id, search=page_name) + assert isinstance(results, list) + assert any(row.id == page.id for row in results) + finally: + _archive_workspace_page(workspace_pages, page.id) + workspace_pages.delete(page.id) + collections.delete(collection.id) + + +class TestMembersSubResource: + def test_members_list_returns_a_plain_list(self, collections: Collections) -> None: + """Pins the contract question flagged in the module docstring: this + parses the payload as a bare JSON array, not an envelope.""" + created = collections.create(CreateCollection(name=unique_name("coll"))) + try: + members = collections.members.list(created.id) + assert isinstance(members, list) + finally: + collections.delete(created.id) + + def test_members_manage_add_then_remove( + self, + collections: Collections, + workspace_members: WorkspaceMembers, + ) -> None: + """Requires at least one workspace member besides the API principal -- + skips if the target workspace has none (a bare-minimum dev workspace + might not).""" + roster = workspace_members.list().data + if not roster: + pytest.skip("workspace has no members to grant collection access to") + member_id = roster[0].member_id + assert member_id is not None + + created = collections.create(CreateCollection(name=unique_name("coll"))) + try: + added = collections.members.manage( + created.id, + CollectionMembersManage(add=[CollectionMemberAdd(member_id=member_id, access=0)]), + ) + assert member_id in added.added + + removed = collections.members.manage( + created.id, CollectionMembersManage(remove=[member_id]) + ) + assert member_id in removed.removed + finally: + collections.delete(created.id) diff --git a/tests/v2/integration/test_crud.py b/tests/v2/integration/test_crud.py new file mode 100644 index 0000000..5adf2f2 --- /dev/null +++ b/tests/v2/integration/test_crud.py @@ -0,0 +1,167 @@ +"""list/retrieve/create/update/delete against a real server, parametrized over +every resource in `SPECS` via the `spec` fixture; assertions read the row's +name through `spec.name_field` and gate `color` checks on `spec.has_color`.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient + +from .helpers import ResourceSpec, unique_name + + +@pytest.fixture +def row( + client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec +) -> Iterator[Any]: + """One freshly created row of the parametrized resource, deleted afterwards.""" + ops = spec.ops(client, workspace_slug, project_id) + created = ops.create(spec.make_write(unique_name(spec.key))) + yield created + try: + ops.delete(created.id) + except Exception: + pass + + +class TestList: + def test_list_by_project_uuid( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + page = spec.ops(client, workspace_slug, project_id).list() + assert isinstance(page.data, list) + + def test_list_by_project_key( + self, client: PlaneClient, workspace_slug: str, project_key: str, spec: ResourceSpec + ) -> None: + page = spec.ops(client, workspace_slug, project_key).list() + assert isinstance(page.data, list) + + def test_list_by_uuid_and_by_key_agree( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + spec: ResourceSpec, + ) -> None: + by_id = {row.id for row in spec.ops(client, workspace_slug, project_id).list().data} + by_key = {row.id for row in spec.ops(client, workspace_slug, project_key).list().data} + assert by_id == by_key + + def test_list_without_fields_returns_full_row( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + spec: ResourceSpec, + row: Any, + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + page = ops.list() + found = next(item for item in page.data if item.id == row.id) + assert getattr(found, spec.name_field) == getattr(row, spec.name_field) + if spec.has_color: + assert found.color is not None + + def test_list_with_fields_is_sparse( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + spec: ResourceSpec, + row: Any, + ) -> None: + """A field not requested comes back None, not an error or a KeyError.""" + ops = spec.ops(client, workspace_slug, project_id) + page = ops.list(fields=["id", spec.name_field]) + found = next(item for item in page.data if item.id == row.id) + assert getattr(found, spec.name_field) is not None + assert found.created_at is None + assert found.external_id is None + if spec.has_color: + assert found.color is None + + +class TestRetrieve: + def test_retrieve_returns_the_created_row( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + spec: ResourceSpec, + row: Any, + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + fetched = ops.retrieve(row.id) + assert fetched.id == row.id + assert getattr(fetched, spec.name_field) == getattr(row, spec.name_field) + if spec.has_color: + assert fetched.color is not None + + def test_retrieve_with_fields_is_sparse( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + spec: ResourceSpec, + row: Any, + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + fetched = ops.retrieve(row.id, fields=["id", spec.name_field]) + assert fetched.id == row.id + assert fetched.external_id is None + if spec.has_color: + assert fetched.color is None + + +class TestCreate: + def test_create_returns_the_written_fields( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + name = unique_name(spec.key) + overrides = {"color": "#abcdef"} if spec.has_color else {} + created = ops.create(spec.make_write(name, **overrides)) + try: + assert getattr(created, spec.name_field) == name + if spec.has_color: + assert created.color == "#abcdef" + assert created.id + finally: + ops.delete(created.id) + + +class TestUpdate: + def test_patch_updates_only_the_given_fields( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + spec: ResourceSpec, + row: Any, + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + new_name = unique_name(f"{spec.key}-renamed") + updated = ops.update(row.id, spec.make_patch_name(new_name)) + assert updated.id == row.id + assert getattr(updated, spec.name_field) == new_name + if spec.has_color: + assert updated.color == row.color # untouched field survives the PATCH + + +class TestDelete: + def test_delete_then_retrieve_404s( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + created = ops.create(spec.make_write(unique_name(spec.key))) + ops.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + ops.retrieve(created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_customer_properties.py b/tests/v2/integration/test_customer_properties.py new file mode 100644 index 0000000..a2506ab --- /dev/null +++ b/tests/v2/integration/test_customer_properties.py @@ -0,0 +1,54 @@ +"""Customer properties against a real server; requires the workspace's +`is_customer_enabled` feature flag, else the endpoint 403s/404s. Not +verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.customer_properties import CustomerProperties +from plane.client import PlaneClient +from plane.models.v2.customer_properties import CreateCustomerProperty, UpdateCustomerProperty + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def customer_properties(client: PlaneClient, workspace_slug: str) -> CustomerProperties: + return client.v2.workspace(workspace_slug).customer_properties + + +@pytest.fixture +def property_row(customer_properties: CustomerProperties) -> Iterator[Any]: + created = customer_properties.create( + CreateCustomerProperty(display_name=unique_name("tier"), property_type="TEXT") + ) + yield created + try: + customer_properties.delete(created.id) + except Exception: + pass + + +def test_list(customer_properties: CustomerProperties) -> None: + page = customer_properties.list() + assert isinstance(page.data, list) + + +def test_create_retrieve_patch_delete( + customer_properties: CustomerProperties, property_row: Any +) -> None: + fetched = customer_properties.retrieve(property_row.id) + assert fetched.property_type == "TEXT" + + updated = customer_properties.update(property_row.id, UpdateCustomerProperty(is_active=False)) + assert updated.is_active is False + + +def test_sparse_fields(customer_properties: CustomerProperties, property_row: Any) -> None: + fetched = customer_properties.retrieve(property_row.id, fields=["id"]) + assert fetched.id == property_row.id + assert fetched.display_name is None diff --git a/tests/v2/integration/test_customers.py b/tests/v2/integration/test_customers.py new file mode 100644 index 0000000..dca6870 --- /dev/null +++ b/tests/v2/integration/test_customers.py @@ -0,0 +1,144 @@ +"""Live coverage for `client.v2.workspace(slug).customers`, plus its nested +`requests` and `property_values` sub-resources; skips (never fails) when +required env vars are absent, same convention as every other file here.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.customers import Customers +from plane.client import PlaneClient +from plane.models.v2.customers import ( + CreateCustomer, + CreateCustomerPropertyValues, + CreateCustomerRequest, + CustomerWorkItemManageRequest, + UpdateCustomer, + UpdateCustomerRequest, +) +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def customers(client: PlaneClient, workspace_slug: str) -> Customers: + return client.v2.workspace(workspace_slug).customers + + +@pytest.fixture(scope="module") +def customer(customers: Customers) -> Iterator[Any]: + """One customer shared by every test in this module.""" + created = customers.create(CreateCustomer(name=unique_name("customer"))) + yield created + try: + customers.delete(created.id) + except Exception: + pass + + +class TestCustomers: + def test_crud(self, customers: Customers, customer: Any) -> None: + fetched = customers.retrieve(customer.id) + assert fetched.id == customer.id + + page = customers.list() + assert any(c.id == customer.id for c in page.data) + + updated = customers.update(customer.id, UpdateCustomer(stage="onboarding")) + assert updated.stage == "onboarding" + + def test_upsert_creates_then_reconciles(self, customers: Customers) -> None: + marker = unique_name("customer-upsert") + first = customers.upsert( + CreateCustomer(name=marker, external_id=marker, external_source="sdk-it") + ) + try: + second = customers.upsert( + CreateCustomer( + name=marker, external_id=marker, external_source="sdk-it", stage="active" + ) + ) + assert second.id == first.id + assert second.stage == "active" + finally: + customers.delete(first.id) + + def test_find_by_name(self, customers: Customers, customer: Any) -> None: + assert customers.find_by_name(customer.name).id == customer.id + + def test_manage_work_items( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + customers: Customers, + customer: Any, + ) -> None: + work_items = client.v2.workspace(workspace_slug).project(project_id).work_items + work_item = work_items.create(CreateWorkItem(name=unique_name("wi-customer-link"))) + try: + added = customers.manage_work_items( + customer.id, CustomerWorkItemManageRequest(add=[work_item.id]) + ) + assert work_item.id in added.added + + removed = customers.manage_work_items( + customer.id, CustomerWorkItemManageRequest(remove=[work_item.id]) + ) + assert work_item.id in removed.removed + finally: + work_items.delete(work_item.id) + + def test_requests_crud(self, customers: Customers, customer: Any) -> None: + created = customers.requests.create( + customer.id, CreateCustomerRequest(name=unique_name("request")) + ) + try: + fetched = customers.requests.retrieve(customer.id, created.id) + assert fetched.id == created.id + + page = customers.requests.list(customer.id) + assert any(r.id == created.id for r in page.data) + + updated = customers.requests.update( + customer.id, created.id, UpdateCustomerRequest(link="https://x.test") + ) + assert updated.link == "https://x.test" + finally: + customers.requests.delete(customer.id, created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + customers.requests.retrieve(customer.id, created.id) + assert exc_info.value.status == 404 + + def test_property_values_list_is_empty_map_for_a_fresh_customer( + self, customers: Customers, customer: Any + ) -> None: + values = customers.property_values.list(customer.id) + assert values.model_dump() == {} + + def test_property_values_create_against_an_existing_customer_property( + self, client: PlaneClient, workspace_slug: str, customers: Customers, customer: Any + ) -> None: + """Bulk-set requires an existing customer property id; this workspace may + have none provisioned (customer properties are a separate resource, covered + by `test_customer_properties.py`) -- skip rather than fail if so.""" + properties = client.v2.transport.request( + "GET", f"/workspaces/{workspace_slug}/customer-properties/" + ) + rows = properties.get("data", []) if isinstance(properties, dict) else [] + if not rows: + pytest.skip("no customer properties provisioned in this workspace") + property_id = rows[0]["id"] + + customers.property_values.create( + customer.id, CreateCustomerPropertyValues(values={property_id: ["test-value"]}) + ) + + values = customers.property_values.list(customer.id) + assert values.model_dump().get(property_id) == ["test-value"] diff --git a/tests/v2/integration/test_cycle_actions.py b/tests/v2/integration/test_cycle_actions.py new file mode 100644 index 0000000..3a1d0db --- /dev/null +++ b/tests/v2/integration/test_cycle_actions.py @@ -0,0 +1,139 @@ +"""`transfer`/`manage_work_items` (`Cycles`) against a real server; they share +one "completed" gate but check opposite directions on `end_date`, so a work +item must be added before a cycle elapses, then the window moved into the past.""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.cycles import CreateCycle, UpdateCycle +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def completed_cycle(proj: Project) -> Iterator[Any]: + """A cycle whose date window is already in the past -- the only shape + `transfer` accepts as a source (server-enforced). Not eligible for + `manage_work_items(add=...)` -- see the module docstring.""" + now = datetime.now(timezone.utc) + created = proj.cycles.create( + CreateCycle( + name=unique_name("cycle-completed"), + start_date=now - timedelta(days=14), + end_date=now - timedelta(days=7), + ), + ) + yield created + try: + proj.cycles.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def open_cycle(proj: Project) -> Iterator[Any]: + """A cycle whose window is entirely in the future -- eligible for `add`, + reliably rejected by `transfer` as a source.""" + now = datetime.now(timezone.utc) + created = proj.cycles.create( + CreateCycle( + name=unique_name("cycle-open"), + start_date=now + timedelta(days=1), + end_date=now + timedelta(days=8), + ), + ) + yield created + try: + proj.cycles.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def destination_cycle(proj: Project) -> Iterator[Any]: + created = proj.cycles.create(CreateCycle(name=unique_name("cycle-destination"))) + yield created + try: + proj.cycles.delete(created.id) + except Exception: + pass + + +@pytest.fixture +def work_item(proj: Project) -> Iterator[Any]: + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi-cycle-actions"))) + yield created + try: + proj.work_items.delete(created.id) + except Exception: + pass + + +def test_manage_work_items_add_then_remove( + proj: Project, + open_cycle: Any, + work_item: Any, +) -> None: + added = proj.cycles.manage_work_items(open_cycle.id, add=[work_item.id]) + assert work_item.id in (added.added or []) + + removed = proj.cycles.manage_work_items(open_cycle.id, remove=[work_item.id]) + assert work_item.id in (removed.removed or []) + + +def test_transfer_moves_incomplete_work_items( + proj: Project, + destination_cycle: Any, + work_item: Any, +) -> None: + now = datetime.now(timezone.utc) + # Starts open (so the work item can be added), then its window is moved + # into the past -- only then does the server consider it a valid + # `transfer` source. See the module docstring. + source = proj.cycles.create( + CreateCycle( + name=unique_name("cycle-transfer-source"), + start_date=now + timedelta(days=1), + end_date=now + timedelta(days=8), + ), + ) + try: + proj.cycles.manage_work_items(source.id, add=[work_item.id]) + + proj.cycles.update( + source.id, + UpdateCycle( + start_date=now - timedelta(days=14), + end_date=now - timedelta(days=7), + ), + ) + + result = proj.cycles.transfer(source.id, destination_cycle.id) + assert result.new_cycle_id == destination_cycle.id + finally: + proj.cycles.delete(source.id) + + +def test_transfer_from_an_incomplete_cycle_is_rejected( + proj: Project, + open_cycle: Any, + destination_cycle: Any, +) -> None: + """v1 parity: only a completed source cycle can be transferred; uses + `open_cycle` (explicit future `end_date`) as the source.""" + with pytest.raises(PlaneAPIError): + proj.cycles.transfer(open_cycle.id, destination_cycle.id) diff --git a/tests/v2/integration/test_errors.py b/tests/v2/integration/test_errors.py new file mode 100644 index 0000000..05ecca6 --- /dev/null +++ b/tests/v2/integration/test_errors.py @@ -0,0 +1,41 @@ +"""Error contract against a real server: a 404 and a field-validation 400; +`PlaneAPIError` carries `.status`/`.code`/`.detail` always, and `.errors` +(a list of field entries) for per-field validation failures.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient + +from .helpers import ResourceSpec, unique_name + +MISSING_ID = "00000000-0000-0000-0000-000000000000" + + +class TestNotFound: + def test_retrieve_missing_id_surfaces_404( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + with pytest.raises(PlaneAPIError) as exc_info: + ops.retrieve(MISSING_ID) + error = exc_info.value + assert error.status == 404 + assert error.code == "not_found" + assert error.detail + + +class TestValidationFailure: + def test_over_length_name_surfaces_field_errors( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + too_long = unique_name(spec.key) + ("x" * 300) + with pytest.raises(PlaneAPIError) as exc_info: + ops.create(spec.make_write(too_long)) + error = exc_info.value + assert error.status == 400 + assert error.errors is not None + assert any(field_error.field == spec.name_field for field_error in error.errors) diff --git a/tests/v2/integration/test_estimates.py b/tests/v2/integration/test_estimates.py new file mode 100644 index 0000000..99b53c5 --- /dev/null +++ b/tests/v2/integration/test_estimates.py @@ -0,0 +1,168 @@ +"""Estimates + nested estimate points against a real server, mirroring +`test_crud.py`/`test_upsert.py`/`test_bulk.py`'s conventions rather than +`helpers.SPECS`. Not verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.estimates import ( + CreateEstimate, + CreateEstimatePoint, + UpdateEstimate, + UpdateEstimatePoint, +) + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def estimate(proj: Project) -> Iterator[Any]: + """One freshly created estimate, deleted afterwards.""" + created = proj.estimates.create(CreateEstimate(name=unique_name("estimate"), type="points")) + yield created + try: + proj.estimates.delete(created.id) + except Exception: + pass + + +class TestEstimatesCrud: + def test_list_and_retrieve(self, proj: Project, estimate: Any) -> None: + page = proj.estimates.list() + assert any(row.id == estimate.id for row in page.data) + + fetched = proj.estimates.retrieve(estimate.id) + assert fetched.id == estimate.id + assert fetched.type == "points" + + def test_update_only_touches_given_fields(self, proj: Project, estimate: Any) -> None: + new_name = unique_name("estimate-renamed") + updated = proj.estimates.update(estimate.id, UpdateEstimate(name=new_name)) + assert updated.name == new_name + assert updated.type == estimate.type + + def test_delete_then_retrieve_404s(self, proj: Project) -> None: + created = proj.estimates.create(CreateEstimate(name=unique_name("estimate-del"))) + proj.estimates.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.estimates.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_sparse_fields_leave_others_none(self, proj: Project, estimate: Any) -> None: + fetched = proj.estimates.retrieve(estimate.id, fields=["id", "name"]) + assert fetched.id == estimate.id + assert fetched.created_at is None + + +class TestEstimatesUpsert: + def test_upsert_creates_then_reconciles(self, proj: Project) -> None: + marker = unique_name("estimate-upsert") + first = proj.estimates.upsert( + CreateEstimate(name=marker, external_source=marker, external_id="1"), + ) + try: + second = proj.estimates.upsert( + CreateEstimate(name=f"{marker}-v2", external_source=marker, external_id="1"), + ) + assert second.id == first.id + assert second.name == f"{marker}-v2" + finally: + proj.estimates.delete(first.id) + + +class TestEstimatesBulk: + def test_bulk_create_update_delete(self, proj: Project) -> None: + # Only one item: the server allows at most one estimate per project + # (v1 parity, `views/estimates.py`); a batch of 2+ always partially + # conflicts, so this still exercises bulk create/update/delete safely. + items = [CreateEstimate(name=unique_name("estimate-bulk"))] + created = proj.estimates.bulk_create(items) + created.raise_for_failures() + ids = [row.id for row in created.results] + try: + updated = proj.estimates.bulk_update( + [{"id": pk, "name": unique_name("estimate-bulk-renamed")} for pk in ids], + ) + assert updated.succeeded == 1 + finally: + deleted = proj.estimates.bulk_delete(ids) + assert deleted.succeeded == 1 + + +class TestEstimateExpandPoints: + def test_expand_points_inlines_the_points_list(self, proj: Project, estimate: Any) -> None: + created_point = proj.estimates.points.create( + estimate.id, CreateEstimatePoint(value="1", key=0) + ) + try: + fetched = proj.estimates.retrieve(estimate.id, expand=["points"]) + assert fetched.points is not None + assert any(p.id == created_point.id for p in fetched.points) + finally: + proj.estimates.points.delete(estimate.id, created_point.id) + + +class TestEstimatePointsCrud: + def test_crud(self, proj: Project, estimate: Any) -> None: + created = proj.estimates.points.create(estimate.id, CreateEstimatePoint(value="XS", key=1)) + try: + assert created.estimate_id == estimate.id + + fetched = proj.estimates.points.retrieve(estimate.id, created.id) + assert fetched.id == created.id + + page = proj.estimates.points.list(estimate.id) + assert any(p.id == created.id for p in page.data) + + updated = proj.estimates.points.update( + estimate.id, created.id, UpdateEstimatePoint(value="Small") + ) + assert updated.value == "Small" + finally: + proj.estimates.points.delete(estimate.id, created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + proj.estimates.points.retrieve(estimate.id, created.id) + assert exc_info.value.status == 404 + + def test_upsert_creates_then_reconciles(self, proj: Project, estimate: Any) -> None: + marker = unique_name("point-upsert") + first = proj.estimates.points.upsert( + estimate.id, + CreateEstimatePoint(value="2", external_source=marker, external_id="1"), + ) + try: + second = proj.estimates.points.upsert( + estimate.id, + CreateEstimatePoint(value="3", external_source=marker, external_id="1"), + ) + assert second.id == first.id + assert second.value == "3" + finally: + proj.estimates.points.delete(estimate.id, first.id) + + def test_bulk_create_update_delete(self, proj: Project, estimate: Any) -> None: + items = [CreateEstimatePoint(value=str(i), key=i) for i in range(3, 5)] + created = proj.estimates.points.bulk_create(estimate.id, items) + created.raise_for_failures() + ids = [row.id for row in created.results] + try: + updated = proj.estimates.points.bulk_update( + estimate.id, [{"id": pk, "value": "updated"} for pk in ids], + ) + assert updated.succeeded == 2 + finally: + deleted = proj.estimates.points.bulk_delete(estimate.id, ids) + assert deleted.succeeded == 2 diff --git a/tests/v2/integration/test_features.py b/tests/v2/integration/test_features.py new file mode 100644 index 0000000..cef3f06 --- /dev/null +++ b/tests/v2/integration/test_features.py @@ -0,0 +1,69 @@ +"""Feature-toggle singletons against a real server; no gate needed since they +exist for any workspace/project. Restores whatever it flips so this suite is +safe to run repeatedly. Not verified against a live server yet.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2.features import ProjectFeatures, WorkspaceFeatures +from plane.client import PlaneClient +from plane.models.v2.features import UpdateProjectFeature, UpdateWorkspaceFeature + + +@pytest.fixture(scope="module") +def workspace_features(client: PlaneClient, workspace_slug: str) -> WorkspaceFeatures: + return client.v2.workspace(workspace_slug).features + + +@pytest.fixture(scope="module") +def project_features(client: PlaneClient, workspace_slug: str, project_id: str) -> ProjectFeatures: + return client.v2.workspace(workspace_slug).project(project_id).features + + +def test_workspace_features_retrieve(workspace_features: WorkspaceFeatures) -> None: + feature = workspace_features.retrieve() + assert feature.id + + +def test_workspace_features_round_trip_toggle(workspace_features: WorkspaceFeatures) -> None: + original = workspace_features.retrieve() + original_value = bool(original.is_wiki_enabled) + try: + flipped = workspace_features.update( + UpdateWorkspaceFeature(is_wiki_enabled=not original_value) + ) + assert flipped.is_wiki_enabled is (not original_value) + finally: + workspace_features.update(UpdateWorkspaceFeature(is_wiki_enabled=original_value)) + + +def test_project_features_retrieve_has_no_id(project_features: ProjectFeatures) -> None: + feature = project_features.retrieve() + assert not hasattr(feature, "id") + + +def test_project_features_round_trip_toggle( + client: PlaneClient, + project_features: ProjectFeatures, + workspace_slug: str, +) -> None: + # `is_epic_enabled` cannot be re-enabled at the project level once the + # workspace owns work item types (`ProjectFeature.save()`) -- detect that + # via the same `is_work_item_types_enabled` flag `test_work_item_types.py` guards. + workspace_owns_types = bool( + client.v2.transport.request("GET", f"/workspaces/{workspace_slug}/features/").get( + "is_work_item_types_enabled" + ) + ) + original = project_features.retrieve() + original_value = bool(original.is_epic_enabled) + try: + flipped = project_features.update(UpdateProjectFeature(is_epic_enabled=not original_value)) + if workspace_owns_types: + # Coerced back off regardless of what was requested. + assert flipped.is_epic_enabled is False + else: + assert flipped.is_epic_enabled is (not original_value) + finally: + project_features.update(UpdateProjectFeature(is_epic_enabled=original_value)) diff --git a/tests/v2/integration/test_find_one.py b/tests/v2/integration/test_find_one.py new file mode 100644 index 0000000..37b7c90 --- /dev/null +++ b/tests/v2/integration/test_find_one.py @@ -0,0 +1,47 @@ +"""`find_by_name` against a real server: single, zero, and multiple match. +The `?name=` filter is case-insensitive but uniqueness is case-sensitive, so +two differently-cased rows both legally exist and both match one lookup.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import MultipleMatchesFound, NoMatchFound +from plane.client import PlaneClient + +from .helpers import ResourceSpec, unique_name + + +class TestFindByName: + def test_single_match( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + name = unique_name(f"{spec.key}-single") + created = ops.create(spec.make_write(name)) + try: + found = ops.find_by_name(name) + assert found.id == created.id + finally: + ops.delete(created.id) + + def test_zero_match_raises_no_match_found( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + with pytest.raises(NoMatchFound): + ops.find_by_name(unique_name(f"{spec.key}-does-not-exist")) + + def test_multiple_match_raises_multiple_matches_found( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + base = unique_name(f"{spec.key}-multi") + lower = ops.create(spec.make_write(base.lower())) + upper = ops.create(spec.make_write(base.upper())) + try: + with pytest.raises(MultipleMatchesFound): + ops.find_by_name(base) + finally: + ops.delete(lower.id) + ops.delete(upper.id) diff --git a/tests/v2/integration/test_full_scenario.py b/tests/v2/integration/test_full_scenario.py new file mode 100644 index 0000000..cf05609 --- /dev/null +++ b/tests/v2/integration/test_full_scenario.py @@ -0,0 +1,164 @@ +"""One end-to-end scenario, readable top to bottom: project -> work item types + properties -> +states/labels -> cycle + module -> work items (readable fields, membership, comment, transition, +lookup by key) -> wiki collection + pages -> cleanup. Set PLANE_E2E_KEEP=1 to keep the rows.""" + +from __future__ import annotations + +import os +import uuid +from datetime import datetime, timezone +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient +from plane.models.v2.collections import CreateCollection +from plane.models.v2.cycles import CreateCycle +from plane.models.v2.labels import CreateLabel +from plane.models.v2.module_work_items import ModuleWorkItemManageRequest +from plane.models.v2.modules import CreateModule +from plane.models.v2.pages import CreatePage, UpdatePage +from plane.models.v2.projects import CreateProject +from plane.models.v2.work_item_properties import CreateWorkItemProperty +from plane.models.v2.work_item_types import CreateWorkItemType +from plane.models.v2.work_items import CreateWorkItem, CreateWorkItemComment, UpdateWorkItem + +KEEP = os.environ.get("PLANE_E2E_KEEP") == "1" + + +def test_full_scenario(client: PlaneClient, workspace_slug: str) -> None: + ws = client.v2.workspace(workspace_slug) + tag = uuid.uuid4().hex[:5].upper() + cleanup: list[Any] = [] # (label, callable) pairs, run in reverse order + + try: + # ---- 1. Project ------------------------------------------------------------- + project = ws.projects.create( + CreateProject(name=f"E2E Scenario {tag}", identifier=f"E{tag}") + ) + cleanup.append(("project", lambda: ws.projects.delete(project.id))) + proj = ws.project(project.identifier or project.id) # address by key from here on + + # ---- 2. Work item types + a custom property (mode-aware) --------------------- + workspace_mode = bool(ws.features.retrieve().is_work_item_types_enabled) + try: + if workspace_mode: + bug_type = ws.work_item_types.create(CreateWorkItemType(name=f"Bug {tag}")) + cleanup.append(("type", lambda: ws.work_item_types.delete(bug_type.id))) + proj.work_item_types.import_types([bug_type.id]) + severity = ws.work_item_properties.create( + CreateWorkItemProperty(display_name=f"Severity {tag}", property_type="TEXT") + ) + cleanup.append(("property", lambda: ws.work_item_properties.delete(severity.id))) + ws.work_item_types.properties.attach(bug_type.id, [severity.id]) + cleanup.append( + ( + "detach", + lambda: ws.work_item_types.properties.detach(bug_type.id, severity.id), + ) + ) + else: + proj.work_item_types.enable() + bug_type = proj.work_item_types.create(CreateWorkItemType(name=f"Bug {tag}")) + cleanup.append(("type", lambda: proj.work_item_types.delete(bug_type.id))) + severity = proj.work_item_properties.create( + CreateWorkItemProperty(display_name=f"Severity {tag}", property_type="TEXT") + ) + cleanup.append(("property", lambda: proj.work_item_properties.delete(severity.id))) + proj.work_item_types.properties.attach(bug_type.id, [severity.id]) + cleanup.append( + ( + "detach", + lambda: proj.work_item_types.properties.detach(bug_type.id, severity.id), + ) + ) + except PlaneAPIError as exc: + if exc.status == 402: + pytest.skip("work item types are not enabled on this workspace's plan") + raise + severity_key = severity.name + assert severity_key + assert any(row.id == bug_type.id for row in proj.work_item_types.list().data) + + # ---- 3. States and labels --------------------------------------------------- + todo = proj.states.find_by_name("Todo") # seeded with every new project + in_progress = proj.states.find_by_name("In Progress") + bug_label = proj.labels.create(CreateLabel(name="bug", color="#d73a4a")) + cleanup.append(("label", lambda: proj.labels.delete(bug_label.id))) + + # ---- 4. Cycle and module ---------------------------------------------------- + sprint = proj.cycles.create(CreateCycle(name="Sprint 1")) + cleanup.append(("cycle", lambda: proj.cycles.delete(sprint.id))) + auth = proj.modules.create(CreateModule(name="Auth")) + cleanup.append(("module", lambda: proj.modules.delete(auth.id))) + + # ---- 5. Work items: readable fields in, ids out ------------------------------ + item = proj.work_items.create( + CreateWorkItem( + name="Fix login bug", + state="Todo", + labels=["bug"], + type=bug_type.name, + custom_fields={severity_key: "high"}, + ) + ) + cleanup.append(("work item", lambda: proj.work_items.delete(item.id))) + assert item.state_id == todo.id + assert bug_label.id in (item.label_ids or []) + assert item.type_id == bug_type.id + assert item.custom_fields and item.custom_fields[severity_key]["value"] == "high" # type: ignore[index] + assert item.identifier # e.g. "E1A2B-1" + + subtask = proj.work_items.create( + CreateWorkItem(name="Add regression test", parent=item.identifier) + ) + cleanup.append(("sub-task", lambda: proj.work_items.delete(subtask.id))) + assert subtask.parent_id == item.id + + proj.cycles.manage_work_items(sprint.id, add=[item.id, subtask.id]) + proj.modules.manage_work_items(auth.id, ModuleWorkItemManageRequest(add=[item.id])) + in_sprint = {row.id for row in proj.work_items.list(cycle_id=sprint.id).data} + assert in_sprint >= {item.id, subtask.id} + + proj.work_items.comments.create( + item.id, CreateWorkItemComment(comment_html="

Repro attached.

") + ) + assert len(proj.work_items.comments.list(item.id).data) == 1 + + moved = proj.work_items.update(item.id, UpdateWorkItem(state="In Progress")) + assert moved.state_id == in_progress.id + + by_key = ws.work_items.retrieve_by_identifier(item.identifier) # no project needed + assert by_key.id == item.id + assert by_key.cycle_id == sprint.id + assert auth.id in (by_key.module_ids or []) + + # ---- 6. Wiki: a collection, a page inside it, and a project page -------------- + handbook = ws.wiki.collections.create(CreateCollection(name=f"Handbook {tag}")) + cleanup.append(("collection", lambda: ws.wiki.collections.delete(handbook.id))) + runbook = ws.wiki.pages.create(CreatePage(name="Login runbook", collection_id=handbook.id)) + cleanup.append(("wiki page", lambda: _archive_then_delete(ws.wiki.pages, runbook.id))) + assert runbook.collection_id == handbook.id + in_handbook = ws.wiki.pages.list(collection_id=handbook.id).data + assert any(row.id == runbook.id for row in in_handbook) + + notes = proj.pages.create(CreatePage(name="Sprint 1 notes")) + cleanup.append(("project page", lambda: _archive_then_delete(proj.pages, notes.id))) + assert any(row.id == notes.id for row in proj.pages.list().data) + finally: + # ---- 7. Cleanup, newest first; every failure is reported, none is swallowed ------- + failures: list[str] = [] + if not KEEP: + for label, undo in reversed(cleanup): + try: + undo() + except Exception as exc: + failures.append(f"{label}: {exc}") + assert not failures, "cleanup left rows behind:\n" + "\n".join(failures) + + +def _archive_then_delete(pages: Any, page_id: str) -> None: + """v2 refuses to delete a live page: archive first (PATCH `archived_at`), then delete.""" + pages.update(page_id, UpdatePage(archived_at=datetime.now(timezone.utc))) + pages.delete(page_id) diff --git a/tests/v2/integration/test_group_sync.py b/tests/v2/integration/test_group_sync.py new file mode 100644 index 0000000..b2b9b7f --- /dev/null +++ b/tests/v2/integration/test_group_sync.py @@ -0,0 +1,120 @@ +"""Live coverage for `ws.group_sync`: the workspace-wide config singleton plus +project-/workspace-scoped IdP group mappings, gated by `FeatureFlag.IDP_GROUP_SYNC` +(402 if unlicensed). Tests restore mutated config and skip when the flag is off.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.group_sync import GroupSync +from plane.client import PlaneClient +from plane.models.v2.group_sync import ( + CreateGroupMapping, + CreateWorkspaceGroupMapping, + UpdateGroupMapping, + UpdateGroupSyncConfig, +) + +from .helpers import unique_name + + +@pytest.fixture +def group_sync(client: PlaneClient, workspace_slug: str) -> GroupSync: + return client.v2.workspace(workspace_slug).group_sync + + +def _require_group_sync(group_sync: GroupSync) -> None: + try: + group_sync.config.get() + except PlaneAPIError as exc: + if exc.status == 402: + pytest.skip("IDP_GROUP_SYNC feature is not enabled on this workspace") + raise + + +class TestGroupSyncConfig: + @pytest.fixture(autouse=True) + def _skip_if_disabled(self, group_sync: GroupSync) -> None: + _require_group_sync(group_sync) + + def test_retrieve_returns_a_row(self, group_sync: GroupSync) -> None: + config = group_sync.config.get() + assert config.id + + def test_update_round_trips_and_restores(self, group_sync: GroupSync) -> None: + original = group_sync.config.get() + try: + toggled = group_sync.config.update( + UpdateGroupSyncConfig(auto_remove=not bool(original.auto_remove)) + ) + assert toggled.auto_remove != original.auto_remove + finally: + group_sync.config.update(UpdateGroupSyncConfig(auto_remove=original.auto_remove)) + + +class TestGroupSyncProjectMappings: + @pytest.fixture(autouse=True) + def _skip_if_disabled(self, group_sync: GroupSync) -> None: + _require_group_sync(group_sync) + + def test_create_list_update_delete_round_trip( + self, group_sync: GroupSync, project_id: str + ) -> None: + created = group_sync.project_mappings.create( + CreateGroupMapping( + idp_group_name=unique_name("idp-group"), + role_slug="member", + project_id=project_id, + ) + ) + try: + page = group_sync.project_mappings.list() + assert any(row.id == created.id for row in page.data) + + updated = group_sync.project_mappings.update( + created.id, UpdateGroupMapping(role_slug="admin") + ) + assert updated.role_slug == "admin" + finally: + group_sync.project_mappings.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + group_sync.project_mappings.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_all_projects_mapping_has_no_project_id(self, group_sync: GroupSync) -> None: + created = group_sync.project_mappings.create( + CreateGroupMapping( + idp_group_name=unique_name("idp-group-all"), + role_slug="member", + all_projects=True, + ) + ) + try: + assert created.all_projects is True + assert created.project_id is None + finally: + group_sync.project_mappings.delete(created.id) + + +class TestGroupSyncWorkspaceMappings: + @pytest.fixture(autouse=True) + def _skip_if_disabled(self, group_sync: GroupSync) -> None: + _require_group_sync(group_sync) + + def test_create_list_delete_round_trip(self, group_sync: GroupSync) -> None: + created = group_sync.workspace_mappings.create( + CreateWorkspaceGroupMapping( + idp_group_name=unique_name("idp-ws-group"), role_slug="member" + ) + ) + try: + page = group_sync.workspace_mappings.list() + assert any(row.id == created.id for row in page.data) + finally: + group_sync.workspace_mappings.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + group_sync.workspace_mappings.retrieve(created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_initiatives.py b/tests/v2/integration/test_initiatives.py new file mode 100644 index 0000000..38c0ee6 --- /dev/null +++ b/tests/v2/integration/test_initiatives.py @@ -0,0 +1,106 @@ +"""Live coverage for `client.v2.workspace(slug).initiatives`, plus its +workspace-level `labels` sub-resource and child-management actions; skips +(never fails) when required env vars are absent.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.initiatives import Initiatives +from plane.client import PlaneClient +from plane.models.v2.initiatives import ( + CreateInitiative, + CreateInitiativeLabel, + InitiativeChildManageRequest, + UpdateInitiative, + UpdateInitiativeLabel, +) +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def initiatives(client: PlaneClient, workspace_slug: str) -> Initiatives: + return client.v2.workspace(workspace_slug).initiatives + + +class TestInitiatives: + def test_crud(self, initiatives: Initiatives) -> None: + created = initiatives.create(CreateInitiative(name=unique_name("initiative"))) + try: + fetched = initiatives.retrieve(created.id, expand=["lead"]) + assert fetched.id == created.id + + page = initiatives.list() + assert any(i.id == created.id for i in page.data) + + updated = initiatives.update(created.id, UpdateInitiative(state="ACTIVE")) + assert updated.state == "ACTIVE" + + assert initiatives.find_by_name(created.name).id == created.id + finally: + initiatives.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + initiatives.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_manage_labels_projects_and_work_items( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + initiatives: Initiatives, + ) -> None: + initiative = initiatives.create(CreateInitiative(name=unique_name("initiative-manage"))) + label = initiatives.labels.create( + CreateInitiativeLabel(name=unique_name("initiative-label")) + ) + work_items = client.v2.workspace(workspace_slug).project(project_id).work_items + work_item = work_items.create(CreateWorkItem(name=unique_name("wi-initiative-link"))) + try: + labels_result = initiatives.manage_labels( + initiative.id, InitiativeChildManageRequest(add=[label.id]) + ) + assert label.id in labels_result.added + + projects_result = initiatives.manage_projects( + initiative.id, InitiativeChildManageRequest(add=[project_id]) + ) + assert project_id in projects_result.added + + work_items_result = initiatives.manage_work_items( + initiative.id, InitiativeChildManageRequest(add=[work_item.id]) + ) + assert work_item.id in work_items_result.added + + refreshed = initiatives.retrieve(initiative.id) + assert refreshed.label_ids and label.id in refreshed.label_ids + assert refreshed.project_ids and project_id in refreshed.project_ids + finally: + work_items.delete(work_item.id) + initiatives.labels.delete(label.id) + initiatives.delete(initiative.id) + + def test_labels_crud_is_workspace_level_not_nested_under_an_initiative( + self, initiatives: Initiatives + ) -> None: + created = initiatives.labels.create( + CreateInitiativeLabel(name=unique_name("initiative-label")) + ) + try: + fetched = initiatives.labels.retrieve(created.id) + assert fetched.id == created.id + + updated = initiatives.labels.update(created.id, UpdateInitiativeLabel(color="#123456")) + assert updated.color == "#123456" + + assert initiatives.labels.find_by_name(created.name).id == created.id + finally: + initiatives.labels.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + initiatives.labels.retrieve(created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_intakes.py b/tests/v2/integration/test_intakes.py new file mode 100644 index 0000000..a5ad1ee --- /dev/null +++ b/tests/v2/integration/test_intakes.py @@ -0,0 +1,50 @@ +"""Intake work items against a real server; requires the project's intake +feature to already be enabled (no v2 toggle exists to verify/enable it here). +Not verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.intakes import CreateIntakeWorkItem, UpdateIntakeWorkItem + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def intake_work_item(proj: Project) -> Iterator[Any]: + created = proj.intakes.create(CreateIntakeWorkItem(name=unique_name("intake-wi"))) + yield created + try: + proj.intakes.delete(created.id) + except Exception: + pass + + +def test_list(proj: Project) -> None: + page = proj.intakes.list() + assert isinstance(page.data, list) + + +def test_create_retrieve_delete(proj: Project, intake_work_item: Any) -> None: + assert intake_work_item.status == -2 # pending, per IntakeWorkItemStatus + + fetched = proj.intakes.retrieve(intake_work_item.id) + assert fetched.id == intake_work_item.id + + +def test_patch_folds_the_status_transition(proj: Project, intake_work_item: Any) -> None: + """v2 folds v1's separate status endpoint into the same PATCH as any other + field -- accept the intake item in one call.""" + updated = proj.intakes.update(intake_work_item.id, UpdateIntakeWorkItem(status=1)) + assert updated.status == 1 diff --git a/tests/v2/integration/test_invitations.py b/tests/v2/integration/test_invitations.py new file mode 100644 index 0000000..67531f7 --- /dev/null +++ b/tests/v2/integration/test_invitations.py @@ -0,0 +1,64 @@ +"""Workspace invitations against a real server; `create`/`bulk` send real +invitation emails, so this suite uses throwaway `+`-tagged addresses and +deletes every invite it creates. Not verified against a live server yet.""" + +from __future__ import annotations + +import uuid + +import pytest + +from plane.api.v2.invitations import Invitations +from plane.client import PlaneClient +from plane.models.v2.invitations import BulkCreateWorkspaceInvites, CreateWorkspaceInvite + + +def _throwaway_email(prefix: str) -> str: + return f"sdk-it-{prefix}-{uuid.uuid4().hex[:8]}@example.invalid" + + +@pytest.fixture(scope="module") +def invitations(client: PlaneClient, workspace_slug: str) -> Invitations: + return client.v2.workspace(workspace_slug).invitations + + +def test_list(invitations: Invitations) -> None: + page = invitations.list() + assert isinstance(page.data, list) + + +def test_create_retrieve_delete(invitations: Invitations) -> None: + email = _throwaway_email("single") + created = invitations.create(CreateWorkspaceInvite(email=email)) + try: + fetched = invitations.retrieve(created.id) + assert fetched.email == email + assert fetched.accepted is False + finally: + invitations.delete(created.id) + + +def test_bulk_creates_every_new_email(invitations: Invitations) -> None: + emails = [_throwaway_email("bulk-a"), _throwaway_email("bulk-b")] + created = invitations.bulk(BulkCreateWorkspaceInvites(emails=emails)) + try: + assert {row.email for row in created} == set(emails) + finally: + for row in created: + try: + invitations.delete(row.id) + except Exception: + pass + + +def test_bulk_skips_already_invited_emails(invitations: Invitations) -> None: + """Re-inviting an already-pending email is a silent no-op server-side (see + `WorkspaceInviteViewSet.bulk`'s `existing` set) -- confirms the SDK surfaces + that as an empty result, not an error.""" + email = _throwaway_email("dedup") + first = invitations.create(CreateWorkspaceInvite(email=email)) + try: + second = invitations.bulk(BulkCreateWorkspaceInvites(emails=[email])) + assert second == [] + finally: + invitations.delete(first.id) diff --git a/tests/v2/integration/test_members.py b/tests/v2/integration/test_members.py new file mode 100644 index 0000000..7c694de --- /dev/null +++ b/tests/v2/integration/test_members.py @@ -0,0 +1,70 @@ +"""`proj.members`/`ws.members` against a real server; `WorkspaceMembers.remove` +is deliberately not exercised on a real member -- it cascades a deactivation +through every project and isn't reversible, so only its error contract is tested.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.members import ProjectMembers, WorkspaceMembers +from plane.client import PlaneClient +from plane.models.v2.members import UpdateProjectMember, WorkspaceMemberRemove + +from .helpers import unique_name + + +@pytest.fixture +def project_members(client: PlaneClient, workspace_slug: str, project_id: str) -> ProjectMembers: + return client.v2.workspace(workspace_slug).project(project_id).members + + +@pytest.fixture +def workspace_members(client: PlaneClient, workspace_slug: str) -> WorkspaceMembers: + return client.v2.workspace(workspace_slug).members + + +class TestProjectMembers: + def test_list_includes_the_project_creator(self, project_members: ProjectMembers) -> None: + """A freshly created project always has at least the creator as a + member (verified live via the shared session `project` fixture).""" + page = project_members.list() + assert page.data + + def test_retrieve_then_update_role(self, project_members: ProjectMembers) -> None: + existing = project_members.list().data[0] + + fetched = project_members.retrieve(existing.id) + assert fetched.id == existing.id + + updated = project_members.update(existing.id, UpdateProjectMember(role=fetched.role)) + assert updated.id == existing.id + + def test_expand_member_rejects_an_unknown_relation( + self, project_members: ProjectMembers + ) -> None: + with pytest.raises(ValueError, match="bogus"): + project_members.list(expand=["bogus"]) + + def test_retrieve_missing_row_404s(self, project_members: ProjectMembers) -> None: + missing_id = "00000000-0000-0000-0000-000000000000" + with pytest.raises(PlaneAPIError) as exc_info: + project_members.retrieve(missing_id) + assert exc_info.value.status == 404 + + +class TestWorkspaceMembers: + def test_list_includes_at_least_the_api_principal( + self, workspace_members: WorkspaceMembers + ) -> None: + page = workspace_members.list() + assert page.data + + def test_remove_unknown_email_is_a_clean_error( + self, workspace_members: WorkspaceMembers + ) -> None: + """Non-destructive: `unique_name` guarantees this email resolves to no + one, so `remove` cannot actually deactivate a real member here.""" + bogus_email = f"{unique_name('nobody')}@example.invalid" + with pytest.raises(PlaneAPIError): + workspace_members.remove(WorkspaceMemberRemove(email=bogus_email)) diff --git a/tests/v2/integration/test_milestone_work_items.py b/tests/v2/integration/test_milestone_work_items.py new file mode 100644 index 0000000..a60f66b --- /dev/null +++ b/tests/v2/integration/test_milestone_work_items.py @@ -0,0 +1,60 @@ +"""Live coverage for `manage_work_items` (api_v2): add/remove link-management +between a milestone and its work items, reached as +`...milestones.manage_work_items(...)`; offline coverage lives in `tests/v2`.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from plane.api.v2._kernel.errors import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.milestone_work_items import MilestoneWorkItemManageRequest +from plane.models.v2.milestones import CreateMilestone + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def milestone(proj: Project) -> dict[str, Any]: + created = proj.milestones.create(CreateMilestone(title=unique_name("milestone"))) + return created.model_dump() + + +class TestMilestoneWorkItems: + def test_manage_adds_then_removes_a_work_item( + self, + proj: Project, + milestone: dict[str, Any], + work_item: Any, + ) -> None: + added = proj.milestones.manage_work_items( + str(milestone["id"]), + MilestoneWorkItemManageRequest(add=[work_item.id]), + ) + assert work_item.id in added.added + + removed = proj.milestones.manage_work_items( + str(milestone["id"]), + MilestoneWorkItemManageRequest(remove=[work_item.id]), + ) + assert work_item.id in removed.removed + + def test_manage_unknown_milestone_is_404( + self, + proj: Project, + work_item: Any, + ) -> None: + with pytest.raises(PlaneAPIError) as exc_info: + proj.milestones.manage_work_items( + "00000000-0000-0000-0000-000000000000", + MilestoneWorkItemManageRequest(add=[work_item.id]), + ) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_module_work_items.py b/tests/v2/integration/test_module_work_items.py new file mode 100644 index 0000000..f515e7e --- /dev/null +++ b/tests/v2/integration/test_module_work_items.py @@ -0,0 +1,61 @@ +"""Live coverage for `manage_work_items` (api_v2): add/remove link-management +between a module and its work items, reached as +`...modules.manage_work_items(...)`; offline coverage lives in `tests/v2`.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.module_work_items import ModuleWorkItemManageRequest +from plane.models.v2.modules import CreateModule + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def module(proj: Project) -> dict[str, Any]: + created = proj.modules.create(CreateModule(name=unique_name("module"))) + return created.model_dump() + + +class TestModuleWorkItems: + def test_manage_adds_then_removes_a_work_item( + self, + proj: Project, + module: dict[str, Any], + work_item: Any, + ) -> None: + added = proj.modules.manage_work_items( + str(module["id"]), + ModuleWorkItemManageRequest(add=[work_item.id]), + ) + assert work_item.id in added.added + + removed = proj.modules.manage_work_items( + str(module["id"]), + ModuleWorkItemManageRequest(remove=[work_item.id]), + ) + assert work_item.id in removed.removed + + def test_manage_over_100_ids_is_rejected_client_side( + self, + proj: Project, + module: dict[str, Any], + ) -> None: + """The pydantic DTO enforces the golden's `maxItems: 100` before the + request is ever sent -- this never reaches the server.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ModuleWorkItemManageRequest( + add=[f"00000000-0000-0000-0000-{i:012d}" for i in range(101)] + ) diff --git a/tests/v2/integration/test_pages.py b/tests/v2/integration/test_pages.py new file mode 100644 index 0000000..2a29226 --- /dev/null +++ b/tests/v2/integration/test_pages.py @@ -0,0 +1,164 @@ +"""`ProjectPages`/`WikiPages` against a real server; not parametrized through +`helpers.SPECS` since pages have a different shape. Deleting requires archiving +first (server 400s otherwise) -- `_archive` below does that PATCH before every delete.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.pages import ProjectPages, WikiPages +from plane.client import PlaneClient +from plane.models.v2.pages import CreatePage, UpdatePage + +from .helpers import unique_name + + +@pytest.fixture +def project_pages(client: PlaneClient, workspace_slug: str, project_id: str) -> ProjectPages: + return client.v2.workspace(workspace_slug).project(project_id).pages + + +@pytest.fixture +def workspace_pages(client: PlaneClient, workspace_slug: str) -> WikiPages: + return client.v2.workspace(workspace_slug).wiki.pages + + +def _archive_project_page(project_pages: ProjectPages, page_id: str) -> None: + # `is_locked=False` rides along unconditionally: a locked page 400s "Page is + # locked." on any PATCH that omits `is_locked` (`_page_write_guards` in + # `views/pages.py`), and this is cleanup, not the thing under test. + project_pages.update( + page_id, UpdatePage(archived_at=datetime.now(timezone.utc), is_locked=False) + ) + + +def _archive_workspace_page(workspace_pages: WikiPages, page_id: str) -> None: + workspace_pages.update( + page_id, UpdatePage(archived_at=datetime.now(timezone.utc), is_locked=False) + ) + + +class TestProjectPages: + def test_create_then_retrieve(self, project_pages: ProjectPages) -> None: + name = unique_name("page") + created = project_pages.create(CreatePage(name=name)) + try: + assert created.name == name + assert created.id + + fetched = project_pages.retrieve(created.id) + assert fetched.id == created.id + finally: + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + + def test_list_by_project_uuid_and_key_agree( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + project_pages: ProjectPages, + ) -> None: + created = project_pages.create(CreatePage(name=unique_name("page"))) + try: + by_id = {row.id for row in project_pages.list().data} + key_scope = client.v2.workspace(workspace_slug).project(project_key) + by_key = {row.id for row in key_scope.pages.list().data} + assert created.id in by_id + assert by_id == by_key + finally: + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + + def test_patch_updates_only_the_given_fields(self, project_pages: ProjectPages) -> None: + created = project_pages.create(CreatePage(name=unique_name("page"))) + try: + new_name = unique_name("page-renamed") + updated = project_pages.update(created.id, UpdatePage(name=new_name)) + assert updated.id == created.id + assert updated.name == new_name + finally: + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + + def test_delete_then_retrieve_404s(self, project_pages: ProjectPages) -> None: + created = project_pages.create(CreatePage(name=unique_name("page"))) + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + project_pages.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_fields_returns_a_sparse_row(self, project_pages: ProjectPages) -> None: + created = project_pages.create(CreatePage(name=unique_name("page"))) + try: + sparse = project_pages.retrieve(created.id, fields=["id"]) + assert sparse.id == created.id + assert sparse.name is None + finally: + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + + def test_expand_owned_by_rejects_an_unknown_relation( + self, project_pages: ProjectPages + ) -> None: + with pytest.raises(ValueError, match="bogus"): + project_pages.list(expand=["bogus"]) + + def test_find_by_name(self, project_pages: ProjectPages) -> None: + name = unique_name("page") + created = project_pages.create(CreatePage(name=name)) + try: + assert project_pages.find_by_name(name).id == created.id + finally: + _archive_project_page(project_pages, created.id) + project_pages.delete(created.id) + + +class TestWikiPages: + def test_create_then_retrieve_is_global(self, workspace_pages: WikiPages) -> None: + name = unique_name("wiki-page") + created = workspace_pages.create(CreatePage(name=name)) + try: + assert created.name == name + assert created.is_global is True + + fetched = workspace_pages.retrieve(created.id) + assert fetched.id == created.id + finally: + _archive_workspace_page(workspace_pages, created.id) + workspace_pages.delete(created.id) + + def test_patch_updates_only_the_given_fields(self, workspace_pages: WikiPages) -> None: + created = workspace_pages.create(CreatePage(name=unique_name("wiki-page"))) + try: + updated = workspace_pages.update(created.id, UpdatePage(is_locked=True)) + assert updated.is_locked is True + finally: + _archive_workspace_page(workspace_pages, created.id) + workspace_pages.delete(created.id) + + def test_delete_then_retrieve_404s(self, workspace_pages: WikiPages) -> None: + created = workspace_pages.create(CreatePage(name=unique_name("wiki-page"))) + _archive_workspace_page(workspace_pages, created.id) + workspace_pages.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + workspace_pages.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_project_scoped_and_workspace_scoped_pages_are_disjoint( + self, + project_pages: ProjectPages, + workspace_pages: WikiPages, + ) -> None: + project_page = project_pages.create(CreatePage(name=unique_name("proj-page"))) + try: + workspace_ids = {row.id for row in workspace_pages.list().data} + assert project_page.id not in workspace_ids + finally: + _archive_project_page(project_pages, project_page.id) + project_pages.delete(project_page.id) diff --git a/tests/v2/integration/test_pagination.py b/tests/v2/integration/test_pagination.py new file mode 100644 index 0000000..a04ffa4 --- /dev/null +++ b/tests/v2/integration/test_pagination.py @@ -0,0 +1,88 @@ +"""Pagination against a real server: both envelopes, and `iterate()` auto-paging +both. Rows are tagged with a per-class `external_source` marker and seeded once +per class to keep the rate-limited API key's request cost down.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any, NamedTuple + +import pytest + +from plane.client import PlaneClient + +from .helpers import SPECS, ResourceSpec, unique_name + +ROW_COUNT = 4 +PER_PAGE = 2 # forces at least 2 pages over ROW_COUNT rows + + +class Seeded(NamedTuple): + ops: Any + ids: list[str] + marker: str + + +@pytest.fixture(scope="class", params=sorted(SPECS), ids=sorted(SPECS)) +def spec(request: pytest.FixtureRequest) -> ResourceSpec: + """Overrides the function-scoped `spec` from conftest so the seed fixture + below -- which must outlive a single test function -- can depend on it.""" + return SPECS[request.param] + + +@pytest.fixture(scope="class") +def seeded( + client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec +) -> Iterator[Seeded]: + ops = spec.ops(client, workspace_slug, project_id) + marker = unique_name(f"{spec.key}-pg") + items = [ + spec.make_write( + unique_name(f"{spec.key}-pg-row"), external_source=marker, external_id=str(i) + ) + for i in range(ROW_COUNT) + ] + result = ops.bulk_create(items) + result.raise_for_failures() + ids = [row.id for row in result.results] + yield Seeded(ops=ops, ids=ids, marker=marker) + ops.bulk_delete(ids) + + +class TestPagination: + """All four scenarios share one seeded batch per resource (see `seeded` above).""" + + def test_offset_list_one_page_reports_a_next_offset(self, seeded: Seeded) -> None: + page = seeded.ops.list(external_source=seeded.marker, per_page=PER_PAGE) + assert len(page.data) == PER_PAGE + assert page.next is not None + assert page.total_count == ROW_COUNT + + def test_offset_iter_follows_every_page(self, seeded: Seeded) -> None: + rows = list(seeded.ops.iterate(external_source=seeded.marker, per_page=PER_PAGE)) + assert {row.id for row in rows} == set(seeded.ids) + + def test_cursor_list_one_page_uses_the_cursor_envelope(self, seeded: Seeded) -> None: + # Both resources default-order on a non-unique float column, which the + # server refuses to keyset on (`ordering_not_cursor_eligible`); cursor + # pagination needs an explicit cursor-safe `order_by` (`created_at`/`id`). + page = seeded.ops.list( + external_source=seeded.marker, + per_page=PER_PAGE, + paginate="cursor", + order_by="created_at", + ) + assert len(page.data) == PER_PAGE + assert page.has_more is True + assert page.next_cursor is not None + + def test_cursor_iter_follows_every_page(self, seeded: Seeded) -> None: + rows = list( + seeded.ops.iterate( + external_source=seeded.marker, + per_page=PER_PAGE, + paginate="cursor", + order_by="created_at", + ) + ) + assert {row.id for row in rows} == set(seeded.ids) diff --git a/tests/v2/integration/test_permission_schemes.py b/tests/v2/integration/test_permission_schemes.py new file mode 100644 index 0000000..c39bc2c --- /dev/null +++ b/tests/v2/integration/test_permission_schemes.py @@ -0,0 +1,37 @@ +"""Live coverage for `ws.permission_schemes`: a read-only list/retrieve surface +over the workspace's built-in and custom schemes. Needs no scope beyond +`workspaces.roles:read`; offline coverage lives in `tests/v2`.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2._kernel.errors import PlaneAPIError +from plane.api.v2.permission_schemes import PermissionSchemes +from plane.client import PlaneClient + + +@pytest.fixture +def permission_schemes(client: PlaneClient, workspace_slug: str) -> PermissionSchemes: + return client.v2.workspace(workspace_slug).permission_schemes + + +class TestPermissionSchemes: + def test_list_includes_system_schemes(self, permission_schemes: PermissionSchemes) -> None: + """Every workspace ships at least the built-in system schemes -- this + should never come back empty, even in a brand-new workspace.""" + page = permission_schemes.list() + assert page.data + assert any(scheme.is_system for scheme in page.data) + + def test_retrieve_round_trips_a_listed_scheme( + self, permission_schemes: PermissionSchemes + ) -> None: + first = permission_schemes.list().data[0] + fetched = permission_schemes.retrieve(first.id) + assert fetched.id == first.id + + def test_retrieve_unknown_id_is_404(self, permission_schemes: PermissionSchemes) -> None: + with pytest.raises(PlaneAPIError) as exc_info: + permission_schemes.retrieve("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_permissions.py b/tests/v2/integration/test_permissions.py new file mode 100644 index 0000000..edcce9e --- /dev/null +++ b/tests/v2/integration/test_permissions.py @@ -0,0 +1,30 @@ +"""Effective permissions against a real server: read-only, no feature gate. +`WorkspacePermissions`/`ProjectPermissions` each expose a single `me()` method. +Not verified against a live server yet.""" + +from __future__ import annotations + +from plane.client import PlaneClient + + +def test_me(client: PlaneClient, workspace_slug: str) -> None: + result = client.v2.workspace(workspace_slug).permissions.me() + assert result.relation is not None + assert isinstance(result.permission_grants, list) + + +def test_project_me(client: PlaneClient, workspace_slug: str, project_id: str) -> None: + result = client.v2.workspace(workspace_slug).project(project_id).permissions.me() + assert result.relation is not None + assert isinstance(result.permission_grants, list) + + +def test_workspace_and_project_grants_can_differ( + client: PlaneClient, workspace_slug: str, project_id: str +) -> None: + """Not the same call under two names -- the workspace and project grant sets + are computed from different `RESOURCE_ACTIONS` subsets server-side.""" + ws = client.v2.workspace(workspace_slug) + workspace_grants = set(ws.permissions.me().permission_grants or []) + project_grants = set(ws.project(project_id).permissions.me().permission_grants or []) + assert workspace_grants != project_grants or workspace_grants == set() diff --git a/tests/v2/integration/test_project_work_item_types_flow.py b/tests/v2/integration/test_project_work_item_types_flow.py new file mode 100644 index 0000000..a05ade3 --- /dev/null +++ b/tests/v2/integration/test_project_work_item_types_flow.py @@ -0,0 +1,179 @@ +"""End-to-end flow for PROJECT-managed work item types (port of plane-ee's +`tests/e2e/api_v2/project_work_item_types_flow.py`). Runs only when the workspace is +in project mode; set PLANE_E2E_KEEP=1 to leave the created rows for inspection.""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.client import PlaneClient +from plane.models.v2.work_item_properties import ( + CreateWorkItemProperty, + CreateWorkItemPropertyOption, +) +from plane.models.v2.work_item_types import CreateWorkItemType +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + +KEEP = os.environ.get("PLANE_E2E_KEEP") == "1" + + +def _name(row: Any) -> str: + return str(row["name"] if isinstance(row, dict) else row.name) + + +def _id(row: Any) -> str: + return str(row["id"] if isinstance(row, dict) else row.id) + + +@pytest.fixture +def ws(client: PlaneClient, workspace_slug: str) -> Workspace: + return client.v2.workspace(workspace_slug) + + +@pytest.fixture +def proj(ws: Workspace, project_id: str) -> Project: + return ws.project(project_id) + + +@pytest.fixture(autouse=True) +def _require_project_mode(ws: Workspace) -> None: + if ws.features.retrieve().is_work_item_types_enabled: + pytest.skip("workspace manages work item types at the workspace level; needs project mode") + + +def test_project_work_item_types_flow(proj: Project) -> None: + suffix = unique_name("")[1:] + created_work_items: list[str] = [] + attached: list[str] = [] + properties: list[str] = [] + type_id: str | None = None + + try: + # 1. enable -- bootstraps the project's default (non-epic) type + enabled = proj.work_item_types.enable() + assert enabled.is_epic is False + + # 2. create a project-owned type + type_name = f"E2E Type {suffix}" + wtype = proj.work_item_types.create(CreateWorkItemType(name=type_name)) + type_id = wtype.id + assert wtype.name == type_name + assert wtype.is_epic is False + + # 3. list + retrieve + schema + assert any(row.id == type_id for row in proj.work_item_types.list().data) + assert proj.work_item_types.retrieve(type_id).id == type_id + schema = proj.work_item_types.schema(type_id) + assert schema.fields is not None + assert schema.custom_fields is not None + + # 4. TEXT property + text_prop = proj.work_item_properties.create( + CreateWorkItemProperty(display_name=f"Severity {suffix}", property_type="TEXT") + ) + properties.append(text_prop.id) + sev_key = text_prop.name # slugified display_name; the custom_fields key + assert sev_key + + # 5. OPTION property with inline options + option_prop = proj.work_item_properties.create( + CreateWorkItemProperty( + display_name=f"Tier {suffix}", + property_type="OPTION", + options=[ + CreateWorkItemPropertyOption(name="Gold", is_default=True), + CreateWorkItemPropertyOption(name="Silver"), + ], + ) + ) + properties.append(option_prop.id) + tier_key = option_prop.name + assert tier_key + assert {_name(o) for o in option_prop.options or []} == {"Gold", "Silver"} + gold_id = next(_id(o) for o in option_prop.options or [] if _name(o) == "Gold") + + # 6. add one more option through the options endpoint + proj.work_item_properties.options.create( + option_prop.id, CreateWorkItemPropertyOption(name="Bronze") + ) + names = {row.name for row in proj.work_item_properties.options.list(option_prop.id).data} + assert names == {"Gold", "Silver", "Bronze"} + + # 7. a second default option is rejected + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_properties.options.create( + option_prop.id, CreateWorkItemPropertyOption(name="Platinum", is_default=True) + ) + assert exc_info.value.status == 400 + + # 8. attach both properties to the type + result = proj.work_item_types.properties.attach(type_id, [text_prop.id, option_prop.id]) + attached.extend([text_prop.id, option_prop.id]) + assert {text_prop.id, option_prop.id}.issubset(set(result.properties)) + listed = {row.id for row in proj.work_item_types.properties.list(type_id).data} + assert {text_prop.id, option_prop.id}.issubset(listed) + + # 9. create a work item of this type with custom_fields, read it back + item = proj.work_items.create( + CreateWorkItem( + name="E2E work item", + type_id=type_id, + custom_fields={sev_key: "high", tier_key: gold_id}, + ) + ) + created_work_items.append(item.id) + assert item.custom_fields is not None + assert item.custom_fields[sev_key]["value"] == "high" # type: ignore[index] + assert item.custom_fields[tier_key]["value_detail"]["name"] == "Gold" # type: ignore[index] + + fetched = proj.work_items.retrieve(item.id) + assert fetched.custom_fields is not None + assert fetched.custom_fields[sev_key]["value"] == "high" # type: ignore[index] + + # 10. the readable `type` name resolves to the same type + item2 = proj.work_items.create(CreateWorkItem(name="E2E by type name", type=type_name)) + created_work_items.append(item2.id) + assert item2.type_id == type_id + + # 11. mark-default + assert proj.work_item_types.mark_default(type_id).is_default is True + finally: + if not KEEP: + for work_item_id in created_work_items: + _swallow(proj.work_items.delete, work_item_id) + for property_id in attached: + _swallow(proj.work_item_types.properties.detach, type_id or "", property_id) + for property_id in properties: + _swallow(proj.work_item_properties.delete, property_id) + + if not KEEP and type_id: + # the type is now the project default, so delete is refused + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_types.delete(type_id) + assert exc_info.value.status == 409 + + +def _swallow(action: Any, *args: Any) -> None: + """Best-effort cleanup step; failures are collected and reported at the end.""" + try: + action(*args) + except Exception as exc: + _cleanup_failures.append(f"{getattr(action, '__qualname__', action)}{args}: {exc}") + + +_cleanup_failures: list[str] = [] + + +@pytest.fixture(autouse=True) +def _report_cleanup_failures() -> Any: + _cleanup_failures.clear() + yield + assert not _cleanup_failures, "cleanup left rows behind:\n" + "\n".join(_cleanup_failures) diff --git a/tests/v2/integration/test_projects.py b/tests/v2/integration/test_projects.py new file mode 100644 index 0000000..64e8446 --- /dev/null +++ b/tests/v2/integration/test_projects.py @@ -0,0 +1,139 @@ +"""Live coverage for `ws.projects`, reached through the chain +(`client.v2.workspace(slug).projects`); no method takes a `workspace_slug` +parameter, and every detail method's first positional argument is `project`.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.projects import Projects +from plane.client import PlaneClient +from plane.models.v2.projects import CreateProject, UpdateProject + +from .helpers import unique_name + + +@pytest.fixture +def projects(client: PlaneClient, workspace_slug: str) -> Projects: + return client.v2.workspace(workspace_slug).projects + + +@pytest.fixture +def temp_project(projects: Projects) -> Iterator[Any]: + """A project created and torn down by this file, independent of the shared + session `project` fixture, since these tests exercise `Projects` itself.""" + identifier = f"B{unique_name('').split('-')[-1][:8].upper()}" + created = projects.create( + CreateProject(identifier=identifier, name=f"Projects IT {identifier}") + ) + yield created + try: + projects.delete(created.id) + except Exception: + pass + + +class TestProjects: + def test_retrieve_by_uuid_and_by_identifier_agree( + self, projects: Projects, temp_project: Any + ) -> None: + by_id = projects.retrieve(temp_project.id) + by_key = projects.retrieve(temp_project.identifier) + assert by_id.id == by_key.id == temp_project.id + + def test_list_includes_the_created_project(self, projects: Projects, temp_project: Any) -> None: + page = projects.list(identifier=temp_project.identifier) + assert any(row.id == temp_project.id for row in page.data) + + def test_update_renames_the_project(self, projects: Projects, temp_project: Any) -> None: + updated = projects.update( + temp_project.identifier, UpdateProject(name="Renamed by projects IT") + ) + assert updated.name == "Renamed by projects IT" + + def test_archive_then_unarchive_round_trip(self, projects: Projects, temp_project: Any) -> None: + assert projects.archive(temp_project.identifier) is None + archived = projects.retrieve(temp_project.identifier) + assert archived.archived_at is not None + + assert projects.unarchive(temp_project.identifier) is None + restored = projects.retrieve(temp_project.identifier) + assert restored.archived_at is None + + def test_summary_reports_zero_counts_for_a_fresh_project( + self, projects: Projects, temp_project: Any + ) -> None: + summary = projects.summary(temp_project.identifier) + assert summary.id == temp_project.id + assert summary.identifier == temp_project.identifier + assert isinstance(summary.counts, dict) + + def test_summary_narrows_to_requested_counts( + self, projects: Projects, temp_project: Any + ) -> None: + summary = projects.summary(temp_project.identifier, counts=["states"]) + assert set(summary.counts) <= {"states"} + + def test_upsert_reconciles_on_external_identity(self, projects: Projects) -> None: + external_id = unique_name("project-ext") + identifier = f"U{unique_name('').split('-')[-1][:8].upper()}" + first = projects.upsert( + CreateProject( + identifier=identifier, + name="Upsert IT v1", + external_id=external_id, + external_source="projects-it", + ) + ) + try: + second = projects.upsert( + CreateProject( + identifier=identifier, + name="Upsert IT v2", + external_id=external_id, + external_source="projects-it", + ) + ) + assert second.id == first.id + assert second.name == "Upsert IT v2" + finally: + projects.delete(first.id) + + def test_bulk_create_then_bulk_update(self, projects: Projects) -> None: + id_a = f"A{unique_name('').split('-')[-1][:8].upper()}" + id_b = f"B{unique_name('').split('-')[-1][:8].upper()}" + result = projects.bulk_create( + [ + CreateProject(identifier=id_a, name="Bulk A"), + CreateProject(identifier=id_b, name="Bulk B"), + ] + ) + try: + assert result.succeeded == 2 + ids = [row.id for row in result.results if hasattr(row, "id")] + # Project names are unique per workspace (confirmed live) -- renaming + # every row to the same literal string would 409 all but the first, + # so each row needs its own new name. + update_result = projects.bulk_update( + [{"id": pk, "name": f"Bulk renamed {i}"} for i, pk in enumerate(ids)] + ) + assert update_result.succeeded == 2 + finally: + for pk in [id_a, id_b]: + try: + projects.delete(pk) + except Exception: + pass + + def test_projects_has_no_bulk_delete(self, projects: Projects) -> None: + assert not hasattr(projects, "bulk_delete") + + def test_role_distribution_reflects_the_calling_member(self, projects: Projects) -> None: + """The token's own membership counts toward at least one role.""" + report = projects.role_distribution() + assert report.total_memberships >= 1 + assert report.total_distinct_members >= 1 + assert sum(role.membership_count for role in report.roles) == report.total_memberships diff --git a/tests/v2/integration/test_releases.py b/tests/v2/integration/test_releases.py new file mode 100644 index 0000000..b1155fd --- /dev/null +++ b/tests/v2/integration/test_releases.py @@ -0,0 +1,323 @@ +"""`client.v2.workspace(slug).releases` and its sub-resources against a real +server; gated by the `RELEASES` flag plus `is_release_enabled`, so fixtures +skip (never fail) the module when that gate is closed. No upsert/bulk-* here.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.releases import Releases +from plane.client import PlaneClient +from plane.models.v2.releases import ( + CreateRelease, + CreateReleaseComment, + CreateReleaseLabel, + CreateReleaseLink, + CreateReleaseTag, + UpdateRelease, + UpdateReleaseChangelog, + UpdateReleaseComment, + UpdateReleaseLabel, + UpdateReleaseLink, + UpdateReleaseTag, +) + +from .helpers import unique_name + + +@pytest.fixture +def releases(client: PlaneClient, workspace_slug: str) -> Releases: + return client.v2.workspace(workspace_slug).releases + + +def _create_release_or_skip(releases: Releases, name: str) -> Any: + try: + return releases.create(CreateRelease(name=name)) + except PlaneAPIError as exc: + if exc.status == 402: + pytest.skip("RELEASES not enabled on this workspace") + raise + + +@pytest.fixture +def release(releases: Releases) -> Iterator[Any]: + """One freshly created release, deleted afterwards.""" + created = _create_release_or_skip(releases, unique_name("release")) + yield created + try: + releases.delete(created.id) + except Exception: + pass + + +class TestCRUD: + def test_create_returns_the_written_fields(self, releases: Releases) -> None: + name = unique_name("release") + created = _create_release_or_skip(releases, name) + try: + assert created.name == name + assert created.id + assert created.status is not None + finally: + releases.delete(created.id) + + def test_retrieve_returns_the_created_row(self, releases: Releases, release: Any) -> None: + fetched = releases.retrieve(release.id) + assert fetched.id == release.id + assert fetched.name == release.name + + def test_retrieve_with_fields_is_sparse(self, releases: Releases, release: Any) -> None: + fetched = releases.retrieve(release.id, fields=["id", "name"]) + assert fetched.id == release.id + assert fetched.status is None + + def test_retrieve_with_expand(self, releases: Releases, release: Any) -> None: + # No lead/tag set on this row, so the expand keys are simply absent/None -- + # this only proves the server accepted the enum, not that it populated it. + fetched = releases.retrieve(release.id, expand=["lead", "tag"]) + assert fetched.id == release.id + + def test_list_finds_the_created_row(self, releases: Releases, release: Any) -> None: + page = releases.list(name=release.name) + assert any(row.id == release.id for row in page.data) + + def test_find_by_name(self, releases: Releases, release: Any) -> None: + found = releases.find_by_name(release.name) + assert found.id == release.id + + def test_patch_updates_only_the_given_fields(self, releases: Releases, release: Any) -> None: + new_name = unique_name("release-renamed") + updated = releases.update(release.id, UpdateRelease(name=new_name)) + assert updated.id == release.id + assert updated.name == new_name + + def test_delete_then_retrieve_404s(self, releases: Releases) -> None: + created = _create_release_or_skip(releases, unique_name("release")) + releases.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + releases.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestErrors: + MISSING_ID = "00000000-0000-0000-0000-000000000000" + + def test_retrieve_missing_id_surfaces_404(self, releases: Releases) -> None: + with pytest.raises(PlaneAPIError) as exc_info: + releases.retrieve(self.MISSING_ID) + error = exc_info.value + assert error.status == 404 + assert error.code == "not_found" + + def test_over_length_name_surfaces_field_errors(self, releases: Releases) -> None: + too_long = unique_name("release") + ("x" * 300) + with pytest.raises(PlaneAPIError) as exc_info: + releases.create(CreateRelease(name=too_long)) + error = exc_info.value + if error.status == 402: + pytest.skip("RELEASES not enabled on this workspace") + assert error.status == 400 + assert error.errors is not None + assert any(field_error.field == "name" for field_error in error.errors) + + +class TestPagination: + ROW_COUNT = 4 + PER_PAGE = 2 + + @pytest.fixture + def seeded(self, releases: Releases) -> Iterator[tuple[str, list[str]]]: + # `releases_list` has no `external_source`/`external_id` filter (unlike + # states/labels) -- `search` is the only free-text filter it exposes, so + # the marker is embedded in the name and matched via `?search=`. + marker = unique_name("release-pg") + ids = [] + for _ in range(self.ROW_COUNT): + row = _create_release_or_skip(releases, f"{marker}-{unique_name('row')}") + ids.append(row.id) + yield marker, ids + for pk in ids: + try: + releases.delete(pk) + except Exception: + pass + + def test_offset_list_reports_a_next_offset( + self, releases: Releases, seeded: tuple[str, list[str]] + ) -> None: + marker, _ids = seeded + page = releases.list(search=marker, per_page=self.PER_PAGE) + assert len(page.data) == self.PER_PAGE + assert page.next is not None + assert page.total_count == self.ROW_COUNT + + def test_offset_iterate_follows_every_page( + self, releases: Releases, seeded: tuple[str, list[str]] + ) -> None: + marker, ids = seeded + rows = list(releases.iterate(search=marker, per_page=self.PER_PAGE)) + assert {row.id for row in rows} == set(ids) + + def test_cursor_list_uses_the_cursor_envelope( + self, releases: Releases, seeded: tuple[str, list[str]] + ) -> None: + marker, _ids = seeded + page = releases.list( + search=marker, + per_page=self.PER_PAGE, + paginate="cursor", + order_by="created_at", + ) + assert len(page.data) == self.PER_PAGE + assert page.has_more is True + assert page.next_cursor is not None + + +class TestManageLabelsAndWorkItems: + def test_manage_labels_add_then_remove(self, releases: Releases, release: Any) -> None: + label = releases.labels.create(CreateReleaseLabel(name=unique_name("rel-label"))) + try: + added = releases.manage_labels(release.id, add=[label.id]) + assert label.id in added.added + + fetched = releases.retrieve(release.id) + assert fetched.label_ids is not None + assert label.id in fetched.label_ids + + removed = releases.manage_labels(release.id, remove=[label.id]) + assert label.id in removed.removed + finally: + releases.labels.delete(label.id) + + def test_manage_work_items_add_then_remove( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + releases: Releases, + release: Any, + ) -> None: + from plane.models.v2.work_items import CreateWorkItem + + work_items = client.v2.workspace(workspace_slug).project(project_id).work_items + work_item = work_items.create(CreateWorkItem(name=unique_name("release-wi"))) + try: + added = releases.manage_work_items(release.id, add=[work_item.id]) + assert work_item.id in added.added + + removed = releases.manage_work_items(release.id, remove=[work_item.id]) + assert work_item.id in removed.removed + finally: + work_items.delete(work_item.id) + + +class TestChangelog: + def test_get_then_update(self, releases: Releases, release: Any) -> None: + changelog = releases.changelog.retrieve(release.id) + assert changelog.release_id == release.id + + updated = releases.changelog.update( + release.id, UpdateReleaseChangelog(description_html="

notes

") + ) + assert updated.description_html == "

notes

" + + +class TestComments: + def test_crud(self, releases: Releases, release: Any) -> None: + created = releases.comments.create( + release.id, CreateReleaseComment(comment_html="

hi

") + ) + try: + assert created.release_id == release.id + + fetched = releases.comments.retrieve(release.id, created.id) + assert fetched.id == created.id + + page = releases.comments.list(release.id) + assert any(c.id == created.id for c in page.data) + + updated = releases.comments.update( + release.id, created.id, UpdateReleaseComment(comment_html="

bye

") + ) + assert updated.comment_html == "

bye

" + finally: + releases.comments.delete(release.id, created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + releases.comments.retrieve(release.id, created.id) + assert exc_info.value.status == 404 + + +class TestLinks: + def test_crud(self, releases: Releases, release: Any) -> None: + created = releases.links.create( + release.id, CreateReleaseLink(title="Docs", url="https://example.com/a") + ) + try: + assert created.url == "https://example.com/a" + + updated = releases.links.update( + release.id, created.id, UpdateReleaseLink(url="https://example.com/b") + ) + assert updated.url == "https://example.com/b" + + page = releases.links.list(release.id) + assert any(link.id == created.id for link in page.data) + finally: + releases.links.delete(release.id, created.id) + + +class TestLabelsCatalog: + def test_crud_and_find_by_name(self, releases: Releases) -> None: + name = unique_name("rel-label") + created = releases.labels.create(CreateReleaseLabel(name=name)) + try: + assert created.name == name + + fetched = releases.labels.retrieve(created.id) + assert fetched.id == created.id + + found = releases.labels.find_by_name(name) + assert found.id == created.id + + new_name = unique_name("rel-label-renamed") + updated = releases.labels.update(created.id, UpdateReleaseLabel(name=new_name)) + assert updated.name == new_name + finally: + releases.labels.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + releases.labels.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestTagsCatalog: + def test_crud_find_by_version_and_prefixed_lookup(self, releases: Releases) -> None: + version = f"0.0.0-{unique_name('rel-tag')}" + created = releases.tags.create(CreateReleaseTag(version=version)) + try: + assert created.version == version + + by_uuid = releases.tags.retrieve(created.id) + assert by_uuid.id == created.id + + # NOT `retrieve(f"version:{version}")`: the golden implies a + # `version:`-prefixed lookup but it 404s live (no custom `get_object`); + # `find_by_version` is the real way to resolve a version to an id. + found = releases.tags.find_by_version(version) + assert found.id == created.id + + updated = releases.tags.update( + created.id, UpdateReleaseTag(description="release notes") + ) + assert updated.description == "release notes" + finally: + releases.tags.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + releases.tags.retrieve(created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_roles.py b/tests/v2/integration/test_roles.py new file mode 100644 index 0000000..40f1288 --- /dev/null +++ b/tests/v2/integration/test_roles.py @@ -0,0 +1,38 @@ +"""Roles against a real server: read-only, no feature gate -- every workspace +has system roles seeded. Not verified against a live server yet.""" + +from __future__ import annotations + +from plane.api.v2.roles import Roles +from plane.client import PlaneClient + + +def test_list_includes_system_roles(client: PlaneClient, workspace_slug: str) -> None: + roles: Roles = client.v2.workspace(workspace_slug).roles + page = roles.list() + assert page.data + assert any(row.is_system for row in page.data) + + +def test_retrieve(client: PlaneClient, workspace_slug: str) -> None: + roles: Roles = client.v2.workspace(workspace_slug).roles + first = roles.list().data[0] + fetched = roles.retrieve(first.id) + assert fetched.id == first.id + + +def test_find_by_name(client: PlaneClient, workspace_slug: str) -> None: + # Role names collide across namespaces by design (confirmed live: + # `MultipleMatchesFound` for "Admin") -- scope both the list and the + # lookup to one namespace to disambiguate. + roles: Roles = client.v2.workspace(workspace_slug).roles + first = roles.list(namespace="workspace").data[0] + assert first.name is not None + found = roles.find_by_name(first.name, namespace="workspace") + assert found.id == first.id + + +def test_list_filters_by_namespace(client: PlaneClient, workspace_slug: str) -> None: + roles: Roles = client.v2.workspace(workspace_slug).roles + page = roles.list(namespace="workspace") + assert all(row.namespace == "workspace" for row in page.data) diff --git a/tests/v2/integration/test_scope_parity.py b/tests/v2/integration/test_scope_parity.py new file mode 100644 index 0000000..5ae7631 --- /dev/null +++ b/tests/v2/integration/test_scope_parity.py @@ -0,0 +1,78 @@ +"""Scope parity against a real server: `.project(project_id)` and +`.project(project_key)` must bind to the same project and agree on every +result. Only runs against `CONVERTED_SPECS` (see `helpers.py`).""" + +from __future__ import annotations + +import pytest + +from plane.client import PlaneClient + +from .helpers import CONVERTED_SPECS, ResourceSpec, unique_name + + +@pytest.fixture(params=sorted(CONVERTED_SPECS), ids=sorted(CONVERTED_SPECS)) +def spec(request: pytest.FixtureRequest) -> ResourceSpec: + """Overrides the function-scoped `spec` from conftest, which parametrizes over + every resource -- this file only makes sense for the converted ones.""" + return CONVERTED_SPECS[request.param] + + +class TestScopeParity: + def test_list_agrees( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + spec: ResourceSpec, + ) -> None: + by_id = spec.ops(client, workspace_slug, project_id).list() + by_key = spec.ops(client, workspace_slug, project_key).list() + assert {row.id for row in by_id.data} == {row.id for row in by_key.data} + + def test_create_retrieve_update_delete_agree( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + spec: ResourceSpec, + ) -> None: + by_id = spec.ops(client, workspace_slug, project_id) + by_key = spec.ops(client, workspace_slug, project_key) + + created_via_id = by_id.create(spec.make_write(unique_name(f"{spec.key}-scope"))) + fetched_via_key = by_key.retrieve(created_via_id.id) + assert fetched_via_key.id == created_via_id.id + assert getattr(fetched_via_key, spec.name_field) == getattr( + created_via_id, spec.name_field + ) + + new_name = unique_name(f"{spec.key}-scope-renamed") + updated_via_key = by_key.update(created_via_id.id, spec.make_patch_name(new_name)) + assert getattr(updated_via_key, spec.name_field) == new_name + + fetched_via_id = by_id.retrieve(created_via_id.id) + assert getattr(fetched_via_id, spec.name_field) == new_name + + by_id.delete(created_via_id.id) + + def test_find_by_name_agrees( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + spec: ResourceSpec, + ) -> None: + by_id = spec.ops(client, workspace_slug, project_id) + by_key = spec.ops(client, workspace_slug, project_key) + name = unique_name(f"{spec.key}-scope-find") + created = by_id.create(spec.make_write(name)) + try: + found_via_id = by_id.find_by_name(name) + found_via_key = by_key.find_by_name(name) + assert found_via_id.id == found_via_key.id == created.id + finally: + by_id.delete(created.id) diff --git a/tests/v2/integration/test_stickies.py b/tests/v2/integration/test_stickies.py new file mode 100644 index 0000000..5d0a967 --- /dev/null +++ b/tests/v2/integration/test_stickies.py @@ -0,0 +1,51 @@ +"""Sticky notes against a real server. No feature gate observed. Not verified +against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.stickies import Stickies +from plane.client import PlaneClient +from plane.models.v2.stickies import CreateSticky, UpdateSticky + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def stickies(client: PlaneClient, workspace_slug: str) -> Stickies: + return client.v2.workspace(workspace_slug).stickies + + +@pytest.fixture +def sticky(stickies: Stickies) -> Iterator[Any]: + created = stickies.create(CreateSticky(name=unique_name("sticky"))) + yield created + try: + stickies.delete(created.id) + except Exception: + pass + + +def test_list(stickies: Stickies) -> None: + page = stickies.list() + assert isinstance(page.data, list) + + +def test_create_with_empty_body(stickies: Stickies) -> None: + created = stickies.create(CreateSticky()) + try: + assert created.id + finally: + stickies.delete(created.id) + + +def test_retrieve_patch_delete(stickies: Stickies, sticky: Any) -> None: + fetched = stickies.retrieve(sticky.id) + assert fetched.id == sticky.id + + updated = stickies.update(sticky.id, UpdateSticky(color="#336699")) + assert updated.color == "#336699" diff --git a/tests/v2/integration/test_teamspaces.py b/tests/v2/integration/test_teamspaces.py new file mode 100644 index 0000000..5782edd --- /dev/null +++ b/tests/v2/integration/test_teamspaces.py @@ -0,0 +1,54 @@ +"""Teamspaces against a real server; requires the workspace's +`is_teams_enabled` feature flag, else the endpoint 403s/404s. Not +verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.teamspaces import Teamspaces +from plane.client import PlaneClient +from plane.models.v2.teamspaces import CreateTeamspace, UpdateTeamspace + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def teamspaces(client: PlaneClient, workspace_slug: str) -> Teamspaces: + return client.v2.workspace(workspace_slug).teamspaces + + +@pytest.fixture +def teamspace(teamspaces: Teamspaces) -> Iterator[Any]: + created = teamspaces.create(CreateTeamspace(name=unique_name("team"))) + yield created + try: + teamspaces.delete(created.id) + except Exception: + pass + + +def test_list(teamspaces: Teamspaces) -> None: + page = teamspaces.list() + assert isinstance(page.data, list) + + +def test_create_retrieve_patch_delete(teamspaces: Teamspaces, teamspace: Any) -> None: + fetched = teamspaces.retrieve(teamspace.id) + assert fetched.id == teamspace.id + + updated = teamspaces.update(teamspace.id, UpdateTeamspace(description_html="

desc

")) + assert updated.description_html == "

desc

" + + +def test_find_by_name(teamspaces: Teamspaces, teamspace: Any) -> None: + found = teamspaces.find_by_name(teamspace.name) + assert found.id == teamspace.id + + +def test_list_expand_lead(teamspaces: Teamspaces, teamspace: Any) -> None: + page = teamspaces.list(expand=["lead"]) + assert isinstance(page.data, list) diff --git a/tests/v2/integration/test_upsert.py b/tests/v2/integration/test_upsert.py new file mode 100644 index 0000000..644f171 --- /dev/null +++ b/tests/v2/integration/test_upsert.py @@ -0,0 +1,41 @@ +"""`upsert` against a real server: the create path and the reconcile path, +keyed on `(external_source, external_id)` -- posting the same pair twice +must update the existing row in place, not create a second one.""" + +from __future__ import annotations + +from plane.client import PlaneClient + +from .helpers import ResourceSpec, unique_name + + +class TestUpsert: + def test_upsert_creates_when_no_row_matches( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + marker = unique_name(f"{spec.key}-upsert-create") + created = ops.upsert(spec.make_write(marker, external_source=marker, external_id="1")) + try: + assert getattr(created, spec.name_field) == marker + assert created.external_id == "1" + finally: + ops.delete(created.id) + + def test_upsert_reconciles_on_second_call( + self, client: PlaneClient, workspace_slug: str, project_id: str, spec: ResourceSpec + ) -> None: + ops = spec.ops(client, workspace_slug, project_id) + marker = unique_name(f"{spec.key}-upsert-reconcile") + first = ops.upsert(spec.make_write(marker, external_source=marker, external_id="1")) + renamed = f"{marker}-renamed" + try: + second = ops.upsert(spec.make_write(renamed, external_source=marker, external_id="1")) + assert second.id == first.id, "same (external_source, external_id) must reconcile" + assert getattr(second, spec.name_field) == renamed + + page = ops.list(external_source=marker, external_id="1") + assert len(page.data) == 1, "reconcile must not leave a duplicate row behind" + assert page.data[0].id == first.id + finally: + ops.delete(first.id) diff --git a/tests/v2/integration/test_users.py b/tests/v2/integration/test_users.py new file mode 100644 index 0000000..2f551f3 --- /dev/null +++ b/tests/v2/integration/test_users.py @@ -0,0 +1,22 @@ +"""Live coverage for `client.v2.users`: currently just `users.me()`. Not +workspace-scoped, so no chain conversion needed; offline coverage lives +in `tests/v2/test_users_resource.py`.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2.users import Users +from plane.client import PlaneClient + + +@pytest.fixture +def users(client: PlaneClient) -> Users: + return client.v2.users + + +class TestUsersMe: + def test_me_returns_the_authenticated_principal(self, users: Users) -> None: + who = users.me() + assert who.id + assert who.principal_kind in {"oauth", "api_key", "other"} diff --git a/tests/v2/integration/test_views.py b/tests/v2/integration/test_views.py new file mode 100644 index 0000000..dfb782e --- /dev/null +++ b/tests/v2/integration/test_views.py @@ -0,0 +1,68 @@ +"""Live coverage for the `views` resource, both project- and workspace-scoped. +Reuses shared `client`/`workspace_slug`/`project_id` fixtures (skips without +live credentials); the disposable view is local to this file.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.views import CreateView, UpdateView + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def project_view(proj: Project) -> Iterator[Any]: + created = proj.views.create(CreateView(name=unique_name("view"))) + yield created + try: + proj.views.delete(created.id) + except Exception: + pass + + +class TestProjectViews: + def test_list_includes_the_created_view(self, proj: Project, project_view: Any) -> None: + page = proj.views.list() + assert any(row.id == project_view.id for row in page.data) + + def test_patch_updates_the_name(self, proj: Project, project_view: Any) -> None: + updated = proj.views.update(project_view.id, UpdateView(name="Renamed view")) + assert updated.name == "Renamed view" + + def test_delete_then_retrieve_404s(self, proj: Project) -> None: + created = proj.views.create(CreateView(name=unique_name("view"))) + proj.views.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.views.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestWorkspaceViews: + def test_create_list_delete_round_trip(self, client: PlaneClient, workspace_slug: str) -> None: + views = client.v2.workspace(workspace_slug).views + created = views.create(CreateView(name=unique_name("ws-view"))) + try: + page = views.list() + assert any(row.id == created.id for row in page.data) + finally: + views.delete(created.id) + + def test_uses_a_different_path_than_project_views( + self, client: PlaneClient, workspace_slug: str, project_view: Any + ) -> None: + """A project-scoped view must not leak into the workspace-scoped list -- + the two hit different path templates against project=NULL vs a real one.""" + workspace_page = client.v2.workspace(workspace_slug).views.list() + assert not any(row.id == project_view.id for row in workspace_page.data) diff --git a/tests/v2/integration/test_webhook_logs.py b/tests/v2/integration/test_webhook_logs.py new file mode 100644 index 0000000..61a0afe --- /dev/null +++ b/tests/v2/integration/test_webhook_logs.py @@ -0,0 +1,59 @@ +"""Live coverage for `client.v2.workspace(slug).webhooks.logs`. A `webhooks_create` +403 may mean the fixture's token lacks admin scope, failing every test here at +fixture setup rather than at the assertion.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2._kernel.errors import PlaneAPIError +from plane.api.v2.webhook_logs import WebhookLogs +from plane.client import PlaneClient + + +@pytest.fixture +def webhook_logs(client: PlaneClient, workspace_slug: str) -> WebhookLogs: + return client.v2.workspace(workspace_slug).webhooks.logs + + +@pytest.fixture +def webhook(client: PlaneClient, workspace_slug: str) -> Iterator[dict[str, Any]]: + """A throwaway webhook, created straight through the transport the same + way `conftest.py`'s `project` fixture bootstraps a project.""" + created: dict[str, Any] = client.v2.transport.request( + "POST", + f"/workspaces/{workspace_slug}/webhooks/", + # Scopes are "."; a bare entity like "work_item" 400s + # "Invalid scopes" -- the work-item entity's scope key is "workitem" + # (no underscore), confirmed live. + json={ + "url": "https://example.com/plane-webhook-sink", + "scopes": ["workitem.created"], + }, + ) + yield created + try: + client.v2.transport.request( + "DELETE", f"/workspaces/{workspace_slug}/webhooks/{created['id']}/" + ) + except Exception: + pass + + +class TestWebhookLogs: + def test_list_on_a_fresh_webhook_is_empty( + self, webhook_logs: WebhookLogs, webhook: dict[str, Any] + ) -> None: + """No deliveries have happened yet -- an empty page, not an error.""" + page = webhook_logs.list(str(webhook["id"])) + assert page.data == [] + + def test_list_for_a_webhook_in_another_workspace_is_404( + self, webhook_logs: WebhookLogs + ) -> None: + with pytest.raises(PlaneAPIError) as exc_info: + webhook_logs.list("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_webhooks.py b/tests/v2/integration/test_webhooks.py new file mode 100644 index 0000000..7aa286d --- /dev/null +++ b/tests/v2/integration/test_webhooks.py @@ -0,0 +1,78 @@ +"""`Webhooks` against a real server, reached as `client.v2.workspace(slug).webhooks`. +Pins the open contract question in `plane.models.v2.webhooks`: whether `create()` +really returns `secret_key` despite the golden documenting a bare `Webhook`.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.webhooks import Webhooks +from plane.client import PlaneClient +from plane.models.v2.webhooks import CreateWebhook, UpdateWebhook + +from .helpers import unique_name + + +@pytest.fixture +def webhooks(client: PlaneClient, workspace_slug: str) -> Webhooks: + return client.v2.workspace(workspace_slug).webhooks + + +def _unique_url() -> str: + return f"https://example.com/hooks/{unique_name('wh')}" + + +class TestCRUD: + def test_create_returns_a_secret_key_once(self, webhooks: Webhooks) -> None: + created = webhooks.create(CreateWebhook(url=_unique_url())) + try: + assert created.id + assert created.secret_key + finally: + webhooks.delete(created.id) + + def test_retrieve_does_not_carry_a_secret(self, webhooks: Webhooks) -> None: + created = webhooks.create(CreateWebhook(url=_unique_url())) + try: + fetched = webhooks.retrieve(created.id) + assert fetched.id == created.id + assert "secret_key" not in (fetched.model_extra or {}) + finally: + webhooks.delete(created.id) + + def test_patch_updates_only_the_given_fields(self, webhooks: Webhooks) -> None: + created = webhooks.create(CreateWebhook(url=_unique_url())) + try: + updated = webhooks.update(created.id, UpdateWebhook(is_active=False)) + assert updated.id == created.id + assert updated.is_active is False + finally: + webhooks.delete(created.id) + + def test_delete_then_retrieve_404s(self, webhooks: Webhooks) -> None: + created = webhooks.create(CreateWebhook(url=_unique_url())) + webhooks.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + webhooks.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_find_by_name(self, webhooks: Webhooks) -> None: + name = unique_name("webhook") + created = webhooks.create(CreateWebhook(url=_unique_url(), name=name)) + try: + assert webhooks.find_by_name(name).id == created.id + finally: + webhooks.delete(created.id) + + +class TestRegenerate: + def test_regenerate_mints_a_different_secret(self, webhooks: Webhooks) -> None: + created = webhooks.create(CreateWebhook(url=_unique_url())) + try: + regenerated = webhooks.regenerate(created.id) + assert regenerated.id == created.id + assert regenerated.secret_key + assert regenerated.secret_key != created.secret_key + finally: + webhooks.delete(created.id) diff --git a/tests/v2/integration/test_work_item_properties.py b/tests/v2/integration/test_work_item_properties.py new file mode 100644 index 0000000..74d2fdf --- /dev/null +++ b/tests/v2/integration/test_work_item_properties.py @@ -0,0 +1,303 @@ +"""`proj.work_item_properties`/`ws.work_item_properties` (and their +`.options`/`.contexts` sub-resources) against a real server; not parametrized +through `helpers.SPECS` since this shard nests two independent scopes.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.client import PlaneClient +from plane.models.v2.work_item_properties import ( + CreateWorkItemProperty, + CreateWorkItemPropertyContext, + CreateWorkItemPropertyOption, + UpdateWorkItemProperty, + UpdateWorkItemPropertyContext, + UpdateWorkItemPropertyOption, +) + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def ws(client: PlaneClient, workspace_slug: str) -> Workspace: + return client.v2.workspace(workspace_slug) + + +def _create_project_property_or_skip(proj: Project, data: CreateWorkItemProperty) -> Any: + try: + return proj.work_item_properties.create(data) + except PlaneAPIError as exc: + if exc.status == 409 and exc.code == "work_item_types_managed_at_workspace": + pytest.skip( + "this workspace runs in workspace-managed work item type mode -- " + "project-scoped property writes are a 409 by design" + ) + raise + + +@pytest.fixture(scope="module") +def project_property(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """One project-scoped OPTION property, shared by every test in this module -- + sub-resources (options) hang off it and clean up their own rows.""" + proj = client.v2.workspace(workspace_slug).project(project_id) + created = _create_project_property_or_skip( + proj, + CreateWorkItemProperty(display_name=unique_name("wip-project"), property_type="OPTION"), + ) + yield created + try: + proj.work_item_properties.delete(created.id) + except Exception: + pass + + +def _create_workspace_property_or_skip(ws: Workspace, data: CreateWorkItemProperty) -> Any: + """Workspace-scoped property writes require workspace-managed mode + symmetrically to `_create_project_property_or_skip` above + (`views/workspace_work_item_properties.py`).""" + try: + return ws.work_item_properties.create(data) + except PlaneAPIError as exc: + if exc.status == 409 and exc.code == "work_item_types_managed_at_project": + pytest.skip( + "this workspace runs in project-managed work item type mode -- " + "workspace-scoped property writes are a 409 by design" + ) + raise + + +@pytest.fixture(scope="module") +def workspace_property(client: PlaneClient, workspace_slug: str) -> Iterator[Any]: + """One workspace-scoped OPTION property, shared by every test in this module -- + sub-resources (contexts, options) hang off it and clean up their own rows.""" + ws = client.v2.workspace(workspace_slug) + created = _create_workspace_property_or_skip( + ws, + CreateWorkItemProperty(display_name=unique_name("wip-workspace"), property_type="OPTION"), + ) + yield created + try: + ws.work_item_properties.delete(created.id) + except Exception: + pass + + +class TestProjectScopedProperties: + def test_create_returns_the_written_fields(self, proj: Project) -> None: + name = unique_name("wip-create") + created = _create_project_property_or_skip( + proj, CreateWorkItemProperty(display_name=name, property_type="TEXT") + ) + try: + assert created.display_name == name + assert created.property_type == "TEXT" + assert created.id + finally: + proj.work_item_properties.delete(created.id) + + def test_retrieve_returns_the_created_row(self, proj: Project, project_property: Any) -> None: + fetched = proj.work_item_properties.retrieve(project_property.id) + assert fetched.id == project_property.id + + def test_list_by_project_uuid_and_key_agree( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + project_property: Any, + ) -> None: + ws = client.v2.workspace(workspace_slug) + by_id = {row.id for row in ws.project(project_id).work_item_properties.list().data} + by_key = {row.id for row in ws.project(project_key).work_item_properties.list().data} + assert project_property.id in by_id + assert by_id == by_key + + def test_patch_updates_only_the_given_fields(self, proj: Project) -> None: + created = _create_project_property_or_skip( + proj, + CreateWorkItemProperty(display_name=unique_name("wip-patch"), property_type="TEXT"), + ) + try: + new_name = unique_name("wip-patched") + updated = proj.work_item_properties.update( + created.id, UpdateWorkItemProperty(display_name=new_name) + ) + assert updated.id == created.id + assert updated.display_name == new_name + assert updated.property_type == "TEXT" + finally: + proj.work_item_properties.delete(created.id) + + def test_delete_then_retrieve_404s(self, proj: Project) -> None: + created = _create_project_property_or_skip( + proj, CreateWorkItemProperty(display_name=unique_name("wip-del"), property_type="TEXT") + ) + proj.work_item_properties.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_properties.retrieve(created.id) + assert exc_info.value.status == 404 + + def test_fields_returns_a_sparse_row(self, proj: Project, project_property: Any) -> None: + fetched = proj.work_item_properties.retrieve(project_property.id, fields=["id"]) + assert fetched.id == project_property.id + assert fetched.display_name is None + + def test_invalid_fields_is_rejected_client_side(self, proj: Project) -> None: + with pytest.raises(ValueError, match="bogus"): + proj.work_item_properties.list(fields=["bogus"]) + + +class TestProjectScopedOptions: + def test_crud(self, proj: Project, project_property: Any) -> None: + created = proj.work_item_properties.options.create( + project_property.id, CreateWorkItemPropertyOption(name=unique_name("wip-opt")) + ) + try: + assert created.id + + fetched = proj.work_item_properties.options.retrieve(project_property.id, created.id) + assert fetched.id == created.id + + page = proj.work_item_properties.options.list(project_property.id) + assert any(o.id == created.id for o in page.data) + + new_name = unique_name("wip-opt-renamed") + updated = proj.work_item_properties.options.update( + project_property.id, created.id, UpdateWorkItemPropertyOption(name=new_name) + ) + assert updated.name == new_name + finally: + proj.work_item_properties.options.delete(project_property.id, created.id) + + def test_delete_then_retrieve_404s(self, proj: Project, project_property: Any) -> None: + created = proj.work_item_properties.options.create( + project_property.id, CreateWorkItemPropertyOption(name=unique_name("wip-opt-del")) + ) + proj.work_item_properties.options.delete(project_property.id, created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_properties.options.retrieve(project_property.id, created.id) + assert exc_info.value.status == 404 + + +class TestWorkspaceScopedProperties: + def test_create_returns_the_written_fields(self, ws: Workspace) -> None: + name = unique_name("wswip-create") + created = _create_workspace_property_or_skip( + ws, CreateWorkItemProperty(display_name=name, property_type="TEXT") + ) + try: + assert created.display_name == name + assert created.id + finally: + ws.work_item_properties.delete(created.id) + + def test_retrieve_returns_the_created_row(self, ws: Workspace, workspace_property: Any) -> None: + fetched = ws.work_item_properties.retrieve(workspace_property.id) + assert fetched.id == workspace_property.id + + def test_patch_updates_only_the_given_fields(self, ws: Workspace) -> None: + created = _create_workspace_property_or_skip( + ws, + CreateWorkItemProperty(display_name=unique_name("wswip-patch"), property_type="TEXT"), + ) + try: + new_name = unique_name("wswip-patched") + updated = ws.work_item_properties.update( + created.id, UpdateWorkItemProperty(display_name=new_name) + ) + assert updated.display_name == new_name + finally: + ws.work_item_properties.delete(created.id) + + def test_delete_then_retrieve_404s(self, ws: Workspace) -> None: + created = _create_workspace_property_or_skip( + ws, CreateWorkItemProperty(display_name=unique_name("wswip-del"), property_type="TEXT") + ) + ws.work_item_properties.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + ws.work_item_properties.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestWorkspaceScopedOptions: + def test_crud(self, ws: Workspace, workspace_property: Any) -> None: + created = ws.work_item_properties.options.create( + workspace_property.id, CreateWorkItemPropertyOption(name=unique_name("wswip-opt")) + ) + try: + assert created.id + + fetched = ws.work_item_properties.options.retrieve(workspace_property.id, created.id) + assert fetched.id == created.id + + page = ws.work_item_properties.options.list(workspace_property.id) + assert any(o.id == created.id for o in page.data) + + new_name = unique_name("wswip-opt-renamed") + updated = ws.work_item_properties.options.update( + workspace_property.id, created.id, UpdateWorkItemPropertyOption(name=new_name) + ) + assert updated.name == new_name + finally: + ws.work_item_properties.options.delete(workspace_property.id, created.id) + + +class TestContexts: + def test_crud(self, ws: Workspace, project_id: str, workspace_property: Any) -> None: + created = ws.work_item_properties.contexts.create( + workspace_property.id, + # `issue_type_ids` is required unless `applies_to_all_work_item_types`; + # an unnamed create clashes with the auto-provisioned "Default" context + # and surfaces as a raw 500 (unhandled `UniqueViolation`, confirmed live). + CreateWorkItemPropertyContext( + name=unique_name("wip-ctx"), + project_ids=[project_id], + applies_to_all_work_item_types=True, + is_required=True, + ), + ) + try: + assert created.id + assert created.is_required is True + + fetched = ws.work_item_properties.contexts.retrieve(workspace_property.id, created.id) + assert fetched.id == created.id + + page = ws.work_item_properties.contexts.list(workspace_property.id) + assert any(c.id == created.id for c in page.data) + + updated = ws.work_item_properties.contexts.update( + workspace_property.id, created.id, UpdateWorkItemPropertyContext(is_required=False) + ) + assert updated.is_required is False + finally: + ws.work_item_properties.contexts.delete(workspace_property.id, created.id) + + def test_delete_then_retrieve_404s( + self, ws: Workspace, project_id: str, workspace_property: Any + ) -> None: + created = ws.work_item_properties.contexts.create( + workspace_property.id, + CreateWorkItemPropertyContext( + name=unique_name("wip-ctx-del"), + project_ids=[project_id], + applies_to_all_work_item_types=True, + ), + ) + ws.work_item_properties.contexts.delete(workspace_property.id, created.id) + with pytest.raises(PlaneAPIError) as exc_info: + ws.work_item_properties.contexts.retrieve(workspace_property.id, created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_work_item_relation_definitions.py b/tests/v2/integration/test_work_item_relation_definitions.py new file mode 100644 index 0000000..2a4dea0 --- /dev/null +++ b/tests/v2/integration/test_work_item_relation_definitions.py @@ -0,0 +1,67 @@ +"""Work item relation definitions against a real server; no feature gate -- +this suite creates and deletes its own custom row since writes to seeded +defaults are blocked. Not verified against a live server yet.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2.work_item_relation_definitions import WorkItemRelationDefinitions +from plane.client import PlaneClient +from plane.models.v2.work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, +) + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def relation_definitions( + client: PlaneClient, workspace_slug: str +) -> WorkItemRelationDefinitions: + return client.v2.workspace(workspace_slug).work_item_relation_definitions + + +@pytest.fixture +def relation_definition(relation_definitions: WorkItemRelationDefinitions) -> Iterator[Any]: + name = unique_name("relates-to") + created = relation_definitions.create( + CreateWorkItemRelationDefinition(name=name, inward=name, outward=name) + ) + yield created + try: + relation_definitions.delete(created.id) + except Exception: + pass + + +def test_list_includes_seeded_defaults(relation_definitions: WorkItemRelationDefinitions) -> None: + page = relation_definitions.list() + assert any(row.is_default for row in page.data) + + +def test_create_retrieve_patch_delete( + relation_definitions: WorkItemRelationDefinitions, + relation_definition: Any, +) -> None: + assert relation_definition.is_default is not True + + fetched = relation_definitions.retrieve(relation_definition.id) + assert fetched.id == relation_definition.id + + updated = relation_definitions.update( + relation_definition.id, UpdateWorkItemRelationDefinition(color="#abcdef") + ) + assert updated.color == "#abcdef" + + +def test_find_by_name( + relation_definitions: WorkItemRelationDefinitions, + relation_definition: Any, +) -> None: + found = relation_definitions.find_by_name(relation_definition.name) + assert found.id == relation_definition.id diff --git a/tests/v2/integration/test_work_item_sub_resources.py b/tests/v2/integration/test_work_item_sub_resources.py new file mode 100644 index 0000000..7bd54a9 --- /dev/null +++ b/tests/v2/integration/test_work_item_sub_resources.py @@ -0,0 +1,296 @@ +"""Every work-item sub-resource against a real server: comments, attachments, +links, worklogs, activities (read-only), relations, and dependencies.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient +from plane.models.v2.work_items import ( + CreateWorkItem, + CreateWorkItemAttachment, + CreateWorkItemComment, + CreateWorkItemLink, + CreateWorkItemWorklog, + UpdateWorkItem, + UpdateWorkItemComment, + UpdateWorkItemLink, + UpdateWorkItemWorklog, + WorkItemAttachmentConfirm, + WorkItemDependencyCreate, + WorkItemRelationCreate, +) + +from .helpers import unique_name + + +@pytest.fixture(scope="module") +def work_item(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """One work item shared by every test in this module -- sub-resources hang off + it and clean up their own rows, so nothing here depends on test order.""" + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi-subresources"))) + yield created + try: + proj.work_items.delete(created.id) + except Exception: + pass + + +@pytest.fixture(scope="module") +def other_work_item(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """A second work item to relate/depend the first one on.""" + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi-subresources-other"))) + yield created + try: + proj.work_items.delete(created.id) + except Exception: + pass + + +class TestComments: + def test_crud( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.comments.create( + work_item.id, CreateWorkItemComment(comment_html="

hi

") + ) + try: + assert created.work_item_id == work_item.id + + fetched = proj.work_items.comments.retrieve(work_item.id, created.id) + assert fetched.id == created.id + + page = proj.work_items.comments.list(work_item.id) + assert any(c.id == created.id for c in page.data) + + updated = proj.work_items.comments.update( + work_item.id, created.id, UpdateWorkItemComment(comment_html="

bye

") + ) + assert updated.comment_html == "

bye

" + finally: + proj.work_items.comments.delete(work_item.id, created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_items.comments.retrieve(work_item.id, created.id) + assert exc_info.value.status == 404 + + def test_upsert_creates_then_reconciles( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + marker = unique_name("comment-upsert") + first = proj.work_items.comments.upsert( + work_item.id, + CreateWorkItemComment( + comment_html="

v1

", external_source=marker, external_id="1" + ), + ) + try: + second = proj.work_items.comments.upsert( + work_item.id, + CreateWorkItemComment( + comment_html="

v2

", external_source=marker, external_id="1" + ), + ) + assert second.id == first.id + assert second.comment_html == "

v2

" + finally: + proj.work_items.comments.delete(work_item.id, first.id) + + def test_bulk_create_update_delete( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + items = [CreateWorkItemComment(comment_html=f"

{i}

") for i in range(2)] + created = proj.work_items.comments.bulk_create(work_item.id, items) + created.raise_for_failures() + ids = [row.id for row in created.results] + try: + updated = proj.work_items.comments.bulk_update( + work_item.id, + [{"id": pk, "comment_html": "

updated

"} for pk in ids], + ) + assert updated.succeeded == 2 + finally: + deleted = proj.work_items.comments.bulk_delete(work_item.id, ids) + assert deleted.succeeded == 2 + + +class TestAttachments: + def test_create_then_confirm_then_delete( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.attachments.create( + work_item.id, + CreateWorkItemAttachment(name="log.txt", size=42, type="text/plain"), + ) + # The golden documents a bare `WorkItemAttachment` for this operation, but + # the live server returns a richer envelope -- verified here against a real + # plane-dev instance; see `WorkItemAttachmentUploadResult`'s docstring. + assert created.asset_id + assert created.upload_data # presigned S3 POST fields to upload the bytes to + attachment_id = created.attachment.id + try: + fetched = proj.work_items.attachments.retrieve(work_item.id, attachment_id) + assert fetched.id == attachment_id + + page = proj.work_items.attachments.list(work_item.id) + assert any(a.id == attachment_id for a in page.data) + + confirmed = proj.work_items.attachments.update( + work_item.id, attachment_id, WorkItemAttachmentConfirm(is_uploaded=True) + ) + assert confirmed.is_uploaded is True + finally: + proj.work_items.attachments.delete(work_item.id, attachment_id) + + +class TestLinks: + def test_crud( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.links.create( + work_item.id, CreateWorkItemLink(url="https://example.com/a") + ) + try: + assert created.url == "https://example.com/a" + + updated = proj.work_items.links.update( + work_item.id, created.id, UpdateWorkItemLink(url="https://example.com/b") + ) + assert updated.url == "https://example.com/b" + + page = proj.work_items.links.list(work_item.id) + assert any(link.id == created.id for link in page.data) + finally: + proj.work_items.links.delete(work_item.id, created.id) + + +class TestWorklogs: + def test_crud( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.worklogs.create( + work_item.id, + CreateWorkItemWorklog(duration=30, description="investigating"), + ) + try: + assert created.duration == 30 + + updated = proj.work_items.worklogs.update( + work_item.id, created.id, UpdateWorkItemWorklog(duration=45) + ) + assert updated.duration == 45 + + page = proj.work_items.worklogs.list(work_item.id) + assert any(w.id == created.id for w in page.data) + finally: + proj.work_items.worklogs.delete(work_item.id, created.id) + + +class TestActivities: + def test_list_and_retrieve_after_a_write( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + # Renaming generates an activity row -- read-only, so this is the only way + # to guarantee at least one exists. + proj.work_items.update(work_item.id, UpdateWorkItem(name=unique_name("wi-activity"))) + + page = proj.work_items.activities.list(work_item.id) + assert len(page.data) > 0 + + first = page.data[0] + fetched = proj.work_items.activities.retrieve(work_item.id, first.id) + assert fetched.id == first.id + + +@pytest.fixture(scope="module") +def relation_definition_id(client: PlaneClient, workspace_slug: str) -> str: + try: + payload = client.v2.transport.request( + "GET", + f"/workspaces/{workspace_slug}/work-item-relation-definitions/", + params={"per_page": 5}, + ) + except PlaneAPIError as exc: + if exc.status == 402: + pytest.skip("CUSTOM_RELATIONS not enabled on this workspace") + raise + data = payload.get("data", []) + if not data: + pytest.skip("workspace has no relation definitions seeded") + return str(data[0]["id"]) + + +class TestRelations: + def test_create_list_and_delete_by_related_id( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + work_item: Any, + other_work_item: Any, + relation_definition_id: str, + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + payload = client.v2.transport.request( + "GET", + f"/workspaces/{workspace_slug}/work-item-relation-definitions/{relation_definition_id}/", + ) + direction = payload["outward"] + + proj.work_items.relations.create( + work_item.id, + WorkItemRelationCreate( + direction=direction, + relation_definition_id=relation_definition_id, + work_item_ids=[other_work_item.id], + ), + ) + try: + result = proj.work_items.relations.list(work_item.id) + all_related_ids = {rid for ids in result.model_extra.values() for rid in ids} + assert other_work_item.id in all_related_ids + finally: + proj.work_items.relations.delete(work_item.id, other_work_item.id) + + result_after = proj.work_items.relations.list(work_item.id) + all_related_ids_after = {rid for ids in result_after.model_extra.values() for rid in ids} + assert other_work_item.id not in all_related_ids_after + + +class TestDependencies: + def test_create_list_and_delete_by_related_id( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + work_item: Any, + other_work_item: Any, + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + proj.work_items.dependencies.create( + work_item.id, + WorkItemDependencyCreate( + relation_type="blocked_by", work_item_ids=[other_work_item.id] + ), + ) + try: + result = proj.work_items.dependencies.list(work_item.id) + assert other_work_item.id in result.blocked_by + finally: + proj.work_items.dependencies.delete(work_item.id, other_work_item.id) + + result_after = proj.work_items.dependencies.list(work_item.id) + assert other_work_item.id not in result_after.blocked_by diff --git a/tests/v2/integration/test_work_item_templates.py b/tests/v2/integration/test_work_item_templates.py new file mode 100644 index 0000000..1d22356 --- /dev/null +++ b/tests/v2/integration/test_work_item_templates.py @@ -0,0 +1,109 @@ +"""Live coverage for the `work_item_templates` resource, both project- and +workspace-scoped. Reuses shared `client`/`workspace_slug`/`project_id` +fixtures; the disposable template is local to this file.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.work_item_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplateData, + WorkItemTemplateUse, +) + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def project_template(proj: Project) -> Iterator[Any]: + created = proj.work_item_templates.create( + CreateWorkItemTemplate( + name=unique_name("template"), + template_data=WorkItemTemplateData(name="Templated work item"), + ), + ) + yield created + try: + proj.work_item_templates.delete(created.id) + except Exception: + pass + + +class TestProjectWorkItemTemplates: + def test_list_includes_the_created_template( + self, proj: Project, project_template: Any + ) -> None: + page = proj.work_item_templates.list() + assert any(row.id == project_template.id for row in page.data) + + def test_retrieve_returns_the_seed_data(self, proj: Project, project_template: Any) -> None: + fetched = proj.work_item_templates.retrieve(project_template.id) + assert fetched.id == project_template.id + assert fetched.template_data is not None + + def test_patch_updates_the_name(self, proj: Project, project_template: Any) -> None: + updated = proj.work_item_templates.update( + project_template.id, UpdateWorkItemTemplate(name="Renamed template") + ) + assert updated.name == "Renamed template" + + def test_use_instantiates_a_work_item(self, proj: Project, project_template: Any) -> None: + work_item = proj.work_item_templates.use(project_template.id) + try: + assert work_item.id + assert work_item.name == "Templated work item" + finally: + proj.work_items.delete(work_item.id) + + def test_use_honors_a_name_override(self, proj: Project, project_template: Any) -> None: + work_item = proj.work_item_templates.use( + project_template.id, WorkItemTemplateUse(name="Overridden name") + ) + try: + assert work_item.name == "Overridden name" + finally: + proj.work_items.delete(work_item.id) + + def test_delete_then_retrieve_404s(self, proj: Project) -> None: + created = proj.work_item_templates.create( + CreateWorkItemTemplate( + name=unique_name("template"), template_data=WorkItemTemplateData(name="Throwaway") + ), + ) + proj.work_item_templates.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_templates.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestWorkspaceWorkItemTemplates: + def test_create_list_delete_round_trip(self, client: PlaneClient, workspace_slug: str) -> None: + templates = client.v2.workspace(workspace_slug).work_item_templates + created = templates.create( + CreateWorkItemTemplate( + name=unique_name("ws-template"), + template_data=WorkItemTemplateData(name="Workspace templated item"), + ), + ) + try: + page = templates.list() + assert any(row.id == created.id for row in page.data) + finally: + templates.delete(created.id) + + def test_has_no_use_action(self, client: PlaneClient, workspace_slug: str) -> None: + templates = client.v2.workspace(workspace_slug).work_item_templates + assert not hasattr(templates, "use") diff --git a/tests/v2/integration/test_work_item_types.py b/tests/v2/integration/test_work_item_types.py new file mode 100644 index 0000000..5a0b910 --- /dev/null +++ b/tests/v2/integration/test_work_item_types.py @@ -0,0 +1,219 @@ +"""Work item types (project- and workspace-scoped) + custom-property +attachments, against a real server. A workspace runs in exactly one mode +(project- or workspace-managed); fixtures probe and skip on mode conflict.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.client import PlaneClient +from plane.models.v2.work_item_types import CreateWorkItemType, UpdateWorkItemType + +from .helpers import unique_name + + +def _skip_on_mode_conflict(exc: PlaneAPIError) -> None: + """Common guard for both project-scoped and workspace-scoped work item type + writes: skip on the *other* mode's 409, and on the 402 a plan/license flag + would raise. Re-raises anything else.""" + if exc.status == 402: + pytest.skip("work item types feature not enabled on this workspace") + if exc.status == 409 and exc.code in ( + "work_item_types_managed_at_workspace", + "work_item_types_managed_at_project", + ): + pytest.skip( + f"this workspace's work item type mode conflicts with this surface " + f"({exc.code}) -- see api_v2/core/feature_modes.py" + ) + raise exc + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +@pytest.fixture +def ws(client: PlaneClient, workspace_slug: str) -> Workspace: + return client.v2.workspace(workspace_slug) + + +@pytest.fixture +def work_item_type(proj: Project) -> Iterator[Any]: + """One freshly created, non-default work item type, deleted afterwards. + Skips if this workspace runs in workspace-managed mode (see module + docstring) -- project-scoped type writes are a 409 there by design.""" + try: + created = proj.work_item_types.create(CreateWorkItemType(name=unique_name("wit"))) + except PlaneAPIError as exc: + _skip_on_mode_conflict(exc) + raise # pragma: no cover -- _skip_on_mode_conflict always raises or skips + yield created + try: + proj.work_item_types.delete(created.id) + except Exception: + pass + + +class TestWorkItemTypesCrud: + def test_list_and_retrieve(self, proj: Project, work_item_type: Any) -> None: + page = proj.work_item_types.list() + assert any(row.id == work_item_type.id for row in page.data) + + fetched = proj.work_item_types.retrieve(work_item_type.id) + assert fetched.id == work_item_type.id + assert fetched.is_default is not True + + def test_update_only_touches_given_fields(self, proj: Project, work_item_type: Any) -> None: + new_name = unique_name("wit-renamed") + updated = proj.work_item_types.update(work_item_type.id, UpdateWorkItemType(name=new_name)) + assert updated.name == new_name + + def test_delete_then_retrieve_404s(self, proj: Project) -> None: + try: + created = proj.work_item_types.create(CreateWorkItemType(name=unique_name("wit-del"))) + except PlaneAPIError as exc: + _skip_on_mode_conflict(exc) + raise # pragma: no cover + proj.work_item_types.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_types.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestWorkItemTypesActions: + def test_mark_default_then_schema(self, proj: Project, work_item_type: Any) -> None: + # Restore the prior default afterwards: a default type cannot be deleted (409). + prior = next((row for row in proj.work_item_types.list().data if row.is_default), None) + try: + marked = proj.work_item_types.mark_default(work_item_type.id) + assert marked.is_default is True + + schema = proj.work_item_types.schema(work_item_type.id) + assert schema.type_id == work_item_type.id + finally: + if prior is not None: + proj.work_item_types.mark_default(prior.id) + + def test_enable_epic_type_is_idempotent(self, proj: Project) -> None: + # `enable` returns the project's DEFAULT (non-epic) type -- the epic type + # is created as a side effect, not returned (`views/work_item_types.py` + # `enable()` responds with `default_type`, which is always `is_epic=False`). + try: + first = proj.work_item_types.enable() + except PlaneAPIError as exc: + _skip_on_mode_conflict(exc) + raise # pragma: no cover + second = proj.work_item_types.enable() + assert first.id == second.id + assert second.is_epic is False + + types = proj.work_item_types.list().data + assert any(row.is_epic for row in types) + + def test_import_types_enables_a_workspace_type_on_the_project( + self, proj: Project, ws: Workspace + ) -> None: + workspace_types = ws.work_item_types.list().data + project_type_ids = {row.id for row in proj.work_item_types.list().data} + candidates = [row for row in workspace_types if row.id not in project_type_ids] + if not candidates: + pytest.skip("no workspace-level work item type is available to import") + target = candidates[0] + + proj.work_item_types.import_types([target.id]) + + imported_ids = {row.id for row in proj.work_item_types.list().data} + assert target.id in imported_ids + + +class TestWorkspaceWorkItemTypes: + """Workspace-level types use a different path template than project-scoped + ones -- covered separately rather than assumed to agree.""" + + @pytest.fixture + def workspace_work_item_type(self, ws: Workspace) -> Iterator[Any]: + try: + created = ws.work_item_types.create( + CreateWorkItemType(name=unique_name("workspace-wit")) + ) + except PlaneAPIError as exc: + _skip_on_mode_conflict(exc) + raise # pragma: no cover + yield created + try: + ws.work_item_types.delete(created.id) + except Exception: + pass + + def test_list_and_retrieve(self, ws: Workspace, workspace_work_item_type: Any) -> None: + page = ws.work_item_types.list() + assert any(row.id == workspace_work_item_type.id for row in page.data) + + fetched = ws.work_item_types.retrieve(workspace_work_item_type.id) + assert fetched.id == workspace_work_item_type.id + + def test_mark_default(self, ws: Workspace, workspace_work_item_type: Any) -> None: + # Restore the prior default afterwards: a default type cannot be deleted (409). + prior = next((row for row in ws.work_item_types.list().data if row.is_default), None) + try: + marked = ws.work_item_types.mark_default(workspace_work_item_type.id) + assert marked.is_default is True + finally: + if prior is not None: + ws.work_item_types.mark_default(prior.id) + + +class TestWorkItemTypeProperties: + @pytest.fixture + def attachable_property_id(self, client: PlaneClient, workspace_slug: str) -> str: + """An existing, unattached custom property definition to attach in tests + below. Skips if the workspace has none -- creating one is out of this + shard's scope (`work-item-properties.json`).""" + try: + payload = client.v2.transport.request( + "GET", f"/workspaces/{workspace_slug}/work-item-properties/", params={"per_page": 5} + ) + except PlaneAPIError as exc: + if exc.status in (402, 404): + pytest.skip("work item properties not available on this workspace") + raise + data = payload.get("data", []) + if not data: + pytest.skip("workspace has no work item property definitions to attach") + return str(data[0]["id"]) + + def test_list_is_empty_on_a_fresh_type(self, proj: Project, work_item_type: Any) -> None: + page = proj.work_item_types.properties.list(work_item_type.id) + assert page.data == [] + + def test_attach_then_list_then_detach( + self, + proj: Project, + work_item_type: Any, + attachable_property_id: str, + ) -> None: + attached = proj.work_item_types.properties.attach( + work_item_type.id, [attachable_property_id] + ) + assert attachable_property_id in attached.properties + try: + page = proj.work_item_types.properties.list(work_item_type.id) + assert any(p.id == attachable_property_id for p in page.data) + + fetched = proj.work_item_types.properties.retrieve( + work_item_type.id, attachable_property_id + ) + assert fetched.id == attachable_property_id + finally: + proj.work_item_types.properties.detach(work_item_type.id, attachable_property_id) + + page_after = proj.work_item_types.properties.list(work_item_type.id) + assert all(p.id != attachable_property_id for p in page_after.data) diff --git a/tests/v2/integration/test_work_items.py b/tests/v2/integration/test_work_items.py new file mode 100644 index 0000000..d953661 --- /dev/null +++ b/tests/v2/integration/test_work_items.py @@ -0,0 +1,234 @@ +"""`client.v2.workspace(...).project(...).work_items` against a real server: +CRUD, `?fields=`/`?expand=`, archive, upsert, bulk ops, pagination, errors. +Workspace-wide listing/`retrieve_by_identifier` live in their own file.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.client import PlaneClient +from plane.models.v2.work_items import CreateWorkItem, UpdateWorkItem + +from .helpers import unique_name + +MISSING_ID = "00000000-0000-0000-0000-000000000000" + + +@pytest.fixture +def work_item(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """One freshly created work item, deleted afterwards.""" + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi"))) + yield created + try: + proj.work_items.delete(created.id) + except Exception: + pass + + +class TestCRUD: + def test_create_returns_the_written_fields( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + name = unique_name("wi-create") + created = proj.work_items.create(CreateWorkItem(name=name)) + try: + assert created.name == name + assert created.id + assert created.identifier # the server-assigned human key, e.g. "ENG-12" + finally: + proj.work_items.delete(created.id) + + def test_retrieve_returns_the_created_row( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + fetched = proj.work_items.retrieve(work_item.id) + assert fetched.id == work_item.id + assert fetched.name == work_item.name + + def test_list_by_project_uuid_and_key_agree( + self, + client: PlaneClient, + workspace_slug: str, + project_id: str, + project_key: str, + work_item: Any, + ) -> None: + ws = client.v2.workspace(workspace_slug) + by_id = {row.id for row in ws.project(project_id).work_items.list().data} + by_key = {row.id for row in ws.project(project_key).work_items.list().data} + assert work_item.id in by_id + assert by_id == by_key + + def test_patch_updates_only_the_given_fields( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + new_name = unique_name("wi-renamed") + updated = proj.work_items.update(work_item.id, UpdateWorkItem(name=new_name)) + assert updated.id == work_item.id + assert updated.name == new_name + + def test_delete_then_retrieve_404s( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi-del"))) + proj.work_items.delete(created.id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_items.retrieve(created.id) + assert exc_info.value.status == 404 + + +class TestFieldsAndExpand: + def test_fields_returns_a_sparse_row( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + fetched = proj.work_items.retrieve(work_item.id, fields=["id", "name"]) + assert fetched.id == work_item.id + assert fetched.priority is None + + def test_expand_state_adds_the_embedded_object( + self, client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + plain = proj.work_items.retrieve(work_item.id) + expanded = proj.work_items.retrieve(work_item.id, expand=["state"]) + # `state` is not a declared field on `WorkItem` -- it only shows up (via + # `extra="allow"`) when `?expand=state` actually asked the server for it. + assert "state" not in plain.model_extra + assert "state" in expanded.model_extra + + def test_invalid_expand_is_rejected_client_side( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + with pytest.raises(ValueError, match="bogus"): + proj.work_items.list(expand=["bogus"]) + + +class TestArchive: + def test_archive_then_unarchive_round_trips( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + # Only work items in a completed/cancelled state can be archived + # (server-enforced) -- find a real one rather than assuming a name. + states = proj.states.list(per_page=100).data + eligible = next((s for s in states if s.group in ("completed", "cancelled")), None) + if eligible is None: + pytest.skip("project has no completed/cancelled-group state to archive into") + + created = proj.work_items.create( + CreateWorkItem(name=unique_name("wi-archive"), state_id=eligible.id), + ) + try: + archived = proj.work_items.archive(created.id) + assert archived.archived_at is not None + + unarchived = proj.work_items.unarchive(created.id) + assert unarchived.archived_at is None + finally: + proj.work_items.delete(created.id) + + +class TestUpsert: + def test_upsert_creates_then_reconciles( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + marker = unique_name("wi-upsert") + first = proj.work_items.upsert( + CreateWorkItem(name=marker, external_source=marker, external_id="1"), + ) + try: + renamed = f"{marker}-renamed" + second = proj.work_items.upsert( + CreateWorkItem(name=renamed, external_source=marker, external_id="1"), + ) + assert second.id == first.id, "same (external_source, external_id) must reconcile" + assert second.name == renamed + + page = proj.work_items.list(external_source=marker, external_id="1") + assert len(page.data) == 1, "reconcile must not leave a duplicate row behind" + finally: + proj.work_items.delete(first.id) + + +class TestBulk: + def test_bulk_create_update_delete_round_trip( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + items = [CreateWorkItem(name=unique_name("wi-bulk")) for _ in range(2)] + created = proj.work_items.bulk_create(items) + created.raise_for_failures() + ids = [row.id for row in created.results] + try: + updated = proj.work_items.bulk_update( + [{"id": pk, "name": unique_name("wi-bulk-renamed")} for pk in ids], + ) + assert updated.succeeded == 2 + assert updated.failed == 0 + finally: + deleted = proj.work_items.bulk_delete(ids) + assert deleted.succeeded == 2 + + +class TestErrors: + def test_retrieve_missing_id_surfaces_404( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_items.retrieve(MISSING_ID) + error = exc_info.value + assert error.status == 404 + assert error.code == "not_found" + + def test_missing_required_name_surfaces_a_field_error( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + with pytest.raises(PlaneAPIError) as exc_info: + client.v2.transport.request( + "POST", + f"/workspaces/{workspace_slug}/projects/{project_id}/work-items/", + json={}, + ) + error = exc_info.value + assert error.status == 400 + assert error.errors is not None + assert any(field_error.field == "name" for field_error in error.errors) + + +class TestPagination: + def test_iter_follows_every_page( + self, client: PlaneClient, workspace_slug: str, project_id: str + ) -> None: + proj = client.v2.workspace(workspace_slug).project(project_id) + marker = unique_name("wi-pg") + items = [ + CreateWorkItem( + name=unique_name("wi-pg-row"), external_source=marker, external_id=str(i) + ) + for i in range(4) + ] + created = proj.work_items.bulk_create(items) + created.raise_for_failures() + ids = [row.id for row in created.results] + try: + page = proj.work_items.list(external_source=marker, per_page=2) + assert len(page.data) == 2 + assert page.next is not None + + rows = list(proj.work_items.iterate(external_source=marker, per_page=2)) + assert {row.id for row in rows} == set(ids) + finally: + proj.work_items.bulk_delete(ids) diff --git a/tests/v2/integration/test_workflows.py b/tests/v2/integration/test_workflows.py new file mode 100644 index 0000000..bcad085 --- /dev/null +++ b/tests/v2/integration/test_workflows.py @@ -0,0 +1,102 @@ +"""Live coverage for `client.v2.workspace(...).project(...).workflows`; +skips (never fails) when required env vars are absent, same convention +as every other file in this directory.""" + +from __future__ import annotations + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.workflows import ( + CreateWorkflow, + CreateWorkflowTransition, + UpdateWorkflow, + UpdateWorkflowState, + UpdateWorkflowTransition, + WorkflowStateCreate, +) + +from .helpers import unique_name + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +class TestWorkflows: + def test_crud_and_nested_states_and_transitions(self, proj: Project) -> None: + """One end-to-end pass: create a workflow, attach a state, add a + transition, then tear everything down; a fresh project always has + >=2 default states.""" + states_page = proj.states.list() + assert len(states_page.data) >= 2, "fixture project needs >=2 default states" + source_state, target_state = states_page.data[0], states_page.data[1] + + # Workflows are gated by `FeatureFlag.WORKFLOWS` (402, no toggle + # reachable through this API) -- skip rather than fail when it's off. + try: + created = proj.workflows.create(CreateWorkflow(name=unique_name("workflow"))) + except PlaneAPIError as exc: + if exc.status == 402: + pytest.skip("Workflows feature is not enabled for this project") + raise + try: + fetched = proj.workflows.retrieve(created.id) + assert fetched.id == created.id + + page = proj.workflows.list() + assert any(w.id == created.id for w in page.data) + + updated = proj.workflows.update(created.id, UpdateWorkflow(is_active=True)) + assert updated.is_active is True + + # -- Nested: states -- + attached = proj.workflows.states.attach( + created.id, + WorkflowStateCreate(state_ids=[source_state.id, target_state.id]), + ) + assert attached.workflow_id == created.id + + states = proj.workflows.states.list(created.id) + attached_state_ids = {row.state_id for row in states.data} + assert {source_state.id, target_state.id} <= attached_state_ids + source_row = next(r for r in states.data if r.state_id == source_state.id) + target_row = next(r for r in states.data if r.state_id == target_state.id) + + patched_state = proj.workflows.states.update( + created.id, + source_row.id, + UpdateWorkflowState(allow_issue_creation=True), + ) + assert patched_state.allow_issue_creation is True + + # -- Nested: transitions -- + transition = proj.workflows.transitions.create( + created.id, + CreateWorkflowTransition( + state_id=source_state.id, transition_state_id=target_state.id + ), + ) + try: + fetched_transition = proj.workflows.transitions.retrieve( + created.id, transition.id + ) + assert fetched_transition.transition_state_id == target_state.id + + patched_transition = proj.workflows.transitions.update( + created.id, transition.id, UpdateWorkflowTransition(required_approvals=1) + ) + assert patched_transition.required_approvals == 1 + finally: + proj.workflows.transitions.delete(created.id, transition.id) + + proj.workflows.states.delete(created.id, target_row.id) + finally: + proj.workflows.delete(created.id) + + with pytest.raises(PlaneAPIError) as exc_info: + proj.workflows.retrieve(created.id) + assert exc_info.value.status == 404 diff --git a/tests/v2/integration/test_worklogs_summary.py b/tests/v2/integration/test_worklogs_summary.py new file mode 100644 index 0000000..9e584ab --- /dev/null +++ b/tests/v2/integration/test_worklogs_summary.py @@ -0,0 +1,37 @@ +"""Live coverage for `proj.worklogs.summary()`: the per-project roll-up of +logged time, returned as a bare array. Requires `is_time_tracking_enabled=True`, +which the shared `project` fixture already sets.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from plane.api.v2.project import Project +from plane.client import PlaneClient +from plane.models.v2.work_items import CreateWorkItemWorklog + + +@pytest.fixture +def proj(client: PlaneClient, workspace_slug: str, project_id: str) -> Project: + return client.v2.workspace(workspace_slug).project(project_id) + + +class TestProjectWorklogsSummary: + def test_summary_reflects_a_logged_worklog(self, proj: Project, work_item: Any) -> None: + """Requires the project to have `is_time_tracking_enabled=True` (true of + the shared `project` fixture) -- otherwise worklog creation 404s.""" + proj.work_items.worklogs.create(work_item.id, CreateWorkItemWorklog(duration=90)) + + rows = proj.worklogs.summary() + + matching = [row for row in rows if row.work_item_id == work_item.id] + assert matching + assert matching[0].duration >= 90 + + def test_summary_on_a_project_with_no_worklogs_is_empty_list(self, proj: Project) -> None: + """A project with zero worklogs still returns `[]`, not `null` or a 404 + -- proves the bare-array parse handles the empty case.""" + rows = proj.worklogs.summary() + assert isinstance(rows, list) diff --git a/tests/v2/integration/test_workspace_work_item_types_flow.py b/tests/v2/integration/test_workspace_work_item_types_flow.py new file mode 100644 index 0000000..a4ba656 --- /dev/null +++ b/tests/v2/integration/test_workspace_work_item_types_flow.py @@ -0,0 +1,164 @@ +"""End-to-end flow for WORKSPACE-managed work item types (port of plane-ee's +`tests/e2e/api_v2/workspace_work_item_types_flow.py`). Needs workspace mode; enabling it +is irreversible, so it only flips when PLANE_E2E_ENABLE_WORKSPACE_WORK_ITEM_TYPES=1.""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest + +from plane.api.v2 import PlaneAPIError +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.client import PlaneClient +from plane.models.v2.features import UpdateWorkspaceFeature +from plane.models.v2.work_item_properties import ( + CreateWorkItemProperty, + CreateWorkItemPropertyContext, + WorkItemPropertyContextOptionInput, +) +from plane.models.v2.work_item_types import CreateWorkItemType +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + +KEEP = os.environ.get("PLANE_E2E_KEEP") == "1" +MAY_ENABLE = os.environ.get("PLANE_E2E_ENABLE_WORKSPACE_WORK_ITEM_TYPES") == "1" + + +def _name(row: Any) -> str: + return str(row["name"] if isinstance(row, dict) else row.name) + + +def _id(row: Any) -> str: + return str(row["id"] if isinstance(row, dict) else row.id) + + +@pytest.fixture +def ws(client: PlaneClient, workspace_slug: str) -> Workspace: + return client.v2.workspace(workspace_slug) + + +@pytest.fixture +def proj(ws: Workspace, project_id: str) -> Project: + return ws.project(project_id) + + +@pytest.fixture(autouse=True) +def _require_workspace_mode(ws: Workspace) -> None: + if ws.features.retrieve().is_work_item_types_enabled: + return + if not MAY_ENABLE: + pytest.skip( + "workspace is in project mode; set PLANE_E2E_ENABLE_WORKSPACE_WORK_ITEM_TYPES=1 " + "to let this flow enable workspace-level types (irreversible)" + ) + # 1. enable -- auto-creates a default workspace type and fans it out to every project + feature = ws.features.update(UpdateWorkspaceFeature(is_work_item_types_enabled=True)) + assert feature.is_work_item_types_enabled is True + + +def test_workspace_work_item_types_flow(ws: Workspace, proj: Project, project_id: str) -> None: + suffix = unique_name("")[1:] + created_work_items: list[str] = [] + attached: list[str] = [] + properties: list[str] = [] + type_id: str | None = None + + try: + # 2. create a workspace-owned type + type_name = f"WS Type {suffix}" + wtype = ws.work_item_types.create(CreateWorkItemType(name=type_name)) + type_id = wtype.id + + # 3. list + retrieve + assert any(row.id == type_id for row in ws.work_item_types.list().data) + assert ws.work_item_types.retrieve(type_id).id == type_id + + # 4-5. workspace TEXT + OPTION properties (option values come from the context below) + text_prop = ws.work_item_properties.create( + CreateWorkItemProperty(display_name=f"Severity {suffix}", property_type="TEXT") + ) + properties.append(text_prop.id) + option_prop = ws.work_item_properties.create( + CreateWorkItemProperty(display_name=f"Tier {suffix}", property_type="OPTION") + ) + properties.append(option_prop.id) + sev_key, tier_key = text_prop.name, option_prop.name + assert sev_key and tier_key + + # 6. scope the OPTION property to this project + type via a context, with its options + context = ws.work_item_properties.contexts.create( + option_prop.id, + CreateWorkItemPropertyContext( + name=f"Bug-only {suffix}", + is_required=False, + applies_to_all_projects=False, + applies_to_all_work_item_types=False, + project_ids=[project_id], + issue_type_ids=[type_id], + options=[ + WorkItemPropertyContextOptionInput(name="Gold", is_default=True), + WorkItemPropertyContextOptionInput(name="Silver"), + ], + ), + ) + gold_id = next(_id(o) for o in context.options or [] if _name(o) == "Gold") + + # 7. attach both properties to the workspace type + result = ws.work_item_types.properties.attach(type_id, [text_prop.id, option_prop.id]) + attached.extend([text_prop.id, option_prop.id]) + assert {text_prop.id, option_prop.id}.issubset(set(result.properties)) + + # 8. import the workspace type into the project + proj.work_item_types.import_types([type_id]) + assert any(row.id == type_id for row in proj.work_item_types.list().data) + + # 9-10. create a work item of the imported type with custom_fields, read it back + item = proj.work_items.create( + CreateWorkItem( + name="WS-mode work item", + type_id=type_id, + custom_fields={sev_key: "high", tier_key: gold_id}, + ) + ) + created_work_items.append(item.id) + assert item.custom_fields is not None + assert item.custom_fields[sev_key]["value"] == "high" # type: ignore[index] + assert proj.work_items.retrieve(item.id).id == item.id + + # 11. project-level type writes are blocked in workspace mode + with pytest.raises(PlaneAPIError) as exc_info: + proj.work_item_types.create(CreateWorkItemType(name="nope")) + assert exc_info.value.status == 409 + assert exc_info.value.code == "work_item_types_managed_at_workspace" + finally: + if not KEEP: + for work_item_id in created_work_items: + _swallow(proj.work_items.delete, work_item_id) + for property_id in attached: + _swallow(ws.work_item_types.properties.detach, type_id or "", property_id) + for property_id in properties: + _swallow(ws.work_item_properties.delete, property_id) + if type_id: + _swallow(ws.work_item_types.delete, type_id) + + +def _swallow(action: Any, *args: Any) -> None: + """Best-effort cleanup step; failures are collected and reported at the end.""" + try: + action(*args) + except Exception as exc: + _cleanup_failures.append(f"{getattr(action, '__qualname__', action)}{args}: {exc}") + + +_cleanup_failures: list[str] = [] + + +@pytest.fixture(autouse=True) +def _report_cleanup_failures() -> Any: + _cleanup_failures.clear() + yield + assert not _cleanup_failures, "cleanup left rows behind:\n" + "\n".join(_cleanup_failures) diff --git a/tests/v2/integration/test_workspace_work_items.py b/tests/v2/integration/test_workspace_work_items.py new file mode 100644 index 0000000..0245895 --- /dev/null +++ b/tests/v2/integration/test_workspace_work_items.py @@ -0,0 +1,56 @@ +"""`client.v2.workspace(...).work_items` against a real server: workspace-wide +listing plus `retrieve_by_identifier` (the "ENG-12"-style lookup); split out +from `test_work_items.py` since its operationId prefix differs.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest + +from plane.client import PlaneClient +from plane.models.v2.work_items import CreateWorkItem + +from .helpers import unique_name + + +@pytest.fixture +def work_item(client: PlaneClient, workspace_slug: str, project_id: str) -> Iterator[Any]: + """One freshly created work item, deleted afterwards.""" + proj = client.v2.workspace(workspace_slug).project(project_id) + created = proj.work_items.create(CreateWorkItem(name=unique_name("wi"))) + yield created + try: + proj.work_items.delete(created.id) + except Exception: + pass + + +def test_list_includes_the_work_item( + client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any +) -> None: + ws = client.v2.workspace(workspace_slug) + page = ws.work_items.list(project_id=project_id) + assert any(row.id == work_item.id for row in page.data) + + +def test_iterate_includes_the_work_item( + client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any +) -> None: + ws = client.v2.workspace(workspace_slug) + rows = list(ws.work_items.iterate(project_id=project_id)) + assert any(row.id == work_item.id for row in rows) + + +def test_retrieve_by_identifier_matches_the_uuid_lookup( + client: PlaneClient, workspace_slug: str, project_id: str, work_item: Any +) -> None: + ws = client.v2.workspace(workspace_slug) + assert work_item.identifier is not None + fetched = ws.work_items.retrieve_by_identifier(work_item.identifier) + assert fetched.id == work_item.id + + url = f"/workspaces/{workspace_slug}/work-items/{work_item.identifier}/" + # Sanity: this really is the workspace-level, non-project-scoped route. + assert "projects" not in url diff --git a/tests/v2/test_artifacts_resource.py b/tests/v2/test_artifacts_resource.py new file mode 100644 index 0000000..1ec36f8 --- /dev/null +++ b/tests/v2/test_artifacts_resource.py @@ -0,0 +1,82 @@ +"""Offline coverage for artifacts; pins the differently-shaped envelope each action actually +validates against.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.artifacts import Artifacts +from plane.config import Configuration +from plane.models.v2.artifacts import CreateArtifact, UpdateArtifactUpdate + +BASE = "https://api.example.com/api/v2/workspaces/acme/artifacts" + + +@pytest.fixture +def artifacts(config: Configuration) -> Artifacts: + return Artifacts(V2Transport(config), slug="acme") + + +@responses.activate +def test_create_returns_the_artifact_envelope(artifacts: Artifacts) -> None: + responses.post( + f"{BASE}/", + json={ + "id": "1", + "name": "Report", + "current_version": 1, + "is_published": False, + "anchor": None, + "data_mode": "snapshot", + }, + status=201, + ) + + created = artifacts.create(CreateArtifact(name="Report", html="

hi

")) + + assert created.is_published is False + assert created.anchor is None + + +@responses.activate +def test_retrieve_returns_the_detail_envelope_with_html(artifacts: Artifacts) -> None: + responses.get( + f"{BASE}/1/", + json={ + "id": "1", + "name": "Report", + "description": "", + "data_mode": "snapshot", + "current_version": 1, + "html": "

hi

", + }, + ) + + detail = artifacts.retrieve("1") + + assert detail.html == "

hi

" + assert responses.calls[0].request.url == f"{BASE}/1/" + + +@responses.activate +def test_publish_sends_no_body_and_returns_the_publish_envelope(artifacts: Artifacts) -> None: + responses.post(f"{BASE}/1/publish/", json={"anchor": "anc123", "is_active": True}, status=201) + + published = artifacts.publish("1") + + assert published.anchor == "anc123" + assert responses.calls[0].request.body is None + + +@responses.activate +def test_update_hits_the_update_sub_path_and_returns_the_updated_envelope( + artifacts: Artifacts, +) -> None: + responses.patch( + f"{BASE}/1/update/", json={"id": "1", "current_version": 2, "data_mode": "snapshot"} + ) + + updated = artifacts.update("1", UpdateArtifactUpdate(html="

v2

")) + + assert updated.current_version == 2 + assert responses.calls[0].request.url == f"{BASE}/1/update/" diff --git a/tests/v2/test_assets_resource.py b/tests/v2/test_assets_resource.py new file mode 100644 index 0000000..9aadd78 --- /dev/null +++ b/tests/v2/test_assets_resource.py @@ -0,0 +1,172 @@ +"""Offline coverage for `WorkspaceAssets`/`UserAssets`; pins that `create()` parses the +`*UploadResult` envelope.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.assets import UserAssets, WorkspaceAssets +from plane.config import Configuration +from plane.models.v2.assets import ( + CreateUserAsset, + CreateWorkspaceAsset, + UserAssetConfirm, + WorkspaceAssetConfirm, +) + +WORKSPACE_BASE = "https://api.example.com/api/v2/workspaces/acme/assets" +USER_BASE = "https://api.example.com/api/v2/users/me/assets" + + +@pytest.fixture +def workspace_assets(config: Configuration) -> WorkspaceAssets: + return WorkspaceAssets(V2Transport(config), slug="acme") + + +@pytest.fixture +def user_assets(config: Configuration) -> UserAssets: + return UserAssets(V2Transport(config)) + + +# -- WorkspaceAssets ------------------------------------------------------------- + + +@responses.activate +def test_workspace_assets_list(workspace_assets: WorkspaceAssets) -> None: + responses.get( + f"{WORKSPACE_BASE}/", + json={ + "data": [{"id": "a1", "name": "log.txt"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = workspace_assets.list() + + assert page.total_count == 1 + assert page.data[0].name == "log.txt" + + +@responses.activate +def test_workspace_assets_retrieve(workspace_assets: WorkspaceAssets) -> None: + responses.get(f"{WORKSPACE_BASE}/a1/", json={"id": "a1", "name": "log.txt"}) + + row = workspace_assets.retrieve("a1") + + assert row.id == "a1" + + +@responses.activate +def test_workspace_assets_create_parses_the_upload_envelope( + workspace_assets: WorkspaceAssets, +) -> None: + responses.post( + f"{WORKSPACE_BASE}/", + json={ + "asset_id": "a1", + "asset_url": "/api/assets/v2/.../a1/", + "upload_data": {"url": "https://s3.example.com", "fields": {"key": "a1"}}, + "asset": {"id": "a1", "name": "log.txt", "is_uploaded": False}, + }, + ) + + result = workspace_assets.create(CreateWorkspaceAsset(name="log.txt", size=42)) + + assert result.asset_id == "a1" + assert result.upload_data["url"] == "https://s3.example.com" + assert result.asset.id == "a1" + assert result.asset.is_uploaded is False + + +@responses.activate +def test_workspace_assets_create_sends_only_the_write_fields( + workspace_assets: WorkspaceAssets, +) -> None: + responses.post( + f"{WORKSPACE_BASE}/", + json={ + "asset_id": "a1", + "asset_url": "u", + "upload_data": {}, + "asset": {"id": "a1"}, + }, + ) + + workspace_assets.create(CreateWorkspaceAsset(name="log.txt", size=42)) + + body = json.loads(responses.calls[0].request.body) + assert body == {"name": "log.txt", "size": 42} + + +@responses.activate +def test_workspace_assets_update_confirms_upload(workspace_assets: WorkspaceAssets) -> None: + responses.patch(f"{WORKSPACE_BASE}/a1/", json={"id": "a1", "is_uploaded": True}) + + updated = workspace_assets.update("a1", WorkspaceAssetConfirm(is_uploaded=True)) + + assert updated.is_uploaded is True + body = json.loads(responses.calls[0].request.body) + assert body == {"is_uploaded": True} + + +@responses.activate +def test_workspace_assets_delete_returns_none(workspace_assets: WorkspaceAssets) -> None: + responses.delete(f"{WORKSPACE_BASE}/a1/", status=204) + + assert workspace_assets.delete("a1") is None + + +# -- UserAssets (not workspace-scoped) -------------------------------------------- + + +@responses.activate +def test_user_assets_list_has_no_workspace_in_the_url(user_assets: UserAssets) -> None: + responses.get( + f"{USER_BASE}/", + json={"data": [{"id": "u1"}], "pagination": {"style": "offset"}}, + ) + + page = user_assets.list() + + assert page.data[0].id == "u1" + assert "workspaces" not in responses.calls[0].request.url + + +@responses.activate +def test_user_assets_create_requires_entity_type(user_assets: UserAssets) -> None: + responses.post( + f"{USER_BASE}/", + json={ + "asset_id": "u1", + "asset_url": "u", + "upload_data": {}, + "asset": {"id": "u1"}, + }, + ) + + result = user_assets.create( + CreateUserAsset(entity_type="USER_AVATAR", name="avatar.png", size=10) + ) + + assert result.asset_id == "u1" + body = json.loads(responses.calls[0].request.body) + assert body["entity_type"] == "USER_AVATAR" + + +@responses.activate +def test_user_assets_update_confirms_upload(user_assets: UserAssets) -> None: + responses.patch(f"{USER_BASE}/u1/", json={"id": "u1", "is_uploaded": True}) + + updated = user_assets.update("u1", UserAssetConfirm(is_uploaded=True)) + + assert updated.is_uploaded is True + + +@responses.activate +def test_user_assets_delete_returns_none(user_assets: UserAssets) -> None: + responses.delete(f"{USER_BASE}/u1/", status=204) + + assert user_assets.delete("u1") is None diff --git a/tests/v2/test_audit_logs_resource.py b/tests/v2/test_audit_logs_resource.py new file mode 100644 index 0000000..bd22dd9 --- /dev/null +++ b/tests/v2/test_audit_logs_resource.py @@ -0,0 +1,76 @@ +"""Offline coverage for `ws.audit_logs`: filtered list, cursor pagination, retrieve, and rejection +of an unknown `order_by`.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.audit_logs import AuditLogs +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def audit_logs(config: Configuration) -> AuditLogs: + return AuditLogs(V2Transport(config), slug="acme") + + +@responses.activate +def test_audit_logs_list_with_filters(audit_logs: AuditLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/audit-logs/", + json={ + "data": [ + {"id": "a1", "category": "member", "outcome": "success", "actor_type": "user"} + ], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = audit_logs.list(category="member", outcome="success") + + assert page.data[0].outcome == "success" + sent_url = responses.calls[0].request.url + assert sent_url is not None + assert "category=member" in sent_url + assert "outcome=success" in sent_url + + +@responses.activate +def test_audit_logs_cursor_page(audit_logs: AuditLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/audit-logs/", + json={ + "data": [{"id": "a1"}], + "pagination": {"style": "cursor"}, + "has_more": False, + "next_cursor": None, + }, + ) + + page = audit_logs.list(paginate="cursor") + + assert page.has_more is False + + +@responses.activate +def test_audit_logs_retrieve(audit_logs: AuditLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/audit-logs/a1/", + json={"id": "a1", "event_name": "member.invited"}, + ) + + entry = audit_logs.retrieve("a1") + + assert entry.event_name == "member.invited" + + +def test_audit_logs_unknown_order_by_rejected(audit_logs: AuditLogs) -> None: + """`order_by` is a closed enum for `audit_logs_list` -- negative assertion, + proven capable of failing below.""" + with pytest.raises(ValueError, match="Unknown order_by"): + audit_logs.list(order_by="-not_a_real_column") diff --git a/tests/v2/test_automations_resource.py b/tests/v2/test_automations_resource.py new file mode 100644 index 0000000..4403a10 --- /dev/null +++ b/tests/v2/test_automations_resource.py @@ -0,0 +1,397 @@ +"""Offline coverage for project/workspace automations; covers a 204-no-body custom action +(`set_status`) and one returning a different model (`regenerate_webhook_secret`).""" + +from __future__ import annotations + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.automations import ProjectAutomations, WorkspaceAutomations +from plane.config import Configuration +from plane.models.v2.automations import ( + CreateAutomation, + CreateAutomationEdge, + CreateAutomationNode, + UpdateAutomation, + UpdateAutomationEdge, + UpdateAutomationNode, +) + +PROJECT_BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/automations" +WORKSPACE_BASE = "https://api.example.com/api/v2/workspaces/acme/automations" + + +@pytest.fixture +def project_automations(config: Configuration) -> ProjectAutomations: + return ProjectAutomations(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_automations(config: Configuration) -> WorkspaceAutomations: + return WorkspaceAutomations(V2Transport(config), slug="acme") + + +def test_project_automations_forward_scope_to_sub_resources( + project_automations: ProjectAutomations, +) -> None: + scope = {"slug": "acme", "project_id": "ENG"} + assert project_automations.edges._scope == scope + assert project_automations.nodes._scope == scope + assert project_automations.activities._scope == scope + + +def test_workspace_automations_forward_scope_to_sub_resources( + workspace_automations: WorkspaceAutomations, +) -> None: + scope = {"slug": "acme"} + assert workspace_automations.edges._scope == scope + assert workspace_automations.nodes._scope == scope + assert workspace_automations.activities._scope == scope + + +# -- Project-scoped CRUD -------------------------------------------------------- + + +@responses.activate +def test_project_list(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={ + "data": [{"id": "a1", "name": "Auto-close stale", "scope": "WorkItem"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = project_automations.list() + + assert page.total_count == 1 + assert page.data[0].scope == "WorkItem" + + +@responses.activate +def test_project_list_passes_filters(project_automations: ProjectAutomations) -> None: + responses.get(f"{PROJECT_BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + project_automations.list(is_enabled=True, status="published") + + query = responses.calls[0].request.url + assert "is_enabled=True" in query + assert "status=published" in query + + +def test_project_list_rejects_unknown_field_before_the_request( + project_automations: ProjectAutomations, +) -> None: + with pytest.raises(ValueError, match="bogus"): + project_automations.list(fields=["bogus"]) + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none( + project_automations: ProjectAutomations, +) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={"data": [{"id": "a1"}], "pagination": {"style": "offset"}}, + ) + + page = project_automations.list(fields=["id"]) + + assert page.data[0].id == "a1" + assert page.data[0].name is None + assert page.data[0].status is None + + +@responses.activate +def test_project_retrieve(project_automations: ProjectAutomations) -> None: + responses.get(f"{PROJECT_BASE}/a1/", json={"id": "a1", "name": "Auto-close stale"}) + + row = project_automations.retrieve("a1") + + assert row.id == "a1" + + +@responses.activate +def test_project_find_by_name(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={ + "data": [{"id": "a1", "name": "Auto-close stale"}], + "pagination": {"style": "offset"}, + }, + ) + + assert project_automations.find_by_name("Auto-close stale").id == "a1" + + +@responses.activate +def test_project_create_sends_shell_fields_only(project_automations: ProjectAutomations) -> None: + responses.post(f"{PROJECT_BASE}/", json={"id": "a1", "name": "New"}, status=201) + + project_automations.create(CreateAutomation(name="New", scope="WorkItem", description="desc")) + + body = json.loads(responses.calls[0].request.body) + assert body == {"name": "New", "scope": "WorkItem", "description": "desc"} + + +@responses.activate +def test_project_update_uses_patch(project_automations: ProjectAutomations) -> None: + responses.patch(f"{PROJECT_BASE}/a1/", json={"id": "a1", "name": "Renamed"}) + + updated = project_automations.update("a1", UpdateAutomation(name="Renamed")) + + assert updated.name == "Renamed" + + +@responses.activate +def test_project_delete_returns_none(project_automations: ProjectAutomations) -> None: + responses.delete(f"{PROJECT_BASE}/a1/", status=204) + + assert project_automations.delete("a1") is None + + +@responses.activate +def test_project_set_status_posts_body_and_returns_none( + project_automations: ProjectAutomations, +) -> None: + """204 no body -- distinct from `_action`, which always parses a row.""" + responses.post(f"{PROJECT_BASE}/a1/status/", status=204) + + result = project_automations.set_status("a1", is_enabled=True) + + assert result is None + body = json.loads(responses.calls[0].request.body) + assert body == {"is_enabled": True} + + +# -- Workspace-scoped CRUD ------------------------------------------------------- + + +@responses.activate +def test_workspace_list_hits_the_workspace_level_path_not_the_project_one( + workspace_automations: WorkspaceAutomations, +) -> None: + responses.get( + f"{WORKSPACE_BASE}/", + json={"data": [{"id": "a2"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_automations.list() + + assert page.data[0].id == "a2" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_workspace_create(workspace_automations: WorkspaceAutomations) -> None: + responses.post(f"{WORKSPACE_BASE}/", json={"id": "a2", "name": "Global"}, status=201) + + created = workspace_automations.create(CreateAutomation(name="Global", scope="Cycle")) + + assert created.id == "a2" + + +@responses.activate +def test_workspace_set_status(workspace_automations: WorkspaceAutomations) -> None: + responses.post(f"{WORKSPACE_BASE}/a2/status/", status=204) + + assert workspace_automations.set_status("a2", is_enabled=False) is None + body = json.loads(responses.calls[0].request.body) + assert body == {"is_enabled": False} + + +# -- Sub-resources: edges --------------------------------------------------------- + + +@responses.activate +def test_project_edges_crud(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/a1/edges/", + json={ + "data": [{"id": "e1", "source_node_id": "n1", "target_node_id": "n2"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{PROJECT_BASE}/a1/edges/", + json={"id": "e1", "source_node_id": "n1", "target_node_id": "n2"}, + status=201, + ) + responses.get(f"{PROJECT_BASE}/a1/edges/e1/", json={"id": "e1"}) + responses.patch(f"{PROJECT_BASE}/a1/edges/e1/", json={"id": "e1", "execution_order": 2}) + responses.delete(f"{PROJECT_BASE}/a1/edges/e1/", status=204) + + page = project_automations.edges.list("a1") + assert page.data[0].id == "e1" + + created = project_automations.edges.create( + "a1", CreateAutomationEdge(source_node_id="n1", target_node_id="n2") + ) + assert created.id == "e1" + + fetched = project_automations.edges.retrieve("a1", "e1") + assert fetched.id == "e1" + + updated = project_automations.edges.update( + "a1", "e1", UpdateAutomationEdge(execution_order=2) + ) + assert updated.execution_order == 2 + + assert project_automations.edges.delete("a1", "e1") is None + + +@responses.activate +def test_workspace_edges_create_hits_the_workspace_level_path( + workspace_automations: WorkspaceAutomations, +) -> None: + responses.post( + f"{WORKSPACE_BASE}/a2/edges/", + json={"id": "e2", "source_node_id": "n3", "target_node_id": "n4"}, + status=201, + ) + + created = workspace_automations.edges.create( + "a2", CreateAutomationEdge(source_node_id="n3", target_node_id="n4") + ) + + assert created.id == "e2" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_edges_list_passes_node_filters(project_automations: ProjectAutomations) -> None: + responses.get(f"{PROJECT_BASE}/a1/edges/", json={"data": [], "pagination": {"style": "offset"}}) + + project_automations.edges.list("a1", source_node_id="n1") + + assert "source_node_id=n1" in responses.calls[0].request.url + + +# -- Sub-resources: nodes (+ regenerate_webhook_secret) -------------------------- + + +@responses.activate +def test_project_nodes_crud(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/a1/nodes/", + json={ + "data": [{"id": "n1", "name": "On create", "node_type": "trigger"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{PROJECT_BASE}/a1/nodes/", + json={"id": "n1", "name": "On create", "node_type": "trigger"}, + status=201, + ) + responses.get(f"{PROJECT_BASE}/a1/nodes/n1/", json={"id": "n1"}) + responses.patch(f"{PROJECT_BASE}/a1/nodes/n1/", json={"id": "n1", "is_enabled": False}) + responses.delete(f"{PROJECT_BASE}/a1/nodes/n1/", status=204) + + page = project_automations.nodes.list("a1") + assert page.data[0].node_type == "trigger" + + created = project_automations.nodes.create( + "a1", + CreateAutomationNode(handler_name="record_created", name="On create", node_type="trigger"), + ) + assert created.id == "n1" + + fetched = project_automations.nodes.retrieve("a1", "n1") + assert fetched.id == "n1" + + updated = project_automations.nodes.update("a1", "n1", UpdateAutomationNode(is_enabled=False)) + assert updated.is_enabled is False + + assert project_automations.nodes.delete("a1", "n1") is None + + +@responses.activate +def test_project_nodes_find_by_name(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/a1/nodes/", + json={"data": [{"id": "n1", "name": "On create"}], "pagination": {"style": "offset"}}, + ) + + assert project_automations.nodes.find_by_name("a1", "On create").id == "n1" + + +@responses.activate +def test_project_regenerate_webhook_secret_returns_a_different_model_than_the_resource( + project_automations: ProjectAutomations, +) -> None: + """Not `_action`: the response is `AutomationWebhookSecret`, not `AutomationNode`.""" + responses.post( + f"{PROJECT_BASE}/a1/nodes/n1/regenerate-webhook-secret/", json={"secret": "whsec_abc"} + ) + + result = project_automations.nodes.regenerate_webhook_secret("a1", "n1") + + assert result.secret == "whsec_abc" + + +@responses.activate +def test_workspace_regenerate_webhook_secret_hits_the_workspace_level_path( + workspace_automations: WorkspaceAutomations, +) -> None: + responses.post( + f"{WORKSPACE_BASE}/a2/nodes/n2/regenerate-webhook-secret/", json={"secret": "whsec_xyz"} + ) + + result = workspace_automations.nodes.regenerate_webhook_secret("a2", "n2") + + assert result.secret == "whsec_xyz" + assert "projects" not in responses.calls[0].request.url + + +# -- Sub-resources: activities (read-only) ---------------------------------------- + + +@responses.activate +def test_project_activities_are_read_only(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/a1/activities/", + json={"data": [{"id": "act1", "verb": "triggered"}], "pagination": {"style": "offset"}}, + ) + responses.get(f"{PROJECT_BASE}/a1/activities/act1/", json={"id": "act1", "verb": "triggered"}) + + page = project_automations.activities.list("a1") + assert page.data[0].verb == "triggered" + + fetched = project_automations.activities.retrieve("a1", "act1") + assert fetched.id == "act1" + + assert project_automations.activities.operations == { + "list": "project_automation_activities_list", + "retrieve": "project_automation_activities_retrieve", + } + + +@responses.activate +def test_workspace_activities_hit_the_workspace_level_path( + workspace_automations: WorkspaceAutomations, +) -> None: + responses.get( + f"{WORKSPACE_BASE}/a2/activities/", + json={"data": [{"id": "act2"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_automations.activities.list("a2") + + assert page.data[0].id == "act2" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_activities_pass_verb_filter(project_automations: ProjectAutomations) -> None: + responses.get( + f"{PROJECT_BASE}/a1/activities/", json={"data": [], "pagination": {"style": "offset"}} + ) + + project_automations.activities.list("a1", verb="triggered") + + assert "verb=triggered" in responses.calls[0].request.url diff --git a/tests/v2/test_bulk.py b/tests/v2/test_bulk.py new file mode 100644 index 0000000..7c6260e --- /dev/null +++ b/tests/v2/test_bulk.py @@ -0,0 +1,166 @@ +import json + +import pytest +import responses +from pydantic import BaseModel, ConfigDict + +from plane.api.v2 import PlaneAPIError +from plane.api.v2._kernel.resource import V2Resource +from plane.api.v2._kernel.transport import V2Transport +from plane.config import Configuration +from plane.models.v2.common import BulkRowFailure, BulkRowSuccess + + +class Row(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + + +class WriteRow(BaseModel): + name: str + + +class PatchRow(BaseModel): + name: str | None = None + + +class Rows(V2Resource[Row, WriteRow, PatchRow]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = Row + operations = { + "list": "states_list", + "retrieve": "states_retrieve", + "create": "states_create", + "update": "states_partial_update", + "upsert": "states_upsert", + } + + +@pytest.fixture +def rows(config: Configuration) -> Rows: + return Rows(V2Transport(config)) + + +@responses.activate +def test_upsert_returns_the_row(rows: Rows) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/upsert/", + json={"id": "1", "name": "Todo"}, + ) + + assert rows._upsert(WriteRow(name="Todo"), slug="acme", project_id="ENG").id == "1" + + +@responses.activate +def test_bulk_create_posts_items_envelope(rows: Rows) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = rows._bulk_create([WriteRow(name="Todo")], slug="acme", project_id="ENG") + + body = json.loads(responses.calls[0].request.body) + assert body == {"items": [{"name": "Todo"}], "all_or_none": False} + assert isinstance(result.results[0], BulkRowSuccess) + assert result.succeeded == 1 + + +@responses.activate +def test_bulk_update_posts_items_envelope(rows: Rows) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/bulk-update/", + json={ + "results": [{"index": 0, "result": "updated", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = rows._bulk_update( + [{"id": "1", "name": "Done"}], slug="acme", project_id="ENG", all_or_none=True + ) + + body = json.loads(responses.calls[0].request.body) + assert body == {"items": [{"id": "1", "name": "Done"}], "all_or_none": True} + assert isinstance(result.results[0], BulkRowSuccess) + assert result.succeeded == 1 + + +@responses.activate +def test_bulk_delete_posts_ids_envelope(rows: Rows) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/bulk-delete/", + json={ + "results": [{"index": 0, "result": "deleted", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + rows._bulk_delete(["1"], slug="acme", project_id="ENG", all_or_none=True) + + body = json.loads(responses.calls[0].request.body) + assert body == {"ids": ["1"], "all_or_none": True} + + +@responses.activate +def test_failed_rows_parse_as_failures_and_can_raise(rows: Rows) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/bulk-create/", + json={ + "results": [ + {"index": 0, "result": "created", "id": "1"}, + { + "index": 1, + "result": "failed", + "type": "invalid_request", + "code": "invalid_request", + "detail": "One or more fields failed validation.", + "errors": [{"field": "name", "message": "This field is required."}], + }, + ], + "succeeded": 1, + "failed": 1, + }, + ) + + result = rows._bulk_create( + [WriteRow(name="Todo"), WriteRow(name="x")], slug="acme", project_id="ENG" + ) + + assert isinstance(result.results[1], BulkRowFailure) + assert result.results[1].errors[0].field == "name" + with pytest.raises(PlaneAPIError, match="1 of 2 rows failed") as exc_info: + result.raise_for_failures() + assert exc_info.value.errors[0].field == "name" + + +def test_batch_cap_is_enforced_client_side(rows: Rows) -> None: + with pytest.raises(ValueError, match="At most 50"): + rows._bulk_delete([str(n) for n in range(51)], slug="acme", project_id="ENG") + + +def test_empty_batch_is_rejected_client_side_for_bulk_create(rows: Rows) -> None: + """The API 400s on an empty `items` list; the client must reject it first, + with wording that mirrors `BulkWriteMixin.perform_bulk_create`'s own detail.""" + with pytest.raises(ValueError, match="Provide a non-empty list of write bodies\\."): + rows._bulk_create([], slug="acme", project_id="ENG") + + +def test_empty_batch_is_rejected_client_side_for_bulk_update(rows: Rows) -> None: + with pytest.raises( + ValueError, match="Provide a non-empty list of write bodies, each with an id\\." + ): + rows._bulk_update([], slug="acme", project_id="ENG") + + +def test_empty_batch_is_rejected_client_side_for_bulk_delete(rows: Rows) -> None: + with pytest.raises(ValueError, match="Provide a non-empty list of ids\\."): + rows._bulk_delete([], slug="acme", project_id="ENG") diff --git a/tests/v2/test_collections_resource.py b/tests/v2/test_collections_resource.py new file mode 100644 index 0000000..b6cce26 --- /dev/null +++ b/tests/v2/test_collections_resource.py @@ -0,0 +1,207 @@ +"""Offline coverage for `Collections`; covers CRUD, `default()`, and the `members`/`pages` sub- +resources' list/manage verbs.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.errors import NoMatchFound +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.collections import Collections +from plane.config import Configuration +from plane.models.v2.collections import ( + CollectionMemberAdd, + CollectionMembersManage, + CollectionPagesManage, + CreateCollection, + UpdateCollection, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/collections" + + +@pytest.fixture +def collections(config: Configuration) -> Collections: + return Collections(V2Transport(config), slug="acme") + + +# -- CRUD -------------------------------------------------------------------------- + + +@responses.activate +def test_list_collections(collections: Collections) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "c1", "name": "Runbooks"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = collections.list() + + assert page.total_count == 1 + assert page.data[0].name == "Runbooks" + + +@responses.activate +def test_retrieve_collection(collections: Collections) -> None: + responses.get(f"{BASE}/c1/", json={"id": "c1", "name": "Runbooks"}) + + row = collections.retrieve("c1") + + assert row.id == "c1" + + +@responses.activate +def test_find_by_name(collections: Collections) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "c1", "name": "Runbooks"}], "pagination": {"style": "offset"}}, + ) + + assert collections.find_by_name("Runbooks").id == "c1" + + +@responses.activate +def test_default_uses_the_is_default_query_filter(collections: Collections) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "c1", "name": "General", "is_default": True}], + "pagination": {"style": "offset"}, + }, + ) + + default = collections.default() + + assert default.id == "c1" + assert "is_default=True" in responses.calls[0].request.url + + +@responses.activate +def test_default_raises_when_none_found(collections: Collections) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + with pytest.raises(NoMatchFound): + collections.default() + + +@responses.activate +def test_create_accepts_a_bare_body(collections: Collections) -> None: + """`CollectionWriteRequest` names no required field in the golden.""" + responses.post(f"{BASE}/", json={"id": "c1"}, status=201) + + created = collections.create(CreateCollection()) + + assert created.id == "c1" + assert json.loads(responses.calls[0].request.body) == {} + + +@responses.activate +def test_update_uses_patch(collections: Collections) -> None: + responses.patch(f"{BASE}/c1/", json={"id": "c1", "name": "Renamed"}) + + updated = collections.update("c1", UpdateCollection(name="Renamed")) + + assert updated.name == "Renamed" + + +@responses.activate +def test_delete_returns_none(collections: Collections) -> None: + responses.delete(f"{BASE}/c1/", status=204) + + assert collections.delete("c1") is None + + +# -- Members (collections.members) ---------------------------------------------- + + +@responses.activate +def test_members_list_parses_a_plain_array(collections: Collections) -> None: + """The golden documents a bare `CollectionMember` (not an array, not an + envelope) for this operation, and it carries no pagination params -- the + real shape is a plain JSON array of rows.""" + responses.get( + f"{BASE}/c1/members/", + json=[ + {"id": "m1", "member_id": "u1", "access": 2}, + {"id": "m2", "member_id": "u2", "access": 0}, + ], + ) + + members = collections.members.list("c1") + + assert [m.member_id for m in members] == ["u1", "u2"] + assert members[0].access == 2 + + +@responses.activate +def test_members_list_passes_expand(collections: Collections) -> None: + responses.get(f"{BASE}/c1/members/", json=[]) + + collections.members.list("c1", expand=["member"]) + + assert "expand=member" in responses.calls[0].request.url + + +def test_members_list_rejects_unknown_expand(collections: Collections) -> None: + with pytest.raises(ValueError, match="bogus"): + collections.members.list("c1", expand=["bogus"]) + + +@responses.activate +def test_members_manage_adds_and_removes(collections: Collections) -> None: + responses.post( + f"{BASE}/c1/members/", + json={"added": ["u1"], "removed": ["u2"]}, + ) + + result = collections.members.manage( + "c1", + CollectionMembersManage(add=[CollectionMemberAdd(member_id="u1", access=1)], remove=["u2"]), + ) + + assert result.added == ["u1"] + assert result.removed == ["u2"] + body = json.loads(responses.calls[0].request.body) + assert body == {"add": [{"member_id": "u1", "access": 1}], "remove": ["u2"]} + + +# -- Pages (collections.pages) --------------------------------------------------- + + +@responses.activate +def test_pages_manage_adds_and_removes(collections: Collections) -> None: + responses.post(f"{BASE}/c1/pages/", json={"added": ["p1"], "removed": []}) + + result = collections.pages.manage("c1", CollectionPagesManage(add=["p1"])) + + assert result.added == ["p1"] + assert result.removed == [] + assert "/collections/c1/pages/" in responses.calls[0].request.url + + +@responses.activate +def test_pages_search_parses_a_plain_array(collections: Collections) -> None: + """Same unpaginated-array shape as `members.list` -- see the module + docstrings in `plane.api.v2.collections`.""" + responses.get( + f"{BASE}/c1/pages-search/", + json=[{"id": "p1", "name": "Runbook"}, {"id": "p2", "name": "Onboarding"}], + ) + + rows = collections.pages.search("c1") + + assert [r.name for r in rows] == ["Runbook", "Onboarding"] + + +@responses.activate +def test_pages_search_passes_fields(collections: Collections) -> None: + responses.get(f"{BASE}/c1/pages-search/", json=[]) + + collections.pages.search("c1", fields=["id", "name"]) + + assert "fields=id%2Cname" in responses.calls[0].request.url diff --git a/tests/v2/test_customer_properties_resource.py b/tests/v2/test_customer_properties_resource.py new file mode 100644 index 0000000..4cf4523 --- /dev/null +++ b/tests/v2/test_customer_properties_resource.py @@ -0,0 +1,84 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.customer_properties import CustomerProperties +from plane.config import Configuration +from plane.models.v2.customer_properties import CreateCustomerProperty, UpdateCustomerProperty + +BASE = "https://api.example.com/api/v2/workspaces/acme/customer-properties" + + +@pytest.fixture +def customer_properties(config: Configuration) -> CustomerProperties: + return CustomerProperties(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_customer_properties(customer_properties: CustomerProperties) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "display_name": "Tier", "property_type": "TEXT"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = customer_properties.list() + + assert page.total_count == 1 + assert page.data[0].property_type == "TEXT" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(customer_properties: CustomerProperties) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = customer_properties.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].display_name is None + + +def test_list_rejects_unknown_field_before_the_request( + customer_properties: CustomerProperties, +) -> None: + with pytest.raises(ValueError, match="Unknown field"): + customer_properties.list(fields=["bogus"]) + + +@responses.activate +def test_create_then_patch(customer_properties: CustomerProperties) -> None: + responses.post( + f"{BASE}/", + json={"id": "1", "display_name": "Tier", "property_type": "TEXT"}, + status=201, + ) + responses.patch(f"{BASE}/1/", json={"id": "1", "display_name": "Tier (renamed)"}) + + created = customer_properties.create( + CreateCustomerProperty(display_name="Tier", property_type="TEXT") + ) + updated = customer_properties.update( + created.id, UpdateCustomerProperty(display_name="Tier (renamed)") + ) + + assert updated.display_name == "Tier (renamed)" + + +@responses.activate +def test_delete(customer_properties: CustomerProperties) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert customer_properties.delete("1") is None + + +@responses.activate +def test_find_by_name(customer_properties: CustomerProperties) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "Tier"}], "pagination": {"style": "offset"}}, + ) + + assert customer_properties.find_by_name("Tier").id == "1" diff --git a/tests/v2/test_customers_resource.py b/tests/v2/test_customers_resource.py new file mode 100644 index 0000000..7ebdc6f --- /dev/null +++ b/tests/v2/test_customers_resource.py @@ -0,0 +1,155 @@ +"""Offline coverage for `Customers`: CRUD, `upsert`, the `requests` sub-resource, dict-shaped +`property_values`, and `manage_work_items`.""" + +from __future__ import annotations + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.customers import Customers +from plane.config import Configuration +from plane.models.v2.customers import ( + CreateCustomer, + CreateCustomerPropertyValues, + CreateCustomerRequest, + CustomerWorkItemManageRequest, + UpdateCustomer, + UpdateCustomerRequest, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/customers" + + +@pytest.fixture +def customers(config: Configuration) -> Customers: + return Customers(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_customers_is_workspace_scoped_not_project_scoped(customers: Customers) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "cu1", "name": "Acme Inc"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = customers.list() + + assert page.data[0].name == "Acme Inc" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_create_then_patch_then_delete_customer(customers: Customers) -> None: + responses.post(f"{BASE}/", json={"id": "cu1", "name": "Acme Inc"}, status=201) + responses.patch(f"{BASE}/cu1/", json={"id": "cu1", "stage": "onboarding"}) + responses.delete(f"{BASE}/cu1/", status=204) + + created = customers.create(CreateCustomer(name="Acme Inc")) + updated = customers.update(created.id, UpdateCustomer(stage="onboarding")) + + assert updated.stage == "onboarding" + assert customers.delete("cu1") is None + + +@responses.activate +def test_upsert_customer(customers: Customers) -> None: + responses.post(f"{BASE}/upsert/", json={"id": "cu1", "name": "Acme Inc"}) + + assert customers.upsert(CreateCustomer(name="Acme Inc")).id == "cu1" + + +@responses.activate +def test_find_customer_by_name(customers: Customers) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "cu1", "name": "Acme Inc"}], "pagination": {"style": "offset"}}, + ) + + assert customers.find_by_name("Acme Inc").id == "cu1" + + +@responses.activate +def test_manage_work_items_posts_add_remove_and_returns_changed_ids( + customers: Customers, +) -> None: + responses.post( + f"{BASE}/cu1/work-items/", + json={"added": ["wi-1"], "removed": ["wi-2"]}, + ) + + result = customers.manage_work_items( + "cu1", CustomerWorkItemManageRequest(add=["wi-1"], remove=["wi-2"]) + ) + + assert result.added == ["wi-1"] + assert result.removed == ["wi-2"] + body = json.loads(responses.calls[0].request.body) + assert body == {"add": ["wi-1"], "remove": ["wi-2"]} + + +# -- Nested: requests ------------------------------------------------------------- + + +@responses.activate +def test_customer_requests_crud(customers: Customers) -> None: + responses.get( + f"{BASE}/cu1/requests/", + json={"data": [{"id": "cr1", "name": "Need SSO"}], "pagination": {"style": "offset"}}, + ) + responses.post(f"{BASE}/cu1/requests/", json={"id": "cr1", "name": "Need SSO"}, status=201) + responses.get(f"{BASE}/cu1/requests/cr1/", json={"id": "cr1", "name": "Need SSO"}) + responses.patch(f"{BASE}/cu1/requests/cr1/", json={"id": "cr1", "name": "Need SSO + SCIM"}) + responses.delete(f"{BASE}/cu1/requests/cr1/", status=204) + + page = customers.requests.list("cu1") + assert page.data[0].name == "Need SSO" + + created = customers.requests.create( + "cu1", CreateCustomerRequest(name="Need SSO", work_item_ids=["wi-1"]) + ) + assert created.id == "cr1" + body = json.loads(responses.calls[1].request.body) + assert body == {"name": "Need SSO", "work_item_ids": ["wi-1"]} + + fetched = customers.requests.retrieve("cu1", "cr1") + assert fetched.id == "cr1" + + updated = customers.requests.update("cu1", "cr1", UpdateCustomerRequest(name="Need SSO + SCIM")) + assert updated.name == "Need SSO + SCIM" + + assert customers.requests.delete("cu1", "cr1") is None + + +# -- Nested: property_values (dict-shaped, no pagination) ------------------------ + + +@responses.activate +def test_property_values_list_returns_dynamic_mapping(customers: Customers) -> None: + responses.get( + f"{BASE}/cu1/property-values/", + json={"prop-1": ["Enterprise"], "prop-2": ["true"]}, + ) + + values = customers.property_values.list("cu1") + + assert values.model_dump() == {"prop-1": ["Enterprise"], "prop-2": ["true"]} + + +@responses.activate +def test_property_values_create_bulk_sets_and_returns_none(customers: Customers) -> None: + responses.post(f"{BASE}/cu1/property-values/", json=None, status=204) + + result = customers.property_values.create( + "cu1", CreateCustomerPropertyValues(values={"prop-1": ["Enterprise"]}) + ) + + assert result is None + body = json.loads(responses.calls[0].request.body) + assert body == {"values": {"prop-1": ["Enterprise"]}} diff --git a/tests/v2/test_cycles_resource.py b/tests/v2/test_cycles_resource.py new file mode 100644 index 0000000..cc11531 --- /dev/null +++ b/tests/v2/test_cycles_resource.py @@ -0,0 +1,158 @@ +"""Offline coverage for `Cycles`; includes `transfer`/`manage_work_items`, folded in from the +former separate `CycleActions` class.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.cycles import Cycles +from plane.config import Configuration +from plane.models.v2.cycles import CreateCycle, UpdateCycle + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/cycles" + + +@pytest.fixture +def cycles(config: Configuration) -> Cycles: + return Cycles(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_cycles(cycles: Cycles) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Sprint 1", "timezone": "UTC"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = cycles.list() + + assert page.total_count == 1 + assert page.data[0].timezone == "UTC" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(cycles: Cycles) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = cycles.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_list_validates_expand_against_the_golden(cycles: Cycles) -> None: + """`owned_by` is the only value `cycles_list` expands to in the golden -- a typo + must be rejected before the request is ever sent, not silently ignored.""" + with pytest.raises(ValueError, match="Unknown expand"): + cycles.list(expand=["ownedby"]) + + +@responses.activate +def test_create_then_patch(cycles: Cycles) -> None: + responses.post( + f"{BASE}/", + json={"id": "1", "name": "Sprint 1"}, + status=201, + ) + responses.patch( + f"{BASE}/1/", + json={"id": "1", "name": "Sprint 1 (renamed)"}, + ) + + created = cycles.create(CreateCycle(name="Sprint 1")) + updated = cycles.update(created.id, UpdateCycle(name="Sprint 1 (renamed)")) + + assert updated.name == "Sprint 1 (renamed)" + + +@responses.activate +def test_delete(cycles: Cycles) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert cycles.delete("1") is None + + +@responses.activate +def test_upsert_hits_the_upsert_url(cycles: Cycles) -> None: + responses.post( + f"{BASE}/upsert/", + json={"id": "1", "name": "Sprint 1"}, + ) + + result = cycles.upsert(CreateCycle(name="Sprint 1")) + + assert result.id == "1" + + +@responses.activate +def test_bulk_create_hits_the_bulk_create_url(cycles: Cycles) -> None: + responses.post( + f"{BASE}/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = cycles.bulk_create([CreateCycle(name="Sprint 1")]) + + assert result.succeeded == 1 + + +@responses.activate +def test_find_by_name(cycles: Cycles) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "Sprint 1"}], "pagination": {"style": "offset"}}, + ) + + assert cycles.find_by_name("Sprint 1").id == "1" + + +# -- Custom actions: transfer / manage_work_items ------------------------------- + + +@responses.activate +def test_transfer_sends_new_cycle_id_and_returns_it(cycles: Cycles) -> None: + responses.post(f"{BASE}/1/transfer/", json={"new_cycle_id": "2"}) + + result = cycles.transfer("1", "2") + + assert result.new_cycle_id == "2" + sent = json.loads(responses.calls[0].request.body) + assert sent == {"new_cycle_id": "2"} + + +@responses.activate +def test_manage_work_items_sends_add_and_remove(cycles: Cycles) -> None: + responses.post(f"{BASE}/1/work-items/", json={"added": ["wi-1"], "removed": ["wi-2"]}) + + result = cycles.manage_work_items("1", add=["wi-1"], remove=["wi-2"]) + + assert result.added == ["wi-1"] + assert result.removed == ["wi-2"] + sent = json.loads(responses.calls[0].request.body) + assert sent == {"add": ["wi-1"], "remove": ["wi-2"]} + + +@responses.activate +def test_manage_work_items_omits_absent_side(cycles: Cycles) -> None: + """Only `add` provided -- `remove` must not be sent as `null`.""" + responses.post(f"{BASE}/1/work-items/", json={"added": ["wi-1"], "removed": []}) + + cycles.manage_work_items("1", add=["wi-1"]) + + sent = json.loads(responses.calls[0].request.body) + assert sent == {"add": ["wi-1"]} + assert "remove" not in sent diff --git a/tests/v2/test_errors.py b/tests/v2/test_errors.py new file mode 100644 index 0000000..82c2d6e --- /dev/null +++ b/tests/v2/test_errors.py @@ -0,0 +1,43 @@ +from plane.api.v2 import PlaneAPIError + + +def test_from_payload_tolerates_malformed_errors_entries() -> None: + """`from_payload` must drop a malformed `errors` entry rather than raising, keeping well-formed + entries and the real HTTP status.""" + error = PlaneAPIError.from_payload( + 400, + { + "type": "invalid_request", + "code": "invalid_request", + "detail": "bad request", + "errors": ["oops", {"field": "name", "message": "required"}], + }, + ) + + assert error.status == 400 + assert error.code == "invalid_request" + assert error.errors is not None + assert len(error.errors) == 1 + assert error.errors[0].field == "name" + + +def test_from_payload_all_errors_entries_malformed_leaves_errors_none() -> None: + error = PlaneAPIError.from_payload( + 400, + { + "type": "invalid_request", + "code": "invalid_request", + "detail": "bad request", + "errors": ["oops", "still not a field error"], + }, + ) + + assert error.status == 400 + assert error.errors is None + + +def test_from_payload_non_dict_body_still_carries_status() -> None: + error = PlaneAPIError.from_payload(502, "Bad Gateway") + + assert error.status == 502 + assert error.code == "server_error" diff --git a/tests/v2/test_estimates_resource.py b/tests/v2/test_estimates_resource.py new file mode 100644 index 0000000..540c083 --- /dev/null +++ b/tests/v2/test_estimates_resource.py @@ -0,0 +1,267 @@ +"""Offline coverage for `Estimates`/`EstimatePoints`; project-scoped only, no workspace variant.""" + +from __future__ import annotations + +import json + +import pytest +import responses +from responses import matchers + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.estimates import EstimatePoints, Estimates +from plane.config import Configuration +from plane.models.v2.common import BulkRowSuccess +from plane.models.v2.estimates import ( + CreateEstimate, + CreateEstimatePoint, + UpdateEstimate, + UpdateEstimatePoint, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/estimates" + + +@pytest.fixture +def estimates(config: Configuration) -> Estimates: + return Estimates(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def points(config: Configuration) -> EstimatePoints: + return EstimatePoints(V2Transport(config), slug="acme", project_id="ENG") + + +# -- Estimates CRUD + upsert -------------------------------------------------------- + + +@responses.activate +def test_list_estimates(estimates: Estimates) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "T-shirt sizes", "type": "categories"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = estimates.list() + + assert page.total_count == 1 + assert page.data[0].type == "categories" + + +@responses.activate +def test_find_by_name(estimates: Estimates) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "T-shirt sizes", "type": "categories"}], + "pagination": {"style": "offset"}, + }, + match=[ + matchers.query_param_matcher( + {"name": "T-shirt sizes", "per_page": "2", "count": "False"} + ) + ], + ) + + assert estimates.find_by_name("T-shirt sizes").id == "1" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(estimates: Estimates) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = estimates.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + assert page.data[0].last_used is None + + +@responses.activate +def test_expand_points_is_encoded_and_parsed(estimates: Estimates) -> None: + responses.get( + f"{BASE}/1/", + json={ + "id": "1", + "name": "Fibonacci", + "type": "points", + "points": [{"id": "p1", "key": 0, "value": "1"}], + }, + ) + + estimate = estimates.retrieve("1", expand=["points"]) + + assert "expand=points" in responses.calls[0].request.url + assert estimate.points is not None + assert estimate.points[0].value == "1" + + +@responses.activate +def test_create_then_patch_then_delete(estimates: Estimates) -> None: + responses.post(f"{BASE}/", json={"id": "1", "name": "Sizes"}, status=201) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "T-Shirt Sizes"}) + responses.delete(f"{BASE}/1/", status=204) + + created = estimates.create(CreateEstimate(name="Sizes")) + updated = estimates.update(created.id, UpdateEstimate(name="T-Shirt Sizes")) + assert updated.name == "T-Shirt Sizes" + + assert estimates.delete(created.id) is None + + +@responses.activate +def test_upsert_reconciles_on_external_id(estimates: Estimates) -> None: + responses.post(f"{BASE}/upsert/", json={"id": "1", "name": "Imported"}) + + result = estimates.upsert( + CreateEstimate(name="Imported", external_source="jira", external_id="EST-1"), + ) + + assert result.id == "1" + + +def test_unknown_field_is_rejected_before_the_request(estimates: Estimates) -> None: + with pytest.raises(ValueError, match="nope"): + estimates.list(fields=["nope"]) + + +def test_unknown_expand_is_rejected_before_the_request(estimates: Estimates) -> None: + with pytest.raises(ValueError, match="bogus"): + estimates.list(expand=["bogus"]) + + +@responses.activate +def test_bulk_create_update_delete(estimates: Estimates) -> None: + responses.post( + f"{BASE}/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + responses.post( + f"{BASE}/bulk-update/", + json={ + "results": [{"index": 0, "result": "updated", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + responses.post( + f"{BASE}/bulk-delete/", + json={ + "results": [{"index": 0, "result": "deleted", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + created = estimates.bulk_create([CreateEstimate(name="Sizes")]) + assert isinstance(created.results[0], BulkRowSuccess) + + updated = estimates.bulk_update([{"id": "1", "name": "Sizes v2"}]) + assert updated.succeeded == 1 + + deleted = estimates.bulk_delete(["1"]) + assert deleted.succeeded == 1 + body = json.loads(responses.calls[2].request.body) + assert body == {"ids": ["1"], "all_or_none": False} + + +# -- Nested EstimatePoints ----------------------------------------------------------- + + +@responses.activate +def test_points_list_is_nested_under_the_estimate(points: EstimatePoints) -> None: + responses.get( + f"{BASE}/1/points/", + json={ + "data": [{"id": "p1", "key": 0, "value": "XS"}], + "pagination": {"style": "offset"}, + }, + ) + + page = points.list("1") + + assert page.data[0].value == "XS" + assert responses.calls[0].request.url.startswith(f"{BASE}/1/points/") + + +@responses.activate +def test_points_create_retrieve_update_delete(points: EstimatePoints) -> None: + responses.post(f"{BASE}/1/points/", json={"id": "p1", "value": "XS", "key": 0}, status=201) + responses.get(f"{BASE}/1/points/p1/", json={"id": "p1", "value": "XS", "key": 0}) + responses.patch(f"{BASE}/1/points/p1/", json={"id": "p1", "value": "Small", "key": 0}) + responses.delete(f"{BASE}/1/points/p1/", status=204) + + created = points.create("1", CreateEstimatePoint(value="XS", key=0)) + assert created.id == "p1" + + fetched = points.retrieve("1", created.id) + assert fetched.value == "XS" + + updated = points.update("1", created.id, UpdateEstimatePoint(value="Small")) + assert updated.value == "Small" + + assert points.delete("1", created.id) is None + + +@responses.activate +def test_points_upsert(points: EstimatePoints) -> None: + responses.post(f"{BASE}/1/points/upsert/", json={"id": "p1", "value": "XS", "key": 0}) + + result = points.upsert( + "1", CreateEstimatePoint(value="XS", external_source="x", external_id="1") + ) + + assert result.id == "p1" + + +@responses.activate +def test_points_bulk_create_update_delete(points: EstimatePoints) -> None: + responses.post( + f"{BASE}/1/points/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "p1"}], + "succeeded": 1, + "failed": 0, + }, + ) + responses.post( + f"{BASE}/1/points/bulk-update/", + json={ + "results": [{"index": 0, "result": "updated", "id": "p1"}], + "succeeded": 1, + "failed": 0, + }, + ) + responses.post( + f"{BASE}/1/points/bulk-delete/", + json={ + "results": [{"index": 0, "result": "deleted", "id": "p1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + created = points.bulk_create("1", [CreateEstimatePoint(value="XS")]) + assert created.succeeded == 1 + + updated = points.bulk_update("1", [{"id": "p1", "value": "Small"}]) + assert updated.succeeded == 1 + + deleted = points.bulk_delete("1", ["p1"]) + assert deleted.succeeded == 1 + + +def test_points_batch_cap_is_enforced_client_side(points: EstimatePoints) -> None: + with pytest.raises(ValueError, match="At most 50"): + points.bulk_delete("1", [str(n) for n in range(51)]) diff --git a/tests/v2/test_features_resource.py b/tests/v2/test_features_resource.py new file mode 100644 index 0000000..296667e --- /dev/null +++ b/tests/v2/test_features_resource.py @@ -0,0 +1,64 @@ +"""Offline coverage for the two feature-toggle singletons; neither has an `id` in the URL.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.features import ProjectFeatures, WorkspaceFeatures +from plane.config import Configuration +from plane.models.v2.features import UpdateProjectFeature, UpdateWorkspaceFeature + +WORKSPACE_URL = "https://api.example.com/api/v2/workspaces/acme/features/" +PROJECT_URL = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/features/" + + +@pytest.fixture +def workspace_features(config: Configuration) -> WorkspaceFeatures: + return WorkspaceFeatures(V2Transport(config), slug="acme") + + +@pytest.fixture +def project_features(config: Configuration) -> ProjectFeatures: + return ProjectFeatures(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_workspace_features_retrieve_hits_the_bare_collection_url( + workspace_features: WorkspaceFeatures, +) -> None: + responses.get(WORKSPACE_URL, json={"id": "1", "is_wiki_enabled": True}) + + feature = workspace_features.retrieve() + + assert responses.calls[0].request.url == WORKSPACE_URL + assert feature.is_wiki_enabled is True + + +@responses.activate +def test_workspace_features_update(workspace_features: WorkspaceFeatures) -> None: + responses.patch(WORKSPACE_URL, json={"id": "1", "is_wiki_enabled": False}) + + feature = workspace_features.update(UpdateWorkspaceFeature(is_wiki_enabled=False)) + + assert feature.is_wiki_enabled is False + + +@responses.activate +def test_project_features_retrieve_has_no_id(project_features: ProjectFeatures) -> None: + """`ProjectFeature` carries no `id` field at all in the golden.""" + responses.get(PROJECT_URL, json={"is_epic_enabled": True}) + + feature = project_features.retrieve() + + assert responses.calls[0].request.url == PROJECT_URL + assert feature.is_epic_enabled is True + assert not hasattr(feature, "id") + + +@responses.activate +def test_project_features_update(project_features: ProjectFeatures) -> None: + responses.patch(PROJECT_URL, json={"is_epic_enabled": False}) + + feature = project_features.update(UpdateProjectFeature(is_epic_enabled=False)) + + assert feature.is_epic_enabled is False diff --git a/tests/v2/test_find_one.py b/tests/v2/test_find_one.py new file mode 100644 index 0000000..8a49ff8 --- /dev/null +++ b/tests/v2/test_find_one.py @@ -0,0 +1,79 @@ +import pytest +import responses +from pydantic import BaseModel, ConfigDict +from responses import matchers + +from plane.api.v2 import MultipleMatchesFound, NoMatchFound +from plane.api.v2._kernel.resource import V2Resource +from plane.api.v2._kernel.transport import V2Transport +from plane.config import Configuration + + +class Row(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + + +class WriteRow(BaseModel): + name: str + + +class PatchRow(BaseModel): + name: str | None = None + + +class Rows(V2Resource[Row, WriteRow, PatchRow]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = Row + operations = { + "list": "states_list", + "retrieve": "states_retrieve", + "create": "states_create", + "update": "states_partial_update", + "upsert": "states_upsert", + } + + +@pytest.fixture +def rows(config: Configuration) -> Rows: + return Rows(V2Transport(config)) + + +@responses.activate +def test_find_one_returns_the_single_match(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [{"id": "1", "name": "Todo"}], "pagination": {"style": "offset"}}, + match=[matchers.query_param_matcher({"name": "Todo", "per_page": "2", "count": "False"})], + ) + + assert rows._find_one(filters={"name": "Todo"}, slug="acme", project_id="ENG").id == "1" + + +@responses.activate +def test_find_one_raises_when_nothing_matches(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [], "pagination": {"style": "offset"}}, + match=[matchers.query_param_matcher({"name": "Nope", "per_page": "2", "count": "False"})], + ) + + with pytest.raises(NoMatchFound, match="name='Nope'"): + rows._find_one(filters={"name": "Nope"}, slug="acme", project_id="ENG") + + +@responses.activate +def test_find_one_raises_when_several_match(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={ + "data": [{"id": "1", "name": "Todo"}, {"id": "2", "name": "todo"}], + "pagination": {"style": "offset"}, + }, + match=[matchers.query_param_matcher({"name": "Todo", "per_page": "2", "count": "False"})], + ) + + with pytest.raises(MultipleMatchesFound, match="Multiple rows matched"): + rows._find_one(filters={"name": "Todo"}, slug="acme", project_id="ENG") diff --git a/tests/v2/test_generated_constants.py b/tests/v2/test_generated_constants.py new file mode 100644 index 0000000..854c4c5 --- /dev/null +++ b/tests/v2/test_generated_constants.py @@ -0,0 +1,26 @@ +from plane.api.v2._generated.constants import BULK_MAX_ITEMS, ERROR_CODES, FIELDS, ORDER_BY + + +def test_state_list_fields_match_the_api() -> None: + assert "name" in FIELDS["states_list"] + assert "group" in FIELDS["states_list"] + assert "all" in FIELDS["states_list"] + + +def test_label_list_fields_include_parent_id() -> None: + assert "parent_id" in FIELDS["labels_list"] + + +def test_order_by_is_bounded() -> None: + assert "-created_at" in ORDER_BY["states_list"] + assert "name" not in ORDER_BY["states_list"] + + +def test_batch_cap() -> None: + assert BULK_MAX_ITEMS == 50 + + +def test_error_codes_are_populated() -> None: + assert len(ERROR_CODES) > 0 + assert "not_found" in ERROR_CODES + assert "invalid_request" in ERROR_CODES diff --git a/tests/v2/test_group_sync_resource.py b/tests/v2/test_group_sync_resource.py new file mode 100644 index 0000000..841128f --- /dev/null +++ b/tests/v2/test_group_sync_resource.py @@ -0,0 +1,146 @@ +"""Offline coverage for `GroupSync`: the pk-less `config` singleton plus +`project_mappings`/`workspace_mappings`.""" + +from __future__ import annotations + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.group_sync import GroupSync +from plane.config import Configuration +from plane.models.v2.group_sync import ( + CreateGroupMapping, + CreateWorkspaceGroupMapping, + UpdateGroupMapping, + UpdateGroupSyncConfig, +) + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def group_sync(config: Configuration) -> GroupSync: + return GroupSync(V2Transport(config), slug="acme") + + +def test_group_sync_forwards_its_scope_to_every_sub_resource(group_sync: GroupSync) -> None: + assert group_sync.config._scope == {"slug": "acme"} + assert group_sync.project_mappings._scope == {"slug": "acme"} + assert group_sync.workspace_mappings._scope == {"slug": "acme"} + + +@responses.activate +def test_group_sync_config_get(group_sync: GroupSync) -> None: + responses.get( + f"{BASE}/workspaces/acme/group-sync/config/", + json={"id": "1", "is_enabled": True, "group_attribute_key": "groups"}, + ) + + config = group_sync.config.get() + + assert config.is_enabled is True + assert config.group_attribute_key == "groups" + + +@responses.activate +def test_group_sync_config_update_hits_config_url_not_a_detail_url(group_sync: GroupSync) -> None: + """The config resource has no `{pk}` -- update must PATCH the collection URL + itself, not append a row id the way a normal resource's `_update` would.""" + responses.patch( + f"{BASE}/workspaces/acme/group-sync/config/", + json={"id": "1", "is_enabled": False}, + ) + + updated = group_sync.config.update(UpdateGroupSyncConfig(is_enabled=False)) + + assert updated.is_enabled is False + assert responses.calls[0].request.url == f"{BASE}/workspaces/acme/group-sync/config/" + + +@responses.activate +def test_group_sync_project_mappings_crud(group_sync: GroupSync) -> None: + responses.get( + f"{BASE}/workspaces/acme/group-sync/project-mappings/", + json={ + "data": [{"id": "1", "idp_group_name": "eng-team", "role_slug": "member"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/workspaces/acme/group-sync/project-mappings/", + json={"id": "2", "idp_group_name": "qa-team", "role_slug": "member"}, + status=201, + ) + responses.patch( + f"{BASE}/workspaces/acme/group-sync/project-mappings/2/", + json={"id": "2", "role_slug": "admin"}, + ) + responses.delete(f"{BASE}/workspaces/acme/group-sync/project-mappings/2/", status=204) + + page = group_sync.project_mappings.list() + assert page.data[0].idp_group_name == "eng-team" + + created = group_sync.project_mappings.create( + CreateGroupMapping(idp_group_name="qa-team", role_slug="member") + ) + assert created.id == "2" + + updated = group_sync.project_mappings.update(created.id, UpdateGroupMapping(role_slug="admin")) + assert updated.role_slug == "admin" + + assert group_sync.project_mappings.delete(created.id) is None + + +@responses.activate +def test_group_sync_project_mapping_all_projects_write(group_sync: GroupSync) -> None: + responses.post( + f"{BASE}/workspaces/acme/group-sync/project-mappings/", + json={"id": "3", "all_projects": True}, + status=201, + ) + + group_sync.project_mappings.create( + CreateGroupMapping(idp_group_name="everyone", role_slug="member", all_projects=True), + ) + + body = json.loads(responses.calls[0].request.body) + assert body["all_projects"] is True + assert "project_id" not in body + + +@responses.activate +def test_group_sync_workspace_mappings_crud(group_sync: GroupSync) -> None: + responses.get( + f"{BASE}/workspaces/acme/group-sync/workspace-mappings/", + json={ + "data": [{"id": "1", "idp_group_name": "eng-team", "role_slug": "member"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/workspaces/acme/group-sync/workspace-mappings/", + json={"id": "2", "idp_group_name": "qa-team", "role_slug": "member"}, + status=201, + ) + responses.delete(f"{BASE}/workspaces/acme/group-sync/workspace-mappings/2/", status=204) + + page = group_sync.workspace_mappings.list() + assert page.data[0].idp_group_name == "eng-team" + + created = group_sync.workspace_mappings.create( + CreateWorkspaceGroupMapping(idp_group_name="qa-team", role_slug="member") + ) + assert created.id == "2" + + assert group_sync.workspace_mappings.delete(created.id) is None + + +def test_workspace_mappings_have_no_project_id_field() -> None: + """Workspace mappings map an IdP group straight to a workspace role -- there is + no `project_id`/`all_projects` field to carry, unlike `CreateGroupMapping`.""" + assert not hasattr(CreateWorkspaceGroupMapping, "model_fields") or ( + "project_id" not in CreateWorkspaceGroupMapping.model_fields + ) diff --git a/tests/v2/test_initiatives_resource.py b/tests/v2/test_initiatives_resource.py new file mode 100644 index 0000000..4565998 --- /dev/null +++ b/tests/v2/test_initiatives_resource.py @@ -0,0 +1,145 @@ +"""Offline coverage for `Initiatives`: CRUD, `?expand=lead`, manage verbs, and the workspace-level +`labels` sibling collection.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.initiatives import Initiatives +from plane.config import Configuration +from plane.models.v2.initiatives import ( + CreateInitiative, + CreateInitiativeLabel, + InitiativeChildManageRequest, + UpdateInitiative, + UpdateInitiativeLabel, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/initiatives" + + +@pytest.fixture +def initiatives(config: Configuration) -> Initiatives: + return Initiatives(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_initiatives_is_workspace_scoped_not_project_scoped( + initiatives: Initiatives, +) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "in1", "name": "Q3 push", "state": "ACTIVE"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = initiatives.list() + + assert page.data[0].state == "ACTIVE" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_list_passes_expand(initiatives: Initiatives) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + initiatives.list(expand=["lead"]) + + assert "expand=lead" in responses.calls[0].request.url + + +def test_list_rejects_unknown_expand_before_the_request(initiatives: Initiatives) -> None: + with pytest.raises(ValueError, match="bogus"): + initiatives.list(expand=["bogus"]) + + +@responses.activate +def test_create_then_patch_then_delete_initiative(initiatives: Initiatives) -> None: + responses.post(f"{BASE}/", json={"id": "in1", "name": "Q3 push"}, status=201) + responses.patch(f"{BASE}/in1/", json={"id": "in1", "state": "COMPLETED"}) + responses.delete(f"{BASE}/in1/", status=204) + + created = initiatives.create(CreateInitiative(name="Q3 push")) + updated = initiatives.update(created.id, UpdateInitiative(state="COMPLETED")) + + assert updated.state == "COMPLETED" + assert initiatives.delete("in1") is None + + +@responses.activate +def test_find_initiative_by_name(initiatives: Initiatives) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "in1", "name": "Q3 push"}], "pagination": {"style": "offset"}}, + ) + + assert initiatives.find_by_name("Q3 push").id == "in1" + + +@responses.activate +def test_manage_labels_projects_work_items(initiatives: Initiatives) -> None: + responses.post(f"{BASE}/in1/labels/", json={"added": ["lbl-1"], "removed": []}) + responses.post(f"{BASE}/in1/projects/", json={"added": ["proj-1"], "removed": []}) + responses.post(f"{BASE}/in1/work-items/", json={"added": ["wi-1"], "removed": ["wi-2"]}) + + labels_result = initiatives.manage_labels("in1", InitiativeChildManageRequest(add=["lbl-1"])) + assert labels_result.added == ["lbl-1"] + + projects_result = initiatives.manage_projects( + "in1", InitiativeChildManageRequest(add=["proj-1"]) + ) + assert projects_result.added == ["proj-1"] + + work_items_result = initiatives.manage_work_items( + "in1", InitiativeChildManageRequest(add=["wi-1"], remove=["wi-2"]) + ) + assert work_items_result.added == ["wi-1"] + assert work_items_result.removed == ["wi-2"] + + # Each verb action hits its own sub-path, not a shared one. + assert responses.calls[0].request.url.endswith("/in1/labels/") + assert responses.calls[1].request.url.endswith("/in1/projects/") + assert responses.calls[2].request.url.endswith("/in1/work-items/") + + +# -- Sibling collection: labels (workspace-level, not nested under an initiative) -- + + +@responses.activate +def test_initiative_labels_crud_has_no_initiative_id_in_its_path( + initiatives: Initiatives, +) -> None: + responses.get( + f"{BASE}/labels/", + json={"data": [{"id": "lbl-1", "name": "priority"}], "pagination": {"style": "offset"}}, + ) + responses.post(f"{BASE}/labels/", json={"id": "lbl-1", "name": "priority"}, status=201) + responses.patch(f"{BASE}/labels/lbl-1/", json={"id": "lbl-1", "color": "#f00"}) + responses.delete(f"{BASE}/labels/lbl-1/", status=204) + + page = initiatives.labels.list() + assert page.data[0].name == "priority" + assert responses.calls[0].request.url == f"{BASE}/labels/" + + created = initiatives.labels.create(CreateInitiativeLabel(name="priority")) + assert created.id == "lbl-1" + + updated = initiatives.labels.update("lbl-1", UpdateInitiativeLabel(color="#f00")) + assert updated.color == "#f00" + + assert initiatives.labels.delete("lbl-1") is None + + +@responses.activate +def test_initiative_labels_find_by_name(initiatives: Initiatives) -> None: + responses.get( + f"{BASE}/labels/", + json={"data": [{"id": "lbl-1", "name": "priority"}], "pagination": {"style": "offset"}}, + ) + + assert initiatives.labels.find_by_name("priority").id == "lbl-1" diff --git a/tests/v2/test_intakes_resource.py b/tests/v2/test_intakes_resource.py new file mode 100644 index 0000000..1c60354 --- /dev/null +++ b/tests/v2/test_intakes_resource.py @@ -0,0 +1,70 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.intakes import Intakes +from plane.config import Configuration +from plane.models.v2.intakes import CreateIntakeWorkItem, UpdateIntakeWorkItem + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/intake-issues" + + +@pytest.fixture +def intakes(config: Configuration) -> Intakes: + return Intakes(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_intake_work_items(intakes: Intakes) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Bug report", "status": -2}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = intakes.list() + + assert page.total_count == 1 + assert page.data[0].status == -2 + + +@responses.activate +def test_list_passes_filters(intakes: Intakes) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + intakes.list(status=1) + + assert "status=1" in responses.calls[0].request.url + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(intakes: Intakes) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = intakes.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_create_then_patch_status(intakes: Intakes) -> None: + """PATCH folds the triage status transition into the same call as any other + field -- no separate status endpoint in v2.""" + responses.post(f"{BASE}/", json={"id": "1", "name": "Bug report", "status": -2}, status=201) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "Bug report", "status": 1}) + + created = intakes.create(CreateIntakeWorkItem(name="Bug report")) + updated = intakes.update(created.id, UpdateIntakeWorkItem(status=1)) + + assert updated.status == 1 + + +@responses.activate +def test_delete(intakes: Intakes) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert intakes.delete("1") is None diff --git a/tests/v2/test_invitations_resource.py b/tests/v2/test_invitations_resource.py new file mode 100644 index 0000000..df20052 --- /dev/null +++ b/tests/v2/test_invitations_resource.py @@ -0,0 +1,88 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.invitations import Invitations +from plane.config import Configuration +from plane.models.v2.invitations import BulkCreateWorkspaceInvites, CreateWorkspaceInvite + +BASE = "https://api.example.com/api/v2/workspaces/acme/invitations" + + +@pytest.fixture +def invitations(config: Configuration) -> Invitations: + return Invitations(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_invitations(invitations: Invitations) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "email": "a@example.com", "accepted": False}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = invitations.list() + + assert page.total_count == 1 + assert page.data[0].accepted is False + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(invitations: Invitations) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = invitations.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].email is None + + +@responses.activate +def test_create_and_retrieve(invitations: Invitations) -> None: + responses.post(f"{BASE}/", json={"id": "1", "email": "a@example.com"}, status=201) + responses.get(f"{BASE}/1/", json={"id": "1", "email": "a@example.com", "accepted": False}) + + created = invitations.create(CreateWorkspaceInvite(email="a@example.com")) + fetched = invitations.retrieve(created.id) + + assert fetched.email == "a@example.com" + + +@responses.activate +def test_delete(invitations: Invitations) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert invitations.delete("1") is None + + +@responses.activate +def test_bulk_parses_the_live_list_response(invitations: Invitations) -> None: + """The live view returns `many=True` on this 201 (a real list), not the golden's single + `WorkspaceInvite` -- asserts the resource handles the list shape.""" + responses.post( + f"{BASE}/bulk/", + json=[ + {"id": "1", "email": "a@example.com"}, + {"id": "2", "email": "b@example.com"}, + ], + status=201, + ) + + result = invitations.bulk(BulkCreateWorkspaceInvites(emails=["a@example.com", "b@example.com"])) + + assert [row.id for row in result] == ["1", "2"] + + +@responses.activate +def test_bulk_also_tolerates_a_single_object_payload(invitations: Invitations) -> None: + """If the server ever matches the golden's documented single-object shape + instead, the SDK should not crash -- it normalizes to a one-item list.""" + responses.post(f"{BASE}/bulk/", json={"id": "1", "email": "a@example.com"}, status=201) + + result = invitations.bulk(BulkCreateWorkspaceInvites(emails=["a@example.com"])) + + assert [row.id for row in result] == ["1"] diff --git a/tests/v2/test_labels_resource.py b/tests/v2/test_labels_resource.py new file mode 100644 index 0000000..765d7d6 --- /dev/null +++ b/tests/v2/test_labels_resource.py @@ -0,0 +1,108 @@ +"""Offline coverage for `Labels`, mirroring `test_states_resource.py`'s bound-scope signature +convention.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.labels import Labels +from plane.config import Configuration +from plane.models.v2.labels import CreateLabel, UpdateLabel + + +@pytest.fixture +def labels(config: Configuration) -> Labels: + return Labels(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_labels(labels: Labels) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/", + json={ + "data": [{"id": "1", "name": "bug", "color": "#f00"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = labels.list() + + assert page.total_count == 1 + assert page.data[0].color == "#f00" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(labels: Labels) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = labels.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_create_then_patch(labels: Labels) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/", + json={"id": "1", "name": "bug", "color": "#f00"}, + status=201, + ) + responses.patch( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/1/", + json={"id": "1", "name": "bugfix"}, + ) + + created = labels.create(CreateLabel(name="bug", color="#f00")) + updated = labels.update(created.id, UpdateLabel(name="bugfix")) + + assert updated.name == "bugfix" + + +@responses.activate +def test_delete_returns_none(labels: Labels) -> None: + responses.delete( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/1/", status=204 + ) + + assert labels.delete("1") is None + + +@responses.activate +def test_upsert(labels: Labels) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/upsert/", + json={"id": "1", "name": "bug"}, + ) + + assert labels.upsert(CreateLabel(name="bug")).id == "1" + + +@responses.activate +def test_find_by_name(labels: Labels) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/", + json={"data": [{"id": "1", "name": "bug"}], "pagination": {"style": "offset"}}, + ) + + assert labels.find_by_name("bug").id == "1" + + +@responses.activate +def test_bulk_create_posts_items_envelope(labels: Labels) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = labels.bulk_create([CreateLabel(name="bug")]) + + assert result.succeeded == 1 diff --git a/tests/v2/test_live_smoke.py b/tests/v2/test_live_smoke.py new file mode 100644 index 0000000..4d3899e --- /dev/null +++ b/tests/v2/test_live_smoke.py @@ -0,0 +1,51 @@ +"""Live smoke test for the v2 surface; skips unless +PLANE_BASE_URL/PLANE_API_KEY/WORKSPACE_SLUG/V2_PROJECT are set.""" + +import os + +import pytest + +from plane.client import PlaneClient + + +@pytest.fixture(scope="module") +def live_client() -> PlaneClient: + base_url = os.getenv("PLANE_BASE_URL") + api_key = os.getenv("PLANE_API_KEY") + if not base_url or not api_key: + pytest.skip("PLANE_BASE_URL and PLANE_API_KEY must be set for live v2 tests") + return PlaneClient(base_url=base_url, api_key=api_key) + + +@pytest.fixture(scope="module") +def workspace_slug() -> str: + slug = os.getenv("WORKSPACE_SLUG") + if not slug: + pytest.skip("WORKSPACE_SLUG not set") + return slug + + +@pytest.fixture(scope="module") +def project_key() -> str: + key = os.getenv("V2_PROJECT") + if not key: + pytest.skip("V2_PROJECT (project key or id) not set") + return key + + +def test_states_round_trip(live_client: PlaneClient, workspace_slug: str, project_key: str) -> None: + scope = live_client.v2.workspace(workspace_slug).project(project_key) + page = scope.states.list(fields=["id", "name"]) + assert page.data, "expected at least one state in the project" + + first = page.data[0] + assert first.name is not None + found = scope.states.find_by_name(first.name) + assert found.id == first.id + + +def test_chained_labels_list( + live_client: PlaneClient, workspace_slug: str, project_key: str +) -> None: + scope = live_client.v2.workspace(workspace_slug).project(project_key) + assert scope.labels.list(fields=["id", "name"]) is not None diff --git a/tests/v2/test_locators.py b/tests/v2/test_locators.py new file mode 100644 index 0000000..0425ab3 --- /dev/null +++ b/tests/v2/test_locators.py @@ -0,0 +1,148 @@ +"""`Workspace`/`Project` locator tests; a resource is authored bound (not wrapped), so only +construction and bound-method behavior need covering.""" + +from __future__ import annotations + +from typing import Any + +import pytest +import responses + +from plane.api.v2 import V2Namespace +from plane.api.v2.project import Project +from plane.api.v2.workspace import Workspace +from plane.config import Configuration + + +class _RequestSpy: + """Replaces `V2Transport.request` to record calls without making any.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def __call__(self, method: str, path: str, **kwargs: Any) -> None: + self.calls.append((method, path)) + raise AssertionError(f"unexpected HTTP call: {method} {path}") + + +def test_workspace_locator_makes_no_http_calls(config: Configuration) -> None: + v2 = V2Namespace(config) + spy = _RequestSpy() + v2.transport.request = spy # type: ignore[method-assign] + + workspace = v2.workspace("acme") + + assert isinstance(workspace, Workspace) + assert spy.calls == [] + + +def test_project_locator_makes_no_http_calls(config: Configuration) -> None: + v2 = V2Namespace(config) + spy = _RequestSpy() + v2.transport.request = spy # type: ignore[method-assign] + + project = v2.workspace("acme").project("ENG") + + assert isinstance(project, Project) + assert spy.calls == [] + + +def test_workspace_wiki_locator_makes_no_http_calls(config: Configuration) -> None: + v2 = V2Namespace(config) + spy = _RequestSpy() + v2.transport.request = spy # type: ignore[method-assign] + + wiki = v2.workspace("acme").wiki + + assert wiki.pages is not None + assert wiki.collections is not None + assert spy.calls == [] + + +def test_workspace_holds_transport_and_slug(config: Configuration) -> None: + v2 = V2Namespace(config) + workspace = v2.workspace("acme") + assert workspace.transport is v2.transport + assert workspace.slug == "acme" + + +def test_project_holds_slug_and_project_id(config: Configuration) -> None: + v2 = V2Namespace(config) + project = v2.workspace("acme").project("ENG") + assert project.slug == "acme" + assert project.project_id == "ENG" + + +def test_project_exposes_states_labels_and_work_items(config: Configuration) -> None: + """`work_items` is now a plain attribute on `Project` like every resource; only its methods + still take the old flat positional arguments.""" + project = V2Namespace(config).workspace("acme").project("ENG") + + assert hasattr(project, "states") + assert hasattr(project, "labels") + assert hasattr(project, "work_items") + + +@responses.activate +def test_chained_states_hits_the_expected_url(config: Configuration) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = V2Namespace(config).workspace("acme").project("ENG").states.list() + + assert page.data[0].id == "1" + + +@responses.activate +def test_chained_labels_pass_filters(config: Configuration) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/labels/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + V2Namespace(config).workspace("acme").project("ENG").labels.list(name="bug") + + assert "name=bug" in responses.calls[0].request.url + + +@responses.activate +def test_scope_builders_make_no_requests_with_responses_active(config: Configuration) -> None: + # No responses registered: constructing a scope must not touch the network. With + # @responses.activate active, any real outbound request errors immediately instead + # of hanging until the timeout, so a regression here fails fast. + V2Namespace(config).workspace("acme").project("ENG") + + +def test_project_accepts_a_project_key_or_a_project_id(config: Configuration) -> None: + """`project` accepts either form -- there is no server round trip at this + point to resolve one to the other, so both are just stored verbatim.""" + workspace = V2Namespace(config).workspace("acme") + + by_key = workspace.project("ENG") + by_id = workspace.project("11111111-1111-1111-1111-111111111111") + + assert by_key.project_id == "ENG" + assert by_id.project_id == "11111111-1111-1111-1111-111111111111" + + +def test_v2_namespace_no_longer_exposes_flat_resources(config: Configuration) -> None: + """The flat form is removed entirely -- every project/workspace-scoped + resource is reached only through `workspace(slug)`/`.project(project)`.""" + v2 = V2Namespace(config) + + assert not hasattr(v2, "states") + assert not hasattr(v2, "labels") + assert not hasattr(v2, "work_items") + assert not hasattr(v2, "resources") + + +@pytest.mark.parametrize( + "attr", ["transport", "users", "user_assets"] +) +def test_v2_namespace_keeps_only_the_non_workspace_scoped_attributes( + config: Configuration, attr: str +) -> None: + v2 = V2Namespace(config) + assert hasattr(v2, attr) diff --git a/tests/v2/test_members_resource.py b/tests/v2/test_members_resource.py new file mode 100644 index 0000000..0629341 --- /dev/null +++ b/tests/v2/test_members_resource.py @@ -0,0 +1,139 @@ +"""Offline coverage for `ProjectMembers`/`WorkspaceMembers`; the latter is list-only plus `remove`, +keyed on email not a row id.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.members import ProjectMembers, WorkspaceMembers +from plane.config import Configuration +from plane.models.v2.members import CreateProjectMember, UpdateProjectMember, WorkspaceMemberRemove + +PROJECT_BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/members" +WORKSPACE_BASE = "https://api.example.com/api/v2/workspaces/acme/members" + + +@pytest.fixture +def project_members(config: Configuration) -> ProjectMembers: + return ProjectMembers(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_members(config: Configuration) -> WorkspaceMembers: + return WorkspaceMembers(V2Transport(config), slug="acme") + + +# -- ProjectMembers ---------------------------------------------------------------- + + +@responses.activate +def test_project_members_list(project_members: ProjectMembers) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={ + "data": [{"id": "m1", "member_id": "u1", "role": "contributor"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = project_members.list() + + assert page.total_count == 1 + assert page.data[0].role == "contributor" + + +@responses.activate +def test_project_members_list_passes_expand(project_members: ProjectMembers) -> None: + responses.get(f"{PROJECT_BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + project_members.list(expand=["member"]) + + assert "expand=member" in responses.calls[0].request.url + + +def test_project_members_list_rejects_unknown_expand(project_members: ProjectMembers) -> None: + with pytest.raises(ValueError, match="bogus"): + project_members.list(expand=["bogus"]) + + +@responses.activate +def test_project_members_retrieve(project_members: ProjectMembers) -> None: + responses.get(f"{PROJECT_BASE}/m1/", json={"id": "m1", "member_id": "u1"}) + + row = project_members.retrieve("m1") + + assert row.id == "m1" + + +@responses.activate +def test_project_members_create(project_members: ProjectMembers) -> None: + responses.post(f"{PROJECT_BASE}/", json={"id": "m1", "member_id": "u1"}, status=201) + + created = project_members.create(CreateProjectMember(member_id="u1", role="contributor")) + + assert created.id == "m1" + body = json.loads(responses.calls[0].request.body) + assert body == {"member_id": "u1", "role": "contributor"} + + +@responses.activate +def test_project_members_update_changes_role(project_members: ProjectMembers) -> None: + responses.patch(f"{PROJECT_BASE}/m1/", json={"id": "m1", "role": "admin"}) + + updated = project_members.update("m1", UpdateProjectMember(role="admin")) + + assert updated.role == "admin" + + +@responses.activate +def test_project_members_delete_returns_none(project_members: ProjectMembers) -> None: + responses.delete(f"{PROJECT_BASE}/m1/", status=204) + + assert project_members.delete("m1") is None + + +# -- WorkspaceMembers (list-only + remove) ---------------------------------------- + + +@responses.activate +def test_workspace_members_list(workspace_members: WorkspaceMembers) -> None: + responses.get( + f"{WORKSPACE_BASE}/", + json={ + "data": [{"id": "m1", "member_id": "u1", "role": "member"}], + "pagination": {"style": "offset"}, + }, + ) + + page = workspace_members.list() + + assert page.data[0].role == "member" + + +@responses.activate +def test_workspace_members_has_no_detail_crud(workspace_members: WorkspaceMembers) -> None: + """`WorkspaceMembers` deliberately exposes no `retrieve`/`create`/`update`/ + `delete` -- the golden has no such operations for this resource.""" + assert not hasattr(workspace_members, "retrieve") + assert not hasattr(workspace_members, "create") + assert not hasattr(workspace_members, "update") + assert not hasattr(workspace_members, "delete") + + +@responses.activate +def test_remove_posts_to_remove_sub_path_keyed_by_email( + workspace_members: WorkspaceMembers, +) -> None: + responses.post(f"{WORKSPACE_BASE}/remove/", status=204) + + result = workspace_members.remove( + WorkspaceMemberRemove(email="gone@example.com", remove_seat=True) + ) + + assert result is None + body = json.loads(responses.calls[0].request.body) + assert body == {"email": "gone@example.com", "remove_seat": True} + assert responses.calls[0].request.url == f"{WORKSPACE_BASE}/remove/" diff --git a/tests/v2/test_milestones_resource.py b/tests/v2/test_milestones_resource.py new file mode 100644 index 0000000..bac2af2 --- /dev/null +++ b/tests/v2/test_milestones_resource.py @@ -0,0 +1,148 @@ +"""Offline coverage for `Milestones`; includes `manage_work_items`, folded in from the former +separate `MilestoneWorkItems` class.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.milestones import Milestones +from plane.config import Configuration +from plane.models.v2.milestone_work_items import MilestoneWorkItemManageRequest +from plane.models.v2.milestones import CreateMilestone, UpdateMilestone + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/milestones" + + +@pytest.fixture +def milestones(config: Configuration) -> Milestones: + return Milestones(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_milestones(milestones: Milestones) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "title": "Beta launch"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = milestones.list() + + assert page.total_count == 1 + assert page.data[0].title == "Beta launch" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(milestones: Milestones) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = milestones.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].title is None + + +@responses.activate +def test_list_validates_fields_against_the_golden(milestones: Milestones) -> None: + """`title` is a real field on `milestones_list` in the golden; `name` is not -- + milestones use `title`, not `name`, for the write/read field itself. A bad field + name must be rejected client-side before the request is ever sent.""" + with pytest.raises(ValueError, match="Unknown field"): + milestones.list(fields=["name"]) + + +@responses.activate +def test_create_then_patch(milestones: Milestones) -> None: + responses.post( + f"{BASE}/", + json={"id": "1", "title": "Beta launch"}, + status=201, + ) + responses.patch( + f"{BASE}/1/", + json={"id": "1", "title": "GA launch"}, + ) + + created = milestones.create(CreateMilestone(title="Beta launch")) + updated = milestones.update(created.id, UpdateMilestone(title="GA launch")) + + assert updated.title == "GA launch" + + +@responses.activate +def test_delete(milestones: Milestones) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert milestones.delete("1") is None + + +@responses.activate +def test_upsert_hits_the_upsert_url(milestones: Milestones) -> None: + responses.post( + f"{BASE}/upsert/", + json={"id": "1", "title": "Beta launch"}, + ) + + result = milestones.upsert(CreateMilestone(title="Beta launch")) + + assert result.id == "1" + + +@responses.activate +def test_bulk_create_hits_the_bulk_create_url(milestones: Milestones) -> None: + responses.post( + f"{BASE}/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = milestones.bulk_create([CreateMilestone(title="Beta launch")]) + + assert result.succeeded == 1 + + +@responses.activate +def test_find_by_name_filters_on_the_name_query_param(milestones: Milestones) -> None: + """The golden aliases the `?name=` filter to the `title` column server-side, so + `find_by_name` keeps the same `name` parameter every other resource uses even + though the underlying field is `title`.""" + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "title": "Beta launch"}], "pagination": {"style": "offset"}}, + ) + + assert milestones.find_by_name("Beta launch").id == "1" + + request = responses.calls[0].request + assert "name=Beta" in (request.url or "") + + +# -- Custom action: manage_work_items -------------------------------------------- + + +@responses.activate +def test_manage_work_items_add_and_remove(milestones: Milestones) -> None: + responses.post( + f"{BASE}/m1/work-items/", + json={"added": ["wi-1"], "removed": ["wi-2"]}, + ) + + result = milestones.manage_work_items( + "m1", MilestoneWorkItemManageRequest(add=["wi-1"], remove=["wi-2"]) + ) + + assert result.added == ["wi-1"] + assert result.removed == ["wi-2"] + sent_body = json.loads(responses.calls[0].request.body) + assert sent_body == {"add": ["wi-1"], "remove": ["wi-2"]} diff --git a/tests/v2/test_modules_resource.py b/tests/v2/test_modules_resource.py new file mode 100644 index 0000000..9229a12 --- /dev/null +++ b/tests/v2/test_modules_resource.py @@ -0,0 +1,150 @@ +"""Offline coverage for `Modules`; includes `manage_work_items`, folded in from the former separate +`ModuleWorkItems` class.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.modules import Modules +from plane.config import Configuration +from plane.models.v2.module_work_items import ModuleWorkItemManageRequest +from plane.models.v2.modules import CreateModule, UpdateModule + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/modules" + + +@pytest.fixture +def modules(config: Configuration) -> Modules: + return Modules(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_modules(modules: Modules) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Onboarding", "status": "planned"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = modules.list() + + assert page.total_count == 1 + assert page.data[0].status == "planned" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(modules: Modules) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = modules.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_list_validates_expand_against_the_golden(modules: Modules) -> None: + """`lead` and `members` are the only values `modules_list` expands to in the + golden -- a typo must be rejected before the request is ever sent.""" + with pytest.raises(ValueError, match="Unknown expand"): + modules.list(expand=["owner"]) + + +@responses.activate +def test_create_then_patch(modules: Modules) -> None: + responses.post( + f"{BASE}/", + json={"id": "1", "name": "Onboarding", "status": "planned"}, + status=201, + ) + responses.patch( + f"{BASE}/1/", + json={"id": "1", "name": "Onboarding", "status": "completed"}, + ) + + created = modules.create(CreateModule(name="Onboarding")) + updated = modules.update(created.id, UpdateModule(status="completed")) + + assert updated.status == "completed" + + +@responses.activate +def test_delete(modules: Modules) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert modules.delete("1") is None + + +@responses.activate +def test_upsert_hits_the_upsert_url(modules: Modules) -> None: + responses.post( + f"{BASE}/upsert/", + json={"id": "1", "name": "Onboarding"}, + ) + + result = modules.upsert(CreateModule(name="Onboarding")) + + assert result.id == "1" + + +@responses.activate +def test_bulk_create_hits_the_bulk_create_url(modules: Modules) -> None: + responses.post( + f"{BASE}/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = modules.bulk_create([CreateModule(name="Onboarding")]) + + assert result.succeeded == 1 + + +@responses.activate +def test_find_by_name(modules: Modules) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "Onboarding"}], "pagination": {"style": "offset"}}, + ) + + assert modules.find_by_name("Onboarding").id == "1" + + +# -- Custom action: manage_work_items -------------------------------------------- + + +@responses.activate +def test_manage_work_items_add_only(modules: Modules) -> None: + responses.post( + f"{BASE}/mod-1/work-items/", + json={"added": ["wi-1", "wi-2"], "removed": []}, + ) + + result = modules.manage_work_items( + "mod-1", ModuleWorkItemManageRequest(add=["wi-1", "wi-2"]) + ) + + assert result.added == ["wi-1", "wi-2"] + assert result.removed == [] + sent = json.loads(responses.calls[0].request.body) + assert sent == {"add": ["wi-1", "wi-2"], "remove": []} + + +def test_module_work_item_manage_request_caps_at_100_ids() -> None: + """`maxItems: 100` from the golden -- negative assertion, proven capable of + failing below.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ModuleWorkItemManageRequest(add=[f"wi-{i}" for i in range(101)]) diff --git a/tests/v2/test_operations_coverage.py b/tests/v2/test_operations_coverage.py new file mode 100644 index 0000000..212e4a0 --- /dev/null +++ b/tests/v2/test_operations_coverage.py @@ -0,0 +1,109 @@ +"""Every resource's `operations` mapping must point at real operationIds. Lesson: validate against +`OPERATION_IDS`, not `FIELDS` -- `FIELDS` only covers the ~316 operations with `?fields=`, so +membership there alone can't tell a fields-less operation from a typo.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +from types import ModuleType + +import plane.api.v2 as v2_package +import tests.v2.fixtures as fixtures_package +from plane.api.v2._generated.constants import OPERATION_IDS +from plane.api.v2._kernel.resource import V2Resource + + +def _iter_resource_classes(package: ModuleType) -> list[type[V2Resource]]: # type: ignore[type-arg] + """Every `V2Resource` subclass under `package` (recursing via `pkgutil.walk_packages`); + anything under an underscore-prefixed module/subpackage is excluded.""" + found: list[type[V2Resource]] = [] # type: ignore[type-arg] + prefix = f"{package.__name__}." + for module_info in pkgutil.walk_packages(package.__path__, prefix=prefix): + relative_parts = module_info.name[len(prefix) :].split(".") + if any(part.startswith("_") for part in relative_parts): + continue + module = importlib.import_module(module_info.name) + for _, obj in inspect.getmembers(module, inspect.isclass): + if ( + issubclass(obj, V2Resource) + and obj is not V2Resource + and obj.__module__ == module.__name__ + ): + found.append(obj) + return found + + +def test_every_resource_class_is_discovered() -> None: + # Sanity check on discovery itself; not pinned to today's exact resource set. + names = {cls.__name__ for cls in _iter_resource_classes(v2_package)} + assert {"States", "Labels"}.issubset(names) + + +def test_every_declared_operation_id_exists_in_the_golden() -> None: + for resource_class in _iter_resource_classes(v2_package): + for action, operation_id in resource_class.operations.items(): + assert operation_id in OPERATION_IDS, ( + f"{resource_class.__name__}.operations[{action!r}] = {operation_id!r} is not " + "a real operationId in the golden (OPERATION_IDS) -- check for a typo. Note: " + "this does NOT mean the operation lacks a `fields`/`order_by`/`expand` enum -- " + "plenty of real operations legitimately have none of those; that case is fine " + "and is exactly why this checks OPERATION_IDS, not FIELDS." + ) + + +def test_every_resource_class_discovery_finds_at_least_70() -> None: + # Floor, not a pin: below 70 means discovery broke or resources were mass-removed. + count = len(_iter_resource_classes(v2_package)) + assert count >= 70, ( + f"Only {count} V2Resource subclasses were discovered under {v2_package.__name__} " + "(expected at least 70) -- either resource discovery is broken, or a large " + "number of resources were removed. Either way, the coverage tests in this " + "file would otherwise be silently checking far fewer classes than intended." + ) + + +def test_every_operation_id_in_the_golden_is_declared_by_some_resource() -> None: + """Every real operationId must be declared by some resource's `operations` mapping (the other + direction of coverage) -- ~123 implemented operations once had none.""" + declared_ids: set[str] = set() + for resource_class in _iter_resource_classes(v2_package): + declared_ids.update(resource_class.operations.values()) + + golden_ids = set(OPERATION_IDS) + missing = sorted(golden_ids - declared_ids) + extra = sorted(declared_ids - golden_ids) + assert not missing and not extra, ( + f"{len(missing)} golden operationId(s) are not declared by any resource's " + f"`operations` mapping: {missing}. " + f"{len(extra)} declared operationId(s) are not in the golden: {extra} " + "(the previous test should already have caught these as typos)." + ) + + +def test_no_operation_id_is_declared_by_two_resources() -> None: + """Each operationId must be declared by exactly one resource; a duplicate would make `_query`'s + `fields`/`expand`/`order_by` validation nondeterministic.""" + owner_by_id: dict[str, str] = {} + duplicates: list[tuple[str, str, str]] = [] + for resource_class in _iter_resource_classes(v2_package): + for operation_id in resource_class.operations.values(): + owner = owner_by_id.get(operation_id) + if owner is not None and owner != resource_class.__name__: + duplicates.append((operation_id, owner, resource_class.__name__)) + else: + owner_by_id[operation_id] = resource_class.__name__ + + assert not duplicates, ( + "The following operationId(s) are declared by more than one resource class " + f"(operationId, first owner, duplicate owner): {duplicates}" + ) + + +def test_discovery_recurses_into_subpackages() -> None: + """Regression check using `tests/v2/fixtures/`: a subclass two packages deep must be found, one + under an underscore-prefixed subpackage must stay excluded.""" + names = {cls.__name__ for cls in _iter_resource_classes(fixtures_package)} + assert "DummyNestedResource" in names + assert "ShouldNotBeDiscovered" not in names diff --git a/tests/v2/test_pages_resource.py b/tests/v2/test_pages_resource.py new file mode 100644 index 0000000..f305df6 --- /dev/null +++ b/tests/v2/test_pages_resource.py @@ -0,0 +1,166 @@ +"""Offline coverage for `ProjectPages`/`WikiPages`; pins that each variant hits its own distinct +URL despite sharing `Page`/`CreatePage`/`UpdatePage`.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.pages import ProjectPages, WikiPages +from plane.config import Configuration +from plane.models.v2.pages import CreatePage, UpdatePage + +PROJECT_BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/pages" +WORKSPACE_BASE = "https://api.example.com/api/v2/workspaces/acme/pages" + + +@pytest.fixture +def project_pages(config: Configuration) -> ProjectPages: + return ProjectPages(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_pages(config: Configuration) -> WikiPages: + return WikiPages(V2Transport(config), slug="acme") + + +# -- ProjectPages ------------------------------------------------------------------ + + +@responses.activate +def test_project_pages_list(project_pages: ProjectPages) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={ + "data": [{"id": "p1", "name": "Runbook"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = project_pages.list() + + assert page.total_count == 1 + assert page.data[0].name == "Runbook" + + +@responses.activate +def test_project_pages_list_passes_expand(project_pages: ProjectPages) -> None: + responses.get(f"{PROJECT_BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + project_pages.list(expand=["owned_by", "parent"]) + + assert "expand=owned_by%2Cparent" in responses.calls[0].request.url + + +def test_project_pages_list_rejects_unknown_expand(project_pages: ProjectPages) -> None: + with pytest.raises(ValueError, match="bogus"): + project_pages.list(expand=["bogus"]) + + +@responses.activate +def test_project_pages_retrieve(project_pages: ProjectPages) -> None: + responses.get(f"{PROJECT_BASE}/p1/", json={"id": "p1", "name": "Runbook"}) + + row = project_pages.retrieve("p1") + + assert row.id == "p1" + + +@responses.activate +def test_project_pages_find_by_name(project_pages: ProjectPages) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={"data": [{"id": "p1", "name": "Runbook"}], "pagination": {"style": "offset"}}, + ) + + assert project_pages.find_by_name("Runbook").id == "p1" + + +@responses.activate +def test_project_pages_create_requires_only_name(project_pages: ProjectPages) -> None: + responses.post(f"{PROJECT_BASE}/", json={"id": "p1", "name": "Runbook"}, status=201) + + project_pages.create(CreatePage(name="Runbook")) + + body = json.loads(responses.calls[0].request.body) + assert body == {"name": "Runbook"} + + +@responses.activate +def test_project_pages_create_carries_collection_id(project_pages: ProjectPages) -> None: + responses.post(f"{PROJECT_BASE}/", json={"id": "p1", "name": "Runbook"}, status=201) + + project_pages.create(CreatePage(name="Runbook", collection_id="c1")) + + body = json.loads(responses.calls[0].request.body) + assert body["collection_id"] == "c1" + + +@responses.activate +def test_project_pages_update_uses_patch(project_pages: ProjectPages) -> None: + responses.patch(f"{PROJECT_BASE}/p1/", json={"id": "p1", "name": "Renamed"}) + + updated = project_pages.update("p1", UpdatePage(name="Renamed")) + + assert updated.name == "Renamed" + + +@responses.activate +def test_project_pages_delete_returns_none(project_pages: ProjectPages) -> None: + responses.delete(f"{PROJECT_BASE}/p1/", status=204) + + assert project_pages.delete("p1") is None + + +# -- WikiPages ------------------------------------------------------------------ + + +@responses.activate +def test_workspace_pages_hits_the_workspace_level_path_not_the_project_one( + workspace_pages: WikiPages, +) -> None: + responses.get( + f"{WORKSPACE_BASE}/", + json={"data": [{"id": "p1", "is_global": True}], "pagination": {"style": "offset"}}, + ) + + page = workspace_pages.list() + + assert page.data[0].is_global is True + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_workspace_pages_retrieve(workspace_pages: WikiPages) -> None: + responses.get(f"{WORKSPACE_BASE}/p1/", json={"id": "p1", "is_global": True}) + + row = workspace_pages.retrieve("p1") + + assert row.id == "p1" + + +@responses.activate +def test_workspace_pages_create(workspace_pages: WikiPages) -> None: + responses.post(f"{WORKSPACE_BASE}/", json={"id": "p1", "name": "Wiki Home"}, status=201) + + created = workspace_pages.create(CreatePage(name="Wiki Home")) + + assert created.id == "p1" + + +@responses.activate +def test_workspace_pages_update(workspace_pages: WikiPages) -> None: + responses.patch(f"{WORKSPACE_BASE}/p1/", json={"id": "p1", "is_locked": True}) + + updated = workspace_pages.update("p1", UpdatePage(is_locked=True)) + + assert updated.is_locked is True + + +@responses.activate +def test_workspace_pages_delete_returns_none(workspace_pages: WikiPages) -> None: + responses.delete(f"{WORKSPACE_BASE}/p1/", status=204) + + assert workspace_pages.delete("p1") is None diff --git a/tests/v2/test_pagination.py b/tests/v2/test_pagination.py new file mode 100644 index 0000000..a69f8b8 --- /dev/null +++ b/tests/v2/test_pagination.py @@ -0,0 +1,140 @@ +from typing import Any + +import pytest +from pydantic import BaseModel + +from plane.api.v2._kernel.pagination import iterate, parse_page +from plane.errors import PlaneError +from plane.models.v2.common import CursorPage, OffsetPage + + +class Row(BaseModel): + id: str + name: str | None = None + + +def test_parses_offset_envelope() -> None: + page = parse_page( + { + "data": [{"id": "1", "name": "Todo"}], + "pagination": {"style": "offset"}, + "next": 50, + "previous": None, + "total_count": 123, + }, + Row, + ) + assert isinstance(page, OffsetPage) + assert page.total_count == 123 + assert page.data[0].name == "Todo" + + +def test_parses_cursor_envelope() -> None: + page = parse_page( + { + "data": [{"id": "1"}], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "b3A9MTcx", + }, + Row, + ) + assert isinstance(page, CursorPage) + assert page.next_cursor == "b3A9MTcx" + + +def test_iterate_follows_offset_pages() -> None: + pages = [ + {"data": [{"id": "1"}], "pagination": {"style": "offset"}, "next": 1}, + {"data": [{"id": "2"}], "pagination": {"style": "offset"}, "next": None}, + ] + seen: list[dict[str, Any]] = [] + + def fetch(params: dict[str, Any]) -> Any: + seen.append(dict(params)) + return parse_page(pages[len(seen) - 1], Row) + + assert [row.id for row in iterate(fetch, {})] == ["1", "2"] + assert seen[1]["offset"] == 1 + + +def test_iterate_follows_cursor_pages() -> None: + pages = [ + { + "data": [{"id": "1"}], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "c1", + }, + { + "data": [{"id": "2"}], + "pagination": {"style": "cursor"}, + "has_more": False, + "next_cursor": None, + }, + ] + seen: list[dict[str, Any]] = [] + + def fetch(params: dict[str, Any]) -> Any: + seen.append(dict(params)) + return parse_page(pages[len(seen) - 1], Row) + + assert [row.id for row in iterate(fetch, {"paginate": "cursor"})] == ["1", "2"] + assert seen[1]["cursor"] == "c1" + + +def test_iterate_raises_on_stalled_offset_page() -> None: + """A server that keeps echoing the same `next` offset must not spin forever.""" + responses = [ + {"data": [{"id": "1"}], "pagination": {"style": "offset"}, "next": 3}, + {"data": [{"id": "2"}], "pagination": {"style": "offset"}, "next": 3}, + ] + calls = iter(responses) + + def fetch(params: dict[str, Any]) -> Any: + return parse_page(next(calls), Row) + + with pytest.raises(PlaneError, match="Pagination stalled"): + list(iterate(fetch, {})) + + +def test_iterate_raises_on_stalled_cursor_page() -> None: + """A server that keeps echoing the same `next_cursor` must not spin forever.""" + responses = [ + { + "data": [{"id": "1"}], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "c1", + }, + { + "data": [{"id": "2"}], + "pagination": {"style": "cursor"}, + "has_more": True, + "next_cursor": "c1", + }, + ] + calls = iter(responses) + + def fetch(params: dict[str, Any]) -> Any: + return parse_page(next(calls), Row) + + with pytest.raises(PlaneError, match="Pagination stalled"): + list(iterate(fetch, {"paginate": "cursor"})) + + +def test_iterate_allows_next_zero_on_first_offset_page() -> None: + """`next=0` on the very first page is legitimate, not a stall: there was no + prior `offset` param to have echoed back.""" + pages = [ + {"data": [{"id": "1"}], "pagination": {"style": "offset"}, "next": 0}, + {"data": [{"id": "2"}], "pagination": {"style": "offset"}, "next": None}, + ] + seen: list[dict[str, Any]] = [] + + def fetch(params: dict[str, Any]) -> Any: + seen.append(dict(params)) + return parse_page(pages[len(seen) - 1], Row) + + assert [row.id for row in iterate(fetch, {})] == ["1", "2"] + assert seen[1]["offset"] == 0 diff --git a/tests/v2/test_permission_schemes_resource.py b/tests/v2/test_permission_schemes_resource.py new file mode 100644 index 0000000..7b9c7a5 --- /dev/null +++ b/tests/v2/test_permission_schemes_resource.py @@ -0,0 +1,71 @@ +"""Offline coverage for `ws.permission_schemes`: a read-only workspace-scoped list (with sparse +`fields`) and retrieve.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.permission_schemes import PermissionSchemes +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def permission_schemes(config: Configuration) -> PermissionSchemes: + return PermissionSchemes(V2Transport(config), slug="acme") + + +@responses.activate +def test_permission_schemes_list(permission_schemes: PermissionSchemes) -> None: + responses.get( + f"{BASE}/workspaces/acme/permission-schemes/", + json={ + "data": [{"id": "1", "name": "Admin", "namespace": "workspace"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = permission_schemes.list() + + assert page.total_count == 1 + assert page.data[0].namespace == "workspace" + + +@responses.activate +def test_permission_schemes_sparse_fields_leave_rest_none( + permission_schemes: PermissionSchemes, +) -> None: + responses.get( + f"{BASE}/workspaces/acme/permission-schemes/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = permission_schemes.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_permission_schemes_retrieve(permission_schemes: PermissionSchemes) -> None: + responses.get( + f"{BASE}/workspaces/acme/permission-schemes/1/", + json={"id": "1", "name": "Admin", "is_system": True}, + ) + + scheme = permission_schemes.retrieve("1") + + assert scheme.is_system is True + + +def test_permission_schemes_unknown_field_rejected( + permission_schemes: PermissionSchemes, +) -> None: + """`fields` is validated against the golden's `permission_schemes_list` enum + -- negative assertion, proven capable of failing below.""" + with pytest.raises(ValueError, match="Unknown field"): + permission_schemes.list(fields=["not_a_real_field"]) diff --git a/tests/v2/test_permissions_resource.py b/tests/v2/test_permissions_resource.py new file mode 100644 index 0000000..2527670 --- /dev/null +++ b/tests/v2/test_permissions_resource.py @@ -0,0 +1,61 @@ +"""Offline coverage for `WorkspacePermissions`/`ProjectPermissions` -- two distinct GET-only +singleton classes, no `id`.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.permissions import ProjectPermissions, WorkspacePermissions +from plane.config import Configuration + +WORKSPACE_URL = "https://api.example.com/api/v2/workspaces/acme/permissions/me/" +PROJECT_URL = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/permissions/me/" + + +@pytest.fixture +def workspace_permissions(config: Configuration) -> WorkspacePermissions: + return WorkspacePermissions(V2Transport(config), slug="acme") + + +@pytest.fixture +def project_permissions(config: Configuration) -> ProjectPermissions: + return ProjectPermissions(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_me_hits_the_workspace_scoped_url(workspace_permissions: WorkspacePermissions) -> None: + responses.get( + WORKSPACE_URL, json={"relation": "member", "permission_grants": ["workspace.view"]} + ) + + result = workspace_permissions.me() + + assert responses.calls[0].request.url == WORKSPACE_URL + assert result.relation == "member" + assert result.permission_grants == ["workspace.view"] + + +@responses.activate +def test_project_me_hits_the_project_scoped_url(project_permissions: ProjectPermissions) -> None: + responses.get(PROJECT_URL, json={"relation": "admin", "permission_grants": ["project.edit"]}) + + result = project_permissions.me() + + assert responses.calls[0].request.url == PROJECT_URL + assert result.relation == "admin" + + +@responses.activate +def test_me_and_project_me_hit_different_urls( + workspace_permissions: WorkspacePermissions, project_permissions: ProjectPermissions +) -> None: + """A regression here would silently point both methods at the same URL -- + proven by making it fail: swap the mocked URLs and confirm the assertion + below breaks before restoring them.""" + responses.get(WORKSPACE_URL, json={"relation": "member", "permission_grants": []}) + responses.get(PROJECT_URL, json={"relation": "admin", "permission_grants": []}) + + workspace_result = workspace_permissions.me() + project_result = project_permissions.me() + + assert workspace_result.relation != project_result.relation diff --git a/tests/v2/test_projects_resource.py b/tests/v2/test_projects_resource.py new file mode 100644 index 0000000..6d53e07 --- /dev/null +++ b/tests/v2/test_projects_resource.py @@ -0,0 +1,220 @@ +"""Offline coverage for `Projects`: CRUD, `upsert`, bulk create/update (no bulk-delete), +archive/unarchive, `summary`, and `role_distribution`.""" + +from __future__ import annotations + +import pytest +import responses +from responses import matchers + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.projects import Projects +from plane.config import Configuration +from plane.models.v2.projects import CreateProject, UpdateProject + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def projects(config: Configuration) -> Projects: + return Projects(V2Transport(config), slug="acme") + + +@responses.activate +def test_projects_retrieve_accepts_bare_identifier(projects: Projects) -> None: + """`pk` is `{"pattern": "^[^/]+$"}` in the golden, not a uuid format -- a bare + project key like `ENG` must round-trip through `retrieve` with no separate + lookup step.""" + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/", + json={"id": "real-uuid", "identifier": "ENG", "name": "Engineering"}, + ) + + project = projects.retrieve("ENG") + + assert project.identifier == "ENG" + assert responses.calls[0].request.url == f"{BASE}/workspaces/acme/projects/ENG/" + + +@responses.activate +def test_find_by_name(projects: Projects) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/", + json={ + "data": [{"id": "1", "identifier": "ENG", "name": "Engineering"}], + "pagination": {"style": "offset"}, + }, + match=[ + matchers.query_param_matcher( + {"name": "Engineering", "per_page": "2", "count": "False"} + ) + ], + ) + + assert projects.find_by_name("Engineering").id == "1" + + +@responses.activate +def test_projects_create_then_update_then_delete(projects: Projects) -> None: + responses.post( + f"{BASE}/workspaces/acme/projects/", + json={"id": "1", "identifier": "ENG", "name": "Engineering"}, + status=201, + ) + responses.patch( + f"{BASE}/workspaces/acme/projects/ENG/", + json={"id": "1", "identifier": "ENG", "name": "Eng Team"}, + ) + responses.delete(f"{BASE}/workspaces/acme/projects/ENG/", status=204) + + created = projects.create(CreateProject(identifier="ENG", name="Engineering")) + assert created.id == "1" + + updated = projects.update("ENG", UpdateProject(name="Eng Team")) + assert updated.name == "Eng Team" + + assert projects.delete("ENG") is None + + +@responses.activate +def test_projects_upsert(projects: Projects) -> None: + responses.post( + f"{BASE}/workspaces/acme/projects/upsert/", + json={"id": "1", "identifier": "ENG", "name": "Engineering"}, + status=201, + ) + + result = projects.upsert(CreateProject(identifier="ENG", name="Engineering")) + + assert result.id == "1" + + +@responses.activate +def test_projects_bulk_create_and_bulk_update(projects: Projects) -> None: + responses.post( + f"{BASE}/workspaces/acme/projects/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + responses.post( + f"{BASE}/workspaces/acme/projects/bulk-update/", + json={ + "results": [{"index": 0, "result": "updated", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + created = projects.bulk_create([CreateProject(identifier="ENG", name="Engineering")]) + assert created.succeeded == 1 + + updated = projects.bulk_update([{"id": "1", "name": "Eng Team"}]) + assert updated.succeeded == 1 + + +def test_projects_has_no_bulk_delete(projects: Projects) -> None: + """Deleting a project cascades work items/cycles/modules/pages/members, so the + API deliberately does not offer a batched bulk-delete for this resource -- + unlike states/labels/work-items, which all do.""" + assert not hasattr(projects, "bulk_delete") + + +@responses.activate +def test_projects_archive_and_unarchive_return_none(projects: Projects) -> None: + responses.post(f"{BASE}/workspaces/acme/projects/ENG/archive/", status=204) + responses.post(f"{BASE}/workspaces/acme/projects/ENG/unarchive/", status=204) + + assert projects.archive("ENG") is None + assert projects.unarchive("ENG") is None + + +@responses.activate +def test_projects_summary_parses_the_summary_shape_not_project(projects: Projects) -> None: + """`summary` returns `ProjectSummary` (id/identifier/name/counts), a different + shape than `Project` -- this is why it cannot go through `_action`, which always + parses the resource's own `model`.""" + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/summary/", + json={"id": "1", "identifier": "ENG", "name": "Engineering", "counts": {"members": 3}}, + ) + + summary = projects.summary("ENG") + + assert summary.counts == {"members": 3} + + +@responses.activate +def test_projects_summary_joins_counts_filter(projects: Projects) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/summary/", + json={"id": "1", "identifier": "ENG", "name": "Engineering", "counts": {"members": 3}}, + ) + + projects.summary("ENG", counts=["members", "states"]) + + assert "counts=members%2Cstates" in responses.calls[0].request.url + + +@responses.activate +def test_role_distribution_hits_the_sibling_workspace_path(projects: Projects) -> None: + """`role_distribution` is a workspace-wide report with no `{pk}`, folded in from the former + standalone `project-role-distribution` resource.""" + responses.get( + f"{BASE}/workspaces/acme/project-role-distribution/", + json={ + "roles": [ + { + "role_id": "r1", + "name": "Admin", + "slug": "admin", + "level": 20, + "is_system": True, + "membership_count": 3, + "distinct_member_count": 3, + } + ], + "total_distinct_members": 3, + "total_memberships": 3, + }, + ) + + report = projects.role_distribution() + + assert report.total_memberships == 3 + assert report.roles[0].name == "Admin" + assert ( + responses.calls[0].request.url + == f"{BASE}/workspaces/acme/project-role-distribution/" + ) + + +@responses.activate +def test_role_distribution_handles_nulled_role(projects: Projects) -> None: + """A role since deleted comes back with `role_id`/`name`/`slug` all null, + per the golden's nullable-but-required fields.""" + responses.get( + f"{BASE}/workspaces/acme/project-role-distribution/", + json={ + "roles": [ + { + "role_id": None, + "name": None, + "slug": None, + "level": None, + "is_system": None, + "membership_count": 1, + "distinct_member_count": 1, + } + ], + "total_distinct_members": 1, + "total_memberships": 1, + }, + ) + + report = projects.role_distribution() + + assert report.roles[0].role_id is None + assert report.roles[0].membership_count == 1 diff --git a/tests/v2/test_releases_resource.py b/tests/v2/test_releases_resource.py new file mode 100644 index 0000000..5b3f8bc --- /dev/null +++ b/tests/v2/test_releases_resource.py @@ -0,0 +1,375 @@ +"""Offline coverage for `Releases`: workspace-scoped, a manage verb returning `{added, removed}`, +and the `changelog` singleton sub-resource.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.releases import Releases +from plane.api.v2.releases.changelog import ReleaseChangelogResource +from plane.config import Configuration +from plane.models.v2.releases import ( + CreateRelease, + CreateReleaseComment, + CreateReleaseLabel, + CreateReleaseLink, + CreateReleaseTag, + UpdateRelease, + UpdateReleaseChangelog, + UpdateReleaseComment, + UpdateReleaseLabel, + UpdateReleaseLink, + UpdateReleaseTag, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/releases" + + +@pytest.fixture +def releases(config: Configuration) -> Releases: + return Releases(V2Transport(config), slug="acme") + + +@pytest.fixture +def changelog(config: Configuration) -> ReleaseChangelogResource: + return ReleaseChangelogResource(V2Transport(config), slug="acme") + + +# -- Workspace-scoped CRUD ------------------------------------------------------- + + +@responses.activate +def test_list_releases(releases: Releases) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "v1.0", "status": "unreleased"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = releases.list() + + assert page.total_count == 1 + assert page.data[0].status == "unreleased" + # Workspace-scoped: no project segment anywhere in the URL. + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_list_passes_expand_and_filters(releases: Releases) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + releases.list(expand=["lead", "tag"], status="unreleased") + + query = responses.calls[0].request.url + assert "expand=lead%2Ctag" in query + assert "status=unreleased" in query + + +def test_list_rejects_unknown_expand_before_the_request(releases: Releases) -> None: + with pytest.raises(ValueError, match="bogus"): + releases.list(expand=["bogus"]) + + +def test_list_rejects_unknown_fields_before_the_request(releases: Releases) -> None: + with pytest.raises(ValueError, match="bogus"): + releases.list(fields=["bogus"]) + + +@responses.activate +def test_retrieve_release(releases: Releases) -> None: + responses.get(f"{BASE}/rel-1/", json={"id": "rel-1", "name": "v1.0"}) + + row = releases.retrieve("rel-1") + + assert row.id == "rel-1" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(releases: Releases) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = releases.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_create_then_patch(releases: Releases) -> None: + responses.post(f"{BASE}/", json={"id": "1", "name": "v1.0"}, status=201) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "v1.0-final"}) + + created = releases.create(CreateRelease(name="v1.0")) + updated = releases.update(created.id, UpdateRelease(name="v1.0-final")) + + assert updated.name == "v1.0-final" + + +@responses.activate +def test_delete_returns_none(releases: Releases) -> None: + responses.delete(f"{BASE}/rel-1/", status=204) + + assert releases.delete("rel-1") is None + + +@responses.activate +def test_find_by_name(releases: Releases) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "v1.0"}], "pagination": {"style": "offset"}}, + ) + + assert releases.find_by_name("v1.0").id == "1" + + +# -- Child membership manage (labels / work items) ------------------------------- + + +@responses.activate +def test_manage_labels_posts_add_remove_and_parses_result(releases: Releases) -> None: + responses.post( + f"{BASE}/rel-1/labels/", + json={"added": ["lbl-1"], "removed": ["lbl-2"]}, + ) + + result = releases.manage_labels("rel-1", add=["lbl-1"], remove=["lbl-2"]) + + body = json.loads(responses.calls[0].request.body) + assert body == {"add": ["lbl-1"], "remove": ["lbl-2"]} + assert result.added == ["lbl-1"] + assert result.removed == ["lbl-2"] + assert responses.calls[0].request.url.endswith("/releases/rel-1/labels/") + + +@responses.activate +def test_manage_work_items_posts_to_its_own_sub_path(releases: Releases) -> None: + responses.post( + f"{BASE}/rel-1/work-items/", + json={"added": ["wi-1"], "removed": []}, + ) + + result = releases.manage_work_items("rel-1", add=["wi-1"]) + + assert result.added == ["wi-1"] + assert result.removed == [] + assert responses.calls[0].request.url.endswith("/releases/rel-1/work-items/") + + +@responses.activate +def test_manage_labels_omits_unset_add_remove(releases: Releases) -> None: + """`exclude_none` means passing only `add` doesn't send a null `remove`.""" + responses.post(f"{BASE}/rel-1/labels/", json={"added": ["lbl-1"], "removed": []}) + + releases.manage_labels("rel-1", add=["lbl-1"]) + + body = json.loads(responses.calls[0].request.body) + assert body == {"add": ["lbl-1"]} + + +# -- Changelog (singleton) -------------------------------------------------------- + + +@responses.activate +def test_retrieve_changelog(changelog: ReleaseChangelogResource) -> None: + responses.get( + f"{BASE}/rel-1/changelog/", + json={"id": "chg-1", "release_id": "rel-1", "description_html": "

notes

"}, + ) + + row = changelog.retrieve("rel-1") + + assert row.id == "chg-1" + assert row.description_html == "

notes

" + + +@responses.activate +def test_changelog_update_uses_patch(changelog: ReleaseChangelogResource) -> None: + responses.patch( + f"{BASE}/rel-1/changelog/", + json={"id": "chg-1", "description_html": "

updated

"}, + ) + + row = changelog.update("rel-1", UpdateReleaseChangelog(description_html="

updated

")) + + body = json.loads(responses.calls[0].request.body) + assert body == {"description_html": "

updated

"} + assert row.description_html == "

updated

" + + +# -- Sub-resources: comments (nested under a release) ---------------------------- + + +@responses.activate +def test_comments_crud(releases: Releases) -> None: + responses.get( + f"{BASE}/rel-1/comments/", + json={ + "data": [{"id": "c1", "comment_html": "

hi

"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/rel-1/comments/", json={"id": "c1", "comment_html": "

hi

"}, status=201 + ) + responses.get(f"{BASE}/rel-1/comments/c1/", json={"id": "c1", "comment_html": "

hi

"}) + responses.patch(f"{BASE}/rel-1/comments/c1/", json={"id": "c1", "comment_html": "

bye

"}) + responses.delete(f"{BASE}/rel-1/comments/c1/", status=204) + + page = releases.comments.list("rel-1") + assert page.data[0].id == "c1" + + created = releases.comments.create("rel-1", CreateReleaseComment(comment_html="

hi

")) + assert created.id == "c1" + + fetched = releases.comments.retrieve("rel-1", "c1") + assert fetched.id == "c1" + + updated = releases.comments.update( + "rel-1", "c1", UpdateReleaseComment(comment_html="

bye

") + ) + assert updated.comment_html == "

bye

" + + assert releases.comments.delete("rel-1", "c1") is None + + +@responses.activate +def test_comments_has_no_upsert_or_bulk(releases: Releases) -> None: + """Unlike work-item comments, the golden gives release comments only the + plain CRUD five -- no upsert/bulk-* operationIds exist for this shard.""" + assert not hasattr(releases.comments, "upsert") + assert not hasattr(releases.comments, "bulk_create") + + +# -- Sub-resources: links (nested under a release) ------------------------------- + + +@responses.activate +def test_links_crud(releases: Releases) -> None: + responses.get( + f"{BASE}/rel-1/links/", + json={"data": [{"id": "l1", "url": "https://x.test"}], "pagination": {"style": "offset"}}, + ) + responses.post(f"{BASE}/rel-1/links/", json={"id": "l1", "url": "https://x.test"}, status=201) + responses.patch(f"{BASE}/rel-1/links/l1/", json={"id": "l1", "url": "https://y.test"}) + responses.delete(f"{BASE}/rel-1/links/l1/", status=204) + + page = releases.links.list("rel-1") + assert page.data[0].id == "l1" + + created = releases.links.create("rel-1", CreateReleaseLink(title="Docs", url="https://x.test")) + assert created.id == "l1" + + updated = releases.links.update("rel-1", "l1", UpdateReleaseLink(url="https://y.test")) + assert updated.url == "https://y.test" + + assert releases.links.delete("rel-1", "l1") is None + + +# -- Catalog: labels (workspace-level, not nested under a release) --------------- + + +@responses.activate +def test_labels_catalog_crud(releases: Releases) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/releases/labels/", + json={"data": [{"id": "lbl-1", "name": "breaking"}], "pagination": {"style": "offset"}}, + ) + responses.post( + "https://api.example.com/api/v2/workspaces/acme/releases/labels/", + json={"id": "lbl-1", "name": "breaking"}, + status=201, + ) + responses.patch( + "https://api.example.com/api/v2/workspaces/acme/releases/labels/lbl-1/", + json={"id": "lbl-1", "name": "breaking-change"}, + ) + responses.delete( + "https://api.example.com/api/v2/workspaces/acme/releases/labels/lbl-1/", status=204 + ) + + page = releases.labels.list() + assert page.data[0].name == "breaking" + + created = releases.labels.create(CreateReleaseLabel(name="breaking")) + assert created.id == "lbl-1" + + updated = releases.labels.update("lbl-1", UpdateReleaseLabel(name="breaking-change")) + assert updated.name == "breaking-change" + + assert releases.labels.delete("lbl-1") is None + # Catalog path has no release id segment. + assert "rel-1" not in responses.calls[0].request.url + + +@responses.activate +def test_labels_catalog_find_by_name(releases: Releases) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/releases/labels/", + json={"data": [{"id": "lbl-1", "name": "breaking"}], "pagination": {"style": "offset"}}, + ) + + assert releases.labels.find_by_name("breaking").id == "lbl-1" + + +# -- Catalog: tags (workspace-level, version:-prefixed lookup) ------------------- + + +@responses.activate +def test_tags_catalog_crud(releases: Releases) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/", + json={"data": [{"id": "tag-1", "version": "1.0.0"}], "pagination": {"style": "offset"}}, + ) + responses.post( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/", + json={"id": "tag-1", "version": "1.0.0"}, + status=201, + ) + responses.patch( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/tag-1/", + json={"id": "tag-1", "version": "1.0.1"}, + ) + responses.delete( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/tag-1/", status=204 + ) + + page = releases.tags.list() + assert page.data[0].version == "1.0.0" + + created = releases.tags.create(CreateReleaseTag(version="1.0.0")) + assert created.id == "tag-1" + + updated = releases.tags.update("tag-1", UpdateReleaseTag(version="1.0.1")) + assert updated.version == "1.0.1" + + assert releases.tags.delete("tag-1") is None + + +@responses.activate +def test_tags_catalog_retrieve_by_version_prefixed_pk(releases: Releases) -> None: + """The golden documents the tag detail pk as UUID *or* `version:` -- + the SDK does not special-case this, it just percent-encodes whatever string + is passed, so the colon-prefixed form round-trips like any other pk.""" + responses.get( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/version%3A1.0.0/", + json={"id": "tag-1", "version": "1.0.0"}, + ) + + fetched = releases.tags.retrieve("version:1.0.0") + + assert fetched.id == "tag-1" + + +@responses.activate +def test_tags_catalog_find_by_version(releases: Releases) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/releases/tags/", + json={"data": [{"id": "tag-1", "version": "1.0.0"}], "pagination": {"style": "offset"}}, + ) + + assert releases.tags.find_by_version("1.0.0").id == "tag-1" diff --git a/tests/v2/test_resource.py b/tests/v2/test_resource.py new file mode 100644 index 0000000..aa625eb --- /dev/null +++ b/tests/v2/test_resource.py @@ -0,0 +1,293 @@ +import pytest +import responses +from pydantic import BaseModel, ConfigDict + +from plane.api.v2._kernel.resource import V2Resource +from plane.api.v2._kernel.transport import V2Transport +from plane.config import Configuration + + +class Row(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str | None = None + + +class WriteRow(BaseModel): + name: str + + +class PatchRow(BaseModel): + name: str | None = None + + +class Rows(V2Resource[Row, WriteRow, PatchRow]): + path = "/workspaces/{slug}/projects/{project_id}/states/" + model = Row + operations = { + "list": "states_list", + "retrieve": "states_retrieve", + "create": "states_create", + "update": "states_partial_update", + } + + +@pytest.fixture +def rows(config: Configuration) -> Rows: + return Rows(V2Transport(config)) + + +class WorkItemRows(V2Resource[Row, WriteRow, PatchRow]): + """Uses real `work_items_list`/`work_items_retrieve` operationIds (whose `fields` enums + genuinely differ) to test per-action `fields` validation.""" + + path = "/workspaces/{slug}/projects/{project_id}/work-items/" + model = Row + operations = { + "list": "work_items_list", + "retrieve": "work_items_retrieve", + } + + +@pytest.fixture +def work_item_rows(config: Configuration) -> WorkItemRows: + return WorkItemRows(V2Transport(config)) + + +@responses.activate +def test_list_returns_typed_rows(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [{"id": "1", "name": "Todo"}], "pagination": {"style": "offset"}}, + ) + + page = rows._list(slug="acme", project_id="ENG") + + assert page.data[0].name == "Todo" + + +@responses.activate +def test_list_encodes_fields_and_filters(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + rows._list(slug="acme", project_id="ENG", params={"fields": ["id", "name"], "name": "Todo"}) + + query = responses.calls[0].request.url + assert "fields=id%2Cname" in query + assert "name=Todo" in query + + +def test_unknown_field_is_rejected_before_the_request(rows: Rows) -> None: + with pytest.raises(ValueError, match="nope"): + rows._list(slug="acme", project_id="ENG", params={"fields": ["nope"]}) + + +@responses.activate +def test_known_order_by_is_accepted(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + rows._list(slug="acme", project_id="ENG", params={"order_by": "-created_at"}) + + assert "order_by=-created_at" in responses.calls[0].request.url + + +def test_unknown_order_by_is_rejected_before_the_request(rows: Rows) -> None: + with pytest.raises(ValueError, match="nope"): + rows._list(slug="acme", project_id="ENG", params={"order_by": "nope"}) + + +@responses.activate +def test_order_by_list_is_validated_and_joined(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + rows._list(slug="acme", project_id="ENG", params={"order_by": ["created_at", "-id"]}) + + assert "order_by=created_at%2C-id" in responses.calls[0].request.url + + +def test_order_by_list_with_an_unknown_entry_raises_value_error_not_type_error( + rows: Rows, +) -> None: + with pytest.raises(ValueError, match="nope"): + rows._list(slug="acme", project_id="ENG", params={"order_by": ["created_at", "nope"]}) + + +@responses.activate +def test_retrieve_appends_pk(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/abc/", + json={"id": "abc", "name": "Todo"}, + ) + + assert rows._retrieve(slug="acme", project_id="ENG", pk="abc").id == "abc" + + +@responses.activate +def test_update_uses_patch(rows: Rows) -> None: + responses.patch( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/abc/", + json={"id": "abc", "name": "Doing"}, + ) + + assert ( + rows._update(PatchRow(name="Doing"), slug="acme", project_id="ENG", pk="abc").name + == "Doing" + ) + + +@responses.activate +def test_delete_returns_none(rows: Rows) -> None: + responses.delete( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/abc/", status=204 + ) + + assert rows._delete(slug="acme", project_id="ENG", pk="abc") is None + + +def test_field_valid_for_retrieve_only_is_rejected_by_list(work_item_rows: WorkItemRows) -> None: + """`custom_fields` is in the real `work_items_retrieve` enum but not `work_items_list` + (verified against the generated golden). Validating `_list` against the `list` + operation's own enum — not a single resource-wide enum — must still catch it.""" + with pytest.raises(ValueError, match="custom_fields"): + work_item_rows._list(slug="acme", project_id="ENG", params={"fields": ["custom_fields"]}) + + +@responses.activate +def test_field_valid_for_retrieve_only_is_accepted_by_retrieve( + work_item_rows: WorkItemRows, +) -> None: + """The converse of the above: the same field, illegal for `_list`, must be accepted + by `_retrieve` because it validates against `work_items_retrieve`'s own enum.""" + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/work-items/abc/", + json={"id": "abc", "custom_fields": {}}, + ) + + row = work_item_rows._retrieve( + slug="acme", project_id="ENG", pk="abc", params={"fields": ["custom_fields"]} + ) + + assert row.id == "abc" + + +@responses.activate +def test_collection_url_percent_encodes_path_params(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/ac%20me/projects/ENG%2FX/states/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + rows._list(slug="ac me", project_id="ENG/X") + + query = responses.calls[0].request.url + assert "ac%20me" in query + assert "ENG%2FX" in query + + +@responses.activate +def test_detail_url_percent_encodes_pk(rows: Rows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/a%2Fb%20c/", + json={"id": "a/b c", "name": "Todo"}, + ) + + row = rows._retrieve(slug="acme", project_id="ENG", pk="a/b c") + + assert row.id == "a/b c" + query = responses.calls[0].request.url + assert "a%2Fb%20c" in query + + +class WorkItemExpandRows(V2Resource[Row, WriteRow, PatchRow]): + """Points at the real `work_items_list` operation id, whose `expand` enum + (`assignees, cycle, labels, modules, parent, state, type`) is exercised here + against real golden data rather than an invented enum.""" + + path = "/workspaces/{slug}/projects/{project_id}/work-items/" + model = Row + operations = {"list": "work_items_list"} + + +@pytest.fixture +def work_item_expand_rows(config: Configuration) -> WorkItemExpandRows: + return WorkItemExpandRows(V2Transport(config)) + + +@responses.activate +def test_valid_expand_passes_through(work_item_expand_rows: WorkItemExpandRows) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/work-items/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + work_item_expand_rows._list( + slug="acme", project_id="ENG", params={"expand": ["state", "labels"]} + ) + + query = responses.calls[0].request.url + assert "expand=state%2Clabels" in query + + +def test_invalid_expand_is_rejected_before_the_request( + work_item_expand_rows: WorkItemExpandRows, +) -> None: + """No responses are registered (and @responses.activate is not applied): if + validation failed to run, the real HTTP call this would otherwise attempt fails + the test by erroring on the network, not by silently passing.""" + with pytest.raises(ValueError, match="bogus"): + work_item_expand_rows._list(slug="acme", project_id="ENG", params={"expand": ["bogus"]}) + + +def test_expand_as_comma_string_is_also_validated( + work_item_expand_rows: WorkItemExpandRows, +) -> None: + with pytest.raises(ValueError, match="bogus"): + work_item_expand_rows._list(slug="acme", project_id="ENG", params={"expand": "state,bogus"}) + + +class ArchivableRows(V2Resource[Row, WriteRow, PatchRow]): + """Exercises `_action` against the real `work_items_archive` operation id.""" + + path = "/workspaces/{slug}/projects/{project_id}/work-items/" + model = Row + operations = {"archive": "work_items_archive"} + + +@pytest.fixture +def archivable_rows(config: Configuration) -> ArchivableRows: + return ArchivableRows(V2Transport(config)) + + +@responses.activate +def test_action_posts_to_the_named_sub_path_and_returns_the_row( + archivable_rows: ArchivableRows, +) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/work-items/abc/archive/", + json={"id": "abc", "name": "Archived"}, + ) + + row = archivable_rows._action("archive", pk="abc", slug="acme", project_id="ENG") + + assert row.id == "abc" + assert row.name == "Archived" + + +@responses.activate +def test_action_validates_expand_against_its_own_operation_id( + archivable_rows: ArchivableRows, +) -> None: + with pytest.raises(ValueError, match="bogus"): + archivable_rows._action( + "archive", pk="abc", slug="acme", project_id="ENG", params={"expand": ["bogus"]} + ) diff --git a/tests/v2/test_roles_resource.py b/tests/v2/test_roles_resource.py new file mode 100644 index 0000000..a8b54a8 --- /dev/null +++ b/tests/v2/test_roles_resource.py @@ -0,0 +1,69 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.roles import Roles +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2/workspaces/acme/roles" + + +@pytest.fixture +def roles(config: Configuration) -> Roles: + return Roles(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_roles(roles: Roles) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Admin", "namespace": "workspace", "level": 20}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = roles.list() + + assert page.total_count == 1 + assert page.data[0].namespace == "workspace" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(roles: Roles) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = roles.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_retrieve(roles: Roles) -> None: + responses.get(f"{BASE}/1/", json={"id": "1", "name": "Admin"}) + + role = roles.retrieve("1") + + assert role.name == "Admin" + + +@responses.activate +def test_find_by_name(roles: Roles) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "Admin"}], "pagination": {"style": "offset"}}, + ) + + assert roles.find_by_name("Admin").id == "1" + + +@responses.activate +def test_find_by_name_raises_on_no_match(roles: Roles) -> None: + from plane.api.v2._kernel.errors import NoMatchFound + + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + with pytest.raises(NoMatchFound, match="name='Nope'"): + roles.find_by_name("Nope") diff --git a/tests/v2/test_states_resource.py b/tests/v2/test_states_resource.py new file mode 100644 index 0000000..1bc52e3 --- /dev/null +++ b/tests/v2/test_states_resource.py @@ -0,0 +1,70 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.states import States +from plane.config import Configuration +from plane.models.v2.states import CreateState, UpdateState + + +@pytest.fixture +def states(config: Configuration) -> States: + return States(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_states(states: States) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={ + "data": [{"id": "1", "name": "Todo", "group": "unstarted"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = states.list() + + assert page.total_count == 1 + assert page.data[0].group == "unstarted" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(states: States) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = states.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_create_then_patch(states: States) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"id": "1", "name": "Todo", "color": "#fff"}, + status=201, + ) + responses.patch( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/1/", + json={"id": "1", "name": "Doing"}, + ) + + created = states.create(CreateState(name="Todo", color="#fff")) + updated = states.update(created.id, UpdateState(name="Doing")) + + assert updated.name == "Doing" + + +@responses.activate +def test_find_by_name(states: States) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [{"id": "1", "name": "Todo"}], "pagination": {"style": "offset"}}, + ) + + assert states.find_by_name("Todo").id == "1" diff --git a/tests/v2/test_stickies_resource.py b/tests/v2/test_stickies_resource.py new file mode 100644 index 0000000..96b2f68 --- /dev/null +++ b/tests/v2/test_stickies_resource.py @@ -0,0 +1,66 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.stickies import Stickies +from plane.config import Configuration +from plane.models.v2.stickies import CreateSticky, UpdateSticky + +BASE = "https://api.example.com/api/v2/workspaces/acme/stickies" + + +@pytest.fixture +def stickies(config: Configuration) -> Stickies: + return Stickies(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_stickies(stickies: Stickies) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Reminder", "color": "#f00"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = stickies.list() + + assert page.total_count == 1 + assert page.data[0].color == "#f00" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(stickies: Stickies) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = stickies.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +def test_list_rejects_unknown_field_before_the_request(stickies: Stickies) -> None: + with pytest.raises(ValueError, match="Unknown field"): + stickies.list(fields=["bogus"]) + + +@responses.activate +def test_create_with_empty_body_then_patch(stickies: Stickies) -> None: + """Every field on `CreateSticky` is optional in the golden -- an empty body is + a valid create.""" + responses.post(f"{BASE}/", json={"id": "1"}, status=201) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "Todo"}) + + created = stickies.create(CreateSticky()) + updated = stickies.update(created.id, UpdateSticky(name="Todo")) + + assert updated.name == "Todo" + + +@responses.activate +def test_delete(stickies: Stickies) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert stickies.delete("1") is None diff --git a/tests/v2/test_teamspaces_resource.py b/tests/v2/test_teamspaces_resource.py new file mode 100644 index 0000000..6e531ba --- /dev/null +++ b/tests/v2/test_teamspaces_resource.py @@ -0,0 +1,75 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.teamspaces import Teamspaces +from plane.config import Configuration +from plane.models.v2.teamspaces import CreateTeamspace, UpdateTeamspace + +BASE = "https://api.example.com/api/v2/workspaces/acme/teamspaces" + + +@pytest.fixture +def teamspaces(config: Configuration) -> Teamspaces: + return Teamspaces(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_teamspaces(teamspaces: Teamspaces) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Platform", "member_ids": ["u1"]}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = teamspaces.list() + + assert page.total_count == 1 + assert page.data[0].member_ids == ["u1"] + + +@responses.activate +def test_list_passes_expand_and_filters(teamspaces: Teamspaces) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + teamspaces.list(expand=["lead"], name="Platform") + + query = responses.calls[0].request.url + assert "expand=lead" in query + assert "name=Platform" in query + + +def test_list_rejects_unknown_expand_before_the_request(teamspaces: Teamspaces) -> None: + with pytest.raises(ValueError, match="Unknown expand"): + teamspaces.list(expand=["bogus"]) + + +@responses.activate +def test_create_then_patch(teamspaces: Teamspaces) -> None: + responses.post(f"{BASE}/", json={"id": "1", "name": "Platform"}, status=201) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "Platform (renamed)"}) + + created = teamspaces.create(CreateTeamspace(name="Platform")) + updated = teamspaces.update(created.id, UpdateTeamspace(name="Platform (renamed)")) + + assert updated.name == "Platform (renamed)" + + +@responses.activate +def test_delete(teamspaces: Teamspaces) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert teamspaces.delete("1") is None + + +@responses.activate +def test_find_by_name(teamspaces: Teamspaces) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "Platform"}], "pagination": {"style": "offset"}}, + ) + + assert teamspaces.find_by_name("Platform").id == "1" diff --git a/tests/v2/test_transport.py b/tests/v2/test_transport.py new file mode 100644 index 0000000..4c71b47 --- /dev/null +++ b/tests/v2/test_transport.py @@ -0,0 +1,84 @@ +import pytest +import responses + +from plane.api.v2 import PlaneAPIError +from plane.api.v2._kernel.transport import V2Transport +from plane.config import Configuration + + +def test_api_root_has_no_version_suffix() -> None: + config = Configuration(base_path="https://api.example.com/", api_key="secret") + assert config.api_root == "https://api.example.com" + assert config.base_path == "https://api.example.com/api/v1" + + +@responses.activate +def test_request_builds_v2_url_and_sends_api_key(config: Configuration) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + transport = V2Transport(config) + + transport.request("GET", "/workspaces/acme/projects/ENG/states/") + + assert responses.calls[0].request.headers["X-Api-Key"] == "secret" + + +@responses.activate +def test_problem_json_becomes_plane_api_error(config: Configuration) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/x/", + status=404, + content_type="application/problem+json", + json={ + "type": "not_found", + "title": "Not found", + "status": 404, + "code": "not_found", + "detail": "No State matches the given query.", + }, + ) + transport = V2Transport(config) + + with pytest.raises(PlaneAPIError) as excinfo: + transport.request("GET", "/workspaces/acme/projects/ENG/states/x/") + + assert excinfo.value.status == 404 + assert excinfo.value.code == "not_found" + assert excinfo.value.type == "not_found" + assert "No State matches" in excinfo.value.detail + + +@responses.activate +def test_validation_problem_exposes_field_errors(config: Configuration) -> None: + responses.post( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/", + status=400, + content_type="application/problem+json", + json={ + "type": "invalid_request", + "title": "Invalid request", + "status": 400, + "code": "invalid_request", + "detail": "One or more fields failed validation.", + "errors": [{"field": "name", "message": "This field is required."}], + }, + ) + transport = V2Transport(config) + + with pytest.raises(PlaneAPIError) as excinfo: + transport.request("POST", "/workspaces/acme/projects/ENG/states/", json={}) + + assert excinfo.value.errors is not None + assert excinfo.value.errors[0].field == "name" + + +@responses.activate +def test_204_returns_none(config: Configuration) -> None: + responses.delete( + "https://api.example.com/api/v2/workspaces/acme/projects/ENG/states/x/", status=204 + ) + transport = V2Transport(config) + + assert transport.request("DELETE", "/workspaces/acme/projects/ENG/states/x/") is None diff --git a/tests/v2/test_users_resource.py b/tests/v2/test_users_resource.py new file mode 100644 index 0000000..3cce0ab --- /dev/null +++ b/tests/v2/test_users_resource.py @@ -0,0 +1,55 @@ +"""Offline coverage for `Users`: `users.me()` is the one route with no workspace/project in its +path; asserts a 401 surfaces as `PlaneAPIError`.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.errors import PlaneAPIError +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.users import Users +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def users(config: Configuration) -> Users: + return Users(V2Transport(config)) + + +@responses.activate +def test_users_me(users: Users) -> None: + responses.get( + f"{BASE}/users/me/", + json={ + "id": "u1", + "display_name": "Ada", + "email": "ada@example.com", + "principal_kind": "api_key", + "scopes": ["read", "write"], + }, + ) + + who = users.me() + + assert who.principal_kind == "api_key" + assert who.scopes == ["read", "write"] + # No slug/project in the path at all. + assert responses.calls[0].request.url is not None + assert responses.calls[0].request.url.startswith(f"{BASE}/users/me/") + + +@responses.activate +def test_users_me_surfaces_401(users: Users) -> None: + """A 401 from the transport raises `PlaneAPIError`, not something silently + swallowed -- negative assertion, proven capable of failing below.""" + responses.get( + f"{BASE}/users/me/", + json={"type": "unauthorized", "code": "unauthorized", "detail": "no token"}, + status=401, + ) + + with pytest.raises(PlaneAPIError): + users.me() diff --git a/tests/v2/test_views_resource.py b/tests/v2/test_views_resource.py new file mode 100644 index 0000000..293f684 --- /dev/null +++ b/tests/v2/test_views_resource.py @@ -0,0 +1,88 @@ +"""Offline coverage for `ProjectViews`/`WorkspaceViews`; shared shape, different path templates, +plus `?expand=owned_by`.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.views import ProjectViews, WorkspaceViews +from plane.config import Configuration +from plane.models.v2.views import CreateView, UpdateView + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def project_views(config: Configuration) -> ProjectViews: + return ProjectViews(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_views(config: Configuration) -> WorkspaceViews: + return WorkspaceViews(V2Transport(config), slug="acme") + + +@responses.activate +def test_project_views_crud(project_views: ProjectViews) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/views/", + json={"data": [{"id": "1", "name": "My view"}], "pagination": {"style": "offset"}}, + ) + responses.post( + f"{BASE}/workspaces/acme/projects/ENG/views/", + json={"id": "2", "name": "Sprint board"}, + status=201, + ) + responses.patch( + f"{BASE}/workspaces/acme/projects/ENG/views/2/", + json={"id": "2", "name": "Sprint board v2"}, + ) + responses.delete(f"{BASE}/workspaces/acme/projects/ENG/views/2/", status=204) + + page = project_views.list() + assert page.data[0].name == "My view" + + created = project_views.create(CreateView(name="Sprint board")) + assert created.id == "2" + + updated = project_views.update(created.id, UpdateView(name="Sprint board v2")) + assert updated.name == "Sprint board v2" + + assert project_views.delete(created.id) is None + + +@responses.activate +def test_workspace_views_use_a_different_path_than_project_views( + workspace_views: WorkspaceViews, +) -> None: + """Project and workspace views share a read/write shape but hit different path + templates -- `.../projects/{project_id}/views/` vs `.../views/` with no project + segment at all.""" + responses.get( + f"{BASE}/workspaces/acme/views/", + json={"data": [{"id": "1", "name": "Workspace view"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_views.list() + + assert page.data[0].name == "Workspace view" + assert responses.calls[0].request.url.startswith(f"{BASE}/workspaces/acme/views/") + + +@responses.activate +def test_views_expand_owned_by(project_views: ProjectViews) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/views/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + project_views.list(expand=["owned_by"]) + + assert "expand=owned_by" in responses.calls[0].request.url + + +def test_views_unknown_expand_is_rejected_before_the_request(project_views: ProjectViews) -> None: + with pytest.raises(ValueError, match="nope"): + project_views.list(expand=["nope"]) diff --git a/tests/v2/test_webhook_logs_resource.py b/tests/v2/test_webhook_logs_resource.py new file mode 100644 index 0000000..5df5f61 --- /dev/null +++ b/tests/v2/test_webhook_logs_resource.py @@ -0,0 +1,66 @@ +"""Offline coverage for `WebhookLogs`: a read-only delivery log nested under a webhook id (list, +retrieve, `iterate`).""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.webhook_logs import WebhookLogs +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def webhook_logs(config: Configuration) -> WebhookLogs: + return WebhookLogs(V2Transport(config), slug="acme") + + +@responses.activate +def test_webhook_logs_list(webhook_logs: WebhookLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/webhook-logs/wh-1/", + json={ + "data": [{"id": "log-1", "webhook_id": "wh-1", "response_status": "200"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = webhook_logs.list("wh-1") + + assert page.data[0].response_status == "200" + + +@responses.activate +def test_webhook_logs_retrieve(webhook_logs: WebhookLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/webhook-logs/wh-1/log-1/", + json={"id": "log-1", "webhook_id": "wh-1", "request_method": "POST"}, + ) + + log = webhook_logs.retrieve("wh-1", "log-1") + + assert log.request_method == "POST" + + +@responses.activate +def test_webhook_logs_iter_follows_pages(webhook_logs: WebhookLogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/webhook-logs/wh-1/", + json={ + "data": [{"id": "1"}], + "pagination": {"style": "offset"}, + "next": 1, + }, + ) + responses.get( + f"{BASE}/workspaces/acme/webhook-logs/wh-1/", + json={"data": [{"id": "2"}], "pagination": {"style": "offset"}, "next": None}, + ) + + ids = [row.id for row in webhook_logs.iterate("wh-1")] + + assert ids == ["1", "2"] diff --git a/tests/v2/test_webhooks_resource.py b/tests/v2/test_webhooks_resource.py new file mode 100644 index 0000000..38b14ab --- /dev/null +++ b/tests/v2/test_webhooks_resource.py @@ -0,0 +1,111 @@ +"""Offline coverage for `Webhooks`: CRUD plus `regenerate`, implemented by hand since both it and +`create` return the richer `WebhookCreateResult` envelope.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.webhooks import Webhooks +from plane.config import Configuration +from plane.models.v2.webhooks import CreateWebhook, UpdateWebhook + +BASE = "https://api.example.com/api/v2/workspaces/acme/webhooks" + + +@pytest.fixture +def webhooks(config: Configuration) -> Webhooks: + return Webhooks(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_webhooks(webhooks: Webhooks) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "w1", "url": "https://example.com/hook", "is_active": True}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = webhooks.list() + + assert page.total_count == 1 + assert page.data[0].is_active is True + + +@responses.activate +def test_retrieve_webhook(webhooks: Webhooks) -> None: + responses.get(f"{BASE}/w1/", json={"id": "w1", "url": "https://example.com/hook"}) + + row = webhooks.retrieve("w1") + + assert row.id == "w1" + + +@responses.activate +def test_find_by_name(webhooks: Webhooks) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "w1", "name": "prod"}], "pagination": {"style": "offset"}}, + ) + + assert webhooks.find_by_name("prod").id == "w1" + + +@responses.activate +def test_create_returns_the_secret_once(webhooks: Webhooks) -> None: + """`WebhookCreateResult` carries `secret_key` -- the golden's own + `webhooks_create` schema omits it, but the resource returns the richer + shape (see the module docstring's contract-drift note).""" + responses.post( + f"{BASE}/", + json={ + "id": "w1", + "url": "https://example.com/hook", + "is_active": True, + "secret_key": "shh-secret", + }, + status=201, + ) + + created = webhooks.create(CreateWebhook(url="https://example.com/hook")) + + assert created.id == "w1" + assert created.secret_key == "shh-secret" + body = json.loads(responses.calls[0].request.body) + assert body == {"url": "https://example.com/hook"} + + +@responses.activate +def test_update_uses_patch(webhooks: Webhooks) -> None: + responses.patch(f"{BASE}/w1/", json={"id": "w1", "is_active": False}) + + updated = webhooks.update("w1", UpdateWebhook(is_active=False)) + + assert updated.is_active is False + + +@responses.activate +def test_delete_returns_none(webhooks: Webhooks) -> None: + responses.delete(f"{BASE}/w1/", status=204) + + assert webhooks.delete("w1") is None + + +@responses.activate +def test_regenerate_posts_to_regenerate_sub_path_and_returns_a_new_secret( + webhooks: Webhooks, +) -> None: + responses.post( + f"{BASE}/w1/regenerate/", + json={"id": "w1", "secret_key": "new-secret"}, + ) + + result = webhooks.regenerate("w1") + + assert result.id == "w1" + assert result.secret_key == "new-secret" + assert responses.calls[0].request.url == f"{BASE}/w1/regenerate/" diff --git a/tests/v2/test_work_item_properties_resource.py b/tests/v2/test_work_item_properties_resource.py new file mode 100644 index 0000000..5d49b2f --- /dev/null +++ b/tests/v2/test_work_item_properties_resource.py @@ -0,0 +1,353 @@ +"""Offline coverage for work item properties + `.options`/`.contexts`; properties/contexts validate +`fields`, options don't, though options do validate `order_by`.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_item_properties import ( + WorkItemProperties, + WorkItemPropertyContexts, + WorkItemPropertyOptions, + WorkspaceWorkItemProperties, + WorkspaceWorkItemPropertyOptions, +) +from plane.config import Configuration +from plane.models.v2.work_item_properties import ( + CreateWorkItemProperty, + CreateWorkItemPropertyContext, + CreateWorkItemPropertyOption, + UpdateWorkItemProperty, + UpdateWorkItemPropertyOption, +) + +PROJECT_BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/work-item-properties" +WORKSPACE_BASE = "https://api.example.com/api/v2/workspaces/acme/work-item-properties" + + +@pytest.fixture +def properties(config: Configuration) -> WorkItemProperties: + return WorkItemProperties(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_properties(config: Configuration) -> WorkspaceWorkItemProperties: + return WorkspaceWorkItemProperties(V2Transport(config), slug="acme") + + +@pytest.fixture +def options(config: Configuration) -> WorkItemPropertyOptions: + return WorkItemPropertyOptions(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_options(config: Configuration) -> WorkspaceWorkItemPropertyOptions: + return WorkspaceWorkItemPropertyOptions(V2Transport(config), slug="acme") + + +@pytest.fixture +def contexts(config: Configuration) -> WorkItemPropertyContexts: + return WorkItemPropertyContexts(V2Transport(config), slug="acme") + + +# -- WorkItemProperties (project-scoped) ---------------------------------------- + + +@responses.activate +def test_list_properties(properties: WorkItemProperties) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={ + "data": [{"id": "p1", "display_name": "Severity", "property_type": "OPTION"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = properties.list() + + assert page.total_count == 1 + assert page.data[0].property_type == "OPTION" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(properties: WorkItemProperties) -> None: + responses.get( + f"{PROJECT_BASE}/", + json={"data": [{"id": "p1"}], "pagination": {"style": "offset"}}, + ) + + page = properties.list(fields=["id"]) + + assert page.data[0].id == "p1" + assert page.data[0].display_name is None + + +def test_list_rejects_unknown_field_before_the_request(properties: WorkItemProperties) -> None: + with pytest.raises(ValueError, match="bogus"): + properties.list(fields=["bogus"]) + + +@responses.activate +def test_create_then_patch_then_delete(properties: WorkItemProperties) -> None: + responses.post( + f"{PROJECT_BASE}/", + json={"id": "p1", "display_name": "Severity", "property_type": "OPTION"}, + status=201, + ) + responses.patch( + f"{PROJECT_BASE}/p1/", + json={"id": "p1", "display_name": "Priority Level"}, + ) + responses.delete(f"{PROJECT_BASE}/p1/", status=204) + + created = properties.create( + CreateWorkItemProperty(display_name="Severity", property_type="OPTION") + ) + updated = properties.update(created.id, UpdateWorkItemProperty(display_name="Priority Level")) + properties.delete(created.id) + + assert updated.display_name == "Priority Level" + + +@responses.activate +def test_retrieve_property(properties: WorkItemProperties) -> None: + responses.get(f"{PROJECT_BASE}/p1/", json={"id": "p1", "display_name": "Severity"}) + + row = properties.retrieve("p1") + + assert row.id == "p1" + + +# -- WorkItemProperties.options (project-scoped) -------------------------------- + + +@responses.activate +def test_list_project_options(options: WorkItemPropertyOptions) -> None: + responses.get( + f"{PROJECT_BASE}/p1/options/", + json={ + "data": [{"id": "o1", "name": "Critical"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = options.list("p1") + + assert page.data[0].name == "Critical" + + +@responses.activate +def test_project_options_list_ignores_fields_kwarg_absence( + options: WorkItemPropertyOptions, +) -> None: + """The golden declares no `?fields=` for options; `list`/`retrieve` simply + don't accept the kwarg (unlike `WorkItemProperties.list`, which does).""" + responses.get( + f"{PROJECT_BASE}/p1/options/", + json={"data": [], "pagination": {"style": "offset"}}, + ) + + options.list("p1", name="Critical") + + query = responses.calls[0].request.url + assert "name=Critical" in query + assert "fields=" not in query + + +def test_project_options_list_rejects_unknown_order_by(options: WorkItemPropertyOptions) -> None: + with pytest.raises(ValueError, match="bogus"): + options.list("p1", order_by="bogus") + + +@responses.activate +def test_project_options_crud(options: WorkItemPropertyOptions) -> None: + responses.post( + f"{PROJECT_BASE}/p1/options/", + json={"id": "o1", "name": "Critical"}, + status=201, + ) + responses.get(f"{PROJECT_BASE}/p1/options/o1/", json={"id": "o1", "name": "Critical"}) + responses.patch(f"{PROJECT_BASE}/p1/options/o1/", json={"id": "o1", "name": "Blocker"}) + responses.delete(f"{PROJECT_BASE}/p1/options/o1/", status=204) + + created = options.create("p1", CreateWorkItemPropertyOption(name="Critical")) + fetched = options.retrieve("p1", created.id) + updated = options.update("p1", created.id, UpdateWorkItemPropertyOption(name="Blocker")) + options.delete("p1", created.id) + + assert fetched.id == "o1" + assert updated.name == "Blocker" + + +# -- WorkspaceWorkItemProperties (workspace-scoped) ------------------------------ + + +@responses.activate +def test_list_workspace_properties(workspace_properties: WorkspaceWorkItemProperties) -> None: + responses.get( + f"{WORKSPACE_BASE}/", + json={ + "data": [{"id": "p1", "display_name": "Severity"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = workspace_properties.list() + + assert page.total_count == 1 + assert page.data[0].display_name == "Severity" + + +def test_workspace_properties_list_rejects_unknown_field( + workspace_properties: WorkspaceWorkItemProperties, +) -> None: + with pytest.raises(ValueError, match="bogus"): + workspace_properties.list(fields=["bogus"]) + + +@responses.activate +def test_workspace_properties_create_then_delete( + workspace_properties: WorkspaceWorkItemProperties, +) -> None: + responses.post( + f"{WORKSPACE_BASE}/", + json={"id": "p1", "display_name": "Severity", "property_type": "TEXT"}, + status=201, + ) + responses.delete(f"{WORKSPACE_BASE}/p1/", status=204) + + created = workspace_properties.create( + CreateWorkItemProperty(display_name="Severity", property_type="TEXT") + ) + workspace_properties.delete(created.id) + + assert created.id == "p1" + + +# -- WorkspaceWorkItemProperties.contexts ---------------------------------------- + + +@responses.activate +def test_list_contexts(contexts: WorkItemPropertyContexts) -> None: + responses.get( + f"{WORKSPACE_BASE}/p1/contexts/", + json={ + "data": [{"id": "c1", "is_required": True, "applies_to_all_projects": True}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = contexts.list("p1") + + assert page.data[0].applies_to_all_projects is True + + +def test_contexts_list_rejects_unknown_field(contexts: WorkItemPropertyContexts) -> None: + with pytest.raises(ValueError, match="bogus"): + contexts.list("p1", fields=["bogus"]) + + +@responses.activate +def test_contexts_create_then_patch_then_delete(contexts: WorkItemPropertyContexts) -> None: + responses.post( + f"{WORKSPACE_BASE}/p1/contexts/", + json={"id": "c1", "is_required": True}, + status=201, + ) + responses.patch(f"{WORKSPACE_BASE}/p1/contexts/c1/", json={"id": "c1", "is_required": False}) + responses.delete(f"{WORKSPACE_BASE}/p1/contexts/c1/", status=204) + + created = contexts.create( + "p1", CreateWorkItemPropertyContext(is_required=True, project_ids=["proj-1"]) + ) + updated = contexts.update("p1", created.id, CreateWorkItemPropertyContext(is_required=False)) + contexts.delete("p1", created.id) + + assert updated.is_required is False + + +@responses.activate +def test_contexts_create_sends_write_only_ids(contexts: WorkItemPropertyContexts) -> None: + import json + + responses.post(f"{WORKSPACE_BASE}/p1/contexts/", json={"id": "c1"}, status=201) + + contexts.create( + "p1", + CreateWorkItemPropertyContext( + project_ids=["proj-1", "proj-2"], issue_type_ids=["type-1"], is_required=True + ), + ) + + body = json.loads(responses.calls[0].request.body) + assert body == { + "project_ids": ["proj-1", "proj-2"], + "issue_type_ids": ["type-1"], + "is_required": True, + } + + +# -- WorkspaceWorkItemProperties.options ----------------------------------------- + + +@responses.activate +def test_list_workspace_options(workspace_options: WorkspaceWorkItemPropertyOptions) -> None: + responses.get( + f"{WORKSPACE_BASE}/p1/options/", + json={ + "data": [{"id": "o1", "name": "Critical"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = workspace_options.list("p1") + + assert page.data[0].name == "Critical" + + +def test_workspace_options_list_rejects_unknown_order_by( + workspace_options: WorkspaceWorkItemPropertyOptions, +) -> None: + with pytest.raises(ValueError, match="bogus"): + workspace_options.list("p1", order_by="bogus") + + +@responses.activate +def test_workspace_options_crud(workspace_options: WorkspaceWorkItemPropertyOptions) -> None: + responses.post( + f"{WORKSPACE_BASE}/p1/options/", + json={"id": "o1", "name": "Critical"}, + status=201, + ) + responses.get(f"{WORKSPACE_BASE}/p1/options/o1/", json={"id": "o1", "name": "Critical"}) + responses.patch(f"{WORKSPACE_BASE}/p1/options/o1/", json={"id": "o1", "name": "Blocker"}) + responses.delete(f"{WORKSPACE_BASE}/p1/options/o1/", status=204) + + created = workspace_options.create("p1", CreateWorkItemPropertyOption(name="Critical")) + fetched = workspace_options.retrieve("p1", created.id) + updated = workspace_options.update( + "p1", created.id, UpdateWorkItemPropertyOption(name="Blocker") + ) + workspace_options.delete("p1", created.id) + + assert fetched.id == "o1" + assert updated.name == "Blocker" + + +# -- Scope forwarding: `.options`/`.contexts` inherit their parent's own scope ---- + + +def test_project_options_inherit_parent_scope(properties: WorkItemProperties) -> None: + assert properties.options._scope == {"slug": "acme", "project_id": "ENG"} + + +def test_workspace_options_and_contexts_inherit_parent_scope( + workspace_properties: WorkspaceWorkItemProperties, +) -> None: + assert workspace_properties.options._scope == {"slug": "acme"} + assert workspace_properties.contexts._scope == {"slug": "acme"} diff --git a/tests/v2/test_work_item_relation_definitions_resource.py b/tests/v2/test_work_item_relation_definitions_resource.py new file mode 100644 index 0000000..f7923b4 --- /dev/null +++ b/tests/v2/test_work_item_relation_definitions_resource.py @@ -0,0 +1,86 @@ +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_item_relation_definitions import WorkItemRelationDefinitions +from plane.config import Configuration +from plane.models.v2.work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/work-item-relation-definitions" + + +@pytest.fixture +def relation_definitions(config: Configuration) -> WorkItemRelationDefinitions: + return WorkItemRelationDefinitions(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_relation_definitions( + relation_definitions: WorkItemRelationDefinitions, +) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "blocks", "inward": "blocks", "outward": "blocked by"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = relation_definitions.list() + + assert page.total_count == 1 + assert page.data[0].inward == "blocks" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none( + relation_definitions: WorkItemRelationDefinitions, +) -> None: + responses.get(f"{BASE}/", json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}) + + page = relation_definitions.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + + +@responses.activate +def test_create_then_patch(relation_definitions: WorkItemRelationDefinitions) -> None: + responses.post( + f"{BASE}/", + json={"id": "1", "name": "relates to", "inward": "relates to", "outward": "relates to"}, + status=201, + ) + responses.patch(f"{BASE}/1/", json={"id": "1", "name": "relates to (renamed)"}) + + created = relation_definitions.create( + CreateWorkItemRelationDefinition( + name="relates to", inward="relates to", outward="relates to" + ) + ) + updated = relation_definitions.update( + created.id, UpdateWorkItemRelationDefinition(name="relates to (renamed)") + ) + + assert updated.name == "relates to (renamed)" + + +@responses.activate +def test_delete(relation_definitions: WorkItemRelationDefinitions) -> None: + responses.delete(f"{BASE}/1/", status=204) + + assert relation_definitions.delete("1") is None + + +@responses.activate +def test_find_by_name(relation_definitions: WorkItemRelationDefinitions) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1", "name": "blocks"}], "pagination": {"style": "offset"}}, + ) + + assert relation_definitions.find_by_name("blocks").id == "1" diff --git a/tests/v2/test_work_item_templates_resource.py b/tests/v2/test_work_item_templates_resource.py new file mode 100644 index 0000000..75cdd4e --- /dev/null +++ b/tests/v2/test_work_item_templates_resource.py @@ -0,0 +1,135 @@ +"""Offline coverage for project/workspace work item templates plus the project-only `use` action, +which returns a `WorkItem`.""" + +from __future__ import annotations + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_item_templates.project import ProjectWorkItemTemplates +from plane.api.v2.work_item_templates.workspace import WorkspaceWorkItemTemplates +from plane.config import Configuration +from plane.models.v2.work_item_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplateData, + WorkItemTemplateUse, +) + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def project_templates(config: Configuration) -> ProjectWorkItemTemplates: + return ProjectWorkItemTemplates(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_templates(config: Configuration) -> WorkspaceWorkItemTemplates: + return WorkspaceWorkItemTemplates(V2Transport(config), slug="acme") + + +@responses.activate +def test_project_templates_crud(project_templates: ProjectWorkItemTemplates) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/", + json={ + "data": [{"id": "1", "name": "Bug report"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/", + json={"id": "2", "name": "Feature request"}, + status=201, + ) + responses.patch( + f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/2/", + json={"id": "2", "name": "Feature"}, + ) + responses.delete(f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/2/", status=204) + + page = project_templates.list() + assert page.data[0].name == "Bug report" + + created = project_templates.create( + CreateWorkItemTemplate( + name="Feature request", template_data=WorkItemTemplateData(name="Feature request") + ), + ) + assert created.id == "2" + + updated = project_templates.update(created.id, UpdateWorkItemTemplate(name="Feature")) + assert updated.name == "Feature" + + assert project_templates.delete(created.id) is None + + +@responses.activate +def test_project_template_use_parses_a_work_item_not_a_template( + project_templates: ProjectWorkItemTemplates, +) -> None: + """`use` returns a `WorkItem`, not this resource's own `model` + (`WorkItemTemplate`) -- this is why it cannot go through `_action`.""" + responses.post( + f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/1/use/", + json={"id": "wi-1", "name": "Feature request", "sequence_id": 42}, + status=201, + ) + + work_item = project_templates.use("1") + + assert work_item.id == "wi-1" + assert work_item.sequence_id == 42 + assert responses.calls[0].request.body is None + + +@responses.activate +def test_project_template_use_sends_overrides( + project_templates: ProjectWorkItemTemplates, +) -> None: + responses.post( + f"{BASE}/workspaces/acme/projects/ENG/work-item-templates/1/use/", + json={"id": "wi-1", "name": "Custom name"}, + status=201, + ) + + project_templates.use("1", WorkItemTemplateUse(name="Custom name")) + + body = json.loads(responses.calls[0].request.body) + assert body == {"name": "Custom name"} + + +@responses.activate +def test_workspace_templates_crud(workspace_templates: WorkspaceWorkItemTemplates) -> None: + responses.get( + f"{BASE}/workspaces/acme/work-item-templates/", + json={"data": [{"id": "1", "name": "Bug report"}], "pagination": {"style": "offset"}}, + ) + responses.post( + f"{BASE}/workspaces/acme/work-item-templates/", + json={"id": "2", "name": "Feature request"}, + status=201, + ) + + page = workspace_templates.list() + assert page.data[0].name == "Bug report" + + created = workspace_templates.create( + CreateWorkItemTemplate( + name="Feature request", template_data=WorkItemTemplateData(name="Feature request") + ), + ) + assert created.id == "2" + + +def test_workspace_templates_have_no_use_action( + workspace_templates: WorkspaceWorkItemTemplates, +) -> None: + """`use` is only offered on the project-scoped variant in the golden -- there + is no `.../work-item-templates/{pk}/use/` route under `.../workspaces/{slug}/ + work-item-templates/`.""" + assert not hasattr(workspace_templates, "use") diff --git a/tests/v2/test_work_item_types_resource.py b/tests/v2/test_work_item_types_resource.py new file mode 100644 index 0000000..2bc2c77 --- /dev/null +++ b/tests/v2/test_work_item_types_resource.py @@ -0,0 +1,336 @@ +"""Offline coverage for project- and workspace-scoped `WorkItemTypes`/`WorkItemTypeProperties`.""" + +from __future__ import annotations + +import json + +import pytest +import responses +from responses import matchers + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_item_types import ( + WorkItemTypeProperties, + WorkItemTypes, + WorkspaceWorkItemTypeProperties, + WorkspaceWorkItemTypes, +) +from plane.config import Configuration +from plane.models.v2.work_item_types import CreateWorkItemType, UpdateWorkItemType + +BASE = "https://api.example.com/api/v2/workspaces/acme" + + +@pytest.fixture +def work_item_types(config: Configuration) -> WorkItemTypes: + return WorkItemTypes(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_work_item_types(config: Configuration) -> WorkspaceWorkItemTypes: + return WorkspaceWorkItemTypes(V2Transport(config), slug="acme") + + +@pytest.fixture +def properties(config: Configuration) -> WorkItemTypeProperties: + return WorkItemTypeProperties(V2Transport(config), slug="acme", project_id="ENG") + + +@pytest.fixture +def workspace_properties(config: Configuration) -> WorkspaceWorkItemTypeProperties: + return WorkspaceWorkItemTypeProperties(V2Transport(config), slug="acme") + + +# -- Project-scoped WorkItemTypes CRUD -------------------------------------------- + + +@responses.activate +def test_list_project_scoped(work_item_types: WorkItemTypes) -> None: + responses.get( + f"{BASE}/projects/ENG/work-item-types/", + json={ + "data": [{"id": "1", "name": "Bug", "is_default": True}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = work_item_types.list() + + assert page.total_count == 1 + assert page.data[0].is_default is True + + +@responses.activate +def test_find_by_name(work_item_types: WorkItemTypes) -> None: + responses.get( + f"{BASE}/projects/ENG/work-item-types/", + json={ + "data": [{"id": "1", "name": "Bug", "is_default": True}], + "pagination": {"style": "offset"}, + }, + match=[matchers.query_param_matcher({"name": "Bug", "per_page": "2", "count": "False"})], + ) + + assert work_item_types.find_by_name("Bug").id == "1" + + +@responses.activate +def test_workspace_find_by_name(workspace_work_item_types: WorkspaceWorkItemTypes) -> None: + responses.get( + f"{BASE}/work-item-types/", + json={ + "data": [{"id": "9", "name": "Bug", "is_default": False}], + "pagination": {"style": "offset"}, + }, + match=[matchers.query_param_matcher({"name": "Bug", "per_page": "2", "count": "False"})], + ) + + assert workspace_work_item_types.find_by_name("Bug").id == "9" + + +@responses.activate +def test_sparse_response_leaves_absent_fields_none(work_item_types: WorkItemTypes) -> None: + responses.get( + f"{BASE}/projects/ENG/work-item-types/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = work_item_types.list(fields=["id"]) + + assert page.data[0].id == "1" + assert page.data[0].name is None + assert page.data[0].level is None + + +@responses.activate +def test_create_then_patch_then_delete(work_item_types: WorkItemTypes) -> None: + responses.post( + f"{BASE}/projects/ENG/work-item-types/", + json={"id": "1", "name": "Bug"}, + status=201, + ) + responses.patch( + f"{BASE}/projects/ENG/work-item-types/1/", + json={"id": "1", "name": "Defect"}, + ) + responses.delete(f"{BASE}/projects/ENG/work-item-types/1/", status=204) + + created = work_item_types.create(CreateWorkItemType(name="Bug")) + updated = work_item_types.update(created.id, UpdateWorkItemType(name="Defect")) + assert updated.name == "Defect" + + assert work_item_types.delete(created.id) is None + + +def test_unknown_field_is_rejected_before_the_request(work_item_types: WorkItemTypes) -> None: + with pytest.raises(ValueError, match="nope"): + work_item_types.list(fields=["nope"]) + + +# -- Custom verb actions ----------------------------------------------------------- + + +@responses.activate +def test_enable_posts_to_the_collection_level_action(work_item_types: WorkItemTypes) -> None: + """`enable` has no `{pk}` -- it is a collection-level action, unlike + `mark_default`/`schema` which are per-row.""" + responses.post( + f"{BASE}/projects/ENG/work-item-types/enable/", + json={"id": "epic-type-id", "is_epic": True}, + ) + + result = work_item_types.enable() + + assert result.is_epic is True + assert responses.calls[0].request.url.endswith("/work-item-types/enable/") + + +@responses.activate +def test_import_types_posts_the_ids_envelope(work_item_types: WorkItemTypes) -> None: + responses.post(f"{BASE}/projects/ENG/work-item-types/import/", json="", status=200) + + result = work_item_types.import_types(["type-a", "type-b"]) + + assert result is None + body = json.loads(responses.calls[0].request.body) + assert body == {"work_item_types": ["type-a", "type-b"]} + + +@responses.activate +def test_mark_default_posts_to_the_hyphenated_detail_action( + work_item_types: WorkItemTypes, +) -> None: + """The URL segment is `mark-default` (hyphen), not `mark_default` -- the + kernel's `_action` uses its `name` argument verbatim as the URL suffix.""" + responses.post( + f"{BASE}/projects/ENG/work-item-types/1/mark-default/", + json={"id": "1", "is_default": True}, + ) + + result = work_item_types.mark_default("1") + + assert result.is_default is True + + +def test_mark_default_validates_fields_against_its_own_operation_id( + work_item_types: WorkItemTypes, +) -> None: + with pytest.raises(ValueError, match="nope"): + work_item_types.mark_default("1", fields=["nope"]) + + +@responses.activate +def test_schema_gets_the_detail_action_and_parses_the_schema_model( + work_item_types: WorkItemTypes, +) -> None: + """`schema` is a GET (not a POST like every other custom action here) and + returns a distinct `WorkItemTypeSchema`, not a `WorkItemType` row.""" + responses.get( + f"{BASE}/projects/ENG/work-item-types/1/schema/", + json={ + "type_id": "1", + "type_name": "Bug", + "type_description": None, + "type_logo_props": None, + "fields": {"priority": {"options": ["low", "high"]}}, + "custom_fields": [], + }, + ) + + schema = work_item_types.schema("1") + + assert schema.type_id == "1" + assert schema.fields == {"priority": {"options": ["low", "high"]}} + + +# -- Workspace-scoped WorkItemTypes: different path template ----------------------- + + +@responses.activate +def test_workspace_list_uses_a_workspace_only_path( + workspace_work_item_types: WorkspaceWorkItemTypes, +) -> None: + responses.get( + f"{BASE}/work-item-types/", + json={"data": [{"id": "1", "name": "Bug"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_work_item_types.list() + + assert page.data[0].name == "Bug" + # Negative assertion: the workspace-scoped path never mentions a project. + assert "/projects/" not in responses.calls[0].request.url + + +@responses.activate +def test_workspace_create_and_mark_default( + workspace_work_item_types: WorkspaceWorkItemTypes, +) -> None: + responses.post(f"{BASE}/work-item-types/", json={"id": "1", "name": "Bug"}, status=201) + responses.post(f"{BASE}/work-item-types/1/mark-default/", json={"id": "1", "is_default": True}) + + created = workspace_work_item_types.create(CreateWorkItemType(name="Bug")) + marked = workspace_work_item_types.mark_default(created.id) + + assert marked.is_default is True + + +@responses.activate +def test_workspace_delete_returns_none( + workspace_work_item_types: WorkspaceWorkItemTypes, +) -> None: + responses.delete(f"{BASE}/work-item-types/1/", status=204) + + assert workspace_work_item_types.delete("1") is None + + +# -- Project-scoped WorkItemTypeProperties ------------------------------------------ + + +@responses.activate +def test_properties_list_and_retrieve(properties: WorkItemTypeProperties) -> None: + responses.get( + f"{BASE}/projects/ENG/work-item-types/1/properties/", + json={ + "data": [{"id": "p1", "name": "severity", "property_type": "OPTION"}], + "pagination": {"style": "offset"}, + }, + ) + responses.get( + f"{BASE}/projects/ENG/work-item-types/1/properties/p1/", + json={"id": "p1", "name": "severity", "property_type": "OPTION"}, + ) + + page = properties.list("1") + assert page.data[0].property_type == "OPTION" + + fetched = properties.retrieve("1", "p1") + assert fetched.id == "p1" + + +@responses.activate +def test_properties_attach_posts_to_the_collection_url_with_ids_body( + properties: WorkItemTypeProperties, +) -> None: + responses.post( + f"{BASE}/projects/ENG/work-item-types/1/properties/", + json={"properties": ["p1", "p2"]}, + ) + + result = properties.attach("1", ["p1", "p2"]) + + assert result.properties == ["p1", "p2"] + body = json.loads(responses.calls[0].request.body) + assert body == {"properties": ["p1", "p2"]} + + +@responses.activate +def test_properties_detach_deletes_by_property_id(properties: WorkItemTypeProperties) -> None: + responses.delete(f"{BASE}/projects/ENG/work-item-types/1/properties/p1/", status=204) + + assert properties.detach("1", "p1") is None + + +# -- Workspace-scoped WorkItemTypeProperties: also a different path template ------- + + +@responses.activate +def test_workspace_properties_list_uses_a_workspace_only_path( + workspace_properties: WorkspaceWorkItemTypeProperties, +) -> None: + responses.get( + f"{BASE}/work-item-types/1/properties/", + json={"data": [{"id": "p1", "name": "severity"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_properties.list("1") + + assert page.data[0].id == "p1" + assert "/projects/" not in responses.calls[0].request.url + + +@responses.activate +def test_workspace_properties_attach_and_detach( + workspace_properties: WorkspaceWorkItemTypeProperties, +) -> None: + responses.post(f"{BASE}/work-item-types/1/properties/", json={"properties": ["p1"]}) + responses.delete(f"{BASE}/work-item-types/1/properties/p1/", status=204) + + attached = workspace_properties.attach("1", ["p1"]) + assert attached.properties == ["p1"] + + assert workspace_properties.detach("1", "p1") is None + + +# -- Scope forwarding: `.properties` inherits its parent's own scope --------------- + + +def test_project_properties_inherit_parent_scope(work_item_types: WorkItemTypes) -> None: + assert work_item_types.properties._scope == {"slug": "acme", "project_id": "ENG"} + + +def test_workspace_properties_inherit_parent_scope( + workspace_work_item_types: WorkspaceWorkItemTypes, +) -> None: + assert workspace_work_item_types.properties._scope == {"slug": "acme"} diff --git a/tests/v2/test_work_items_resource.py b/tests/v2/test_work_items_resource.py new file mode 100644 index 0000000..78ecb00 --- /dev/null +++ b/tests/v2/test_work_items_resource.py @@ -0,0 +1,443 @@ +"""Offline coverage for `work_items`; exercises the `_action` verb helper and the non-paginated +relations/dependencies shape.""" + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_items import WorkItems +from plane.config import Configuration +from plane.models.v2.work_items import ( + CreateWorkItem, + CreateWorkItemComment, + UpdateWorkItem, + UpdateWorkItemComment, + WorkItemDependencyCreate, + WorkItemRelationCreate, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/work-items" + + +@pytest.fixture +def work_items(config: Configuration) -> WorkItems: + return WorkItems(V2Transport(config), slug="acme", project_id="ENG") + + +# -- CRUD ------------------------------------------------------------------- + + +@responses.activate +def test_list_work_items(work_items: WorkItems) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "1", "name": "Fix bug", "identifier": "ENG-1"}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = work_items.list() + + assert page.total_count == 1 + assert page.data[0].identifier == "ENG-1" + + +@responses.activate +def test_list_passes_expand_and_filters(work_items: WorkItems) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + work_items.list(expand=["state", "labels"], priority="urgent") + + query = responses.calls[0].request.url + assert "expand=state%2Clabels" in query + assert "priority=urgent" in query + + +def test_list_rejects_unknown_expand_before_the_request(work_items: WorkItems) -> None: + with pytest.raises(ValueError, match="bogus"): + work_items.list(expand=["bogus"]) + + +@responses.activate +def test_retrieve_work_item(work_items: WorkItems) -> None: + responses.get(f"{BASE}/wi-1/", json={"id": "wi-1", "name": "Fix bug"}) + + row = work_items.retrieve("wi-1") + + assert row.id == "wi-1" + + +@responses.activate +def test_create_prefers_readable_fields(work_items: WorkItems) -> None: + responses.post(f"{BASE}/", json={"id": "wi-1", "name": "Fix bug"}, status=201) + + work_items.create( + CreateWorkItem(name="Fix bug", state="Todo", labels=["bug"], assignees=["a@b.com"]), + ) + + body = json.loads(responses.calls[0].request.body) + assert body == { + "name": "Fix bug", + "state": "Todo", + "labels": ["bug"], + "assignees": ["a@b.com"], + } + + +@responses.activate +def test_update_uses_patch(work_items: WorkItems) -> None: + responses.patch(f"{BASE}/wi-1/", json={"id": "wi-1", "name": "Renamed"}) + + updated = work_items.update("wi-1", UpdateWorkItem(name="Renamed")) + + assert updated.name == "Renamed" + + +@responses.activate +def test_delete_returns_none(work_items: WorkItems) -> None: + responses.delete(f"{BASE}/wi-1/", status=204) + + assert work_items.delete("wi-1") is None + + +@responses.activate +def test_upsert(work_items: WorkItems) -> None: + responses.post(f"{BASE}/upsert/", json={"id": "wi-1", "name": "Fix bug"}) + + assert work_items.upsert(CreateWorkItem(name="Fix bug")).id == "wi-1" + + +@responses.activate +def test_bulk_create(work_items: WorkItems) -> None: + responses.post( + f"{BASE}/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = work_items.bulk_create([CreateWorkItem(name="A")]) + + assert result.succeeded == 1 + + +@responses.activate +def test_bulk_update(work_items: WorkItems) -> None: + responses.post( + f"{BASE}/bulk-update/", + json={ + "results": [{"index": 0, "result": "updated", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = work_items.bulk_update([{"id": "1", "name": "B"}]) + + assert result.succeeded == 1 + + +@responses.activate +def test_bulk_delete(work_items: WorkItems) -> None: + responses.post( + f"{BASE}/bulk-delete/", + json={ + "results": [{"index": 0, "result": "deleted", "id": "1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = work_items.bulk_delete(["1"]) + + assert result.succeeded == 1 + + +# -- archive/unarchive (_action) ------------------------------------------------ + + +@responses.activate +def test_archive_posts_to_archive_sub_path_and_returns_the_row(work_items: WorkItems) -> None: + responses.post( + f"{BASE}/wi-1/archive/", json={"id": "wi-1", "archived_at": "2026-01-01T00:00:00Z"} + ) + + row = work_items.archive("wi-1") + + assert row.id == "wi-1" + assert row.archived_at is not None + + +@responses.activate +def test_unarchive_posts_to_unarchive_sub_path(work_items: WorkItems) -> None: + responses.post(f"{BASE}/wi-1/unarchive/", json={"id": "wi-1", "archived_at": None}) + + row = work_items.unarchive("wi-1") + + assert row.id == "wi-1" + assert row.archived_at is None + + +# -- Sub-resources: comments (full CRUD + its own upsert/bulk) ----------------- + + +@responses.activate +def test_comments_crud(work_items: WorkItems) -> None: + responses.get( + f"{BASE}/wi-1/comments/", + json={ + "data": [{"id": "c1", "comment_html": "

hi

"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/wi-1/comments/", json={"id": "c1", "comment_html": "

hi

"}, status=201 + ) + responses.get(f"{BASE}/wi-1/comments/c1/", json={"id": "c1", "comment_html": "

hi

"}) + responses.patch(f"{BASE}/wi-1/comments/c1/", json={"id": "c1", "comment_html": "

bye

"}) + responses.delete(f"{BASE}/wi-1/comments/c1/", status=204) + + page = work_items.comments.list("wi-1") + assert page.data[0].id == "c1" + + created = work_items.comments.create("wi-1", CreateWorkItemComment(comment_html="

hi

")) + assert created.id == "c1" + + fetched = work_items.comments.retrieve("wi-1", "c1") + assert fetched.id == "c1" + + updated = work_items.comments.update( + "wi-1", "c1", UpdateWorkItemComment(comment_html="

bye

") + ) + assert updated.comment_html == "

bye

" + + assert work_items.comments.delete("wi-1", "c1") is None + + +@responses.activate +def test_comments_upsert(work_items: WorkItems) -> None: + responses.post(f"{BASE}/wi-1/comments/upsert/", json={"id": "c1", "comment_html": "

hi

"}) + + result = work_items.comments.upsert("wi-1", CreateWorkItemComment(comment_html="

hi

")) + + assert result.id == "c1" + + +@responses.activate +def test_comments_have_their_own_bulk_operations(work_items: WorkItems) -> None: + """Comments' bulk ops (`work_item_comments_bulk_*`) are distinct operationIds + from the parent work item's own bulk ops -- this hits the comments sub-path, + not the work-items one.""" + responses.post( + f"{BASE}/wi-1/comments/bulk-create/", + json={ + "results": [{"index": 0, "result": "created", "id": "c1"}], + "succeeded": 1, + "failed": 0, + }, + ) + + result = work_items.comments.bulk_create( + "wi-1", [CreateWorkItemComment(comment_html="

hi

")] + ) + + assert result.succeeded == 1 + assert "/comments/bulk-create/" in responses.calls[0].request.url + + +# -- Sub-resources: attachments (no expand) ------------------------------------- + + +@responses.activate +def test_attachments_crud(work_items: WorkItems) -> None: + responses.get( + f"{BASE}/wi-1/attachments/", + json={"data": [{"id": "a1", "name": "log.txt"}], "pagination": {"style": "offset"}}, + ) + responses.post( + f"{BASE}/wi-1/attachments/", + json={ + "asset_id": "a1", + "asset_url": "/api/assets/v2/.../a1/", + "upload_data": {"url": "https://s3.example.com", "fields": {"key": "a1"}}, + "attachment": {"id": "a1", "name": "log.txt"}, + }, + ) + responses.patch(f"{BASE}/wi-1/attachments/a1/", json={"id": "a1", "is_uploaded": True}) + responses.delete(f"{BASE}/wi-1/attachments/a1/", status=204) + + from plane.models.v2.work_items import CreateWorkItemAttachment, WorkItemAttachmentConfirm + + page = work_items.attachments.list("wi-1") + assert page.data[0].name == "log.txt" + + created = work_items.attachments.create( + "wi-1", CreateWorkItemAttachment(name="log.txt", size=10) + ) + assert created.asset_id == "a1" + assert created.attachment.id == "a1" + assert created.upload_data["url"] == "https://s3.example.com" + + updated = work_items.attachments.update( + "wi-1", "a1", WorkItemAttachmentConfirm(is_uploaded=True) + ) + assert updated.is_uploaded is True + + assert work_items.attachments.delete("wi-1", "a1") is None + + +# -- Sub-resources: links ----------------------------------------------------- + + +@responses.activate +def test_links_crud(work_items: WorkItems) -> None: + from plane.models.v2.work_items import CreateWorkItemLink, UpdateWorkItemLink + + responses.post(f"{BASE}/wi-1/links/", json={"id": "l1", "url": "https://x.test"}, status=201) + responses.patch(f"{BASE}/wi-1/links/l1/", json={"id": "l1", "url": "https://y.test"}) + responses.delete(f"{BASE}/wi-1/links/l1/", status=204) + + created = work_items.links.create("wi-1", CreateWorkItemLink(url="https://x.test")) + assert created.id == "l1" + + updated = work_items.links.update("wi-1", "l1", UpdateWorkItemLink(url="https://y.test")) + assert updated.url == "https://y.test" + + assert work_items.links.delete("wi-1", "l1") is None + + +# -- Sub-resources: worklogs (has expand) -------------------------------------- + + +@responses.activate +def test_worklogs_crud_and_expand(work_items: WorkItems) -> None: + from plane.models.v2.work_items import CreateWorkItemWorklog + + responses.get( + f"{BASE}/wi-1/worklogs/", + json={"data": [{"id": "w1", "duration": 60}], "pagination": {"style": "offset"}}, + ) + responses.post(f"{BASE}/wi-1/worklogs/", json={"id": "w1", "duration": 60}, status=201) + + page = work_items.worklogs.list("wi-1", expand=["logged_by"]) + assert page.data[0].duration == 60 + assert "expand=logged_by" in responses.calls[0].request.url + + created = work_items.worklogs.create("wi-1", CreateWorkItemWorklog(duration=60)) + assert created.id == "w1" + + +def test_worklogs_reject_unknown_expand(work_items: WorkItems) -> None: + with pytest.raises(ValueError, match="bogus"): + work_items.worklogs.list("wi-1", expand=["bogus"]) + + +# -- Sub-resources: activities (read-only) -------------------------------------- + + +@responses.activate +def test_activities_are_read_only(work_items: WorkItems) -> None: + responses.get( + f"{BASE}/wi-1/activities/", + json={"data": [{"id": "act1", "verb": "updated"}], "pagination": {"style": "offset"}}, + ) + responses.get(f"{BASE}/wi-1/activities/act1/", json={"id": "act1", "verb": "updated"}) + + page = work_items.activities.list("wi-1") + assert page.data[0].verb == "updated" + + fetched = work_items.activities.retrieve("wi-1", "act1") + assert fetched.id == "act1" + + # No create/update/delete methods exist on the resource at all. + assert not hasattr(work_items.activities, "create") + assert not hasattr(work_items.activities, "delete") + + +# -- Sub-resources: relations (non-paginated, delete-by-related-id) ------------ + + +@responses.activate +def test_relations_list_returns_the_dict_shaped_object_not_a_page(work_items: WorkItems) -> None: + responses.get(f"{BASE}/wi-1/relations/", json={"blocks": ["wi-2"], "blocked_by": []}) + + result = work_items.relations.list("wi-1") + + assert result.model_extra == {"blocks": ["wi-2"], "blocked_by": []} + + +@responses.activate +def test_relations_create(work_items: WorkItems) -> None: + responses.post(f"{BASE}/wi-1/relations/", json={"blocks": ["wi-2"]}, status=201) + + result = work_items.relations.create( + "wi-1", + WorkItemRelationCreate( + direction="outward", relation_definition_id="rd-1", work_item_ids=["wi-2"] + ), + ) + + body = json.loads(responses.calls[0].request.body) + assert body == { + "direction": "outward", + "relation_definition_id": "rd-1", + "work_item_ids": ["wi-2"], + } + assert result.model_extra == {"blocks": ["wi-2"]} + + +@responses.activate +def test_relations_delete_is_by_related_work_item_id_not_a_row_pk(work_items: WorkItems) -> None: + responses.delete(f"{BASE}/wi-1/relations/wi-2/", status=204) + + assert work_items.relations.delete("wi-1", "wi-2") is None + assert responses.calls[0].request.url.endswith("/relations/wi-2/") + + +# -- Sub-resources: dependencies (fixed six directions) ------------------------- + + +@responses.activate +def test_dependencies_list_has_the_six_fixed_directions(work_items: WorkItems) -> None: + responses.get( + f"{BASE}/wi-1/dependencies/", + json={ + "blocked_by": ["wi-2"], + "blocking": [], + "start_after": [], + "start_before": [], + "finish_after": [], + "finish_before": [], + }, + ) + + result = work_items.dependencies.list("wi-1") + + assert result.blocked_by == ["wi-2"] + assert result.blocking == [] + + +@responses.activate +def test_dependencies_create(work_items: WorkItems) -> None: + responses.post(f"{BASE}/wi-1/dependencies/", json={"blocked_by": ["wi-2"]}, status=201) + + work_items.dependencies.create( + "wi-1", + WorkItemDependencyCreate(relation_type="blocked_by", work_item_ids=["wi-2"]), + ) + + body = json.loads(responses.calls[0].request.body) + assert body == {"relation_type": "blocked_by", "work_item_ids": ["wi-2"]} + + +@responses.activate +def test_dependencies_delete_is_by_related_work_item_id(work_items: WorkItems) -> None: + responses.delete(f"{BASE}/wi-1/dependencies/wi-2/", status=204) + + assert work_items.dependencies.delete("wi-1", "wi-2") is None diff --git a/tests/v2/test_workflows_resource.py b/tests/v2/test_workflows_resource.py new file mode 100644 index 0000000..12dc81d --- /dev/null +++ b/tests/v2/test_workflows_resource.py @@ -0,0 +1,158 @@ +"""Offline coverage for `Workflows`: project-scoped CRUD plus the workflow-id-nested +`states`/`transitions` sub-resources.""" + +from __future__ import annotations + +import json + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.workflows import Workflows +from plane.config import Configuration +from plane.models.v2.workflows import ( + CreateWorkflow, + CreateWorkflowTransition, + UpdateWorkflow, + UpdateWorkflowState, + UpdateWorkflowTransition, + WorkflowStateCreate, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/projects/ENG/workflows" + + +@pytest.fixture +def workflows(config: Configuration) -> Workflows: + return Workflows(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_list_workflows(workflows: Workflows) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "wf1", "name": "Default", "is_active": True}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = workflows.list() + + assert page.total_count == 1 + assert page.data[0].name == "Default" + + +@responses.activate +def test_create_then_patch_then_delete_workflow(workflows: Workflows) -> None: + responses.post(f"{BASE}/", json={"id": "wf1", "name": "Default"}, status=201) + responses.patch(f"{BASE}/wf1/", json={"id": "wf1", "name": "Renamed"}) + responses.delete(f"{BASE}/wf1/", status=204) + + created = workflows.create(CreateWorkflow(name="Default")) + updated = workflows.update(created.id, UpdateWorkflow(name="Renamed")) + + assert updated.name == "Renamed" + assert workflows.delete("wf1") is None + + +@responses.activate +def test_retrieve_workflow(workflows: Workflows) -> None: + responses.get(f"{BASE}/wf1/", json={"id": "wf1", "name": "Default"}) + + row = workflows.retrieve("wf1") + + assert row.id == "wf1" + + +def test_list_rejects_unknown_fields_before_the_request(workflows: Workflows) -> None: + with pytest.raises(ValueError, match="bogus"): + workflows.list(fields=["bogus"]) + + +# -- Nested: states (bulk-attach `attach`) ---------------------------------------- + + +@responses.activate +def test_states_bulk_attach(workflows: Workflows) -> None: + responses.post( + f"{BASE}/wf1/states/", + json={"id": "ws1", "workflow_id": "wf1", "state_id": "st1"}, + status=201, + ) + + created = workflows.states.attach("wf1", WorkflowStateCreate(state_ids=["st1", "st2"])) + + assert created.id == "ws1" + body = json.loads(responses.calls[0].request.body) + assert body == {"state_ids": ["st1", "st2"]} + + +@responses.activate +def test_states_list_and_update_membership_row(workflows: Workflows) -> None: + responses.get( + f"{BASE}/wf1/states/", + json={ + "data": [{"id": "ws1", "workflow_id": "wf1", "state_id": "st1", "type": "default"}], + "pagination": {"style": "offset"}, + }, + ) + responses.patch( + f"{BASE}/wf1/states/ws1/", + json={"id": "ws1", "is_default": True}, + ) + + page = workflows.states.list("wf1") + assert page.data[0].state_id == "st1" + + updated = workflows.states.update("wf1", "ws1", UpdateWorkflowState(is_default=True)) + assert updated.is_default is True + + +@responses.activate +def test_states_delete_detaches(workflows: Workflows) -> None: + responses.delete(f"{BASE}/wf1/states/ws1/", status=204) + + assert workflows.states.delete("wf1", "ws1") is None + + +# -- Nested: transitions --------------------------------------------------------- + + +@responses.activate +def test_transitions_crud(workflows: Workflows) -> None: + responses.get( + f"{BASE}/wf1/state-transitions/", + json={ + "data": [{"id": "wt1", "workflow_state_id": "ws1"}], + "pagination": {"style": "offset"}, + }, + ) + responses.post( + f"{BASE}/wf1/state-transitions/", + json={"id": "wt1", "workflow_state_id": "ws1", "transition_state_id": "ws2"}, + status=201, + ) + responses.patch( + f"{BASE}/wf1/state-transitions/wt1/", + json={"id": "wt1", "required_approvals": 2}, + ) + responses.delete(f"{BASE}/wf1/state-transitions/wt1/", status=204) + + page = workflows.transitions.list("wf1") + assert page.data[0].workflow_state_id == "ws1" + + created = workflows.transitions.create( + "wf1", + CreateWorkflowTransition(state_id="ws1", transition_state_id="ws2"), + ) + assert created.transition_state_id == "ws2" + + updated = workflows.transitions.update( + "wf1", "wt1", UpdateWorkflowTransition(required_approvals=2) + ) + assert updated.required_approvals == 2 + + assert workflows.transitions.delete("wf1", "wt1") is None diff --git a/tests/v2/test_worklogs_summary_resource.py b/tests/v2/test_worklogs_summary_resource.py new file mode 100644 index 0000000..68d4361 --- /dev/null +++ b/tests/v2/test_worklogs_summary_resource.py @@ -0,0 +1,44 @@ +"""Offline coverage for `ProjectWorklogs`: a read-only endpoint returning a bare JSON array, not a +paginated envelope.""" + +from __future__ import annotations + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.worklogs import ProjectWorklogs +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2" + + +@pytest.fixture +def worklogs(config: Configuration) -> ProjectWorklogs: + return ProjectWorklogs(V2Transport(config), slug="acme", project_id="ENG") + + +@responses.activate +def test_worklogs_summary_parses_bare_array(worklogs: ProjectWorklogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/worklogs/summary/", + json=[ + {"work_item_id": "wi-1", "duration": 120}, + {"work_item_id": "wi-2", "duration": 45}, + ], + ) + + rows = worklogs.summary() + + assert [row.duration for row in rows] == [120, 45] + assert rows[0].work_item_id == "wi-1" + + +@responses.activate +def test_worklogs_summary_empty_project_returns_empty_list(worklogs: ProjectWorklogs) -> None: + responses.get( + f"{BASE}/workspaces/acme/projects/ENG/worklogs/summary/", + json=[], + ) + + assert worklogs.summary() == [] diff --git a/tests/v2/test_workspace_work_items_resource.py b/tests/v2/test_workspace_work_items_resource.py new file mode 100644 index 0000000..b0420c4 --- /dev/null +++ b/tests/v2/test_workspace_work_items_resource.py @@ -0,0 +1,81 @@ +"""Offline coverage for `WorkspaceWorkItems`; carries `retrieve_by_identifier`, fetching a work +item by its human-readable key with no project id.""" + +import pytest +import responses + +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.work_items.workspace import WorkspaceWorkItems +from plane.config import Configuration + +BASE = "https://api.example.com/api/v2/workspaces/acme/work-items" + + +@pytest.fixture +def workspace_work_items(config: Configuration) -> WorkspaceWorkItems: + return WorkspaceWorkItems(V2Transport(config), slug="acme") + + +@responses.activate +def test_list_hits_the_workspace_level_path_not_the_project_one( + workspace_work_items: WorkspaceWorkItems, +) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}}, + ) + + page = workspace_work_items.list() + + assert page.data[0].id == "1" + assert "projects" not in responses.calls[0].request.url + + +@responses.activate +def test_list_passes_expand_and_filters(workspace_work_items: WorkspaceWorkItems) -> None: + responses.get(f"{BASE}/", json={"data": [], "pagination": {"style": "offset"}}) + + workspace_work_items.list(expand=["state"], project_id="proj-1") + + query = responses.calls[0].request.url + assert "expand=state" in query + assert "project_id=proj-1" in query + + +def test_list_rejects_unknown_expand_before_the_request( + workspace_work_items: WorkspaceWorkItems, +) -> None: + with pytest.raises(ValueError, match="bogus"): + workspace_work_items.list(expand=["bogus"]) + + +@responses.activate +def test_iterate_follows_pages(workspace_work_items: WorkspaceWorkItems) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "1"}], "pagination": {"style": "offset"}, "next": 1}, + ) + responses.get( + f"{BASE}/", + json={"data": [{"id": "2"}], "pagination": {"style": "offset"}, "next": None}, + ) + + rows = list(workspace_work_items.iterate()) + + assert [row.id for row in rows] == ["1", "2"] + + +@responses.activate +def test_retrieve_by_identifier_is_the_readable_key_lookup( + workspace_work_items: WorkspaceWorkItems, +) -> None: + responses.get( + f"{BASE}/ENG-12/", + json={"id": "wi-1", "identifier": "ENG-12"}, + ) + + row = workspace_work_items.retrieve_by_identifier("ENG-12") + + assert row.identifier == "ENG-12" + # No project id anywhere in the URL -- the whole point of this lookup. + assert "projects" not in responses.calls[0].request.url