Skip to content

feat(azuredevops): deliver to Azure DevOps Services - #23

Merged
AlexeyShalaev merged 16 commits into
masterfrom
feat/azure-devops
Oct 9, 2026
Merged

AlexeyShalaev merged 16 commits into
masterfrom
feat/azure-devops

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Azure DevOps Services as a delivery platform: a hub on GitHub, GitLab, Gitea/Forgejo or Bitbucket can now deliver to repositories on dev.azure.com. A hub on Azure Pipelines comes later.

Provider type azure-devops.

  • url: https://dev.azure.com/<organization>: one provider per organization. Targets are project/repo, and web URLs …/<project>/_git/<repo> resolve.
  • The reader and the writer are PATs of two users, with Code (read) and Code (read & write) scopes.
  • Logins are identity GUIDs.
  • topics: fails check.

Driver (internal/platform/azuredevops).

  • Reads. Repositories (empty, disabled and fork states). Files come from the Items, Trees and Blobs APIs. The blob id and mode are checked, and symlinks and submodules are not regular files. A missing opt-in file is ErrNotFound only for a missing path at an existing commit; a missing branch or repository, or a disabled repository, is never one.
  • Pull requests. It lists and reads pull requests, with the closer from a single GET, and writes them: drafts, labels by name, comments as closed threads, close by abandon.
  • Permissions. The writer's permissions are checked through the Has Permissions API.
  • Errors. Sign-in redirects and 203 HTML answers are authentication failures, and Retry-After pauses the provider even on a 200.
  • Push refusals. TF402455 is rules:policy, and TF401027 is permission:push.

Marker in pull request properties (MarkerInProperties). Descriptions are limited to 4000 characters, so the marker lives in the property touchmark.marker. The description stays the readable part, within 4000.

  • Contract. The contract does not change. Bodies still carry the marker line, and the driver splits it off (marker.Detach) and joins it back (marker.Attach). A marker pasted into a description never counts.
  • Order of writes. On create, the property is written with retries; if it cannot be written, the new pull request is abandoned. Edits of an open pull request write the description first and the property last. A close writes the property before the abandon.
  • People's text. Edits that keep people's text leave the description byte for byte, so a long human description cannot block a close.
  • Closed pull requests are not edited (ClosedImmutable, as on Bitbucket). Whether properties of an abandoned pull request can be edited is unknown, and the no-write decline memory already works.

GitHub, GitLab, Gitea, Forgejo and Bitbucket are unchanged, and tests prove their bodies and decisions are byte-identical.

Tests.

  • Driver unit tests, including the failure modes the review found.
  • An azure-devops flavor of the fake platform, delivery scenarios and a property simulation on it, and crash-matrix cases.
  • Marker split/join fuzzing.

An independent review found no regressions on the other platforms. Its findings are fixed here: a lost marker after create, long human descriptions, the write order, a disabled repository read, anonymous reads and Retry-After on success.

Known risk, documented in the threat model. Project Readers may be able to write pull request properties. If so, a Reader could block a target, or make a decline not count; author, head and fork identity come from the API and cannot be forged. This is on the live-check list. If it is confirmed, the marker moves to a comment by the writer.

Not verified live (no organization). These are listed in the docs: property limits and permissions, the description limit, labels on create, the duplicate pull request error, the closer of an abandoned pull request, the answer to a bad PAT, push refusal texts, and Trees API modes.

A provider of type azure-devops is one organization of Azure DevOps
Services: its url is https://dev.azure.com/<organization> (required, one
provider per organization) and its REST base is that url. Any other url
needs an api_url (a test server) and must still name the organization as
its only path segment; the old <organization>.visualstudio.com form is
refused.

