Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,19 @@ jobs:

- name: Test the JS linter action
run: ./test/js-linter/execution.sh

definition-of-done-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6

- name: Test definition-of-done action
run: ./definition-of-done/tests/run.sh

pull-request-compliance-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6

- name: Test pull-request-compliance action
run: ./pull-request-compliance/tests/run.sh
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@
!.env.test
# environment variables with secrets
.env*.local

# Python caches
__pycache__/
*.pyc
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ Two actions are present to release services which are go binaries:

Example of usage is presents in the `actions.yml` of each action

## Pull Request Governance

Two actions are available for pull request quality gates:

- [definition-of-done](/definition-of-done): validate that all checklist items are checked when a `Definition of Done` section is present in the PR body.
- [pull-request-compliance](/pull-request-compliance): validate PR title/body compliance with deterministic checks and optional OpenAI policy/template validation.

## Automatically Merge Dependabot Pull Requests

GitHub action to automatically merge the Dependabot PRs. It merges the dependency upgrade if it upgrades a minor or patch version.
Expand Down
35 changes: 35 additions & 0 deletions definition-of-done/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Definition of Done Action

Checks the pull request description for a `Definition of Done` section. If that section is present, all checklist items must be checked.

## Inputs

- `pr-body` (required): pull request body markdown.
- `section-heading` (optional): heading text to match, default `Definition of Done`.

## Example

Reusable workflow example file: `examples/workflow.yml`.

```yaml
name: definition-of-done
on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: Scalingo/actions/definition-of-done@main
with:
pr-body: ${{ github.event.pull_request.body }}
```

## Tests

Run:

```bash
./definition-of-done/tests/run.sh
```
21 changes: 21 additions & 0 deletions definition-of-done/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: "Scalingo Definition of Done GitHub Action"
description: "Validate that all checklist items are checked in the PR Definition of Done section"

inputs:
pr-body:
description: "Pull request body markdown"
required: true
section-heading:
description: "Heading text to match for the DoD section (case-insensitive)"
required: false
default: "Definition of Done"

runs:
using: "composite"
steps:
- name: Validate Definition of Done checklist
shell: bash
env:
PR_BODY: ${{ inputs.pr-body }}
SECTION_HEADING: ${{ inputs.section-heading }}
run: python3 "${GITHUB_ACTION_PATH}/scripts/check_definition_of_done.py"
24 changes: 24 additions & 0 deletions definition-of-done/docs/inline_dod_block_Version3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -----------------------------
# Deterministic: DoD checklist
# -----------------------------
import re, sys

heading_re = re.compile(r"(?im)^#{1,6}\s*definition\s+of\s+done\s*$")
m = heading_re.search(body)
if m:
start = m.end()
next_heading_re = re.compile(r"(?im)^#{1,6}\s+\S.*$")
m2 = next_heading_re.search(body, pos=start)
section = body[start:] if not m2 else body[start:m2.start()]

items = re.findall(r"(?m)^\s*-\s*\[(?P<mark>[ xX])\]\s+(?P<text>.+?)\s*$", section)
if not items:
print("::error::A 'Definition of Done' heading is present, but no checklist items were found under it.")
sys.exit(1)

unchecked = [text for mark, text in items if mark.strip() == ""]
if unchecked:
print("::error::Definition of Done checklist has unchecked items:")
for t in unchecked:
print(f" - {t}")
sys.exit(1)
43 changes: 43 additions & 0 deletions definition-of-done/docs/pr_compliance_dod_check_Version3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import re, sys

def enforce_definition_of_done(body: str) -> None:
"""
If a Markdown heading (any level) matches 'definition of done' (case-insensitive),
require all task list items under that section to be checked.

Section runs until the next Markdown heading of any level or end of text.
"""
# Match headings like:
# # Definition of Done
# ## definition of done
# ###### DeFiNiTiOn Of DoNe
heading_re = re.compile(
r"(?im)^(?P<hashes>#{1,6})\s*(?P<title>definition\s+of\s+done)\s*$"
)

m = heading_re.search(body)
if not m:
return # DoD section not present => no enforcement

start = m.end()

# Find next heading after this one (any level)
next_heading_re = re.compile(r"(?im)^#{1,6}\s+\S.*$")
m2 = next_heading_re.search(body, pos=start)
section = body[start:] if not m2 else body[start:m2.start()]

# Task list items in Markdown
items = re.findall(
r"(?m)^\s*-\s*\[(?P<mark>[ xX])\]\s+(?P<text>.+?)\s*$",
section
)
if not items:
print("::error::A 'Definition of Done' heading is present, but no checklist items were found under it.")
sys.exit(1)

unchecked = [text for mark, text in items if mark.strip() == ""]
if unchecked:
print("::error::Definition of Done checklist has unchecked items:")
for t in unchecked:
print(f" - {t}")
sys.exit(1)
13 changes: 13 additions & 0 deletions definition-of-done/examples/workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: definition-of-done

