[PATCH v3 08/10] api: add patch comments detail endpoint and respective tests

Raxel Gutierrez raxel at google.com
Fri Aug 13 15:31:25 AEST 2021


Add new endpoint for patch comments at api/.../comments/<comment_id>.
The endpoint will make it possible to use the REST API to update the new
`addressed` field for individual patch comments with JavaScript on the
client side. In the process of these changes, clean up use of the
CurrentPatchDefault context so that it exists in base.py and can be used
throughout the API (e.g. Check and Comment REST endpoints).

Add the OpenAPI definition of the new endpoint and upgrade API version
to v1.3 to reflect the new endpoint as minor change for semantic
versioning.

Add tests for the new api/.../comments/<comment_id> endpoint that takes
GET, PATCH, and PUT requests. The tests cover retrieval and update
requests and handle calls from the various API versions. Also, they
handle permissions for update requests on the new `addressed` field and
invalid update values for the `addressed` field.

Add `addressed` field to create_patch_comment helper in api tests
utils.py.

Signed-off-by: Raxel Gutierrez <raxel at google.com>
---
 docs/api/schemas/generate-schemas.py   |    4 +-
 docs/api/schemas/latest/patchwork.yaml |   93 +-
 docs/api/schemas/patchwork.j2          |   97 +
 docs/api/schemas/v1.3/patchwork.yaml   | 2704 ++++++++++++++++++++++++
 patchwork/api/base.py                  |   24 +-
 patchwork/api/check.py                 |   20 +-
 patchwork/api/comment.py               |   70 +-
 patchwork/tests/api/test_comment.py    |  199 +-
 patchwork/urls.py                      |   15 +-
 9 files changed, 3171 insertions(+), 55 deletions(-)
 create mode 100644 docs/api/schemas/v1.3/patchwork.yaml

diff --git a/docs/api/schemas/generate-schemas.py b/docs/api/schemas/generate-schemas.py
index a0c1e45..3a436a1 100755
--- a/docs/api/schemas/generate-schemas.py
+++ b/docs/api/schemas/generate-schemas.py
@@ -14,8 +14,8 @@ except ImportError:
     yaml = None
 
 ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
-VERSIONS = [(1, 0), (1, 1), (1, 2), None]
-LATEST_VERSION = (1, 2)
+VERSIONS = [(1, 0), (1, 1), (1, 2), (1, 3), None]
+LATEST_VERSION = (1, 3)
 
 
 def generate_schemas():
diff --git a/docs/api/schemas/latest/patchwork.yaml b/docs/api/schemas/latest/patchwork.yaml
index a8910a7..0d56b93 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -13,7 +13,7 @@ info:
   license:
     name: GPL v2 License
     url: https://www.gnu.org/licenses/gpl-2.0.html
-  version: '1.2'
+  version: '1.3'
 paths:
   /api/:
     get:
@@ -635,6 +635,72 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - comments
+  /api/patches/{patch_id}/comments/{comment_id}/:
+    parameters:
+      - in: path
+        name: patch_id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: Patch ID
+          type: integer
+      - in: path
+        name: comment_id
+        description: A unique integer value identifying this comment.
+        required: true
+        schema:
+          title: Comment ID
+          type: integer
+    get:
+      description: Show a patch comment.
+      operationId: patch_comments_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+    patch:
+      description: Update a patch comment (partial).
+      operationId: patch_comments_partial_update
+      requestBody:
+        $ref: '#/components/requestBodies/Comment'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorCommentUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
   /api/patches/{patch_id}/checks/:
     parameters:
       - in: path
@@ -1242,6 +1308,12 @@ components:
         application/x-www-form-urlencoded:
           schema:
             $ref: '#/components/schemas/CheckCreate'
+    Comment:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/CommentUpdate'
     Patch:
       required: true
       content:
@@ -1528,6 +1600,15 @@ components:
               additionalProperties:
                 type: string
           readOnly: true
+        addressed:
+          title: Addressed
+          type: boolean
+    CommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: boolean
     CoverList:
       type: object
       properties:
@@ -1712,9 +1793,11 @@ components:
                 previous_relation:
                   title: Previous relation
                   type: string
+                  nullable: true
                 current_relation:
                   title: Current relation
                   type: string
+                  nullable: true
     EventPatchDelegated:
       allOf:
         - $ref: '#/components/schemas/EventBase'
@@ -2555,6 +2638,14 @@ components:
           items:
             type: string
           readOnly: true
+    ErrorCommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: array
+          items:
+            type: string
     ErrorPatchUpdate:
       type: object
       properties:
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index af20743..9c159e7 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -656,6 +656,74 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - comments
+{% if version >= (1, 3) %}
+  /api/{{ version_url }}patches/{patch_id}/comments/{comment_id}/:
+    parameters:
+      - in: path
+        name: patch_id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: Patch ID
+          type: integer
+      - in: path
+        name: comment_id
+        description: A unique integer value identifying this comment.
+        required: true
+        schema:
+          title: Comment ID
+          type: integer
+    get:
+      description: Show a patch comment.
+      operationId: patch_comments_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+    patch:
+      description: Update a patch comment (partial).
+      operationId: patch_comments_partial_update
+      requestBody:
+        $ref: '#/components/requestBodies/Comment'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorCommentUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+{% endif %}
   /api/{{ version_url }}patches/{patch_id}/checks/:
     parameters:
       - in: path
@@ -1277,6 +1345,14 @@ components:
         application/x-www-form-urlencoded:
           schema:
             $ref: '#/components/schemas/CheckCreate'
+{% if version >= (1, 3) %}
+    Comment:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/CommentUpdate'
+{% endif %}
     Patch:
       required: true
       content:
@@ -1586,6 +1662,17 @@ components:
               additionalProperties:
                 type: string
           readOnly: true
+{% if version >= (1, 3) %}
+        addressed:
+          title: Addressed
+          type: boolean
+    CommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: boolean
+{% endif %}
     CoverList:
       type: object
       properties:
@@ -2659,6 +2746,16 @@ components:
           items:
             type: string
           readOnly: true
+{% if version >= (1, 3) %}
+    ErrorCommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: array
+          items:
+            type: string
+{% endif %}
     ErrorPatchUpdate:
       type: object
       properties:
