Skip to content

PCSM-313: Migrate /jira ticket creation to opencode action #27

PCSM-313: Migrate /jira ticket creation to opencode action

PCSM-313: Migrate /jira ticket creation to opencode action #27

Workflow file for this run

name: Jira Create
"on":
issue_comment:
types: [created]
concurrency:
group: jira-create-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
create-jira:
# Runs only on /jira or "/jira ..." comments on issues (not PRs).
# PR-side flow uses .github/workflows/opencode-pr-summary.yml + opencode-review.yml;
# a separate Jira ticket from a PR comment would create a duplicate tracker.
if: |
(github.event.comment.body == '/jira' || startsWith(github.event.comment.body, '/jira ')) &&
github.event.issue.pull_request == null
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
ANTHROPIC_MODEL: claude-sonnet-4-20250514
REPO_FULL: ${{ github.repository }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ACTOR: ${{ github.event.comment.user.login }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Check maintainer permission
run: |
set -euo pipefail
PERM=$(gh api "repos/$REPO_OWNER/$REPO_NAME/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo "none")
if [[ "$PERM" != "write" && "$PERM" != "admin" ]]; then
gh api "repos/$REPO_FULL/issues/$ISSUE_NUMBER/comments" \
-f body="@$ACTOR Sorry, the \`/jira\` command is restricted to maintainers." 2>/dev/null || true
echo "SKIP=true" >> "$GITHUB_ENV"
fi
- name: Check existing Jira link
if: env.SKIP != 'true'
run: |
set -euo pipefail
COMMENTS=$(gh api --paginate "repos/$REPO_FULL/issues/$ISSUE_NUMBER/comments" --jq '.[].body')
if echo "$COMMENTS" | grep -q "<!-- jira:PCSM-"; then
TICKET=$(printf '%s\n' "$COMMENTS" | grep -oE '<!-- jira:PCSM-[0-9]+ -->' | head -1 | sed -E 's/<!-- jira:([^ ]+) -->/\1/')
gh api "repos/$REPO_FULL/issues/$ISSUE_NUMBER/comments" \
-f body="Jira ticket already exists: [$TICKET]($JIRA_BASE_URL/browse/$TICKET)" || true
echo "SKIP=true" >> "$GITHUB_ENV"
fi
- name: Compute Jira auth
if: env.SKIP != 'true'
run: |
set -euo pipefail
AUTH=$(printf '%s' "$JIRA_USER_EMAIL:$JIRA_API_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::$AUTH"
echo "JIRA_AUTH=$AUTH" >> "$GITHUB_ENV"
- name: Verify Jira access
if: env.SKIP != 'true'
run: |
set -euo pipefail
# Preflight diagnostic. Without this, the Create Jira ticket step below
# reports a terse HTTP 400 `{"errors":{"project":"valid project is required"}}`
# for two different underlying causes: token can't see PCSM, or the
# configured issue type isn't in PCSM's scheme. This step distinguishes them.
PROJECT_CODE=$(curl --silent --show-error -o project.json -w '%{http_code}' \
"$JIRA_BASE_URL/rest/api/3/project/PCSM" \
-H "Authorization: Basic $JIRA_AUTH" \
-H "Accept: application/json")
if [[ "$PROJECT_CODE" -eq 401 || "$PROJECT_CODE" -eq 403 ]]; then
echo "::error::Jira authentication failed (HTTP $PROJECT_CODE). Check JIRA_USER_EMAIL and JIRA_API_TOKEN secrets and their scopes."
cat project.json
exit 1
fi
if [[ "$PROJECT_CODE" -eq 404 ]]; then
echo "::error::Jira token cannot see project PCSM. Grant Browse Projects and Create Issues permission to this account on the PCSM project in Jira."
exit 1
fi
if [[ "$PROJECT_CODE" -ge 400 ]]; then
echo "::error::Jira /project/PCSM returned HTTP $PROJECT_CODE."
cat project.json
exit 1
fi
if ! jq -e '.issueTypes[] | select(.name == "Admin & Maintenance Task")' project.json >/dev/null; then
echo "::error::PCSM project visible but 'Admin & Maintenance Task' issue type is not in its scheme. Available types:"
jq -r '.issueTypes[].name' project.json
exit 1
fi
echo "PCSM project accessible, 'Admin & Maintenance Task' issue type available."
- name: Get issue content
id: get-issue
if: env.SKIP != 'true'
run: |
set -euo pipefail
ISSUE=$(gh api "repos/$REPO_FULL/issues/$ISSUE_NUMBER")
TITLE=$(echo "$ISSUE" | jq -r '.title')
BODY=$(echo "$ISSUE" | jq -r '.body // ""')
ISSUE_URL=$(echo "$ISSUE" | jq -r '.html_url')
{
echo 'TITLE<<EOF'
printf '%s\n' "$TITLE"
echo 'EOF'
echo 'BODY<<EOF'
printf '%s\n' "$BODY"
echo 'EOF'
echo 'ISSUE_URL<<EOF'
printf '%s\n' "$ISSUE_URL"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Generate Jira description
id: generate-description
if: env.SKIP != 'true'
env:
TITLE: ${{ steps.get-issue.outputs.TITLE }}
BODY: ${{ steps.get-issue.outputs.BODY }}
ISSUE_URL: ${{ steps.get-issue.outputs.ISSUE_URL }}
run: |
set -euo pipefail
REQUEST=$(jq -n \
--arg title "$TITLE" \
--arg body "$BODY" \
--arg issue_url "$ISSUE_URL" \
--arg model "$ANTHROPIC_MODEL" \
'{
model: $model,
max_tokens: 1024,
messages: [
{
role: "user",
content: (
"Create a structured Jira ticket description from this GitHub issue. Use professional technical language. Format: Summary section, Steps to Reproduce (if bug), Expected Behavior, Additional Context. Include link to GitHub issue: "
+ $issue_url
+ ". Do not use AI-slop phrases. Issue title: "
+ $title
+ "\n\nIssue body:\n"
+ $body
)
}
]
}')
RESPONSE=$(curl --silent --show-error https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d "$REQUEST")
if echo "$RESPONSE" | jq -e '.error' >/dev/null 2>&1; then
echo "Anthropic API error: $(echo "$RESPONSE" | jq -r '.error.message')"
exit 1
fi
DESCRIPTION_TEXT=$(echo "$RESPONSE" | jq -r '[.content[] | select(.type == "text") | .text] | join("\n\n")')
test -n "$DESCRIPTION_TEXT"
{
echo 'DESCRIPTION_TEXT<<EOF'
printf '%s\n' "$DESCRIPTION_TEXT"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create Jira ticket
id: create-jira
if: env.SKIP != 'true'
env:
TITLE: ${{ steps.get-issue.outputs.TITLE }}
DESCRIPTION_TEXT: ${{ steps.generate-description.outputs.DESCRIPTION_TEXT }}
run: |
set -euo pipefail
ADF_DESCRIPTION=$(python3 <<'PY'
import json
import os
text = os.environ.get("DESCRIPTION_TEXT", "").strip()
paragraphs = []
for block in text.split("\n\n"):
block = block.strip()
if not block:
continue
paragraphs.append(
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": block.replace("\n", " "),
}
],
}
)
if not paragraphs:
paragraphs = [
{
"type": "paragraph",
"content": [{"type": "text", "text": text or "GitHub issue imported."}],
}
]
print(json.dumps({"type": "doc", "version": 1, "content": paragraphs}))
PY
)
PAYLOAD=$(jq -n \
--arg summary "$TITLE" \
--argjson description "$ADF_DESCRIPTION" \
'{
fields: {
project: {key: "PCSM"},
summary: $summary,
description: $description,
issuetype: {name: "Admin & Maintenance Task"},
labels: ["github-issue"]
}
}')
HTTP_CODE=$(curl --silent --show-error -o jira-response.json -w '%{http_code}' -X POST "$JIRA_BASE_URL/rest/api/3/issue" \
-H "Authorization: Basic $JIRA_AUTH" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [[ "$HTTP_CODE" -ge 400 ]]; then
echo "Jira API error ($HTTP_CODE):"
cat jira-response.json
exit 1
fi
JIRA_RESPONSE=$(cat jira-response.json)
TICKET_KEY=$(echo "$JIRA_RESPONSE" | jq -r '.key')
if [[ -z "$TICKET_KEY" || "$TICKET_KEY" == "null" ]]; then
echo "$JIRA_RESPONSE"
exit 1
fi
echo "TICKET_KEY=$TICKET_KEY" >> "$GITHUB_OUTPUT"
- name: Comment with Jira link
if: env.SKIP != 'true'
env:
TICKET_KEY: ${{ steps.create-jira.outputs.TICKET_KEY }}
run: |
set -euo pipefail
COMMENT_BODY=$(printf 'Jira ticket created: [%s](%s/browse/%s)\n<!-- jira:%s -->' "$TICKET_KEY" "$JIRA_BASE_URL" "$TICKET_KEY" "$TICKET_KEY")
gh api "repos/$REPO_FULL/issues/$ISSUE_NUMBER/comments" \
-f body="$COMMENT_BODY"
- name: Add Jira remote link
if: env.SKIP != 'true'
env:
TICKET_KEY: ${{ steps.create-jira.outputs.TICKET_KEY }}
TITLE: ${{ steps.get-issue.outputs.TITLE }}
ISSUE_URL: ${{ steps.get-issue.outputs.ISSUE_URL }}
run: |
set -euo pipefail
PAYLOAD=$(jq -n \
--arg issue_url "$ISSUE_URL" \
--arg title "$TITLE" \
'{
object: {
url: $issue_url,
title: ("GitHub Issue: " + $title),
icon: {url16x16: "https://github.com/favicon.ico"}
}
}')
HTTP_CODE=$(curl --silent --show-error -o remotelink-response.json -w '%{http_code}' -X POST "$JIRA_BASE_URL/rest/api/3/issue/$TICKET_KEY/remotelink" \
-H "Authorization: Basic $JIRA_AUTH" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [[ "$HTTP_CODE" -ge 400 ]]; then
echo "Jira API error ($HTTP_CODE):"
cat remotelink-response.json
exit 1
fi