From 15f4b47401a89668b858f8be49fa13b99bb9e285 Mon Sep 17 00:00:00 2001 From: 0xheartcode <0xheartcode@gmail.com> Date: Tue, 17 Mar 2026 12:51:19 +0100 Subject: [PATCH 1/2] feat: add support for multiple -m flags in create and comment git commit supports multiple -m flags to build multi-paragraph messages. git-issue-create and git-issue-comment now match this behavior: each additional -m appends a new paragraph separated by a blank line, so git issue create 'Bug title' -m 'Context' -m 'Steps to reproduce' produces a two-paragraph body. Single -m behavior is unchanged. Tests added for both commands. Closes #138 --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- bin/git-issue-comment | 9 +++++++-- bin/git-issue-create | 9 +++++++-- t/test-issue.sh | 37 +++++++++++++++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6fd227c..8c76a6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Platform bridges: `gh` (GitHub), `glab` (GitLab), REST API (Gitea/Forgejo). ## Key Commands ```bash -make test # Run all 282 tests +make test # Run all 284 tests make install # Install system-wide (/usr/local) git issue create "title" -l bug # Create issue with label git issue ls --format full # List issues with metadata diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5314fa9..a635dfa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thanks for your interest in contributing! This project welcomes contributions fr ```bash git clone https://github.com/remenoscodes/git-native-issue.git cd git-native-issue -make test # Run all 282 tests +make test # Run all 284 tests ``` ## Development Setup diff --git a/README.md b/README.md index 1ff9409..928041e 100644 --- a/README.md +++ b/README.md @@ -652,7 +652,7 @@ the deliverable that makes ecosystem adoption possible. make test ``` -282 tests: core (77), bridge (37), merge/fsck (21), QoL (22), validation (36), quality (59), edge cases (13), comment sync (8), concurrency (9). +284 tests: core (79), bridge (37), merge/fsck (21), QoL (22), validation (36), quality (59), edge cases (13), comment sync (8), concurrency (9). ## Performance Notes diff --git a/bin/git-issue-comment b/bin/git-issue-comment index bf821d1..5bd97d2 100755 --- a/bin/git-issue-comment +++ b/bin/git-issue-comment @@ -12,7 +12,7 @@ usage() { usage: git issue comment -m Options: - -m, --message Comment text (required) + -m, --message Comment text; use multiple times for paragraphs -h, --help Show this help EOF exit 1 @@ -42,7 +42,12 @@ do case "$1" in -m|--message) test $# -ge 2 || { echo "error: -m requires a value" >&2; exit 1; } - message="$2" + if test -n "$message" + then + message="$(printf '%s\n\n%s' "$message" "$2")" + else + message="$2" + fi shift 2 ;; -h|--help) diff --git a/bin/git-issue-create b/bin/git-issue-create index fe90f5a..7556a07 100755 --- a/bin/git-issue-create +++ b/bin/git-issue-create @@ -12,7 +12,7 @@ usage() { usage: git issue create [options] Options: - -m, --message <text> Issue description (body) + -m, --message <text> Issue description (body); use multiple times for paragraphs -l, --label <label> Add a label (can be repeated) -a, --assignee <email> Assign to someone -p, --priority <level> Set priority (low, medium, high, critical) @@ -34,7 +34,12 @@ do case "$1" in -m|--message) test $# -ge 2 || { echo "error: -m requires a value" >&2; exit 1; } - body="$2" + if test -n "$body" + then + body="$(printf '%s\n\n%s' "$body" "$2")" + else + body="$2" + fi shift 2 ;; -l|--label) diff --git a/t/test-issue.sh b/t/test-issue.sh index 64c8560..f08e95f 100755 --- a/t/test-issue.sh +++ b/t/test-issue.sh @@ -204,6 +204,24 @@ case "$body" in ;; esac +# ============================================================ +# TEST: create with multiple -m flags builds multi-paragraph body +# ============================================================ +run_test +setup_repo +git issue create "Multi-paragraph issue" -m "First paragraph" -m "Second paragraph" >/dev/null +ref="$(git for-each-ref --format='%(refname)' refs/issues/ | head -1)" +root="$(git rev-list --max-parents=0 "$ref")" +body="$(git log -1 --format='%b' "$root" | sed '/^[A-Z][A-Za-z-]*: /d')" +case "$body" in + *"First paragraph"*"Second paragraph"*) + pass "create with multiple -m flags builds multi-paragraph body" + ;; + *) + fail "create with multiple -m flags builds multi-paragraph body" "got: '$body'" + ;; +esac + # ============================================================ # TEST: create with priority # ============================================================ @@ -766,6 +784,25 @@ else fail "three comments create correct 4-commit chain" "got $total commits" fi +# ============================================================ +# TEST: comment with multiple -m flags builds multi-paragraph body +# ============================================================ +run_test +setup_repo +out="$(git issue create "Comment paragraph test" 2>&1)" +id="$(printf '%s' "$out" | sed 's/Created issue //')" +git issue comment "$id" -m "First paragraph" -m "Second paragraph" >/dev/null +ref="$(git for-each-ref --format='%(refname)' refs/issues/ | head -1)" +full="$(git log -1 --format='%B' "$ref")" +case "$full" in + *"First paragraph"*"Second paragraph"*) + pass "comment with multiple -m flags builds multi-paragraph body" + ;; + *) + fail "comment with multiple -m flags builds multi-paragraph body" "got: '$full'" + ;; +esac + # ============================================================ # TEST: state --state custom value # ============================================================ From 9d7862d2739f752151a16a0d382537bbe4077361 Mon Sep 17 00:00:00 2001 From: Emerson Soares <remenoscodes@gmail.com> Date: Tue, 7 Apr 2026 11:03:12 +0200 Subject: [PATCH 2/2] chore: save workspace state before machine reset --- ISSUE-FORMAT.md | 54 +- RELEASE-CHECKLIST-v1.2.1.md | 168 ++++ bin/git-issue-export | 16 +- bin/git-issue-export-gitea | 18 +- bin/git-issue-export-github | 18 +- bin/git-issue-export-gitlab | 18 +- bin/git-issue-import | 14 +- bin/git-issue-import-azuredevops | 547 +++++++++++++ t/fixtures/azuredevops-wiql-response.json | 13 + .../azuredevops-workitem-1-comments.json | 26 + t/fixtures/azuredevops-workitem-1.json | 28 + t/fixtures/azuredevops-workitem-2.json | 30 + t/fixtures/azuredevops-workitem-3.json | 34 + t/test-azuredevops-bridge.sh | 718 ++++++++++++++++++ 14 files changed, 1678 insertions(+), 24 deletions(-) create mode 100644 RELEASE-CHECKLIST-v1.2.1.md create mode 100755 bin/git-issue-import-azuredevops create mode 100644 t/fixtures/azuredevops-wiql-response.json create mode 100644 t/fixtures/azuredevops-workitem-1-comments.json create mode 100644 t/fixtures/azuredevops-workitem-1.json create mode 100644 t/fixtures/azuredevops-workitem-2.json create mode 100644 t/fixtures/azuredevops-workitem-3.json create mode 100644 t/test-azuredevops-bridge.sh diff --git a/ISSUE-FORMAT.md b/ISSUE-FORMAT.md index e67da9b..a3e5653 100644 --- a/ISSUE-FORMAT.md +++ b/ISSUE-FORMAT.md @@ -373,7 +373,8 @@ merge-trailers = *( trailer ) trailer = trailer-key ": " trailer-value CRLF trailer-key = "Labels" / "Assignee" / "Priority" / "Milestone" / "Fixed-By" / "Release" / "Reason" / "Provider-ID" / - "Title" / custom-trailer-key + "Provider-Comment-ID" / "Parent-ID" / "Title" / + custom-trailer-key custom-trailer-key = "X-" TEXT-NO-LF trailer-value = TEXT-NO-LF ; must not contain actual LF @@ -820,6 +821,22 @@ Export creates provider issues from local `refs/issues/` data: 2. Comment export: commits without trailers are treated as comments; commits with trailers are metadata changes (skipped) +#### Cross-Platform Export + +When the `--cross-platform` flag is passed, export implementations +MUST NOT skip issues with foreign `Provider-ID:` values. Instead, +they fall through to the new-issue creation path. After the first +cross-platform export, the issue gains a target `Provider-ID:` (e.g., +`github:owner/repo#42`), and subsequent exports enter normal sync mode. + +This enables importing from one provider (e.g., Azure DevOps) and +exporting to another (e.g., GitHub): + +``` +git issue import azuredevops:org/project --state all +git issue export github:owner/repo --cross-platform +``` + ### 8.3 Round-Trip Safety The `Provider-ID:` trailer ensures: @@ -849,10 +866,43 @@ Provider-ID: github:owner/repo#42 Format: `<provider>:<identifier>` -This enables: +### 9.1 Supported Providers + +| Provider | Format | Example | +|--------------|-----------------------------------------|------------------------------------------| +| GitHub | `github:<owner>/<repo>#<number>` | `github:myorg/myrepo#42` | +| GitLab | `gitlab:<group>/<project>#<number>` | `gitlab:team/app#17` | +| Gitea | `gitea:<owner>/<repo>#<number>` | `gitea:dev/api#5` | +| Forgejo | `forgejo:<owner>/<repo>#<number>` | `forgejo:forge/core#12` | +| Azure DevOps | `azuredevops:<org>/<project>#<id>` | `azuredevops:contoso/MyProject#1234` | + +Comments use `Provider-Comment-ID:` with a `#comment-<id>` suffix: + +``` +Provider-Comment-ID: github:owner/repo#comment-123456 +Provider-Comment-ID: azuredevops:org/project#comment-789 +``` + +### 9.2 Parent-ID Trailer + +For providers with hierarchical work items (e.g., Azure DevOps Epics, +Features, User Stories, Tasks), the `Parent-ID:` trailer records the +parent relationship: + +``` +Parent-ID: azuredevops:org/project#42 +``` + +This preserves the hierarchy from the source provider. Implementations +MAY use this trailer to reconstruct parent-child relationships. + +### 9.3 Identity + +Provider-ID enables: - Round-trip import/export without duplication - Cross-reference between local and remote issue IDs - Detecting already-imported issues during subsequent imports +- Cross-platform export (see Section 8.2) --- diff --git a/RELEASE-CHECKLIST-v1.2.1.md b/RELEASE-CHECKLIST-v1.2.1.md new file mode 100644 index 0000000..16114ed --- /dev/null +++ b/RELEASE-CHECKLIST-v1.2.1.md @@ -0,0 +1,168 @@ +# Release Checklist for v1.2.1 + +## ✅ Completed Steps + +- [x] Fixed all 10 bugs discovered during integration testing +- [x] Updated CHANGELOG.md with v1.2.1 entry +- [x] Created RELEASE-NOTES-v1.2.1.md +- [x] Updated version to 1.2.1 in bin/git-issue +- [x] Committed all changes (commit: eeba1c5) +- [x] Created git tag v1.2.1 +- [x] Comprehensive testing: 111/111 tests passing + +## 🚀 Next Steps + +### 1. Push to GitHub + +```bash +cd /Users/emersonsoares/source/remenoscodes.git-native-issue + +# Push commits +git push origin main + +# Push tag +git push origin v1.2.1 +``` + +### 2. Create GitHub Release + +Go to: https://github.com/remenoscodes/git-native-issue/releases/new + +**Tag:** v1.2.1 +**Title:** v1.2.1 - Critical Bug Fixes for Gitea/Forgejo Bridge +**Description:** Copy from RELEASE-NOTES-v1.2.1.md + +**Release highlights:** +```markdown +## 🐛 Critical Bug Fix Release + +v1.2.1 fixes **10 critical bugs** discovered during comprehensive integration testing. + +### What's Fixed + +- ✅ Import/export router argument passing +- ✅ Gitea label auto-creation with smart colors +- ✅ GitLab comment sync +- ✅ Optional authentication for public repos +- ✅ Better error messages +- ✅ Dry-run mode improvements + +### Testing +- **111/111 tests passing** +- All platforms validated: GitHub, GitLab, Gitea + +**Recommendation:** All v1.2.0 users should upgrade immediately. + +[See full release notes](./RELEASE-NOTES-v1.2.1.md) +``` + +### 3. Update Homebrew Formula + +**Repository:** https://github.com/remenoscodes/homebrew-git-native-issue +**Formula:** Formula/git-native-issue.rb + +**Changes needed:** +1. Update `url` to point to v1.2.1 tarball +2. Update `sha256` hash +3. Update `version` to "1.2.1" + +**Steps:** +```bash +# Get the tarball SHA256 +curl -L https://github.com/remenoscodes/git-native-issue/archive/refs/tags/v1.2.1.tar.gz | shasum -a 256 + +# Edit the formula +cd /path/to/homebrew-git-native-issue +edit Formula/git-native-issue.rb + +# Update these lines: + url "https://github.com/remenoscodes/git-native-issue/archive/refs/tags/v1.2.1.tar.gz" + sha256 "NEW_SHA256_HERE" + version "1.2.1" + +# Test the formula +brew install --build-from-source ./Formula/git-native-issue.rb +git issue version # Should show 1.2.1 + +# Commit and push +git add Formula/git-native-issue.rb +git commit -m "Update git-native-issue to v1.2.1 + +Critical bug fixes: +- Import/export router argument passing +- Gitea label auto-creation +- GitLab comment sync +- Better error handling +- Dry-run improvements + +Testing: 111/111 tests passing" + +git push origin main +``` + +### 4. Test Installation + +```bash +# Uninstall current version +brew uninstall git-native-issue + +# Update brew +brew update + +# Install new version +brew install remenoscodes/git-native-issue/git-native-issue + +# Verify version +git issue version # Should show 1.2.1 + +# Test basic functionality +git issue create "Test v1.2.1" -m "Testing new release" +git issue ls +``` + +### 5. Announce Release + +**Platforms to announce on:** +- [ ] GitHub Discussions (if enabled) +- [ ] Project README.md (update badge if showing version) +- [ ] Social media (if applicable) + +**Announcement template:** +``` +🎉 git-native-issue v1.2.1 released! + +Critical bug fixes for Gitea/Forgejo bridge: +✅ 10 bugs fixed +✅ Smart label auto-creation +✅ Better error messages +✅ 111/111 tests passing + +Upgrade: brew upgrade git-native-issue + +Details: https://github.com/remenoscodes/git-native-issue/releases/tag/v1.2.1 +``` + +## 📊 Release Statistics + +**Version:** 1.2.1 +**Release Date:** 2026-02-09 +**Bugs Fixed:** 10 +**Files Changed:** 8 (560 insertions, 143 deletions) +**Test Coverage:** 111/111 tests passing (100%) +**Platforms Validated:** GitHub, GitLab, Gitea +**Testing Duration:** ~2 hours comprehensive integration testing + +## 🔗 Important Links + +- **GitHub Repo:** https://github.com/remenoscodes/git-native-issue +- **Homebrew Tap:** https://github.com/remenoscodes/homebrew-git-native-issue +- **v1.2.1 Release:** https://github.com/remenoscodes/git-native-issue/releases/tag/v1.2.1 +- **Changelog:** CHANGELOG.md +- **Release Notes:** RELEASE-NOTES-v1.2.1.md + +## 📝 Notes + +- This is a **patch release** addressing critical bugs from v1.2.0 +- **No breaking changes** - existing workflows continue to work +- **Recommended for all users** - especially those using Gitea/Forgejo +- Next release (v1.3.0) could focus on additional platform support or features diff --git a/bin/git-issue-export b/bin/git-issue-export index 382089b..5f12f5b 100755 --- a/bin/git-issue-export +++ b/bin/git-issue-export @@ -10,10 +10,11 @@ usage() { usage: git issue export <provider> [options] Supported providers: - github:<owner>/<repo> GitHub repository - gitlab:<group>/<project> GitLab project - gitea:<owner>/<repo> Gitea repository - forgejo:<owner>/<repo> Forgejo repository + github:<owner>/<repo> GitHub repository + gitlab:<group>/<project> GitLab project + gitea:<owner>/<repo> Gitea repository + forgejo:<owner>/<repo> Forgejo repository + azuredevops:<org>/<project> Azure DevOps Boards Options vary by provider. Use 'git issue export <provider> --help' for provider-specific options. @@ -73,9 +74,14 @@ case "$provider" in forgejo:*/*) exec "$ISSUE_BIN_DIR/git-issue-export-gitea" "$@" ;; + azuredevops:*/*) + echo "error: Azure DevOps export is not yet implemented" >&2 + echo " use --cross-platform flag with another provider to export ADO-imported items" >&2 + exit 1 + ;; *) echo "error: unsupported or invalid provider '$provider'" >&2 - echo " supported: github:<owner>/<repo>, gitlab:<owner>/<project>, gitea:<owner>/<repo>, forgejo:<owner>/<repo>" >&2 + echo " supported: github:<owner>/<repo>, gitlab:<owner>/<project>, gitea:<owner>/<repo>, forgejo:<owner>/<repo>, azuredevops:<org>/<project>" >&2 exit 1 ;; esac diff --git a/bin/git-issue-export-gitea b/bin/git-issue-export-gitea index d388517..6c68021 100755 --- a/bin/git-issue-export-gitea +++ b/bin/git-issue-export-gitea @@ -14,6 +14,7 @@ usage: git issue export gitea:<owner>/<repo> [options] git issue export forgejo:<owner>/<repo> [options] Options: + --cross-platform Export issues imported from other providers (e.g., Azure DevOps) --url <url> Instance URL (default: https://gitea.com for gitea, https://codeberg.org for forgejo) --token <token> API token (or use GITEA_TOKEN/FORGEJO_TOKEN env var) --dry-run Show what would be exported without exporting @@ -30,6 +31,7 @@ EOF provider="" dry_run=0 +cross_platform=0 api_url="" api_token="" platform="" @@ -37,6 +39,10 @@ platform="" while test $# -gt 0 do case "$1" in + --cross-platform) + cross_platform=1 + shift + ;; --url) test $# -ge 2 || { echo "error: --url requires a value" >&2; exit 1; } api_url="$2" @@ -561,10 +567,14 @@ $cmt_body" continue ;; *) - # Foreign Provider-ID (from different source) - skipped=$((skipped + 1)) - printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" - continue + if test "$cross_platform" -eq 0 + then + # Foreign Provider-ID — skip (default behavior) + skipped=$((skipped + 1)) + printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" + continue + fi + # --cross-platform: fall through to export as new issue ;; esac fi diff --git a/bin/git-issue-export-github b/bin/git-issue-export-github index c5b4db6..fe1deca 100755 --- a/bin/git-issue-export-github +++ b/bin/git-issue-export-github @@ -12,6 +12,7 @@ usage() { usage: git issue export github:<owner>/<repo> [options] Options: + --cross-platform Export issues imported from other providers (e.g., Azure DevOps) --dry-run Show what would be exported without exporting -h, --help Show this help @@ -24,10 +25,15 @@ EOF provider="" dry_run=0 +cross_platform=0 while test $# -gt 0 do case "$1" in + --cross-platform) + cross_platform=1 + shift + ;; --dry-run) dry_run=1 shift @@ -448,10 +454,14 @@ $cmt_body" continue ;; *) - # Foreign Provider-ID (imported from a different repo), skip - skipped=$((skipped + 1)) - printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" - continue + if test "$cross_platform" -eq 0 + then + # Foreign Provider-ID — skip (default behavior) + skipped=$((skipped + 1)) + printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" + continue + fi + # --cross-platform: fall through to export as new issue ;; esac fi diff --git a/bin/git-issue-export-gitlab b/bin/git-issue-export-gitlab index 2905e55..13dd939 100755 --- a/bin/git-issue-export-gitlab +++ b/bin/git-issue-export-gitlab @@ -12,6 +12,7 @@ usage() { usage: git issue export gitlab:<group>/<project> [options] Options: + --cross-platform Export issues imported from other providers (e.g., Azure DevOps) --dry-run Show what would be exported without exporting -h, --help Show this help @@ -24,10 +25,15 @@ EOF provider="" dry_run=0 +cross_platform=0 while test $# -gt 0 do case "$1" in + --cross-platform) + cross_platform=1 + shift + ;; --dry-run) dry_run=1 shift @@ -413,10 +419,14 @@ $cmt_body" continue ;; *) - # Foreign Provider-ID (from different source) - skipped=$((skipped + 1)) - printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" - continue + if test "$cross_platform" -eq 0 + then + # Foreign Provider-ID — skip (default behavior) + skipped=$((skipped + 1)) + printf 'Skipped %s (imported from %s)\n' "$short_id" "$existing_pid" + continue + fi + # --cross-platform: fall through to export as new issue ;; esac fi diff --git a/bin/git-issue-import b/bin/git-issue-import index 54a78f7..655a374 100755 --- a/bin/git-issue-import +++ b/bin/git-issue-import @@ -10,10 +10,11 @@ usage() { usage: git issue import <provider> [options] Supported providers: - github:<owner>/<repo> GitHub repository - gitlab:<group>/<project> GitLab project - gitea:<owner>/<repo> Gitea repository - forgejo:<owner>/<repo> Forgejo repository + github:<owner>/<repo> GitHub repository + gitlab:<group>/<project> GitLab project + gitea:<owner>/<repo> Gitea repository + forgejo:<owner>/<repo> Forgejo repository + azuredevops:<org>/<project> Azure DevOps Boards Options vary by provider. Use 'git issue import <provider> --help' for provider-specific options. @@ -74,9 +75,12 @@ case "$provider" in forgejo:*/*) exec "$ISSUE_BIN_DIR/git-issue-import-gitea" "$@" ;; + azuredevops:*/*) + exec "$ISSUE_BIN_DIR/git-issue-import-azuredevops" "$@" + ;; *) echo "error: unsupported or invalid provider '$provider'" >&2 - echo " supported: github:<owner>/<repo>, gitlab:<owner>/<project>, gitea:<owner>/<repo>, forgejo:<owner>/<repo>" >&2 + echo " supported: github:<owner>/<repo>, gitlab:<owner>/<project>, gitea:<owner>/<repo>, forgejo:<owner>/<repo>, azuredevops:<org>/<project>" >&2 exit 1 ;; esac diff --git a/bin/git-issue-import-azuredevops b/bin/git-issue-import-azuredevops new file mode 100755 index 0000000..ad40077 --- /dev/null +++ b/bin/git-issue-import-azuredevops @@ -0,0 +1,547 @@ +#!/bin/sh +# +# git-issue-import-azuredevops - Import work items from Azure DevOps Boards +# +# Usage: git issue import azuredevops:<org>/<project> [options] +# + +set -e + +usage() { + cat <<EOF +usage: git issue import azuredevops:<org>/<project> [options] + +Options: + --state <state> Filter by state: open, closed, all (default: open) + --types <types> Comma-separated work item types (default: Epic,Feature,User Story,Task,Bug) + --dry-run Show what would be imported without importing + -h, --help Show this help + +Authentication: + Requires Azure CLI (az) with the azure-devops extension. + Run 'az login' and ensure the azure-devops extension is installed: + az extension add --name azure-devops +EOF + exit 1 +} + +provider="" +state_filter="open" +dry_run=0 +item_types="Epic,Feature,User Story,Task,Bug" + +while test $# -gt 0 +do + case "$1" in + --state) + test $# -ge 2 || { echo "error: --state requires a value" >&2; exit 1; } + case "$2" in + open|closed|all) ;; + *) echo "error: --state must be open, closed, or all" >&2; exit 1 ;; + esac + state_filter="$2" + shift 2 + ;; + --types) + test $# -ge 2 || { echo "error: --types requires a value" >&2; exit 1; } + item_types="$2" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + ;; + --) + shift + break + ;; + -*) + echo "error: unknown option '$1'" >&2 + usage + ;; + *) + if test -z "$provider" + then + provider="$1" + fi + shift + ;; + esac +done + +if test -z "$provider" +then + echo "error: provider is required (e.g., azuredevops:org/project)" >&2 + usage +fi + +# Parse provider string +case "$provider" in + azuredevops:*/*) + ado_path="${provider#azuredevops:}" + ado_org="${ado_path%/*}" + ado_project="${ado_path#*/}" + ;; + *) + echo "error: invalid provider string '$provider'" >&2 + echo " expected format: azuredevops:<org>/<project>" >&2 + exit 1 + ;; +esac + +# Verify we are inside a git repository +git rev-parse --git-dir >/dev/null 2>&1 || { + echo "fatal: not a git repository" >&2 + exit 128 +} + +# Source shared library +. "$(dirname "$0")/git-issue-lib" + +# Check prerequisites +command -v az >/dev/null 2>&1 || { + echo "error: 'az' (Azure CLI) is required but not found" >&2 + echo " install: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" >&2 + exit 1 +} + +command -v jq >/dev/null 2>&1 || { + echo "error: 'jq' is required but not found" >&2 + echo " install: https://stedolan.github.io/jq/" >&2 + exit 1 +} + +# Check az auth +az account show >/dev/null 2>&1 || { + echo "error: 'az' is not authenticated. Run 'az login' first." >&2 + exit 1 +} + +# Check azure-devops extension +az extension show --name azure-devops >/dev/null 2>&1 || { + echo "error: azure-devops extension required. Install with:" >&2 + echo " az extension add --name azure-devops" >&2 + exit 1 +} + +ado_org_url="https://dev.azure.com/$ado_org" + +# Build Provider-ID index from existing issues +_import_tmpdir="$(mktemp -d)" +trap 'rm -rf "$_import_tmpdir"' EXIT +provider_index="$_import_tmpdir/provider-index" + +git for-each-ref --format='%(refname)' refs/issues/ | while IFS= read -r ref +do + git log --format='%(trailers:key=Provider-ID,valueonly)' "$ref" | \ + sed '/^$/d' | sed 's/^[[:space:]]*//' | while IFS= read -r pid + do + test -n "$pid" && printf '%s\n' "$pid" + done +done > "$provider_index" + +# Build WIQL state filter +case "$state_filter" in + open) + wiql_state="AND [System.State] NOT IN ('Done','Resolved','Closed','Removed')" + ;; + closed) + wiql_state="AND [System.State] IN ('Done','Resolved','Closed')" + ;; + all) + wiql_state="" + ;; +esac + +# Build WIQL type filter from comma-separated types +wiql_types="$(printf '%s' "$item_types" | sed "s/,/','/g" | sed "s/^/'/" | sed "s/$/'/")" + +wiql_query="SELECT [System.Id] FROM workitems WHERE [System.TeamProject] = '$ado_project' AND [System.WorkItemType] IN ($wiql_types) $wiql_state ORDER BY [System.Id] ASC" + +# Run WIQL query via az boards +printf 'Querying work items from %s/%s...\n' "$ado_org" "$ado_project" >&2 + +wiql_response="$(az boards query \ + --wiql "$wiql_query" \ + --org "$ado_org_url" \ + --project "$ado_project" \ + --output json 2>&1)" || { + echo "error: WIQL query failed" >&2 + echo " $wiql_response" >&2 + exit 1 +} + +# Extract work item IDs +# az boards query returns WIQL response with workItems array +item_count="$(printf '%s' "$wiql_response" | jq '.workItems | length')" + +# Validate item_count +case "$item_count" in + ''|*[!0-9]*) + echo "error: invalid item count: '$item_count'" >&2 + exit 1 + ;; +esac + +printf 'Found %d work items\n' "$item_count" >&2 + +if test "$item_count" -eq 0 +then + echo "No work items to import" + exit 0 +fi + +imported=0 +skipped=0 +updated=0 + +# Get the empty tree SHA +empty_tree="$(git hash-object -t tree /dev/null)" + +# Helper: find ref for a Provider-ID +find_ref_for_provider_id() { + target_id="$1" + git for-each-ref --format='%(refname)' refs/issues/ | while IFS= read -r ref + do + if git log --format='%(trailers:key=Provider-ID,valueonly)' "$ref" | \ + sed '/^$/d' | sed 's/^[[:space:]]*//' | grep -qxF "$target_id" + then + printf '%s\n' "$ref" + return 0 + fi + done +} + +# Helper: get imported comment IDs for an issue +get_imported_comment_ids() { + ref="$1" + git log --format='%(trailers:key=Provider-Comment-ID,valueonly)' "$ref" | \ + sed '/^$/d' | sed 's/^[[:space:]]*//' +} + +# Helper: map ADO work item type to label +map_work_item_type() { + case "$1" in + "Epic") printf 'type:epic' ;; + "Feature") printf 'type:feature' ;; + "User Story") printf 'type:user-story' ;; + "Product Backlog Item") printf 'type:pbi' ;; + "Task") printf 'type:task' ;; + "Bug") printf 'type:bug' ;; + *) printf 'type:%s' "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')" ;; + esac +} + +# Helper: map ADO state to open/closed +map_ado_state() { + case "$1" in + "Active"|"New"|"Open"|"In Progress"|"To Do"|"Committed"|"Doing"|"Approved"|"Design") + printf 'open' + ;; + "Done"|"Resolved"|"Closed"|"Completed"|"Removed"|"Cut") + printf 'closed' + ;; + *) + printf 'open' + ;; + esac +} + +# Helper: strip HTML tags from description +strip_html() { + printf '%s' "$1" | \ + sed 's/<br[[:space:]]*\/?>/\n/g' | \ + sed 's/<\/div>/\n/g' | \ + sed 's/<\/p>/\n/g' | \ + sed 's/<\/li>/\n/g' | \ + sed 's/<li[^>]*>/- /g' | \ + sed 's/<[^>]*>//g' | \ + sed 's/</</g;s/>/>/g;s/&/\&/g;s/ / /g;s/"/"/g;s/'/'"'"'/g' | \ + sed 's/&#[0-9]*;//g' | \ + sed '/^$/N;/^\n$/d' +} + +# Helper: normalize ADO tags (semicolon-separated) to comma-separated labels +normalize_ado_tags() { + printf '%s' "$1" | \ + sed 's/ *; */,/g' | \ + sed 's/^,*//;s/,*$//' | \ + sed 's/,,*/,/g' +} + +# Fetch comments for a work item using az devops invoke +fetch_ado_comments() { + _fac_id="$1" + az devops invoke \ + --area wit \ + --resource comments \ + --route-parameters workItemId="$_fac_id" \ + --api-version 7.1-preview.4 \ + --org "$ado_org_url" \ + --output json 2>/dev/null || printf '{"comments":[],"count":0}\n' +} + +# Process each work item +i=0 +while test "$i" -lt "$item_count" +do + work_item_id="$(printf '%s' "$wiql_response" | jq -r ".workItems[$i].id")" + provider_id="azuredevops:$ado_org/$ado_project#$work_item_id" + + # Check if already imported + if grep -qF "$provider_id" "$provider_index" 2>/dev/null + then + # Issue exists - check for new comments to import + existing_ref="$(find_ref_for_provider_id "$provider_id")" + + if test -z "$existing_ref" + then + skipped=$((skipped + 1)) + i=$((i + 1)) + continue + fi + + # Fetch comments for this work item + comments_json="$(fetch_ado_comments "$work_item_id")" + comment_count="$(printf '%s' "$comments_json" | jq '.comments | length')" + + if test "$comment_count" -eq 0 + then + skipped=$((skipped + 1)) + i=$((i + 1)) + continue + fi + + # Get list of already-imported comment IDs + imported_comment_ids="$(get_imported_comment_ids "$existing_ref")" + + # Find new comments + new_comments=0 + parent="$(git rev-parse "$existing_ref")" + j=0 + + while test "$j" -lt "$comment_count" + do + comment_id="$(printf '%s' "$comments_json" | jq -r ".comments[$j].id")" + comment_provider_id="azuredevops:$ado_org/$ado_project#comment-$comment_id" + + # Skip if already imported + if echo "$imported_comment_ids" | grep -qF "$comment_provider_id" + then + j=$((j + 1)) + continue + fi + + comment_text="$(printf '%s' "$comments_json" | jq -r ".comments[$j].text")" + comment_author_name="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdBy.displayName // \"Unknown\"")" + comment_author_email="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdBy.uniqueName // \"unknown@users.noreply.azuredevops.com\"")" + comment_created="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdDate")" + + if test "$dry_run" -eq 1 + then + printf '[DRY RUN] Would import comment %s on work item #%s\n' "$comment_id" "$work_item_id" + j=$((j + 1)) + continue + fi + + # Strip HTML from comment text + clean_comment="$(strip_html "$comment_text")" + + # Create commit for comment + tmpfile="$(mktemp)" + printf '%s\n' "$clean_comment" > "$tmpfile" + + git interpret-trailers --in-place \ + --trailer "Provider-Comment-ID: $comment_provider_id" \ + "$tmpfile" + + tree="$empty_tree" + commit="$(GIT_AUTHOR_NAME="$comment_author_name" \ + GIT_AUTHOR_EMAIL="$comment_author_email" \ + GIT_AUTHOR_DATE="$comment_created" \ + git commit-tree "$tree" -p "$parent" -m "$(cat "$tmpfile")")" + + rm -f "$tmpfile" + parent="$commit" + new_comments=$((new_comments + 1)) + j=$((j + 1)) + done + + if test "$new_comments" -gt 0 + then + git update-ref "$existing_ref" "$parent" + printf 'Updated %s with %d new comment(s)\n' "${existing_ref#refs/issues/}" "$new_comments" + updated=$((updated + 1)) + else + skipped=$((skipped + 1)) + fi + + i=$((i + 1)) + continue + fi + + # New work item - fetch full details via az boards + item_json="$(az boards work-item show \ + --id "$work_item_id" \ + --expand all \ + --org "$ado_org_url" \ + --output json 2>/dev/null)" || { + echo "warning: failed to fetch work item #$work_item_id, skipping" >&2 + i=$((i + 1)) + continue + } + + title="$(printf '%s' "$item_json" | jq -r '.fields["System.Title"]')" + raw_description="$(printf '%s' "$item_json" | jq -r '.fields["System.Description"] // ""')" + ado_state="$(printf '%s' "$item_json" | jq -r '.fields["System.State"]')" + work_item_type="$(printf '%s' "$item_json" | jq -r '.fields["System.WorkItemType"]')" + created_at="$(printf '%s' "$item_json" | jq -r '.fields["System.CreatedDate"]')" + + # Author + author_name="$(printf '%s' "$item_json" | jq -r '.fields["System.CreatedBy"].displayName // "Unknown"')" + author_email="$(printf '%s' "$item_json" | jq -r '.fields["System.CreatedBy"].uniqueName // "unknown@users.noreply.azuredevops.com"')" + + # Assignee (identity object, may be null) + assignee_email="$(printf '%s' "$item_json" | jq -r '.fields["System.AssignedTo"].uniqueName // ""')" + + # Tags (semicolon-separated) + ado_tags="$(printf '%s' "$item_json" | jq -r '.fields["System.Tags"] // ""')" + + # Parent (may be null) + parent_id="$(printf '%s' "$item_json" | jq -r '.fields["System.Parent"] // ""')" + + # Strip HTML from description + body="" + if test -n "$raw_description" + then + body="$(strip_html "$raw_description")" + fi + + # Map state + mapped_state="$(map_ado_state "$ado_state")" + + # Build labels: type label + converted tags + type_label="$(map_work_item_type "$work_item_type")" + tags_as_labels="$(normalize_ado_tags "$ado_tags")" + + if test -n "$tags_as_labels" + then + all_labels="$type_label, $tags_as_labels" + else + all_labels="$type_label" + fi + + if test "$dry_run" -eq 1 + then + printf '[DRY RUN] Would import work item #%s: %s (%s)\n' "$work_item_id" "$title" "$work_item_type" + i=$((i + 1)) + continue + fi + + # Generate UUID + if command -v uuidgen >/dev/null 2>&1 + then + uuid="$(uuidgen | tr '[:upper:]' '[:lower:]')" + elif test -r /proc/sys/kernel/random/uuid + then + uuid="$(cat /proc/sys/kernel/random/uuid)" + else + uuid="$(od -An -tx1 -N 16 /dev/urandom | tr -d ' \n' | sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/')" + fi + ref="refs/issues/$uuid" + + # Create commit message with metadata + tmpfile="$(mktemp)" + { + printf '%s\n\n' "$title" + test -n "$body" && printf '%s\n' "$body" + } > "$tmpfile" + + # Build trailer arguments + trailer_args="--trailer \"Format-Version: 1\" --trailer \"Provider-ID: $provider_id\" --trailer \"State: $mapped_state\" --trailer \"Labels: $all_labels\"" + + # Add trailers + git interpret-trailers --in-place \ + --trailer "Format-Version: 1" \ + --trailer "Provider-ID: $provider_id" \ + --trailer "State: $mapped_state" \ + --trailer "Labels: $all_labels" \ + ${assignee_email:+--trailer "Assignee: $assignee_email"} \ + ${parent_id:+--trailer "Parent-ID: azuredevops:$ado_org/$ado_project#$parent_id"} \ + "$tmpfile" + + # Create the issue commit + tree="$empty_tree" + commit="$(GIT_AUTHOR_NAME="$author_name" \ + GIT_AUTHOR_EMAIL="$author_email" \ + GIT_AUTHOR_DATE="$created_at" \ + git commit-tree "$tree" -m "$(cat "$tmpfile")")" + + rm -f "$tmpfile" + parent="$commit" + + # Import comments + comments_json="$(fetch_ado_comments "$work_item_id")" + comment_count="$(printf '%s' "$comments_json" | jq '.comments | length')" + + j=0 + while test "$j" -lt "$comment_count" + do + comment_id="$(printf '%s' "$comments_json" | jq -r ".comments[$j].id")" + comment_text="$(printf '%s' "$comments_json" | jq -r ".comments[$j].text")" + comment_author_name="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdBy.displayName // \"Unknown\"")" + comment_author_email="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdBy.uniqueName // \"unknown@users.noreply.azuredevops.com\"")" + comment_created="$(printf '%s' "$comments_json" | jq -r ".comments[$j].createdDate")" + + comment_provider_id="azuredevops:$ado_org/$ado_project#comment-$comment_id" + + # Strip HTML from comment + clean_comment="$(strip_html "$comment_text")" + + tmpfile="$(mktemp)" + printf '%s\n' "$clean_comment" > "$tmpfile" + + git interpret-trailers --in-place \ + --trailer "Provider-Comment-ID: $comment_provider_id" \ + "$tmpfile" + + tree="$empty_tree" + commit="$(GIT_AUTHOR_NAME="$comment_author_name" \ + GIT_AUTHOR_EMAIL="$comment_author_email" \ + GIT_AUTHOR_DATE="$comment_created" \ + git commit-tree "$tree" -p "$parent" -m "$(cat "$tmpfile")")" + + rm -f "$tmpfile" + parent="$commit" + j=$((j + 1)) + done + + # Handle closed state + if test "$mapped_state" = "closed" + then + tmpfile="$(mktemp)" + printf 'Close issue\n' > "$tmpfile" + git interpret-trailers --in-place \ + --trailer "State: closed" \ + "$tmpfile" + + commit="$(git commit-tree "$empty_tree" -p "$parent" -m "$(cat "$tmpfile")")" + rm -f "$tmpfile" + parent="$commit" + fi + + git update-ref "$ref" "$parent" + + printf 'Imported %s: %s (%s)\n' "$uuid" "$title" "$work_item_type" + imported=$((imported + 1)) + i=$((i + 1)) +done + +# Summary +printf '\nImport summary:\n' +printf ' Imported: %d\n' "$imported" +printf ' Updated: %d\n' "$updated" +printf ' Skipped: %d\n' "$skipped" diff --git a/t/fixtures/azuredevops-wiql-response.json b/t/fixtures/azuredevops-wiql-response.json new file mode 100644 index 0000000..8ea28c7 --- /dev/null +++ b/t/fixtures/azuredevops-wiql-response.json @@ -0,0 +1,13 @@ +{ + "queryType": "flat", + "queryResultType": "workItem", + "asOf": "2025-04-01T09:00:00Z", + "columns": [ + {"referenceName": "System.Id", "name": "ID", "url": "https://dev.azure.com/testorg/_apis/wit/fields/System.Id"} + ], + "workItems": [ + {"id": 1, "url": "https://dev.azure.com/testorg/_apis/wit/workItems/1"}, + {"id": 2, "url": "https://dev.azure.com/testorg/_apis/wit/workItems/2"}, + {"id": 3, "url": "https://dev.azure.com/testorg/_apis/wit/workItems/3"} + ] +} diff --git a/t/fixtures/azuredevops-workitem-1-comments.json b/t/fixtures/azuredevops-workitem-1-comments.json new file mode 100644 index 0000000..920e216 --- /dev/null +++ b/t/fixtures/azuredevops-workitem-1-comments.json @@ -0,0 +1,26 @@ +{ + "totalCount": 2, + "count": 2, + "comments": [ + { + "id": 1001, + "text": "Reviewed the migration plan. Looks good to proceed.", + "createdDate": "2025-04-01T12:00:00Z", + "createdBy": { + "displayName": "Bob Reporter", + "uniqueName": "bob@example.com", + "id": "def-456" + } + }, + { + "id": 1002, + "text": "Updated the timeline. Targeting Q2 completion.", + "createdDate": "2025-04-02T09:30:00Z", + "createdBy": { + "displayName": "Alice Developer", + "uniqueName": "alice@example.com", + "id": "abc-123" + } + } + ] +} diff --git a/t/fixtures/azuredevops-workitem-1.json b/t/fixtures/azuredevops-workitem-1.json new file mode 100644 index 0000000..96a7e3e --- /dev/null +++ b/t/fixtures/azuredevops-workitem-1.json @@ -0,0 +1,28 @@ +{ + "id": 1, + "rev": 3, + "fields": { + "System.Id": 1, + "System.Title": "Platform migration initiative", + "System.Description": "<div>Migrate all services to new platform</div>", + "System.State": "Active", + "System.WorkItemType": "Epic", + "System.CreatedDate": "2025-04-01T09:00:00Z", + "System.CreatedBy": { + "displayName": "Alice Developer", + "uniqueName": "alice@example.com", + "id": "abc-123" + }, + "System.AssignedTo": { + "displayName": "Alice Developer", + "uniqueName": "alice@example.com", + "id": "abc-123" + }, + "System.Tags": "migration; platform; Q2", + "System.AreaPath": "TestProject\\Team A", + "System.IterationPath": "TestProject\\Sprint 3", + "System.Parent": null + }, + "relations": [], + "_links": {} +} diff --git a/t/fixtures/azuredevops-workitem-2.json b/t/fixtures/azuredevops-workitem-2.json new file mode 100644 index 0000000..cdca86e --- /dev/null +++ b/t/fixtures/azuredevops-workitem-2.json @@ -0,0 +1,30 @@ +{ + "id": 2, + "rev": 1, + "fields": { + "System.Id": 2, + "System.Title": "Login page crashes on special characters", + "System.Description": "<div>Steps to reproduce:<br>1. Enter <script> in login<br>2. Page crashes</div>", + "System.State": "New", + "System.WorkItemType": "Bug", + "System.CreatedDate": "2025-03-15T14:00:00Z", + "System.CreatedBy": { + "displayName": "Bob Reporter", + "uniqueName": "bob@example.com", + "id": "def-456" + }, + "System.AssignedTo": null, + "System.Tags": "", + "System.AreaPath": "TestProject", + "System.IterationPath": "TestProject", + "System.Parent": 1 + }, + "relations": [ + { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": "https://dev.azure.com/testorg/_apis/wit/workItems/1", + "attributes": {"isLocked": false, "name": "Parent"} + } + ], + "_links": {} +} diff --git a/t/fixtures/azuredevops-workitem-3.json b/t/fixtures/azuredevops-workitem-3.json new file mode 100644 index 0000000..73f69b9 --- /dev/null +++ b/t/fixtures/azuredevops-workitem-3.json @@ -0,0 +1,34 @@ +{ + "id": 3, + "rev": 5, + "fields": { + "System.Id": 3, + "System.Title": "Update onboarding documentation", + "System.Description": "", + "System.State": "Done", + "System.WorkItemType": "User Story", + "System.CreatedDate": "2025-02-01T08:00:00Z", + "System.CreatedBy": { + "displayName": "Charlie Writer", + "uniqueName": "charlie@example.com", + "id": "ghi-789" + }, + "System.AssignedTo": { + "displayName": "Charlie Writer", + "uniqueName": "charlie@example.com", + "id": "ghi-789" + }, + "System.Tags": "docs", + "System.AreaPath": "TestProject\\Team A", + "System.IterationPath": "TestProject\\Sprint 2", + "System.Parent": 1 + }, + "relations": [ + { + "rel": "System.LinkTypes.Hierarchy-Reverse", + "url": "https://dev.azure.com/testorg/_apis/wit/workItems/1", + "attributes": {"isLocked": false, "name": "Parent"} + } + ], + "_links": {} +} diff --git a/t/test-azuredevops-bridge.sh b/t/test-azuredevops-bridge.sh new file mode 100644 index 0000000..88edd8c --- /dev/null +++ b/t/test-azuredevops-bridge.sh @@ -0,0 +1,718 @@ +#!/bin/sh +# +# Tests for git-issue import (Azure DevOps bridge) +# +# Run: sh t/test-azuredevops-bridge.sh +# +# Uses a mock 'az' script that returns fixture JSON based on commands. +# Real 'jq' is used (not mocked). +# + +set -e + +# Colors (if terminal supports them) +if test -t 1 +then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + NC='' +fi + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)" +FIXTURE_DIR="$(cd "$(dirname "$0")/fixtures" && pwd)" +TEST_DIR="$(mktemp -d)" +ORIG_PATH="$PATH" + +trap 'rm -rf "$TEST_DIR"' EXIT + +pass() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + printf "${GREEN} PASS${NC} %s\n" "$1" +} + +fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf "${RED} FAIL${NC} %s\n" "$1" + if test -n "${2:-}" + then + printf " %s\n" "$2" + fi +} + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) +} + +# Set up a fresh test repo with mock az on PATH +setup_repo() { + rm -rf "$TEST_DIR/repo" + mkdir "$TEST_DIR/repo" + cd "$TEST_DIR/repo" + git init -q + git commit --allow-empty -q -m "initial" + + # Create mock az + mkdir -p "$TEST_DIR/mock-bin" + create_mock_az + chmod +x "$TEST_DIR/mock-bin/az" + + export PATH="$TEST_DIR/mock-bin:$BIN_DIR:$ORIG_PATH" +} + +# Default mock az that handles the standard import test case +create_mock_az() { + cat > "$TEST_DIR/mock-bin/az" <<MOCKEOF +#!/bin/sh +# Mock az CLI for testing Azure DevOps bridge + +case "\$*" in + "account show"*) + printf '{"id":"sub-123","name":"Test Subscription"}\n' + ;; + "extension show --name azure-devops"*) + printf '{"name":"azure-devops","version":"1.0.0"}\n' + ;; + *"boards query"*"--wiql"*) + cat "$FIXTURE_DIR/azuredevops-wiql-response.json" + ;; + *"boards work-item show"*"--id 1"*) + cat "$FIXTURE_DIR/azuredevops-workitem-1.json" + ;; + *"boards work-item show"*"--id 2"*) + cat "$FIXTURE_DIR/azuredevops-workitem-2.json" + ;; + *"boards work-item show"*"--id 3"*) + cat "$FIXTURE_DIR/azuredevops-workitem-3.json" + ;; + *"devops invoke"*"workItemId=1"*) + cat "$FIXTURE_DIR/azuredevops-workitem-1-comments.json" + ;; + *"devops invoke"*"workItemId=2"*) + printf '{"comments":[],"count":0}\n' + ;; + *"devops invoke"*"workItemId=3"*) + printf '{"comments":[],"count":0}\n' + ;; + *) + echo "mock-az: unhandled call: \$*" >&2 + exit 1 + ;; +esac +MOCKEOF +} + +printf "Running git-issue Azure DevOps bridge tests...\n\n" + +# ============================================================ +# IMPORT TESTS +# ============================================================ + +# ============================================================ +# TEST: import creates correct refs +# ============================================================ +run_test +setup_repo +output="$(git issue import azuredevops:testorg/TestProject --state all 2>&1)" +ref_count="$(git for-each-ref --format='x' refs/issues/ | wc -l | tr -d ' ')" +if test "$ref_count" -eq 3 +then + pass "import creates refs for Azure DevOps work items" +else + fail "import creates refs for Azure DevOps work items" "expected 3 refs, got $ref_count" +fi + +# ============================================================ +# TEST: imported work item has correct title +# ============================================================ +run_test +found_title=0 +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + root="$(git rev-list --max-parents=0 "$ref")" + subject="$(git log -1 --format='%s' "$root")" + if test "$subject" = "Platform migration initiative" + then + found_title=1 + epic_ref="$ref" + break + fi +done +if test "$found_title" -eq 1 +then + pass "imported work item has correct title" +else + fail "imported work item has correct title" "title not found" +fi + +# ============================================================ +# TEST: imported work item has Provider-ID trailer +# ============================================================ +run_test +root="$(git rev-list --max-parents=0 "$epic_ref")" +pid="$(git log -1 --format='%(trailers:key=Provider-ID,valueonly)' "$root" | sed '/^$/d')" +pid="$(printf '%s' "$pid" | sed 's/^[[:space:]]*//')" +if test "$pid" = "azuredevops:testorg/TestProject#1" +then + pass "imported work item has correct Provider-ID trailer" +else + fail "imported work item has correct Provider-ID trailer" "got: '$pid'" +fi + +# ============================================================ +# TEST: imported work item has Format-Version trailer +# ============================================================ +run_test +fv="$(git log -1 --format='%(trailers:key=Format-Version,valueonly)' "$root" | sed '/^$/d')" +fv="$(printf '%s' "$fv" | sed 's/^[[:space:]]*//')" +if test "$fv" = "1" +then + pass "imported work item has Format-Version: 1 trailer" +else + fail "imported work item has Format-Version: 1 trailer" "got: '$fv'" +fi + +# ============================================================ +# TEST: work item type maps to type: label (Epic) +# ============================================================ +run_test +labels="$(git log -1 --format='%(trailers:key=Labels,valueonly)' "$root" | sed '/^$/d')" +labels="$(printf '%s' "$labels" | sed 's/^[[:space:]]*//')" +case "$labels" in + *"type:epic"*) + pass "Epic work item maps to type:epic label" + ;; + *) + fail "Epic work item maps to type:epic label" "got: '$labels'" + ;; +esac + +# ============================================================ +# TEST: ADO tags (semicolon-separated) convert to labels +# ============================================================ +run_test +# Tags should be "migration; platform; Q2" → "migration,platform,Q2" +case "$labels" in + *"migration"*"platform"*"Q2"*) + pass "ADO semicolon-separated tags convert to comma-separated labels" + ;; + *) + fail "ADO semicolon-separated tags convert to comma-separated labels" "got: '$labels'" + ;; +esac + +# ============================================================ +# TEST: AssignedTo identity maps to Assignee email +# ============================================================ +run_test +assignee="$(git log -1 --format='%(trailers:key=Assignee,valueonly)' "$root" | sed '/^$/d')" +assignee="$(printf '%s' "$assignee" | sed 's/^[[:space:]]*//')" +if test "$assignee" = "alice@example.com" +then + pass "AssignedTo identity maps to Assignee email trailer" +else + fail "AssignedTo identity maps to Assignee email trailer" "got: '$assignee'" +fi + +# ============================================================ +# TEST: Parent-ID trailer recorded when System.Parent is set +# ============================================================ +run_test +# Find Bug (work item #2, has parent=1) +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + root="$(git rev-list --max-parents=0 "$ref")" + subject="$(git log -1 --format='%s' "$root")" + if test "$subject" = "Login page crashes on special characters" + then + bug_ref="$ref" + break + fi +done +bug_root="$(git rev-list --max-parents=0 "$bug_ref")" +parent_id="$(git log -1 --format='%(trailers:key=Parent-ID,valueonly)' "$bug_root" | sed '/^$/d')" +parent_id="$(printf '%s' "$parent_id" | sed 's/^[[:space:]]*//')" +if test "$parent_id" = "azuredevops:testorg/TestProject#1" +then + pass "Parent-ID trailer recorded for child work item" +else + fail "Parent-ID trailer recorded for child work item" "got: '$parent_id'" +fi + +# ============================================================ +# TEST: Bug work item maps to type:bug label +# ============================================================ +run_test +bug_labels="$(git log -1 --format='%(trailers:key=Labels,valueonly)' "$bug_root" | sed '/^$/d')" +bug_labels="$(printf '%s' "$bug_labels" | sed 's/^[[:space:]]*//')" +case "$bug_labels" in + *"type:bug"*) + pass "Bug work item maps to type:bug label" + ;; + *) + fail "Bug work item maps to type:bug label" "got: '$bug_labels'" + ;; +esac + +# ============================================================ +# TEST: Unassigned work item has no Assignee trailer +# ============================================================ +run_test +bug_assignee="$(git log -1 --format='%(trailers:key=Assignee,valueonly)' "$bug_root" | sed '/^$/d')" +if test -z "$bug_assignee" +then + pass "unassigned work item has no Assignee trailer" +else + fail "unassigned work item has no Assignee trailer" "got: '$bug_assignee'" +fi + +# ============================================================ +# TEST: Closed work item (Done state) has State: closed +# ============================================================ +run_test +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + root="$(git rev-list --max-parents=0 "$ref")" + subject="$(git log -1 --format='%s' "$root")" + if test "$subject" = "Update onboarding documentation" + then + story_ref="$ref" + break + fi +done +state="$(git log --format='%(trailers:key=State,valueonly)' "$story_ref" | sed '/^$/d' | head -1)" +state="$(printf '%s' "$state" | sed 's/^[[:space:]]*//')" +if test "$state" = "closed" +then + pass "closed ADO work item (Done) imported with State: closed" +else + fail "closed ADO work item (Done) imported with State: closed" "got: '$state'" +fi + +# ============================================================ +# TEST: User Story maps to type:user-story label +# ============================================================ +run_test +story_root="$(git rev-list --max-parents=0 "$story_ref")" +story_labels="$(git log -1 --format='%(trailers:key=Labels,valueonly)' "$story_root" | sed '/^$/d')" +story_labels="$(printf '%s' "$story_labels" | sed 's/^[[:space:]]*//')" +case "$story_labels" in + *"type:user-story"*) + pass "User Story maps to type:user-story label" + ;; + *) + fail "User Story maps to type:user-story label" "got: '$story_labels'" + ;; +esac + +# ============================================================ +# TEST: Comments imported as child commits +# ============================================================ +run_test +# Epic (work item #1) has 2 comments = root + 2 comments + close? No, it's open +# root + 2 comments = 3 commits +total="$(git rev-list --count "$epic_ref")" +if test "$total" -eq 3 +then + pass "comments imported as child commits (2 comments on Epic)" +else + fail "comments imported as child commits (2 comments on Epic)" "expected 3 commits, got $total" +fi + +# ============================================================ +# TEST: Comment has Provider-Comment-ID trailer +# ============================================================ +run_test +head_commit="$(git rev-parse "$epic_ref")" +pcid="$(git log -1 --format='%(trailers:key=Provider-Comment-ID,valueonly)' "$head_commit" | sed '/^$/d')" +pcid="$(printf '%s' "$pcid" | sed 's/^[[:space:]]*//')" +if test "$pcid" = "azuredevops:testorg/TestProject#comment-1002" +then + pass "comment has correct Provider-Comment-ID trailer" +else + fail "comment has correct Provider-Comment-ID trailer" "got: '$pcid'" +fi + +# ============================================================ +# TEST: HTML stripped from description +# ============================================================ +run_test +epic_root="$(git rev-list --max-parents=0 "$epic_ref")" +body="$(git log -1 --format='%b' "$epic_root" | sed '/^[A-Z][A-Za-z-]*: /d' | sed '/^$/d')" +case "$body" in + *"<div>"*|*"<br>"*|*"</div>"*) + fail "HTML stripped from description" "HTML tags still present: '$body'" + ;; + *"Migrate all services"*) + pass "HTML stripped from description" + ;; + *) + fail "HTML stripped from description" "expected content not found: '$body'" + ;; +esac + +# ============================================================ +# TEST: Idempotency - re-import skips already-imported +# ============================================================ +run_test +output="$(git issue import azuredevops:testorg/TestProject --state all 2>&1)" +ref_count="$(git for-each-ref --format='x' refs/issues/ | wc -l | tr -d ' ')" +case "$output" in + *"Skipped:"*"3"*) + if test "$ref_count" -eq 3 + then + pass "re-import skips already-imported items (idempotent)" + else + fail "re-import skips already-imported items (idempotent)" "ref count changed to $ref_count" + fi + ;; + *) + fail "re-import skips already-imported items (idempotent)" "output: $output" + ;; +esac + +# ============================================================ +# TEST: import with --dry-run does not create refs +# ============================================================ +run_test +setup_repo +output="$(git issue import azuredevops:testorg/TestProject --state all --dry-run 2>&1)" +ref_count="$(git for-each-ref --format='x' refs/issues/ | wc -l | tr -d ' ')" +if test "$ref_count" -eq 0 +then + case "$output" in + *"DRY RUN"*"Would import"*) + pass "import --dry-run shows plan but creates no refs" + ;; + *) + fail "import --dry-run shows plan but creates no refs" "output: $output" + ;; + esac +else + fail "import --dry-run shows plan but creates no refs" "created $ref_count refs" +fi + +# ============================================================ +# TEST: all commits use empty tree +# ============================================================ +run_test +setup_repo +create_mock_az +chmod +x "$TEST_DIR/mock-bin/az" +git issue import azuredevops:testorg/TestProject --state all >/dev/null 2>&1 +empty_tree="$(git hash-object -t tree /dev/null)" +all_ok=1 +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + for commit in $(git rev-list "$ref") + do + tree="$(git log -1 --format='%T' "$commit")" + if test "$tree" != "$empty_tree" + then + all_ok=0 + break + fi + done +done +if test "$all_ok" -eq 1 +then + pass "all imported commits use empty tree" +else + fail "all imported commits use empty tree" +fi + +# ============================================================ +# TEST: import preserves author identity +# ============================================================ +run_test +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + root="$(git rev-list --max-parents=0 "$ref")" + subject="$(git log -1 --format='%s' "$root")" + if test "$subject" = "Platform migration initiative" + then + author_name="$(git log -1 --format='%an' "$root")" + author_email="$(git log -1 --format='%ae' "$root")" + if test "$author_name" = "Alice Developer" && test "$author_email" = "alice@example.com" + then + pass "import preserves author identity" + else + fail "import preserves author identity" "name='$author_name' email='$author_email'" + fi + break + fi +done + +# ============================================================ +# TEST: import preserves creation timestamp +# ============================================================ +run_test +# Re-find epic ref in current repo (previous setup_repo may have changed repo) +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + root="$(git rev-list --max-parents=0 "$ref")" + subject="$(git log -1 --format='%s' "$root")" + if test "$subject" = "Platform migration initiative" + then + epic_ref="$ref" + break + fi +done +epic_root="$(git rev-list --max-parents=0 "$epic_ref")" +author_date="$(git log -1 --format='%aI' "$epic_root")" +case "$author_date" in + 2025-04-01T09:00:00*) + pass "import preserves creation timestamp" + ;; + *) + fail "import preserves creation timestamp" "got: '$author_date'" + ;; +esac + +# ============================================================ +# TEST: missing az CLI gives clear error +# ============================================================ +run_test +setup_repo +# Remove mock az from PATH +rm -f "$TEST_DIR/mock-bin/az" +output="$(git issue import azuredevops:testorg/TestProject 2>&1)" || true +case "$output" in + *"'az'"*"required"*|*"not found"*) + pass "missing az CLI gives clear error" + ;; + *) + fail "missing az CLI gives clear error" "output: $output" + ;; +esac +# Restore mock +create_mock_az +chmod +x "$TEST_DIR/mock-bin/az" + +# ============================================================ +# TEST: auth failure gives useful error +# ============================================================ +run_test +setup_repo +cat > "$TEST_DIR/mock-bin/az" <<'MOCKEOF_UNAUTH' +#!/bin/sh +case "$*" in + "account show"*) + exit 1 + ;; + *) + exit 1 + ;; +esac +MOCKEOF_UNAUTH +chmod +x "$TEST_DIR/mock-bin/az" +output="$(git issue import azuredevops:testorg/TestProject 2>&1)" || true +case "$output" in + *"not authenticated"*|*"az login"*) + pass "auth failure returns useful error message" + ;; + *) + fail "auth failure returns useful error message" "output: $output" + ;; +esac + +# ============================================================ +# TEST: invalid provider string fails +# ============================================================ +run_test +setup_repo +create_mock_az +chmod +x "$TEST_DIR/mock-bin/az" +if git issue import "azuredevops:invalid" 2>/dev/null +then + fail "import with invalid provider string fails" "should have failed" +else + pass "import with invalid provider string fails" +fi + +# ============================================================ +# TEST: import outside git repo fails +# ============================================================ +run_test +tmpdir="$(mktemp -d)" +cd "$tmpdir" +if git issue import azuredevops:testorg/TestProject 2>/dev/null +then + fail "import outside git repo fails" "should have failed" +else + pass "import outside git repo fails" +fi +cd "$TEST_DIR" +rm -rf "$tmpdir" + +# ============================================================ +# TEST: empty description handled gracefully +# ============================================================ +run_test +setup_repo +cat > "$TEST_DIR/mock-bin/az" <<MOCKEOF_EMPTY +#!/bin/sh +case "\$*" in + "account show"*) + printf '{"id":"sub-123"}\n' + ;; + "extension show --name azure-devops"*) + printf '{"name":"azure-devops"}\n' + ;; + *"boards query"*) + printf '{"workItems":[{"id":99}]}\n' + ;; + *"boards work-item show"*"--id 99"*) + printf '{"id":99,"fields":{"System.Id":99,"System.Title":"No description item","System.Description":"","System.State":"New","System.WorkItemType":"Task","System.CreatedDate":"2025-01-01T00:00:00Z","System.CreatedBy":{"displayName":"Dev","uniqueName":"dev@test.com"},"System.AssignedTo":null,"System.Tags":"","System.Parent":null}}\n' + ;; + *"devops invoke"*) + printf '{"comments":[],"count":0}\n' + ;; + *) + exit 1 + ;; +esac +MOCKEOF_EMPTY +chmod +x "$TEST_DIR/mock-bin/az" +git issue import azuredevops:testorg/TestProject --state all >/dev/null 2>&1 +ref_count="$(git for-each-ref --format='x' refs/issues/ | wc -l | tr -d ' ')" +if test "$ref_count" -eq 1 +then + pass "empty description handled gracefully" +else + fail "empty description handled gracefully" "got $ref_count refs" +fi + +# ============================================================ +# TEST: work item with no tags has only type label +# ============================================================ +run_test +ref="$(git for-each-ref --format='%(refname)' refs/issues/ | head -1)" +root="$(git rev-list --max-parents=0 "$ref")" +labels="$(git log -1 --format='%(trailers:key=Labels,valueonly)' "$root" | sed '/^$/d')" +labels="$(printf '%s' "$labels" | sed 's/^[[:space:]]*//')" +if test "$labels" = "type:task" +then + pass "work item with no tags has only type: label" +else + fail "work item with no tags has only type: label" "got: '$labels'" +fi + +# ============================================================ +# CROSS-PLATFORM EXPORT TESTS +# ============================================================ + +# ============================================================ +# TEST: without --cross-platform, ADO items skipped by GitHub export +# ============================================================ +run_test +setup_repo +create_mock_az +chmod +x "$TEST_DIR/mock-bin/az" +# Import ADO items +git issue import azuredevops:testorg/TestProject --state all >/dev/null 2>&1 + +# Create mock gh for export +cat > "$TEST_DIR/mock-bin/gh" <<'MOCKEOF_GH' +#!/bin/sh +case "$*" in + "auth status"*) + exit 0 + ;; + "api /user"*) + printf '{"login":"testuser","id":1}\n' + ;; + *) + printf '{"number":42}\n' + ;; +esac +MOCKEOF_GH +chmod +x "$TEST_DIR/mock-bin/gh" + +output="$(git issue export github:testowner/testrepo 2>&1 || true)" +case "$output" in + *"Skipped"*"imported from azuredevops:"*) + pass "without --cross-platform, ADO items skipped by GitHub export" + ;; + *) + fail "without --cross-platform, ADO items skipped by GitHub export" "output: $output" + ;; +esac + +# ============================================================ +# TEST: with --cross-platform, ADO items exported to GitHub +# ============================================================ +run_test +setup_repo +create_mock_az +chmod +x "$TEST_DIR/mock-bin/az" +git issue import azuredevops:testorg/TestProject --state all >/dev/null 2>&1 + +# Create mock gh that tracks exports +cat > "$TEST_DIR/mock-bin/gh" <<MOCKEOF_GH2 +#!/bin/sh +case "\$*" in + "auth status"*) + exit 0 + ;; + "api /user"*) + printf '{"login":"testuser","id":1}\n' + ;; + *"--method POST"*"/issues"*) + printf '{"number":42,"html_url":"https://github.com/testowner/testrepo/issues/42"}\n' + echo "EXPORTED" >> "$TEST_DIR/export-log" + ;; + *"--method POST"*"/comments"*) + printf '{"id":100}\n' + ;; + *"--method PATCH"*) + printf '{"number":42}\n' + ;; + *"/search/"*) + printf '{"items":[],"total_count":0}\n' + ;; + *"/commits"*) + printf '[]\n' + ;; + *) + printf '{}\n' + ;; +esac +MOCKEOF_GH2 +chmod +x "$TEST_DIR/mock-bin/gh" + +output="$(git issue export github:testowner/testrepo --cross-platform 2>&1 || true)" +if test -f "$TEST_DIR/export-log" +then + export_count="$(wc -l < "$TEST_DIR/export-log" | tr -d ' ')" + if test "$export_count" -gt 0 + then + pass "with --cross-platform, ADO items exported to GitHub" + else + fail "with --cross-platform, ADO items exported to GitHub" "no exports recorded" + fi +else + fail "with --cross-platform, ADO items exported to GitHub" "export log not created. output: $output" +fi + +# ============================================================ +# SUMMARY +# ============================================================ +printf "\n%.60s\n" "============================================================" +printf "Tests: %d | Passed: ${GREEN}%d${NC} | Failed: ${RED}%d${NC}\n" \ + "$TESTS_RUN" "$TESTS_PASSED" "$TESTS_FAILED" +printf "%.60s\n" "============================================================" + +if test "$TESTS_FAILED" -gt 0 +then + exit 1 +fi