Repository paths are project/repository. Web URLs of repositories,
https://dev.azure.com/<organization>/<project>/_git/<repository>, resolve
to the provider; the organization URL itself is the namespace of an org
entry, which selects the whole organization (a project goes through
match). Azure DevOps has no topics: check refuses them with a hint, warns
about match and exclude patterns deeper than project/repository, and a
local run takes any target of the provider as possibly selected by its
org entries. The JSON Schemas of hub.yml, targets.yml and the reports
follow; the fixtures that used azure-devops as an unknown type use
sourcehut now. setup and the hub's key store refuse azure-devops: a hub
on Azure Pipelines comes later.
Caps.Marker MarkerInProperties is a platform whose descriptions are too
short for the marker (Azure DevOps: 4 000 characters), which keeps it in a
pull request property instead. The contract of PR.Body, NewPR.Body and
PREdit.Body does not change: the core writes and reads one body that ends
with the marker line. A driver of such a platform splits what the core
writes with marker.Detach (the description without marker lines, and the
last marker line for the property) and joins what it reads with
marker.Attach (the description without marker lines, then the stored
line), so a marker pasted into a description never counts. Strip and Find
answer the same on a body and on its detached and attached form.

prbody bounds the description alone there: Caps.MaxBody reserves no room
for the marker and does not count the marker line. Every other platform
keeps its bodies byte for byte (their goldens are unchanged); the new
azure-devops goldens check that each description fits 4 000 bytes and
splits back into the body.
… pull requests

A new driver, internal/platform/azuredevops, serves plan for providers of
type azure-devops; its write driver refuses with ClassUnsupported until the
writer comes.

The reader authenticates with a personal access token: Basic with an empty
user name for the REST API, Basic touchmark:<token> for git, both masked.
Every request sends api-version=7.1 and X-TFS-FedAuthRedirect: Suppress; a
redirect to the sign-in page or a 203 with HTML is ClassAuth. Identities are
their GUIDs: Self reads connectionData (the public-access identity is a
refused token), Lookup the Identities API by id and refuses anything else.
Probe asks nothing: flavor azure-devops, native drafts, closedBy as the
closer, a 4 000-byte description with the marker in a property
(MarkerInProperties), closed pull requests immutable, labels by name,
branch and push policies known at push time only, conservative limits.

Repo reads <project>/<repository>; Resolve lists the organization's
repositories in one call (an org entry names the organization; topics are
refused), forks only on request, disabled ones included for the core to
skip. ReadFile reads the folder through the Items API, the entry's mode,
id and size through the Trees API and the bytes through the Blobs API,
checking the blob id; only GitItemNotFoundException or a folder without
the entry is ErrNotFound, a missing branch or repository is ClassUnknown.
PRs lists each head's pull requests in every status, reads the marker
property of each, a pull request alone when its listed description may be
cut or to learn who abandoned it, the base through the Refs API and an
active pull request's head from its branch. OpenPRsBy sweeps each project
by creator. Errors follow the typeKey and TF codes of Azure DevOps' error
body; 429, TF400733 or a Retry-After on any refusal is rate limited.
NewWriter builds a real writer. Target reads the repository by its id and
evaluates the writer's permissions with Permissions - Has Permissions on
the Git Repositories namespace (GenericContribute and CreateBranch for
contents, PullRequestContribute for pull requests; a missing one is
ClassPermission with rule contents or pull-requests). When that API
refuses the token, the check is skipped and the writes meet a missing
permission instead.

The TargetWriter pushes with Basic touchmark:<token>. CreatePR splits the
body (marker.Detach): the description without its marker line, at most
4 000 characters, and the marker line in the property touchmark.marker,
written right after the pull request; draft is the native flag; labels go
by name in the request and those the answer lacks are added one by one.
EditPR writes the marker property first, then the labels, then one PATCH
of what changes of title, description, base and status abandoned, so a
closing body's marker is stored before the pull request is abandoned. It
never reactivates or changes an abandoned pull request (ClassUnsupported),
and a completed one is ClassConflict. Comments are closed threads, which
never wait for a resolution; EnsureLabels has nothing to create. The
Checker reports access from the same permissions, rules as unknown, and
the token's expiry, scopes and 2fa as unknown.

