Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Python script for generating resource entries #440

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
204 changes: 204 additions & 0 deletions .github/scripts/resource-template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import json
import os
import re
import sys
import uuid

#
# Parse the issue body into JSON
# (ported from https://github.com/GrantBirki/issue-template-parser)
#

def normalize_str(value, delimiter = '_'):
sanitized = value.strip().lower()
sanitized = re.sub(r'[^a-z0-9]', delimiter, sanitized)
sanitized = re.sub(f'^{delimiter}+|{delimiter}+$', '', sanitized)
sanitized = re.sub(f'{delimiter}+', delimiter, sanitized)

return sanitized

def format_key(key):
return normalize_str(key, '_')

def format_value(value):
sanitized = value.strip()
sanitized = re.sub(r'\r', '', sanitized)
sanitized = re.sub(r'^[\n]+|[\n]+$', '', sanitized)

# Handle empty responses
if sanitized == 'None' or sanitized == '_No response_' or sanitized == '':
return None

# Handle a single line CSV
if not "\n" in sanitized and ',' in sanitized:
return [
item.strip() for item in sanitized.split(',')
]

# Handle a multi-lines without checkboxes and return it as a multi-line string
for line in sanitized.split('\n'):
if not line.startswith('- [x]') and not line.startswith('- [ ]'):
return sanitized

checkboxes = {
'selected': [],
'unselected': [],
}

for line in sanitized.split('\n'):
if line.startswith('- [x]'):
checkboxes['selected'].append(line[6:].strip())
elif line.startswith('- [ ]'):
checkboxes['unselected'].append(line[6:].strip())

return checkboxes

def parse_issue_body(body):
issueBodyRe = r'### *(?P<key>.*?)\s*[\r\n]+(?P<value>[\s\S]*?)(?=###|$)'
matches = re.finditer(issueBodyRe, body)
result = {}

for match in matches:
key = match.group('key').strip()
value = match.group('value').strip()

result[format_key(key)] = format_value(value)

return result

#
# Utility functions for CI/CD operations
#

def is_ci():
return os.getenv('CI', 'false') == 'true'

def set_gha_output(name, value):
# Utility function to set the output for GitHub Actions, with support for
# multi-line values.
#
# https://github.com/orgs/community/discussions/28146#discussioncomment-5638014

with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
delimiter = uuid.uuid1()
print(f'{name}<<{delimiter}', file=fh)
print(value, file=fh)
print(delimiter, file=fh)

def write_debug(message):
if os.getenv('RUNNER_DEBUG', '0') == '1':
print(f'::debug::{message}')
elif os.getenv('DEBUG', 'false') == 'true':
print(f'[DEBUG] {message}')

#
# Utility functions for I/O operations
#

FILENAME_CHAR_LIMIT = 255
FILENAME_EXTENSION = '.md'
NEWLINE = "\n"
CATEGORY_PREFIXES = {
'Case studies': 'case-study',
'Fact sheets & overviews': 'fact-sheet',
}

def get_file_prefix(category):
if category in CATEGORY_PREFIXES:
return CATEGORY_PREFIXES[category]

raise NotImplementedError(f'Category {category} is not supported')

def build_filename(prefix, title):
working_title = normalize_str(title, '-')
filename = f'{prefix}-{working_title}'

# Some file systems have a filename character limit, so truncate if necessary
if len(filename) > FILENAME_CHAR_LIMIT:
last_index = filename.rfind('-', 0, FILENAME_CHAR_LIMIT - len(FILENAME_EXTENSION))
filename = filename[:last_index]

return f'{filename}.md'

def create_resource(filename, content):
with open(f'src/_resources/{filename}', 'w') as file:
file.write(content)

def main():
if len(sys.argv) > 1:
input_str = sys.argv[1]
else:
input_str = sys.stdin.read()

try:
json_data = json.loads(input_str)
except json.decoder.JSONDecodeError as e:
write_debug(f'Failed to parse input as JSON: {e}')

if is_ci():
set_gha_output('success', 'false')
set_gha_output('failure_reason', 'invalid_json')
else:
print('This script expects valid JSON as its input.')

return

issue = parse_issue_body(json_data['body'])
write_debug(f'Parsed issue body: {json.dumps(issue, indent=2)}')

