Skip to content

Commit 299dd34

Browse files
authored
Merge pull request #504 from DataIntegrationGroup/jira-automation
Harden jira codex PR workflow
2 parents ee62cea + 329e761 commit 299dd34

1 file changed

Lines changed: 392 additions & 0 deletions

File tree

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
# .github/workflows/jira-codex-pr.yml
2+
name: Implement Jira ticket with Codex and open/update PR (uv + python)
3+
4+
on:
5+
repository_dispatch:
6+
types: [jira_implement]
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: jira-${{ github.event.client_payload.jira_key }}
14+
cancel-in-progress: false
15+
16+
env:
17+
# ---------------- GUARDRAILS ----------------
18+
ALLOWED_JIRA_PROJECT_KEYS: "ABC,DEF" # comma-separated
19+
ALLOWED_ISSUE_TYPES: "Story,Bug,Task" # comma-separated
20+
REQUIRED_LABEL: "codex" # require this label on the Jira issue
21+
REQUIRED_CUSTOM_FIELD_ID: "" # optional; e.g. "customfield_12345" (leave empty to disable)
22+
23+
# ---------------- BRANCH/PR ----------------
24+
BASE_BRANCH: "main"
25+
BRANCH_PREFIX: "jira"
26+
MAX_TITLE_SLUG_LEN: "40"
27+
28+
# ---------------- PYTHON/UV ----------------
29+
PYTHON_VERSION: "3.13"
30+
MAX_DESC_CHARS: "8000"
31+
32+
# Commands (run inside uv env)
33+
FORMAT_COMMAND: "uv run black ."
34+
LINT_COMMAND: "uv run flake8"
35+
TEST_COMMAND: "uv run pytest -q"
36+
37+
jobs:
38+
implement:
39+
runs-on: ubuntu-latest
40+
timeout-minutes: 60
41+
42+
steps:
43+
- name: Checkout
44+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
45+
with:
46+
fetch-depth: 0
47+
48+
- name: Ensure jq exists
49+
run: |
50+
set -euo pipefail
51+
if ! command -v jq >/dev/null 2>&1; then
52+
sudo apt-get update
53+
sudo apt-get install -y jq
54+
fi
55+
56+
- name: Set up Python
57+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
58+
with:
59+
python-version: ${{ env.PYTHON_VERSION }}
60+
61+
- name: Set up uv (with cache)
62+
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
63+
with:
64+
enable-cache: true
65+
66+
- name: Ensure uv.lock exists (determinism)
67+
run: |
68+
set -euo pipefail
69+
test -f uv.lock || (echo "uv.lock missing; commit it for deterministic CI." && exit 1)
70+
71+
- name: Sync dependencies (pyproject/uv.lock)
72+
run: |
73+
set -euo pipefail
74+
uv sync --all-extras --dev
75+
76+
- name: Verify tooling exists
77+
run: |
78+
set -euo pipefail
79+
uv run black --version
80+
uv run flake8 --version
81+
uv run pytest --version
82+
83+
- name: Read Jira key
84+
id: jira
85+
run: |
86+
set -euo pipefail
87+
KEY="${{ github.event.client_payload.jira_key }}"
88+
if [ -z "$KEY" ] || [ "$KEY" = "null" ]; then
89+
echo "Missing jira_key in dispatch payload."
90+
exit 1
91+
fi
92+
echo "JIRA_KEY=$KEY" >> $GITHUB_OUTPUT
93+
94+
- name: Fetch Jira issue JSON
95+
id: issue
96+
env:
97+
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
98+
JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
99+
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
100+
JIRA_KEY: ${{ steps.jira.outputs.JIRA_KEY }}
101+
MAX_DESC_CHARS: ${{ env.MAX_DESC_CHARS }}
102+
run: |
103+
set -euo pipefail
104+
curl -fsS --retry 3 --retry-all-errors -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
105+
-H "Accept: application/json" \
106+
"$JIRA_BASE_URL/rest/api/3/issue/$JIRA_KEY" > jira.json
107+
108+
SUMMARY=$(jq -r '.fields.summary // empty' jira.json)
109+
ISSUE_TYPE=$(jq -r '.fields.issuetype.name // empty' jira.json)
110+
PROJECT_KEY=$(jq -r '.fields.project.key // empty' jira.json)
111+
112+
if [ -z "$SUMMARY" ] || [ -z "$ISSUE_TYPE" ] || [ -z "$PROJECT_KEY" ]; then
113+
echo "Missing one of: summary, issuetype, project.key"
114+
exit 1
115+
fi
116+
117+
LABELS=$(jq -r '.fields.labels[]? // empty' jira.json | tr '\n' ',' | sed 's/,$//')
118+
DESC=$(jq -c '.fields.description // {}' jira.json)
119+
DESC_TRIMMED="${DESC:0:${MAX_DESC_CHARS}}"
120+
121+
{
122+
echo "SUMMARY<<EOF"
123+
echo "$SUMMARY"
124+
echo "EOF"
125+
echo "ISSUE_TYPE<<EOF"
126+
echo "$ISSUE_TYPE"
127+
echo "EOF"
128+
echo "PROJECT_KEY<<EOF"
129+
echo "$PROJECT_KEY"
130+
echo "EOF"
131+
echo "LABELS<<EOF"
132+
echo "$LABELS"
133+
echo "EOF"
134+
echo "DESC<<EOF"
135+
echo "$DESC_TRIMMED"
136+
echo "EOF"
137+
} >> "$GITHUB_OUTPUT"
138+
139+
- name: Guardrails - allowlists
140+
env:
141+
PROJECT_KEY: ${{ steps.issue.outputs.PROJECT_KEY }}
142+
ISSUE_TYPE: ${{ steps.issue.outputs.ISSUE_TYPE }}
143+
LABELS: ${{ steps.issue.outputs.LABELS }}
144+
REQUIRED_LABEL: ${{ env.REQUIRED_LABEL }}
145+
ALLOWED_JIRA_PROJECT_KEYS: ${{ env.ALLOWED_JIRA_PROJECT_KEYS }}
146+
ALLOWED_ISSUE_TYPES: ${{ env.ALLOWED_ISSUE_TYPES }}
147+
run: |
148+
set -euo pipefail
149+
150+
echo "$ALLOWED_JIRA_PROJECT_KEYS" | tr ',' '\n' | grep -Fxq "$PROJECT_KEY" || {
151+
echo "Project $PROJECT_KEY not allowed (allowed: $ALLOWED_JIRA_PROJECT_KEYS)."
152+
exit 1
153+
}
154+
155+
echo "$ALLOWED_ISSUE_TYPES" | tr ',' '\n' | grep -Fxq "$ISSUE_TYPE" || {
156+
echo "Issue type $ISSUE_TYPE not allowed (allowed: $ALLOWED_ISSUE_TYPES)."
157+
exit 1
158+
}
159+
160+
if [ -n "$REQUIRED_LABEL" ]; then
161+
echo "$LABELS" | tr ',' '\n' | grep -Fxq "$REQUIRED_LABEL" || {
162+
echo "Required label '$REQUIRED_LABEL' not present."
163+
exit 1
164+
}
165+
fi
166+
167+
- name: Guardrails - optional required custom field
168+
if: ${{ env.REQUIRED_CUSTOM_FIELD_ID != '' }}
169+
env:
170+
FIELD_ID: ${{ env.REQUIRED_CUSTOM_FIELD_ID }}
171+
run: |
172+
set -euo pipefail
173+
VAL=$(jq -r --arg f "$FIELD_ID" '.fields[$f] // empty' jira.json)
174+
if [ -z "$VAL" ] || [ "$VAL" = "false" ]; then
175+
echo "Required Jira field $FIELD_ID not set."
176+
exit 1
177+
fi
178+
179+
- name: Compute branch name
180+
id: branch
181+
env:
182+
JIRA_KEY: ${{ steps.jira.outputs.JIRA_KEY }}
183+
SUMMARY: ${{ steps.issue.outputs.SUMMARY }}
184+
BRANCH_PREFIX: ${{ env.BRANCH_PREFIX }}
185+
MAX_TITLE_SLUG_LEN: ${{ env.MAX_TITLE_SLUG_LEN }}
186+
run: |
187+
set -euo pipefail
188+
SAFE=$(echo "$SUMMARY" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9 -' | tr ' ' '-' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
189+
SAFE=$(echo "$SAFE" | cut -c1-"$MAX_TITLE_SLUG_LEN")
190+
if [ -z "$SAFE" ]; then
191+
SAFE="ticket"
192+
fi
193+
BRANCH="${BRANCH_PREFIX}/${JIRA_KEY}-${SAFE}"
194+
echo "BRANCH=$BRANCH" >> $GITHUB_OUTPUT
195+
echo "BRANCH=$BRANCH" >> $GITHUB_ENV
196+
197+
- name: Ensure branch exists (idempotent)
198+
env:
199+
BASE_BRANCH: ${{ env.BASE_BRANCH }}
200+
run: |
201+
set -euo pipefail
202+
git fetch origin "$BASE_BRANCH"
203+
git fetch origin "$BRANCH" || true
204+
205+
if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
206+
echo "Branch exists on origin. Checking it out."
207+
git checkout -B "$BRANCH" "origin/$BRANCH"
208+
else
209+
echo "Creating new branch from $BASE_BRANCH."
210+
git checkout -B "$BRANCH" "origin/$BASE_BRANCH"
211+
fi
212+
213+
- name: Run Codex to implement ticket
214+
uses: openai/codex-action@94bb7a052e529936e5260a35838e61b190855739 # v1
215+
with:
216+
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
217+
prompt: |
218+
You are implementing Jira ticket ${{ steps.jira.outputs.JIRA_KEY }} in this repository.
219+
220+
Ticket metadata:
221+
- Title: ${{ steps.issue.outputs.SUMMARY }}
222+
- Type: ${{ steps.issue.outputs.ISSUE_TYPE }}
223+
- Project: ${{ steps.issue.outputs.PROJECT_KEY }}
224+
- Description (ADF/JSON): ${{ steps.issue.outputs.DESC }}
225+
226+
Scope & guardrails:
227+
- Minimal, well-scoped change set; avoid refactors unless necessary.
228+
- Do NOT touch secrets, credentials, or CI config unless explicitly required.
229+
- Avoid these paths unless absolutely necessary:
230+
- .github/
231+
- infra/
232+
- terraform/
233+
- k8s/
234+
- deploy/
235+
- helm/
236+
237+
Python repo conventions (must follow):
238+
- Format: black .
239+
- Lint: flake8
240+
- Tests: pytest -q
241+
- Add/update tests when behavior changes.
242+
- Keep style consistent with existing code.
243+
244+
Before finishing:
245+
- Ensure black, flake8, and pytest pass in this workflow environment.
246+
247+
Operational constraints:
248+
- Implement changes directly in the checked-out branch.
249+
- Do not create additional branches.
250+
- Do not rewrite git history.
251+
252+
- name: Enforce forbidden paths policy
253+
env:
254+
LABELS: ${{ steps.issue.outputs.LABELS }}
255+
run: |
256+
set -euo pipefail
257+
FORBIDDEN_REGEX='^(\.github/|infra/|terraform/|k8s/|deploy/|helm/)'
258+
ALLOW_LABEL="codex-allow-infra"
259+
260+
if echo "$LABELS" | tr ',' '\n' | grep -Fxq "$ALLOW_LABEL"; then
261+
echo "Override label present ($ALLOW_LABEL); skipping forbidden-path check."
262+
exit 0
263+
fi
264+
265+
git fetch origin "$BASE_BRANCH"
266+
CHANGED=$(git diff --name-only "origin/$BASE_BRANCH...HEAD" || true)
267+
268+
if echo "$CHANGED" | grep -E "$FORBIDDEN_REGEX"; then
269+
echo "Forbidden paths modified. Add label '$ALLOW_LABEL' on Jira issue to allow."
270+
echo "$CHANGED" | sed 's/^/ - /'
271+
exit 1
272+
fi
273+
274+
- name: Run format, lint, tests
275+
env:
276+
FORMAT_COMMAND: ${{ env.FORMAT_COMMAND }}
277+
LINT_COMMAND: ${{ env.LINT_COMMAND }}
278+
TEST_COMMAND: ${{ env.TEST_COMMAND }}
279+
run: |
280+
set -euo pipefail
281+
eval "$FORMAT_COMMAND"
282+
eval "$LINT_COMMAND"
283+
eval "$TEST_COMMAND"
284+
285+
- name: Ensure there is a diff (fail-fast)
286+
run: |
287+
set -euo pipefail
288+
if git status --porcelain | grep .; then
289+
echo "Changes detected."
290+
else
291+
echo "No changes produced; failing to avoid empty PR."
292+
exit 1
293+
fi
294+
295+
- name: Commit & push (idempotent)
296+
env:
297+
JIRA_KEY: ${{ steps.jira.outputs.JIRA_KEY }}
298+
SUMMARY: ${{ steps.issue.outputs.SUMMARY }}
299+
run: |
300+
set -euo pipefail
301+
git add -A
302+
git commit -m "${JIRA_KEY}: ${SUMMARY}" || echo "Nothing new to commit."
303+
git push --set-upstream origin "$BRANCH"
304+
305+
- name: Create or update PR (idempotent)
306+
id: pr
307+
env:
308+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
309+
BASE_BRANCH: ${{ env.BASE_BRANCH }}
310+
JIRA_KEY: ${{ steps.jira.outputs.JIRA_KEY }}
311+
SUMMARY: ${{ steps.issue.outputs.SUMMARY }}
312+
run: |
313+
set -euo pipefail
314+
315+
EXISTING=$(gh pr list --head "$BRANCH" --json number,state,url --jq '.[0] // empty')
316+
317+
BODY_FILE="$(mktemp)"
318+
cat > "$BODY_FILE" <<EOF
319+
Automated implementation via Codex.
320+
321+
Jira: ${JIRA_KEY}
322+
323+
CI:
324+
- black
325+
- flake8
326+
- pytest
327+
328+
Notes:
329+
- Forbidden paths are blocked unless Jira has label: codex-allow-infra
330+
EOF
331+
332+
if [ -n "$EXISTING" ]; then
333+
NUM=$(echo "$EXISTING" | jq -r '.number')
334+
STATE=$(echo "$EXISTING" | jq -r '.state')
335+
URL=$(echo "$EXISTING" | jq -r '.url')
336+
337+
echo "Found existing PR #$NUM ($STATE): $URL"
338+
339+
if [ "$STATE" = "CLOSED" ]; then
340+
gh pr reopen "$NUM"
341+
fi
342+
343+
gh pr edit "$NUM" --title "${JIRA_KEY}: ${SUMMARY}" --body-file "$BODY_FILE" --base "$BASE_BRANCH"
344+
345+
echo "PR_URL=$URL" >> $GITHUB_OUTPUT
346+
echo "PR_NUMBER=$NUM" >> $GITHUB_OUTPUT
347+
else
348+
URL=$(gh pr create \
349+
--title "${JIRA_KEY}: ${SUMMARY}" \
350+
--body-file "$BODY_FILE" \
351+
--base "$BASE_BRANCH" \
352+
--head "$BRANCH")
353+
echo "Created PR: $URL"
354+
echo "PR_URL=$URL" >> $GITHUB_OUTPUT
355+
fi
356+
357+
- name: Comment back on Jira with PR link
358+
env:
359+
JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
360+
JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
361+
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
362+
JIRA_KEY: ${{ steps.jira.outputs.JIRA_KEY }}
363+
PR_URL: ${{ steps.pr.outputs.PR_URL }}
364+
run: |
365+
set -euo pipefail
366+
if [ -z "$PR_URL" ] || [ "$PR_URL" = "null" ]; then
367+
echo "No PR URL found; skipping Jira comment."
368+
exit 0
369+
fi
370+
371+
payload=$(jq -n --arg url "$PR_URL" '{
372+
body: {
373+
type: "doc",
374+
version: 1,
375+
content: [
376+
{
377+
type: "paragraph",
378+
content: [
379+
{type: "text", text: "PR created/updated: "},
380+
{type: "text", text: $url, marks: [{type: "link", attrs: {href: $url}}]}
381+
]
382+
}
383+
]
384+
}
385+
}')
386+
387+
curl -fsS --retry 3 --retry-all-errors -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \
388+
-H "Accept: application/json" \
389+
-H "Content-Type: application/json" \
390+
-X POST \
391+
--data "$payload" \
392+
"$JIRA_BASE_URL/rest/api/3/issue/$JIRA_KEY/comment" > /dev/null

0 commit comments

Comments
 (0)