> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Git links

> Attach a GitHub pull request or GitLab merge request to an issue — no webhooks, no publicly reachable instance.

# Git links

An issue in Crewship and the pull request that resolves it used to be two
unconnected facts. **Git links** connect them: paste a pull-request or
merge-request URL onto an issue and Crewship recognises the provider, fetches
the request through that provider's API with a credential you already store,
and keeps its title, state, author and branches on the issue.

It works the same way for **GitHub and GitLab**, on the public services and on
self-hosted instances of either — and it needs **no webhook and no inbound
network access**, which matters because most Crewship instances are not
reachable from github.com.

<Note>
  Attaching a link never changes the issue's status. Auto-transitioning an issue
  when its pull request merges, `Fixes ENG-123` magic words, and branch-name
  generation are deliberately not part of this release.
</Note>

## In the issue detail

Open an issue — `/issues/<identifier>`, or the centre pane of `/issues`; they
are the same screen — and its links are a **Pull requests** card under
*Description* and *Links*. GitLab merge requests appear in the same card.

Each row leads with the **state**, because a merged pull request, a closed one,
an open draft and an open ready-to-review one are four different things and it
is the thing you are scanning for. The states line down the left edge:

| Badge       | Means                                                                                                                                 |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Open**    | Ready for review                                                                                                                      |
| **Draft**   | Open, but not asking for anything yet                                                                                                 |
| **Merged**  | It landed                                                                                                                             |
| **Closed**  | It did not                                                                                                                            |
| **Unknown** | The stored state is missing or is not one of the four. A link is only recorded after a successful fetch, so you should never see this |

After the state come the title, `owner/repo#number`, the author, the branch
pair and when the state was last confirmed. **The title, author and branch
names come from the forge and are shown as plain text** — they are never
rendered as markdown or HTML, so a pull-request title cannot smuggle markup
into your issue.

The **pull-request icon in the card header** attaches a link: paste the URL,
press Enter. When it fails, the reason appears under the box with your URL
still in it — including the one you will hit most, *"no ACTIVE GITHUB
credential in this workspace can reach `ghe.acme.internal`…"*, which names both
halves of the fix. Add the credential, label it with the host, press Attach
again.

Hovering a row reveals two actions: **refresh** (the `relink` command — re-read
the state now) and **remove**. Removing takes no confirmation; nothing is
asserted by a link and nothing is left behind when it goes.

A row whose last refresh failed keeps the state it had and says so in amber,
with the reason and when the state was last true. Refreshing it again is one
click; the row updates whether the refresh succeeds or fails, so the reason
stays on the card rather than only in a toast.

<Note>
  Attaching, refreshing and removing need **MANAGER or above**. A reader below
  that sees the same card with the same links and no write affordances at all.
</Note>

## Attach a link

<CodeGroup>
  ```bash CLI theme={null}
  crewship issue link ENG-4 https://github.com/acme/thing/pull/7
  crewship issue link ENG-4 https://gitlab.com/acme/thing/-/merge_requests/7
  crewship issue link ENG-4 https://ghe.acme.internal/platform/gw/pull/12
  ```

  ```bash API theme={null}
  curl -X POST "$CREWSHIP/api/v1/crews/$CREW/issues/ENG-4/code-links" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://github.com/acme/thing/pull/7"}'
  ```
</CodeGroup>

The response carries what the provider said:

```json theme={null}
{
  "id": "cmsezurje0003d5e624ad",
  "provider": "GITHUB",
  "host": "github.com",
  "owner": "acme",
  "repo": "thing",
  "number": 7,
  "url": "https://github.com/acme/thing/pull/7",
  "title": "Add the widget",
  "state": "OPEN",
  "author": "octocat",
  "source_branch": "feat/widget",
  "target_branch": "main",
  "remote_merged_at": null,
  "last_synced_at": "2026-08-04T18:31:03Z"
}
```

`state` is normalised to one of **`OPEN`**, **`DRAFT`**, **`MERGED`** or
**`CLOSED`** across both providers. A merged GitHub pull request reports itself
as `closed` with a `merged_at`; Crewship files that as `MERGED`, not `CLOSED`.

## The other commands

| Command                                 | Does                                       |
| --------------------------------------- | ------------------------------------------ |
| `crewship issue links ENG-4`            | List the links on an issue                 |
| `crewship issue link ENG-4 <url>`       | Attach a pull request / merge request      |
| `crewship issue relink ENG-4 <link-id>` | Re-read the link's state from its provider |
| `crewship issue unlink ENG-4 <link-id>` | Remove the link                            |

Each maps to one route under
`/api/v1/crews/{crewId}/issues/{identifier}/code-links` (`GET`, `POST`,
`POST …/{linkId}/refresh`, `DELETE …/{linkId}`). Attaching, refreshing and
removing need **MANAGER or above**; listing needs any workspace member.

State is **not** polled. A link shows what the last successful fetch returned,
with `last_synced_at` saying when that was; `relink` is how you ask again.

## Which URLs are recognised

```
GitHub   https://<host>/<owner>/<repo>/pull/<number>
GitLab   https://<host>/<group>/…/<project>/-/merge_requests/<number>
```

Trailing path (`/files`), query strings and fragments are ignored, so a link
copied from the "Files changed" tab is the same link as one copied from the
conversation tab — attaching both is a `409`, not two rows.