# If the resource hasn't been approved yet, skip creation
if issue['approved'].strip().lower() != 'yes':
write_debug('Resource has not been approved, skipping creation')

if is_ci():
set_gha_output('success', 'false')
set_gha_output('failure_reason', 'not_approved')

return

# Ensure all required keys are present, we support multiple issue templates so
# by checking the keys, we're ensuring we have the right template
required_issue_keys = ['approved', 'date_year', 'date_month', 'title', 'asset_url', 'category', 'tag']
if not all(key in issue for key in required_issue_keys):
missing_keys = [key for key in required_issue_keys if key not in issue]

write_debug(f'Expected keys: {required_issue_keys}')
write_debug(f'Issue body is missing one or more of the following keys: {missing_keys}')

if is_ci():
set_gha_output('success', 'false')
set_gha_output('failure_reason', 'missing_keys')

return


filename = build_filename(get_file_prefix(issue['category']), issue['title'])
tags = issue['tag'] if isinstance(issue['tag'], list) else [issue['tag']]
content = f"""
---
date: "{issue['date_year']}-{issue['date_month']}-01T00:00:00-07:00"
title: "{issue['title']}"
asset: "{issue['asset_url']}"
category: "{issue['category']}"
tags:
{NEWLINE.join(f' - {tag}' for tag in tags)}
---
""".lstrip()

create_resource(filename, content)

if is_ci():
set_gha_output('success', 'true')
set_gha_output('resource_filename', filename)

write_debug(f'Creating resource: {filename}')
else:
print(f'Created resource: {filename}')
print('')
print(f'Follow the instructions in the file to complete the resource creation.')
print(f' 1. Download the following file: {issue["asset_url"]}')
print(f' 2. Add the downloaded file to the `src/assets/` directory')
print(f' 3. Update the `asset` field in the created resource (above) to the filename of the newly added resource')

if __name__ == '__main__':
main()
69 changes: 69 additions & 0 deletions .github/workflows/resource-template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Auto-Create PRs for Resource Requests
on:
issues:
types: [opened, transferred]
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number'
required: true
default: '1'
type: number

permissions:
contents: write
pull-requests: write

jobs:
generate-resource:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
name: Set up Python
with:
python-version: '3.13'

- name: Configure State
id: workflow_state
run: |
echo "issue_number=${{ github.event.issue.number || github.event.inputs.issue_number }}" >> "$GITHUB_OUTPUT"

- name: Run the script
id: resource_gen
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh issue view ${{ steps.workflow_state.outputs.issue_number }} --json body | python .github/scripts/resource-template.py

- name: Check if Supported Template
if: steps.resource_gen.outputs.failure_reason == 'missing_keys'
run: |
echo "This issue does not have the expected keys, it's likely not a resource request."

- name: Warn about unapproved resource
if: steps.resource_gen.outputs.failure_reason == 'not_approved'
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ steps.workflow_state.outputs.issue_number }}
body: |
Your resource request was marked as not approved, therefore I have
not created a PR for it. Once it's approved, we can rerun this
process.

- name: Create the Pull Request
if: steps.resource_gen.outputs.success == 'true'
uses: peter-evans/create-pull-request@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
title: "Auto-PR for Resource Request #${{ steps.workflow_state.outputs.issue_number }}"
draft: true
body: >
This PR is created automatically from a resource request. Please
review the changes and merge when ready.

Closes #${{ steps.workflow_state.outputs.issue_number }}
add-paths: "src/_resources/"
commit-message: "feat: start resource for req #${{ steps.workflow_state.outputs.issue_number }}"
branch: "chore/resource-${{ steps.workflow_state.outputs.issue_number }}"
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ and will watch the site files for changes.

Site analytics is tracked by Google Analytics, version 4. Ask an administrator to grant you access.

## Resource Requests

We have a semi-automated way of handling resource requests via a Python script combind with the [`gh`](http://cli.github.com/) command.

```bash
gh issue view <issue num> --json body | python .github/scripts/resource-template.py
```

## License

Content (including graphics, images, video, documents, and text) in this repository is licensed under [CC-BY 4.0][content-license].
Expand Down