Skip to content

feat(forge): Azure DevOps integration — PRs, work items, multi-issue seeds - #351

Merged
simion merged 5 commits into
simion:mainfrom
kaceper11:azure-devops
Oct 1, 2026
Merged

simion merged 5 commits into
simion:mainfrom
kaceper11:azure-devops

Conversation

@kaceper11

Copy link
Copy Markdown
Contributor

Closes #328

Summary

Adds Azure DevOps Services (cloud) as a third forge provider alongside GitHub and GitLab — detected remotes, az CLI + azure-devops extension detection, auth detection (no tokens stored), PR status/checks/reviewers/comments, Boards work-item picking, and PR creation, all through the existing forge lifecycle.

  • Remote parsing — dev.azure.com/{org}/{project}/_git/{repo}, *.visualstudio.com (collection path dropped — the extension's org-URL grammar rejects path segments), ssh.dev.azure.com:v3/... and vs-ssh.visualstudio.com:v3/..., legacy {project}/_ssh/{repo}, PAT-in-URL variants. All az calls pass explicit --org/--project/--repository rather than --detect, so a PAT-bearing remote can never silently resolve the wrong org.
  • PR lifecycle — status, policy evaluations → check rows, reviewer votes, comment threads (incl. file-position threads), pr create with --draft/--auto-complete, pr list --creator me, pr show for the "From a PR" picker. Sibling-project repos with the same name are rejected (org-scoped PR IDs).
  • Work items — WIQL-backed boards list for the issue picker, with provider nouns ("work item") throughout the UI and prompts.
  • Branch fetch — per-provider refspecs (refs/pull/N/head, refs/merge-requests/N/head, refs/heads/<head> for azure); fork PRs are refused instead of fetching a same-named branch from the wrong repo. Fetches go through the shared noninteractive/deadline guards.
  • New Task dialog — multi-pick issues seed one task carrying all picked contexts under a single instructions tail (plural WORK_ISSUES_PROMPT, provider-noun aware); !123 and /pullrequest/N query forms; stale-operation guards keyed on dialog-open identity; exclusive source panes.
  • Prompt delivery — bracketed paste for long/multiline seeds; echo listener armed before the write; codex/copilot [Pasted …]/[Paste #N] chips accepted as echo for wrapped sends; CLI inject path gets the same readiness + echo verification as seeding.
  • Sandbox/docs — .azure state dir mounted (host creds untouched), Azure hosts in the sandbox allowlist, docs + e2e wording updated.

Out of scope: Azure DevOps Server/on-prem discovery (documented boundary).

Test plan

  • cargo test forge — 33 tests (remote forms, scope args, WIQL, PR/threads parsing, missing-PR classification, .azure mount guard)
  • npx vitest run — 2642 tests (multi-pick prompt composition, plural/provider wording, cap enforcement, control-char stripping, paste-chip echo)
  • tsc --noEmit, cargo check clean
  • Verified live against a stubbed az: work-item pick → seeded → codex submitted and ran
  • Real Azure org end-to-end (no live org to test against — --creator me and WIQL are exercised only against the stub)

Generated with Devin

kaceper11 and others added 4 commits September 30, 2026 22:47
Adds az/azure-devops as a third forge alongside gh and glab: remote
detection (dev.azure.com, *.visualstudio.com, ssh.dev.azure.com v3,
vs-ssh), CLI + extension + Entra/PAT auth probing, PR status/policies/
comments, Azure Boards work-item listing and issue-task seeding, PR
creation, IPC + frontend wiring, sandbox/Docker allowlisting, and docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…te-chip echo

Codex 0.159 TUI fires sessionStart on the FIRST prompt submit, not at
startup, so gating the seeded prompt on hook readiness guaranteed a
timeout: every codex task ended in seed-blocked. hooksOwnStartupReadiness
excludes codex from hook-owned startup readiness (work-state hooks still
gate mid-turn), so it falls back to the painted-and-quiet heuristic with
echo verification - which still refuses to submit into a picker dialog.

Two stacked delivery bugs then surfaced on that path: the echo listener
attached inside the text write's .then (an echo riding the write's own
round trip read as 'did not echo' and withheld the CR), and the literal
echo check never passes for multi-line prompts because codex renders a
bracketed paste as a '[Pasted Content N chars]' chip - the chip IS the
echo (a selection dialog can't draw one), so it is accepted for
paste-wrapped sends.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The card's identity row is all shrink-0 except the spacer; a fully
populated row (~330px) overran the 280px default right panel and the
window clipped the rightmost 'Open on' button. ReviewChip ('Changes
requested', the widest label) now truncates with a title fallback.

forge.rs: pr_status/pr_comments/issue_list/pr_create re-probe the CLI
path instead of the startup-time cache, so a CLI installed mid-session
works without visiting Settings first.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- az calls scope explicitly (--org/--project/--repository); --detect gone,
  so PAT remotes cannot silently resolve the wrong org
- From a PR works for azure: pr list --creator me, pr show, per-provider
  fetch refspecs, fork PRs refused instead of fetching the wrong branch
- Multi-pick issues seed ONE task; plural WORK_ISSUES_PROMPT tail with
  provider noun swap; unmodified builtins only, user edits win
- agentSend: paste chip counts as echo again for wrapped sends, failure
  diagnostics restored; cliRpc inject gets readiness + echo verification
- NewTaskDialog: stale guards on seed-object identity, pane exclusivity,
  !N ref parsing, branch latch; CreatePrDialog provider gating
- visualstudio.com org URLs drop the collection path (extension rejects
  it); legacy _ssh remotes parse; sibling-project repo guard

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Conflict in WelcomeDialog.tsx: keep both new imports — the forge
sign-in helpers and markDesktopEntryAsked from the Linux desktop-entry
step.

@simion simion left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally on top of main: clean merge, cargo test 1202, npm test 2654, tsc -b + e2e typecheck clean, and task.e2e 88/88 plus git.e2e 75/75 on a rebuilt binary.

The argv discipline on the Rust side is right: everything goes through forge.rs::run() as separate argv elements, no shell anywhere, and passing explicit --org/--project/--repository instead of --detect is the correct call for exactly the reason the doc comment gives.

I am merging this, and then pushing three follow-ups straight after rather than holding the branch. Flagging them here so they are on the record and not silently absorbed:

1. Shell injection into an agent-run command. src/lib/forge.ts:93-94 and :105-106 build a command STRING that the agent is instructed to run, and single-quote the remote-derived org/project/repo without escaping embedded quotes.
azureRemoteParts decodes each path segment, so a remote containing %27 yields a literal ' and closes the quote.
Cloning a repo whose remote is crafted is enough to reach it, via commentPromptFor and the New Task issue seed.
This PR already has the fix one file over, in CreatePrDialog.tsx:126, with a comment saying precisely why.

2. The merge-handled localStorage key gained a provider segment (src/store/pr.ts:636) with no read of the legacy key, so on upgrade every already-handled merge is unhandled again and maybeHandleMerged re-fires once: a toast, a notification, and under on_pr_merge: "archive" an archive of a task the user had deliberately kept.

3. Dropping the providerByProject early-out (src/store/pr.ts:190) lets two in-flight resolves race, and the catch arm writes null unconditionally, so a transient failure can overwrite a good "azure" and leave the repo reading as forgeless.

Also worth a mention, not blocking: the new fixtures introduce @x.io, which is a registered domain. CLAUDE.md asks for the vocabulary already in the tree (user@example.com, e2e@termic.dev) precisely because someone owns anything that looks real. I am changing those in the follow-up.

Thank you for this. The remote-parsing work is the part that would have been miserable to get right later: four URL shapes including the SSH v3 forms and PAT-bearing remotes, with remote_for_display guarding against the PAT reaching UI copy, is careful work and it is tested.

@simion
simion merged commit 2a86181 into simion:main Oct 1, 2026
7 checks passed
simion added a commit that referenced this pull request Oct 1, 2026
feat(tasks): per-member PR/CI rows + update-all for multi-repo tasks

Merged locally: GitHub reported the branch as CONFLICTING, but the merge is
clean in both directions (merge-tree, and a real merge of main into the
branch, both exit 0 with no conflicted paths). Its mergeability was computed
before #351 landed and never recomputed.

Verified on this exact main: cargo 1216, npm test 2665, git.e2e 75/75,
tsc -b and the e2e typecheck clean.
simion added a commit that referenced this pull request Oct 1, 2026
…to run

Follow-ups to #351, flagged in its review and fixed here rather than left.

**Shell injection.** `azurePrThreadsCommand` and `azureWorkItemCommentsCommand`
return a command STRING that is typed into a prompt and that the agent is
instructed to execute, so every interpolated value is shell input rather than
an argv element. They single-quoted the org, project and repo without
escaping, and `azureRemoteParts` percent-DECODES each path segment, so a
remote containing `%27` yields a literal quote, closes it, and the rest of
the segment is read as shell. Cloning a repo whose remote is crafted is
enough to reach it, through the PR-comment prompt and the New Task issue
seed.

The Rust side was never exposed (it builds argv through `forge.rs::run()`),
and CreatePrDialog already escapes its branch exactly this way, with a
comment saying why. The same escape is now a shared `shellArg`.

The test parses the result with a small POSIX single-quote splitter rather
than asserting on substrings, and the first version of it failed on a fix
that worked: the correct `'\''` escape CONTAINS the sequence a substring
test forbids. The control in the test shows what the unescaped build does,
splitting one value into two shell words.

**A merge re-announced once on upgrade.** The localStorage key gained a
`provider` segment, so every merge already handled under the old spelling
read as unhandled exactly once: a toast, a desktop notification, and under
`on_pr_merge: "archive"` an archive of a task the user had chosen to keep.
It now falls back to the legacy key, on the miss path only.

**A transient failure could un-forge a repo.** `resolveProvider` dropped its
early-out for good reasons (the real TTL is Rust-side), but its catch wrote
null unconditionally, so two dialogs resolving the same project out of order
could replace a good "azure" with null. CreatePrDialog reads null as "this
repo is not on any forge" and disables draft-with-agent until relaunch. A
first failure still records null, which is honest when nothing is known.

**Fixtures.** The new ADO fixtures used `@x.io`, a registered domain that
appears nowhere else in the tree. CLAUDE.md asks for the vocabulary already
here precisely because someone owns anything that looks real; these are
`@example.com` now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBZ9SY1zTBpPW33QAekZC1
@simion

simion commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Merged, and the three follow-ups are on main in e380a5d.

The injection one is worth a second look if you write another of these builders: shellArg is now in src/lib/forge.ts and anything that interpolates a remote-derived value into a command STRING should go through it. The Rust side needs nothing, because it builds argv.

The test for it is the part I would steal rather than the fix. It parses the built command with a small POSIX single-quote splitter and asserts the hostile value stays one argument, because my first attempt asserted on substrings and failed on a fix that worked: the correct '\\'' escape contains the very sequence a substring test forbids. There is a control case in there too, showing the unescaped build splitting one value into two shell words.

Thanks again. The remote parsing is the part that would have been painful to retrofit, and it is tested properly.

@kaceper11
kaceper11 deleted the azure-devops branch October 1, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Azure DevOps integration

2 participants