diff --git a/docs/api/schemas/v1.3/patchwork.yaml b/docs/api/schemas/v1.3/patchwork.yaml
new file mode 100644
index 0000000..fdf131c
--- /dev/null
+++ b/docs/api/schemas/v1.3/patchwork.yaml
@@ -0,0 +1,2704 @@
+# DO NOT EDIT THIS FILE. It is generated from a template. Changes should be
+# proposed against the template and updated files generated using the
+# 'generate-schemas.py' tool
+---
+openapi: '3.0.0'
+info:
+  title: Patchwork API
+  description: >
+    Patchwork is a web-based patch tracking system designed to facilitate the
+    contribution and management of contributions to an open-source project.
+  contact:
+    email: patchwork at lists.ozlabs.org
+  license:
+    name: GPL v2 License
+    url: https://www.gnu.org/licenses/gpl-2.0.html
+  version: '1.3'
+paths:
+  /api/1.3/:
+    get:
+      description: List API resources.
+      operationId: api_list
+      parameters: []
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Index'
+      tags:
+        - api
+  /api/1.3/bundles/:
+    get:
+      description: List bundles.
+      operationId: bundles_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - in: query
+          name: project
+          description: An ID or linkname of a project to filter bundles by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: owner
+          description: An ID or username of a user to filter bundles by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: public
+          description: Show only public (`true`) or private (`false`) bundles.
+          schema:
+            title: ''
+            type: string
+            enum:
+              - 'true'
+              - 'false'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Bundle'
+      tags:
+        - bundles
+    post:
+      description: Create a bundle.
+      operationId: bundles_create
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Bundle'
+      responses:
+        '201':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Bundle'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorBundleCreateUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - bundles
+  /api/1.3/bundles/{id}/:
+    parameters:
+      - in: path
+        name: id
+        required: true
+        description: A unique integer value identifying this bundle.
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a bundle.
+      operationId: bundles_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Bundle'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - bundles
+    patch:
+      description: Update a bundle (partial).
+      operationId: bundles_partial_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Bundle'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Bundle'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorBundleCreateUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - bundles
+    put:
+      description: Update a bundle.
+      operationId: bundles_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Bundle'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Bundle'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorBundleCreateUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - bundles
+  /api/1.3/covers/:
+    get:
+      description: List cover letters.
+      operationId: covers_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - $ref: '#/components/parameters/BeforeFilter'
+        - $ref: '#/components/parameters/SinceFilter'
+        - in: query
+          name: project
+          description: >
+            An ID or linkname of a project to filter cover letters by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: series
+          description: An ID of a series to filter cover letters by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: submitter
+          description: >
+            An ID or email address of a person to filter cover letters by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: msgid
+          description: >
+            The cover message-id as a case-sensitive string, without leading or
+            trailing angle brackets, to filter by.
+          schema:
+            title: ''
+            type: string
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/CoverList'
+      tags:
+        - covers
+  /api/1.3/covers/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this cover letter.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a cover letter.
+      operationId: covers_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/CoverDetail'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - covers
+  /api/1.3/covers/{id}/comments/:
+    parameters:
+      - in: path
+        name: id
+        description: >
+          A unique integer value identifying the parent cover letter.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: List comments
+      operationId: cover_comments_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Comment'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+  /api/1.3/events/:
+    get:
+      description: List events.
+      operationId: events_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - $ref: '#/components/parameters/BeforeFilter'
+        - $ref: '#/components/parameters/SinceFilter'
+        - in: query
+          name: project
+          description: An ID or linkname of a project to filter events by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: category
+          description: An event category to filter events by.
+          schema:
+            title: ''
+            type: string
+            enum:
+              - cover-created
+              - patch-created
+              - patch-completed
+              - patch-state-changed
+              - patch-relation-changed
+              - patch-delegated
+              - check-created
+              - series-created
+              - series-completed
+        - in: query
+          name: series
+          description: An ID of a series to filter events by.
+          schema:
+            title: ''
+            type: integer
+        - in: query
+          name: patch
+          description: An ID of a patch to filter events by.
+          schema:
+            title: ''
+            type: integer
+        - in: query
+          name: cover
+          description: An ID of a cover letter to filter events by.
+          schema:
+            title: ''
+            type: integer
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  anyOf:
+                    - $ref: '#/components/schemas/EventCoverCreated'
+                    - $ref: '#/components/schemas/EventPatchCreated'
+                    - $ref: '#/components/schemas/EventPatchCompleted'
+                    - $ref: '#/components/schemas/EventPatchStateChanged'
+                    - $ref: '#/components/schemas/EventPatchRelationChanged'
+                    - $ref: '#/components/schemas/EventPatchDelegated'
+                    - $ref: '#/components/schemas/EventCheckCreated'
+                    - $ref: '#/components/schemas/EventSeriesCreated'
+                    - $ref: '#/components/schemas/EventSeriesCompleted'
+                  discriminator:
+                    propertyName: category
+                    mapping:
+                      cover-created: '#/components/schemas/EventCoverCreated'
+                      patch-created: '#/components/schemas/EventPatchCreated'
+                      patch-completed: >
+                        '#/components/schemas/EventPatchCompleted'
+                      patch-state-changed: >
+                        '#/components/schemas/EventPatchStateChanged'
+                      patch-relation-changed: >
+                        '#/components/schemas/EventPatchRelationChanged'
+                      patch-delegated: >
+                        '#/components/schemas/EventPatchDelegated'
+                      check-created: '#/components/schemas/EventCheckCreated'
+                      series-created: '#/components/schemas/EventSeriesCreated'
+                      series-completed: >
+                        '#/components/schemas/EventSeriesCompleted'
+      tags:
+        - events
+  /api/1.3/patches/:
+    get:
+      description: List patches.
+      operationId: patches_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - $ref: '#/components/parameters/BeforeFilter'
+        - $ref: '#/components/parameters/SinceFilter'
+        - in: query
+          name: project
+          description: An ID or linkname of a project to filter patches by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: series
+          description: An ID of a series to filter patches by.
+          schema:
+            title: ''
+            type: integer
+        - in: query
+          name: submitter
+          description: >
+            An ID or email address of a person to filter patches by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: delegate
+          description: An ID or username of a user to filter patches by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: state
+          description: A slug representation of a state to filter patches by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: archived
+          description: >
+            Show only archived (`true`) or non-archived (`false`) patches.
+          schema:
+            title: ''
+            type: string
+            enum:
+              - 'true'
+              - 'false'
+        - in: query
+          name: hash
+          description: >
+            The patch hash as a case-insensitive hexadecimal string, to filter by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: msgid
+          description: >
+            The patch message-id as a case-sensitive string, without leading or
+            trailing angle brackets, to filter by.
+          schema:
+            title: ''
+            type: string
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/PatchList'
+      tags:
+        - patches
+  /api/1.3/patches/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this patch.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a patch.
+      operationId: patches_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PatchDetail'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - patches
+    patch:
+      description: Update a patch (partial).
+      operationId: patches_partial_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Patch'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PatchDetail'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorPatchUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '409':
+          description: Conflict
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - patches
+    put:
+      description: Update a patch.
+      operationId: patches_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Patch'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PatchDetail'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorPatchUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '409':
+          description: Conflict
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - patches
+  /api/1.3/patches/{id}/comments/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: List comments
+      operationId: patch_comments_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Comment'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+  /api/1.3/patches/{patch_id}/comments/{comment_id}/:
+    parameters:
+      - in: path
+        name: patch_id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: Patch ID
+          type: integer
+      - in: path
+        name: comment_id
+        description: A unique integer value identifying this comment.
+        required: true
+        schema:
+          title: Comment ID
+          type: integer
+    get:
+      description: Show a patch comment.
+      operationId: patch_comments_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+    patch:
+      description: Update a patch comment (partial).
+      operationId: patch_comments_partial_update
+      requestBody:
+        $ref: '#/components/requestBodies/Comment'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Comment'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorCommentUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - comments
+  /api/1.3/patches/{patch_id}/checks/:
+    parameters:
+      - in: path
+        name: patch_id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: Patch ID
+          type: integer
+    get:
+      description: List checks.
+      operationId: checks_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - $ref: '#/components/parameters/BeforeFilter'
+        - $ref: '#/components/parameters/SinceFilter'
+        - in: query
+          name: user
+          description: An ID or username of a user to filter checks by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: state
+          description: A check state to filter checks by.
+          schema:
+            title: ''
+            type: string
+            enum:
+              - pending
+              - success
+              - warning
+              - fail
+        - in: query
+          name: context
+          description: A check context to filter checks by.
+          schema:
+            title: ''
+            type: string
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Check'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - checks
+    post:
+      description: Create a check.
+      operationId: checks_create
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Check'
+      responses:
+        '201':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Check'
+        '400':
+          description: Invalid Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorCheckCreate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - checks
+  /api/1.3/patches/{patch_id}/checks/{check_id}/:
+    parameters:
+      - in: path
+        name: patch_id
+        description: A unique integer value identifying the parent patch.
+        required: true
+        schema:
+          title: Patch ID
+          type: integer
+      - in: path
+        name: check_id
+        description: A unique integer value identifying this check.
+        required: true
+        schema:
+          title: Check ID
+          type: integer
+    get:
+      description: Show a check.
+      operationId: checks_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Check'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - checks
+  /api/1.3/people/:
+    get:
+      description: List people.
+      operationId: people_list
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Person'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - people
+  /api/1.3/people/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this person.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a person.
+      operationId: people_read
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Person'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - people
+  /api/1.3/projects/:
+    get:
+      description: List projects.
+      operationId: projects_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Project'
+      tags:
+        - projects
+  /api/1.3/projects/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this project.
+        required: true
+        schema:
+          title: ID
+          # TODO: Add regex?
+          type: string
+    get:
+      description: Show a project.
+      operationId: projects_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Project'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - projects
+    patch:
+      description: Update a project (partial).
+      operationId: projects_partial_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Project'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Project'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorProjectUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - projects
+    put:
+      description: Update a project.
+      operationId: projects_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Project'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Project'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorProjectUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - projects
+  /api/1.3/series/:
+    get:
+      description: List series.
+      operationId: series_list
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+        - $ref: '#/components/parameters/BeforeFilter'
+        - $ref: '#/components/parameters/SinceFilter'
+        - in: query
+          name: submitter
+          description: An ID or email address of a person to filter series by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: project
+          description: An ID or linkname of a project to filter series by.
+          schema:
+            title: ''
+            type: string
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Series'
+      tags:
+        - series
+  /api/1.3/series/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this series.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a series.
+      operationId: series_read
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
+  /api/1.3/users/:
+    get:
+      description: List users.
+      operationId: users_list
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+        - $ref: '#/components/parameters/Search'
+      responses:
+        '200':
+          description: ''
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/User'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - users
+  /api/1.3/users/{id}/:
+    parameters:
+      - in: path
+        name: id
+        description: A unique integer value identifying this user.
+        required: true
+        schema:
+          title: ID
+          type: integer
+    get:
+      description: Show a user.
+      operationId: users_read
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/UserDetail'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - users
+    patch:
+      description: Update a user (partial).
+      operationId: users_partial_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/User'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/UserDetail'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorUserUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - users
+    put:
+      description: Update a user.
+      operationId: users_update
+#      security:
+#        - basicAuth: []
+#        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/User'
+      responses:
+        '200':
+          description: ''
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/UserDetail'
+        '400':
+          description: Bad request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorUserUpdate'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: Not found
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - users
+components:
+  securitySchemes:
+    basicAuth:
+      type: http
+      scheme: basic
+    apiKeyAuth:
+      type: http
+      scheme: bearer
+  parameters:
+    Page:
+      in: query
+      name: page
+      description: A page number within the paginated result set.
+      schema:
+        title: Page
+        type: integer
+    PageSize:
+      in: query
+      name: per_page
+      description: Number of results to return per page.
+      schema:
+        title: Page size
+        type: integer
+    Order:
+      in: query
+      name: order
+      description: Which field to use when ordering the results.
+      schema:
+        title: Ordering
+        type: string
+    Search:
+      in: query
+      name: q
+      description: A search term.
+      schema:
+        title: Search
+        type: string
+    BeforeFilter:
+      in: query
+      name: before
+      description: Latest date-time to retrieve results for.
+      schema:
+        title: ''
+        type: string
+    SinceFilter:
+      in: query
+      name: since
+      description: Earliest date-time to retrieve results for.
+      schema:
+        title: ''
+        type: string
+  headers:
+    Link:
+      description: >
+        Links to related resources, in the format defined by
+        [RFC 5988](https://tools.ietf.org/html/rfc5988#section-5).
+        This will include a link with relation type `next` to the
+        next page, if there is a next page.
+      schema:
+        type: string
+  requestBodies:
+    Bundle:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/BundleCreateUpdate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/BundleCreateUpdate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/BundleCreateUpdate'
+    Check:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/CheckCreate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/CheckCreate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/CheckCreate'
+    Comment:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/CommentUpdate'
+    Patch:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/PatchUpdate'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/PatchUpdate'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/PatchUpdate'
+    Project:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/Project'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/Project'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/Project'
+    User:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/UserDetail'
+        multipart/form-data:
+          schema:
+            $ref: '#/components/schemas/UserDetail'
+        application/x-www-form-urlencoded:
+          schema:
+            $ref: '#/components/schemas/UserDetail'
+  schemas:
+    Index:
+      type: object
+      properties:
+        bundles:
+          title: Bundles URL
+          type: string
+          format: uri
+          readOnly: true
+        covers:
+          title: Covers URL
+          type: string
+          format: uri
+          readOnly: true
+        events:
+          title: Events URL
+          type: string
+          format: uri
+          readOnly: true
+        patches:
+          title: Patches URL
+          type: string
+          format: uri
+          readOnly: true
+        people:
+          title: People URL
+          type: string
+          format: uri
+          readOnly: true
+        projects:
+          title: Projects URL
+          type: string
+          format: uri
+          readOnly: true
+        users:
+          title: Users URL
+          type: string
+          format: uri
+          readOnly: true
+        series:
+          title: Series URL
+          type: string
+          format: uri
+          readOnly: true
+    Bundle:
+      required:
+        - name
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        project:
+          $ref: '#/components/schemas/ProjectEmbedded'
+        name:
+          title: Name
+          type: string
+          minLength: 1
+          maxLength: 50
+        owner:
+          type: object
+          title: Owner
+          readOnly: true
+          nullable: false
+          allOf:
+            - $ref: '#/components/schemas/UserEmbedded'
+        patches:
+          title: Patches
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+          uniqueItems: true
+        public:
+          title: Public
+          type: boolean
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+    BundleCreateUpdate:
+      type: object
+      required:
+        - name
+      properties:
+        name:
+          title: Name
+          type: string
+          minLength: 1
+          maxLength: 50
+        patches:
+          title: Patches
+          type: array
+          items:
+            type: integer
+          uniqueItems: true
+        public:
+          title: Public
+          type: boolean
+    Check:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: Url
+          type: string
+          format: uri
+          readOnly: true
+        user:
+          $ref: '#/components/schemas/UserEmbedded'
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        state:
+          title: State
+          description: The state of the check.
+          type: string
+          enum:
+            - pending
+            - success
+            - warning
+            - fail
+        target_url:
+          title: Target URL
+          description: >
+            The target URL to associate with this check. This should be
+            specific to the patch.
+          type: string
+          format: uri
+          maxLength: 200
+          nullable: true
+        context:
+          title: Context
+          description: >
+            A label to discern check from checks of other testing systems.
+          type: string
+          pattern: ^[-a-zA-Z0-9_]+$
+          minLength: 1
+          maxLength: 255
+        description:
+          title: Description
+          description: A brief description of the check.
+          type: string
+          nullable: true
+    CheckCreate:
+      type: object
+      required:
+       - state
+      properties:
+        state:
+          title: State
+          description: The state of the check.
+          type: string
+          enum:
+            - pending
+            - success
+            - warning
+            - fail
+        target_url:
+          title: Target URL
+          description:
+            The target URL to associate with this check. This should be
+            specific to the patch.
+          type: string
+          format: uri
+          maxLength: 200
+          nullable: true
+        context:
+          title: Context
+          description: >
+            A label to discern check from checks of other testing systems.
+          type: string
+          pattern: ^[-a-zA-Z0-9_]+$
+          minLength: 1
+          maxLength: 255
+        description:
+          title: Description
+          description: A brief description of the check.
+          type: string
+          nullable: true
+    Comment:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        msgid:
+          title: Message ID
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        list_archive_url:
+          title: List archive URL
+          type: string
+          readOnly: true
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        subject:
+          title: Subject
+          type: string
+          readOnly: true
+        submitter:
+          type: object
+          title: Submitter
+          allOf:
+            - $ref: '#/components/schemas/PersonEmbedded'
+        content:
+          title: Content
+          type: string
+          readOnly: true
+          minLength: 1
+        headers:
+          title: Headers
+          anyOf:
+            - type: object
+              additionalProperties:
+                type: array
+                items:
+                  type: string
+            - type: object
+              additionalProperties:
+                type: string
+          readOnly: true
+        addressed:
+          title: Addressed
+          type: boolean
+    CommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: boolean
+    CoverList:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        project:
+          $ref: '#/components/schemas/ProjectEmbedded'
+        msgid:
+          title: Message ID
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        list_archive_url:
+          title: List archive URL
+          type: string
+          readOnly: true
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        submitter:
+          type: object
+          title: Submitter
+          readOnly: true
+          allOf:
+            - $ref: '#/components/schemas/PersonEmbedded'
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+        series:
+          type: array
+          items:
+            $ref: '#/components/schemas/SeriesEmbedded'
+          readOnly: true
+        comments:
+          title: Comments
+          type: string
+          format: uri
+          readOnly: true
+    CoverDetail:
+      allOf:
+        - $ref: '#/components/schemas/CoverList'
+        - properties:
+            headers:
+              title: Headers
+              anyOf:
+                - type: object
+                  additionalProperties:
+                    type: array
+                    items:
+                      type: string
+                - type: object
+                  additionalProperties:
+                    type: string
+              readOnly: true
+            content:
+              title: Content
+              type: string
+              readOnly: true
+              minLength: 1
+    EventBase:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        category:
+          title: Category
+          description: The category of the event.
+          type: string
+          readOnly: true
+        project:
+          $ref: '#/components/schemas/ProjectEmbedded'
+        date:
+          title: Date
+          description: The time this event was created.
+          type: string
+          format: iso8601
+          readOnly: true
+        actor:
+          type: object
+          title: Actor
+          description: The user that caused/created this event.
+          readOnly: true
+          nullable: true
+          allOf:
+            - $ref: '#/components/schemas/UserEmbedded'
+        payload:
+          type: object
+    EventCoverCreated:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - cover-created
+            payload:
+              properties:
+                cover:
+                  $ref: '#/components/schemas/CoverEmbedded'
+    EventPatchCreated:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - patch-created
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+    EventPatchCompleted:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - patch-completed
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+                series:
+                  $ref: '#/components/schemas/SeriesEmbedded'
+    EventPatchStateChanged:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - patch-state-changed
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+                previous_state:
+                  title: Previous state
+                  type: string
+                current_state:
+                  title: Current state
+                  type: string
+    EventPatchRelationChanged:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - patch-relation-changed
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+                previous_relation:
+                  title: Previous relation
+                  type: string
+                  nullable: true
+                current_relation:
+                  title: Current relation
+                  type: string
+                  nullable: true
+    EventPatchDelegated:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - patch-delegated
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+                previous_delegate:
+                  $ref: '#/components/schemas/UserEmbedded'
+                current_delegate:
+                  $ref: '#/components/schemas/UserEmbedded'
+    EventCheckCreated:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - check-created
+            payload:
+              properties:
+                patch:
+                  $ref: '#/components/schemas/PatchEmbedded'
+                check:
+                  $ref: '#/components/schemas/CheckEmbedded'
+    EventSeriesCreated:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - series-created
+            payload:
+              properties:
+                series:
+                  $ref: '#/components/schemas/SeriesEmbedded'
+    EventSeriesCompleted:
+      allOf:
+        - $ref: '#/components/schemas/EventBase'
+        - type: object
+          properties:
+            category:
+              enum:
+                - series-completed
+            payload:
+              properties:
+                series:
+                  $ref: '#/components/schemas/SeriesEmbedded'
+    PatchList:
+      required:
+        - state
+        - delegate
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        project:
+          $ref: '#/components/schemas/ProjectEmbedded'
+        msgid:
+          title: Message ID
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        list_archive_url:
+          title: List archive URL
+          type: string
+          readOnly: true
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        commit_ref:
+          title: Commit ref
+          type: string
+          maxLength: 255
+          nullable: true
+        pull_url:
+          title: Pull URL
+          type: string
+          format: uri
+          maxLength: 255
+          nullable: true
+        state:
+          title: State
+          type: string
+        archived:
+          title: Archived
+          type: boolean
+        hash:
+          title: Hash
+          type: string
+          readOnly: true
+          minLength: 1
+        submitter:
+          type: object
+          title: Submitter
+          readOnly: true
+          allOf:
+            - $ref: '#/components/schemas/PersonEmbedded'
+        delegate:
+          type: object
+          title: Delegate
+          nullable: true
+          readOnly: true
+          allOf:
+            - $ref: '#/components/schemas/UserEmbedded'
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+        series:
+          type: array
+          items:
+            $ref: '#/components/schemas/SeriesEmbedded'
+          readOnly: true
+        comments:
+          title: Comments
+          type: string
+          format: uri
+          readOnly: true
+        check:
+          title: Check
+          type: string
+          readOnly: true
+          enum:
+            - pending
+            - success
+            - warning
+            - fail
+        checks:
+          title: Checks
+          type: string
+          format: uri
+          readOnly: true
+        tags:
+          title: Tags
+          type: object
+          additionalProperties:
+            type: string
+          readOnly: true
+        related:
+          title: Relations
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+    PatchDetail:
+      allOf:
+        - $ref: '#/components/schemas/PatchList'
+        - properties:
+            headers:
+              title: Headers
+              anyOf:
+                - type: object
+                  additionalProperties:
+                    type: array
+                    items:
+                      type: string
+                - type: object
+                  additionalProperties:
+                    type: string
+              readOnly: true
+            content:
+              title: Content
+              type: string
+              readOnly: true
+              minLength: 1
+            diff:
+              title: Diff
+              type: string
+              readOnly: true
+              minLength: 1
+            prefixes:
+              title: Prefixes
+              type: array
+              items:
+                type: string
+              readOnly: true
+    PatchUpdate:
+      type: object
+      properties:
+        commit_ref:
+          title: Commit ref
+          type: string
+          maxLength: 255
+          nullable: true
+        pull_url:
+          title: Pull URL
+          type: string
+          format: uri
+          maxLength: 255
+          nullable: true
+        state:
+          title: State
+          type: string
+        archived:
+          title: Archived
+          type: boolean
+        delegate:
+          title: Delegate
+          type: integer
+          nullable: true
+        related:
+          title: Relations
+          type: array
+          items:
+            type: integer
+    Person:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        email:
+          title: Email
+          type: string
+          format: email
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        user:
+          type: object
+          title: User
+          nullable: true
+          readOnly: true
+          allOf:
+            - $ref: '#/components/schemas/UserEmbedded'
+    Project:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        link_name:
+          title: Link name
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        list_id:
+          title: List ID
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 255
+        list_email:
+          title: List email
+          type: string
+          format: email
+          readOnly: true
+          minLength: 1
+          maxLength: 200
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          maxLength: 2000
+        scm_url:
+          title: SCM URL
+          type: string
+          format: uri
+          maxLength: 2000
+        webscm_url:
+          title: Web SCM URL
+          type: string
+          format: uri
+          maxLength: 2000
+        maintainers:
+          type: array
+          items:
+            $ref: '#/components/schemas/UserEmbedded'
+          readOnly: true
+          uniqueItems: true
+        subject_match:
+          title: Subject match
+          description: >
+            Regex to match the subject against if only part of emails sent to
+            the list belongs to this project. Will be used with IGNORECASE and
+            MULTILINE flags. If rules for more projects match the first one
+            returned from DB is chosen; empty field serves as a default for
+            every email which has no other match.
+          type: string
+          readOnly: true
+          maxLength: 64
+        list_archive_url:
+          title: List archive URL
+          type: string
+          format: uri
+          maxLength: 2000
+          nullable: true
+        list_archive_url_format:
+          title: List archive URL format
+          type: string
+          format: uri
+          maxLength: 2000
+          nullable: true
+          description: >
+            URL format for the list archive's Message-ID redirector. {} will be
+            replaced by the Message-ID.
+        commit_url_format:
+          title: Web SCM URL format for a particular commit
+          type: string
+    Series:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        project:
+          $ref: '#/components/schemas/ProjectEmbedded'
+        name:
+          title: Name
+          description: >
+            An optional name to associate with the series, e.g. "John's PCI
+            series".
+          type: string
+          maxLength: 255
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        submitter:
+          type: object
+          title: Submitter
+          readOnly: true
+          allOf:
+            - $ref: '#/components/schemas/PersonEmbedded'
+        version:
+          title: Version
+          description: >
+            Version of series as indicated by the subject prefix(es).
+          type: integer
+        total:
+          title: Total
+          description: >
+            Number of patches in series as indicated by the subject prefix(es).
+          type: integer
+          readOnly: true
+        received_total:
+          title: Received total
+          type: integer
+          readOnly: true
+        received_all:
+          title: Received all
+          type: boolean
+          readOnly: true
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+        cover_letter:
+          $ref: '#/components/schemas/CoverEmbedded'
+        patches:
+          title: Patches
+          type: array
+          items:
+            $ref: '#/components/schemas/PatchEmbedded'
+          readOnly: true
+          uniqueItems: true
+    User:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        username:
+          title: Username
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 150
+        first_name:
+          title: First name
+          type: string
+          maxLength: 30
+        last_name:
+          title: Last name
+          type: string
+          maxLength: 150
+        email:
+          title: Email address
+          type: string
+          format: email
+          readOnly: true
+          minLength: 1
+    UserDetail:
+      type: object
+      allOf:
+        - $ref: '#/components/schemas/User'
+        - type: object
+          properties:
+            settings:
+              type: object
+              properties:
+                send_email:
+                  title: Send email
+                  description: >
+                    Whether Patchwork should send email on your behalf.
+                    Only present and configurable for your account.
+                  type: boolean
+                items_per_page:
+                  title: Items per page
+                  description: >
+                    Number of items to display per page (web UI).
+                    Only present and configurable for your account.
+                  type: integer
+                show_ids:
+                  title: Show IDs
+                  description:
+                    Show click-to-copy IDs in the list view (web UI).
+                    Only present and configurable for your account.
+                  type: boolean
+    CheckEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: Url
+          type: string
+          format: uri
+          readOnly: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        state:
+          title: State
+          description: The state of the check.
+          type: string
+          readOnly: true
+          enum:
+            - pending
+            - success
+            - warning
+            - fail
+        target_url:
+          title: Target url
+          description: >
+            The target URL to associate with this check. This should be specific
+            to the patch.
+          type: string
+          format: uri
+          maxLength: 200
+          nullable: true
+          readOnly: true
+        context:
+          title: Context
+          description: >
+            A label to discern check from checks of other testing systems.
+          type: string
+          pattern: ^[-a-zA-Z0-9_]+$
+          maxLength: 255
+          minLength: 1
+          readOnly: true
+    CoverEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        msgid:
+          title: Message ID
+          type: string
+          readOnly: true
+          minLength: 1
+        list_archive_url:
+          title: List archive URL
+          type: string
+          readOnly: true
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+    PatchEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        msgid:
+          title: Message ID
+          type: string
+          readOnly: true
+          minLength: 1
+        list_archive_url:
+          title: List archive URL
+          type: string
+          readOnly: true
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+    PersonEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+        email:
+          title: Email
+          type: string
+          format: email
+          readOnly: true
+          minLength: 1
+    ProjectEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        name:
+          title: Name
+          type: string
+          readOnly: true
+          minLength: 1
+        link_name:
+          title: Link name
+          type: string
+          readOnly: true
+          maxLength: 255
+          minLength: 1
+        list_id:
+          title: List ID
+          type: string
+          readOnly: true
+          maxLength: 255
+          minLength: 1
+        list_email:
+          title: List email
+          type: string
+          format: email
+          readOnly: true
+          maxLength: 200
+          minLength: 1
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+          maxLength: 2000
+        scm_url:
+          title: SCM URL
+          type: string
+          format: uri
+          readOnly: true
+          maxLength: 2000
+        webscm_url:
+          title: WebSCM URL
+          type: string
+          format: uri
+          readOnly: true
+          maxLength: 2000
+        list_archive_url:
+          title: List archive URL
+          type: string
+          format: uri
+          maxLength: 2000
+          nullable: true
+        list_archive_url_format:
+          title: List archive URL format
+          type: string
+          format: uri
+          maxLength: 2000
+          nullable: true
+          description: >
+            URL format for the list archive's Message-ID redirector. {} will be
+            replaced by the Message-ID.
+        commit_url_format:
+          title: Web SCM URL format for a particular commit
+          type: string
+          readOnly: true
+    SeriesEmbedded:
+      type: object
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        name:
+          title: Name
+          description: >
+            An optional name to associate with the series, e.g. "John's PCI
+            series".
+          type: string
+          readOnly: true
+          maxLength: 255
+          nullable: true
+        date:
+          title: Date
+          type: string
+          format: iso8601
+          readOnly: true
+        version:
+          title: Version
+          description: >
+            Version of series as indicated by the subject prefix(es).
+          type: integer
+          readOnly: true
+        mbox:
+          title: Mbox
+          type: string
+          format: uri
+          readOnly: true
+    UserEmbedded:
+      type: object
+      nullable: true
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          type: string
+          format: uri
+          readOnly: true
+        username:
+          title: Username
+          type: string
+          readOnly: true
+          minLength: 1
+          maxLength: 150
+        first_name:
+          title: First name
+          type: string
+          maxLength: 30
+          readOnly: true
+        last_name:
+          title: Last name
+          type: string
+          maxLength: 150
+          readOnly: true
+        email:
+          title: Email address
+          type: string
+          format: email
+          readOnly: true
+          minLength: 1
+    Error:
+      type: object
+      properties:
+        detail:
+          title: Detail
+          type: string
+          readOnly: true
+    ErrorBundleCreateUpdate:
+      type: object
+      properties:
+        name:
+          title: Name
+          type: array
+          items:
+            type: string
+          readOnly: true
+        patches:
+          title: Patches
+          type: array
+          items:
+            type: string
+          readOnly: true
+        public:
+          title: Public
+          type: array
+          items:
+            type: string
+    ErrorCheckCreate:
+      type: object
+      properties:
+        state:
+          title: State
+          type: array
+          items:
+            type: string
+          readOnly: true
+        target_url:
+          title: Target URL
+          type: array
+          items:
+            type: string
+          readOnly: true
+        context:
+          title: Context
+          type: array
+          items:
+            type: string
+          readOnly: true
+        description:
+          title: Description
+          type: array
+          items:
+            type: string
+          readOnly: true
+    ErrorCommentUpdate:
+      type: object
+      properties:
+        addressed:
+          title: Addressed
+          type: array
+          items:
+            type: string
+    ErrorPatchUpdate:
+      type: object
+      properties:
+        state:
+          title: State
+          type: array
+          items:
+            type: string
+          readOnly: true
+        delegate:
+          title: Delegate
+          type: array
+          items:
+            type: string
+          readOnly: true
+        commit_ref:
+          title: Commit ref
+          type: array
+          items:
+            type: string
+          readOnly: true
+        archived:
+          title: Archived
+          type: array
+          items:
+            type: string
+          readOnly: true
+    ErrorProjectUpdate:
+      type: object
+      properties:
+        web_url:
+          title: Web URL
+          type: string
+          format: uri
+          readOnly: true
+        scm_url:
+          title: SCM URL
+          type: string
+          format: uri
+          readOnly: true
+        webscm_url:
+          title: Web SCM URL
+          type: string
+          format: uri
+          readOnly: true
+    ErrorUserUpdate:
+      type: object
+      properties:
+        first_name:
+          title: First name
+          type: string
+          readOnly: true
+        last_name:
+          title: First name
+          type: string
+          readOnly: true
diff --git a/patchwork/api/base.py b/patchwork/api/base.py
index 89a4311..856fbd3 100644
--- a/patchwork/api/base.py
+++ b/patchwork/api/base.py
@@ -3,6 +3,7 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+import rest_framework
 
 from django.conf import settings
 from django.shortcuts import get_object_or_404
@@ -15,6 +16,24 @@ from rest_framework.serializers import HyperlinkedModelSerializer
 from patchwork.api import utils
 
 
+DRF_VERSION = tuple(int(x) for x in rest_framework.__version__.split('.'))
+
+
+if DRF_VERSION > (3, 11):
+    class CurrentPatchDefault(object):
+        requires_context = True
+
+        def __call__(self, serializer_field):
+            return serializer_field.context['request'].patch
+else:
+    class CurrentPatchDefault(object):
+        def set_context(self, serializer_field):
+            self.patch = serializer_field.context['request'].patch
+
+        def __call__(self):
+            return self.patch
+
+
 class LinkHeaderPagination(PageNumberPagination):
     """Provide pagination based on rfc5988.
 
@@ -44,7 +63,10 @@ class LinkHeaderPagination(PageNumberPagination):
 
 
 class PatchworkPermission(permissions.BasePermission):
-    """This permission works for Project and Patch model objects"""
+    """
+    This permission works for Project, Patch, and PatchComment
+    model objects
+    """
     def has_object_permission(self, request, view, obj):
         # read only for everyone
         if request.method in permissions.SAFE_METHODS:
diff --git a/patchwork/api/check.py b/patchwork/api/check.py
index a6bf5f8..2049d2f 100644
--- a/patchwork/api/check.py
+++ b/patchwork/api/check.py
@@ -6,7 +6,6 @@
 from django.http import Http404
 from django.http.request import QueryDict
 from django.shortcuts import get_object_or_404
-import rest_framework
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.generics import ListCreateAPIView
 from rest_framework.generics import RetrieveAPIView
@@ -17,30 +16,13 @@ from rest_framework.serializers import ValidationError
 
 from patchwork.api.base import CheckHyperlinkedIdentityField
 from patchwork.api.base import MultipleFieldLookupMixin
+from patchwork.api.base import CurrentPatchDefault
 from patchwork.api.embedded import UserSerializer
 from patchwork.api.filters import CheckFilterSet
 from patchwork.models import Check
 from patchwork.models import Patch
 
 
-DRF_VERSION = tuple(int(x) for x in rest_framework.__version__.split('.'))
-
-
-if DRF_VERSION > (3, 11):
-    class CurrentPatchDefault(object):
-        requires_context = True
-
-        def __call__(self, serializer_field):
-            return serializer_field.context['request'].patch
-else:
-    class CurrentPatchDefault(object):
-        def set_context(self, serializer_field):
-            self.patch = serializer_field.context['request'].patch
-
-        def __call__(self):
-            return self.patch
-
-
 class CheckSerializer(HyperlinkedModelSerializer):
 
     url = CheckHyperlinkedIdentityField('api-check-detail')
diff --git a/patchwork/api/comment.py b/patchwork/api/comment.py
index 0c578b4..ab54e22 100644
--- a/patchwork/api/comment.py
+++ b/patchwork/api/comment.py
@@ -5,12 +5,17 @@
 
 import email.parser
 
+from django.shortcuts import get_object_or_404
 from django.http import Http404
 from rest_framework.generics import ListAPIView
+from rest_framework.generics import RetrieveUpdateAPIView
+from rest_framework.serializers import HiddenField
 from rest_framework.serializers import SerializerMethodField
 
 from patchwork.api.base import BaseHyperlinkedModelSerializer
+from patchwork.api.base import MultipleFieldLookupMixin
 from patchwork.api.base import PatchworkPermission
+from patchwork.api.base import CurrentPatchDefault
 from patchwork.api.embedded import PersonSerializer
 from patchwork.models import Cover
 from patchwork.models import CoverComment
@@ -66,15 +71,50 @@ class CoverCommentListSerializer(BaseCommentListSerializer):
         versioned_fields = BaseCommentListSerializer.Meta.versioned_fields
 
 
-class PatchCommentListSerializer(BaseCommentListSerializer):
+class PatchCommentSerializer(BaseCommentListSerializer):
+
+    patch = HiddenField(default=CurrentPatchDefault())
 
     class Meta:
         model = PatchComment
-        fields = BaseCommentListSerializer.Meta.fields
-        read_only_fields = fields
+        fields = BaseCommentListSerializer.Meta.fields + (
+            'patch', 'addressed')
+        read_only_fields = BaseCommentListSerializer.Meta.fields + ('patch', )
+        versioned_fields = {
+            '1.3': ('patch', 'addressed'),
+        }
+        extra_kwargs = {
+            'url': {'view_name': 'api-patch-comment-detail'}
+        }
         versioned_fields = BaseCommentListSerializer.Meta.versioned_fields
 
 
+class PatchCommentMixin(object):
+
+    permission_classes = (PatchworkPermission,)
+    serializer_class = PatchCommentSerializer
+
+    def get_object(self):
+        queryset = self.filter_queryset(self.get_queryset())
+        comment_id = self.kwargs['comment_id']
+        try:
+            obj = queryset.get(id=int(comment_id))
+        except (ValueError, PatchComment.DoesNotExist):
+            obj = get_object_or_404(queryset, linkname=comment_id)
+        self.kwargs['comment_id'] = obj.id
+        self.check_object_permissions(self.request, obj)
+        return obj
+
+    def get_queryset(self):
+        patch_id = self.kwargs['patch_id']
+        if not Patch.objects.filter(id=patch_id).exists():
+            raise Http404
+
+        return PatchComment.objects.filter(
+            patch=patch_id
+        ).select_related('submitter')
+
+
 class CoverCommentList(ListAPIView):
     """List cover comments"""
 
@@ -94,20 +134,24 @@ class CoverCommentList(ListAPIView):
         ).select_related('submitter')
 
 
-class PatchCommentList(ListAPIView):
-    """List comments"""
+class PatchCommentList(PatchCommentMixin, ListAPIView):
+    """List patch comments"""
 
-    permission_classes = (PatchworkPermission,)
-    serializer_class = PatchCommentListSerializer
     search_fields = ('subject',)
     ordering_fields = ('id', 'subject', 'date', 'submitter')
     ordering = 'id'
     lookup_url_kwarg = 'patch_id'
 
-    def get_queryset(self):
-        if not Patch.objects.filter(id=self.kwargs['patch_id']).exists():
-            raise Http404
 
-        return PatchComment.objects.filter(
-            patch=self.kwargs['patch_id']
-        ).select_related('submitter')
+class PatchCommentDetail(PatchCommentMixin, MultipleFieldLookupMixin,
+                         RetrieveUpdateAPIView):
+    """
+    get:
+    Show a patch comment.
+    patch:
+    Update a patch comment.
+    put:
+    Update a patch comment.
+    """
+    lookup_url_kwargs = ('patch_id', 'comment_id')
+    lookup_fields = ('patch_id', 'id')
diff --git a/patchwork/tests/api/test_comment.py b/patchwork/tests/api/test_comment.py
index 59450d8..f43d1c7 100644
--- a/patchwork/tests/api/test_comment.py
+++ b/patchwork/tests/api/test_comment.py
@@ -9,11 +9,16 @@ from django.conf import settings
 from django.urls import NoReverseMatch
 from django.urls import reverse
 
+from patchwork.models import PatchComment
 from patchwork.tests.api import utils
 from patchwork.tests.utils import create_cover
 from patchwork.tests.utils import create_cover_comment
 from patchwork.tests.utils import create_patch
 from patchwork.tests.utils import create_patch_comment
+from patchwork.tests.utils import create_maintainer
+from patchwork.tests.utils import create_project
+from patchwork.tests.utils import create_person
+from patchwork.tests.utils import create_user
 from patchwork.tests.utils import SAMPLE_CONTENT
 
 if settings.ENABLE_REST_API:
@@ -86,34 +91,40 @@ class TestCoverComments(utils.APITestCase):
 @unittest.skipUnless(settings.ENABLE_REST_API, 'requires ENABLE_REST_API')
 class TestPatchComments(utils.APITestCase):
     @staticmethod
-    def api_url(patch, version=None):
-        kwargs = {}
+    def api_url(patch, version=None, item=None):
+        kwargs = {'patch_id': patch.id}
         if version:
             kwargs['version'] = version
-        kwargs['patch_id'] = patch.id
+        if item is None:
+            return reverse('api-patch-comment-list', kwargs=kwargs)
+        kwargs['comment_id'] = item.id
+        return reverse('api-patch-comment-detail', kwargs=kwargs)
 
-        return reverse('api-patch-comment-list', kwargs=kwargs)
+    def setUp(self):
+        super(TestPatchComments, self).setUp()
+        self.project = create_project()
+        self.user = create_maintainer(self.project)
+        self.patch = create_patch(project=self.project)
 
     def assertSerialized(self, comment_obj, comment_json):
         self.assertEqual(comment_obj.id, comment_json['id'])
         self.assertEqual(comment_obj.submitter.id,
                          comment_json['submitter']['id'])
+        self.assertEqual(comment_obj.addressed, comment_json['addressed'])
         self.assertIn(SAMPLE_CONTENT, comment_json['content'])
 
     def test_list_empty(self):
         """List patch comments when none are present."""
-        patch = create_patch()
-        resp = self.client.get(self.api_url(patch))
+        resp = self.client.get(self.api_url(self.patch))
         self.assertEqual(status.HTTP_200_OK, resp.status_code)
         self.assertEqual(0, len(resp.data))
 
     @utils.store_samples('patch-comment-list')
     def test_list(self):
         """List patch comments."""
-        patch = create_patch()
-        comment = create_patch_comment(patch=patch)
+        comment = create_patch_comment(patch=self.patch)
 
-        resp = self.client.get(self.api_url(patch))
+        resp = self.client.get(self.api_url(self.patch))
         self.assertEqual(status.HTTP_200_OK, resp.status_code)
         self.assertEqual(1, len(resp.data))
         self.assertSerialized(comment, resp.data[0])
@@ -121,26 +132,180 @@ class TestPatchComments(utils.APITestCase):
 
     def test_list_version_1_1(self):
         """List patch comments using API v1.1."""
-        patch = create_patch()
-        comment = create_patch_comment(patch=patch)
+        comment = create_patch_comment(patch=self.patch)
 
-        resp = self.client.get(self.api_url(patch, version='1.1'))
+        resp = self.client.get(self.api_url(self.patch, version='1.1'))
         self.assertEqual(status.HTTP_200_OK, resp.status_code)
         self.assertEqual(1, len(resp.data))
         self.assertSerialized(comment, resp.data[0])
         self.assertNotIn('list_archive_url', resp.data[0])
 
     def test_list_version_1_0(self):
-        """List patch comments using API v1.0."""
-        patch = create_patch()
-        create_patch_comment(patch=patch)
+        """List patch comments using API v1.0.
 
-        # check we can't access comments using the old version of the API
+        Ensure we can't access comments using the old version of the API.
+        """
         with self.assertRaises(NoReverseMatch):
-            self.client.get(self.api_url(patch, version='1.0'))
+            self.client.get(self.api_url(self.patch, version='1.0'))
 
     def test_list_invalid_patch(self):
         """Ensure we get a 404 for a non-existent patch."""
         resp = self.client.get(
             reverse('api-patch-comment-list', kwargs={'patch_id': '99999'}))
         self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
+
+    @utils.store_samples('patch-comment-detail')
+    def test_detail(self):
+        """Show a patch comment."""
+        comment = create_patch_comment(patch=self.patch)
+
+        resp = self.client.get(self.api_url(self.patch, item=comment))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(comment, resp.data)
+
+    def test_detail_version_1_3(self):
+        """Show a patch comment using API v1.3."""
+        comment = create_patch_comment(patch=self.patch)
+
+        resp = self.client.get(
+            self.api_url(self.patch, version='1.3', item=comment))
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertSerialized(comment, resp.data)
+
+    def test_detail_version_1_2(self):
+        """Show a patch comment using API v1.2."""
+        comment = create_patch_comment(patch=self.patch)
+
+        with self.assertRaises(NoReverseMatch):
+            self.client.get(
+                self.api_url(self.patch, version='1.2', item=comment))
+
+    def test_detail_version_1_1(self):
+        """Show a patch comment using API v1.1."""
+        comment = create_patch_comment(patch=self.patch)
+
+        with self.assertRaises(NoReverseMatch):
+            self.client.get(
+                self.api_url(self.patch, version='1.1', item=comment))
+
+    def test_detail_version_1_0(self):
+        """Show a patch comment using API v1.0."""
+        comment = create_patch_comment(patch=self.patch)
+
+        with self.assertRaises(NoReverseMatch):
+            self.client.get(
+                self.api_url(self.patch, version='1.0', item=comment))
+
+    @utils.store_samples('patch-comment-detail-error-not-found')
+    def test_detail_invalid_patch(self):
+        """Ensure we handle non-existent patches."""
+        comment = create_patch_comment()
+        resp = self.client.get(
+            reverse('api-patch-comment-detail', kwargs={
+                'patch_id': '99999',
+                'comment_id': comment.id}
+            ),
+        )
+        self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
+
+    def _test_update(self, person, **kwargs):
+        submitter = kwargs.get('submitter', person)
+        patch = kwargs.get('patch', self.patch)
+        comment = create_patch_comment(submitter=submitter, patch=patch)
+
+        if kwargs.get('authenticate', True):
+            self.client.force_authenticate(user=person.user)
+        return self.client.patch(
+            self.api_url(patch, item=comment),
+            {'addressed': kwargs.get('addressed', True)},
+            validate_request=kwargs.get('validate_request', True)
+        )
+
+    @utils.store_samples('patch-comment-detail-update-authorized')
+    def test_update_authorized(self):
+        """Update an existing patch comment as an authorized user.
+
+        To be authorized users must meet at least one of the following:
+        - project maintainer, patch submitter, patch delegate, or
+          patch comment submitter
+
+        Ensure updates can only be performed by authorized users.
+        """
+        # Update as maintainer
+        person = create_person(user=self.user)
+        resp = self._test_update(person=person)
+        self.assertEqual(1, PatchComment.objects.all().count())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertTrue(resp.data['addressed'])
+
+        # Update as patch submitter
+        person = create_person(name='patch-submitter', user=create_user())
+        patch = create_patch(submitter=person)
+        resp = self._test_update(person=person, patch=patch)
+        self.assertEqual(2, PatchComment.objects.all().count())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertTrue(resp.data['addressed'])
+
+        # Update as patch delegate
+        person = create_person(name='patch-delegate', user=create_user())
+        patch = create_patch(delegate=person.user)
+        resp = self._test_update(person=person, patch=patch)
+        self.assertEqual(3, PatchComment.objects.all().count())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertTrue(resp.data['addressed'])
+
+        # Update as patch comment submitter
+        person = create_person(name='comment-submitter', user=create_user())
+        patch = create_patch()
+        resp = self._test_update(person=person, patch=patch, submitter=person)
+        self.assertEqual(4, PatchComment.objects.all().count())
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertTrue(resp.data['addressed'])
+
+    @utils.store_samples('patch-comment-detail-update-not-authorized')
+    def test_update_not_authorized(self):
+        """Update an existing patch comment when not signed in and not authorized.
+
+        To be authorized users must meet at least one of the following:
+        - project maintainer, patch submitter, patch delegate, or
+          patch comment submitter
+
+        Ensure updates can only be performed by authorized users.
+        """
+        person = create_person(user=self.user)
+        resp = self._test_update(person=person, authenticate=False)
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
+
+        person = create_person()  # normal user without edit permissions
+        resp = self._test_update(person=person)  # signed-in
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
+
+    @utils.store_samples('patch-comment-detail-update-error-bad-request')
+    def test_update_invalid_addressed(self):
+        """Update an existing patch comment using invalid values.
+
+        Ensure we handle invalid patch comment addressed values.
+        """
+        person = create_person(name='patch-submitter', user=create_user())
+        patch = create_patch(submitter=person)
+        resp = self._test_update(person=person,
+                                 patch=patch,
+                                 addressed='not-valid',
+                                 validate_request=False)
+        self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code)
+        self.assertFalse(
+            getattr(PatchComment.objects.all().first(), 'addressed')
+        )
+
+    def test_create_delete(self):
+        """Ensure creates and deletes aren't allowed"""
+        comment = create_patch_comment(patch=self.patch)
+        self.user.is_superuser = True
+        self.user.save()
+        self.client.force_authenticate(user=self.user)
+
+        resp = self.client.post(self.api_url(self.patch, item=comment))
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+        resp = self.client.delete(self.api_url(self.patch, item=comment))
+        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 1e6c12a..0c48727 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -343,12 +343,23 @@ if settings.ENABLE_REST_API:
         ),
     ]
 
+    api_1_3_patterns = [
+        path(
+            'patches/<patch_id>/comments/<comment_id>/',
+            api_comment_views.PatchCommentDetail.as_view(),
+            name='api-patch-comment-detail',
+        ),
+    ]
+
     urlpatterns += [
         re_path(
-            r'^api/(?:(?P<version>(1.0|1.1|1.2))/)?', include(api_patterns)
+            r'^api/(?:(?P<version>(1.0|1.1|1.2|1.3))/)?', include(api_patterns)
+        ),
+        re_path(
+            r'^api/(?:(?P<version>(1.1|1.2|1.3))/)?', include(api_1_1_patterns)
         ),
         re_path(
-            r'^api/(?:(?P<version>(1.1|1.2))/)?', include(api_1_1_patterns)
+            r'^api/(?:(?P<version>(1.3))/)?', include(api_1_3_patterns)
         ),
         # token change
         path(
-- 
2.33.0.rc1.237.g0d66db33f3-goog



More information about the Patchwork mailing list