This is page 5 of 8. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .clinerules ├── .env.example ├── .eslintrc.json ├── .github │ ├── FUNDING.yml │ ├── release-please-config.json │ ├── release-please-manifest.json │ └── workflows │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky │ ├── commit-msg │ └── pre-commit ├── .kilocode │ └── mcp.json ├── .prettierrc ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── commitlint.config.js ├── CONTRIBUTING.md ├── create_branch.sh ├── docs │ ├── authentication.md │ ├── azure-identity-authentication.md │ ├── ci-setup.md │ ├── examples │ │ ├── azure-cli-authentication.env │ │ ├── azure-identity-authentication.env │ │ ├── pat-authentication.env │ │ └── README.md │ ├── testing │ │ ├── README.md │ │ └── setup.md │ └── tools │ ├── core-navigation.md │ ├── organizations.md │ ├── pipelines.md │ ├── projects.md │ ├── pull-requests.md │ ├── README.md │ ├── repositories.md │ ├── resources.md │ ├── search.md │ ├── user-tools.md │ ├── wiki.md │ └── work-items.md ├── finish_task.sh ├── jest.e2e.config.js ├── jest.int.config.js ├── jest.unit.config.js ├── LICENSE ├── memory │ └── tasks_memory_2025-05-26T16-18-03.json ├── package-lock.json ├── package.json ├── project-management │ ├── planning │ │ ├── architecture-guide.md │ │ ├── azure-identity-authentication-design.md │ │ ├── project-plan.md │ │ ├── project-structure.md │ │ ├── tech-stack.md │ │ └── the-dream-team.md │ ├── startup.xml │ ├── tdd-cycle.xml │ └── troubleshooter.xml ├── README.md ├── setup_env.sh ├── shrimp-rules.md ├── src │ ├── clients │ │ └── azure-devops.ts │ ├── features │ │ ├── organizations │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-organizations │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pipelines │ │ │ ├── get-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pipelines │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── trigger-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── types.ts │ │ ├── projects │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-project │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-project-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-projects │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pull-requests │ │ │ ├── add-pull-request-comment │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── create-pull-request │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-pull-request-comments │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pull-requests │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── types.ts │ │ │ └── update-pull-request │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-all-repositories-tree │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── feature.spec.unit.ts.snap │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-file-content │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-repositories │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── search │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── search-code │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-work-items │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── users │ │ │ ├── get-me │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── wikis │ │ │ ├── create-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── create-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wikis │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-wiki-pages │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ └── update-wiki-page │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── work-items │ │ ├── __test__ │ │ │ ├── fixtures.ts │ │ │ ├── test-helpers.ts │ │ │ └── test-utils.ts │ │ ├── create-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── get-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── index.spec.unit.ts │ │ ├── index.ts │ │ ├── list-work-items │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── manage-work-item-link │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── schemas.ts │ │ ├── tool-definitions.ts │ │ ├── types.ts │ │ └── update-work-item │ │ ├── feature.spec.int.ts │ │ ├── feature.spec.unit.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── index.spec.unit.ts │ ├── index.ts │ ├── server.spec.e2e.ts │ ├── server.ts │ ├── shared │ │ ├── api │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── auth-factory.ts │ │ │ ├── client-factory.ts │ │ │ └── index.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ └── version.ts │ │ ├── enums │ │ │ ├── index.spec.unit.ts │ │ │ └── index.ts │ │ ├── errors │ │ │ ├── azure-devops-errors.ts │ │ │ ├── handle-request-error.ts │ │ │ └── index.ts │ │ ├── test │ │ │ └── test-helpers.ts │ │ └── types │ │ ├── config.ts │ │ ├── index.ts │ │ ├── request-handler.ts │ │ └── tool-definition.ts │ └── utils │ ├── environment.spec.unit.ts │ └── environment.ts ├── tasks.json ├── tests │ └── setup.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /project-management/troubleshooter.xml: -------------------------------------------------------------------------------- ``` 1 | <TroubleshootingGuide> 2 | <Flowchart> 3 | graph TD 4 | A[Start: Identify Issue] --> B[Gather Information] 5 | B --> C[Form Hypotheses] 6 | C --> D[Test Hypotheses] 7 | D -->|Issue Resolved?| E[Implement Solution] 8 | D -->|Issue Persists| B 9 | E --> F[Verify Fix] 10 | F -->|Success| G[Document & Conclude] 11 | F -->|Failure| B 12 | </Flowchart> 13 | <Introduction> 14 | Troubleshooting is an essential skill that remains vital across all fields, from software 15 | development to mechanical engineering and household repairs. It’s the art of systematically 16 | identifying and resolving problems, a competency that never goes obsolete due to its 17 | universal relevance. Whether you’re debugging a crashing application, fixing a car that 18 | won’t start, or repairing a leaky faucet, troubleshooting empowers you to tackle challenges 19 | methodically. This guide provides a framework that adapts to any domain, highlighting the 20 | importance of a structured approach and a problem-solving mindset. 21 | </Introduction> 22 | <Preparation> 23 | Preparation is the foundation of effective troubleshooting. Before addressing any issue, 24 | take these steps: 25 | 1. **Gather Necessary Tools:** Equip yourself with the right resources—debugging software 26 | for coding, wrenches and multimeters for machinery, or a toolkit with pliers and tape for 27 | home repairs. 28 | 2. **Understand the System:** Study the system or device involved. Review code 29 | documentation, machine schematics, or appliance manuals to grasp how it should function. 30 | 3. **Ensure Safety:** Prioritize safety by disconnecting power, wearing protective gear, or 31 | shutting off water supplies as needed. 32 | 4. **Document the Initial State:** Note symptoms, error messages, or unusual behaviors 33 | (e.g., a software error code, a grinding noise from an engine, or water pooling under a 34 | sink) to establish a baseline. 35 | Proper preparation minimizes guesswork and sets the stage for efficient problem-solving. 36 | </Preparation> 37 | <Diagnosis> 38 | Diagnosis is the core of troubleshooting, requiring a systematic approach to uncover the 39 | root cause: 40 | 1. **Gather Information:** Collect data through observation or tools—check software logs, 41 | listen for mechanical noises, or inspect pipes for leaks. 42 | 2. **Form Hypotheses:** Develop theories about the cause based on evidence. For a software 43 | bug, suspect a recent code change; for a car, consider a dead battery; for a leak, think of 44 | a loose seal. 45 | 3. **Test Hypotheses:** Conduct targeted tests—run a software debug session, measure battery 46 | voltage with a multimeter, or tighten a pipe fitting and check for drips. 47 | 4. **Analyze Results:** Assess test outcomes to confirm or adjust your hypotheses. If the 48 | issue persists, gather more data and refine your theories. 49 | For example, in software, replicate a crash and trace it to a faulty loop; in mechanical 50 | engineering, test a pump after hearing a whine; in household repairs, turn on water to 51 | locate a drip’s source. This process is iterative—loop back as needed until the problem is 52 | clear. 53 | </Diagnosis> 54 | <SolutionImplementation> 55 | With the cause identified, implement a solution methodically: 56 | 1. **Prioritize Fixes:** Address critical issues first—fix a server outage before a minor UI 57 | glitch, replace a broken engine belt before tuning performance, or stop a major leak before 58 | patching a crack. 59 | 2. **Apply the Solution:** Execute the fix—patch the code and deploy it, install a new part, 60 | or replace a worn washer. Follow best practices or guidelines specific to the domain. 61 | 3. **Test the Solution:** Verify the fix works—run the software, start the engine, or turn 62 | on the tap to ensure functionality. 63 | 4. **Document Changes:** Record actions taken, like code updates in a changelog, parts 64 | swapped in a maintenance log, or repair steps in a notebook, for future reference. 65 | Examples include deploying a software update and checking for crashes, replacing a car 66 | alternator and testing the charge, or sealing a pipe and ensuring no leaks remain. Precision 67 | here prevents new issues. 68 | </SolutionImplementation> 69 | <Verification> 70 | Verification confirms the issue is resolved and the system is stable: 71 | 1. **Perform Functional Tests:** Run the system normally—execute software features, drive 72 | the car, or use the repaired appliance. 73 | 2. **Check for Side Effects:** Look for unintended outcomes, like new software errors, 74 | engine vibrations, or damp spots near a fix. 75 | 3. **Monitor Over Time:** Observe performance longer-term—watch software logs for a day, run 76 | machinery through cycles, or check a pipe after hours of use. 77 | 4. **Get User Feedback:** If applicable, ask users (e.g., software testers, car owners, or 78 | household members) to confirm the problem is gone. 79 | For instance, monitor a web app post-fix for uptime, test a repaired tractor under load, or 80 | ensure a faucet stays dry overnight. Thorough verification ensures lasting success. 81 | </Verification> 82 | <CommonMistakes> 83 | Avoid these pitfalls to troubleshoot effectively: 84 | 1. **Jumping to Conclusions:** Assuming a software crash is a server issue without logs, or 85 | replacing an engine part without testing, wastes time—always validate hypotheses. 86 | 2. **Neglecting Documentation:** Skipping notes on code changes or repair steps complicates 87 | future fixes—keep detailed records. 88 | 3. **Overlooking Simple Solutions:** A reboot might fix a software glitch, or a loose bolt 89 | could be the mechanical issue—check the obvious first. 90 | 4. **Insufficient Testing:** Deploying a patch without full tests, or assuming a pipe is 91 | fixed after a quick look, risks recurrence—test rigorously. 92 | 5. **Ignoring Safety:** Debugging live circuits or repairing plumbing without shutoffs 93 | invites danger—prioritize safety always. 94 | Awareness of these errors keeps your process on track. 95 | </CommonMistakes> 96 | <MindsetTips> 97 | Cultivate this mindset for troubleshooting success: 98 | 1. **Patience:** Problems may resist quick fixes—stay calm when a bug eludes you or a repair 99 | takes hours. 100 | 2. **Attention to Detail:** Notice subtle clues—a log timestamp, a faint hum, or a drip 101 | pattern can crack the case. 102 | 3. **Persistence:** If a fix fails, keep testing—don’t abandon a software trace or a machine 103 | teardown midstream. 104 | 4. **Open-Mindedness:** A bug might stem from an overlooked module, or a leak from an 105 | unexpected joint—stay flexible. 106 | 5. **Learning Orientation:** Each challenge teaches something—log a new coding trick, a 107 | mechanical quirk, or a repair tip. 108 | 6. **Collaboration:** Seek input—a colleague might spot a code flaw or a neighbor recall a 109 | similar fix. 110 | This mindset turns obstacles into opportunities. 111 | </MindsetTips> 112 | <Conclusion> 113 | Troubleshooting is a timeless skill that equips you to solve problems anywhere—from 114 | codebases to engines to homes. This guide’s systematic approach—preparing diligently, 115 | diagnosing precisely, implementing thoughtfully, and verifying completely—ensures success 116 | across domains. Avoiding common mistakes and embracing a resilient mindset amplify your 117 | effectiveness. Mastering troubleshooting not only boosts professional prowess but also 118 | fosters everyday resourcefulness, making it a skill worth honing for life. 119 | </Conclusion> 120 | </TroubleshootingGuide> ``` -------------------------------------------------------------------------------- /project-management/startup.xml: -------------------------------------------------------------------------------- ``` 1 | <AiTaskAgent> 2 | <GlobalRule alwaysApply="true">If an ANY point you get stuck, review troubleshooter.xml to help you troubleshoot the problem.</GlobalRule> 3 | <GlobalRule alwaysApply="true">All new code creation should ALWAYS follow tdd-cycle.xml</GlobalRule> 4 | <GlobalRule alwaysApply="true">Tasks in the GitHub project board at https://github.com/users/Tiberriver256/projects/1 are sorted in order of priority - ALWAYS pick the task from the top of the backlog column.</GlobalRule> 5 | <GlobalRule alwaysApply="true">Always use the GitHub CLI (gh) for project and issue management. If documentation is needed, use browser_navigate to access the documentation. Always use a markdown file for writing/updating issues rather than trying to work with cli args.</GlobalRule> 6 | <GlobalRule alwaysApply="true">There is a strict WIP limit of 1. If any issue is in the Research, Implementation, or In Review status, that issue MUST be completed before starting a new one from Backlog.</GlobalRule> 7 | <GlobalRule alwaysApply="true">We are always operating as the GitHub user 'Tiberriver256'. All issues must be assigned to 'Tiberriver256' before starting work on them.</GlobalRule> 8 | <GlobalRule alwaysApply="true">To update a project item status, first get the project ID with `gh project list --owner Tiberriver256 --format json`, then get the item ID with `gh project item-list [project-id] --format json`, and finally update the status with `gh project item-edit --id [item-id] --project-id [project-id] --field-id PVTSSF_lAHOAGqmtM4A2BrBzgrZoeI --single-select-option-id [status-id]`. Status IDs are: Backlog (f75ad846), Research (61e4505c), Implementation (47fc9ee4), In review (df73e18b), Done (98236657).</GlobalRule> 9 | <GlobalRule alwaysApply="true">To create a GitHub issue: 1) Create a markdown file for the issue body (e.g., `issue_body.md`), 2) Use `gh issue create --title "Issue Title" --body-file issue_body.md --label "enhancement"` to create the issue, 3) Assign it with `gh issue edit [issue-number] --add-assignee Tiberriver256`, 4) Add status label with `gh issue edit [issue-number] --add-label "status:research"`, 5) Add to project with `gh project item-add [project-id] --owner Tiberriver256 --url [issue-url]`, and 6) Update status in project using the project item-edit command as described above.</GlobalRule> 10 | <InitialSetup order="1"> 11 | <Step order="1">Read the dream team documentation at project-management/planning/the-dream-team.md to understand the team structure and roles</Step> 12 | <Step order="2">Read all files in the project-management/planning directory to understand the project architecture, features, and structure</Step> 13 | <Step order="3">Check if there is any issue in the GitHub project board at https://github.com/users/Tiberriver256/projects/1 with a status of "Research", "Implementation", or "In Review". Use 'gh project item-list' to check the current issues and their status.</Step> 14 | <Step order="4"> 15 | If there is any issue in "Research", "Implementation", or "In Review" status, ensure it is assigned to 'Tiberriver256' and work on that issue, moving directly into the appropriate phase of TaskWorkflow. 16 | If not, take the FIRST issue from the top of the "Backlog" status in the project board at https://github.com/users/Tiberriver256/projects/1, assign it to 'Tiberriver256', and update its status to "Research". 17 | Remember that issues are sorted by priority with most important at the top. Add a comment with your implementation approach and planned sub-tasks if needed. Use the GitHub CLI (gh) for all project and issue management. 18 | </Step> 19 | <Step order="5">Create a new branch for the current task, branching from the latest main branch. Use a descriptive name for the branch, related to the task, by running ./create_branch.sh <branch_name>.</Step> 20 | <Step order="6">Read tdd-cycle.xml to understand the TDD cycle.</Step> 21 | <Step order="7">Read all files in the docs/testing directory to understand the testing strategy.</Step> 22 | <Step order="8">Start the research phase of TaskWorkflow.</Step> 23 | </InitialSetup> 24 | 25 | <TaskWorkflow order="2"> 26 | <Phase name="Research" order="1"> 27 | <Step order="1">Make sure the issue is assigned to 'Tiberriver256' and its status is set to "Research" in the GitHub project board.</Step> 28 | <Step order="2">Research the selected GitHub issue thoroughly</Step> 29 | <Step order="3">Create notes in a comment on the GitHub issue about your approach. Use the GitHub CLI (gh) for interacting with issues.</Step> 30 | <Step order="4">Break down the task into sub-tasks only if necessary (prefer simplicity)</Step> 31 | <Step order="5">If the task is straightforward, keep it as a single task</Step> 32 | </Phase> 33 | 34 | <Phase name="Planning" order="2"> 35 | <STOPPING_POINT order="1">Present your sub-tasks (if any) and approach for approval</STOPPING_POINT> 36 | </Phase> 37 | 38 | <Phase name="Implementation" order="3"> 39 | <Step order="1">Update the issue status to "Implementation" in the GitHub project board, ensuring it remains assigned to 'Tiberriver256'.</Step> 40 | <Step order="2">Assume the role and persona of the team member assigned to the task</Step> 41 | <Step order="3">If multiple roles are involved, simulate pair/mob programming</Step> 42 | <Step order="4">Use Test-Driven Development for all coding tasks</Step> 43 | <Step order="5">Create any necessary readme.md files for documentation or reference</Step> 44 | </Phase> 45 | 46 | <Phase name="Completion" order="4"> 47 | <Step order="1">Create a pull request and update the issue status to "In Review" in the GitHub project board, ensuring it remains assigned to 'Tiberriver256'.</Step> 48 | <STOPPING_POINT order="2">Present your work for review</STOPPING_POINT> 49 | <Step order="3">Address any feedback, and present for re-review; Continue in this manner until approved</Step> 50 | <Step order="4">When the task is approved, run ./finish_task.sh "PR Title" "PR Description" to commit, push, and update the PR for the repository at https://github.com/Tiberriver256/mcp-server-azure-devops</Step> 51 | <Step order="5">After the PR is merged, update the issue status to "Done" and close the GitHub issue with an appropriate comment summarizing the work done. Use the GitHub CLI (gh) to close issues.</Step> 52 | <Step order="6">Wait for feedback before starting a new task</Step> 53 | </Phase> 54 | </TaskWorkflow> 55 | 56 | <WorkingPrinciples> 57 | <Principle>Use the tree command when exploring directory structures</Principle> 58 | <Principle>Follow KISS (Keep It Stupid Simple) and YAGNI (You Aren't Gonna Need It) principles</Principle> 59 | <Principle>Focus on delivery rather than over-engineering or gold-plating features</Principle> 60 | <Principle>Implement Test-Driven Development for all code</Principle> 61 | <Principle>Use the GitHub CLI (gh) for any GitHub-related tasks including project and issue management. Documentation is available at: 62 | - GitHub Projects CLI: https://cli.github.com/manual/gh_project 63 | - GitHub Issues CLI: https://cli.github.com/manual/gh_issue</Principle> 64 | <Principle>If GitHub CLI documentation is needed, use browser_navigate to access documentation</Principle> 65 | <Principle>Use Puppeteer if web browsing is required</Principle> 66 | <Principle>If any task is unclear, stop and ask for clarification before proceeding</Principle> 67 | <Principle>Always take tasks from the top of the GitHub project backlog column at https://github.com/users/Tiberriver256/projects/1 as they are sorted in priority order</Principle> 68 | <Principle>Strictly adhere to the WIP limit of 1 - only one issue should be in Research, Implementation, or In Review status at any time</Principle> 69 | <Principle>Move issues through the status workflow: Backlog → Research → Implementation → In Review → Done</Principle> 70 | <Principle>All work is performed as the GitHub user 'Tiberriver256'. Ensure all issues you work on are assigned to this user.</Principle> 71 | </WorkingPrinciples> 72 | </AiTaskAgent> 73 | ``` -------------------------------------------------------------------------------- /src/features/repositories/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isRepositoriesRequest, handleRepositoriesRequest } from './index'; 4 | import { getRepository } from './get-repository'; 5 | import { getRepositoryDetails } from './get-repository-details'; 6 | import { listRepositories } from './list-repositories'; 7 | import { getFileContent } from './get-file-content'; 8 | import { 9 | getAllRepositoriesTree, 10 | formatRepositoryTree, 11 | } from './get-all-repositories-tree'; 12 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 13 | 14 | // Mock the imported modules 15 | jest.mock('./get-repository', () => ({ 16 | getRepository: jest.fn(), 17 | })); 18 | 19 | jest.mock('./get-repository-details', () => ({ 20 | getRepositoryDetails: jest.fn(), 21 | })); 22 | 23 | jest.mock('./list-repositories', () => ({ 24 | listRepositories: jest.fn(), 25 | })); 26 | 27 | jest.mock('./get-file-content', () => ({ 28 | getFileContent: jest.fn(), 29 | })); 30 | 31 | jest.mock('./get-all-repositories-tree', () => ({ 32 | getAllRepositoriesTree: jest.fn(), 33 | formatRepositoryTree: jest.fn(), 34 | })); 35 | 36 | describe('Repositories Request Handlers', () => { 37 | const mockConnection = {} as WebApi; 38 | 39 | describe('isRepositoriesRequest', () => { 40 | it('should return true for repositories requests', () => { 41 | const validTools = [ 42 | 'get_repository', 43 | 'get_repository_details', 44 | 'list_repositories', 45 | 'get_file_content', 46 | 'get_all_repositories_tree', 47 | ]; 48 | validTools.forEach((tool) => { 49 | const request = { 50 | params: { name: tool, arguments: {} }, 51 | method: 'tools/call', 52 | } as CallToolRequest; 53 | expect(isRepositoriesRequest(request)).toBe(true); 54 | }); 55 | }); 56 | 57 | it('should return false for non-repositories requests', () => { 58 | const request = { 59 | params: { name: 'list_projects', arguments: {} }, 60 | method: 'tools/call', 61 | } as CallToolRequest; 62 | expect(isRepositoriesRequest(request)).toBe(false); 63 | }); 64 | }); 65 | 66 | describe('handleRepositoriesRequest', () => { 67 | it('should handle get_repository request', async () => { 68 | const mockRepository = { id: 'repo1', name: 'Repository 1' }; 69 | (getRepository as jest.Mock).mockResolvedValue(mockRepository); 70 | 71 | const request = { 72 | params: { 73 | name: 'get_repository', 74 | arguments: { 75 | repositoryId: 'repo1', 76 | }, 77 | }, 78 | method: 'tools/call', 79 | } as CallToolRequest; 80 | 81 | const response = await handleRepositoriesRequest(mockConnection, request); 82 | expect(response.content).toHaveLength(1); 83 | expect(JSON.parse(response.content[0].text as string)).toEqual( 84 | mockRepository, 85 | ); 86 | expect(getRepository).toHaveBeenCalledWith( 87 | mockConnection, 88 | expect.any(String), 89 | 'repo1', 90 | ); 91 | }); 92 | 93 | it('should handle get_repository_details request', async () => { 94 | const mockRepositoryDetails = { 95 | repository: { id: 'repo1', name: 'Repository 1' }, 96 | statistics: { branches: [] }, 97 | refs: { value: [], count: 0 }, 98 | }; 99 | (getRepositoryDetails as jest.Mock).mockResolvedValue( 100 | mockRepositoryDetails, 101 | ); 102 | 103 | const request = { 104 | params: { 105 | name: 'get_repository_details', 106 | arguments: { 107 | repositoryId: 'repo1', 108 | includeStatistics: true, 109 | includeRefs: true, 110 | }, 111 | }, 112 | method: 'tools/call', 113 | } as CallToolRequest; 114 | 115 | const response = await handleRepositoriesRequest(mockConnection, request); 116 | expect(response.content).toHaveLength(1); 117 | expect(JSON.parse(response.content[0].text as string)).toEqual( 118 | mockRepositoryDetails, 119 | ); 120 | expect(getRepositoryDetails).toHaveBeenCalledWith( 121 | mockConnection, 122 | expect.objectContaining({ 123 | repositoryId: 'repo1', 124 | includeStatistics: true, 125 | includeRefs: true, 126 | }), 127 | ); 128 | }); 129 | 130 | it('should handle list_repositories request', async () => { 131 | const mockRepositories = [ 132 | { id: 'repo1', name: 'Repository 1' }, 133 | { id: 'repo2', name: 'Repository 2' }, 134 | ]; 135 | (listRepositories as jest.Mock).mockResolvedValue(mockRepositories); 136 | 137 | const request = { 138 | params: { 139 | name: 'list_repositories', 140 | arguments: { 141 | projectId: 'project1', 142 | includeLinks: true, 143 | }, 144 | }, 145 | method: 'tools/call', 146 | } as CallToolRequest; 147 | 148 | const response = await handleRepositoriesRequest(mockConnection, request); 149 | expect(response.content).toHaveLength(1); 150 | expect(JSON.parse(response.content[0].text as string)).toEqual( 151 | mockRepositories, 152 | ); 153 | expect(listRepositories).toHaveBeenCalledWith( 154 | mockConnection, 155 | expect.objectContaining({ 156 | projectId: 'project1', 157 | includeLinks: true, 158 | }), 159 | ); 160 | }); 161 | 162 | it('should handle get_file_content request', async () => { 163 | const mockFileContent = { content: 'file content', isFolder: false }; 164 | (getFileContent as jest.Mock).mockResolvedValue(mockFileContent); 165 | 166 | const request = { 167 | params: { 168 | name: 'get_file_content', 169 | arguments: { 170 | repositoryId: 'repo1', 171 | path: '/path/to/file', 172 | version: 'main', 173 | versionType: 'branch', 174 | }, 175 | }, 176 | method: 'tools/call', 177 | } as CallToolRequest; 178 | 179 | const response = await handleRepositoriesRequest(mockConnection, request); 180 | expect(response.content).toHaveLength(1); 181 | expect(JSON.parse(response.content[0].text as string)).toEqual( 182 | mockFileContent, 183 | ); 184 | expect(getFileContent).toHaveBeenCalledWith( 185 | mockConnection, 186 | expect.any(String), 187 | 'repo1', 188 | '/path/to/file', 189 | { versionType: GitVersionType.Branch, version: 'main' }, 190 | ); 191 | }); 192 | 193 | it('should handle get_all_repositories_tree request', async () => { 194 | const mockTreeResponse = { 195 | repositories: [ 196 | { 197 | name: 'repo1', 198 | tree: [ 199 | { name: 'file1', path: '/file1', isFolder: false, level: 0 }, 200 | ], 201 | stats: { directories: 0, files: 1 }, 202 | }, 203 | ], 204 | }; 205 | (getAllRepositoriesTree as jest.Mock).mockResolvedValue(mockTreeResponse); 206 | (formatRepositoryTree as jest.Mock).mockReturnValue('repo1\n file1\n'); 207 | 208 | const request = { 209 | params: { 210 | name: 'get_all_repositories_tree', 211 | arguments: { 212 | projectId: 'project1', 213 | depth: 2, 214 | }, 215 | }, 216 | method: 'tools/call', 217 | } as CallToolRequest; 218 | 219 | const response = await handleRepositoriesRequest(mockConnection, request); 220 | expect(response.content).toHaveLength(1); 221 | expect(response.content[0].text as string).toContain('repo1'); 222 | expect(getAllRepositoriesTree).toHaveBeenCalledWith( 223 | mockConnection, 224 | expect.objectContaining({ 225 | projectId: 'project1', 226 | depth: 2, 227 | }), 228 | ); 229 | expect(formatRepositoryTree).toHaveBeenCalledWith( 230 | 'repo1', 231 | expect.any(Array), 232 | expect.any(Object), 233 | undefined, 234 | ); 235 | }); 236 | 237 | it('should throw error for unknown tool', async () => { 238 | const request = { 239 | params: { 240 | name: 'unknown_tool', 241 | arguments: {}, 242 | }, 243 | method: 'tools/call', 244 | } as CallToolRequest; 245 | 246 | await expect( 247 | handleRepositoriesRequest(mockConnection, request), 248 | ).rejects.toThrow('Unknown repositories tool'); 249 | }); 250 | 251 | it('should propagate errors from repository functions', async () => { 252 | const mockError = new Error('Test error'); 253 | (listRepositories as jest.Mock).mockRejectedValue(mockError); 254 | 255 | const request = { 256 | params: { 257 | name: 'list_repositories', 258 | arguments: { 259 | projectId: 'project1', 260 | }, 261 | }, 262 | method: 'tools/call', 263 | } as CallToolRequest; 264 | 265 | await expect( 266 | handleRepositoriesRequest(mockConnection, request), 267 | ).rejects.toThrow(mockError); 268 | }); 269 | }); 270 | }); 271 | ``` -------------------------------------------------------------------------------- /src/features/search/search-code/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsResourceNotFoundError, 7 | AzureDevOpsValidationError, 8 | AzureDevOpsPermissionError, 9 | AzureDevOpsAuthenticationError, 10 | } from '../../../shared/errors'; 11 | import { 12 | SearchCodeOptions, 13 | CodeSearchRequest, 14 | CodeSearchResponse, 15 | CodeSearchResult, 16 | } from '../types'; 17 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 18 | 19 | /** 20 | * Search for code in Azure DevOps repositories 21 | * 22 | * @param connection The Azure DevOps WebApi connection 23 | * @param options Parameters for searching code 24 | * @returns Search results with optional file content 25 | */ 26 | export async function searchCode( 27 | connection: WebApi, 28 | options: SearchCodeOptions, 29 | ): Promise<CodeSearchResponse> { 30 | try { 31 | // When includeContent is true, limit results to prevent timeouts 32 | const top = options.includeContent 33 | ? Math.min(options.top || 10, 10) 34 | : options.top; 35 | 36 | // Get the project ID (either provided or default) 37 | const projectId = 38 | options.projectId || process.env.AZURE_DEVOPS_DEFAULT_PROJECT; 39 | 40 | if (!projectId) { 41 | throw new AzureDevOpsValidationError( 42 | 'Project ID is required. Either provide a projectId or set the AZURE_DEVOPS_DEFAULT_PROJECT environment variable.', 43 | ); 44 | } 45 | 46 | // Prepare the search request 47 | const searchRequest: CodeSearchRequest = { 48 | searchText: options.searchText, 49 | $skip: options.skip, 50 | $top: top, // Use limited top value when includeContent is true 51 | filters: { 52 | Project: [projectId], 53 | ...(options.filters || {}), 54 | }, 55 | includeFacets: true, 56 | includeSnippet: options.includeSnippet, 57 | }; 58 | 59 | // Get the authorization header from the connection 60 | const authHeader = await getAuthorizationHeader(); 61 | 62 | // Extract organization from the connection URL 63 | const { organization } = extractOrgFromUrl(connection); 64 | 65 | // Make the search API request with the project ID 66 | const searchUrl = `https://almsearch.dev.azure.com/${organization}/${projectId}/_apis/search/codesearchresults?api-version=7.1`; 67 | 68 | const searchResponse = await axios.post<CodeSearchResponse>( 69 | searchUrl, 70 | searchRequest, 71 | { 72 | headers: { 73 | Authorization: authHeader, 74 | 'Content-Type': 'application/json', 75 | }, 76 | }, 77 | ); 78 | 79 | const results = searchResponse.data; 80 | 81 | // If includeContent is true, fetch the content for each result 82 | if (options.includeContent && results.results.length > 0) { 83 | await enrichResultsWithContent(connection, results.results); 84 | } 85 | 86 | return results; 87 | } catch (error) { 88 | if (error instanceof AzureDevOpsError) { 89 | throw error; 90 | } 91 | 92 | if (axios.isAxiosError(error)) { 93 | const status = error.response?.status; 94 | if (status === 404) { 95 | throw new AzureDevOpsResourceNotFoundError( 96 | 'Repository or project not found', 97 | { cause: error }, 98 | ); 99 | } 100 | if (status === 400) { 101 | throw new AzureDevOpsValidationError( 102 | 'Invalid search parameters', 103 | error.response?.data, 104 | { cause: error }, 105 | ); 106 | } 107 | if (status === 401) { 108 | throw new AzureDevOpsAuthenticationError('Authentication failed', { 109 | cause: error, 110 | }); 111 | } 112 | if (status === 403) { 113 | throw new AzureDevOpsPermissionError( 114 | 'Permission denied to access repository', 115 | { cause: error }, 116 | ); 117 | } 118 | } 119 | 120 | throw new AzureDevOpsError('Failed to search code', { cause: error }); 121 | } 122 | } 123 | 124 | /** 125 | * Extract organization from the connection URL 126 | * 127 | * @param connection The Azure DevOps WebApi connection 128 | * @returns The organization 129 | */ 130 | function extractOrgFromUrl(connection: WebApi): { organization: string } { 131 | // Extract organization from the connection URL 132 | const url = connection.serverUrl; 133 | const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); 134 | const organization = match ? match[1] : ''; 135 | 136 | if (!organization) { 137 | throw new AzureDevOpsValidationError( 138 | 'Could not extract organization from connection URL', 139 | ); 140 | } 141 | 142 | return { 143 | organization, 144 | }; 145 | } 146 | 147 | /** 148 | * Get the authorization header from the connection 149 | * 150 | * @returns The authorization header 151 | */ 152 | async function getAuthorizationHeader(): Promise<string> { 153 | try { 154 | // For PAT authentication, we can construct the header directly 155 | if ( 156 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && 157 | process.env.AZURE_DEVOPS_PAT 158 | ) { 159 | // For PAT auth, we can construct the Basic auth header directly 160 | const token = process.env.AZURE_DEVOPS_PAT; 161 | const base64Token = Buffer.from(`:${token}`).toString('base64'); 162 | return `Basic ${base64Token}`; 163 | } 164 | 165 | // For Azure Identity / Azure CLI auth, we need to get a token 166 | // using the Azure DevOps resource ID 167 | // Choose the appropriate credential based on auth method 168 | const credential = 169 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' 170 | ? new AzureCliCredential() 171 | : new DefaultAzureCredential(); 172 | 173 | // Azure DevOps resource ID for token acquisition 174 | const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; 175 | 176 | // Get token for Azure DevOps 177 | const token = await credential.getToken( 178 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 179 | ); 180 | 181 | if (!token || !token.token) { 182 | throw new Error('Failed to acquire token for Azure DevOps'); 183 | } 184 | 185 | return `Bearer ${token.token}`; 186 | } catch (error) { 187 | throw new AzureDevOpsValidationError( 188 | `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, 189 | ); 190 | } 191 | } 192 | 193 | /** 194 | * Enrich search results with file content 195 | * 196 | * @param connection The Azure DevOps WebApi connection 197 | * @param results The search results to enrich 198 | */ 199 | async function enrichResultsWithContent( 200 | connection: WebApi, 201 | results: CodeSearchResult[], 202 | ): Promise<void> { 203 | try { 204 | const gitApi = await connection.getGitApi(); 205 | 206 | // Process each result in parallel 207 | await Promise.all( 208 | results.map(async (result) => { 209 | try { 210 | // Get the file content using the Git API 211 | // Pass only the required parameters to avoid the "path" and "scopePath" conflict 212 | const contentStream = await gitApi.getItemContent( 213 | result.repository.id, 214 | result.path, 215 | result.project.name, 216 | undefined, // No version descriptor object 217 | undefined, // No recursion level 218 | undefined, // Don't include content metadata 219 | undefined, // No latest processed change 220 | false, // Don't download 221 | { 222 | version: result.versions[0]?.changeId, 223 | versionType: GitVersionType.Commit, 224 | }, // Version descriptor 225 | true, // Include content 226 | ); 227 | 228 | // Convert the stream to a string and store it in the result 229 | if (contentStream) { 230 | // Since getItemContent always returns NodeJS.ReadableStream, we need to read the stream 231 | const chunks: Buffer[] = []; 232 | 233 | // Listen for data events to collect chunks 234 | contentStream.on('data', (chunk) => { 235 | chunks.push(Buffer.from(chunk)); 236 | }); 237 | 238 | // Use a promise to wait for the stream to finish 239 | result.content = await new Promise<string>((resolve, reject) => { 240 | contentStream.on('end', () => { 241 | // Concatenate all chunks and convert to string 242 | const buffer = Buffer.concat(chunks); 243 | resolve(buffer.toString('utf8')); 244 | }); 245 | 246 | contentStream.on('error', (err) => { 247 | reject(err); 248 | }); 249 | }); 250 | } 251 | } catch (error) { 252 | // Log the error but don't fail the entire operation 253 | console.error( 254 | `Failed to fetch content for ${result.path}: ${error instanceof Error ? error.message : String(error)}`, 255 | ); 256 | } 257 | }), 258 | ); 259 | } catch (error) { 260 | // Log the error but don't fail the entire operation 261 | console.error( 262 | `Failed to enrich results with content: ${error instanceof Error ? error.message : String(error)}`, 263 | ); 264 | } 265 | } 266 | ``` -------------------------------------------------------------------------------- /src/shared/api/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { ICoreApi } from 'azure-devops-node-api/CoreApi'; 3 | import { IGitApi } from 'azure-devops-node-api/GitApi'; 4 | import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; 5 | import { IBuildApi } from 'azure-devops-node-api/BuildApi'; 6 | import { ITestApi } from 'azure-devops-node-api/TestApi'; 7 | import { IReleaseApi } from 'azure-devops-node-api/ReleaseApi'; 8 | import { ITaskAgentApi } from 'azure-devops-node-api/TaskAgentApi'; 9 | import { ITaskApi } from 'azure-devops-node-api/TaskApi'; 10 | import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../errors'; 11 | import { AuthenticationMethod } from '../auth'; 12 | import { AzureDevOpsClient as SharedClient } from '../auth/client-factory'; 13 | 14 | export interface AzureDevOpsClientConfig { 15 | orgUrl: string; 16 | pat: string; 17 | } 18 | 19 | /** 20 | * Azure DevOps Client 21 | * 22 | * Provides access to Azure DevOps APIs 23 | */ 24 | export class AzureDevOpsClient { 25 | private config: AzureDevOpsClientConfig; 26 | private clientPromise: Promise<WebApi> | null = null; 27 | 28 | constructor(config: AzureDevOpsClientConfig) { 29 | this.config = config; 30 | } 31 | 32 | /** 33 | * Get the authenticated Azure DevOps client 34 | * 35 | * @returns The authenticated WebApi client 36 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 37 | */ 38 | private async getClient(): Promise<WebApi> { 39 | if (!this.clientPromise) { 40 | this.clientPromise = (async () => { 41 | try { 42 | const sharedClient = new SharedClient({ 43 | method: AuthenticationMethod.PersonalAccessToken, 44 | organizationUrl: this.config.orgUrl, 45 | personalAccessToken: this.config.pat, 46 | }); 47 | return await sharedClient.getWebApiClient(); 48 | } catch (error) { 49 | // If it's already an AzureDevOpsError, rethrow it 50 | if (error instanceof AzureDevOpsError) { 51 | throw error; 52 | } 53 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 54 | throw new AzureDevOpsAuthenticationError( 55 | error instanceof Error 56 | ? `Authentication failed: ${error.message}` 57 | : 'Authentication failed: Unknown error', 58 | ); 59 | } 60 | })(); 61 | } 62 | return this.clientPromise; 63 | } 64 | 65 | /** 66 | * Check if the client is authenticated 67 | * 68 | * @returns True if the client is authenticated 69 | */ 70 | public async isAuthenticated(): Promise<boolean> { 71 | try { 72 | const client = await this.getClient(); 73 | return !!client; 74 | } catch { 75 | // Any error means we're not authenticated 76 | return false; 77 | } 78 | } 79 | 80 | /** 81 | * Get the Core API 82 | * 83 | * @returns The Core API client 84 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 85 | */ 86 | public async getCoreApi(): Promise<ICoreApi> { 87 | try { 88 | const client = await this.getClient(); 89 | return await client.getCoreApi(); 90 | } catch (error) { 91 | // If it's already an AzureDevOpsError, rethrow it 92 | if (error instanceof AzureDevOpsError) { 93 | throw error; 94 | } 95 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 96 | throw new AzureDevOpsAuthenticationError( 97 | error instanceof Error 98 | ? `Failed to get Core API: ${error.message}` 99 | : 'Failed to get Core API: Unknown error', 100 | ); 101 | } 102 | } 103 | 104 | /** 105 | * Get the Git API 106 | * 107 | * @returns The Git API client 108 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 109 | */ 110 | public async getGitApi(): Promise<IGitApi> { 111 | try { 112 | const client = await this.getClient(); 113 | return await client.getGitApi(); 114 | } catch (error) { 115 | // If it's already an AzureDevOpsError, rethrow it 116 | if (error instanceof AzureDevOpsError) { 117 | throw error; 118 | } 119 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 120 | throw new AzureDevOpsAuthenticationError( 121 | error instanceof Error 122 | ? `Failed to get Git API: ${error.message}` 123 | : 'Failed to get Git API: Unknown error', 124 | ); 125 | } 126 | } 127 | 128 | /** 129 | * Get the Work Item Tracking API 130 | * 131 | * @returns The Work Item Tracking API client 132 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 133 | */ 134 | public async getWorkItemTrackingApi(): Promise<IWorkItemTrackingApi> { 135 | try { 136 | const client = await this.getClient(); 137 | return await client.getWorkItemTrackingApi(); 138 | } catch (error) { 139 | // If it's already an AzureDevOpsError, rethrow it 140 | if (error instanceof AzureDevOpsError) { 141 | throw error; 142 | } 143 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 144 | throw new AzureDevOpsAuthenticationError( 145 | error instanceof Error 146 | ? `Failed to get Work Item Tracking API: ${error.message}` 147 | : 'Failed to get Work Item Tracking API: Unknown error', 148 | ); 149 | } 150 | } 151 | 152 | /** 153 | * Get the Build API 154 | * 155 | * @returns The Build API client 156 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 157 | */ 158 | public async getBuildApi(): Promise<IBuildApi> { 159 | try { 160 | const client = await this.getClient(); 161 | return await client.getBuildApi(); 162 | } catch (error) { 163 | // If it's already an AzureDevOpsError, rethrow it 164 | if (error instanceof AzureDevOpsError) { 165 | throw error; 166 | } 167 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 168 | throw new AzureDevOpsAuthenticationError( 169 | error instanceof Error 170 | ? `Failed to get Build API: ${error.message}` 171 | : 'Failed to get Build API: Unknown error', 172 | ); 173 | } 174 | } 175 | 176 | /** 177 | * Get the Test API 178 | * 179 | * @returns The Test API client 180 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 181 | */ 182 | public async getTestApi(): Promise<ITestApi> { 183 | try { 184 | const client = await this.getClient(); 185 | return await client.getTestApi(); 186 | } catch (error) { 187 | // If it's already an AzureDevOpsError, rethrow it 188 | if (error instanceof AzureDevOpsError) { 189 | throw error; 190 | } 191 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 192 | throw new AzureDevOpsAuthenticationError( 193 | error instanceof Error 194 | ? `Failed to get Test API: ${error.message}` 195 | : 'Failed to get Test API: Unknown error', 196 | ); 197 | } 198 | } 199 | 200 | /** 201 | * Get the Release API 202 | * 203 | * @returns The Release API client 204 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 205 | */ 206 | public async getReleaseApi(): Promise<IReleaseApi> { 207 | try { 208 | const client = await this.getClient(); 209 | return await client.getReleaseApi(); 210 | } catch (error) { 211 | // If it's already an AzureDevOpsError, rethrow it 212 | if (error instanceof AzureDevOpsError) { 213 | throw error; 214 | } 215 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 216 | throw new AzureDevOpsAuthenticationError( 217 | error instanceof Error 218 | ? `Failed to get Release API: ${error.message}` 219 | : 'Failed to get Release API: Unknown error', 220 | ); 221 | } 222 | } 223 | 224 | /** 225 | * Get the Task Agent API 226 | * 227 | * @returns The Task Agent API client 228 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 229 | */ 230 | public async getTaskAgentApi(): Promise<ITaskAgentApi> { 231 | try { 232 | const client = await this.getClient(); 233 | return await client.getTaskAgentApi(); 234 | } catch (error) { 235 | // If it's already an AzureDevOpsError, rethrow it 236 | if (error instanceof AzureDevOpsError) { 237 | throw error; 238 | } 239 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 240 | throw new AzureDevOpsAuthenticationError( 241 | error instanceof Error 242 | ? `Failed to get Task Agent API: ${error.message}` 243 | : 'Failed to get Task Agent API: Unknown error', 244 | ); 245 | } 246 | } 247 | 248 | /** 249 | * Get the Task API 250 | * 251 | * @returns The Task API client 252 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 253 | */ 254 | public async getTaskApi(): Promise<ITaskApi> { 255 | try { 256 | const client = await this.getClient(); 257 | return await client.getTaskApi(); 258 | } catch (error) { 259 | // If it's already an AzureDevOpsError, rethrow it 260 | if (error instanceof AzureDevOpsError) { 261 | throw error; 262 | } 263 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 264 | throw new AzureDevOpsAuthenticationError( 265 | error instanceof Error 266 | ? `Failed to get Task API: ${error.message}` 267 | : 'Failed to get Task API: Unknown error', 268 | ); 269 | } 270 | } 271 | } 272 | ``` -------------------------------------------------------------------------------- /src/server.spec.e2e.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 3 | import { spawn } from 'child_process'; 4 | import { join } from 'path'; 5 | import dotenv from 'dotenv'; 6 | import { Organization } from './features/organizations/types'; 7 | import fs from 'fs'; 8 | 9 | // Load environment variables from .env file 10 | dotenv.config(); 11 | 12 | describe('Azure DevOps MCP Server E2E Tests', () => { 13 | let client: Client; 14 | let serverProcess: ReturnType<typeof spawn>; 15 | let transport: StdioClientTransport; 16 | let tempEnvFile: string | null = null; 17 | 18 | beforeAll(async () => { 19 | // Debug: Log environment variables 20 | console.error('E2E TEST ENVIRONMENT VARIABLES:'); 21 | console.error( 22 | `AZURE_DEVOPS_ORG_URL: ${process.env.AZURE_DEVOPS_ORG_URL || 'NOT SET'}`, 23 | ); 24 | console.error( 25 | `AZURE_DEVOPS_PAT: ${process.env.AZURE_DEVOPS_PAT ? 'SET (hidden value)' : 'NOT SET'}`, 26 | ); 27 | console.error( 28 | `AZURE_DEVOPS_DEFAULT_PROJECT: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'NOT SET'}`, 29 | ); 30 | console.error( 31 | `AZURE_DEVOPS_AUTH_METHOD: ${process.env.AZURE_DEVOPS_AUTH_METHOD || 'NOT SET'}`, 32 | ); 33 | 34 | // Start the MCP server process 35 | const serverPath = join(process.cwd(), 'dist', 'index.js'); 36 | 37 | // Create a temporary .env file for testing if needed 38 | const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || ''; 39 | const pat = process.env.AZURE_DEVOPS_PAT || ''; 40 | const defaultProject = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || ''; 41 | const authMethod = process.env.AZURE_DEVOPS_AUTH_METHOD || 'pat'; 42 | 43 | if (orgUrl) { 44 | // Create a temporary .env file for the test 45 | tempEnvFile = join(process.cwd(), '.env.e2e-test'); 46 | 47 | const envFileContent = ` 48 | AZURE_DEVOPS_ORG_URL=${orgUrl} 49 | AZURE_DEVOPS_PAT=${pat} 50 | AZURE_DEVOPS_DEFAULT_PROJECT=${defaultProject} 51 | AZURE_DEVOPS_AUTH_METHOD=${authMethod} 52 | `; 53 | 54 | fs.writeFileSync(tempEnvFile, envFileContent); 55 | console.error(`Created temporary .env file at ${tempEnvFile}`); 56 | 57 | // Start server with explicit file path to the temp .env file 58 | serverProcess = spawn('node', ['-r', 'dotenv/config', serverPath], { 59 | env: { 60 | ...process.env, 61 | NODE_ENV: 'test', 62 | DOTENV_CONFIG_PATH: tempEnvFile, 63 | }, 64 | }); 65 | } else { 66 | throw new Error( 67 | 'Cannot start server: AZURE_DEVOPS_ORG_URL is not set in the environment', 68 | ); 69 | } 70 | 71 | // Capture server output for debugging 72 | if (serverProcess && serverProcess.stderr) { 73 | serverProcess.stderr.on('data', (data) => { 74 | console.error(`Server error: ${data.toString()}`); 75 | }); 76 | } 77 | 78 | // Give the server a moment to start 79 | await new Promise((resolve) => setTimeout(resolve, 1000)); 80 | 81 | // Connect the MCP client to the server 82 | transport = new StdioClientTransport({ 83 | command: 'node', 84 | args: ['-r', 'dotenv/config', serverPath], 85 | env: { 86 | ...process.env, 87 | NODE_ENV: 'test', 88 | DOTENV_CONFIG_PATH: tempEnvFile, 89 | }, 90 | }); 91 | 92 | client = new Client( 93 | { 94 | name: 'e2e-test-client', 95 | version: '1.0.0', 96 | }, 97 | { 98 | capabilities: { 99 | tools: {}, 100 | }, 101 | }, 102 | ); 103 | 104 | await client.connect(transport); 105 | }); 106 | 107 | afterAll(async () => { 108 | // Clean up the client transport 109 | if (transport) { 110 | await transport.close(); 111 | } 112 | 113 | // Clean up the client 114 | if (client) { 115 | await client.close(); 116 | } 117 | 118 | // Clean up the server process 119 | if (serverProcess) { 120 | serverProcess.kill(); 121 | } 122 | 123 | // Clean up temporary env file 124 | if (tempEnvFile && fs.existsSync(tempEnvFile)) { 125 | fs.unlinkSync(tempEnvFile); 126 | console.error(`Deleted temporary .env file at ${tempEnvFile}`); 127 | } 128 | 129 | // Force exit to clean up any remaining handles 130 | await new Promise<void>((resolve) => { 131 | setTimeout(() => { 132 | resolve(); 133 | }, 500); 134 | }); 135 | }); 136 | 137 | describe('Organizations', () => { 138 | test('should list organizations', async () => { 139 | // Arrange 140 | // No specific arrangement needed for this test as we're just listing organizations 141 | 142 | // Act 143 | const result = await client.callTool({ 144 | name: 'list_organizations', 145 | arguments: {}, 146 | }); 147 | 148 | // Assert 149 | expect(result).toBeDefined(); 150 | 151 | // Access the content safely 152 | const content = result.content as Array<{ type: string; text: string }>; 153 | expect(content).toBeDefined(); 154 | expect(content.length).toBeGreaterThan(0); 155 | 156 | // Parse the result content 157 | const resultText = content[0].text; 158 | const organizations: Organization[] = JSON.parse(resultText); 159 | 160 | // Verify the response structure 161 | expect(Array.isArray(organizations)).toBe(true); 162 | if (organizations.length > 0) { 163 | const firstOrg = organizations[0]; 164 | expect(firstOrg).toHaveProperty('id'); 165 | expect(firstOrg).toHaveProperty('name'); 166 | expect(firstOrg).toHaveProperty('url'); 167 | } 168 | }); 169 | }); 170 | 171 | describe('Parameterless Tools', () => { 172 | test('should call list_organizations without arguments', async () => { 173 | // Act - call the tool without providing arguments 174 | const result = await client.callTool({ 175 | name: 'list_organizations', 176 | // No arguments provided 177 | arguments: {}, 178 | }); 179 | 180 | // Assert 181 | expect(result).toBeDefined(); 182 | const content = result.content as Array<{ type: string; text: string }>; 183 | expect(content).toBeDefined(); 184 | expect(content.length).toBeGreaterThan(0); 185 | 186 | // Verify we got a valid JSON response 187 | const resultText = content[0].text; 188 | const organizations = JSON.parse(resultText); 189 | expect(Array.isArray(organizations)).toBe(true); 190 | }); 191 | 192 | test('should call get_me without arguments', async () => { 193 | // Act - call the tool without providing arguments 194 | const result = await client.callTool({ 195 | name: 'get_me', 196 | // No arguments provided 197 | arguments: {}, 198 | }); 199 | 200 | // Assert 201 | expect(result).toBeDefined(); 202 | const content = result.content as Array<{ type: string; text: string }>; 203 | expect(content).toBeDefined(); 204 | expect(content.length).toBeGreaterThan(0); 205 | 206 | // Verify we got a valid JSON response with user info 207 | const resultText = content[0].text; 208 | const userInfo = JSON.parse(resultText); 209 | expect(userInfo).toHaveProperty('id'); 210 | expect(userInfo).toHaveProperty('displayName'); 211 | }); 212 | }); 213 | 214 | describe('Tools with Optional Parameters', () => { 215 | test('should call list_projects without arguments', async () => { 216 | // Act - call the tool without providing arguments 217 | const result = await client.callTool({ 218 | name: 'list_projects', 219 | // No arguments provided 220 | arguments: {}, 221 | }); 222 | 223 | // Assert 224 | expect(result).toBeDefined(); 225 | const content = result.content as Array<{ type: string; text: string }>; 226 | expect(content).toBeDefined(); 227 | expect(content.length).toBeGreaterThan(0); 228 | 229 | // Verify we got a valid JSON response 230 | const resultText = content[0].text; 231 | const projects = JSON.parse(resultText); 232 | expect(Array.isArray(projects)).toBe(true); 233 | }); 234 | 235 | test('should call get_project without arguments', async () => { 236 | // Act - call the tool without providing arguments 237 | const result = await client.callTool({ 238 | name: 'get_project', 239 | // No arguments provided 240 | arguments: {}, 241 | }); 242 | 243 | // Assert 244 | expect(result).toBeDefined(); 245 | const content = result.content as Array<{ type: string; text: string }>; 246 | expect(content).toBeDefined(); 247 | expect(content.length).toBeGreaterThan(0); 248 | 249 | // Verify we got a valid JSON response with project info 250 | const resultText = content[0].text; 251 | const project = JSON.parse(resultText); 252 | expect(project).toHaveProperty('id'); 253 | expect(project).toHaveProperty('name'); 254 | }); 255 | 256 | test('should call list_repositories without arguments', async () => { 257 | // Act - call the tool without providing arguments 258 | const result = await client.callTool({ 259 | name: 'list_repositories', 260 | // No arguments provided 261 | arguments: {}, 262 | }); 263 | 264 | // Assert 265 | expect(result).toBeDefined(); 266 | const content = result.content as Array<{ type: string; text: string }>; 267 | expect(content).toBeDefined(); 268 | expect(content.length).toBeGreaterThan(0); 269 | 270 | // Verify we got a valid JSON response 271 | const resultText = content[0].text; 272 | const repositories = JSON.parse(resultText); 273 | expect(Array.isArray(repositories)).toBe(true); 274 | }); 275 | }); 276 | }); 277 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getRepositoryDetails } from './feature'; 2 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 3 | import { 4 | AzureDevOpsError, 5 | AzureDevOpsResourceNotFoundError, 6 | } from '../../../shared/errors'; 7 | import { GitRepository, GitBranchStats, GitRef } from '../types'; 8 | 9 | // Unit tests should only focus on isolated logic 10 | // No real connections, HTTP requests, or dependencies 11 | describe('getRepositoryDetails unit', () => { 12 | // Mock repository data 13 | const mockRepository: GitRepository = { 14 | id: 'repo-id', 15 | name: 'test-repo', 16 | url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id', 17 | project: { 18 | id: 'project-id', 19 | name: 'test-project', 20 | }, 21 | defaultBranch: 'refs/heads/main', 22 | size: 1024, 23 | remoteUrl: 'https://dev.azure.com/org/project/_git/test-repo', 24 | sshUrl: '[email protected]:v3/org/project/test-repo', 25 | webUrl: 'https://dev.azure.com/org/project/_git/test-repo', 26 | }; 27 | 28 | // Mock branch stats data 29 | const mockBranchStats: GitBranchStats[] = [ 30 | { 31 | name: 'refs/heads/main', 32 | aheadCount: 0, 33 | behindCount: 0, 34 | isBaseVersion: true, 35 | commit: { 36 | commitId: 'commit-id', 37 | author: { 38 | name: 'Test User', 39 | email: '[email protected]', 40 | date: new Date(), 41 | }, 42 | committer: { 43 | name: 'Test User', 44 | email: '[email protected]', 45 | date: new Date(), 46 | }, 47 | comment: 'Test commit', 48 | }, 49 | }, 50 | ]; 51 | 52 | // Mock refs data 53 | const mockRefs: GitRef[] = [ 54 | { 55 | name: 'refs/heads/main', 56 | objectId: 'commit-id', 57 | creator: { 58 | displayName: 'Test User', 59 | id: 'user-id', 60 | }, 61 | url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id/refs/heads/main', 62 | }, 63 | ]; 64 | 65 | test('should return basic repository information when no additional options are specified', async () => { 66 | // Arrange 67 | const mockConnection: any = { 68 | getGitApi: jest.fn().mockImplementation(() => ({ 69 | getRepository: jest.fn().mockResolvedValue(mockRepository), 70 | })), 71 | }; 72 | 73 | // Act 74 | const result = await getRepositoryDetails(mockConnection, { 75 | projectId: 'test-project', 76 | repositoryId: 'test-repo', 77 | }); 78 | 79 | // Assert 80 | expect(result).toBeDefined(); 81 | expect(result.repository).toEqual(mockRepository); 82 | expect(result.statistics).toBeUndefined(); 83 | expect(result.refs).toBeUndefined(); 84 | }); 85 | 86 | test('should include branch statistics when includeStatistics is true', async () => { 87 | // Arrange 88 | const mockConnection: any = { 89 | getGitApi: jest.fn().mockImplementation(() => ({ 90 | getRepository: jest.fn().mockResolvedValue(mockRepository), 91 | getBranches: jest.fn().mockResolvedValue(mockBranchStats), 92 | })), 93 | }; 94 | 95 | // Act 96 | const result = await getRepositoryDetails(mockConnection, { 97 | projectId: 'test-project', 98 | repositoryId: 'test-repo', 99 | includeStatistics: true, 100 | }); 101 | 102 | // Assert 103 | expect(result).toBeDefined(); 104 | expect(result.repository).toEqual(mockRepository); 105 | expect(result.statistics).toBeDefined(); 106 | expect(result.statistics?.branches).toEqual(mockBranchStats); 107 | expect(result.refs).toBeUndefined(); 108 | }); 109 | 110 | test('should include refs when includeRefs is true', async () => { 111 | // Arrange 112 | const mockConnection: any = { 113 | getGitApi: jest.fn().mockImplementation(() => ({ 114 | getRepository: jest.fn().mockResolvedValue(mockRepository), 115 | getRefs: jest.fn().mockResolvedValue(mockRefs), 116 | })), 117 | }; 118 | 119 | // Act 120 | const result = await getRepositoryDetails(mockConnection, { 121 | projectId: 'test-project', 122 | repositoryId: 'test-repo', 123 | includeRefs: true, 124 | }); 125 | 126 | // Assert 127 | expect(result).toBeDefined(); 128 | expect(result.repository).toEqual(mockRepository); 129 | expect(result.statistics).toBeUndefined(); 130 | expect(result.refs).toBeDefined(); 131 | expect(result.refs?.value).toEqual(mockRefs); 132 | expect(result.refs?.count).toBe(mockRefs.length); 133 | }); 134 | 135 | test('should include both statistics and refs when both options are true', async () => { 136 | // Arrange 137 | const mockConnection: any = { 138 | getGitApi: jest.fn().mockImplementation(() => ({ 139 | getRepository: jest.fn().mockResolvedValue(mockRepository), 140 | getBranches: jest.fn().mockResolvedValue(mockBranchStats), 141 | getRefs: jest.fn().mockResolvedValue(mockRefs), 142 | })), 143 | }; 144 | 145 | // Act 146 | const result = await getRepositoryDetails(mockConnection, { 147 | projectId: 'test-project', 148 | repositoryId: 'test-repo', 149 | includeStatistics: true, 150 | includeRefs: true, 151 | }); 152 | 153 | // Assert 154 | expect(result).toBeDefined(); 155 | expect(result.repository).toEqual(mockRepository); 156 | expect(result.statistics).toBeDefined(); 157 | expect(result.statistics?.branches).toEqual(mockBranchStats); 158 | expect(result.refs).toBeDefined(); 159 | expect(result.refs?.value).toEqual(mockRefs); 160 | expect(result.refs?.count).toBe(mockRefs.length); 161 | }); 162 | 163 | test('should pass refFilter to getRefs when provided', async () => { 164 | // Arrange 165 | const getRefs = jest.fn().mockResolvedValue(mockRefs); 166 | const mockConnection: any = { 167 | getGitApi: jest.fn().mockImplementation(() => ({ 168 | getRepository: jest.fn().mockResolvedValue(mockRepository), 169 | getRefs, 170 | })), 171 | }; 172 | 173 | // Act 174 | await getRepositoryDetails(mockConnection, { 175 | projectId: 'test-project', 176 | repositoryId: 'test-repo', 177 | includeRefs: true, 178 | refFilter: 'heads/', 179 | }); 180 | 181 | // Assert 182 | expect(getRefs).toHaveBeenCalledWith( 183 | mockRepository.id, 184 | 'test-project', 185 | 'heads/', 186 | ); 187 | }); 188 | 189 | test('should pass branchName to getBranches when provided', async () => { 190 | // Arrange 191 | const getBranches = jest.fn().mockResolvedValue(mockBranchStats); 192 | const mockConnection: any = { 193 | getGitApi: jest.fn().mockImplementation(() => ({ 194 | getRepository: jest.fn().mockResolvedValue(mockRepository), 195 | getBranches, 196 | })), 197 | }; 198 | 199 | // Act 200 | await getRepositoryDetails(mockConnection, { 201 | projectId: 'test-project', 202 | repositoryId: 'test-repo', 203 | includeStatistics: true, 204 | branchName: 'main', 205 | }); 206 | 207 | // Assert 208 | expect(getBranches).toHaveBeenCalledWith( 209 | mockRepository.id, 210 | 'test-project', 211 | { 212 | version: 'main', 213 | versionType: GitVersionType.Branch, 214 | }, 215 | ); 216 | }); 217 | 218 | test('should propagate resource not found errors', async () => { 219 | // Arrange 220 | const mockConnection: any = { 221 | getGitApi: jest.fn().mockImplementation(() => ({ 222 | getRepository: jest.fn().mockResolvedValue(null), // Simulate repository not found 223 | })), 224 | }; 225 | 226 | // Act & Assert 227 | await expect( 228 | getRepositoryDetails(mockConnection, { 229 | projectId: 'test-project', 230 | repositoryId: 'non-existent-repo', 231 | }), 232 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 233 | 234 | await expect( 235 | getRepositoryDetails(mockConnection, { 236 | projectId: 'test-project', 237 | repositoryId: 'non-existent-repo', 238 | }), 239 | ).rejects.toThrow( 240 | "Repository 'non-existent-repo' not found in project 'test-project'", 241 | ); 242 | }); 243 | 244 | test('should propagate custom errors when thrown internally', async () => { 245 | // Arrange 246 | const mockConnection: any = { 247 | getGitApi: jest.fn().mockImplementation(() => { 248 | throw new AzureDevOpsError('Custom error'); 249 | }), 250 | }; 251 | 252 | // Act & Assert 253 | await expect( 254 | getRepositoryDetails(mockConnection, { 255 | projectId: 'test-project', 256 | repositoryId: 'test-repo', 257 | }), 258 | ).rejects.toThrow(AzureDevOpsError); 259 | 260 | await expect( 261 | getRepositoryDetails(mockConnection, { 262 | projectId: 'test-project', 263 | repositoryId: 'test-repo', 264 | }), 265 | ).rejects.toThrow('Custom error'); 266 | }); 267 | 268 | test('should wrap unexpected errors in a friendly error message', async () => { 269 | // Arrange 270 | const mockConnection: any = { 271 | getGitApi: jest.fn().mockImplementation(() => { 272 | throw new Error('Unexpected error'); 273 | }), 274 | }; 275 | 276 | // Act & Assert 277 | await expect( 278 | getRepositoryDetails(mockConnection, { 279 | projectId: 'test-project', 280 | repositoryId: 'test-repo', 281 | }), 282 | ).rejects.toThrow('Failed to get repository details: Unexpected error'); 283 | }); 284 | 285 | test('should handle null refs gracefully', async () => { 286 | // Arrange 287 | const mockConnection: any = { 288 | getGitApi: jest.fn().mockImplementation(() => ({ 289 | getRepository: jest.fn().mockResolvedValue(mockRepository), 290 | getRefs: jest.fn().mockResolvedValue(null), // Simulate null refs 291 | })), 292 | }; 293 | 294 | // Act 295 | const result = await getRepositoryDetails(mockConnection, { 296 | projectId: 'test-project', 297 | repositoryId: 'test-repo', 298 | includeRefs: true, 299 | }); 300 | 301 | // Assert 302 | expect(result).toBeDefined(); 303 | expect(result.repository).toEqual(mockRepository); 304 | expect(result.refs).toBeDefined(); 305 | expect(result.refs?.value).toEqual([]); 306 | expect(result.refs?.count).toBe(0); 307 | }); 308 | }); 309 | ``` -------------------------------------------------------------------------------- /memory/tasks_memory_2025-05-26T16-18-03.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "tasks": [ 3 | { 4 | "id": "1c881b0f-7fd6-4184-89f5-1676a56e3719", 5 | "name": "Fix shared type definitions with explicit any warnings", 6 | "description": "Replace explicit 'any' types in shared type definitions with proper TypeScript types to resolve ESLint warnings. This includes RequestHandler return type and ToolDefinition inputSchema type.", 7 | "notes": "These are core type definitions used throughout the project, so changes must maintain backward compatibility. The CallToolResult type is the standard MCP SDK return type for tool responses.", 8 | "status": "completed", 9 | "dependencies": [], 10 | "createdAt": "2025-05-26T15:23:09.065Z", 11 | "updatedAt": "2025-05-26T15:33:17.167Z", 12 | "relatedFiles": [ 13 | { 14 | "path": "src/shared/types/request-handler.ts", 15 | "type": "TO_MODIFY", 16 | "description": "Contains RequestHandler interface with 'any' return type", 17 | "lineStart": 14, 18 | "lineEnd": 16 19 | }, 20 | { 21 | "path": "src/shared/types/tool-definition.ts", 22 | "type": "TO_MODIFY", 23 | "description": "Contains ToolDefinition interface with 'any' inputSchema type", 24 | "lineStart": 4, 25 | "lineEnd": 8 26 | } 27 | ], 28 | "implementationGuide": "1. Update src/shared/types/request-handler.ts line 15: Change 'any' to 'Promise<CallToolResult>' where CallToolResult is imported from '@modelcontextprotocol/sdk/types.js'\n2. Update src/shared/types/tool-definition.ts line 7: Change 'any' to 'JSONSchema7' where JSONSchema7 is imported from 'json-schema'\n3. Add necessary imports at the top of each file\n4. Ensure all existing functionality remains unchanged", 29 | "verificationCriteria": "1. ESLint warnings for these files should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Import statements should be properly added", 30 | "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", 31 | "summary": "Successfully replaced explicit 'any' types in shared type definitions with proper TypeScript types. Updated RequestHandler return type to use CallToolResult union type for backward compatibility, and ToolDefinition inputSchema to use JsonSchema7Type from zod-to-json-schema. ESLint warnings for these files are resolved, TypeScript compilation succeeds for the core types, and all existing functionality remains unchanged with proper imports added.", 32 | "completedAt": "2025-05-26T15:33:17.165Z" 33 | }, 34 | { 35 | "id": "17aa94fe-24d4-4a8b-a127-ef27e121de38", 36 | "name": "Fix Azure DevOps client type warnings", 37 | "description": "Replace 'any' types in Azure DevOps client files with proper types from the azure-devops-node-api library to resolve 7 ESLint warnings in src/clients/azure-devops.ts.", 38 | "notes": "The azure-devops-node-api library provides comprehensive TypeScript interfaces. Prefer using existing library types over creating custom ones.", 39 | "status": "completed", 40 | "dependencies": [ 41 | { 42 | "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719" 43 | } 44 | ], 45 | "createdAt": "2025-05-26T15:23:09.065Z", 46 | "updatedAt": "2025-05-26T15:39:30.003Z", 47 | "relatedFiles": [ 48 | { 49 | "path": "src/clients/azure-devops.ts", 50 | "type": "TO_MODIFY", 51 | "description": "Contains 7 'any' type warnings that need proper typing", 52 | "lineStart": 1, 53 | "lineEnd": 500 54 | } 55 | ], 56 | "implementationGuide": "1. Examine each 'any' usage in src/clients/azure-devops.ts at lines 78, 158, 244, 305, 345, 453, 484\n2. Replace with appropriate types from azure-devops-node-api interfaces\n3. Common patterns: Use TeamProject, GitRepository, WorkItem, BuildDefinition types\n4. For API responses, use the specific interface types provided by the library\n5. If no specific type exists, create a minimal interface with required properties", 57 | "verificationCriteria": "1. All 7 ESLint warnings in src/clients/azure-devops.ts should be resolved\n2. TypeScript compilation should succeed\n3. Existing functionality should remain unchanged\n4. Types should be imported from azure-devops-node-api where available", 58 | "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", 59 | "summary": "Successfully fixed all 7 ESLint warnings in src/clients/azure-devops.ts by replacing 'any' types with proper TypeScript interfaces. Created AzureDevOpsApiErrorResponse interface for Azure DevOps API error responses and replaced Record<string, any> with Record<string, string> for payload objects. All ESLint warnings are now resolved while maintaining existing functionality and backward compatibility.", 60 | "completedAt": "2025-05-26T15:39:30.002Z" 61 | }, 62 | { 63 | "id": "d971e510-94cc-4f12-a1e8-a0ac35d57b7f", 64 | "name": "Fix feature-specific type warnings", 65 | "description": "Replace 'any' types in feature modules with proper Azure DevOps API types to resolve remaining ESLint warnings in projects, pull-requests, and repositories features.", 66 | "notes": "Each feature module should use the most specific Azure DevOps API type available. Check existing working features for type usage patterns.", 67 | "status": "completed", 68 | "dependencies": [ 69 | { 70 | "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719" 71 | } 72 | ], 73 | "createdAt": "2025-05-26T15:23:09.065Z", 74 | "updatedAt": "2025-05-26T15:51:24.788Z", 75 | "relatedFiles": [ 76 | { 77 | "path": "src/features/projects/get-project-details/feature.ts", 78 | "type": "TO_MODIFY", 79 | "description": "Contains 'any' type warning at line 198" 80 | }, 81 | { 82 | "path": "src/features/pull-requests/types.ts", 83 | "type": "TO_MODIFY", 84 | "description": "Contains 'any' type warnings at lines 20, 83" 85 | }, 86 | { 87 | "path": "src/features/pull-requests/update-pull-request/feature.ts", 88 | "type": "TO_MODIFY", 89 | "description": "Contains 'any' type warnings at lines 33, 144, 213, 254" 90 | }, 91 | { 92 | "path": "src/features/repositories/get-all-repositories-tree/feature.ts", 93 | "type": "TO_MODIFY", 94 | "description": "Contains 'any' type warning at line 231" 95 | }, 96 | { 97 | "path": "src/shared/auth/client-factory.ts", 98 | "type": "TO_MODIFY", 99 | "description": "Contains 'any' type warning at line 282" 100 | } 101 | ], 102 | "implementationGuide": "1. Fix src/features/projects/get-project-details/feature.ts line 198: Use TeamProject or TeamProjectReference type\n2. Fix src/features/pull-requests/types.ts lines 20, 83: Use GitPullRequest related interfaces\n3. Fix src/features/pull-requests/update-pull-request/feature.ts lines 33, 144, 213, 254: Use GitPullRequest and JsonPatchOperation types\n4. Fix src/features/repositories/get-all-repositories-tree/feature.ts line 231: Use GitTreeRef or GitItem type\n5. Fix src/shared/auth/client-factory.ts line 282: Use proper authentication credential type\n6. Import types from azure-devops-node-api/interfaces/", 103 | "verificationCriteria": "1. All remaining ESLint 'any' type warnings should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Types should be consistent with Azure DevOps API documentation", 104 | "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", 105 | "summary": "Successfully fixed all feature-specific type warnings by replacing 'any' types with proper Azure DevOps API types. Fixed src/features/projects/get-project-details/feature.ts by using WorkItemTypeField interface, src/features/pull-requests/types.ts by replacing 'any' with specific union types, src/features/pull-requests/update-pull-request/feature.ts by using WebApi, AuthenticationMethod, and WorkItemRelation types, src/features/repositories/get-all-repositories-tree/feature.ts by using IGitApi type, and src/shared/auth/client-factory.ts by using IProfileApi type. All ESLint 'any' type warnings in the specified files have been resolved while maintaining type safety and consistency with Azure DevOps API documentation.", 106 | "completedAt": "2025-05-26T15:51:24.787Z" 107 | } 108 | ] 109 | } ``` -------------------------------------------------------------------------------- /src/shared/auth/client-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { ICoreApi } from 'azure-devops-node-api/CoreApi'; 3 | import { IGitApi } from 'azure-devops-node-api/GitApi'; 4 | import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; 5 | import { IBuildApi } from 'azure-devops-node-api/BuildApi'; 6 | import { ITestApi } from 'azure-devops-node-api/TestApi'; 7 | import { IReleaseApi } from 'azure-devops-node-api/ReleaseApi'; 8 | import { ITaskAgentApi } from 'azure-devops-node-api/TaskAgentApi'; 9 | import { ITaskApi } from 'azure-devops-node-api/TaskApi'; 10 | import { IProfileApi } from 'azure-devops-node-api/ProfileApi'; 11 | import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../errors'; 12 | import { AuthConfig, createAuthClient } from './auth-factory'; 13 | 14 | /** 15 | * Azure DevOps Client 16 | * 17 | * Provides access to Azure DevOps APIs using the configured authentication method 18 | */ 19 | export class AzureDevOpsClient { 20 | private config: AuthConfig; 21 | private clientPromise: Promise<WebApi> | null = null; 22 | 23 | /** 24 | * Creates a new Azure DevOps client 25 | * 26 | * @param config Authentication configuration 27 | */ 28 | constructor(config: AuthConfig) { 29 | this.config = config; 30 | } 31 | 32 | /** 33 | * Get the authenticated Azure DevOps client 34 | * 35 | * @returns The authenticated WebApi client 36 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 37 | */ 38 | private async getClient(): Promise<WebApi> { 39 | if (!this.clientPromise) { 40 | this.clientPromise = (async () => { 41 | try { 42 | return await createAuthClient(this.config); 43 | } catch (error) { 44 | // If it's already an AzureDevOpsError, rethrow it 45 | if (error instanceof AzureDevOpsError) { 46 | throw error; 47 | } 48 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 49 | throw new AzureDevOpsAuthenticationError( 50 | error instanceof Error 51 | ? `Authentication failed: ${error.message}` 52 | : 'Authentication failed: Unknown error', 53 | ); 54 | } 55 | })(); 56 | } 57 | return this.clientPromise; 58 | } 59 | 60 | /** 61 | * Get the underlying WebApi client 62 | * 63 | * @returns The authenticated WebApi client 64 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 65 | */ 66 | public async getWebApiClient(): Promise<WebApi> { 67 | return this.getClient(); 68 | } 69 | 70 | /** 71 | * Check if the client is authenticated 72 | * 73 | * @returns True if the client is authenticated 74 | */ 75 | public async isAuthenticated(): Promise<boolean> { 76 | try { 77 | const client = await this.getClient(); 78 | return !!client; 79 | } catch { 80 | // Any error means we're not authenticated 81 | return false; 82 | } 83 | } 84 | 85 | /** 86 | * Get the Core API 87 | * 88 | * @returns The Core API client 89 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 90 | */ 91 | public async getCoreApi(): Promise<ICoreApi> { 92 | try { 93 | const client = await this.getClient(); 94 | return await client.getCoreApi(); 95 | } catch (error) { 96 | // If it's already an AzureDevOpsError, rethrow it 97 | if (error instanceof AzureDevOpsError) { 98 | throw error; 99 | } 100 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 101 | throw new AzureDevOpsAuthenticationError( 102 | error instanceof Error 103 | ? `Failed to get Core API: ${error.message}` 104 | : 'Failed to get Core API: Unknown error', 105 | ); 106 | } 107 | } 108 | 109 | /** 110 | * Get the Git API 111 | * 112 | * @returns The Git API client 113 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 114 | */ 115 | public async getGitApi(): Promise<IGitApi> { 116 | try { 117 | const client = await this.getClient(); 118 | return await client.getGitApi(); 119 | } catch (error) { 120 | // If it's already an AzureDevOpsError, rethrow it 121 | if (error instanceof AzureDevOpsError) { 122 | throw error; 123 | } 124 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 125 | throw new AzureDevOpsAuthenticationError( 126 | error instanceof Error 127 | ? `Failed to get Git API: ${error.message}` 128 | : 'Failed to get Git API: Unknown error', 129 | ); 130 | } 131 | } 132 | 133 | /** 134 | * Get the Work Item Tracking API 135 | * 136 | * @returns The Work Item Tracking API client 137 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 138 | */ 139 | public async getWorkItemTrackingApi(): Promise<IWorkItemTrackingApi> { 140 | try { 141 | const client = await this.getClient(); 142 | return await client.getWorkItemTrackingApi(); 143 | } catch (error) { 144 | // If it's already an AzureDevOpsError, rethrow it 145 | if (error instanceof AzureDevOpsError) { 146 | throw error; 147 | } 148 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 149 | throw new AzureDevOpsAuthenticationError( 150 | error instanceof Error 151 | ? `Failed to get Work Item Tracking API: ${error.message}` 152 | : 'Failed to get Work Item Tracking API: Unknown error', 153 | ); 154 | } 155 | } 156 | 157 | /** 158 | * Get the Build API 159 | * 160 | * @returns The Build API client 161 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 162 | */ 163 | public async getBuildApi(): Promise<IBuildApi> { 164 | try { 165 | const client = await this.getClient(); 166 | return await client.getBuildApi(); 167 | } catch (error) { 168 | // If it's already an AzureDevOpsError, rethrow it 169 | if (error instanceof AzureDevOpsError) { 170 | throw error; 171 | } 172 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 173 | throw new AzureDevOpsAuthenticationError( 174 | error instanceof Error 175 | ? `Failed to get Build API: ${error.message}` 176 | : 'Failed to get Build API: Unknown error', 177 | ); 178 | } 179 | } 180 | 181 | /** 182 | * Get the Test API 183 | * 184 | * @returns The Test API client 185 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 186 | */ 187 | public async getTestApi(): Promise<ITestApi> { 188 | try { 189 | const client = await this.getClient(); 190 | return await client.getTestApi(); 191 | } catch (error) { 192 | // If it's already an AzureDevOpsError, rethrow it 193 | if (error instanceof AzureDevOpsError) { 194 | throw error; 195 | } 196 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 197 | throw new AzureDevOpsAuthenticationError( 198 | error instanceof Error 199 | ? `Failed to get Test API: ${error.message}` 200 | : 'Failed to get Test API: Unknown error', 201 | ); 202 | } 203 | } 204 | 205 | /** 206 | * Get the Release API 207 | * 208 | * @returns The Release API client 209 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 210 | */ 211 | public async getReleaseApi(): Promise<IReleaseApi> { 212 | try { 213 | const client = await this.getClient(); 214 | return await client.getReleaseApi(); 215 | } catch (error) { 216 | // If it's already an AzureDevOpsError, rethrow it 217 | if (error instanceof AzureDevOpsError) { 218 | throw error; 219 | } 220 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 221 | throw new AzureDevOpsAuthenticationError( 222 | error instanceof Error 223 | ? `Failed to get Release API: ${error.message}` 224 | : 'Failed to get Release API: Unknown error', 225 | ); 226 | } 227 | } 228 | 229 | /** 230 | * Get the Task Agent API 231 | * 232 | * @returns The Task Agent API client 233 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 234 | */ 235 | public async getTaskAgentApi(): Promise<ITaskAgentApi> { 236 | try { 237 | const client = await this.getClient(); 238 | return await client.getTaskAgentApi(); 239 | } catch (error) { 240 | // If it's already an AzureDevOpsError, rethrow it 241 | if (error instanceof AzureDevOpsError) { 242 | throw error; 243 | } 244 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 245 | throw new AzureDevOpsAuthenticationError( 246 | error instanceof Error 247 | ? `Failed to get Task Agent API: ${error.message}` 248 | : 'Failed to get Task Agent API: Unknown error', 249 | ); 250 | } 251 | } 252 | 253 | /** 254 | * Get the Task API 255 | * 256 | * @returns The Task API client 257 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 258 | */ 259 | public async getTaskApi(): Promise<ITaskApi> { 260 | try { 261 | const client = await this.getClient(); 262 | return await client.getTaskApi(); 263 | } catch (error) { 264 | // If it's already an AzureDevOpsError, rethrow it 265 | if (error instanceof AzureDevOpsError) { 266 | throw error; 267 | } 268 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 269 | throw new AzureDevOpsAuthenticationError( 270 | error instanceof Error 271 | ? `Failed to get Task API: ${error.message}` 272 | : 'Failed to get Task API: Unknown error', 273 | ); 274 | } 275 | } 276 | 277 | /** 278 | * Get the Profile API 279 | * 280 | * @returns The Profile API client 281 | * @throws {AzureDevOpsAuthenticationError} If authentication fails 282 | */ 283 | public async getProfileApi(): Promise<IProfileApi> { 284 | try { 285 | const client = await this.getClient(); 286 | return await client.getProfileApi(); 287 | } catch (error) { 288 | // If it's already an AzureDevOpsError, rethrow it 289 | if (error instanceof AzureDevOpsError) { 290 | throw error; 291 | } 292 | // Otherwise, wrap it in an AzureDevOpsAuthenticationError 293 | throw new AzureDevOpsAuthenticationError( 294 | error instanceof Error 295 | ? `Failed to get Profile API: ${error.message}` 296 | : 'Failed to get Profile API: Unknown error', 297 | ); 298 | } 299 | } 300 | } 301 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces'; 2 | import { WebApi } from 'azure-devops-node-api'; 3 | import { 4 | WorkItemRelation, 5 | WorkItemExpand, 6 | } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; 7 | import { AzureDevOpsClient } from '../../../shared/auth/client-factory'; 8 | import { AzureDevOpsError } from '../../../shared/errors'; 9 | import { UpdatePullRequestOptions } from '../types'; 10 | import { AuthenticationMethod } from '../../../shared/auth/auth-factory'; 11 | import { pullRequestStatusMapper } from '../../../shared/enums'; 12 | 13 | /** 14 | * Updates an existing pull request in Azure DevOps with the specified changes. 15 | * 16 | * @param options - The options for updating the pull request 17 | * @returns The updated pull request 18 | */ 19 | export const updatePullRequest = async ( 20 | options: UpdatePullRequestOptions, 21 | ): Promise<GitPullRequest> => { 22 | const { 23 | projectId, 24 | repositoryId, 25 | pullRequestId, 26 | title, 27 | description, 28 | status, 29 | isDraft, 30 | addWorkItemIds, 31 | removeWorkItemIds, 32 | addReviewers, 33 | removeReviewers, 34 | additionalProperties, 35 | } = options; 36 | 37 | try { 38 | // Get connection to Azure DevOps 39 | const client = new AzureDevOpsClient({ 40 | method: 41 | (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthenticationMethod) ?? 'pat', 42 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL ?? '', 43 | personalAccessToken: process.env.AZURE_DEVOPS_PAT, 44 | }); 45 | const connection = await client.getWebApiClient(); 46 | 47 | // Get the Git API client 48 | const gitApi = await connection.getGitApi(); 49 | 50 | // First, get the current pull request 51 | const pullRequest = await gitApi.getPullRequestById( 52 | pullRequestId, 53 | projectId, 54 | ); 55 | 56 | if (!pullRequest) { 57 | throw new AzureDevOpsError( 58 | `Pull request ${pullRequestId} not found in repository ${repositoryId}`, 59 | ); 60 | } 61 | 62 | // Store the artifactId for work item linking 63 | const artifactId = pullRequest.artifactId; 64 | 65 | // Create an object with the properties to update 66 | const updateObject: Partial<GitPullRequest> = {}; 67 | 68 | if (title !== undefined) { 69 | updateObject.title = title; 70 | } 71 | 72 | if (description !== undefined) { 73 | updateObject.description = description; 74 | } 75 | 76 | if (isDraft !== undefined) { 77 | updateObject.isDraft = isDraft; 78 | } 79 | 80 | if (status) { 81 | const enumStatus = pullRequestStatusMapper.toEnum(status); 82 | if (enumStatus !== undefined) { 83 | updateObject.status = enumStatus; 84 | } else { 85 | throw new AzureDevOpsError( 86 | `Invalid status: ${status}. Valid values are: active, abandoned, completed`, 87 | ); 88 | } 89 | } 90 | 91 | // Add any additional properties that were specified 92 | if (additionalProperties) { 93 | Object.assign(updateObject, additionalProperties); 94 | } 95 | 96 | // Update the pull request 97 | const updatedPullRequest = await gitApi.updatePullRequest( 98 | updateObject, 99 | repositoryId, 100 | pullRequestId, 101 | projectId, 102 | ); 103 | 104 | // Handle work items separately if needed 105 | const addIds = addWorkItemIds ?? []; 106 | const removeIds = removeWorkItemIds ?? []; 107 | if (addIds.length > 0 || removeIds.length > 0) { 108 | await handleWorkItems({ 109 | connection, 110 | pullRequestId, 111 | repositoryId, 112 | projectId, 113 | workItemIdsToAdd: addIds, 114 | workItemIdsToRemove: removeIds, 115 | artifactId, 116 | }); 117 | } 118 | 119 | // Handle reviewers separately if needed 120 | const addReviewerIds = addReviewers ?? []; 121 | const removeReviewerIds = removeReviewers ?? []; 122 | if (addReviewerIds.length > 0 || removeReviewerIds.length > 0) { 123 | await handleReviewers({ 124 | connection, 125 | pullRequestId, 126 | repositoryId, 127 | projectId, 128 | reviewersToAdd: addReviewerIds, 129 | reviewersToRemove: removeReviewerIds, 130 | }); 131 | } 132 | 133 | return updatedPullRequest; 134 | } catch (error) { 135 | throw new AzureDevOpsError( 136 | `Failed to update pull request ${pullRequestId} in repository ${repositoryId}: ${error instanceof Error ? error.message : String(error)}`, 137 | ); 138 | } 139 | }; 140 | 141 | /** 142 | * Handle adding or removing work items from a pull request 143 | */ 144 | interface WorkItemHandlingOptions { 145 | connection: WebApi; 146 | pullRequestId: number; 147 | repositoryId: string; 148 | projectId?: string; 149 | workItemIdsToAdd: number[]; 150 | workItemIdsToRemove: number[]; 151 | artifactId?: string; 152 | } 153 | 154 | async function handleWorkItems( 155 | options: WorkItemHandlingOptions, 156 | ): Promise<void> { 157 | const { 158 | connection, 159 | pullRequestId, 160 | repositoryId, 161 | projectId, 162 | workItemIdsToAdd, 163 | workItemIdsToRemove, 164 | artifactId, 165 | } = options; 166 | 167 | try { 168 | // For each work item to add, create a link 169 | if (workItemIdsToAdd.length > 0) { 170 | const workItemTrackingApi = await connection.getWorkItemTrackingApi(); 171 | 172 | for (const workItemId of workItemIdsToAdd) { 173 | // Add the relationship between the work item and pull request 174 | await workItemTrackingApi.updateWorkItem( 175 | null, 176 | [ 177 | { 178 | op: 'add', 179 | path: '/relations/-', 180 | value: { 181 | rel: 'ArtifactLink', 182 | // Use the artifactId if available, otherwise fall back to the old format 183 | url: 184 | artifactId || 185 | `vstfs:///Git/PullRequestId/${projectId ?? ''}/${repositoryId}/${pullRequestId}`, 186 | attributes: { 187 | name: 'Pull Request', 188 | }, 189 | }, 190 | }, 191 | ], 192 | workItemId, 193 | ); 194 | } 195 | } 196 | 197 | // For each work item to remove, remove the link 198 | if (workItemIdsToRemove.length > 0) { 199 | const workItemTrackingApi = await connection.getWorkItemTrackingApi(); 200 | 201 | for (const workItemId of workItemIdsToRemove) { 202 | try { 203 | // First, get the work item with relations expanded 204 | const workItem = await workItemTrackingApi.getWorkItem( 205 | workItemId, 206 | undefined, // fields 207 | undefined, // asOf 208 | WorkItemExpand.Relations, 209 | ); 210 | 211 | if (workItem.relations) { 212 | // Find the relationship to the pull request using the artifactId 213 | const prRelationIndex = workItem.relations.findIndex( 214 | (rel: WorkItemRelation) => 215 | rel.rel === 'ArtifactLink' && 216 | rel.attributes && 217 | rel.attributes.name === 'Pull Request' && 218 | rel.url === artifactId, 219 | ); 220 | 221 | if (prRelationIndex !== -1) { 222 | // Remove the relationship 223 | await workItemTrackingApi.updateWorkItem( 224 | null, 225 | [ 226 | { 227 | op: 'remove', 228 | path: `/relations/${prRelationIndex}`, 229 | }, 230 | ], 231 | workItemId, 232 | ); 233 | } 234 | } 235 | } catch (error) { 236 | console.log( 237 | `Error removing work item ${workItemId} from pull request ${pullRequestId}: ${ 238 | error instanceof Error ? error.message : String(error) 239 | }`, 240 | ); 241 | } 242 | } 243 | } 244 | } catch (error) { 245 | throw new AzureDevOpsError( 246 | `Failed to update work item links for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`, 247 | ); 248 | } 249 | } 250 | 251 | /** 252 | * Handle adding or removing reviewers from a pull request 253 | */ 254 | interface ReviewerHandlingOptions { 255 | connection: WebApi; 256 | pullRequestId: number; 257 | repositoryId: string; 258 | projectId?: string; 259 | reviewersToAdd: string[]; 260 | reviewersToRemove: string[]; 261 | } 262 | 263 | async function handleReviewers( 264 | options: ReviewerHandlingOptions, 265 | ): Promise<void> { 266 | const { 267 | connection, 268 | pullRequestId, 269 | repositoryId, 270 | projectId, 271 | reviewersToAdd, 272 | reviewersToRemove, 273 | } = options; 274 | 275 | try { 276 | const gitApi = await connection.getGitApi(); 277 | 278 | // Add reviewers 279 | if (reviewersToAdd.length > 0) { 280 | for (const reviewer of reviewersToAdd) { 281 | try { 282 | // Create a reviewer object with the identifier 283 | await gitApi.createPullRequestReviewer( 284 | { 285 | id: reviewer, // This can be email or ID 286 | isRequired: false, 287 | }, 288 | repositoryId, 289 | pullRequestId, 290 | reviewer, 291 | projectId, 292 | ); 293 | } catch (error) { 294 | console.log( 295 | `Error adding reviewer ${reviewer} to pull request ${pullRequestId}: ${ 296 | error instanceof Error ? error.message : String(error) 297 | }`, 298 | ); 299 | } 300 | } 301 | } 302 | 303 | // Remove reviewers 304 | if (reviewersToRemove.length > 0) { 305 | for (const reviewer of reviewersToRemove) { 306 | try { 307 | await gitApi.deletePullRequestReviewer( 308 | repositoryId, 309 | pullRequestId, 310 | reviewer, 311 | projectId, 312 | ); 313 | } catch (error) { 314 | console.log( 315 | `Error removing reviewer ${reviewer} from pull request ${pullRequestId}: ${ 316 | error instanceof Error ? error.message : String(error) 317 | }`, 318 | ); 319 | } 320 | } 321 | } 322 | } catch (error) { 323 | throw new AzureDevOpsError( 324 | `Failed to update reviewers for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`, 325 | ); 326 | } 327 | } 328 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/get-pull-request-comments/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { getPullRequestComments } from './feature'; 3 | import { listPullRequests } from '../list-pull-requests/feature'; 4 | import { addPullRequestComment } from '../add-pull-request-comment/feature'; 5 | import { 6 | getTestConnection, 7 | shouldSkipIntegrationTest, 8 | } from '@/shared/test/test-helpers'; 9 | 10 | describe('getPullRequestComments integration', () => { 11 | let connection: WebApi | null = null; 12 | let projectName: string; 13 | let repositoryName: string; 14 | let pullRequestId: number; 15 | let testThreadId: number; 16 | 17 | // Generate unique identifiers using timestamp for comment content 18 | const timestamp = Date.now(); 19 | const randomSuffix = Math.floor(Math.random() * 1000); 20 | 21 | beforeAll(async () => { 22 | // Get a real connection using environment variables 23 | connection = await getTestConnection(); 24 | 25 | // Set up project and repository names from environment 26 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 27 | repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; 28 | 29 | // Skip setup if integration tests should be skipped 30 | if (shouldSkipIntegrationTest() || !connection) { 31 | return; 32 | } 33 | 34 | try { 35 | // Find an active pull request to use for testing 36 | const pullRequests = await listPullRequests( 37 | connection, 38 | projectName, 39 | repositoryName, 40 | { 41 | projectId: projectName, 42 | repositoryId: repositoryName, 43 | status: 'active', 44 | top: 1, 45 | }, 46 | ); 47 | 48 | if (!pullRequests || pullRequests.value.length === 0) { 49 | throw new Error('No active pull requests found for testing'); 50 | } 51 | 52 | pullRequestId = pullRequests.value[0].pullRequestId!; 53 | console.log(`Using existing pull request #${pullRequestId} for testing`); 54 | 55 | // Create a test comment thread that we can use for specific thread tests 56 | const result = await addPullRequestComment( 57 | connection, 58 | projectName, 59 | repositoryName, 60 | pullRequestId, 61 | { 62 | projectId: projectName, 63 | repositoryId: repositoryName, 64 | pullRequestId, 65 | content: `Test comment thread ${timestamp}-${randomSuffix}`, 66 | status: 'active', 67 | }, 68 | ); 69 | 70 | testThreadId = result.thread!.id!; 71 | console.log(`Created test comment thread #${testThreadId} for testing`); 72 | } catch (error) { 73 | console.error('Error in test setup:', error); 74 | throw error; 75 | } 76 | }); 77 | 78 | test('should get all comment threads from pull request with file path and line number', async () => { 79 | // Skip if integration tests should be skipped 80 | if (shouldSkipIntegrationTest() || !connection) { 81 | console.log('Skipping test due to missing connection'); 82 | return; 83 | } 84 | 85 | // Skip if repository name is not defined 86 | if (!repositoryName) { 87 | console.log('Skipping test due to missing repository name'); 88 | return; 89 | } 90 | 91 | const threads = await getPullRequestComments( 92 | connection, 93 | projectName, 94 | repositoryName, 95 | pullRequestId, 96 | { 97 | projectId: projectName, 98 | repositoryId: repositoryName, 99 | pullRequestId, 100 | }, 101 | ); 102 | 103 | // Verify threads were returned 104 | expect(threads).toBeDefined(); 105 | expect(Array.isArray(threads)).toBe(true); 106 | expect(threads.length).toBeGreaterThan(0); 107 | 108 | // Verify thread structure 109 | const firstThread = threads[0]; 110 | expect(firstThread.id).toBeDefined(); 111 | expect(firstThread.comments).toBeDefined(); 112 | expect(Array.isArray(firstThread.comments)).toBe(true); 113 | expect(firstThread.comments!.length).toBeGreaterThan(0); 114 | 115 | // Verify comment structure including new fields 116 | const firstComment = firstThread.comments![0]; 117 | expect(firstComment.content).toBeDefined(); 118 | expect(firstComment.id).toBeDefined(); 119 | expect(firstComment.publishedDate).toBeDefined(); 120 | expect(firstComment.author).toBeDefined(); 121 | 122 | // Verify new fields are present (may be undefined/null for general comments) 123 | expect(firstComment).toHaveProperty('filePath'); 124 | expect(firstComment).toHaveProperty('rightFileStart'); 125 | expect(firstComment).toHaveProperty('rightFileEnd'); 126 | expect(firstComment).toHaveProperty('leftFileStart'); 127 | expect(firstComment).toHaveProperty('leftFileEnd'); 128 | }, 30000); 129 | 130 | test('should get a specific comment thread by ID with file path and line number', async () => { 131 | // Skip if integration tests should be skipped 132 | if (shouldSkipIntegrationTest() || !connection) { 133 | console.log('Skipping test due to missing connection'); 134 | return; 135 | } 136 | 137 | // Skip if repository name is not defined 138 | if (!repositoryName) { 139 | console.log('Skipping test due to missing repository name'); 140 | return; 141 | } 142 | 143 | const threads = await getPullRequestComments( 144 | connection, 145 | projectName, 146 | repositoryName, 147 | pullRequestId, 148 | { 149 | projectId: projectName, 150 | repositoryId: repositoryName, 151 | pullRequestId, 152 | threadId: testThreadId, 153 | }, 154 | ); 155 | 156 | // Verify only one thread was returned 157 | expect(threads).toBeDefined(); 158 | expect(Array.isArray(threads)).toBe(true); 159 | expect(threads.length).toBe(1); 160 | 161 | // Verify it's the correct thread 162 | const thread = threads[0]; 163 | expect(thread.id).toBe(testThreadId); 164 | expect(thread.comments).toBeDefined(); 165 | expect(Array.isArray(thread.comments)).toBe(true); 166 | expect(thread.comments!.length).toBeGreaterThan(0); 167 | 168 | // Verify the comment content matches what we created 169 | const comment = thread.comments![0]; 170 | expect(comment.content).toBe( 171 | `Test comment thread ${timestamp}-${randomSuffix}`, 172 | ); 173 | 174 | // Verify new fields are present (may be undefined/null for general comments) 175 | expect(comment).toHaveProperty('filePath'); 176 | expect(comment).toHaveProperty('rightFileStart'); 177 | expect(comment).toHaveProperty('rightFileEnd'); 178 | expect(comment).toHaveProperty('leftFileStart'); 179 | expect(comment).toHaveProperty('leftFileEnd'); 180 | }, 30000); 181 | 182 | test('should handle pagination with top parameter', async () => { 183 | // Skip if integration tests should be skipped 184 | if (shouldSkipIntegrationTest() || !connection) { 185 | console.log('Skipping test due to missing connection'); 186 | return; 187 | } 188 | 189 | // Skip if repository name is not defined 190 | if (!repositoryName) { 191 | console.log('Skipping test due to missing repository name'); 192 | return; 193 | } 194 | 195 | // Get all threads first to compare 196 | const allThreads = await getPullRequestComments( 197 | connection, 198 | projectName, 199 | repositoryName, 200 | pullRequestId, 201 | { 202 | projectId: projectName, 203 | repositoryId: repositoryName, 204 | pullRequestId, 205 | }, 206 | ); 207 | 208 | // Then get with pagination 209 | const paginatedThreads = await getPullRequestComments( 210 | connection, 211 | projectName, 212 | repositoryName, 213 | pullRequestId, 214 | { 215 | projectId: projectName, 216 | repositoryId: repositoryName, 217 | pullRequestId, 218 | top: 1, 219 | }, 220 | ); 221 | 222 | // Verify pagination 223 | expect(paginatedThreads).toBeDefined(); 224 | expect(Array.isArray(paginatedThreads)).toBe(true); 225 | expect(paginatedThreads.length).toBe(1); 226 | expect(paginatedThreads.length).toBeLessThanOrEqual(allThreads.length); 227 | 228 | // Verify the thread structure is the same 229 | const thread = paginatedThreads[0]; 230 | expect(thread.id).toBeDefined(); 231 | expect(thread.comments).toBeDefined(); 232 | expect(Array.isArray(thread.comments)).toBe(true); 233 | expect(thread.comments!.length).toBeGreaterThan(0); 234 | 235 | // Verify new fields are present in paginated results 236 | const comment = thread.comments![0]; 237 | expect(comment).toHaveProperty('filePath'); 238 | expect(comment).toHaveProperty('rightFileStart'); 239 | expect(comment).toHaveProperty('rightFileEnd'); 240 | expect(comment).toHaveProperty('leftFileStart'); 241 | expect(comment).toHaveProperty('leftFileEnd'); 242 | }, 30000); 243 | 244 | test('should handle includeDeleted parameter', async () => { 245 | // Skip if integration tests should be skipped 246 | if (shouldSkipIntegrationTest() || !connection) { 247 | console.log('Skipping test due to missing connection'); 248 | return; 249 | } 250 | 251 | // Skip if repository name is not defined 252 | if (!repositoryName) { 253 | console.log('Skipping test due to missing repository name'); 254 | return; 255 | } 256 | 257 | const threads = await getPullRequestComments( 258 | connection, 259 | projectName, 260 | repositoryName, 261 | pullRequestId, 262 | { 263 | projectId: projectName, 264 | repositoryId: repositoryName, 265 | pullRequestId, 266 | includeDeleted: true, 267 | }, 268 | ); 269 | 270 | // We can only verify the call succeeds, as we can't guarantee deleted comments exist 271 | expect(threads).toBeDefined(); 272 | expect(Array.isArray(threads)).toBe(true); 273 | 274 | // If there are any threads, verify they have the new fields 275 | if (threads.length > 0) { 276 | const thread = threads[0]; 277 | if (thread.comments && thread.comments.length > 0) { 278 | const comment = thread.comments[0]; 279 | expect(comment).toHaveProperty('filePath'); 280 | expect(comment).toHaveProperty('rightFileStart'); 281 | expect(comment).toHaveProperty('rightFileEnd'); 282 | expect(comment).toHaveProperty('leftFileStart'); 283 | expect(comment).toHaveProperty('leftFileEnd'); 284 | } 285 | } 286 | }, 30000); // 30 second timeout for integration test 287 | }); 288 | ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { 3 | AzureDevOpsResourceNotFoundError, 4 | AzureDevOpsError, 5 | } from '../../../shared/errors'; 6 | import { 7 | TeamProject, 8 | WebApiTeam, 9 | } from 'azure-devops-node-api/interfaces/CoreInterfaces'; 10 | import { WorkItemField } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; 11 | 12 | // Type for work item type field with additional properties 13 | interface WorkItemTypeField extends WorkItemField { 14 | isRequired?: boolean; 15 | isIdentity?: boolean; 16 | isPicklist?: boolean; 17 | } 18 | 19 | /** 20 | * Options for getting project details 21 | */ 22 | export interface GetProjectDetailsOptions { 23 | projectId: string; 24 | includeProcess?: boolean; 25 | includeWorkItemTypes?: boolean; 26 | includeFields?: boolean; 27 | includeTeams?: boolean; 28 | expandTeamIdentity?: boolean; 29 | } 30 | 31 | /** 32 | * Process information with work item types 33 | */ 34 | interface ProcessInfo { 35 | id: string; 36 | name: string; 37 | description?: string; 38 | isDefault: boolean; 39 | type: string; 40 | workItemTypes?: WorkItemTypeInfo[]; 41 | hierarchyInfo?: { 42 | portfolioBacklogs?: { 43 | name: string; 44 | workItemTypes: string[]; 45 | }[]; 46 | requirementBacklog?: { 47 | name: string; 48 | workItemTypes: string[]; 49 | }; 50 | taskBacklog?: { 51 | name: string; 52 | workItemTypes: string[]; 53 | }; 54 | }; 55 | } 56 | 57 | /** 58 | * Work item type information with states and fields 59 | */ 60 | interface WorkItemTypeInfo { 61 | name: string; 62 | referenceName: string; 63 | description?: string; 64 | isDisabled: boolean; 65 | states?: { 66 | name: string; 67 | color?: string; 68 | stateCategory: string; 69 | }[]; 70 | fields?: { 71 | name: string; 72 | referenceName: string; 73 | type: string; 74 | required?: boolean; 75 | isIdentity?: boolean; 76 | isPicklist?: boolean; 77 | description?: string; 78 | }[]; 79 | } 80 | 81 | /** 82 | * Project details response 83 | */ 84 | interface ProjectDetails extends TeamProject { 85 | process?: ProcessInfo; 86 | teams?: WebApiTeam[]; 87 | } 88 | 89 | /** 90 | * Get detailed information about a project 91 | * 92 | * @param connection The Azure DevOps WebApi connection 93 | * @param options Options for getting project details 94 | * @returns The project details 95 | * @throws {AzureDevOpsResourceNotFoundError} If the project is not found 96 | */ 97 | export async function getProjectDetails( 98 | connection: WebApi, 99 | options: GetProjectDetailsOptions, 100 | ): Promise<ProjectDetails> { 101 | try { 102 | const { 103 | projectId, 104 | includeProcess = false, 105 | includeWorkItemTypes = false, 106 | includeFields = false, 107 | includeTeams = false, 108 | expandTeamIdentity = false, 109 | } = options; 110 | 111 | // Get the core API 112 | const coreApi = await connection.getCoreApi(); 113 | 114 | // Get the basic project information 115 | const project = await coreApi.getProject(projectId); 116 | 117 | if (!project) { 118 | throw new AzureDevOpsResourceNotFoundError( 119 | `Project '${projectId}' not found`, 120 | ); 121 | } 122 | 123 | // Initialize the result with the project information and ensure required properties 124 | const result: ProjectDetails = { 125 | ...project, 126 | // Ensure capabilities is always defined 127 | capabilities: project.capabilities || { 128 | versioncontrol: { sourceControlType: 'Git' }, 129 | processTemplate: { templateName: 'Unknown', templateTypeId: 'unknown' }, 130 | }, 131 | }; 132 | 133 | // If teams are requested, get them 134 | if (includeTeams) { 135 | const teams = await coreApi.getTeams(projectId, expandTeamIdentity); 136 | result.teams = teams; 137 | } 138 | 139 | // If process information is requested, get it 140 | if (includeProcess) { 141 | // Get the process template ID from the project capabilities 142 | const processTemplateId = 143 | project.capabilities?.processTemplate?.templateTypeId || 'unknown'; 144 | 145 | // Always create a process object, even if we don't have a template ID 146 | // In a real implementation, we would use the Process API 147 | // Since it's not directly available in the WebApi type, we'll simulate it 148 | // This is a simplified version for the implementation 149 | // In a real implementation, you would need to use the appropriate API 150 | 151 | // Create the process info object directly 152 | const processInfo: ProcessInfo = { 153 | id: processTemplateId, 154 | name: project.capabilities?.processTemplate?.templateName || 'Unknown', 155 | description: 'Process template for the project', 156 | isDefault: true, 157 | type: 'system', 158 | }; 159 | 160 | // If work item types are requested, get them 161 | if (includeWorkItemTypes) { 162 | // In a real implementation, we would get work item types from the API 163 | // For now, we'll use the work item tracking API to get basic types 164 | const workItemTrackingApi = await connection.getWorkItemTrackingApi(); 165 | const workItemTypes = 166 | await workItemTrackingApi.getWorkItemTypes(projectId); 167 | 168 | // Map the work item types to our format 169 | const processWorkItemTypes: WorkItemTypeInfo[] = workItemTypes.map( 170 | (wit) => { 171 | // Create the work item type info object 172 | const workItemTypeInfo: WorkItemTypeInfo = { 173 | name: wit.name || 'Unknown', 174 | referenceName: 175 | wit.referenceName || `System.Unknown.${Date.now()}`, 176 | description: wit.description, 177 | isDisabled: false, 178 | states: [ 179 | { name: 'New', stateCategory: 'Proposed' }, 180 | { name: 'Active', stateCategory: 'InProgress' }, 181 | { name: 'Resolved', stateCategory: 'InProgress' }, 182 | { name: 'Closed', stateCategory: 'Completed' }, 183 | ], 184 | }; 185 | 186 | // If fields are requested, don't add fields here - we'll add them after fetching from API 187 | return workItemTypeInfo; 188 | }, 189 | ); 190 | 191 | // If fields are requested, get the field definitions from the API 192 | if (includeFields) { 193 | try { 194 | // Instead of getting all fields and applying them to all work item types, 195 | // let's get the fields specific to each work item type 196 | for (const wit of processWorkItemTypes) { 197 | try { 198 | // Get fields specific to this work item type using the specialized method 199 | const typeSpecificFields = 200 | await workItemTrackingApi.getWorkItemTypeFieldsWithReferences( 201 | projectId, 202 | wit.name, 203 | ); 204 | 205 | // Map the fields to our format 206 | wit.fields = typeSpecificFields.map( 207 | (field: WorkItemTypeField) => ({ 208 | name: field.name || 'Unknown', 209 | referenceName: field.referenceName || 'Unknown', 210 | type: field.type?.toString().toLowerCase() || 'string', 211 | required: field.isRequired || false, 212 | isIdentity: field.isIdentity || false, 213 | isPicklist: field.isPicklist || false, 214 | description: field.description, 215 | }), 216 | ); 217 | } catch (typeFieldError) { 218 | console.error( 219 | `Error fetching fields for work item type ${wit.name}:`, 220 | typeFieldError, 221 | ); 222 | 223 | // Fallback to basic fields 224 | wit.fields = [ 225 | { 226 | name: 'Title', 227 | referenceName: 'System.Title', 228 | type: 'string', 229 | required: true, 230 | }, 231 | { 232 | name: 'Description', 233 | referenceName: 'System.Description', 234 | type: 'html', 235 | required: false, 236 | }, 237 | ]; 238 | } 239 | } 240 | } catch (fieldError) { 241 | console.error('Error in field processing:', fieldError); 242 | 243 | // Fallback to default fields if API call fails 244 | processWorkItemTypes.forEach((wit) => { 245 | wit.fields = [ 246 | { 247 | name: 'Title', 248 | referenceName: 'System.Title', 249 | type: 'string', 250 | required: true, 251 | }, 252 | { 253 | name: 'Description', 254 | referenceName: 'System.Description', 255 | type: 'html', 256 | required: false, 257 | }, 258 | ]; 259 | }); 260 | } 261 | } 262 | 263 | processInfo.workItemTypes = processWorkItemTypes; 264 | 265 | // Add hierarchy information if available 266 | // This is a simplified version - in a real implementation, you would 267 | // need to get the backlog configuration and map it to the work item types 268 | processInfo.hierarchyInfo = { 269 | portfolioBacklogs: [ 270 | { 271 | name: 'Epics', 272 | workItemTypes: processWorkItemTypes 273 | .filter( 274 | (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'epic', 275 | ) 276 | .map((wit: WorkItemTypeInfo) => wit.name), 277 | }, 278 | { 279 | name: 'Features', 280 | workItemTypes: processWorkItemTypes 281 | .filter( 282 | (wit: WorkItemTypeInfo) => 283 | wit.name.toLowerCase() === 'feature', 284 | ) 285 | .map((wit: WorkItemTypeInfo) => wit.name), 286 | }, 287 | ], 288 | requirementBacklog: { 289 | name: 'Stories', 290 | workItemTypes: processWorkItemTypes 291 | .filter( 292 | (wit: WorkItemTypeInfo) => 293 | wit.name.toLowerCase() === 'user story' || 294 | wit.name.toLowerCase() === 'bug', 295 | ) 296 | .map((wit: WorkItemTypeInfo) => wit.name), 297 | }, 298 | taskBacklog: { 299 | name: 'Tasks', 300 | workItemTypes: processWorkItemTypes 301 | .filter( 302 | (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'task', 303 | ) 304 | .map((wit: WorkItemTypeInfo) => wit.name), 305 | }, 306 | }; 307 | } 308 | 309 | // Always set the process on the result 310 | result.process = processInfo; 311 | } 312 | 313 | return result; 314 | } catch (error) { 315 | if (error instanceof AzureDevOpsError) { 316 | throw error; 317 | } 318 | throw new Error( 319 | `Failed to get project details: ${error instanceof Error ? error.message : String(error)}`, 320 | ); 321 | } 322 | } 323 | ``` -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- ```markdown 1 | # Authentication Guide for Azure DevOps MCP Server 2 | 3 | This guide provides detailed information about the authentication methods supported by the Azure DevOps MCP Server, including setup instructions, configuration examples, and troubleshooting tips. 4 | 5 | ## Supported Authentication Methods 6 | 7 | The Azure DevOps MCP Server supports three authentication methods: 8 | 9 | 1. **Personal Access Token (PAT)** - Simple token-based authentication 10 | 2. **Azure Identity (DefaultAzureCredential)** - Flexible authentication using the Azure Identity SDK 11 | 3. **Azure CLI** - Authentication using your Azure CLI login 12 | 13 | ## Method 1: Personal Access Token (PAT) Authentication 14 | 15 | PAT authentication is the simplest method and works well for personal use or testing. 16 | 17 | ### Setup Instructions 18 | 19 | 1. **Generate a PAT in Azure DevOps**: 20 | 21 | - Go to https://dev.azure.com/{your-organization}/_usersSettings/tokens 22 | - Or click on your profile picture > Personal access tokens 23 | - Select "+ New Token" 24 | - Name your token (e.g., "MCP Server Access") 25 | - Set an expiration date 26 | - Select the following scopes: 27 | - **Code**: Read & Write 28 | - **Work Items**: Read & Write 29 | - **Build**: Read & Execute 30 | - **Project and Team**: Read 31 | - **Graph**: Read 32 | - **Release**: Read & Execute 33 | - Click "Create" and copy the generated token 34 | 35 | 2. **Configure your `.env` file**: 36 | ``` 37 | AZURE_DEVOPS_AUTH_METHOD=pat 38 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 39 | AZURE_DEVOPS_PAT=your-personal-access-token 40 | AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project 41 | ``` 42 | 43 | ### Security Considerations 44 | 45 | - PATs have an expiration date and will need to be renewed 46 | - Store your PAT securely and never commit it to source control 47 | - Consider using environment variables or a secrets manager in production 48 | - Scope your PAT to only the permissions needed for your use case 49 | 50 | ## Method 2: Azure Identity Authentication (DefaultAzureCredential) 51 | 52 | Azure Identity authentication uses the `DefaultAzureCredential` class from the `@azure/identity` package, which provides a simplified authentication experience by trying multiple credential types in sequence. 53 | 54 | ### How DefaultAzureCredential Works 55 | 56 | `DefaultAzureCredential` tries the following credential types in order: 57 | 58 | 1. Environment variables (EnvironmentCredential) 59 | 2. Managed Identity (ManagedIdentityCredential) 60 | 3. Azure CLI (AzureCliCredential) 61 | 4. Visual Studio Code (VisualStudioCodeCredential) 62 | 5. Azure PowerShell (AzurePowerShellCredential) 63 | 6. Interactive Browser (InteractiveBrowserCredential) - optional, disabled by default 64 | 65 | This makes it ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes. 66 | 67 | ### Setup Instructions 68 | 69 | 1. **Install the Azure Identity SDK**: 70 | The SDK is already included as a dependency in the Azure DevOps MCP Server. 71 | 72 | 2. **Configure your `.env` file**: 73 | 74 | ``` 75 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 76 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 77 | AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project 78 | ``` 79 | 80 | 3. **Set up credentials based on your environment**: 81 | 82 | a. **For service principals (client credentials)**: 83 | 84 | ``` 85 | AZURE_TENANT_ID=your-tenant-id 86 | AZURE_CLIENT_ID=your-client-id 87 | AZURE_CLIENT_SECRET=your-client-secret 88 | ``` 89 | 90 | b. **For managed identities in Azure**: 91 | No additional configuration needed if running in Azure with a managed identity. 92 | 93 | c. **For local development**: 94 | 95 | - Log in with Azure CLI: `az login` 96 | - Or use Visual Studio Code Azure Account extension 97 | 98 | ### Security Considerations 99 | 100 | - Use managed identities in Azure for improved security 101 | - For service principals, rotate client secrets regularly 102 | - Store credentials securely using Azure Key Vault or environment variables 103 | - Apply the principle of least privilege when assigning roles 104 | 105 | ## Method 3: Azure CLI Authentication 106 | 107 | Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/identity` package, which authenticates using the Azure CLI's logged-in account. 108 | 109 | ### Setup Instructions 110 | 111 | 1. **Install the Azure CLI**: 112 | 113 | - Follow the instructions at https://docs.microsoft.com/cli/azure/install-azure-cli 114 | 115 | 2. **Log in to Azure**: 116 | 117 | ```bash 118 | az login 119 | ``` 120 | 121 | 3. **Configure your `.env` file**: 122 | ``` 123 | AZURE_DEVOPS_AUTH_METHOD=azure-cli 124 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 125 | AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project 126 | ``` 127 | 128 | ### Security Considerations 129 | 130 | - Azure CLI authentication is best for local development 131 | - Ensure your Azure CLI session is kept secure 132 | - Log out when not in use: `az logout` 133 | 134 | ## Configuration Reference 135 | 136 | | Variable | Description | Required | Default | 137 | | ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------- | ---------------- | 138 | | `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | No | `azure-identity` | 139 | | `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | 140 | | `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | 141 | | `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | 142 | | `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | 143 | | `AZURE_TENANT_ID` | Azure AD tenant ID (for service principals) | Only with service principals | - | 144 | | `AZURE_CLIENT_ID` | Azure AD application ID (for service principals) | Only with service principals | - | 145 | | `AZURE_CLIENT_SECRET` | Azure AD client secret (for service principals) | Only with service principals | - | 146 | | `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info | 147 | 148 | ## Troubleshooting Authentication Issues 149 | 150 | ### PAT Authentication Issues 151 | 152 | 1. **Invalid PAT**: Ensure your PAT hasn't expired and has the required scopes 153 | 154 | - Error: `TF400813: The user 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' is not authorized to access this resource.` 155 | - Solution: Generate a new PAT with the correct scopes 156 | 157 | 2. **Scope issues**: If receiving 403 errors, check if your PAT has the necessary permissions 158 | 159 | - Error: `TF401027: You need the Git 'Read' permission to perform this action.` 160 | - Solution: Update your PAT with the required scopes 161 | 162 | 3. **Organization access**: Verify your PAT has access to the organization specified in the URL 163 | - Error: `TF400813: Resource not found for anonymous request.` 164 | - Solution: Ensure your PAT has access to the specified organization 165 | 166 | ### Azure Identity Authentication Issues 167 | 168 | 1. **Missing credentials**: Ensure you have the necessary credentials configured 169 | 170 | - Error: `CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token` 171 | - Solution: Check that you're logged in with Azure CLI or have environment variables set 172 | 173 | 2. **Permission issues**: Verify your identity has the necessary permissions 174 | 175 | - Error: `AuthorizationFailed: The client does not have authorization to perform action` 176 | - Solution: Assign the appropriate roles to your identity 177 | 178 | 3. **Token acquisition errors**: Check network connectivity and Azure AD endpoint availability 179 | - Error: `ClientAuthError: Interaction required` 180 | - Solution: Check network connectivity or use a different credential type 181 | 182 | ### Azure CLI Authentication Issues 183 | 184 | 1. **CLI not installed**: Ensure Azure CLI is installed and in your PATH 185 | 186 | - Error: `AzureCliCredential authentication failed: Azure CLI not found` 187 | - Solution: Install Azure CLI 188 | 189 | 2. **Not logged in**: Verify you're logged in to Azure CLI 190 | 191 | - Error: `AzureCliCredential authentication failed: Please run 'az login'` 192 | - Solution: Run `az login` 193 | 194 | 3. **Permission issues**: Check if your Azure CLI account has access to Azure DevOps 195 | - Error: `TF400813: The user is not authorized to access this resource` 196 | - Solution: Log in with an account that has access to Azure DevOps 197 | 198 | ## Best Practices 199 | 200 | 1. **Choose the right authentication method for your environment**: 201 | 202 | - For local development: Azure CLI or PAT 203 | - For CI/CD pipelines: PAT or service principal 204 | - For Azure-hosted applications: Managed Identity 205 | 206 | 2. **Follow the principle of least privilege**: 207 | 208 | - Only grant the permissions needed for your use case 209 | - Regularly review and rotate credentials 210 | 211 | 3. **Secure your credentials**: 212 | 213 | - Use environment variables or a secrets manager 214 | - Never commit credentials to source control 215 | - Set appropriate expiration dates for PATs 216 | 217 | 4. **Monitor and audit authentication**: 218 | - Review Azure DevOps access logs 219 | - Set up alerts for suspicious activity 220 | 221 | ## Examples 222 | 223 | ### Example 1: Local Development with PAT 224 | 225 | ```bash 226 | # .env file 227 | AZURE_DEVOPS_AUTH_METHOD=pat 228 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 229 | AZURE_DEVOPS_PAT=abcdefghijklmnopqrstuvwxyz0123456789 230 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 231 | ``` 232 | 233 | ### Example 2: Azure-hosted Application with Managed Identity 234 | 235 | ```bash 236 | # .env file 237 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 238 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 239 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 240 | ``` 241 | 242 | ### Example 3: CI/CD Pipeline with Service Principal 243 | 244 | ```bash 245 | # .env file 246 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 247 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 248 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 249 | AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 250 | AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111 251 | AZURE_CLIENT_SECRET=your-client-secret 252 | ``` 253 | 254 | ### Example 4: Local Development with Azure CLI 255 | 256 | ```bash 257 | # .env file 258 | AZURE_DEVOPS_AUTH_METHOD=azure-cli 259 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 260 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 261 | ``` 262 | ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { searchWorkItems } from './feature'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsResourceNotFoundError, 7 | AzureDevOpsValidationError, 8 | AzureDevOpsPermissionError, 9 | } from '../../../shared/errors'; 10 | import { SearchWorkItemsOptions, WorkItemSearchResponse } from '../types'; 11 | 12 | // Mock axios 13 | jest.mock('axios'); 14 | const mockedAxios = axios as jest.Mocked<typeof axios>; 15 | 16 | // Mock @azure/identity 17 | jest.mock('@azure/identity', () => ({ 18 | DefaultAzureCredential: jest.fn().mockImplementation(() => ({ 19 | getToken: jest 20 | .fn() 21 | .mockResolvedValue({ token: 'mock-azure-identity-token' }), 22 | })), 23 | AzureCliCredential: jest.fn(), 24 | })); 25 | 26 | // Mock WebApi 27 | jest.mock('azure-devops-node-api'); 28 | const MockedWebApi = WebApi as jest.MockedClass<typeof WebApi>; 29 | 30 | describe('searchWorkItems', () => { 31 | let connection: WebApi; 32 | let options: SearchWorkItemsOptions; 33 | let mockResponse: WorkItemSearchResponse; 34 | 35 | beforeEach(() => { 36 | // Reset mocks 37 | jest.clearAllMocks(); 38 | 39 | // Mock environment variables 40 | process.env.AZURE_DEVOPS_AUTH_METHOD = 'pat'; 41 | process.env.AZURE_DEVOPS_PAT = 'mock-pat'; 42 | 43 | // Set up connection mock 44 | // Create a mock auth handler that implements IRequestHandler 45 | const mockAuthHandler = { 46 | prepareRequest: jest.fn(), 47 | canHandleAuthentication: jest.fn().mockReturnValue(true), 48 | handleAuthentication: jest.fn(), 49 | }; 50 | connection = new MockedWebApi( 51 | 'https://dev.azure.com/mock-org', 52 | mockAuthHandler, 53 | ); 54 | (connection as any).serverUrl = 'https://dev.azure.com/mock-org'; 55 | (connection.getCoreApi as jest.Mock).mockResolvedValue({ 56 | getProjects: jest.fn().mockResolvedValue([]), 57 | }); 58 | 59 | // Set up options 60 | options = { 61 | searchText: 'test query', 62 | projectId: 'mock-project', 63 | top: 50, 64 | skip: 0, 65 | includeFacets: true, 66 | }; 67 | 68 | // Set up mock response 69 | mockResponse = { 70 | count: 2, 71 | results: [ 72 | { 73 | project: { 74 | id: 'project-id-1', 75 | name: 'mock-project', 76 | }, 77 | fields: { 78 | 'system.id': '42', 79 | 'system.workitemtype': 'Bug', 80 | 'system.title': 'Test Bug', 81 | 'system.state': 'Active', 82 | 'system.assignedto': 'Test User', 83 | }, 84 | hits: [ 85 | { 86 | fieldReferenceName: 'system.title', 87 | highlights: ['Test <b>Bug</b>'], 88 | }, 89 | ], 90 | url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/42', 91 | }, 92 | { 93 | project: { 94 | id: 'project-id-1', 95 | name: 'mock-project', 96 | }, 97 | fields: { 98 | 'system.id': '43', 99 | 'system.workitemtype': 'Task', 100 | 'system.title': 'Test Task', 101 | 'system.state': 'New', 102 | 'system.assignedto': 'Test User', 103 | }, 104 | hits: [ 105 | { 106 | fieldReferenceName: 'system.title', 107 | highlights: ['Test <b>Task</b>'], 108 | }, 109 | ], 110 | url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/43', 111 | }, 112 | ], 113 | facets: { 114 | 'System.WorkItemType': [ 115 | { 116 | name: 'Bug', 117 | id: 'Bug', 118 | resultCount: 1, 119 | }, 120 | { 121 | name: 'Task', 122 | id: 'Task', 123 | resultCount: 1, 124 | }, 125 | ], 126 | }, 127 | }; 128 | 129 | // Mock axios response 130 | mockedAxios.post.mockResolvedValue({ data: mockResponse }); 131 | }); 132 | 133 | afterEach(() => { 134 | // Clean up environment variables 135 | delete process.env.AZURE_DEVOPS_AUTH_METHOD; 136 | delete process.env.AZURE_DEVOPS_PAT; 137 | }); 138 | 139 | it('should search work items with the correct parameters', async () => { 140 | // Act 141 | const result = await searchWorkItems(connection, options); 142 | 143 | // Assert 144 | expect(mockedAxios.post).toHaveBeenCalledWith( 145 | 'https://almsearch.dev.azure.com/mock-org/mock-project/_apis/search/workitemsearchresults?api-version=7.1', 146 | { 147 | searchText: 'test query', 148 | $skip: 0, 149 | $top: 50, 150 | filters: { 151 | 'System.TeamProject': ['mock-project'], 152 | }, 153 | includeFacets: true, 154 | }, 155 | expect.objectContaining({ 156 | headers: expect.objectContaining({ 157 | Authorization: expect.stringContaining('Basic'), 158 | 'Content-Type': 'application/json', 159 | }), 160 | }), 161 | ); 162 | expect(result).toEqual(mockResponse); 163 | }); 164 | 165 | it('should include filters when provided', async () => { 166 | // Arrange 167 | options.filters = { 168 | 'System.WorkItemType': ['Bug', 'Task'], 169 | 'System.State': ['Active'], 170 | }; 171 | 172 | // Act 173 | await searchWorkItems(connection, options); 174 | 175 | // Assert 176 | expect(mockedAxios.post).toHaveBeenCalledWith( 177 | expect.any(String), 178 | expect.objectContaining({ 179 | filters: { 180 | 'System.TeamProject': ['mock-project'], 181 | 'System.WorkItemType': ['Bug', 'Task'], 182 | 'System.State': ['Active'], 183 | }, 184 | }), 185 | expect.any(Object), 186 | ); 187 | }); 188 | 189 | it('should include orderBy when provided', async () => { 190 | // Arrange 191 | options.orderBy = [{ field: 'System.CreatedDate', sortOrder: 'ASC' }]; 192 | 193 | // Act 194 | await searchWorkItems(connection, options); 195 | 196 | // Assert 197 | expect(mockedAxios.post).toHaveBeenCalledWith( 198 | expect.any(String), 199 | expect.objectContaining({ 200 | $orderBy: [{ field: 'System.CreatedDate', sortOrder: 'ASC' }], 201 | }), 202 | expect.any(Object), 203 | ); 204 | }); 205 | 206 | it('should handle 404 errors correctly', async () => { 207 | // Arrange - Mock the implementation to throw the specific error 208 | mockedAxios.post.mockImplementation(() => { 209 | throw new AzureDevOpsResourceNotFoundError( 210 | 'Resource not found: Project not found', 211 | ); 212 | }); 213 | 214 | // Act & Assert 215 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 216 | AzureDevOpsResourceNotFoundError, 217 | ); 218 | }); 219 | 220 | it('should handle 400 errors correctly', async () => { 221 | // Arrange - Mock the implementation to throw the specific error 222 | mockedAxios.post.mockImplementation(() => { 223 | throw new AzureDevOpsValidationError('Invalid request: Invalid query'); 224 | }); 225 | 226 | // Act & Assert 227 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 228 | AzureDevOpsValidationError, 229 | ); 230 | }); 231 | 232 | it('should handle 401/403 errors correctly', async () => { 233 | // Arrange - Mock the implementation to throw the specific error 234 | mockedAxios.post.mockImplementation(() => { 235 | throw new AzureDevOpsPermissionError( 236 | 'Permission denied: Permission denied', 237 | ); 238 | }); 239 | 240 | // Act & Assert 241 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 242 | AzureDevOpsPermissionError, 243 | ); 244 | }); 245 | 246 | it('should handle other axios errors correctly', async () => { 247 | // Arrange - Mock the implementation to throw the specific error 248 | mockedAxios.post.mockImplementation(() => { 249 | throw new AzureDevOpsError( 250 | 'Azure DevOps API error: Internal server error', 251 | ); 252 | }); 253 | 254 | // Act & Assert 255 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 256 | AzureDevOpsError, 257 | ); 258 | }); 259 | 260 | it('should handle non-axios errors correctly', async () => { 261 | // Arrange 262 | mockedAxios.post.mockRejectedValue(new Error('Network error')); 263 | 264 | // Act & Assert 265 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 266 | AzureDevOpsError, 267 | ); 268 | }); 269 | 270 | it('should throw an error if organization cannot be extracted', async () => { 271 | // Arrange 272 | (connection as any).serverUrl = 'https://invalid-url'; 273 | 274 | // Act & Assert 275 | await expect(searchWorkItems(connection, options)).rejects.toThrow( 276 | AzureDevOpsValidationError, 277 | ); 278 | }); 279 | 280 | it('should use Azure Identity authentication when AZURE_DEVOPS_AUTH_METHOD is azure-identity', async () => { 281 | // Mock environment variables 282 | const originalEnv = process.env.AZURE_DEVOPS_AUTH_METHOD; 283 | process.env.AZURE_DEVOPS_AUTH_METHOD = 'azure-identity'; 284 | 285 | // Mock the WebApi connection 286 | const mockConnection = { 287 | serverUrl: 'https://dev.azure.com/testorg', 288 | getCoreApi: jest.fn().mockResolvedValue({ 289 | getProjects: jest.fn().mockResolvedValue([]), 290 | }), 291 | }; 292 | 293 | // Mock axios post 294 | const mockResponse = { 295 | data: { 296 | count: 0, 297 | results: [], 298 | }, 299 | }; 300 | (axios.post as jest.Mock).mockResolvedValueOnce(mockResponse); 301 | 302 | // Call the function 303 | await searchWorkItems(mockConnection as unknown as WebApi, { 304 | projectId: 'testproject', 305 | searchText: 'test query', 306 | }); 307 | 308 | // Verify the axios post was called with a Bearer token 309 | expect(axios.post).toHaveBeenCalledWith( 310 | expect.any(String), 311 | expect.any(Object), 312 | { 313 | headers: { 314 | Authorization: 'Bearer mock-azure-identity-token', 315 | 'Content-Type': 'application/json', 316 | }, 317 | }, 318 | ); 319 | 320 | // Cleanup 321 | process.env.AZURE_DEVOPS_AUTH_METHOD = originalEnv; 322 | }); 323 | 324 | test('should perform organization-wide work item search when projectId is not provided', async () => { 325 | // Arrange 326 | const mockSearchResponse = { 327 | data: { 328 | count: 2, 329 | results: [ 330 | { 331 | id: 1, 332 | fields: { 333 | 'System.Title': 'Test Bug 1', 334 | 'System.State': 'Active', 335 | 'System.WorkItemType': 'Bug', 336 | 'System.TeamProject': 'Project1', 337 | }, 338 | project: { 339 | name: 'Project1', 340 | id: 'project-id-1', 341 | }, 342 | }, 343 | { 344 | id: 2, 345 | fields: { 346 | 'System.Title': 'Test Bug 2', 347 | 'System.State': 'Active', 348 | 'System.WorkItemType': 'Bug', 349 | 'System.TeamProject': 'Project2', 350 | }, 351 | project: { 352 | name: 'Project2', 353 | id: 'project-id-2', 354 | }, 355 | }, 356 | ], 357 | }, 358 | }; 359 | 360 | mockedAxios.post.mockResolvedValueOnce(mockSearchResponse); 361 | 362 | // Act 363 | const result = await searchWorkItems(connection, { 364 | searchText: 'bug', 365 | }); 366 | 367 | // Assert 368 | expect(result).toBeDefined(); 369 | expect(result.count).toBe(2); 370 | expect(result.results).toHaveLength(2); 371 | expect(result.results[0].fields['System.TeamProject']).toBe('Project1'); 372 | expect(result.results[1].fields['System.TeamProject']).toBe('Project2'); 373 | expect(mockedAxios.post).toHaveBeenCalledTimes(1); 374 | expect(mockedAxios.post).toHaveBeenCalledWith( 375 | expect.stringContaining( 376 | 'https://almsearch.dev.azure.com/mock-org/_apis/search/workitemsearchresults', 377 | ), 378 | expect.not.objectContaining({ 379 | filters: expect.objectContaining({ 380 | 'System.TeamProject': expect.anything(), 381 | }), 382 | }), 383 | expect.any(Object), 384 | ); 385 | }); 386 | }); 387 | ``` -------------------------------------------------------------------------------- /src/features/search/search-code/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { searchCode } from './feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '@/shared/test/test-helpers'; 7 | import { SearchCodeOptions } from '../types'; 8 | 9 | describe('searchCode integration', () => { 10 | let connection: WebApi | null = null; 11 | let projectName: string; 12 | 13 | beforeAll(async () => { 14 | // Get a real connection using environment variables 15 | connection = await getTestConnection(); 16 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 17 | }); 18 | 19 | test('should search code in a project', async () => { 20 | // Skip if no connection is available 21 | if (shouldSkipIntegrationTest()) { 22 | console.log('Skipping test: No Azure DevOps connection available'); 23 | return; 24 | } 25 | 26 | // This connection must be available if we didn't skip 27 | if (!connection) { 28 | throw new Error( 29 | 'Connection should be available when test is not skipped', 30 | ); 31 | } 32 | 33 | const options: SearchCodeOptions = { 34 | searchText: 'function', 35 | projectId: projectName, 36 | top: 10, 37 | }; 38 | 39 | try { 40 | // Act - make an actual API call to Azure DevOps 41 | const result = await searchCode(connection, options); 42 | 43 | // Assert on the actual response 44 | expect(result).toBeDefined(); 45 | expect(typeof result.count).toBe('number'); 46 | expect(Array.isArray(result.results)).toBe(true); 47 | 48 | // Check structure of returned items (if any) 49 | if (result.results.length > 0) { 50 | const firstResult = result.results[0]; 51 | expect(firstResult.fileName).toBeDefined(); 52 | expect(firstResult.path).toBeDefined(); 53 | expect(firstResult.project).toBeDefined(); 54 | expect(firstResult.repository).toBeDefined(); 55 | 56 | if (firstResult.project) { 57 | expect(firstResult.project.name).toBe(projectName); 58 | } 59 | } 60 | } catch (error) { 61 | // Skip test if the code search extension is not installed 62 | if ( 63 | error instanceof Error && 64 | (error.message.includes('ms.vss-code-search is not installed') || 65 | error.message.includes('Resource not found') || 66 | error.message.includes('Failed to search code')) 67 | ) { 68 | console.log( 69 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', 70 | ); 71 | return; 72 | } 73 | throw error; 74 | } 75 | }); 76 | 77 | test('should include file content when requested', async () => { 78 | // Skip if no connection is available 79 | if (shouldSkipIntegrationTest()) { 80 | console.log('Skipping test: No Azure DevOps connection available'); 81 | return; 82 | } 83 | 84 | // This connection must be available if we didn't skip 85 | if (!connection) { 86 | throw new Error( 87 | 'Connection should be available when test is not skipped', 88 | ); 89 | } 90 | 91 | const options: SearchCodeOptions = { 92 | searchText: 'function', 93 | projectId: projectName, 94 | top: 5, 95 | includeContent: true, 96 | }; 97 | 98 | try { 99 | // Act - make an actual API call to Azure DevOps 100 | const result = await searchCode(connection, options); 101 | 102 | // Assert on the actual response 103 | expect(result).toBeDefined(); 104 | 105 | // Check if content is included (if any results) 106 | if (result.results.length > 0) { 107 | // At least some results should have content 108 | // Note: Some files might fail to fetch content, so we don't expect all to have it 109 | const hasContent = result.results.some((r) => r.content !== undefined); 110 | expect(hasContent).toBe(true); 111 | } 112 | } catch (error) { 113 | // Skip test if the code search extension is not installed 114 | if ( 115 | error instanceof Error && 116 | (error.message.includes('ms.vss-code-search is not installed') || 117 | error.message.includes('Resource not found') || 118 | error.message.includes('Failed to search code')) 119 | ) { 120 | console.log( 121 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', 122 | ); 123 | return; 124 | } 125 | throw error; 126 | } 127 | }); 128 | 129 | test('should filter results when filters are provided', async () => { 130 | // Skip if no connection is available 131 | if (shouldSkipIntegrationTest()) { 132 | console.log('Skipping test: No Azure DevOps connection available'); 133 | return; 134 | } 135 | 136 | // This connection must be available if we didn't skip 137 | if (!connection) { 138 | throw new Error( 139 | 'Connection should be available when test is not skipped', 140 | ); 141 | } 142 | 143 | try { 144 | // First get some results to find a repository name 145 | const initialOptions: SearchCodeOptions = { 146 | searchText: 'function', 147 | projectId: projectName, 148 | top: 1, 149 | }; 150 | 151 | const initialResult = await searchCode(connection, initialOptions); 152 | 153 | // Skip if no results found 154 | if (initialResult.results.length === 0) { 155 | console.log('Skipping filter test: No initial results found'); 156 | return; 157 | } 158 | 159 | // Use the repository from the first result for filtering 160 | const repoName = initialResult.results[0].repository.name; 161 | 162 | const filteredOptions: SearchCodeOptions = { 163 | searchText: 'function', 164 | projectId: projectName, 165 | filters: { 166 | Repository: [repoName], 167 | }, 168 | top: 5, 169 | }; 170 | 171 | // Act - make an actual API call to Azure DevOps with filters 172 | const result = await searchCode(connection, filteredOptions); 173 | 174 | // Assert on the actual response 175 | expect(result).toBeDefined(); 176 | 177 | // All results should be from the specified repository 178 | if (result.results.length > 0) { 179 | const allFromRepo = result.results.every( 180 | (r) => r.repository.name === repoName, 181 | ); 182 | expect(allFromRepo).toBe(true); 183 | } 184 | } catch (error) { 185 | // Skip test if the code search extension is not installed 186 | if ( 187 | error instanceof Error && 188 | (error.message.includes('ms.vss-code-search is not installed') || 189 | error.message.includes('Resource not found') || 190 | error.message.includes('Failed to search code')) 191 | ) { 192 | console.log( 193 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', 194 | ); 195 | return; 196 | } 197 | throw error; 198 | } 199 | }); 200 | 201 | test('should handle pagination', async () => { 202 | // Skip if no connection is available 203 | if (shouldSkipIntegrationTest()) { 204 | console.log('Skipping test: No Azure DevOps connection available'); 205 | return; 206 | } 207 | 208 | // This connection must be available if we didn't skip 209 | if (!connection) { 210 | throw new Error( 211 | 'Connection should be available when test is not skipped', 212 | ); 213 | } 214 | 215 | try { 216 | // Get first page 217 | const firstPageOptions: SearchCodeOptions = { 218 | searchText: 'function', 219 | projectId: projectName, 220 | top: 2, 221 | skip: 0, 222 | }; 223 | 224 | const firstPageResult = await searchCode(connection, firstPageOptions); 225 | 226 | // Skip if not enough results for pagination test 227 | if (firstPageResult.count <= 2) { 228 | console.log('Skipping pagination test: Not enough results'); 229 | return; 230 | } 231 | 232 | // Get second page 233 | const secondPageOptions: SearchCodeOptions = { 234 | searchText: 'function', 235 | projectId: projectName, 236 | top: 2, 237 | skip: 2, 238 | }; 239 | 240 | const secondPageResult = await searchCode(connection, secondPageOptions); 241 | 242 | // Assert on pagination 243 | expect(secondPageResult).toBeDefined(); 244 | expect(secondPageResult.results.length).toBeGreaterThan(0); 245 | 246 | // First and second page should have different results 247 | if ( 248 | firstPageResult.results.length > 0 && 249 | secondPageResult.results.length > 0 250 | ) { 251 | const firstPagePaths = firstPageResult.results.map((r) => r.path); 252 | const secondPagePaths = secondPageResult.results.map((r) => r.path); 253 | 254 | // Check if there's any overlap between pages 255 | const hasOverlap = firstPagePaths.some((path) => 256 | secondPagePaths.includes(path), 257 | ); 258 | expect(hasOverlap).toBe(false); 259 | } 260 | } catch (error) { 261 | // Skip test if the code search extension is not installed 262 | if ( 263 | error instanceof Error && 264 | (error.message.includes('ms.vss-code-search is not installed') || 265 | error.message.includes('Resource not found') || 266 | error.message.includes('Failed to search code')) 267 | ) { 268 | console.log( 269 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', 270 | ); 271 | return; 272 | } 273 | throw error; 274 | } 275 | }); 276 | 277 | test('should use default project when no projectId is provided', async () => { 278 | // Skip if no connection is available 279 | if (shouldSkipIntegrationTest()) { 280 | console.log('Skipping test: No Azure DevOps connection available'); 281 | return; 282 | } 283 | 284 | // This connection must be available if we didn't skip 285 | if (!connection) { 286 | throw new Error( 287 | 'Connection should be available when test is not skipped', 288 | ); 289 | } 290 | 291 | // Store original environment variable 292 | const originalEnv = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; 293 | 294 | try { 295 | // Set the default project to the current project name for testing 296 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = projectName; 297 | 298 | // Search without specifying a project ID 299 | const options: SearchCodeOptions = { 300 | searchText: 'function', 301 | top: 5, 302 | }; 303 | 304 | // Act - make an actual API call to Azure DevOps 305 | const result = await searchCode(connection, options); 306 | 307 | // Assert on the actual response 308 | expect(result).toBeDefined(); 309 | expect(typeof result.count).toBe('number'); 310 | expect(Array.isArray(result.results)).toBe(true); 311 | 312 | // Check structure of returned items (if any) 313 | if (result.results.length > 0) { 314 | const firstResult = result.results[0]; 315 | expect(firstResult.fileName).toBeDefined(); 316 | expect(firstResult.path).toBeDefined(); 317 | expect(firstResult.project).toBeDefined(); 318 | expect(firstResult.repository).toBeDefined(); 319 | 320 | if (firstResult.project) { 321 | expect(firstResult.project.name).toBe(projectName); 322 | } 323 | } 324 | } catch (error) { 325 | // Skip test if the code search extension is not installed 326 | if ( 327 | error instanceof Error && 328 | (error.message.includes('ms.vss-code-search is not installed') || 329 | error.message.includes('Resource not found') || 330 | error.message.includes('Failed to search code')) 331 | ) { 332 | console.log( 333 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', 334 | ); 335 | return; 336 | } 337 | throw error; 338 | } finally { 339 | // Restore original environment variable 340 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = originalEnv; 341 | } 342 | }); 343 | }); 344 | ``` -------------------------------------------------------------------------------- /src/features/search/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Options for searching code in Azure DevOps repositories 3 | */ 4 | export interface SearchCodeOptions { 5 | searchText: string; 6 | projectId?: string; 7 | filters?: { 8 | Repository?: string[]; 9 | Path?: string[]; 10 | Branch?: string[]; 11 | CodeElement?: string[]; 12 | }; 13 | top?: number; 14 | skip?: number; 15 | includeSnippet?: boolean; 16 | includeContent?: boolean; 17 | } 18 | 19 | /** 20 | * Request body for the Azure DevOps Search API 21 | */ 22 | export interface CodeSearchRequest { 23 | searchText: string; 24 | $skip?: number; 25 | $top?: number; 26 | filters?: { 27 | Project?: string[]; 28 | Repository?: string[]; 29 | Path?: string[]; 30 | Branch?: string[]; 31 | CodeElement?: string[]; 32 | }; 33 | includeFacets?: boolean; 34 | includeSnippet?: boolean; 35 | } 36 | 37 | /** 38 | * Match information for search results 39 | */ 40 | export interface CodeSearchMatch { 41 | charOffset: number; 42 | length: number; 43 | } 44 | 45 | /** 46 | * Collection information for search results 47 | */ 48 | export interface CodeSearchCollection { 49 | name: string; 50 | } 51 | 52 | /** 53 | * Project information for search results 54 | */ 55 | export interface CodeSearchProject { 56 | name: string; 57 | id: string; 58 | } 59 | 60 | /** 61 | * Repository information for search results 62 | */ 63 | export interface CodeSearchRepository { 64 | name: string; 65 | id: string; 66 | type: string; 67 | } 68 | 69 | /** 70 | * Version information for search results 71 | */ 72 | export interface CodeSearchVersion { 73 | branchName: string; 74 | changeId: string; 75 | } 76 | 77 | /** 78 | * Individual code search result 79 | */ 80 | export interface CodeSearchResult { 81 | fileName: string; 82 | path: string; 83 | content?: string; // Added to store full file content 84 | matches: { 85 | content?: CodeSearchMatch[]; 86 | fileName?: CodeSearchMatch[]; 87 | }; 88 | collection: CodeSearchCollection; 89 | project: CodeSearchProject; 90 | repository: CodeSearchRepository; 91 | versions: CodeSearchVersion[]; 92 | contentId: string; 93 | } 94 | 95 | /** 96 | * Facet information for search results 97 | */ 98 | export interface CodeSearchFacet { 99 | name: string; 100 | id: string; 101 | resultCount: number; 102 | } 103 | 104 | /** 105 | * Response from the Azure DevOps Search API 106 | */ 107 | export interface CodeSearchResponse { 108 | count: number; 109 | results: CodeSearchResult[]; 110 | infoCode?: number; 111 | facets?: { 112 | Project?: CodeSearchFacet[]; 113 | Repository?: CodeSearchFacet[]; 114 | Path?: CodeSearchFacet[]; 115 | Branch?: CodeSearchFacet[]; 116 | CodeElement?: CodeSearchFacet[]; 117 | }; 118 | } 119 | 120 | /** 121 | * Options for searching wiki pages in Azure DevOps projects 122 | */ 123 | export interface SearchWikiOptions { 124 | /** 125 | * The text to search for within wiki pages 126 | */ 127 | searchText: string; 128 | 129 | /** 130 | * The ID or name of the project to search in 131 | * If not provided, search will be performed across the entire organization 132 | */ 133 | projectId?: string; 134 | 135 | /** 136 | * Optional filters to narrow search results 137 | */ 138 | filters?: { 139 | /** 140 | * Filter by project names. Useful for cross-project searches. 141 | */ 142 | Project?: string[]; 143 | }; 144 | 145 | /** 146 | * Number of results to return 147 | * @default 100 148 | * @minimum 1 149 | * @maximum 1000 150 | */ 151 | top?: number; 152 | 153 | /** 154 | * Number of results to skip for pagination 155 | * @default 0 156 | * @minimum 0 157 | */ 158 | skip?: number; 159 | 160 | /** 161 | * Whether to include faceting in results 162 | * @default true 163 | */ 164 | includeFacets?: boolean; 165 | } 166 | 167 | /** 168 | * Request body for the Azure DevOps Wiki Search API 169 | */ 170 | export interface WikiSearchRequest { 171 | /** 172 | * The search text to find in wiki pages 173 | */ 174 | searchText: string; 175 | 176 | /** 177 | * Number of results to skip for pagination 178 | */ 179 | $skip?: number; 180 | 181 | /** 182 | * Number of results to return 183 | */ 184 | $top?: number; 185 | 186 | /** 187 | * Filters to be applied. Set to null if no filters are needed. 188 | */ 189 | filters?: { 190 | /** 191 | * Filter by project names 192 | */ 193 | Project?: string[]; 194 | }; 195 | 196 | /** 197 | * Options for sorting search results 198 | * If null, results are sorted by relevance 199 | */ 200 | $orderBy?: SortOption[]; 201 | 202 | /** 203 | * Whether to include faceting in the result 204 | * @default false 205 | */ 206 | includeFacets?: boolean; 207 | } 208 | 209 | /** 210 | * Sort option for search results 211 | */ 212 | export interface SortOption { 213 | /** 214 | * Field to sort by 215 | */ 216 | field: string; 217 | 218 | /** 219 | * Sort direction 220 | */ 221 | sortOrder: 'asc' | 'desc' | 'ASC' | 'DESC'; 222 | } 223 | 224 | /** 225 | * Defines the matched terms in the field of the wiki result 226 | */ 227 | export interface WikiHit { 228 | /** 229 | * Reference name of the highlighted field 230 | */ 231 | fieldReferenceName: string; 232 | 233 | /** 234 | * Matched/highlighted snippets of the field 235 | */ 236 | highlights: string[]; 237 | } 238 | 239 | /** 240 | * Defines the wiki result that matched a wiki search request 241 | */ 242 | export interface WikiResult { 243 | /** 244 | * Name of the result file 245 | */ 246 | fileName: string; 247 | 248 | /** 249 | * Path at which result file is present 250 | */ 251 | path: string; 252 | 253 | /** 254 | * Collection of the result file 255 | */ 256 | collection: { 257 | /** 258 | * Name of the collection 259 | */ 260 | name: string; 261 | }; 262 | 263 | /** 264 | * Project details of the wiki document 265 | */ 266 | project: { 267 | /** 268 | * ID of the project 269 | */ 270 | id: string; 271 | 272 | /** 273 | * Name of the project 274 | */ 275 | name: string; 276 | 277 | /** 278 | * Visibility of the project 279 | */ 280 | visibility?: string; 281 | }; 282 | 283 | /** 284 | * Wiki information for the result 285 | */ 286 | wiki: { 287 | /** 288 | * ID of the wiki 289 | */ 290 | id: string; 291 | 292 | /** 293 | * Mapped path for the wiki 294 | */ 295 | mappedPath: string; 296 | 297 | /** 298 | * Name of the wiki 299 | */ 300 | name: string; 301 | 302 | /** 303 | * Version for wiki 304 | */ 305 | version: string; 306 | }; 307 | 308 | /** 309 | * Content ID of the result file 310 | */ 311 | contentId: string; 312 | 313 | /** 314 | * Highlighted snippets of fields that match the search request 315 | * The list is sorted by relevance of the snippets 316 | */ 317 | hits: WikiHit[]; 318 | } 319 | 320 | /** 321 | * Defines a wiki search response item 322 | */ 323 | export interface WikiSearchResponse { 324 | /** 325 | * Total number of matched wiki documents 326 | */ 327 | count: number; 328 | 329 | /** 330 | * List of top matched wiki documents 331 | */ 332 | results: WikiResult[]; 333 | 334 | /** 335 | * Numeric code indicating additional information: 336 | * 0 - Ok 337 | * 1 - Account is being reindexed 338 | * 2 - Account indexing has not started 339 | * 3 - Invalid Request 340 | * ... and others as defined in the API 341 | */ 342 | infoCode?: number; 343 | 344 | /** 345 | * A dictionary storing an array of Filter objects against each facet 346 | */ 347 | facets?: { 348 | /** 349 | * Project facets for filtering 350 | */ 351 | Project?: CodeSearchFacet[]; 352 | }; 353 | } 354 | 355 | /** 356 | * Options for searching work items in Azure DevOps projects 357 | */ 358 | export interface SearchWorkItemsOptions { 359 | /** 360 | * The text to search for within work items 361 | */ 362 | searchText: string; 363 | 364 | /** 365 | * The ID or name of the project to search in 366 | * If not provided, search will be performed across the entire organization 367 | */ 368 | projectId?: string; 369 | 370 | /** 371 | * Optional filters to narrow search results 372 | */ 373 | filters?: { 374 | /** 375 | * Filter by project names. Useful for cross-project searches. 376 | */ 377 | 'System.TeamProject'?: string[]; 378 | 379 | /** 380 | * Filter by work item types (Bug, Task, User Story, etc.) 381 | */ 382 | 'System.WorkItemType'?: string[]; 383 | 384 | /** 385 | * Filter by work item states (New, Active, Closed, etc.) 386 | */ 387 | 'System.State'?: string[]; 388 | 389 | /** 390 | * Filter by assigned users 391 | */ 392 | 'System.AssignedTo'?: string[]; 393 | 394 | /** 395 | * Filter by area paths 396 | */ 397 | 'System.AreaPath'?: string[]; 398 | }; 399 | 400 | /** 401 | * Number of results to return 402 | * @default 100 403 | * @minimum 1 404 | * @maximum 1000 405 | */ 406 | top?: number; 407 | 408 | /** 409 | * Number of results to skip for pagination 410 | * @default 0 411 | * @minimum 0 412 | */ 413 | skip?: number; 414 | 415 | /** 416 | * Whether to include faceting in results 417 | * @default true 418 | */ 419 | includeFacets?: boolean; 420 | 421 | /** 422 | * Options for sorting search results 423 | * If null, results are sorted by relevance 424 | */ 425 | orderBy?: SortOption[]; 426 | } 427 | 428 | /** 429 | * Request body for the Azure DevOps Work Item Search API 430 | */ 431 | export interface WorkItemSearchRequest { 432 | /** 433 | * The search text to find in work items 434 | */ 435 | searchText: string; 436 | 437 | /** 438 | * Number of results to skip for pagination 439 | */ 440 | $skip?: number; 441 | 442 | /** 443 | * Number of results to return 444 | */ 445 | $top?: number; 446 | 447 | /** 448 | * Filters to be applied. Set to null if no filters are needed. 449 | */ 450 | filters?: { 451 | 'System.TeamProject'?: string[]; 452 | 'System.WorkItemType'?: string[]; 453 | 'System.State'?: string[]; 454 | 'System.AssignedTo'?: string[]; 455 | 'System.AreaPath'?: string[]; 456 | }; 457 | 458 | /** 459 | * Options for sorting search results 460 | * If null, results are sorted by relevance 461 | */ 462 | $orderBy?: SortOption[]; 463 | 464 | /** 465 | * Whether to include faceting in the result 466 | * @default false 467 | */ 468 | includeFacets?: boolean; 469 | } 470 | 471 | /** 472 | * Defines the matched terms in the field of the work item result 473 | */ 474 | export interface WorkItemHit { 475 | /** 476 | * Reference name of the highlighted field 477 | */ 478 | fieldReferenceName: string; 479 | 480 | /** 481 | * Matched/highlighted snippets of the field 482 | */ 483 | highlights: string[]; 484 | } 485 | 486 | /** 487 | * Defines the work item result that matched a work item search request 488 | */ 489 | export interface WorkItemResult { 490 | /** 491 | * Project details of the work item 492 | */ 493 | project: { 494 | /** 495 | * ID of the project 496 | */ 497 | id: string; 498 | 499 | /** 500 | * Name of the project 501 | */ 502 | name: string; 503 | }; 504 | 505 | /** 506 | * A standard set of work item fields and their values 507 | */ 508 | fields: { 509 | /** 510 | * ID of the work item 511 | */ 512 | 'system.id': string; 513 | 514 | /** 515 | * Type of the work item (Bug, Task, User Story, etc.) 516 | */ 517 | 'system.workitemtype': string; 518 | 519 | /** 520 | * Title of the work item 521 | */ 522 | 'system.title': string; 523 | 524 | /** 525 | * User assigned to the work item 526 | */ 527 | 'system.assignedto'?: string; 528 | 529 | /** 530 | * Current state of the work item 531 | */ 532 | 'system.state'?: string; 533 | 534 | /** 535 | * Tags associated with the work item 536 | */ 537 | 'system.tags'?: string; 538 | 539 | /** 540 | * Revision number of the work item 541 | */ 542 | 'system.rev'?: string; 543 | 544 | /** 545 | * Creation date of the work item 546 | */ 547 | 'system.createddate'?: string; 548 | 549 | /** 550 | * Last modified date of the work item 551 | */ 552 | 'system.changeddate'?: string; 553 | 554 | /** 555 | * Other fields may be included based on the work item type 556 | */ 557 | [key: string]: string | number | boolean | null | undefined; 558 | }; 559 | 560 | /** 561 | * Highlighted snippets of fields that match the search request 562 | * The list is sorted by relevance of the snippets 563 | */ 564 | hits: WorkItemHit[]; 565 | 566 | /** 567 | * URL to the work item 568 | */ 569 | url: string; 570 | } 571 | 572 | /** 573 | * Defines a work item search response item 574 | */ 575 | export interface WorkItemSearchResponse { 576 | /** 577 | * Total number of matched work items 578 | */ 579 | count: number; 580 | 581 | /** 582 | * List of top matched work items 583 | */ 584 | results: WorkItemResult[]; 585 | 586 | /** 587 | * Numeric code indicating additional information: 588 | * 0 - Ok 589 | * 1 - Account is being reindexed 590 | * 2 - Account indexing has not started 591 | * 3 - Invalid Request 592 | * ... and others as defined in the API 593 | */ 594 | infoCode?: number; 595 | 596 | /** 597 | * A dictionary storing an array of Filter objects against each facet 598 | */ 599 | facets?: { 600 | 'System.TeamProject'?: CodeSearchFacet[]; 601 | 'System.WorkItemType'?: CodeSearchFacet[]; 602 | 'System.State'?: CodeSearchFacet[]; 603 | 'System.AssignedTo'?: CodeSearchFacet[]; 604 | 'System.AreaPath'?: CodeSearchFacet[]; 605 | }; 606 | } 607 | ```