**Self-hosted instances are recognised without configuration.** The provider is
identified from the **URL path**, not the hostname: every GitHub deployment,
Enterprise Server included, serves `/pull/<n>`, and no GitLab deployment does.
A hostname allowlist would recognise github.com and gitlab.com and fail every
self-hosted instance — the opposite of what this feature is for.

Recognising it is not the same as reaching it. A self-hosted forge still needs
a credential labelled with its host (see [Credentials](#credentials)), and one
on a private address also needs the operator opt-in
([below](#self-hosted-forges-on-a-private-network)).

Anything else — an issue URL, a repository root, a commit — is rejected with a
`400` and a message naming the shape it expected.

## Credentials

Git links do not introduce a new place to keep secrets. They use the
[credentials](/guides/credentials) already in the workspace: an **ACTIVE**,
non-deleted credential whose provider is `GITHUB` or `GITLAB`, scoped either to
the whole workspace or to the crew that owns the issue. The token is sent in a
request header — never in a URL, a query string or a command line.

Which credential is used:

1. **The one labelled with the host.** Set the credential's *account label* to
   the forge's host (`ghe.acme.internal`, `gitlab.acme.internal:8443`) and it
   is matched by host. This is how multiple forges, or multiple accounts on
   one forge, are told apart.
2. **Otherwise, for `github.com` / `gitlab.com` only**, the workspace's oldest
   active credential for that provider.

For any other host, an unlabelled credential is **not** used — sending a
github.com token to an arbitrary pasted host is exactly the accident this rule
prevents. Add the label and the link resolves.

The matched credential does more than supply the token: **the account label is
the address the API request is sent to.** The label and the pasted host are the
same string — that is how the credential was found — so this changes nothing
about which forge is reached. It means the request target comes from a record
your workspace stored rather than from the link somebody pasted, which is why
an unlabelled credential cannot reach a self-hosted host at all.

```bash theme={null}
crewship credential create \
  --name ghe-readonly --provider GITHUB \
  --account-label ghe.acme.internal
```

The token needs read access to the repository: `repo` scope on GitHub, `read_api`
on GitLab. Every fetch records a `USE` event against the credential, so a
revoked key's blast radius is a query rather than an investigation.

## Self-hosted forges on a private network

By default Crewship **refuses to fetch from private, loopback and link-local
addresses**. A pasted URL is user-supplied, and the server attaches a
credential to whatever it dials, so the strict posture is the default: a link
cannot be used to probe the network Crewship runs on, and DNS names that
resolve to internal addresses are refused at connect time, not just at parse
time.

If your GitLab or GitHub Enterprise lives on an intranet address, an admin
turns the opt-in on:

```bash theme={null}
crewship instance settings set git_links.allow_private_hosts true
```

That reopens RFC1918, loopback and ULA addresses, and allows `http://` targets.
It does **not** reopen cloud metadata (`169.254.169.254` and its IPv6 forms),
multicast or reserved ranges — those stay refused regardless.

## When something goes wrong

Failures come back as RFC 7807 problem documents with a stable `type` URI and a
`code`, because several of them share an HTTP status and need different fixes.

| `code`                   | Status | What to do                                                                                                                                                                                                                                                             |
| ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid-body`           | 400    | The request body is not valid JSON                                                                                                                                                                                                                                     |
| `unsupported-url`        | 400    | The URL is not a pull-request / merge-request link                                                                                                                                                                                                                     |
| `no-credential`          | 412    | Add a credential for that provider, or label an existing one with the host                                                                                                                                                                                             |
| `blocked-host`           | 422    | The host is on a private/internal address — see the section above                                                                                                                                                                                                      |
| `pull-request-not-found` | 404    | Wrong number, or the credential cannot see a private repository                                                                                                                                                                                                        |
| `credential-rejected`    | 502    | The provider returned 401 — the token was revoked or expired                                                                                                                                                                                                           |
| `credential-forbidden`   | 502    | The token is valid but lacks read access to the repository                                                                                                                                                                                                             |
| `rate-limited`           | 429    | The provider is throttling; `Retry-After` says for how long                                                                                                                                                                                                            |
| `provider-unavailable`   | 502    | The forge returned 5xx or did not answer                                                                                                                                                                                                                               |
| `cross-host-redirect`    | 502    | The forge redirected off its own host. Not followed: the request carries a credential, and a custom auth header (GitLab's `PRIVATE-TOKEN`) is not stripped by the HTTP client on a cross-host hop. Same-host redirects — a renamed repository — are followed normally. |
| `already-linked`         | 409    | That pull request is already attached to this issue                                                                                                                                                                                                                    |

A **refresh** that fails keeps the state it already had and records the reason
on the link (`last_sync_error`), so the UI and `crewship issue links` show
"merged, last checked Tuesday, refreshing is failing because the token was
revoked" instead of losing what was known.

## What agents see

An agent reading an issue through the sidecar gets its links, with one
difference from the browser view: the pull request's **title, author and branch
names are wrapped in an `<untrusted>` block**. Those strings are written by
whoever opened the pull request — on a public repository, by anyone — and the
fence is what tells the model to treat them as data rather than instructions.
The URL, provider and state are Crewship's own parsed values and are not
fenced. See [Lookout](/guides/lookout) for the fence's contract.

## What's next

The link table records the provider's `merged_at` from day one, and is indexed
for the reverse lookup ("which issue does this pull request belong to?"). That
is the groundwork for the phase-2 behaviour — moving an issue to Done when its
pull request merges — which will be additive.