on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: Scalingo/actions/definition-of-done@main
with:
pr-body: ${{ github.event.pull_request.body }}
68 changes: 68 additions & 0 deletions definition-of-done/scripts/check_definition_of_done.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Validate Definition of Done checklist sections in Markdown text."""

import os
import re
import sys


def heading_pattern(heading_text: str) -> str:
tokens = heading_text.strip().split()
if not tokens:
return r"definition\s+of\s+done"
return r"\s+".join(re.escape(token) for token in tokens)


def find_dod_sections(body: str, heading_text: str) -> list[str]:
title_pattern = heading_pattern(heading_text)
heading_re = re.compile(rf"(?im)^#{{1,6}}\s*{title_pattern}\s*$")
generic_heading_re = re.compile(r"(?im)^#{1,6}\s+\S.*$")

sections: list[str] = []
for match in heading_re.finditer(body):
start = match.end()
next_heading = generic_heading_re.search(body, pos=start)
end = next_heading.start() if next_heading else len(body)
sections.append(body[start:end])

return sections


def validate_section(section: str) -> list[str]:
items = re.findall(r"(?m)^\s*-\s*\[(?P<mark>[ xX])\]\s+(?P<text>.+?)\s*$", section)
if not items:
return ["A 'Definition of Done' heading is present, but no checklist items were found under it."]

unchecked = [text for mark, text in items if mark.strip() == ""]
if not unchecked:
return []

errors = ["Definition of Done checklist has unchecked items:"]
errors.extend(f" - {item}" for item in unchecked)
return errors


def main() -> int:
body = os.environ.get("PR_BODY", "")
heading = os.environ.get("SECTION_HEADING", "Definition of Done")

if not body.strip():
print("::error::PR description is empty.")
return 1

sections = find_dod_sections(body, heading)
if not sections:
return 0

for section in sections:
errors = validate_section(section)
if errors:
for error in errors:
print(f"::error::{error}")
return 1

return 0


if __name__ == "__main__":
sys.exit(main())
5 changes: 5 additions & 0 deletions definition-of-done/tests/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/../.."
python3 -m unittest discover -s definition-of-done/tests -p 'test_*.py' -v
84 changes: 84 additions & 0 deletions definition-of-done/tests/test_check_definition_of_done.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3

import importlib.util
import io
import os
import pathlib
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch

SCRIPT_PATH = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "check_definition_of_done.py"
SPEC = importlib.util.spec_from_file_location("check_definition_of_done", SCRIPT_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)


class DefinitionOfDoneTests(unittest.TestCase):
def run_main(self, body: str, heading: str = "Definition of Done"):
env = {
"PR_BODY": body,
"SECTION_HEADING": heading,
}
output = io.StringIO()
with patch.dict(os.environ, env, clear=False), redirect_stdout(output):
rc = MODULE.main()
return rc, output.getvalue()

def test_empty_body_fails(self):
rc, output = self.run_main(" ")
self.assertEqual(rc, 1)
self.assertIn("PR description is empty", output)

def test_missing_dod_heading_passes(self):
rc, output = self.run_main("## Summary\nNo DoD section here")
self.assertEqual(rc, 0)
self.assertEqual(output, "")

def test_checked_dod_passes(self):
body = """## Definition of Done
- [x] Requirements understood
- [X] Tests updated
"""
rc, output = self.run_main(body)
self.assertEqual(rc, 0)
self.assertEqual(output, "")

def test_heading_matching_is_case_insensitive_and_spacing_tolerant(self):
body = """### DeFiNiTiOn Of DoNe
- [x] item
"""
rc, output = self.run_main(body)
self.assertEqual(rc, 0)
self.assertEqual(output, "")

def test_unchecked_item_fails(self):
body = """## Definition of Done
- [x] One
- [ ] Two
"""
rc, output = self.run_main(body)
self.assertEqual(rc, 1)
self.assertIn("unchecked items", output)
self.assertIn("Two", output)

def test_dod_without_checklist_fails(self):
body = """## Definition of Done
This section has no checklist
"""
rc, output = self.run_main(body)
self.assertEqual(rc, 1)
self.assertIn("no checklist items", output)

def test_custom_heading_can_be_used(self):
body = """## Custom Done Block
- [x] All good
"""
rc, output = self.run_main(body, heading="Custom Done Block")
self.assertEqual(rc, 0)
self.assertEqual(output, "")


if __name__ == "__main__":
unittest.main()
54 changes: 54 additions & 0 deletions pull-request-compliance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Pull Request Compliance Action

Validates pull request title/body with deterministic checks and optional OpenAI policy/template validation.

## Deterministic checks

- Empty PR description is rejected.
- `Definition of Done` checklist must be fully checked when the section is present.
- Placeholder tokens `TBD`, `TODO`, and `??` are rejected.

## Inputs

- `pr-title` (required): pull request title.
- `pr-body` (required): pull request body markdown.
- `template-path` (optional): default `.github/pull_request_template.md`.
- `policy-path` (optional): default `.github/pr_compliance_policy.md`.
- `openai-api-key` (optional): enables policy/template LLM validation when set.
- `openai-model` (optional): default `gpt-4o-mini`.

## Example

Reusable workflow example file: `examples/workflow.yml`.

```yaml
name: pr-compliance
on:
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]

jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4

- uses: Scalingo/actions/pull-request-compliance@main
with:
pr-title: ${{ github.event.pull_request.title }}
pr-body: ${{ github.event.pull_request.body }}
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
```

## Tests

Run:

```bash
./pull-request-compliance/tests/run.sh
```

Unit tests mock OpenAI calls, so no API key is required.
Loading