A push Azure Repos refuses with TF402455 (a branch policy) is
blocked:rules:policy, one with TF401027 (a missing Git permission)
blocked:permission:push. A commit without the user's address is authored
by <identity id>@touchmark.invalid.
CapsFor(AzureDevOps) matches the driver's Probe: a 4 000-byte description
with the marker in a property, native drafts, labels by name, a known
closer, closed pull requests immutable, branch and push policies at push
time. Where the marker lives apart the fake stores what the driver reads
back: the description without marker lines, then the last marker line of
the body; the description alone is held to MaxBody, comments are not, and
a person's edit of the description keeps the stored marker. An abandoned
pull request is final, as on Bitbucket. The meter counts the property
write after a create and before an edit, and one call per label added.
A pull request opens with its labels and a marker kept apart from a
description of at most 4 000 bytes; an abandoned one holds as a decline
with no write to it, lapses when the opt-in file changes, and a
forget_declines entry brings the content back while present; an opt-in
change under an open pull request rewrites only its marker; touchmark's
own close stores the closed marker before it abandons, and is no decline;
a long change set is cut to fit while the marker records every change.
The property test runs 15 Azure DevOps scenarios, the crash matrix two
Azure DevOps cases.
The providers guide gets an Azure DevOps section: one provider per
organization, two users' PATs (Code read; Code read & write), identity ids
as logins, project/repository paths and _git web URLs, no topics, the
marker in a pull request property beside a 4 000-character description,
labels by name, abandoned pull requests never edited or reactivated,
branch policies met at push time, and what only a live organization can
confirm. Memory, operations, the glossary, the compatibility page and the
page for agents follow.
CreatePR tries the idempotent property replace again after a transient
failure or a short rate limit (1, 2 and 4 s; a Retry-After over 30 s
ends the attempts); should it still fail, the new pull request is
abandoned and the call fails with the property's class, so that no own
pull request stays active without its marker.

EditPR of an active pull request that is not a close sends its PATCH
first, then the labels, then the property: a failure in between leaves
the previous marker, which the next run sees as an edit still to make.
A close keeps "property, then abandon", and abandons even when a
description change would not fit (the description stays as it is). A
description equal to the current one, marker lines, line endings and
trailing blanks aside, is not sent.

Also: a disabled repository is ClassUnknown in ReadFile, never a missing
file; anonymous reads sent to sign-in say that Azure DevOps wants a
token; Retry-After on an answer that went through pauses the throttle;
PRs reads the marker of every active pull request and of the 60 newest
abandoned and completed ones only.
…re DevOps

FailMarkerNext fails the next write of the marker property as Azure
DevOps' driver meets it once its attempts are spent: CreatePR leaves the
pull request abandoned without a marker (or active, when the abandon
fails too), EditPR keeps the previous marker after the rest of an edit
and changes nothing of a close. EditPR sends no description equal to the
current one and lets a close leave one that would not fit. The comments
no longer present pull request properties as protected.
…on Azure DevOps

Where the marker lives in a pull request property, an edit that leaves
the description as read (a close, an opt-in refresh, a revocation)
keeps it byte for byte instead of making it inert: the driver does not
send it, and Inert could push a description people filled past the
4 000-character limit, so that the close failed every run. Only its
marker line is checked as touchmark's text. Edits that change the
description (a consumed rebuild, an ack) are made inert as before, and
other platforms write the same bodies as ever.

After a create whose outcome is unknown, the open pull request found
counts as created on such platforms only once it carries the expected
marker; otherwise an edit with the new body and labels stores it.
The providers guide no longer presents the marker property as
protected, and says how touchmark keeps it, that kept descriptions are
not sent, that the reader needs a token, how Retry-After and the
property reads are handled, and adds who can write pull request
properties to the live checks. The threat model records the residual
risk: a project Reader may block a target or make a decline not count.
# Conflicts:
#	docs/agents.md
#	internal/cli/setup.go
#	internal/hubch/keys.go
@AlexeyShalaev
AlexeyShalaev merged commit dd4be4b into master Oct 9, 2026
22 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/azure-devops branch October 9, 2026 09:29
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.

1 participant