From fd6e3b0e82acc7e599ea438c0061a2719013aa85 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Wed, 10 Dec 2025 18:02:04 +0530 Subject: [PATCH 1/9] fix(bot): unify inactivity bot + DRY_RUN support + metadata fixes Signed-off-by: Akshat Kumar --- .../dry_run_inactivity_unassign_phase1.sh | 173 ------------------ .../dry_run_inactivity_unassign_phase2.sh | 148 --------------- .github/scripts/inactivity_unassign.sh | 154 ++++++++++++++++ .github/scripts/inactivity_unassign_phase1.sh | 135 -------------- .github/scripts/inactivity_unassign_phase2.sh | 161 ---------------- .../bot-inactivity-unassign-phase.yml | 41 +++++ .../bot-inactivity-unassign-phase1.yml | 38 ---- CHANGELOG.md | 1 + 8 files changed, 196 insertions(+), 655 deletions(-) delete mode 100755 .github/scripts/dry_run_inactivity_unassign_phase1.sh delete mode 100755 .github/scripts/dry_run_inactivity_unassign_phase2.sh create mode 100755 .github/scripts/inactivity_unassign.sh delete mode 100755 .github/scripts/inactivity_unassign_phase1.sh delete mode 100755 .github/scripts/inactivity_unassign_phase2.sh create mode 100644 .github/workflows/bot-inactivity-unassign-phase.yml delete mode 100644 .github/workflows/bot-inactivity-unassign-phase1.yml diff --git a/.github/scripts/dry_run_inactivity_unassign_phase1.sh b/.github/scripts/dry_run_inactivity_unassign_phase1.sh deleted file mode 100755 index 6d4991ce4..000000000 --- a/.github/scripts/dry_run_inactivity_unassign_phase1.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# =============================================================== -# DRY-RUN: Phase 1 Inactivity Unassign Bot -# - Uses assignment timestamp -# - Only considers OPEN PRs linked to the issue -# - Logs everything without making changes -# - Provides summary of users who would be unassigned -# =============================================================== - -REPO="${REPO:-}" -DAYS="${DAYS:-21}" - -if [ -z "$REPO" ]; then - echo "ERROR: REPO environment variable not set. Example: export REPO=owner/repo" - exit 1 -fi - -echo "------------------------------------------------------------" -echo " DRY RUN: Phase 1 Inactivity Unassign Check" -echo " Repo: $REPO" -echo " Threshold: $DAYS days" -echo "------------------------------------------------------------" -echo - -NOW_TS=$(date +%s) - -# ----------------------------- -# Cross-platform timestamp parsing -# ----------------------------- -parse_timestamp() { - local ts="$1" - if date --version >/dev/null 2>&1; then - date -d "$ts" +%s # GNU date - else - date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" # macOS/BSD - fi -} - -# ----------------------------- -# Array to store summary of users to message -# ----------------------------- -declare -a TO_UNASSIGN=() - -# ----------------------------- -# Fetch all open issues with assignees (skip PRs) -# ----------------------------- -ISSUES=$(gh api "repos/$REPO/issues" \ - --paginate \ - --jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number') - -if [ -z "$ISSUES" ]; then - echo "No open issues with assignees found." - exit 0 -fi - -echo "[INFO] Found issues: $ISSUES" -echo - -# ----------------------------- -# Iterate over issues -# ----------------------------- -for ISSUE in $ISSUES; do - echo "============================================================" - echo " ISSUE #$ISSUE" - echo "============================================================" - - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') - - if [ -z "$ASSIGNEES" ]; then - echo "[INFO] No assignees? Skipping." - echo - continue - fi - - echo "[INFO] Assignees: $ASSIGNEES" - echo - - # ----------------------------- - # Iterate over assignees - # ----------------------------- - for USER in $ASSIGNEES; do - echo " → Checking assignee: $USER" - - # ----------------------------- - # Find the assignment timestamp for this user - # ----------------------------- - ASSIGN_TS=$(gh api "repos/$REPO/issues/$ISSUE/events" \ - --jq ".[] | select(.event==\"assigned\" and .assignee.login==\"$USER\") | .created_at" | tail -n1) - - if [ -z "$ASSIGN_TS" ]; then - echo " [WARN] Could not find assignment timestamp. Using issue creation date as fallback." - ASSIGN_TS=$(echo "$ISSUE_JSON" | jq -r '.created_at') - fi - - ASSIGN_TS_SEC=$(parse_timestamp "$ASSIGN_TS") - DIFF_DAYS=$(( (NOW_TS - ASSIGN_TS_SEC) / 86400 )) - - echo " [INFO] Assigned at: $ASSIGN_TS" - echo " [INFO] Assigned for: $DIFF_DAYS days" - - # ----------------------------- - # Check if user has an OPEN PR linked to this issue - # ----------------------------- - PR_NUMBERS=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" \ - --jq ".[] - | select(.event == \"cross-referenced\") - | select(.source.issue.pull_request != null) - | select(.source.issue.user.login == \"$USER\") - | .source.issue.number") - - OPEN_PR_FOUND="" - for PR_NUM in $PR_NUMBERS; do - PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state') - if [ "$PR_STATE" = "OPEN" ]; then - OPEN_PR_FOUND="$PR_NUM" - break - fi - done - - if [ -n "$OPEN_PR_FOUND" ]; then - echo " [KEEP] User $USER has an OPEN PR linked to this issue: $OPEN_PR_FOUND → skip unassign." - echo - continue - fi - - echo " [RESULT] User $USER has NO OPEN PRs linked to this issue." - - # ----------------------------- - # Decide on DRY-RUN unassign - # ----------------------------- - if [ "$DIFF_DAYS" -ge "$DAYS" ]; then - UNASSIGN_MESSAGE="Hi @$USER, you have been assigned to this issue for $DIFF_DAYS days without any open PRs linked. You would be automatically unassigned to keep things tidy. Please re-assign yourself if you are still working on this." - - echo " [DRY RUN] Would UNASSIGN $USER (assigned for $DIFF_DAYS days, threshold $DAYS)" - echo " [DRY RUN] Message that would be posted:" - echo " --------------------------------------------------" - echo " $UNASSIGN_MESSAGE" - echo " --------------------------------------------------" - - TO_UNASSIGN+=("Issue #$ISSUE → $USER (assigned $DIFF_DAYS days) → Message: $UNASSIGN_MESSAGE") - else - echo " [KEEP] Only $DIFF_DAYS days old → NOT stale yet." - fi - - - echo - done - - echo -done - -# ----------------------------- -# Summary of all users to message -# ----------------------------- -if [ ${#TO_UNASSIGN[@]} -gt 0 ]; then - echo "============================================================" - echo " SUMMARY: Users who would be unassigned / messaged" - echo "============================================================" - for ITEM in "${TO_UNASSIGN[@]}"; do - echo " - $ITEM" - done -else - echo "No users would be unassigned in this dry-run." -fi - -echo "------------------------------------------------------------" -echo " DRY RUN COMPLETE — No changes were made." -echo "------------------------------------------------------------" diff --git a/.github/scripts/dry_run_inactivity_unassign_phase2.sh b/.github/scripts/dry_run_inactivity_unassign_phase2.sh deleted file mode 100755 index 4c48075a4..000000000 --- a/.github/scripts/dry_run_inactivity_unassign_phase2.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# DRY-RUN: Phase 2 Inactivity Unassign Bot -# - Does NOT change anything -# - Logs which PRs/issues WOULD be affected - -REPO="${REPO:-}" -DAYS="${DAYS:-21}" - -if [ -z "$REPO" ]; then - echo "ERROR: REPO environment variable not set." - echo "Example: export REPO=owner/repo" - exit 1 -fi - -echo "------------------------------------------------------------" -echo " DRY RUN: Phase 2 Inactivity Unassign (PR inactivity)" -echo " Repo: $REPO" -echo " Threshold: $DAYS days (no commit activity on PR)" -echo "------------------------------------------------------------" - -NOW_TS=$(date +%s) - -parse_ts() { - local ts="$1" - if date --version >/dev/null 2>&1; then - date -d "$ts" +%s - else - date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" - fi -} - -declare -a SUMMARY=() - -ISSUES=$(gh api "repos/$REPO/issues" \ - --paginate \ - --jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number') - -if [ -z "$ISSUES" ]; then - echo "No open issues with assignees found." - exit 0 -fi - -echo "[INFO] Found issues: $ISSUES" -echo - -for ISSUE in $ISSUES; do - echo "============================================================" - echo " ISSUE #$ISSUE" - echo "============================================================" - - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') - - if [ -z "$ASSIGNEES" ]; then - echo "[INFO] No assignees? Skipping." - echo - continue - fi - - echo "[INFO] Assignees: $ASSIGNEES" - echo - - for USER in $ASSIGNEES; do - echo " → Checking assignee: $USER" - - PR_NUMBERS=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" \ - --jq ".[] - | select(.event == \"cross-referenced\") - | select(.source.issue.pull_request != null) - | select(.source.issue.user.login == \"$USER\") - | .source.issue.number") - - if [ -z "$PR_NUMBERS" ]; then - echo " [INFO] No linked PRs by $USER for this issue." - echo - continue - fi - - echo " [INFO] Linked PRs by $USER: $PR_NUMBERS" - - STALE_PR="" - STALE_AGE_DAYS=0 - - for PR_NUM in $PR_NUMBERS; do - PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state') - - if [ "$PR_STATE" != "OPEN" ]; then - echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)." - continue - fi - - # Use the same "last commit" logic as the real bot (with pagination) - COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "") - - LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \ - | jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "") - - if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then - echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping." - continue - fi - - LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE") - AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 )) - - echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)" - - if [ "$AGE_DAYS" -ge "$DAYS" ]; then - STALE_PR="$PR_NUM" - STALE_AGE_DAYS="$AGE_DAYS" - break - fi - done - - if [ -z "$STALE_PR" ]; then - echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)." - echo - continue - fi - - echo " [DRY RUN] Would CLOSE PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)" - echo " [DRY RUN] Would UNASSIGN @$USER from issue #$ISSUE" - echo - - SUMMARY+=("Issue #$ISSUE → user @$USER → stale PR #$STALE_PR (no commits for $STALE_AGE_DAYS days)") - done - - echo -done - -if [ ${#SUMMARY[@]} -gt 0 ]; then - echo "============================================================" - echo " SUMMARY: Actions that WOULD be taken (no changes made)" - echo "============================================================" - for ITEM in "${SUMMARY[@]}"; do - echo " - $ITEM" - done -else - echo "No stale PRs / unassignments detected in this dry-run." -fi - -echo "------------------------------------------------------------" -echo " DRY RUN COMPLETE — No changes were made." -echo "------------------------------------------------------------" diff --git a/.github/scripts/inactivity_unassign.sh b/.github/scripts/inactivity_unassign.sh new file mode 100755 index 000000000..a4b689754 --- /dev/null +++ b/.github/scripts/inactivity_unassign.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Unified Inactivity Bot (Phase 1 + Phase 2) +# Supports DRY_RUN mode: +# DRY_RUN = 1 → simulate only (no changes) +# DRY_RUN = 0 → real actions + +REPO="${REPO:-${GITHUB_REPOSITORY:-}}" +DAYS="${DAYS:-21}" +DRY_RUN="${DRY_RUN:-0}" + +# Normalize DRY_RUN input ("true"/"false" → 1/0) +shopt -s nocasematch +case "$DRY_RUN" in + "true") DRY_RUN=1 ;; + "false") DRY_RUN=0 ;; +esac +shopt -u nocasematch + +if [[ -z "$REPO" ]]; then + echo "ERROR: REPO environment variable not set." + exit 1 +fi + +echo "------------------------------------------------------------" +echo " Unified Inactivity Unassign Bot" +echo " Repo: $REPO" +echo " Threshold: $DAYS days" +echo " DRY_RUN: $DRY_RUN" +echo "------------------------------------------------------------" + +NOW_TS=$(date +%s) + +# Converts GitHub timestamps → epoch +parse_ts() { + local ts="$1" + if date --version >/dev/null 2>&1; then + date -d "$ts" +%s + else + date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" + fi +} + +# Fetch all open issues that have assignees +ISSUES=$( + gh api "repos/$REPO/issues" --paginate \ + --jq '.[] + | select(.state=="open" and (.assignees|length>0) and (.pull_request|not)) + | .number' +) + +for ISSUE in $ISSUES; do + echo "============================================================" + echo " ISSUE #$ISSUE" + echo "============================================================" + + ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") + ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') + CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at') + CREATED_TS=$(parse_ts "$CREATED_AT") + + for USER in $ASSIGNEES; do + echo " → Checking assignee: $USER" + + # Fetch timeline (for PR cross-references) + TIMELINE=$(gh api \ + -H "Accept: application/vnd.github.mockingbird-preview+json" \ + "repos/$REPO/issues/$ISSUE/timeline" + ) + + # Filter only PRs from SAME repository + PR_NUMBERS=$(echo "$TIMELINE" | jq -r --arg repo "$REPO" ' + .[] + | select(.event == "cross-referenced") + | select(.source.issue.pull_request != null) + | select(.source.issue.repository.full_name == $repo) + | .source.issue.number + ') + + # ------------------------------- + # PHASE 1: ISSUE HAS NO PR + # ------------------------------- + if [[ -z "$PR_NUMBERS" ]]; then + AGE_DAYS=$(( (NOW_TS - CREATED_TS) / 86400 )) + echo " [INFO] Assigned for: ${AGE_DAYS} days" + + if (( AGE_DAYS >= DAYS )); then + echo " [PHASE 1 STALE] No PR linked + stale" + + if (( DRY_RUN == 0 )); then + gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" + echo " [ACTION] Unassigned $USER" + else + echo " [DRY RUN] Would unassign $USER" + fi + else + echo " [KEEP] Not stale yet" + fi + + continue + fi + + # ------------------------------- + # PHASE 2: ISSUE HAS PR(s) + # ------------------------------- + echo " [INFO] Linked PRs: $PR_NUMBERS" + + for PR_NUM in $PR_NUMBERS; do + + # Safe PR check + if ! PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state' 2>/dev/null); then + echo " [SKIP] #$PR_NUM is not a valid PR in $REPO" + continue + fi + + if [[ "$PR_STATE" != "OPEN" ]]; then + echo " [SKIP] PR #$PR_NUM is not open" + continue + fi + + # Last commit (paginate + last) + COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate) + LAST_TS_STR=$(echo "$COMMITS" | jq -r 'last | (.commit.committer.date // .commit.author.date)') + LAST_TS=$(parse_ts "$LAST_TS_STR") + + AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) + + echo " [INFO] PR #$PR_NUM → Last commit = $LAST_TS_STR (~${AGE_DAYS} days)" + + if (( AGE_DAYS >= DAYS )); then + echo " [STALE PR] PR #$PR_NUM is stale" + + if (( DRY_RUN == 0 )); then + gh pr close "$PR_NUM" --repo "$REPO" + gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" + echo " [ACTION] Closed PR + unassigned $USER" + else + echo " [DRY RUN] Would close PR #$PR_NUM + unassign $USER" + fi + else + echo " [KEEP] PR is active" + fi + + done + + done + +done + +echo "------------------------------------------------------------" +echo " Unified Inactivity Bot Complete" +echo " DRY_RUN: $DRY_RUN" +echo "------------------------------------------------------------" diff --git a/.github/scripts/inactivity_unassign_phase1.sh b/.github/scripts/inactivity_unassign_phase1.sh deleted file mode 100755 index c50b5a79c..000000000 --- a/.github/scripts/inactivity_unassign_phase1.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Env: -# GH_TOKEN - provided by GitHub Actions -# REPO - owner/repo (fallback to GITHUB_REPOSITORY) -# DAYS - inactivity threshold in days (default 21) - -REPO="${REPO:-${GITHUB_REPOSITORY:-}}" -DAYS="${DAYS:-21}" - -if [ -z "$REPO" ]; then - echo "ERROR: REPO environment variable not set." - exit 1 -fi - -echo "------------------------------------------------------------" -echo " Inactivity Unassign Bot (Phase 1)" -echo " Repo: $REPO" -echo " Threshold: $DAYS days" -echo "------------------------------------------------------------" -echo - -NOW_TS=$(date +%s) - -# Cross-platform timestamp parsing (Linux + macOS/BSD) -parse_ts() { - local ts="$1" - if date --version >/dev/null 2>&1; then - date -d "$ts" +%s # GNU date (Linux) - else - date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" # macOS/BSD - fi -} - -# Fetch open ISSUES (not PRs) that have assignees -ISSUES=$(gh api "repos/$REPO/issues" \ - --paginate \ - --jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number') - -if [ -z "$ISSUES" ]; then - echo "No open issues with assignees found." - exit 0 -fi - -for ISSUE in $ISSUES; do - echo "============================================================" - echo " ISSUE #$ISSUE" - echo "============================================================" - - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') - - if [ -z "$ASSIGNEES" ]; then - echo "[INFO] No assignees? Skipping." - echo - continue - fi - - echo "[INFO] Assignees: $ASSIGNEES" - echo - - for USER in $ASSIGNEES; do - echo " → Checking assignee: $USER" - - # 1) Find when THIS USER was last assigned to THIS ISSUE - ASSIGN_TS=$(gh api "repos/$REPO/issues/$ISSUE/events" \ - --jq ".[] | select(.event==\"assigned\" and .assignee.login==\"$USER\") | .created_at" \ - | tail -n1) - - if [ -z "$ASSIGN_TS" ]; then - echo " [WARN] No assignment event for $USER, falling back to issue creation." - ASSIGN_TS=$(echo "$ISSUE_JSON" | jq -r '.created_at') - fi - - ASSIGN_TS_SEC=$(parse_ts "$ASSIGN_TS") - DIFF_DAYS=$(( (NOW_TS - ASSIGN_TS_SEC) / 86400 )) - - echo " [INFO] Assigned at: $ASSIGN_TS" - echo " [INFO] Assigned for: $DIFF_DAYS days" - - # 2) Check for OPEN PRs linked to THIS ISSUE by THIS USER (via timeline) - PR_NUMBERS=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" \ - --jq ".[] - | select(.event == \"cross-referenced\") - | select(.source.issue.pull_request != null) - | select(.source.issue.user.login == \"$USER\") - | .source.issue.number") - - OPEN_PR_FOUND="" - for PR_NUM in $PR_NUMBERS; do - PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state') - if [ "$PR_STATE" = "OPEN" ]; then - OPEN_PR_FOUND="$PR_NUM" - break - fi - done - - if [ -n "$OPEN_PR_FOUND" ]; then - echo " [KEEP] $USER has OPEN PR #$OPEN_PR_FOUND linked to this issue → skip unassign." - echo - continue - fi - - echo " [RESULT] $USER has NO OPEN PRs linked to this issue." - - # 3) Decide unassign - if [ "$DIFF_DAYS" -lt "$DAYS" ]; then - echo " [KEEP] Only $DIFF_DAYS days (< $DAYS) → not stale yet." - echo - continue - fi - - echo " [UNASSIGN] $USER (assigned $DIFF_DAYS days, threshold $DAYS)" - - # Unassign via gh CLI helper - gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" - - # Comment - MESSAGE="Hi @$USER, you were automatically unassigned from this issue because there have been no open PRs linked to it for **$DIFF_DAYS days**. This helps keep issues available for contributors who are currently active. You're very welcome to re-assign yourself or pick this back up whenever you have time 🚀" - - gh issue comment "$ISSUE" --repo "$REPO" --body "$MESSAGE" - - echo " [DONE] Unassigned and commented on issue #$ISSUE for $USER." - echo - done - - echo -done - -echo "------------------------------------------------------------" -echo " Inactivity Unassign Bot (Phase 1) complete." -echo "------------------------------------------------------------" \ No newline at end of file diff --git a/.github/scripts/inactivity_unassign_phase2.sh b/.github/scripts/inactivity_unassign_phase2.sh deleted file mode 100755 index af0ac5976..000000000 --- a/.github/scripts/inactivity_unassign_phase2.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Inactivity Unassign Bot (Phase 2 - PR inactivity) -# Env: -# GH_TOKEN - provided by GitHub Actions -# REPO - owner/repo (fallback to GITHUB_REPOSITORY) -# DAYS - inactivity threshold in days (default 21) - -REPO="${REPO:-${GITHUB_REPOSITORY:-}}" -DAYS="${DAYS:-21}" - -if [ -z "$REPO" ]; then - echo "ERROR: REPO environment variable not set." - exit 1 -fi - -echo "------------------------------------------------------------" -echo " Inactivity Unassign Bot (Phase 2 - PR inactivity)" -echo " Repo: $REPO" -echo " Threshold: $DAYS days (no commit activity on PR)" -echo "------------------------------------------------------------" - -NOW_TS=$(date +%s) - -# Cross-platform timestamp parsing (Linux + macOS/BSD) -parse_ts() { - local ts="$1" - if date --version >/dev/null 2>&1; then - # GNU date (Linux) - date -d "$ts" +%s - else - # macOS / BSD - date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" - fi -} - -# Fetch open ISSUES (not PRs) that have assignees -ISSUES=$(gh api "repos/$REPO/issues" \ - --paginate \ - --jq '.[] | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number') - -if [ -z "$ISSUES" ]; then - echo "No open issues with assignees found." - exit 0 -fi - -for ISSUE in $ISSUES; do - echo "============================================================" - echo " ISSUE #$ISSUE" - echo "============================================================" - - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') - - if [ -z "$ASSIGNEES" ]; then - echo "[INFO] No assignees? Skipping." - echo - continue - fi - - echo "[INFO] Assignees: $ASSIGNEES" - echo - - for USER in $ASSIGNEES; do - echo " → Checking assignee: $USER" - - # Find OPEN PRs linked to THIS issue, authored by THIS user - PR_NUMBERS=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" \ - --jq ".[] - | select(.event == \"cross-referenced\") - | select(.source.issue.pull_request != null) - | select(.source.issue.user.login == \"$USER\") - | .source.issue.number") - - if [ -z "$PR_NUMBERS" ]; then - echo " [INFO] No linked PRs by $USER for this issue → Phase 1 covers the no-PR case." - echo - continue - fi - - echo " [INFO] Linked PRs by $USER: $PR_NUMBERS" - - STALE_PR="" - STALE_AGE_DAYS=0 - - # Look for a stale OPEN PR - for PR_NUM in $PR_NUMBERS; do - PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state') - - if [ "$PR_STATE" != "OPEN" ]; then - echo " [SKIP] PR #$PR_NUM is not open ($PR_STATE)." - continue - fi - - # Last commit date on the PR (use API order + paginate, take last) - COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "") - - LAST_COMMIT_DATE=$(echo "$COMMITS_JSON" \ - | jq -r 'last | (.commit.committer.date // .commit.author.date)' 2>/dev/null || echo "") - - if [ -z "$LAST_COMMIT_DATE" ] || [ "$LAST_COMMIT_DATE" = "null" ]; then - echo " [WARN] Could not determine last commit date for PR #$PR_NUM, skipping." - continue - fi - - LAST_COMMIT_TS=$(parse_ts "$LAST_COMMIT_DATE") - AGE_DAYS=$(( (NOW_TS - LAST_COMMIT_TS) / 86400 )) - - echo " [INFO] PR #$PR_NUM last commit: $LAST_COMMIT_DATE (~${AGE_DAYS} days ago)" - - if [ "$AGE_DAYS" -ge "$DAYS" ]; then - STALE_PR="$PR_NUM" - STALE_AGE_DAYS="$AGE_DAYS" - break - fi - done - - if [ -z "$STALE_PR" ]; then - echo " [KEEP] No OPEN PR for $USER is stale (>= $DAYS days)." - echo - continue - fi - - echo " [STALE] PR #$STALE_PR by $USER has had no commit activity for $STALE_AGE_DAYS days (>= $DAYS)." - echo " [ACTION] Closing PR #$STALE_PR and unassigning @$USER from issue #$ISSUE." - - MESSAGE=$( - cat < Date: Wed, 10 Dec 2025 19:31:11 +0530 Subject: [PATCH 2/9] fix(bot): use assignment age and improve logging Signed-off-by: Akshat Kumar --- .github/scripts/inactivity_unassign.sh | 143 +++++++++++++++++-------- 1 file changed, 99 insertions(+), 44 deletions(-) diff --git a/.github/scripts/inactivity_unassign.sh b/.github/scripts/inactivity_unassign.sh index a4b689754..c590c25b0 100755 --- a/.github/scripts/inactivity_unassign.sh +++ b/.github/scripts/inactivity_unassign.sh @@ -2,15 +2,15 @@ set -euo pipefail # Unified Inactivity Bot (Phase 1 + Phase 2) -# Supports DRY_RUN mode: -# DRY_RUN = 1 → simulate only (no changes) -# DRY_RUN = 0 → real actions +# DRY_RUN: +# 1 → simulate only (no changes) +# 0 → real actions REPO="${REPO:-${GITHUB_REPOSITORY:-}}" DAYS="${DAYS:-21}" DRY_RUN="${DRY_RUN:-0}" -# Normalize DRY_RUN input ("true"/"false" → 1/0) +# Normalise DRY_RUN input ("true"/"false" → 1/0, case-insensitive) shopt -s nocasematch case "$DRY_RUN" in "true") DRY_RUN=1 ;; @@ -32,17 +32,19 @@ echo "------------------------------------------------------------" NOW_TS=$(date +%s) -# Converts GitHub timestamps → epoch +# Convert GitHub timestamp → unix epoch parse_ts() { local ts="$1" if date --version >/dev/null 2>&1; then + # GNU date date -d "$ts" +%s else + # macOS / BSD date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" fi } -# Fetch all open issues that have assignees +# Fetch all open issues with assignees (non-PRs) ISSUES=$( gh api "repos/$REPO/issues" --paginate \ --jq '.[] @@ -50,102 +52,155 @@ ISSUES=$( | .number' ) +if [[ -z "$ISSUES" ]]; then + echo "[INFO] No open issues with assignees found." +fi + for ISSUE in $ISSUES; do echo "============================================================" echo " ISSUE #$ISSUE" echo "============================================================" ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") + ISSUE_CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at') + ISSUE_CREATED_TS=$(parse_ts "$ISSUE_CREATED_AT") + ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') - CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at') - CREATED_TS=$(parse_ts "$CREATED_AT") + + echo " [INFO] Issue created at: $ISSUE_CREATED_AT" + + # Fetch timeline once per issue (used for assignment + PR links) + TIMELINE=$(gh api \ + -H "Accept: application/vnd.github.mockingbird-preview+json" \ + "repos/$REPO/issues/$ISSUE/timeline" + ) for USER in $ASSIGNEES; do + echo echo " → Checking assignee: $USER" - # Fetch timeline (for PR cross-references) - TIMELINE=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" + # ------------------------------- + # Determine assignment time for USER + # ------------------------------- + ASSIGNED_AT_STR=$( + echo "$TIMELINE" | jq -r --arg user "$USER" ' + [ .[] + | select(.event=="assigned" and .assignee.login==$user) + | .created_at + ] + | sort + | last // "null" + ' ) - # Filter only PRs from SAME repository - PR_NUMBERS=$(echo "$TIMELINE" | jq -r --arg repo "$REPO" ' - .[] - | select(.event == "cross-referenced") - | select(.source.issue.pull_request != null) - | select(.source.issue.repository.full_name == $repo) - | .source.issue.number - ') + ASSIGNMENT_SOURCE="assignment_event" + + if [[ -z "$ASSIGNED_AT_STR" || "$ASSIGNED_AT_STR" == "null" ]]; then + # Fallback: no explicit assignment event -> use issue creation time + ASSIGNED_AT_STR="$ISSUE_CREATED_AT" + ASSIGNMENT_SOURCE="issue_created_at (no explicit assignment event)" + fi + + ASSIGNED_TS=$(parse_ts "$ASSIGNED_AT_STR") + ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 )) + + echo " [INFO] Assignment source: $ASSIGNMENT_SOURCE" + echo " [INFO] Assigned at: $ASSIGNED_AT_STR (~${ASSIGNED_AGE_DAYS} days ago)" # ------------------------------- - # PHASE 1: ISSUE HAS NO PR + # Find linked PRs for THIS user in THIS repo # ------------------------------- + PR_NUMBERS=$( + echo "$TIMELINE" | jq -r --arg repo "$REPO" --arg user "$USER" ' + .[] + | select(.event == "cross-referenced") + | select(.source.issue.pull_request != null) + | select(.source.issue.repository.full_name == $repo) + | select(.source.issue.user.login == $user) + | .source.issue.number + ' + ) + if [[ -z "$PR_NUMBERS" ]]; then - AGE_DAYS=$(( (NOW_TS - CREATED_TS) / 86400 )) - echo " [INFO] Assigned for: ${AGE_DAYS} days" + echo " [INFO] Linked PRs: none" + else + echo " [INFO] Linked PRs: $PR_NUMBERS" + fi - if (( AGE_DAYS >= DAYS )); then - echo " [PHASE 1 STALE] No PR linked + stale" + # ============================================================ + # PHASE 1: ISSUE HAS NO PR FOR THIS USER + # ============================================================ + if [[ -z "$PR_NUMBERS" ]]; then + if (( ASSIGNED_AGE_DAYS >= DAYS )); then + echo " [RESULT] Phase 1 → no PR linked + stale (>= $DAYS days)" if (( DRY_RUN == 0 )); then gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" - echo " [ACTION] Unassigned $USER" + echo " [ACTION] Unassigned @$USER from issue #$ISSUE" else - echo " [DRY RUN] Would unassign $USER" + echo " [DRY RUN] Would unassign @$USER from issue #$ISSUE" fi else - echo " [KEEP] Not stale yet" + echo " [RESULT] Phase 1 → no PR linked but not stale (< $DAYS days) → KEEP" fi + # No PRs means no Phase 2 work required for this user continue fi - # ------------------------------- - # PHASE 2: ISSUE HAS PR(s) - # ------------------------------- - echo " [INFO] Linked PRs: $PR_NUMBERS" + # ============================================================ + # PHASE 2: ISSUE HAS PR(s) → check last commit activity + # ============================================================ + PHASE2_TOOK_ACTION=0 for PR_NUM in $PR_NUMBERS; do - - # Safe PR check + # Ensure PR exists in this repo if ! PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state' 2>/dev/null); then echo " [SKIP] #$PR_NUM is not a valid PR in $REPO" continue fi + echo " [INFO] PR #$PR_NUM state: $PR_STATE" + if [[ "$PR_STATE" != "OPEN" ]]; then echo " [SKIP] PR #$PR_NUM is not open" continue fi - # Last commit (paginate + last) + # Fetch all commits & take the last one (API order + paginate) COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate) LAST_TS_STR=$(echo "$COMMITS" | jq -r 'last | (.commit.committer.date // .commit.author.date)') LAST_TS=$(parse_ts "$LAST_TS_STR") + PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) - AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) - - echo " [INFO] PR #$PR_NUM → Last commit = $LAST_TS_STR (~${AGE_DAYS} days)" + echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)" - if (( AGE_DAYS >= DAYS )); then - echo " [STALE PR] PR #$PR_NUM is stale" + if (( PR_AGE_DAYS >= DAYS )); then + echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)" + PHASE2_TOOK_ACTION=1 if (( DRY_RUN == 0 )); then gh pr close "$PR_NUM" --repo "$REPO" gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" - echo " [ACTION] Closed PR + unassigned $USER" + echo " [ACTION] Closed PR #$PR_NUM and unassigned @$USER from issue #$ISSUE" else - echo " [DRY RUN] Would close PR #$PR_NUM + unassign $USER" + echo " [DRY RUN] Would close PR #$PR_NUM and unassign @$USER from issue #$ISSUE" fi + + # Per current spec, first stale PR per user/issue is enough + break else - echo " [KEEP] PR is active" + echo " [INFO] PR #$PR_NUM is active (< $DAYS days) → KEEP" fi - done + if (( PHASE2_TOOK_ACTION == 0 )); then + echo " [RESULT] Phase 2 → all linked PRs active or not applicable → KEEP" + fi + done + echo done echo "------------------------------------------------------------" From e649fe19dc98350c1d0d047bbcd5641f5d1c9d30 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Wed, 10 Dec 2025 21:19:20 +0530 Subject: [PATCH 3/9] fix(bot): restore comments and improve inactivity logging Signed-off-by: Akshat Kumar --- .github/scripts/inactivity_unassign.sh | 153 +++++++++--------- .../bot-inactivity-unassign-phase.yml | 10 +- 2 files changed, 86 insertions(+), 77 deletions(-) diff --git a/.github/scripts/inactivity_unassign.sh b/.github/scripts/inactivity_unassign.sh index c590c25b0..e260ed702 100755 --- a/.github/scripts/inactivity_unassign.sh +++ b/.github/scripts/inactivity_unassign.sh @@ -2,9 +2,9 @@ set -euo pipefail # Unified Inactivity Bot (Phase 1 + Phase 2) -# DRY_RUN: -# 1 → simulate only (no changes) -# 0 → real actions +# DRY_RUN controls behaviour: +# DRY_RUN = 1 → simulate only (no changes, just logs) +# DRY_RUN = 0 → real actions (comments, closes, unassigns) REPO="${REPO:-${GITHUB_REPOSITORY:-}}" DAYS="${DAYS:-21}" @@ -25,37 +25,33 @@ fi echo "------------------------------------------------------------" echo " Unified Inactivity Unassign Bot" -echo " Repo: $REPO" -echo " Threshold: $DAYS days" -echo " DRY_RUN: $DRY_RUN" +echo " Repo: $REPO" +echo " Threshold $DAYS days" +echo " DRY_RUN: $DRY_RUN" echo "------------------------------------------------------------" NOW_TS=$(date +%s) -# Convert GitHub timestamp → unix epoch +# Convert GitHub ISO timestamp → epoch seconds parse_ts() { local ts="$1" if date --version >/dev/null 2>&1; then - # GNU date + # GNU date (Linux) date -d "$ts" +%s else - # macOS / BSD + # BSD / macOS date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" fi } -# Fetch all open issues with assignees (non-PRs) +# Fetch all open issues with assignees (no PRs) ISSUES=$( gh api "repos/$REPO/issues" --paginate \ - --jq '.[] - | select(.state=="open" and (.assignees|length>0) and (.pull_request|not)) + --jq '.[] + | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) | .number' ) -if [[ -z "$ISSUES" ]]; then - echo "[INFO] No open issues with assignees found." -fi - for ISSUE in $ISSUES; do echo "============================================================" echo " ISSUE #$ISSUE" @@ -63,53 +59,48 @@ for ISSUE in $ISSUES; do ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") ISSUE_CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at') - ISSUE_CREATED_TS=$(parse_ts "$ISSUE_CREATED_AT") - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') echo " [INFO] Issue created at: $ISSUE_CREATED_AT" + echo - # Fetch timeline once per issue (used for assignment + PR links) - TIMELINE=$(gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" + # Fetch timeline once (used for assignment events + PR links) + TIMELINE=$( + gh api \ + -H "Accept: application/vnd.github.mockingbird-preview+json" \ + "repos/$REPO/issues/$ISSUE/timeline" ) for USER in $ASSIGNEES; do - echo echo " → Checking assignee: $USER" - # ------------------------------- - # Determine assignment time for USER - # ------------------------------- - ASSIGNED_AT_STR=$( - echo "$TIMELINE" | jq -r --arg user "$USER" ' + # Determine assignment timestamp for this user + ASSIGN_EVENT_JSON=$( + echo "$TIMELINE" | jq -c --arg user "$USER" ' [ .[] - | select(.event=="assigned" and .assignee.login==$user) - | .created_at - ] - | sort - | last // "null" + | select(.event == "assigned") + | select(.assignee.login == $user) + ] + | last // empty ' ) - ASSIGNMENT_SOURCE="assignment_event" - - if [[ -z "$ASSIGNED_AT_STR" || "$ASSIGNED_AT_STR" == "null" ]]; then - # Fallback: no explicit assignment event -> use issue creation time - ASSIGNED_AT_STR="$ISSUE_CREATED_AT" - ASSIGNMENT_SOURCE="issue_created_at (no explicit assignment event)" + if [[ -n "$ASSIGN_EVENT_JSON" && "$ASSIGN_EVENT_JSON" != "null" ]]; then + ASSIGNED_AT=$(echo "$ASSIGN_EVENT_JSON" | jq -r '.created_at') + ASSIGN_SOURCE="assignment_event" + else + # Fallback: use issue creation time when no explicit assignment event + ASSIGNED_AT="$ISSUE_CREATED_AT" + ASSIGN_SOURCE="issue_created_at (no explicit assignment event)" fi - ASSIGNED_TS=$(parse_ts "$ASSIGNED_AT_STR") + ASSIGNED_TS=$(parse_ts "$ASSIGNED_AT") ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 )) - echo " [INFO] Assignment source: $ASSIGNMENT_SOURCE" - echo " [INFO] Assigned at: $ASSIGNED_AT_STR (~${ASSIGNED_AGE_DAYS} days ago)" + echo " [INFO] Assignment source: $ASSIGN_SOURCE" + echo " [INFO] Assigned at: $ASSIGNED_AT (~${ASSIGNED_AGE_DAYS} days ago)" - # ------------------------------- - # Find linked PRs for THIS user in THIS repo - # ------------------------------- + # Determine PRs linked to this issue for this user PR_NUMBERS=$( echo "$TIMELINE" | jq -r --arg repo "$REPO" --arg user "$USER" ' .[] @@ -121,40 +112,50 @@ for ISSUE in $ISSUES; do ' ) + # =========================== + # PHASE 1: ISSUE HAS NO PR(s) + # =========================== if [[ -z "$PR_NUMBERS" ]]; then echo " [INFO] Linked PRs: none" - else - echo " [INFO] Linked PRs: $PR_NUMBERS" - fi - # ============================================================ - # PHASE 1: ISSUE HAS NO PR FOR THIS USER - # ============================================================ - if [[ -z "$PR_NUMBERS" ]]; then if (( ASSIGNED_AGE_DAYS >= DAYS )); then - echo " [RESULT] Phase 1 → no PR linked + stale (>= $DAYS days)" + echo " [RESULT] Phase 1 → stale assignment (>= $DAYS days, no PR)" if (( DRY_RUN == 0 )); then + MESSAGE=$( + cat </dev/null); then echo " [SKIP] #$PR_NUM is not a valid PR in $REPO" continue @@ -167,40 +168,48 @@ for ISSUE in $ISSUES; do continue fi - # Fetch all commits & take the last one (API order + paginate) - COMMITS=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate) - LAST_TS_STR=$(echo "$COMMITS" | jq -r 'last | (.commit.committer.date // .commit.author.date)') + COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate) + LAST_TS_STR=$(echo "$COMMITS_JSON" | jq -r 'last | (.commit.committer.date // .commit.author.date)') LAST_TS=$(parse_ts "$LAST_TS_STR") PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)" if (( PR_AGE_DAYS >= DAYS )); then + PHASE2_TOUCHED=1 echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)" - PHASE2_TOOK_ACTION=1 if (( DRY_RUN == 0 )); then + MESSAGE=$( + cat < Date: Thu, 11 Dec 2025 16:38:37 +0530 Subject: [PATCH 4/9] bot: unify inactivity unassign logic (Phase 1 + Phase 2) Signed-off-by: Akshat Kumar --- .github/scripts/inactivity_unassign.sh | 162 +++++++++++++------------ 1 file changed, 86 insertions(+), 76 deletions(-) diff --git a/.github/scripts/inactivity_unassign.sh b/.github/scripts/inactivity_unassign.sh index e260ed702..692e6defa 100755 --- a/.github/scripts/inactivity_unassign.sh +++ b/.github/scripts/inactivity_unassign.sh @@ -3,14 +3,14 @@ set -euo pipefail # Unified Inactivity Bot (Phase 1 + Phase 2) # DRY_RUN controls behaviour: -# DRY_RUN = 1 → simulate only (no changes, just logs) -# DRY_RUN = 0 → real actions (comments, closes, unassigns) +# DRY_RUN = 1 -> simulate only (no changes, just logs) +# DRY_RUN = 0 -> real actions (comments, closes, unassigns) REPO="${REPO:-${GITHUB_REPOSITORY:-}}" DAYS="${DAYS:-21}" DRY_RUN="${DRY_RUN:-0}" -# Normalise DRY_RUN input ("true"/"false" → 1/0, case-insensitive) +# Normalise DRY_RUN input ("true"/"false" -> 1/0, case-insensitive) shopt -s nocasematch case "$DRY_RUN" in "true") DRY_RUN=1 ;; @@ -30,26 +30,34 @@ echo " Threshold $DAYS days" echo " DRY_RUN: $DRY_RUN" echo "------------------------------------------------------------" +# current time (epoch seconds) NOW_TS=$(date +%s) -# Convert GitHub ISO timestamp → epoch seconds +# Convert GitHub ISO timestamp -> epoch seconds (works on Linux/BSD) parse_ts() { local ts="$1" if date --version >/dev/null 2>&1; then - # GNU date (Linux) date -d "$ts" +%s else - # BSD / macOS date -j -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +"%s" fi } -# Fetch all open issues with assignees (no PRs) +# Quick gh availability/auth checks +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh CLI not found. Install it and ensure it's on PATH." + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "WARN: gh auth status failed — ensure gh is logged in for non-dry runs." +fi + +# Get list of open issues with assignees (pagination via gh) ISSUES=$( - gh api "repos/$REPO/issues" --paginate \ - --jq '.[] - | select(.state=="open" and (.assignees | length > 0) and (.pull_request | not)) - | .number' + gh api "repos/$REPO/issues" --paginate --jq '.[] + | select(.state=="open" and (.assignees|length>0) and (.pull_request|not)) + | .number' 2>/dev/null || true ) for ISSUE in $ISSUES; do @@ -57,69 +65,70 @@ for ISSUE in $ISSUES; do echo " ISSUE #$ISSUE" echo "============================================================" - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE") - ISSUE_CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at') - ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[].login') + ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE" 2>/dev/null || echo "{}") + ISSUE_CREATED_AT=$(echo "$ISSUE_JSON" | jq -r '.created_at // empty') + ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '.assignees[]?.login' 2>/dev/null || true) - echo " [INFO] Issue created at: $ISSUE_CREATED_AT" + echo " [INFO] Issue created at: ${ISSUE_CREATED_AT:-(unknown)}" echo - # Fetch timeline once (used for assignment events + PR links) - TIMELINE=$( - gh api \ - -H "Accept: application/vnd.github.mockingbird-preview+json" \ - "repos/$REPO/issues/$ISSUE/timeline" - ) + # Fetch timeline once (used for assignment events + PR links). + # If gh fails, default to a valid empty JSON array so jq never blocks. + TIMELINE=$(gh api -H "Accept: application/vnd.github.mockingbird-preview+json" "repos/$REPO/issues/$ISSUE/timeline" 2>/dev/null || echo "[]") + TIMELINE=${TIMELINE:-'[]'} # defensive default (ensures valid JSON) + + # if there are no assignees, skip (defensive) + if [[ -z "${ASSIGNEES// }" ]]; then + echo " [INFO] No assignees for this issue, skipping." + echo + continue + fi for USER in $ASSIGNEES; do echo " → Checking assignee: $USER" - # Determine assignment timestamp for this user - ASSIGN_EVENT_JSON=$( - echo "$TIMELINE" | jq -c --arg user "$USER" ' - [ .[] - | select(.event == "assigned") - | select(.assignee.login == $user) - ] - | last // empty - ' - ) + # Determine assignment timestamp for this user: find last assigned event for this user + # Use here-string to pass TIMELINE into jq (prevents jq from reading stdin unexpectedly). + ASSIGN_EVENT_JSON=$(jq -c --arg user "$USER" ' + [ .[] | select(.event == "assigned") | select(.assignee.login == $user) ] | last // empty' <<<"$TIMELINE" 2>/dev/null || echo "") if [[ -n "$ASSIGN_EVENT_JSON" && "$ASSIGN_EVENT_JSON" != "null" ]]; then - ASSIGNED_AT=$(echo "$ASSIGN_EVENT_JSON" | jq -r '.created_at') + ASSIGNED_AT=$(echo "$ASSIGN_EVENT_JSON" | jq -r '.created_at // empty') ASSIGN_SOURCE="assignment_event" else - # Fallback: use issue creation time when no explicit assignment event - ASSIGNED_AT="$ISSUE_CREATED_AT" + # fallback: use issue creation time + ASSIGNED_AT="${ISSUE_CREATED_AT:-}" ASSIGN_SOURCE="issue_created_at (no explicit assignment event)" fi - ASSIGNED_TS=$(parse_ts "$ASSIGNED_AT") - ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 )) + # compute assignment age safely (if no timestamp, set to 0) + if [[ -n "$ASSIGNED_AT" ]]; then + ASSIGNED_TS=$(parse_ts "$ASSIGNED_AT") + ASSIGNED_AGE_DAYS=$(( (NOW_TS - ASSIGNED_TS) / 86400 )) + else + ASSIGNED_AGE_DAYS=0 + fi echo " [INFO] Assignment source: $ASSIGN_SOURCE" - echo " [INFO] Assigned at: $ASSIGNED_AT (~${ASSIGNED_AGE_DAYS} days ago)" - - # Determine PRs linked to this issue for this user - PR_NUMBERS=$( - echo "$TIMELINE" | jq -r --arg repo "$REPO" --arg user "$USER" ' - .[] - | select(.event == "cross-referenced") - | select(.source.issue.pull_request != null) - | select(.source.issue.repository.full_name == $repo) - | select(.source.issue.user.login == $user) - | .source.issue.number - ' - ) - - # =========================== - # PHASE 1: ISSUE HAS NO PR(s) - # =========================== - if [[ -z "$PR_NUMBERS" ]]; then + echo " [INFO] Assigned at: ${ASSIGNED_AT:-(unknown)} (~${ASSIGNED_AGE_DAYS} days ago)" + + # Determine PRs cross-referenced from the same repo + PR_NUMBERS=$(jq -r --arg repo "$REPO" ' + .[] | + select(.event == "cross-referenced") | + select(.source.issue.pull_request != null) | + select(.source.issue.repository.full_name == $repo) | + .source.issue.number' <<<"$TIMELINE" 2>/dev/null || true) + + # Defensive normalization: remove blank lines and spaces + PR_NUMBERS=$(echo "$PR_NUMBERS" | sed '/^[[:space:]]*$/d' || true) + + # PHASE 1: no PRs attached + if [[ -z "${PR_NUMBERS// }" ]]; then echo " [INFO] Linked PRs: none" if (( ASSIGNED_AGE_DAYS >= DAYS )); then - echo " [RESULT] Phase 1 → stale assignment (>= $DAYS days, no PR)" + echo " [RESULT] Phase 1 -> stale assignment (>= $DAYS days, no PR)" if (( DRY_RUN == 0 )); then MESSAGE=$( @@ -133,51 +142,53 @@ If you'd like to continue working on this later, feel free to get re-assigned or EOF ) - gh issue comment "$ISSUE" --repo "$REPO" --body "$MESSAGE" - gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" + gh issue comment "$ISSUE" --repo "$REPO" --body "$MESSAGE" || echo "WARN: couldn't post comment (gh error)" + gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" || echo "WARN: couldn't remove assignee (gh error)" echo " [ACTION] Commented and unassigned @$USER from issue #$ISSUE" else echo " [DRY RUN] Would comment + unassign @$USER from issue #$ISSUE (Phase 1 stale)" fi else - echo " [RESULT] Phase 1 → no PR linked but not stale (< $DAYS days) → KEEP" + echo " [RESULT] Phase 1 -> no PR linked but not stale (< $DAYS days) -> KEEP" fi echo continue fi - # =========================== - # PHASE 2: ISSUE HAS PR(s) - # =========================== + # PHASE 2: process linked PR(s) echo " [INFO] Linked PRs: $PR_NUMBERS" - PHASE2_TOUCHED=0 for PR_NUM in $PR_NUMBERS; do - # Safe PR existence check + # Safe check: verify PR exists in this repo if ! PR_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json state --jq '.state' 2>/dev/null); then echo " [SKIP] #$PR_NUM is not a valid PR in $REPO" continue fi + # log state and continue only if open echo " [INFO] PR #$PR_NUM state: $PR_STATE" - if [[ "$PR_STATE" != "OPEN" ]]; then echo " [SKIP] PR #$PR_NUM is not open" continue fi - COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate) - LAST_TS_STR=$(echo "$COMMITS_JSON" | jq -r 'last | (.commit.committer.date // .commit.author.date)') - LAST_TS=$(parse_ts "$LAST_TS_STR") - PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) + # get last commit time safely + COMMITS_JSON=$(gh api "repos/$REPO/pulls/$PR_NUM/commits" --paginate 2>/dev/null || echo "[]") + LAST_TS_STR=$(jq -r 'last? | (.commit.committer.date // .commit.author.date) // empty' <<<"$COMMITS_JSON" 2>/dev/null || echo "") + if [[ -n "$LAST_TS_STR" ]]; then + LAST_TS=$(parse_ts "$LAST_TS_STR") + PR_AGE_DAYS=$(( (NOW_TS - LAST_TS) / 86400 )) + else + PR_AGE_DAYS=$((DAYS+1)) # treat as stale if we cannot find commit timestamp + fi - echo " [INFO] PR #$PR_NUM last commit: $LAST_TS_STR (~${PR_AGE_DAYS} days ago)" + echo " [INFO] PR #$PR_NUM last commit: ${LAST_TS_STR:-(unknown)} (~${PR_AGE_DAYS} days ago)" if (( PR_AGE_DAYS >= DAYS )); then PHASE2_TOUCHED=1 - echo " [RESULT] Phase 2 → PR #$PR_NUM is stale (>= $DAYS days since last commit)" + echo " [RESULT] Phase 2 -> PR #$PR_NUM is stale (>= $DAYS days since last commit)" if (( DRY_RUN == 0 )); then MESSAGE=$( @@ -190,21 +201,20 @@ You're very welcome to open a new PR or ask to be re-assigned when you're ready EOF ) - gh pr comment "$PR_NUM" --repo "$REPO" --body "$MESSAGE" - gh pr close "$PR_NUM" --repo "$REPO" - gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" - + gh pr comment "$PR_NUM" --repo "$REPO" --body "$MESSAGE" || echo "WARN: couldn't comment on PR" + gh pr close "$PR_NUM" --repo "$REPO" || echo "WARN: couldn't close PR" + gh issue edit "$ISSUE" --repo "$REPO" --remove-assignee "$USER" || echo "WARN: couldn't remove assignee" echo " [ACTION] Commented on PR #$PR_NUM, closed it, and unassigned @$USER from issue #$ISSUE" else echo " [DRY RUN] Would comment, close PR #$PR_NUM, and unassign @$USER from issue #$ISSUE" fi else - echo " [INFO] PR #$PR_NUM is active (< $DAYS days) → KEEP" + echo " [INFO] PR #$PR_NUM is active (< $DAYS days) -> KEEP" fi done if (( PHASE2_TOUCHED == 0 )); then - echo " [RESULT] Phase 2 → all linked PRs active or not applicable → KEEP" + echo " [RESULT] Phase 2 -> all linked PRs active or not applicable -> KEEP" fi echo From 8552f3c72294c33118a0f951afde6f697db1aa65 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Thu, 11 Dec 2025 17:21:31 +0530 Subject: [PATCH 5/9] =?UTF-8?q?bot:=20unify=20inactivity=20unassign=20logi?= =?UTF-8?q?c=20(Phase=201=20+=20Phase=202)=20=E2=80=94=20squashed=20&=20si?= =?UTF-8?q?gned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Akshat Kumar --- CHANGELOG.md | 9 +- CONTRIBUTING.md | 3 +- docs/sdk_developers/training/executable.md | 357 ++++++++++++++++++ ...nt_create_transaction_create_with_alias.py | 180 +++++---- tests/unit/test_subscription_handle.py | 28 ++ 5 files changed, 510 insertions(+), 67 deletions(-) create mode 100644 docs/sdk_developers/training/executable.md create mode 100644 tests/unit/test_subscription_handle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9284af9ba..8f26fa472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. ### Added - Unified the inactivity-unassign bot into a single script with `DRY_RUN` support, and fixed handling of cross-repo PR references for stale detection. + +- Added unit tests for `SubscriptionHandle` class covering cancellation state, thread management, and join operations. + + +- Refactored `account_create_transaction_create_with_alias.py` example by splitting monolithic function into modular functions: `generate_main_and_alias_keys()`, `create_account_with_ecdsa_alias()`, `fetch_account_info()`, `print_account_summary()` (#1016) +- - Modularized `transfer_transaction_fungible` example by introducing `account_balance_query()` & `transfer_transaction()`.Renamed `transfer_tokens()` → `main()` -- Phase 2 of the inactivity-unassign bot:Automatically detects stale open pull requests (no commit activity for 21+ days), comments with a helpful InactivityBot message, closes the stale PR, and unassigns the contributor from the linked issue. +- Phase 2 of the inactivity-unassign bot: Automatically detects stale open pull requests (no commit activity for 21+ days), comments with a helpful InactivityBot message, closes the stale PR, and unassigns the contributor from the linked issue. - Added `__str__()` to CustomFixedFee and updated examples and tests accordingly. - Added unit tests for `crypto_utils` (#993) - Added a github template for good first issues @@ -26,6 +32,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1. - Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362) - Add TLS support with two-stage control (`set_transport_security()` and `set_verify_certificates()`) for encrypted connections to Hedera networks. TLS is enabled by default for hosted networks (mainnet, testnet, previewnet) and disabled for local networks (solo, localhost) (#855) - Add PR inactivity reminder bot for stale pull requests `.github/workflows/pr-inactivity-reminder-bot.yml` +- Add comprehensive training documentation for _Executable class `docs/sdk_developers/training/executable.md` ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67bfbb67c..b2974d7a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -166,4 +166,5 @@ Thank you for contributing to the Hiero Python SDK! 🎉 - **Need help or want to connect?** Join our community on Discord! See the **[Discord Joining Guide](docs/discord.md)** for detailed steps on how to join the LFDT server - **Quick Links:** - Join the main [Linux Foundation Decentralized Trust (LFDT) Discord Server](https://discord.gg/hyperledger). - - Go directly to the [#hiero-python-sdk channel](https://discord.com/channels/905194001349627914/1336494517544681563) \ No newline at end of file + - Go directly to the [#hiero-python-sdk channel](https://discord.com/channels/905194001349627914/1336494517544681563) + diff --git a/docs/sdk_developers/training/executable.md b/docs/sdk_developers/training/executable.md new file mode 100644 index 000000000..99422ae12 --- /dev/null +++ b/docs/sdk_developers/training/executable.md @@ -0,0 +1,357 @@ +# _Executable Class Training + +## Table of Contents + +- [Introduction to _Executable](#introduction-to-_executable) +- [Execution Flow](#execution-flow) +- [Retry Logic](#retry-logic) +- [Exponential backoff](#exponential-backoff) +- [Error Handling](#error-handling) +- [Logging & Debugging](#logging--debugging) +- [Practical Examples](#practical-examples) + +## Introduction to _Executable + * The _Executable class is the backbone of the Hedera SDK execution engine. It handles sending transactions and queries, retry logic, error mapping, and logging, allowing child classes (like Transaction and Query) to focus on business logic. + +```mermaid +graph TD; + _Executable-->Transaction; + _Executable-->Query; + Query-->TokenNftInfoQuery; + Query-->TokenInfoQuery; + Transaction-->TokenFreezeTransaction; + Transaction-->TokenDissociateTransaction; +``` + + +## Execution Flow + + - How _execute(client) works in the Hedera SDK? + + The typical execution flow for transactions and queries using the Executable interface follows these steps: + + 1. **Build** → Create the transaction/query with required parameters + 2. **FreezeWith(client)** → Locks the transaction for signing + 3. **Sign(privateKey)** → Add required signatures + 4. **Execute(client)** → Submit to the network + 5. **GetReceipt(client)** → Confirm success/failure + + + - Here’s how child classes hook into the execution pipeline: + + | Command | Description | + | --- | --- | + | `_make_request` | Build the protobuf request for this operation. Example: a transaction class serializes its body into a Transaction proto; a query class builds the appropriate query proto. | + | `_get_method(channel: _Channel) -> _Method` | Choose which gRPC stub method to call. You get service stubs from channel, then return _Method(transaction_func=...) for transactions or _Method(query_func=...) for queries. The executor calls _execute_method, which picks transaction if present, otherwise query. | + | `_map_status_error(response)` | Inspect the network response status and convert it to an appropriate exception (precheck/receipt). This lets the executor decide whether to raise or retry based on _should_retry. | + | `_should_retry(response) -> _ExecutionState` | _ExecutionState: Decide the execution state from the response/status: RETRY, FINISHED, ERROR, or EXPIRED. This drives the retry loop and backoff. | + | `_map_response(response, node_id, proto_request)` | Convert the raw gRPC/Proto response into the SDK’s response type (e.g., TransactionResponse, Query result) that gets returned to the caller. | + + +## Retry Logic + - Core Logic: + 1. Loop up to max_attempts times — The outer for loop tries the operation multiple times + 2. Exponential backoff — Each retry waits longer than the previous one + 3. Execute and check response — After execution, determine if we should retry, fail, or succeed + 4. Smart error handling — Different errors trigger different actions + +image + + +**_Retry logic = Try the operation, wait progressively longer between attempts, pick a different node if needed, and give up after max attempts. This makes the system resilient to temporary network hiccups._** + + +## Exponential backoff +Key Steps: + + * First retry: wait `_min_backoff` ms + * Second retry: wait 2× that + * Third retry: wait 4× that (doubling each time) + * Stops growing at `_max_backoff` + + _(Why? Gives the network time to recover between attempts without hammering it immediately.)_ + + Handling gRPC errors: + ```python + except grpc.RpcError as e: + err_persistant = f"Status: {e.code()}, Details: {e.details()}" + node = client.network._select_node() # Switch nodes + logger.trace("Switched to a different node...", "error", err_persistant) + continue # Retry with new node + ``` + Retryable gRPC codes: + + * `UNAVAILABLE — Node` down/unreachable + * `DEADLINE_EXCEEDED` — Request timeout + * `RESOURCE_EXHAUSTED` — Rate limited + * `INTERNAL` — Server error + + _(If the [gRPC](https://en.wikipedia.org/wiki/GRPC) call itself fails, switch to a different network node and retry.)_ + +## Error Handling + + * Mapping network errors to Python exceptions + Abstract method that child classes implement: + ```python + @abstractmethod + def _map_status_error(self, response): + """Maps a response status code to an appropriate error object.""" + raise NotImplementedError(...) + ``` + + - Precheck errors --> PrecheckError (e.g., invalid account, insufficient balance) + - Receipt errors --> ReceiptStatusError (e.g., transaction executed but failed) + - Other statuses --> Appropriate exception types based on the status code + + +* Retryable vs Fatal Errors + Determined by `_should_retry(response) → _ExecutionState`: + + ```python + @abstractmethod + def _should_retry(self, response) -> _ExecutionState: + """Determine whether the operation should be retried based on the response.""" + raise NotImplementedError(...) + ``` + + The response is checked via `_should_retry()` which returns one of four `Execution States`: + + | State | Action | + | :--------------| :---------------------------------------| + | **RETRY** | `Wait (backoff), then loop again` | + | **FINISHED** | `Success! Return the response` | + | **ERROR** | `Permanent failure, raise exception` | + | **EXPIRED** | `Request expired, raise exception` | + + + +## Logging & Debugging + +- Request ID tracking + * Unique request identifier per operation: + ```python + def _get_request_id(self): + """Format the request ID for the logger.""" + return f"{self.__class__.__name__}:{time.time_ns()}" + ``` + * Format: `ClassName:nanosecond_timestamp` (e.g., `TransferTransaction:1702057234567890123`) + * Unique per execution, allowing you to trace a single operation through logs + * Passed to every logger call for correlation + + * Used throughout execution: + ```python + logger.trace("Executing", "requestId", self._get_request_id(), "nodeAccountID", self.node_account_id, "attempt", attempt + 1, "maxAttempts", max_attempts) + logger.trace("Executing gRPC call", "requestId", self._get_request_id()) + logger.trace("Retrying request attempt", "requestId", request_id, "delay", current_backoff, ...) + ``` + + * At each attempt start: + ```python + logger.trace( + "Executing", + "requestId", self._get_request_id(), + "nodeAccountID", self.node_account_id, # Which node this attempt uses + "attempt", attempt + 1, # Current attempt (1-based) + "maxAttempts", max_attempts # Total allowed attempts + ) + ``` + * During gRPC call: + ```python + logger.trace("Executing gRPC call", "requestId", self._get_request_id()) + ``` + * After response received: + ```python + logger.trace( + f"{self.__class__.__name__} status received", + "nodeAccountID", self.node_account_id, + "network", client.network.network, # Network name (testnet, mainnet) + "state", execution_state.name, # RETRY, FINISHED, ERROR, EXPIRED + "txID", tx_id # Transaction ID if available + ) + ``` + + * Before backoff/retry: + ```python + logger.trace( + f"Retrying request attempt", + "requestId", request_id, + "delay", current_backoff, # Milliseconds to wait + "attempt", attempt, + "error", error # The error that triggered retry + ) + time.sleep(current_backoff * 0.001) # Convert ms to seconds + ``` + * Node switch on gRPC error: + ```python + logger.trace( + "Switched to a different node for the next attempt", + "error", err_persistant, + "from node", self.node_account_id, # Old node + "to node", node._account_id # New node + ) + ``` + * Final failure: + ```python + logger.error( + "Exceeded maximum attempts for request", + "requestId", self._get_request_id(), + "last exception being", err_persistant + ) + ``` + +- Tips for debugging transaction/query failures +1. Enable Trace Logging + * Capture detailed execution flow: + ```python + client.logger.set_level("trace") # or DEBUG + ``` + * What you'll see: + - _Every attempt number_ + - _Node account IDs being used_ + - _Backoff delays between retries_ + - _Status received at each stage_ + - _Node switches on errors_ + +2. Identify the Failure Type by Execution State +Execution State Flow: +``` +┌─ RETRY (0) +│ ├─ Network hiccup (gRPC error) +│ ├─ Temporary node issue +│ └─ Rate limiting (try after backoff) +│ +├─ FINISHED (1) +│ └─ Success ✓ (return response) +│ +├─ ERROR (2) +│ ├─ Precheck error (bad input) +│ ├─ Invalid account/permissions +│ ├─ Insufficient balance +│ └─ Permanent failure +│ +└─ EXPIRED (3) + └─ Transaction ID expired (timing issue) +``` + +3. Track Backoff Progression + Exponential backoff indicates retryable errors: + ```text + Attempt 1: (no backoff, first try) + Attempt 2: delay 250ms + Attempt 3: delay 500ms + Attempt 4: delay 1000ms (1s) + Attempt 5: delay 2000ms (2s) + Attempt 6: delay 4000ms (4s) + Attempt 7: delay 8000ms (8s, capped) + Attempt 8+: delay 8000ms (stays capped) + ``` + Interpretation: + * => Growing delays: System is retrying a transient issue → healthy behavior + * => Reaches cap (8s) multiple times: Network or node is struggling + * => Fails immediately (no backoff): Permanent error(precheck/validation) +4. Monitor Node Switches +Watch for node switching patterns in logs: + ```text + Switched to a different node + from node: 0.0.3 + to node: 0.0.4 + error: Status: UNAVAILABLE, Details: Node is offline + ``` +Healthy patterns: + * => Few switches (1-2 per 3+ attempts) + * => Changes due to network errors (gRPC failures) + +Problem patterns: + * => Rapid switches (multiple per attempt) + * => All nodes fail → network-wide issue + * => Always same node fails → that node may be down + +5. Cross-Reference Transaction ID with Hedera Explorer + For transactions, use the transaction ID to verify on the network: + ``` + # From logs, capture txID + txID: "0.0.123@1702057234.567890123" + + # Query Hedera mirror node + curl https://testnet.mirrornode.hedera.com/api/v1/transactions/0.0.123-1702057234-567890123 + ``` + What you'll find: + * => Actual execution result on the network + * => Receipt status + * => Gas used (for contract calls) + * => Confirms if transaction made it despite client-side errors + +6. Debug Specific Error Scenarios + +| Error | Cause | Debug Steps | +| :--- | :---: | :---: | +| MaxAttemptsError | Failed after max retries | Check backoff log; all nodes failing? | +| PrecheckError | Bad request (immediate fail)| Validate: account ID, amount, permissions | +| ReceiptStatusError| Executed but failed | Check transaction details, balance, contract logic| +| gRPC RpcError | Network issue | Check node status, firewall, internet | +| EXPIRED state | Transaction ID too old | Use fresh transaction ID, check system clock | + +7. Practical Debugging Workflow + * Step 1: Capture the request ID + ``` + From error output: requestId = TransferTransaction:1702057234567890123 + ``` + + * Step 2: Search logs for that request ID + ```text + grep "1702057234567890123" application.log + ``` + + * Step 3: Analyze the sequence + ``` + [TRACE] Executing attempt=1 nodeAccountID=0.0.3 ... + [TRACE] Executing gRPC call ... + [TRACE] Retrying request delay=250ms ... + [TRACE] Executing attempt=2 nodeAccountID=0.0.4 ... + [TRACE] Switched to a different node error=Status: UNAVAILABLE ... + [ERROR] Exceeded maximum attempts ... + ``` + + * Step 4: Determine root cause + * => Multiple retries with node switches → Network issue + * => Single attempt, immediate ERROR → Input validation issue + * => EXPIRED after 1+ attempts → Timeout/clock issue + + * Step 5: Take action + * => Network issue: Retry after delay, check network status + * => Input issue: Fix account ID, amount, permissions + * => Timeout: Increase max_backoff or check system clock + +8. Enable Verbose Logging for Production Issues + * For real-world debugging: + ```python + # Set higher log level before executing + client.logger.set_level("debug") + + # Or configure structured logging + import logging + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + ``` + Captures: + * => Request ID, attempt number, node ID + * => Backoff delays and progression + * => gRPC errors with status codes + * => Final error message with context + +Debug Checklist: + + * ✅ Confirm request ID appears in logs (means operation was attempted) + * ✅ Count attempts (did it retry or fail immediately?) + * ✅ Check execution states (RETRY → ERROR or RETRY → FINISHED?) + * ✅ Note node switches (gRPC errors or single node?) + * ✅ Verify backoff progression (exponential or capped?) + * ✅ Match final error to exception type (Precheck, Receipt, MaxAttempts, etc.) + * ✅ Cross-check transaction ID with Hedera explorer if available + +This lets you quickly identify whether a failure is transient (network), permanent (bad input), or rate-related (backoff didn't help). + +## Practical Examples + * [Token Association Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/tokens/token_associate_transaction.py) + * [Token Freeze Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/tokens/token_freeze_transaction.py) + * [Token Account Info Query Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/query/account_info_query.py) + diff --git a/examples/account/account_create_transaction_create_with_alias.py b/examples/account/account_create_transaction_create_with_alias.py index 9d8e6cade..21370e8cd 100644 --- a/examples/account/account_create_transaction_create_with_alias.py +++ b/examples/account/account_create_transaction_create_with_alias.py @@ -23,6 +23,7 @@ Client, PrivateKey, AccountCreateTransaction, + AccountInfo, AccountInfoQuery, Network, AccountId, @@ -49,83 +50,132 @@ def setup_client(): print("Error: Please check OPERATOR_ID and OPERATOR_KEY in your .env file.") sys.exit(1) -def create_account_with_separate_ecdsa_alias(client: Client) -> None: - """Create an account whose alias comes from a separate ECDSA key.""" - try: - print("\nSTEP 1: Generating main account key and separate ECDSA alias key...") - - # Main account key (can be any key type, here ed25519) - main_private_key = PrivateKey.generate() - main_public_key = main_private_key.public_key() - - # Separate ECDSA key used only for the EVM alias - alias_private_key = PrivateKey.generate("ecdsa") - alias_public_key = alias_private_key.public_key() - alias_evm_address = alias_public_key.to_evm_address() - - if alias_evm_address is None: - print("❌ Error: Failed to generate EVM address from alias ECDSA key.") - sys.exit(1) - - print(f"✅ Main account public key: {main_public_key}") - print(f"✅ Alias ECDSA public key: {alias_public_key}") - print(f"✅ Alias EVM address: {alias_evm_address}") - - print("\nSTEP 2: Creating the account with the EVM alias from the ECDSA key...") - - # Use the helper that accepts both the main key and the ECDSA alias key - transaction = ( - AccountCreateTransaction( - initial_balance=Hbar(5), - memo="Account with separate ECDSA alias", - ) - .set_key_with_alias(main_private_key, alias_public_key) - ) - # Freeze and sign: - # - operator key signs as payer (via client) - # - alias private key MUST sign to authorize the alias - transaction = ( - transaction.freeze_with(client) - .sign(alias_private_key) - ) +def generate_main_and_alias_keys() -> tuple[PrivateKey, PrivateKey]: + """Generate the main account key and a separate ECDSA alias key. + + Returns: + tuple: (main_private_key, alias_private_key) + """ + print("\nSTEP 1: Generating main account key and separate ECDSA alias key...") + + # Main account key (can be any key type, here ed25519) + main_private_key = PrivateKey.generate() + main_public_key = main_private_key.public_key() + + # Separate ECDSA key used only for the EVM alias + alias_private_key = PrivateKey.generate("ecdsa") + alias_public_key = alias_private_key.public_key() + alias_evm_address = alias_public_key.to_evm_address() + + if alias_evm_address is None: + print("❌ Error: Failed to generate EVM address from alias ECDSA key.") + sys.exit(1) - response = transaction.execute(client) - new_account_id = response.account_id + print(f"✅ Main account public key: {main_public_key}") + print(f"✅ Alias ECDSA public key: {alias_public_key}") + print(f"✅ Alias EVM address: {alias_evm_address}") - if new_account_id is None: - raise RuntimeError( - "AccountID not found in receipt. Account may not have been created." - ) + return main_private_key, alias_private_key - print(f"✅ Account created with ID: {new_account_id}\n") - account_info = ( - AccountInfoQuery() - .set_account_id(new_account_id) - .execute(client) +def create_account_with_ecdsa_alias( + client: Client, main_private_key: PrivateKey, alias_private_key: PrivateKey +) -> AccountId: + """Create an account with a separate ECDSA key as the EVM alias. + + Args: + client: The Hedera client. + main_private_key: The main account private key. + alias_private_key: The ECDSA private key for the EVM alias. + + Returns: + AccountId: The newly created account ID. + """ + print("\nSTEP 2: Creating the account with the EVM alias from the ECDSA key...") + + alias_public_key = alias_private_key.public_key() + + # Use the helper that accepts both the main key and the ECDSA alias key + transaction = ( + AccountCreateTransaction( + initial_balance=Hbar(5), + memo="Account with separate ECDSA alias", + ) + .set_key_with_alias(main_private_key, alias_public_key) + ) + + # Freeze and sign: + # - operator key signs as payer (via client) + # - alias private key MUST sign to authorize the alias + transaction = ( + transaction.freeze_with(client) + .sign(alias_private_key) + ) + + response = transaction.execute(client) + new_account_id = response.account_id + + if new_account_id is None: + raise RuntimeError( + "AccountID not found in receipt. Account may not have been created." ) - out = info_to_dict(account_info) - print("Account Info:") - print(json.dumps(out, indent=2) + "\n") + print(f"✅ Account created with ID: {new_account_id}\n") + return new_account_id - if account_info.contract_account_id is not None: - print( - f"✅ Contract Account ID (EVM alias on-chain): " - f"{account_info.contract_account_id}" - ) - else: - print("❌ Error: Contract Account ID (alias) does not exist.") - except Exception as error: - print(f"❌ Error: {error}") - sys.exit(1) +def fetch_account_info(client: Client, account_id: AccountId) -> AccountInfo: + """Fetch account information from the network. + + Args: + client: The Hedera client. + account_id: The account ID to query. + + Returns: + AccountInfo: The account info object. + """ + print("\nSTEP 3: Fetching account information...") + account_info = ( + AccountInfoQuery() + .set_account_id(account_id) + .execute(client) + ) + return account_info + + +def print_account_summary(account_info: AccountInfo) -> None: + """Print a summary of the account information. + + Args: + account_info: The account info object to display. + """ + out = info_to_dict(account_info) + print("Account Info:") + print(json.dumps(out, indent=2) + "\n") + + if account_info.contract_account_id is not None: + print( + f"✅ Contract Account ID (EVM alias on-chain): " + f"{account_info.contract_account_id}" + ) + else: + print("❌ Error: Contract Account ID (alias) does not exist.") + def main(): """Main entry point.""" - client = setup_client() - create_account_with_separate_ecdsa_alias(client) + try: + client = setup_client() + main_private_key, alias_private_key = generate_main_and_alias_keys() + account_id = create_account_with_ecdsa_alias( + client, main_private_key, alias_private_key + ) + account_info = fetch_account_info(client, account_id) + print_account_summary(account_info) + except Exception as error: + print(f"❌ Error: {error}") + sys.exit(1) if __name__ == "__main__": diff --git a/tests/unit/test_subscription_handle.py b/tests/unit/test_subscription_handle.py new file mode 100644 index 000000000..230680d6e --- /dev/null +++ b/tests/unit/test_subscription_handle.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock + +from hiero_sdk_python.utils.subscription_handle import SubscriptionHandle + + +def test_not_cancelled_by_default(): + handle = SubscriptionHandle() + assert not handle.is_cancelled() + + +def test_cancel_marks_as_cancelled(): + handle = SubscriptionHandle() + handle.cancel() + assert handle.is_cancelled() + + +def test_set_thread_and_join_calls_thread_join_with_timeout(): + handle = SubscriptionHandle() + mock_thread = Mock() + handle.set_thread(mock_thread) + handle.join(timeout=0.25) + mock_thread.join.assert_called_once_with(0.25) + + +def test_join_without_thread_raises_nothing(): + handle = SubscriptionHandle() + # should not raise + handle.join() From 27a0280056fe823855cfc108bf251a08625640d2 Mon Sep 17 00:00:00 2001 From: Akshat8510 Date: Thu, 11 Dec 2025 17:33:47 +0530 Subject: [PATCH 6/9] Delete docs/sdk_developers/training/executable.md Signed-off-by: Akshat8510 --- docs/sdk_developers/training/executable.md | 357 --------------------- 1 file changed, 357 deletions(-) delete mode 100644 docs/sdk_developers/training/executable.md diff --git a/docs/sdk_developers/training/executable.md b/docs/sdk_developers/training/executable.md deleted file mode 100644 index 99422ae12..000000000 --- a/docs/sdk_developers/training/executable.md +++ /dev/null @@ -1,357 +0,0 @@ -# _Executable Class Training - -## Table of Contents - -- [Introduction to _Executable](#introduction-to-_executable) -- [Execution Flow](#execution-flow) -- [Retry Logic](#retry-logic) -- [Exponential backoff](#exponential-backoff) -- [Error Handling](#error-handling) -- [Logging & Debugging](#logging--debugging) -- [Practical Examples](#practical-examples) - -## Introduction to _Executable - * The _Executable class is the backbone of the Hedera SDK execution engine. It handles sending transactions and queries, retry logic, error mapping, and logging, allowing child classes (like Transaction and Query) to focus on business logic. - -```mermaid -graph TD; - _Executable-->Transaction; - _Executable-->Query; - Query-->TokenNftInfoQuery; - Query-->TokenInfoQuery; - Transaction-->TokenFreezeTransaction; - Transaction-->TokenDissociateTransaction; -``` - - -## Execution Flow - - - How _execute(client) works in the Hedera SDK? - - The typical execution flow for transactions and queries using the Executable interface follows these steps: - - 1. **Build** → Create the transaction/query with required parameters - 2. **FreezeWith(client)** → Locks the transaction for signing - 3. **Sign(privateKey)** → Add required signatures - 4. **Execute(client)** → Submit to the network - 5. **GetReceipt(client)** → Confirm success/failure - - - - Here’s how child classes hook into the execution pipeline: - - | Command | Description | - | --- | --- | - | `_make_request` | Build the protobuf request for this operation. Example: a transaction class serializes its body into a Transaction proto; a query class builds the appropriate query proto. | - | `_get_method(channel: _Channel) -> _Method` | Choose which gRPC stub method to call. You get service stubs from channel, then return _Method(transaction_func=...) for transactions or _Method(query_func=...) for queries. The executor calls _execute_method, which picks transaction if present, otherwise query. | - | `_map_status_error(response)` | Inspect the network response status and convert it to an appropriate exception (precheck/receipt). This lets the executor decide whether to raise or retry based on _should_retry. | - | `_should_retry(response) -> _ExecutionState` | _ExecutionState: Decide the execution state from the response/status: RETRY, FINISHED, ERROR, or EXPIRED. This drives the retry loop and backoff. | - | `_map_response(response, node_id, proto_request)` | Convert the raw gRPC/Proto response into the SDK’s response type (e.g., TransactionResponse, Query result) that gets returned to the caller. | - - -## Retry Logic - - Core Logic: - 1. Loop up to max_attempts times — The outer for loop tries the operation multiple times - 2. Exponential backoff — Each retry waits longer than the previous one - 3. Execute and check response — After execution, determine if we should retry, fail, or succeed - 4. Smart error handling — Different errors trigger different actions - -image - - -**_Retry logic = Try the operation, wait progressively longer between attempts, pick a different node if needed, and give up after max attempts. This makes the system resilient to temporary network hiccups._** - - -## Exponential backoff -Key Steps: - - * First retry: wait `_min_backoff` ms - * Second retry: wait 2× that - * Third retry: wait 4× that (doubling each time) - * Stops growing at `_max_backoff` - - _(Why? Gives the network time to recover between attempts without hammering it immediately.)_ - - Handling gRPC errors: - ```python - except grpc.RpcError as e: - err_persistant = f"Status: {e.code()}, Details: {e.details()}" - node = client.network._select_node() # Switch nodes - logger.trace("Switched to a different node...", "error", err_persistant) - continue # Retry with new node - ``` - Retryable gRPC codes: - - * `UNAVAILABLE — Node` down/unreachable - * `DEADLINE_EXCEEDED` — Request timeout - * `RESOURCE_EXHAUSTED` — Rate limited - * `INTERNAL` — Server error - - _(If the [gRPC](https://en.wikipedia.org/wiki/GRPC) call itself fails, switch to a different network node and retry.)_ - -## Error Handling - - * Mapping network errors to Python exceptions - Abstract method that child classes implement: - ```python - @abstractmethod - def _map_status_error(self, response): - """Maps a response status code to an appropriate error object.""" - raise NotImplementedError(...) - ``` - - - Precheck errors --> PrecheckError (e.g., invalid account, insufficient balance) - - Receipt errors --> ReceiptStatusError (e.g., transaction executed but failed) - - Other statuses --> Appropriate exception types based on the status code - - -* Retryable vs Fatal Errors - Determined by `_should_retry(response) → _ExecutionState`: - - ```python - @abstractmethod - def _should_retry(self, response) -> _ExecutionState: - """Determine whether the operation should be retried based on the response.""" - raise NotImplementedError(...) - ``` - - The response is checked via `_should_retry()` which returns one of four `Execution States`: - - | State | Action | - | :--------------| :---------------------------------------| - | **RETRY** | `Wait (backoff), then loop again` | - | **FINISHED** | `Success! Return the response` | - | **ERROR** | `Permanent failure, raise exception` | - | **EXPIRED** | `Request expired, raise exception` | - - - -## Logging & Debugging - -- Request ID tracking - * Unique request identifier per operation: - ```python - def _get_request_id(self): - """Format the request ID for the logger.""" - return f"{self.__class__.__name__}:{time.time_ns()}" - ``` - * Format: `ClassName:nanosecond_timestamp` (e.g., `TransferTransaction:1702057234567890123`) - * Unique per execution, allowing you to trace a single operation through logs - * Passed to every logger call for correlation - - * Used throughout execution: - ```python - logger.trace("Executing", "requestId", self._get_request_id(), "nodeAccountID", self.node_account_id, "attempt", attempt + 1, "maxAttempts", max_attempts) - logger.trace("Executing gRPC call", "requestId", self._get_request_id()) - logger.trace("Retrying request attempt", "requestId", request_id, "delay", current_backoff, ...) - ``` - - * At each attempt start: - ```python - logger.trace( - "Executing", - "requestId", self._get_request_id(), - "nodeAccountID", self.node_account_id, # Which node this attempt uses - "attempt", attempt + 1, # Current attempt (1-based) - "maxAttempts", max_attempts # Total allowed attempts - ) - ``` - * During gRPC call: - ```python - logger.trace("Executing gRPC call", "requestId", self._get_request_id()) - ``` - * After response received: - ```python - logger.trace( - f"{self.__class__.__name__} status received", - "nodeAccountID", self.node_account_id, - "network", client.network.network, # Network name (testnet, mainnet) - "state", execution_state.name, # RETRY, FINISHED, ERROR, EXPIRED - "txID", tx_id # Transaction ID if available - ) - ``` - - * Before backoff/retry: - ```python - logger.trace( - f"Retrying request attempt", - "requestId", request_id, - "delay", current_backoff, # Milliseconds to wait - "attempt", attempt, - "error", error # The error that triggered retry - ) - time.sleep(current_backoff * 0.001) # Convert ms to seconds - ``` - * Node switch on gRPC error: - ```python - logger.trace( - "Switched to a different node for the next attempt", - "error", err_persistant, - "from node", self.node_account_id, # Old node - "to node", node._account_id # New node - ) - ``` - * Final failure: - ```python - logger.error( - "Exceeded maximum attempts for request", - "requestId", self._get_request_id(), - "last exception being", err_persistant - ) - ``` - -- Tips for debugging transaction/query failures -1. Enable Trace Logging - * Capture detailed execution flow: - ```python - client.logger.set_level("trace") # or DEBUG - ``` - * What you'll see: - - _Every attempt number_ - - _Node account IDs being used_ - - _Backoff delays between retries_ - - _Status received at each stage_ - - _Node switches on errors_ - -2. Identify the Failure Type by Execution State -Execution State Flow: -``` -┌─ RETRY (0) -│ ├─ Network hiccup (gRPC error) -│ ├─ Temporary node issue -│ └─ Rate limiting (try after backoff) -│ -├─ FINISHED (1) -│ └─ Success ✓ (return response) -│ -├─ ERROR (2) -│ ├─ Precheck error (bad input) -│ ├─ Invalid account/permissions -│ ├─ Insufficient balance -│ └─ Permanent failure -│ -└─ EXPIRED (3) - └─ Transaction ID expired (timing issue) -``` - -3. Track Backoff Progression - Exponential backoff indicates retryable errors: - ```text - Attempt 1: (no backoff, first try) - Attempt 2: delay 250ms - Attempt 3: delay 500ms - Attempt 4: delay 1000ms (1s) - Attempt 5: delay 2000ms (2s) - Attempt 6: delay 4000ms (4s) - Attempt 7: delay 8000ms (8s, capped) - Attempt 8+: delay 8000ms (stays capped) - ``` - Interpretation: - * => Growing delays: System is retrying a transient issue → healthy behavior - * => Reaches cap (8s) multiple times: Network or node is struggling - * => Fails immediately (no backoff): Permanent error(precheck/validation) -4. Monitor Node Switches -Watch for node switching patterns in logs: - ```text - Switched to a different node - from node: 0.0.3 - to node: 0.0.4 - error: Status: UNAVAILABLE, Details: Node is offline - ``` -Healthy patterns: - * => Few switches (1-2 per 3+ attempts) - * => Changes due to network errors (gRPC failures) - -Problem patterns: - * => Rapid switches (multiple per attempt) - * => All nodes fail → network-wide issue - * => Always same node fails → that node may be down - -5. Cross-Reference Transaction ID with Hedera Explorer - For transactions, use the transaction ID to verify on the network: - ``` - # From logs, capture txID - txID: "0.0.123@1702057234.567890123" - - # Query Hedera mirror node - curl https://testnet.mirrornode.hedera.com/api/v1/transactions/0.0.123-1702057234-567890123 - ``` - What you'll find: - * => Actual execution result on the network - * => Receipt status - * => Gas used (for contract calls) - * => Confirms if transaction made it despite client-side errors - -6. Debug Specific Error Scenarios - -| Error | Cause | Debug Steps | -| :--- | :---: | :---: | -| MaxAttemptsError | Failed after max retries | Check backoff log; all nodes failing? | -| PrecheckError | Bad request (immediate fail)| Validate: account ID, amount, permissions | -| ReceiptStatusError| Executed but failed | Check transaction details, balance, contract logic| -| gRPC RpcError | Network issue | Check node status, firewall, internet | -| EXPIRED state | Transaction ID too old | Use fresh transaction ID, check system clock | - -7. Practical Debugging Workflow - * Step 1: Capture the request ID - ``` - From error output: requestId = TransferTransaction:1702057234567890123 - ``` - - * Step 2: Search logs for that request ID - ```text - grep "1702057234567890123" application.log - ``` - - * Step 3: Analyze the sequence - ``` - [TRACE] Executing attempt=1 nodeAccountID=0.0.3 ... - [TRACE] Executing gRPC call ... - [TRACE] Retrying request delay=250ms ... - [TRACE] Executing attempt=2 nodeAccountID=0.0.4 ... - [TRACE] Switched to a different node error=Status: UNAVAILABLE ... - [ERROR] Exceeded maximum attempts ... - ``` - - * Step 4: Determine root cause - * => Multiple retries with node switches → Network issue - * => Single attempt, immediate ERROR → Input validation issue - * => EXPIRED after 1+ attempts → Timeout/clock issue - - * Step 5: Take action - * => Network issue: Retry after delay, check network status - * => Input issue: Fix account ID, amount, permissions - * => Timeout: Increase max_backoff or check system clock - -8. Enable Verbose Logging for Production Issues - * For real-world debugging: - ```python - # Set higher log level before executing - client.logger.set_level("debug") - - # Or configure structured logging - import logging - logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') - ``` - Captures: - * => Request ID, attempt number, node ID - * => Backoff delays and progression - * => gRPC errors with status codes - * => Final error message with context - -Debug Checklist: - - * ✅ Confirm request ID appears in logs (means operation was attempted) - * ✅ Count attempts (did it retry or fail immediately?) - * ✅ Check execution states (RETRY → ERROR or RETRY → FINISHED?) - * ✅ Note node switches (gRPC errors or single node?) - * ✅ Verify backoff progression (exponential or capped?) - * ✅ Match final error to exception type (Precheck, Receipt, MaxAttempts, etc.) - * ✅ Cross-check transaction ID with Hedera explorer if available - -This lets you quickly identify whether a failure is transient (network), permanent (bad input), or rate-related (backoff didn't help). - -## Practical Examples - * [Token Association Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/tokens/token_associate_transaction.py) - * [Token Freeze Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/tokens/token_freeze_transaction.py) - * [Token Account Info Query Example](https://github.com/hiero-ledger/hiero-sdk-python/blob/main/examples/query/account_info_query.py) - From 6756d639a289c7a88c7b33a4e9beda8e274e3827 Mon Sep 17 00:00:00 2001 From: Akshat8510 Date: Thu, 11 Dec 2025 17:34:20 +0530 Subject: [PATCH 7/9] Delete tests/unit/test_subscription_handle.py Signed-off-by: Akshat8510 --- tests/unit/test_subscription_handle.py | 28 -------------------------- 1 file changed, 28 deletions(-) delete mode 100644 tests/unit/test_subscription_handle.py diff --git a/tests/unit/test_subscription_handle.py b/tests/unit/test_subscription_handle.py deleted file mode 100644 index 230680d6e..000000000 --- a/tests/unit/test_subscription_handle.py +++ /dev/null @@ -1,28 +0,0 @@ -from unittest.mock import Mock - -from hiero_sdk_python.utils.subscription_handle import SubscriptionHandle - - -def test_not_cancelled_by_default(): - handle = SubscriptionHandle() - assert not handle.is_cancelled() - - -def test_cancel_marks_as_cancelled(): - handle = SubscriptionHandle() - handle.cancel() - assert handle.is_cancelled() - - -def test_set_thread_and_join_calls_thread_join_with_timeout(): - handle = SubscriptionHandle() - mock_thread = Mock() - handle.set_thread(mock_thread) - handle.join(timeout=0.25) - mock_thread.join.assert_called_once_with(0.25) - - -def test_join_without_thread_raises_nothing(): - handle = SubscriptionHandle() - # should not raise - handle.join() From c7113890df441559d4394fcb29554740515f74db Mon Sep 17 00:00:00 2001 From: Akshat8510 Date: Thu, 11 Dec 2025 17:34:43 +0530 Subject: [PATCH 8/9] Delete examples/account/account_create_transaction_create_with_alias.py Signed-off-by: Akshat8510 --- ...nt_create_transaction_create_with_alias.py | 182 ------------------ 1 file changed, 182 deletions(-) delete mode 100644 examples/account/account_create_transaction_create_with_alias.py diff --git a/examples/account/account_create_transaction_create_with_alias.py b/examples/account/account_create_transaction_create_with_alias.py deleted file mode 100644 index 21370e8cd..000000000 --- a/examples/account/account_create_transaction_create_with_alias.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -Example: Create an account using a separate ECDSA key for the EVM alias. - -This demonstrates: -- Using a "main" key for the account -- Using a separate ECDSA public key as the EVM alias -- The need to sign the transaction with the alias private key - -Usage: -- uv run -m examples.account.account_create_transaction_create_with_alias -- python -m examples.account.account_create_transaction_create_with_alias -(we use -m because we use the util `info_to_dict`) -""" - -import os -import sys -import json -from dotenv import load_dotenv - -from examples.utils import info_to_dict - -from hiero_sdk_python import ( - Client, - PrivateKey, - AccountCreateTransaction, - AccountInfo, - AccountInfoQuery, - Network, - AccountId, - Hbar, -) - -load_dotenv() -network_name = os.getenv("NETWORK", "testnet").lower() - - -def setup_client(): - """Setup Client.""" - network = Network(network_name) - print(f"Connecting to Hedera {network_name} network!") - client = Client(network) - - try: - operator_id = AccountId.from_string(os.getenv("OPERATOR_ID", "")) - operator_key = PrivateKey.from_string(os.getenv("OPERATOR_KEY", "")) - client.set_operator(operator_id, operator_key) - print(f"Client set up with operator id {client.operator_account_id}") - return client - except Exception: - print("Error: Please check OPERATOR_ID and OPERATOR_KEY in your .env file.") - sys.exit(1) - - -def generate_main_and_alias_keys() -> tuple[PrivateKey, PrivateKey]: - """Generate the main account key and a separate ECDSA alias key. - - Returns: - tuple: (main_private_key, alias_private_key) - """ - print("\nSTEP 1: Generating main account key and separate ECDSA alias key...") - - # Main account key (can be any key type, here ed25519) - main_private_key = PrivateKey.generate() - main_public_key = main_private_key.public_key() - - # Separate ECDSA key used only for the EVM alias - alias_private_key = PrivateKey.generate("ecdsa") - alias_public_key = alias_private_key.public_key() - alias_evm_address = alias_public_key.to_evm_address() - - if alias_evm_address is None: - print("❌ Error: Failed to generate EVM address from alias ECDSA key.") - sys.exit(1) - - print(f"✅ Main account public key: {main_public_key}") - print(f"✅ Alias ECDSA public key: {alias_public_key}") - print(f"✅ Alias EVM address: {alias_evm_address}") - - return main_private_key, alias_private_key - - -def create_account_with_ecdsa_alias( - client: Client, main_private_key: PrivateKey, alias_private_key: PrivateKey -) -> AccountId: - """Create an account with a separate ECDSA key as the EVM alias. - - Args: - client: The Hedera client. - main_private_key: The main account private key. - alias_private_key: The ECDSA private key for the EVM alias. - - Returns: - AccountId: The newly created account ID. - """ - print("\nSTEP 2: Creating the account with the EVM alias from the ECDSA key...") - - alias_public_key = alias_private_key.public_key() - - # Use the helper that accepts both the main key and the ECDSA alias key - transaction = ( - AccountCreateTransaction( - initial_balance=Hbar(5), - memo="Account with separate ECDSA alias", - ) - .set_key_with_alias(main_private_key, alias_public_key) - ) - - # Freeze and sign: - # - operator key signs as payer (via client) - # - alias private key MUST sign to authorize the alias - transaction = ( - transaction.freeze_with(client) - .sign(alias_private_key) - ) - - response = transaction.execute(client) - new_account_id = response.account_id - - if new_account_id is None: - raise RuntimeError( - "AccountID not found in receipt. Account may not have been created." - ) - - print(f"✅ Account created with ID: {new_account_id}\n") - return new_account_id - - -def fetch_account_info(client: Client, account_id: AccountId) -> AccountInfo: - """Fetch account information from the network. - - Args: - client: The Hedera client. - account_id: The account ID to query. - - Returns: - AccountInfo: The account info object. - """ - print("\nSTEP 3: Fetching account information...") - account_info = ( - AccountInfoQuery() - .set_account_id(account_id) - .execute(client) - ) - return account_info - - -def print_account_summary(account_info: AccountInfo) -> None: - """Print a summary of the account information. - - Args: - account_info: The account info object to display. - """ - out = info_to_dict(account_info) - print("Account Info:") - print(json.dumps(out, indent=2) + "\n") - - if account_info.contract_account_id is not None: - print( - f"✅ Contract Account ID (EVM alias on-chain): " - f"{account_info.contract_account_id}" - ) - else: - print("❌ Error: Contract Account ID (alias) does not exist.") - - -def main(): - """Main entry point.""" - try: - client = setup_client() - main_private_key, alias_private_key = generate_main_and_alias_keys() - account_id = create_account_with_ecdsa_alias( - client, main_private_key, alias_private_key - ) - account_info = fetch_account_info(client, account_id) - print_account_summary(account_info) - except Exception as error: - print(f"❌ Error: {error}") - sys.exit(1) - - -if __name__ == "__main__": - main() From c5dee4b2c36f56464294144043be462c342e1f39 Mon Sep 17 00:00:00 2001 From: Akshat8510 Date: Thu, 11 Dec 2025 17:34:59 +0530 Subject: [PATCH 9/9] Delete CONTRIBUTING.md Signed-off-by: Akshat8510 --- CONTRIBUTING.md | 170 ------------------------------------------------ 1 file changed, 170 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b2974d7a3..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,170 +0,0 @@ -# Contributing to the Hiero Python SDK - -Thank you for your interest in contributing to the Hiero Python SDK! - -## Table of Contents - -- [Ways to Contribute](#ways-to-contribute) - - [Code Contributions](#-code-contributions) - - [Bug Reports](#-bug-reports) - - [Feature Requests](#-feature-requests) - - [Blog Posts](#-blog-posts) -- [Developer Resources](#developer-resources) -- [Community & Support](#community--support) -- [Cheatsheet](#cheatsheet) -- [Common Issues](#common-issues) - ---- - -## Ways to Contribute - -### 💻 Code Contributions - -**Get started:** See [Steup Guide](docs/sdk_developers/setup.md) - -**Quick workflow:** -1. Find/create an issue → [Issues](https://github.com/hiero-ledger/hiero-sdk-python/issues) -2. Get assigned (comment "I'd like to work on this") -3. Follow the [Workflow Guide](docs/sdk_developers/workflow.md) -4. Submit a PR - -**Requirements:** -- Signed commits (GPG + DCO) - [Signing Guide](docs/sdk_developers/signing.md) -- Updated changelog - [Changelog Guide](docs/sdk_developers/changelog_entry.md) -- Tests pass -- Code quality checks - [Checklist](docs/sdk_developers/checklist.md) - - -#### ⚠️ A Note on Breaking Changes - -**Avoid breaking changes** when possible. If necessary: -1. Create a new issue explaining the benefits -2. Wait for approval -3. Submit as a separate PR with: - - Reasons for the change - - Backwards compatibility plan - - Tests - - Changelog documentation - ---- - -### 🐛 Bug Reports - -Found a bug? Help us fix it! - -**See here** → [Bug Reports](docs/sdk_developers/bug.md) - ---- - -### 💡 Feature Requests - -Have an idea? We'd love to hear it! - -1. **Search existing requests** - Avoid duplicates -2. **[Create a Feature Request](https://github.com/hiero-ledger/hiero-sdk-python/issues/new)** -3. **Describe:** - - What problem does it solve? - - How should it work? - - Example code (if applicable) - -**Want to implement it yourself?** Comment on the issue and we'll assign you! - ---- - -### 📝 Blog Posts - -Want to write about the Hiero Python SDK? - -We welcome blog posts! Whether you're sharing a tutorial, case study, or your experience building with the SDK, we'd love to feature your content. - -**Quick overview:** -- Blog posts are submitted to the [Hiero Website Repository](https://github.com/hiero-ledger/hiero-website) -- Written in Markdown with Hugo frontmatter -- Review process through PR - -**Full guide with step-by-step instructions:** [Blog Post Guide](docs/sdk_developers/blog.md) - ---- - -## Developer Resources - -### Essential Guides - -| Guide | What It Covers | -|-------|----------------| -| [Setup](docs/sdk_developers/setup.md) | Fork, clone, install, configure | -| [Workflow](docs/sdk_developers/workflow.md) | Branching, committing, PRs | -| [Signing](docs/sdk_developers/signing.md) | GPG + DCO commit signing | -| [Changelog](docs/sdk_developers/changelog_entry.md) | Writing changelog entries | -| [Checklist](docs/sdk_developers/checklist.md) | Pre-submission checklist | -| [Rebasing](docs/sdk_developers/rebasing.md) | Keeping branch updated | -| [Merge Conflicts](docs/sdk_developers/merge_conflicts.md) | Resolving conflicts | -| [Types](docs/sdk_developers/types.md) | Python type hints | -| [Linting](docs/sdk_developers/linting.md) | Code quality tools | - ---- - -## Cheatsheet - -### First-Time Setup -```bash -# Fork on GitHub, then: -git clone https://github.com/YOUR_USERNAME/hiero-sdk-python.git -cd hiero-sdk-python -git remote add upstream https://github.com/hiero-ledger/hiero-sdk-python.git - -# Install dependencies -curl -LsSf https://astral.sh/uv/install.sh | sh -uv sync -uv run python generate_proto.py -``` - -**Full setup:** [Setup Guide](docs/sdk_developers/setup.md) - -### Making a Contribution -```bash -# Start new work -git checkout main -git pull upstream main -git checkout -b "name-of-your-issue" - -# Make changes, then commit (signed!) -git add . -git commit -S -s -m "feat: add new feature" - -# Update changelog -# Edit CHANGELOG.md, add entry under [Unreleased] - -# Push and create PR -git push origin "name-of-your-issue" -``` - -**Full workflow:** [Workflow Guide](docs/sdk_developers/workflow.md) - -### Keeping Branch Updated -```bash -git checkout main -git pull upstream main -git checkout your-branch -git rebase main -S -``` - -**Full guide:** [Rebasing Guide](docs/sdk_developers/rebasing.md) - ---- - -## Common Issues - -**HELP! I have a an issue...** -No worries, we're here to help. But please first see the [Common Issues Guide](docs/common_issues.md). - - ---- - -Thank you for contributing to the Hiero Python SDK! 🎉 - -- **Need help or want to connect?** Join our community on Discord! See the **[Discord Joining Guide](docs/discord.md)** for detailed steps on how to join the LFDT server -- **Quick Links:** - - Join the main [Linux Foundation Decentralized Trust (LFDT) Discord Server](https://discord.gg/hyperledger). - - Go directly to the [#hiero-python-sdk channel](https://discord.com/channels/905194001349627914/1336494517544681563) -