From 656b43d1d76a18a5e10f3effdbc59ec2ae3ff3c6 Mon Sep 17 00:00:00 2001
From: Shrikanth Upadhayaya <shrik450@gmail.com>
Date: Thu, 5 Sep 2024 12:49:01 -0700
Subject: [PATCH] Initialize

---
 .github/dependabot.yml     |  10 ++
 .github/workflows/test.yml |  31 ++++++
 .gitignore                 |   1 +
 Dockerfile                 |   8 ++
 LICENSE.txt                |  21 ++++
 README.md                  |  87 +++++++++++++++++
 action.yml                 |  43 +++++++++
 post_dr_comment.py         | 191 +++++++++++++++++++++++++++++++++++++
 requirements-test.txt      |   1 +
 requirements.txt           |   3 +
 10 files changed, 396 insertions(+)
 create mode 100644 .github/dependabot.yml
 create mode 100644 .github/workflows/test.yml
 create mode 100644 .gitignore
 create mode 100644 Dockerfile
 create mode 100644 LICENSE.txt
 create mode 100644 README.md
 create mode 100644 action.yml
 create mode 100644 post_dr_comment.py
 create mode 100644 requirements-test.txt
 create mode 100644 requirements.txt

diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f5a8323
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+  - package-ecosystem: pip
+    directory: /
+    schedule:
+      interval: monthly
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: monthly
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..7657c07
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,31 @@
+name: Lint and test
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: ["**"]
+
+jobs:
+  test:
+    name: Test
+    runs-on: ubuntu-22.04
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11", "3.12"]
+
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install -r requirements-test.txt
+      - name: Check formatting
+        run: ruff format --diff .
+      - name: Lint with ruff
+        run: ruff check --target-version=py310 .
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b694934
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.venv
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0048727
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,8 @@
+FROM python:3.12-bookworm
+
+COPY requirements.txt /requirements.txt
+COPY post_dr_comment.py /post_dr_comment.py
+
+RUN pip install -r /requirements.txt
+
+ENTRYPOINT [ "/post_dr_comment.py" ]
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..8211765
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 AllSpice
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ad34ead
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+# Post Comment on a Design Review
+
+Post a comment on a Design Review using a markdown file as the source of the
+comment on AllSpice Hub using
+[AllSpice Actions](https://learn.allspice.io/docs/actions-cicd).
+
+## Usage
+
+Add the following step to your actions:
+
+```yaml
+- name: Post Comment on Design Review
+  uses: https://hub.allspice.io/Actions/post-dr-comment@v0.1
+  with:
+    # The path to the markdown file containing the comment body.
+    comment_path: path/to/comment.md
+```
+
+### Important Notes
+
+1. This action works only when used in a workflow triggered by a Design Review,
+   as it will automatically pick up the associated design review.
+2. By default, successive runs of the action will edit the same comment.
+3. This action also reads YAML frontmatter from the markdown file to post
+   attachments to the posted comment.
+
+### Customizing the Comment Content
+
+The action uses a markdown file as the source of the comment body. You can
+create a markdown file in your repository and specify its path using the
+`comment_path` input.
+
+Example `comment.md`:
+
+```markdown
+---
+attachments:
+  - path/to/attachment1.png
+  - path/to/attachment2.pdf
+---
+
+# Comment Title
+
+This is the body of the comment.
+
+- Point 1
+- Point 2
+- Point 3
+
+[Link to more information](https://example.com)
+```
+
+The YAML frontmatter at the beginning of the file (between `---`) can be used
+to specify attachments that will be added to the comment. The YAML frontmatter
+is optional, and when present, isn't included in the posted comment's body.
+
+### Reusing Existing Comments
+
+By default, the action will reuse the existing comment made by this action in
+successive runs. This behavior can be controlled using the
+`reuse_existing_comment` input. Set it to 'False' if you want to create a new
+comment on each run.
+
+### Debugging
+
+If you encounter any issues or need more detailed information about the
+action's execution, you can set the `log_level` input to 'DEBUG' for more
+verbose logging.
+
+## SSL
+
+If your instance is running on a self-signed certificate, you can tell the
+action to use your certificate by setting the `REQUESTS_CA_BUNDLE` environment
+variable.
+
+```yaml
+- name: Post Comment on Design Review
+  uses: https://hub.allspice.io/Actions/post-dr-comment@v0.1
+  with:
+    comment_path: path/to/comment.md
+  env:
+    REQUESTS_CA_BUNDLE: /path/to/your/certificate.cert
+```
+
+For more information about AllSpice Actions and how to use them in your
+workflows, please refer to the
+[AllSpice Documentation](https://learn.allspice.io/docs/actions-cicd).
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..24aa129
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,43 @@
+name: "Post Comment on a Design Review"
+description: >
+  Post a comment on a Design Review using a markdown file as the source of the
+  comment. 
+
+  This works only when used in a workflow triggered by a Design Review, as it
+  will automatically pick up the associated design review. By default,
+  successive runs of the action will edit the same comment.
+
+  This action also reads YAML frontmatter from the markdown file to post
+  attachments to the posted comment.
+
+inputs:
+  comment_path:
+    description: The path to a markdown file containing the comment body.
+    required: true
+  reuse_existing_comment:
+    description: Whether to reuse the existing comment made by this action in successive runs.
+    required: false
+    default: "True"
+  log_level:
+    description: The log level used by the action. Used for debugging.
+    required: false
+    default: "INFO"
+
+runs:
+  using: "docker"
+  image: "Dockerfile"
+  args:
+    - "--allspice_hub_url"
+    - ${{ github.server_url }}
+    - "--repository"
+    - ${{ github.repository }}
+    - "--design-review-number"
+    - ${{ github.event.number }}
+    - "--comment-path"
+    - "${{ github.workspace}}/${{ inputs.comment_path }}"
+    - "--reuse-existing-comment"
+    - ${{ inputs.reuse_existing_comment }}
+    - "--log-level"
+    - ${{ inputs.log_level }}
+  env:
+    ALLSPICE_AUTH_TOKEN: ${{ github.token }}
diff --git a/post_dr_comment.py b/post_dr_comment.py
new file mode 100644
index 0000000..2452289
--- /dev/null
+++ b/post_dr_comment.py
@@ -0,0 +1,191 @@
+"""
+post_dr_comment.py: Post a comment to an AllSpice Hub Design Review.
+"""
+
+import argparse
+import logging
+import os
+import yaml
+
+from typing import Tuple
+
+from allspice import AllSpice, Comment, DesignReview
+
+COMMENT_IDENTIFIER = "<!-- AllSpice Hub Auto-DR Comment -->"
+
+logger = logging.getLogger(__name__)
+
+
+def parse_bool(input: str | bool) -> bool:
+    """
+    Parse a YAML-like boolean string as a boolean.
+    """
+
+    if isinstance(input, bool):
+        return input
+    if input.lower() in ("yes", "true", "t", "y", "1"):
+        return True
+    elif input.lower() in ("no", "false", "f", "n", "0"):
+        return False
+    else:
+        raise argparse.ArgumentTypeError(
+            "One of: yes, no, true, false, t, f, y, n, 1, 0 expected."
+        )
+
+
+def parse_front_matter(comment_body: str) -> Tuple[dict, str]:
+    """
+    Check if the comment body has a front matter and parse it.
+
+    Returns the front matter as a dictionary and the comment body without the
+    front matter.
+
+    If the front matter is empty or missing, the front matter dictionary will
+    be empty.
+    """
+
+    front_matter = {}
+    found_front_matter = False
+    stripped_comment_body = comment_body.strip()
+
+    if stripped_comment_body.startswith("---"):
+        split_comment = stripped_comment_body.split("---", 2)
+        if len(split_comment) == 3:
+            found_front_matter = True
+            try:
+                front_matter = yaml.safe_load(split_comment[1])
+                comment_body = split_comment[2].lstrip()
+                found_front_matter = True
+            except yaml.YAMLError as e:
+                logger.error(f"Failed to parse front matter: {e}")
+
+    if not found_front_matter:
+        logger.info("No front matter found in comment body.")
+        return front_matter, comment_body
+
+    return front_matter, stripped_comment_body
+
+
+def upsert_comment(design_review: DesignReview, comment_body: str) -> Comment:
+    """
+    Upsert a comment on a Design Review.
+    If a comment with the same identifier already exists, update the existing
+    comment. Otherwise, create a new comment.
+
+    Returns the created or updated comment.
+    """
+
+    existing_comment = None
+    comments = design_review.get_comments()
+
+    for comment in comments:
+        if COMMENT_IDENTIFIER in comment.body:
+            existing_comment = comment
+            break
+
+    if existing_comment:
+        logger.info("Updating existing comment.")
+        existing_comment.body = comment_body
+        existing_comment.commit()
+
+        return existing_comment
+    else:
+        logger.info("Creating new comment.")
+        return design_review.create_comment(comment_body)
+
+
+def upsert_attachments(comment: Comment, attachments: list[str]):
+    """
+    Upsert attachments to a comment.
+
+    This clears all existing attachments and then adds new attachments.
+    """
+
+    existing_attachments = comment.get_attachments()
+    for attachment in existing_attachments:
+        comment.delete_attachment(attachment)
+
+    for attachment in attachments:
+        with open(attachment, "rb") as f:
+            comment.create_attachment(f)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description=__doc__,
+    )
+
+    parser.add_argument(
+        "--allspice-hub-url",
+        required=False,
+        default="https://hub.allspice.io",
+        help="The URL of the AllSpice Hub DR to post the comment to.",
+    )
+    parser.add_argument(
+        "--repository",
+        required=True,
+        help="The repository that the design review is associated with.",
+    )
+    parser.add_argument(
+        "--design-review-number",
+        required=True,
+        help="The number of the design review to post the comment to.",
+    )
+    parser.add_argument(
+        "--comment-path",
+        required=True,
+        help="The path to the Comment Markdown file.",
+    )
+    parser.add_argument(
+        "--reuse-existing-comment",
+        required=False,
+        default=True,
+        type=parse_bool,
+        help="Whether to reuse an existing comment if it exists.",
+    )
+    parser.add_argument(
+        "--log-level",
+        required=False,
+        default="INFO",
+        help="The logging level to use.",
+    )
+
+    token = os.getenv("ALLSPICE_AUTH_TOKEN")
+    if not token:
+        raise ValueError("ALLSPICE_AUTH_TOKEN environment variable not set.")
+
+    args = parser.parse_args()
+
+    logger.setLevel(args.log_level.upper())
+    client = AllSpice(
+        args.allspice_hub_url,
+        token_text=token,
+        log_level=args.log_level.upper(),
+    )
+    owner, repo = args.repository.split("/")
+    design_review = DesignReview.request(client, owner, repo, args.design_review_number)
+
+    with open(args.comment_path, "r") as f:
+        comment_body = f.read()
+
+    front_matter, comment_body = parse_front_matter(comment_body)
+    attachments = []
+    if front_matter:
+        logger.debug(f"Front matter: {front_matter}")
+
+        if "attachments" in front_matter:
+            attachments = front_matter["attachments"]
+
+    if args.reuse_existing_comment:
+        comment = upsert_comment(design_review, comment_body)
+    else:
+        comment = design_review.create_comment(comment_body)
+
+    if attachments:
+        upsert_attachments(comment, attachments)
+
+    logger.info("Comment posted successfully.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 0000000..bf1318e
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1 @@
+ruff==0.6.3
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f829b21
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+py-allspice~=3.5.0
+PyYAML~=6.0.2
+