diff --git a/AGENTS.md b/AGENTS.md index 9bb477b..983de82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,10 @@ For AI agents (Goose, Cursor, etc.) working on Platypus platform tooling: 1. [access/README.md](access/README.md) 2. [access/mcps/interlink-map.md](access/mcps/interlink-map.md) 3. [docs/how-we-work.md](docs/how-we-work.md) -4. [jira/rules/epic-preservation.yaml](jira/rules/epic-preservation.yaml) +4. [jira/recipes/jira-delivery-workflow.md](jira/recipes/jira-delivery-workflow.md) +5. [jira/rules/epic-preservation.yaml](jira/rules/epic-preservation.yaml) + +**Before coding a ticket:** run `jira/scripts/jira-context.sh PLAT-XXX` for scope (requires local Jira config). After merge, comment on the ticket and mark the correct issue level Done — see the recipe. **Rules** diff --git a/README.md b/README.md index d310f64..e3e3b3a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Platform tooling, **access documentation**, and Jira planning for the Platypus ( | Engineers | [access/README.md](access/README.md) → [docs/onboarding.md](docs/onboarding.md) | | Local config | `~/.config/platform-tools/config.yaml` — see [config/config.yaml.example](config/config.yaml.example) | | AI agents | [AGENTS.md](AGENTS.md) | -| PLAT planning | [docs/how-we-work.md](docs/how-we-work.md) → [jira/scripts/jira-plan.sh](jira/scripts/jira-plan.sh) | +| PLAT planning | [docs/how-we-work.md](docs/how-we-work.md) → [jira/recipes/jira-delivery-workflow.md](jira/recipes/jira-delivery-workflow.md) | +| Jira context CLI | `jira/scripts/jira-context.sh PLAT-XXX` | | Merge / PR rules | [docs/how-we-work.md#deployment-and-merge-process](docs/how-we-work.md#deployment-and-merge-process) | ## Links diff --git a/docs/how-we-work.md b/docs/how-we-work.md index 10741da..1fdfeb1 100644 --- a/docs/how-we-work.md +++ b/docs/how-we-work.md @@ -2,6 +2,8 @@ **last_updated:** 2026-08-28 +> **Workflow recipe:** [jira/recipes/jira-delivery-workflow.md](../jira/recipes/jira-delivery-workflow.md) — pull context, blockers, merge checklist, marking tickets Done. + ## PLAT planning loop Platform delivery is tracked on the [PLAT board](https://catalystsoftware.atlassian.net/jira/software/c/projects/PLAT/summary). This repo owns lightweight **jira-plan** tooling (read-before-write, human ack before apply): @@ -24,6 +26,83 @@ jira/scripts/jira-plan.sh --help See [jira-space-overview.md](jira-space-overview.md) for Epic → Story → Task hierarchy. +## Jira context and ticket lifecycle + +Delivery work should stay **traceable**: Jira describes *what* and *why*; git shows *how*. Use the steps below so tickets, PRs, and comments stay aligned. + +### Pull relevant context (before you branch) + +Start from the ticket in your PR title, not from memory. + +```bash +# Markdown summary: status, parent, links, siblings (privacy-safe formatting) +jira/scripts/jira-context.sh PLAT-XXX + +# Planning / dependency sweep +jira/scripts/jira-plan.sh pull +jira/scripts/jira-plan.sh review +``` + +| Source | What to extract | +|--------|-----------------| +| **Target ticket** | Summary, description, labels, current status | +| **Parent Story** | Acceptance criteria — your task should support these | +| **Epic** (`plat-ref:E0N` or parent epic) | Sequencing and dependencies ([dependency-rules.yaml](../jira/rules/dependency-rules.yaml)) | +| **Issue links** | `blocks` / `is blocked by` / `relates to` | +| **Siblings** | Other open tasks under the same story — avoid duplicate work | + +**Agents and IDE tools** can load `jira-context.sh` output as read-only scope. Do not copy customer names, tenant IDs, or credentials from Jira into commits or prompts. Obfuscate in ticket comments the same way as in git ([Privacy](#privacy)). + +Full step-by-step recipe: [jira/recipes/jira-delivery-workflow.md](../jira/recipes/jira-delivery-workflow.md). + +### Align git work with Jira + +| Step | Jira | Git | +|------|------|-----| +| Start | Ticket **In Progress** (or assign yourself) | Branch `ab/PLAT-XXX/short-topic` | +| Open PR | Ticket unchanged or comment “PR opened” | Title `PLAT-XXX: …`; body links ticket | +| Review | Respond to review questions in Jira if they affect AC | Address PR comments; push updates | +| Merge | Comment with **merged PR URL** | Merge to `main`; delete branch | + +Post-merge comment (no secrets, no customer PII): + +```bash +jira/scripts/jira-comment.sh PLAT-XXX "Merged: https://github.com/totango//pull/N — " +``` + +### Blockers + +When progress stops on external access, another team, or an unresolved decision: + +1. Create a new **PLAT** ticket — summary `Blocker: …`, label `blocker`. +2. Link it **`blocks`** the ticket you are working on. +3. Comment on the blocked ticket with the blocker key. +4. Do **not** mark the blocked ticket **Done** until the blocker is resolved or scope is renegotiated. + +Split unrelated follow-ups into **new** tickets instead of expanding the original scope silently. + +### Marking tickets Done + +Match Jira status to **actual** delivery — not “PR merged” alone when AC are unfinished. + +| Issue type | Move to Done when | +|------------|-------------------| +| **Subtask** | Single deliverable complete (usually one merged PR) | +| **Task** | Phase complete; no remaining subtasks for that chunk | +| **Story** | **All** acceptance criteria satisfied | +| **Epic** | Epic exit criteria met (see [jira-space-overview.md](jira-space-overview.md)) — typically not every PR | + +**After merge checklist** + +1. PR merged and branch removed. +2. Jira comment with PR link ([`jira-comment.sh`](../jira/scripts/jira-comment.sh)). +3. Close **Subtask/Task** if this PR fulfilled it. +4. Close **Story** only if every AC is done; otherwise file remaining work as new tasks. +5. Update or close **blocker** tickets. +6. File **follow-ups** as new PLAT tickets (link `relates to` parent story/epic). + +Transitions are done in the Jira UI today; `jira_list_transitions` in [`jira-api.sh`](../jira/scripts/lib/jira-api.sh) is available for future automation. + ## Local configuration Store machine-specific settings **outside the git repo** using the XDG-style path: @@ -151,6 +230,7 @@ Label doc-only PRs **`eng-information`**. 4. CI green (`validate-pr-title` required; doc checks when applicable). 5. **≥ 1 approval** from a teammate (soft rule — do not self-merge without review). 6. Squash or merge per team preference; delete the branch after merge. +7. **Jira hygiene** — comment with merged PR URL; transition Subtask/Task/Story per [Marking tickets Done](#marking-tickets-done); update blockers and file follow-ups as new tickets. ### Privacy at merge time diff --git a/docs/onboarding.md b/docs/onboarding.md index 2624363..0c4a257 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -8,8 +8,8 @@ 2. Read [access/README.md](../access/README.md) and [security.md](security.md). 3. Copy `config/config.yaml.example` → `~/.config/platform-tools/config.yaml` (local only). 4. Run `access/scripts/check-access.sh` — expect SKIP/FAIL until configured. -5. Join PLAT planning: [how-we-work.md](how-we-work.md). -6. Merges to `main`: PR title `PLAT-XXX: …`, ≥ 1 approval, CI green — see [Deployment and merge process](how-we-work.md#deployment-and-merge-process). +5. Join PLAT planning: [how-we-work.md](how-we-work.md) and [jira delivery recipe](../jira/recipes/jira-delivery-workflow.md). +6. Merges to `main`: PR title `PLAT-XXX: …`, ≥ 1 approval, CI green — see [Deployment and merge process](how-we-work.md#deployment-and-merge-process). After merge, comment on Jira and close the right ticket level. ## Agents diff --git a/jira/recipes/jira-delivery-workflow.md b/jira/recipes/jira-delivery-workflow.md new file mode 100644 index 0000000..5fc5811 --- /dev/null +++ b/jira/recipes/jira-delivery-workflow.md @@ -0,0 +1,112 @@ +# Recipe: Jira delivery workflow (PLAT) + +**last_updated:** 2026-08-28 +**tags:** eng-information, platform, jira +**audience:** engineers, agents + +Use this recipe when starting work, opening a PR, or closing out a ticket. It complements [how-we-work.md](../../docs/how-we-work.md) and is safe to load as agent context. + +--- + +## 1. Pull context before coding + +```bash +# Single ticket (markdown for humans/agents) +jira/scripts/jira-context.sh PLAT-XXX + +# Raw JSON for tooling +jira/scripts/jira-context.sh PLAT-XXX --json + +# Broader snapshot (planning / dependency review) +jira/scripts/jira-plan.sh pull +``` + +**Read in Jira (or from context output):** + +| Check | Why | +|-------|-----| +| Parent **Story** acceptance criteria | Your task should roll up to story outcomes | +| **Epic** (`plat-ref:E0N` label or parent) | Avoid work that conflicts with epic sequencing | +| **Linked issues** | Blockers, duplicates, relates-to | +| **Status** of siblings | Coordinate if another task owns the same surface | + +**Agents:** Use `jira-context.sh` output for scope only. Do not paste ticket bodies with customer names into commits. + +--- + +## 2. Branch and PR alignment + +| Artifact | Convention | +|----------|------------| +| Branch | `ab/PLAT-XXX/short-topic` or `platform-tools/PLAT-XXX/topic` | +| PR title | `PLAT-XXX: imperative summary` (CI validates) | +| PR body | Jira link, Summary, QA, Validation, Next steps — see [PR template](../../.github/pull_request_template.md) | +| Commits | Prefer `PLAT-XXX: …` in subject when practical | + +One **Subtask** ≈ one PR where possible. If scope grows, split a new PLAT ticket rather than overloading the original. + +--- + +## 3. During development — blockers + +Create a **blocker** ticket when work cannot proceed without another team, access, or decision. + +| Field | Guidance | +|-------|----------| +| **Type** | Task (or Bug if production defect) | +| **Summary** | `Blocker: — needs ` | +| **Link** | `blocks` → the ticket you cannot finish | +| **Labels** | `blocker`, area label (`platform`, `access-docs`, …) | +| **Description** | What you tried, what is missing, **no customer PII** | + +Comment on the blocked ticket with the new blocker key. Do **not** mark the original ticket Done while blocked. + +--- + +## 4. At merge — Jira + git hygiene + +After the PR merges to `main`: + +```bash +# 1. Comment with PR link (no secrets, no customer data) +jira/scripts/jira-comment.sh PLAT-XXX "Merged: https://github.com/totango/platform-tools/pull/N — " + +# 2. Transition in Jira UI (or list transitions for automation later) +# Subtask/Task → Done when PR scope is complete +# Story → Done only when ALL acceptance criteria are met +``` + +**Completion checklist** + +- [ ] PR merged; branch deleted +- [ ] Jira comment links the PR (or documents why not, e.g. doc-only internal change) +- [ ] **Subtask/Task** moved to **Done** if this PR fulfilled it +- [ ] **Story** moved to **Done** only if every AC is satisfied (or split remaining AC to new tasks) +- [ ] **Blocker** tickets updated: close if resolved, or leave open with comment +- [ ] Follow-up work filed as new PLAT tickets (not left as PR comment only) + +--- + +## 5. When to mark what Done + +| Level | Mark Done when | +|-------|----------------| +| **Subtask** | Single deliverable shipped (usually one merged PR) | +| **Task** | Phase complete; no open subtasks for this chunk | +| **Story** | All acceptance criteria met and demo-able | +| **Epic** | Exit criteria from [jira-space-overview.md](../../docs/jira-space-overview.md) — typically quarterly review, not every PR | + +If you only completed part of a Story, leave the Story **In Progress** and close the Task/Subtask you finished. + +--- + +## 6. Agent quick reference + +```text +START → jira-context.sh PLAT-XXX +WORK → branch ab/PLAT-XXX/… ; PR title PLAT-XXX: … +BLOCK → create blocker ticket ; link blocks ; comment +MERGE → jira-comment.sh ; transition Done on correct level +``` + +See [AGENTS.md](../../AGENTS.md) for repo read order and privacy rules. diff --git a/jira/scripts/jira-comment.sh b/jira/scripts/jira-comment.sh new file mode 100755 index 0000000..6956bbf --- /dev/null +++ b/jira/scripts/jira-comment.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Post a comment on a PLAT issue (e.g. PR merged, blocker note). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/lib/load-config.sh" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/lib/jira-api.sh" + +usage() { + cat <<'EOF' +jira-comment — add a comment to a PLAT issue + +Usage: + jira-comment.sh PLAT-XXX "Comment text" + jira-comment.sh PLAT-XXX --file path/to/comment.md + echo "text" | jira-comment.sh PLAT-XXX --stdin + +Use for PR links, merge notes, and blocker updates — not for secrets or customer data. +EOF +} + +die() { echo "jira-comment: error: $*" >&2; exit 1; } + +KEY="${1:-}" +[[ -n "$KEY" ]] || { usage; exit 2; } +shift + +body="" +if [[ "${1:-}" == "--file" ]]; then + [[ -n "${2:-}" ]] || die "missing --file path" + body="$(cat "$2")" +elif [[ "${1:-}" == "--stdin" ]]; then + body="$(cat)" +elif [[ -n "${1:-}" ]]; then + body="$*" +else + usage + exit 2 +fi + +[[ -n "$body" ]] || die "empty comment" + +payload="$(python3 -c ' +import json, sys +text = sys.argv[1] +print(json.dumps({"body": {"type": "doc", "version": 1, "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": text}]} +]}})) +' "$body")" + +result="$(jira_add_comment "$KEY" "$payload")" +comment_id="$(echo "$result" | jq -r '.id // empty')" +echo "Comment added to ${KEY}${comment_id:+ (id ${comment_id})}" diff --git a/jira/scripts/jira-context.sh b/jira/scripts/jira-context.sh new file mode 100755 index 0000000..0b1843f --- /dev/null +++ b/jira/scripts/jira-context.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Pull Jira issue context (summary, parent, links, children) for engineers and agents. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="${SCRIPT_DIR}/lib/jira-api.sh" +FORMATTER="${SCRIPT_DIR}/lib/jira-context.py" + +usage() { + cat <<'EOF' +jira-context — fetch PLAT ticket context for planning or agent prompts + +Usage: + jira-context.sh PLAT-XXX Markdown summary to stdout + jira-context.sh PLAT-XXX --json Raw issue + children JSON + jira-context.sh --help + +Requires jira.email and jira.api_token in ~/.config/platform-tools/config.yaml +EOF +} + +die() { echo "jira-context: error: $*" >&2; exit 1; } + +KEY="${1:-}" +FORMAT="markdown" +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi +[[ -n "$KEY" ]] || { usage; exit 2; } +if [[ "${2:-}" == "--json" ]]; then + FORMAT="json" +fi + +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/lib/load-config.sh" +# shellcheck source=/dev/null +source "$LIB" + +issue="$(jira_get_issue_full "$KEY")" +parent_key="$(echo "$issue" | jq -r '.fields.parent.key // empty')" +children_json='{"issues":[]}' +if [[ -n "$parent_key" ]]; then + children_json="$(jira_search "parent = ${parent_key} ORDER BY rank" 50)" +elif [[ "$(echo "$issue" | jq -r '.fields.issuetype.name')" =~ ^(Story|Epic|Task)$ ]]; then + children_json="$(jira_search "parent = ${KEY} ORDER BY rank" 50)" +fi + +payload="$(jq -n --argjson issue "$issue" --argjson children "$children_json" \ + '{issue: $issue, children: $children.issues}')" + +if [[ "$FORMAT" == "json" ]]; then + echo "$payload" | jq . +else + echo "$payload" | python3 "$FORMATTER" "$KEY" +fi diff --git a/jira/scripts/jira-plan.sh b/jira/scripts/jira-plan.sh index 89f9525..d85b726 100755 --- a/jira/scripts/jira-plan.sh +++ b/jira/scripts/jira-plan.sh @@ -20,6 +20,11 @@ Usage: jira-plan.sh ack [--run-id ID] Write approval.json (interactive stub) jira-plan.sh apply [--run-id ID] Refuse without approval.json +Related: + jira-context.sh PLAT-XXX Pull ticket context (markdown) + jira-comment.sh PLAT-XXX "text" Post merge/blocker comment + See jira/recipes/jira-delivery-workflow.md + Local config (~/.config/platform-tools/config.yaml): jira.email, jira.api_token, jira.site, jira.project See config/config.yaml.example diff --git a/jira/scripts/lib/jira-api.sh b/jira/scripts/lib/jira-api.sh index 902bd8d..c4acd11 100644 --- a/jira/scripts/lib/jira-api.sh +++ b/jira/scripts/lib/jira-api.sh @@ -43,6 +43,34 @@ jira_get_issue() { jira_api GET "/issue/${key}?fields=summary,labels,issuetype,parent" } +jira_get_issue_full() { + local key="$1" + jira_require_auth + jira_api GET "/issue/${key}?fields=summary,description,status,assignee,labels,issuetype,parent,issuelinks,subtasks" +} + +jira_add_comment() { + local key="$1" payload="$2" + jira_require_auth + jira_api POST "/issue/${key}/comment" -d "$payload" +} + +jira_list_transitions() { + local key="$1" + jira_require_auth + jira_api GET "/issue/${key}/transitions" +} + +jira_transition_issue() { + local key="$1" transition_id="$2" payload="${3:-}" + jira_require_auth + if [[ -n "$payload" ]]; then + jira_api POST "/issue/${key}/transitions" -d "$payload" + else + jira_api POST "/issue/${key}/transitions" -d "{\"transition\":{\"id\":\"${transition_id}\"}}" + fi +} + jira_create_issue() { local payload="$1" jira_require_auth diff --git a/jira/scripts/lib/jira-context.py b/jira/scripts/lib/jira-context.py new file mode 100644 index 0000000..2640cf6 --- /dev/null +++ b/jira/scripts/lib/jira-context.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Format Jira issue JSON as markdown context for engineers and agents.""" +from __future__ import annotations + +import json +import re +import sys +from typing import Any + + +def adf_to_text(node: Any) -> str: + if node is None: + return "" + if isinstance(node, str): + return node + if isinstance(node, list): + return "".join(adf_to_text(item) for item in node) + if not isinstance(node, dict): + return "" + node_type = node.get("type") + if node_type == "text": + return node.get("text", "") + if node_type == "hardBreak": + return "\n" + if node_type == "paragraph": + return adf_to_text(node.get("content", [])) + "\n" + if node_type in {"bulletList", "orderedList", "listItem"}: + return adf_to_text(node.get("content", [])) + if node_type == "heading": + level = node.get("attrs", {}).get("level", 2) + prefix = "#" * min(level, 6) + return f"{prefix} {adf_to_text(node.get('content', [])).strip()}\n\n" + return adf_to_text(node.get("content", [])) + + +def field_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + if "content" in value: + return adf_to_text(value).strip() + if "name" in value: + return str(value["name"]) + if "status" in value: + return field_text(value["status"]) + return str(value).strip() + + +def link_type_name(link: dict[str, Any]) -> str: + return link.get("type", {}).get("name", "relates to") + + +def format_issue(issue: dict[str, Any], children: list[dict[str, Any]] | None = None) -> str: + key = issue.get("key", "UNKNOWN") + fields = issue.get("fields", {}) + summary = fields.get("summary", "") + status = field_text(fields.get("status")) + issue_type = field_text(fields.get("issuetype")) + labels = fields.get("labels") or [] + parent = fields.get("parent") or {} + parent_key = parent.get("key", "") + parent_summary = parent.get("fields", {}).get("summary", "") + description = field_text(fields.get("description")) + assignee = field_text(fields.get("assignee")) or "unassigned" + links = fields.get("issuelinks") or [] + + lines = [ + f"# {key}: {summary}", + "", + f"- **Type:** {issue_type}", + f"- **Status:** {status}", + f"- **Assignee:** {assignee}", + ] + if parent_key: + lines.append(f"- **Parent:** {parent_key} — {parent_summary}") + if labels: + lines.append(f"- **Labels:** {', '.join(labels)}") + lines.append(f"- **URL:** https://catalystsoftware.atlassian.net/browse/{key}") + lines.append("") + + if description: + lines.extend(["## Description", "", description, ""]) + + if links: + lines.append("## Links") + for link in links: + outward = link.get("outwardIssue") + inward = link.get("inwardIssue") + rel = link_type_name(link) + if outward: + ok = outward.get("key", "") + osum = outward.get("fields", {}).get("summary", "") + lines.append(f"- {rel} → **{ok}** {osum}") + if inward: + ik = inward.get("key", "") + isum = inward.get("fields", {}).get("summary", "") + lines.append(f"- {rel} ← **{ik}** {isum}") + lines.append("") + + if children: + lines.append("## Child issues") + for child in children: + ck = child.get("key", "") + cs = child.get("fields", {}).get("summary", "") + cst = field_text(child.get("fields", {}).get("status")) + lines.append(f"- **{ck}** ({cst}) — {cs}") + lines.append("") + + lines.extend( + [ + "## Privacy reminder", + "", + "Do not paste customer names, tenant IDs, or credentials into git or agent context.", + "Use this summary for scope only; verify details in Jira.", + "", + ] + ) + return "\n".join(lines) + + +def extract_key(text: str) -> str: + match = re.search(r"\b(PLAT-\d+)\b", text, re.IGNORECASE) + if not match: + raise SystemExit(f"Could not parse PLAT key from: {text!r}") + return match.group(1).upper() + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: jira-context.py [issue.json]", file=sys.stderr) + return 2 + key = extract_key(sys.argv[1]) + if len(sys.argv) >= 3: + payload = json.load(open(sys.argv[2], encoding="utf-8")) + else: + payload = json.load(sys.stdin) + children = payload.get("children", []) + issue = payload.get("issue", payload) + print(format_issue(issue, children)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())