PCSM-313: Migrate /jira ticket creation to opencode action #27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |