refactor: move the transport onto the official Gitea SDK
The transport was hand-rolled net/http against the REST API. The payload shapes were ours, in internal/wire, which meant every field Gitea learned was a field somebody here had to notice; and "does this instance have issue dependencies?" had to be guessed from a status code, because a 404 from a missing route and a 404 from a missing issue look alike. The SDK settles both. The shapes are maintained by the people who maintain the server, and the client negotiates the server's version when it is built, so the dependency endpoint is now gated on `>= 1.20.0` — verified against the release where the route appears, not assumed. Below the gate nothing is requested at all. internal/wire keeps what the SDK has no answer for: addressing. The SDK takes an owner, a name and an int64 and never parses, while `42`, `#42`, `owner/repo#42` and an issue URL are four spellings of one address, all four are what somebody has in hand, and Key is what the ledger is keyed by. The payload structs go. Four things that had to survive the move, and did: - request bodies still land in .kettle/payload/, now via an http.RoundTripper on the client the SDK is given — which is better than before, because it files every request rather than the ones a call site remembered to name; - errors still carry the HTTP status AND the response body, and a decode failure on a 2xx is deliberately not an APIError, so the dependency probe cannot read a bad decode as "feature missing"; - the number -> slug ledger is untouched, entries still outlive the files they name; - Client.For(repo) still re-points at another repository for one call. What it cost, written down in AGENTS.md where it happened. internal/mapping's layering test was a fact about the import graph — nothing in its closure could open a socket — and the SDK ships its types and its client in one package, so the test now asserts what is still true: the bridge performs no I/O, checked on direct imports plus a grep for time.Now. A run makes one extra request before it does anything. Gitea's issue edit endpoint carries no labels, so a push whose labels changed needs a second call; push makes it and says so. go.mod requires go 1.26, which the SDK sets and which is now the floor for building this binary. internal/issue and internal/project are byte-identical. The domain did not notice, which is the whole argument for the layering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+110
-27
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
`kettle` is a globally installed binary. It owns everything that used to be a
|
`kettle` is a globally installed binary. It owns everything that used to be a
|
||||||
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
|
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the
|
||||||
store lives, who this machine is, and (once the transport lands) how issues move
|
store lives, who this machine is, and how issues move to and from Gitea.
|
||||||
to and from Gitea.
|
|
||||||
|
|
||||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||||
a binary cannot enforce. Everything mechanical is here.
|
a binary cannot enforce. Everything mechanical is here.
|
||||||
@@ -24,15 +23,37 @@ A compiled binary answers all three by construction. There is one walk
|
|||||||
(`internal/project`), it is imported rather than re-derived, and the layering
|
(`internal/project`), it is imported rather than re-derived, and the layering
|
||||||
rule is a build graph a test walks.
|
rule is a build graph a test walks.
|
||||||
|
|
||||||
## One dependency
|
## Two dependencies
|
||||||
|
|
||||||
`gopkg.in/yaml.v3`, vendored, and that is the whole list. Everything else is the
|
`gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea`, both vendored, and that is the
|
||||||
standard library: the transport is plain `net/http` against a documented REST
|
whole list — eight modules once the SDK's own are counted, 2.3 MB of `vendor/`.
|
||||||
API, and the CLI has no cobra — commands are values in a registry, which is what
|
The CLI still has no cobra: commands are values in a registry, which is what
|
||||||
lets the plugin's SKILL.md files be generated from the same struct that holds
|
lets the plugin's SKILL.md files be generated from the same struct that holds
|
||||||
the code.
|
the code.
|
||||||
|
|
||||||
`vendor/` is committed, so a build needs no network.
|
`vendor/` is committed, so a build needs no network. **`go.mod` says `go 1.26`**,
|
||||||
|
which the SDK requires; that is now the minimum for anybody building this binary.
|
||||||
|
|
||||||
|
The transport was hand-rolled `net/http` against the REST API and is now the
|
||||||
|
official SDK. What that bought:
|
||||||
|
|
||||||
|
- **the payload shapes are one vocabulary, maintained by the people who maintain
|
||||||
|
the server.** They used to be ours, in `internal/wire`, and every field Gitea
|
||||||
|
learned was a field somebody here had to notice.
|
||||||
|
- **the server's version, for free.** The SDK negotiates it when a client is
|
||||||
|
built, which is what lets the transport answer "does this instance have issue
|
||||||
|
dependencies?" from the version instead of guessing from a status code — see
|
||||||
|
`dependenciesSince`.
|
||||||
|
|
||||||
|
What it cost is written down where it happened: the shapes come with an HTTP
|
||||||
|
client attached (see the layering note below), a run makes one extra request
|
||||||
|
before it does anything (the version handshake), and Gitea's issue **edit**
|
||||||
|
endpoint carries no labels, so an issue whose labels changed needs a second call
|
||||||
|
— `push` makes it and says so.
|
||||||
|
|
||||||
|
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
|
||||||
|
tree. `internal/gitea` is the package named `gitea` and the SDK is `sdk` inside
|
||||||
|
it; the same alias holds in `internal/mapping` and `internal/cmd`.
|
||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
|
|
||||||
@@ -50,10 +71,10 @@ internal/cmd the command tree: flags, receipts, exit codes
|
|||||||
│ │ request, pagination, payload dumps,
|
│ │ request, pagination, payload dumps,
|
||||||
│ │ the number -> slug ledger
|
│ │ the number -> slug ledger
|
||||||
│ ▼
|
│ ▼
|
||||||
├────────────────────► internal/wire PROTOCOL: the JSON shapes and the
|
├────────────────────► internal/wire ADDRESSES: Repo and Key, and the
|
||||||
│ ▲ identifiers. Imports nothing.
|
│ ▲ parsing that reads them. Imports
|
||||||
│ │
|
│ │ nothing.
|
||||||
└──► internal/mapping ─────┘ BRIDGE: md <-> those shapes, pure,
|
└──► internal/mapping ─────┘ BRIDGE: md <-> the SDK's payloads,
|
||||||
│ no I/O; label colours live here
|
│ no I/O; label colours live here
|
||||||
▼
|
▼
|
||||||
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
internal/issue DOMAIN what an issue is: format, taxonomy, validation,
|
||||||
@@ -65,27 +86,49 @@ internal/project ROOT which directory is the project, and every path
|
|||||||
depends on nothing
|
depends on nothing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Both the transport and the bridge also import `code.gitea.io/sdk/gitea`, which
|
||||||
|
is where the payloads now live. The arrow that used to point at `internal/wire`
|
||||||
|
for them points there instead.
|
||||||
|
|
||||||
Read it bottom-up and each layer knows strictly less about trackers than the one
|
Read it bottom-up and each layer knows strictly less about trackers than the one
|
||||||
above it. Four tests hold the line, and each fails on a real mistake rather than
|
above it. Four rules hold the line, seven tests between them, and each fails on
|
||||||
on a naming convention:
|
a real mistake rather than on a naming convention:
|
||||||
|
|
||||||
- `internal/issue` may import `internal/project` and the standard library, and
|
- `internal/issue` may import `internal/project` and the standard library, and
|
||||||
nothing else. One test walks `go list -deps` and fails on any path with a dot
|
nothing else. One test walks `go list -deps` and fails on any path with a dot
|
||||||
in its first element — which is also what keeps yaml out of the domain — and
|
in its first element — which is also what keeps yaml AND the SDK out of the
|
||||||
another names `net/http`, `net`, `os/exec` and `encoding/json`, standard
|
domain — and another names `net/http`, `net`, `os/exec` and `encoding/json`,
|
||||||
library the first test would not catch.
|
standard library the first test would not catch. **Untouched by the migration
|
||||||
|
to the SDK, and that is the point: the domain did not notice it happened.**
|
||||||
- `internal/wire` imports only the standard library, checked the same two ways.
|
- `internal/wire` imports only the standard library, checked the same two ways.
|
||||||
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
|
- `internal/gitea` must not import `internal/issue` **or** `internal/mapping`:
|
||||||
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
|
the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
|
||||||
- `internal/mapping` performs no I/O and imports neither the transport nor the
|
- `internal/mapping` reaches for nothing but the domain and the SDK — checked on
|
||||||
configuration.
|
its DIRECT imports, with `os`, `net/http` and `internal/gitea` named — and a
|
||||||
|
second test greps its sources for `time.Now`.
|
||||||
|
|
||||||
`wire` exists because Go needs the JSON shapes to be one type. The transport and
|
**That fourth rule was stronger before the SDK, and the trade is deliberate.**
|
||||||
the bridge were written in parallel and each invented its own `Issue`, `Label`,
|
The payload shapes lived in `internal/wire`, a package that imported the
|
||||||
`Milestone` and `Comment`; every command on top would then have copied fields
|
standard library and nothing else, so "the bridge cannot reach a transport" was
|
||||||
from one struct into the other by hand, which is two vocabularies for one thing —
|
a fact about the import graph: there was nothing in its dependency closure that
|
||||||
exactly what this layering exists to prevent. Python did not have the problem
|
could open a socket. `code.gitea.io/sdk/gitea` is a client and a set of types in
|
||||||
because it passed dicts.
|
one package, so importing the types imports the client, and a test that walked
|
||||||
|
the closure would now be asserting something false. What is still true, and what
|
||||||
|
the test now says, is that **mapping performs no I/O** — no `os`, no `net/http`,
|
||||||
|
no transport, no configuration, no clock. `time` is allowed where it was not,
|
||||||
|
because the SDK hands over a `time.Time` and somebody has to format it back into
|
||||||
|
the string an issue file holds; the clock itself is still the caller's, and the
|
||||||
|
grep for `time.Now` is what says so.
|
||||||
|
|
||||||
|
`wire` used to exist because Go needs the JSON shapes to be one type — the
|
||||||
|
transport and the bridge were written in parallel and each invented its own
|
||||||
|
`Issue`, `Label`, `Milestone` and `Comment`. The SDK settles that argument for
|
||||||
|
the shapes. **What survives is addressing**, which the SDK has no answer for at
|
||||||
|
all: it takes an owner, a name and an `int64`, and never parses. `42`, `#42`,
|
||||||
|
`owner/repo#42` and an issue URL are four spellings of one address, all four are
|
||||||
|
what somebody has in hand, and `wire.Key` is what the ledger is keyed by and
|
||||||
|
what the `gitea:` metadata field holds. So `wire` keeps `Repo`, `Key`, their
|
||||||
|
parsing and their tests, and lost the payloads.
|
||||||
|
|
||||||
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
|
If a tracker concept — an issue number, a login, an HTTP call, a label colour —
|
||||||
shows up in `internal/issue`, it is in the wrong place.
|
shows up in `internal/issue`, it is in the wrong place.
|
||||||
@@ -153,7 +196,14 @@ instead of the tracker, which is why it lives in the domain.
|
|||||||
|
|
||||||
`.kettle/payload/` is a sibling, never a child: request bodies are debris of the
|
`.kettle/payload/` is a sibling, never a child: request bodies are debris of the
|
||||||
transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about
|
transport, and a scratchpad inside a store makes `ls .kettle/issues` lie about
|
||||||
what exists.
|
what exists. It is written by an `http.RoundTripper` installed on the SDK's
|
||||||
|
client, so **every** request with a body is filed and not only the ones a call
|
||||||
|
site remembered to name — a name only decides what the file is called. A run
|
||||||
|
that sends nothing, which includes every read-only command, still leaves no
|
||||||
|
directory: the first write creates it. The dump is the same JSON the wire
|
||||||
|
carried, re-indented and with `<`, `>` and `&` left alone, because the SDK
|
||||||
|
marshals with encoding/json's escaping and a dump nobody can read is a dump
|
||||||
|
nobody reads.
|
||||||
|
|
||||||
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
|
`kettle init` migrates older layouts in, oldest first — `tmp/issues` and then
|
||||||
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
|
`.tea/issues` — and each is a **move**. A store left behind at an old path is a
|
||||||
@@ -178,6 +228,12 @@ so the harness's own value would point every fixture at this repository.
|
|||||||
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
|
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so
|
||||||
a test run can neither read nor overwrite the developer's own tokens.
|
a test run can neither read nor overwrite the developer's own tokens.
|
||||||
|
|
||||||
|
**Every fake tracker answers `/api/v1/version`**, because building a client asks
|
||||||
|
for it: the SDK will not hand one back until the instance has said what it is,
|
||||||
|
and that answer is what the dependency gate is decided on later. The fakes say
|
||||||
|
1.26.1. One says 1.19.4, and that is a test — an instance too old for the issue
|
||||||
|
dependency endpoints is answered from its version, with no request made.
|
||||||
|
|
||||||
## The round trip
|
## The round trip
|
||||||
|
|
||||||
`push` and `pull` are the two halves of one rule, and the rule is that **the
|
`push` and `pull` are the two halves of one rule, and the rule is that **the
|
||||||
@@ -185,6 +241,10 @@ store holds what has not left this machine.**
|
|||||||
|
|
||||||
A successful push deletes `<id>.md` and every sidecar under that slug, on create
|
A successful push deletes `<id>.md` and every sidecar under that slug, on create
|
||||||
and on `--update` alike, and prints the number and URL the issue now lives at.
|
and on `--update` alike, and prints the number and URL the issue now lives at.
|
||||||
|
An `--update` can take one extra request with it: Gitea's edit endpoint carries
|
||||||
|
no labels, so when the answer's label set and the issue's disagree — a label
|
||||||
|
added or removed locally, or one a create silently dropped — the whole set goes
|
||||||
|
up in a PUT, and a warning on stderr says which names moved.
|
||||||
The deletion happens **only after a confirmed tracker response and only after the
|
The deletion happens **only after a confirmed tracker response and only after the
|
||||||
number -> slug ledger has been written** — network down, non-2xx, or an answer
|
number -> slug ledger has been written** — network down, non-2xx, or an answer
|
||||||
that does not carry the right number, and the file stays where it is while the
|
that does not carry the right number, and the file stays where it is while the
|
||||||
@@ -219,13 +279,36 @@ There is also no `--login` and no `--repo` on any sync command bar `labels`.
|
|||||||
Which login a project runs under, and which repository its issues belong to, are
|
Which login a project runs under, and which repository its issues belong to, are
|
||||||
facts about the project, stated once by `kettle init`. A cross-repository address
|
facts about the project, stated once by `kettle init`. A cross-repository address
|
||||||
is still an address: `kettle pull owner/repo#42` re-points the client for that
|
is still an address: `kettle pull owner/repo#42` re-points the client for that
|
||||||
one call.
|
one call — `Client.For(repo)`, which is bookkeeping and not a second connection,
|
||||||
|
because the SDK takes the owner and the name per call. The credentials, the
|
||||||
|
negotiated version and the scratchpad come along.
|
||||||
|
|
||||||
|
## Issue dependencies
|
||||||
|
|
||||||
|
The one endpoint with a story. `depends:` becomes a native Gitea link, which is
|
||||||
|
what makes the tracker show the blocking panel and refuse to close a blocked
|
||||||
|
issue first, and it is read back the same way on a pull.
|
||||||
|
|
||||||
|
- **Reading** goes through the SDK (`ListIssueDependencies`).
|
||||||
|
- **Writing does not.** Gitea's own `IssueMeta` is `{index, owner, repo}` and has
|
||||||
|
been since the endpoint existed; the SDK's is `{index}`, which can only ever
|
||||||
|
link inside one repository, and a `depends:` entry is allowed to live
|
||||||
|
somewhere else. So one hand-rolled POST survives, through the same HTTP client
|
||||||
|
as everything else — same payload dump, same `*APIError`.
|
||||||
|
- **The version gates both.** The routes are absent from Gitea 1.19 and present
|
||||||
|
in 1.20, checked against the release tags themselves, so an instance older
|
||||||
|
than that is answered from the version it already gave us rather than from a
|
||||||
|
404 — which on an old server is also what "no such issue" looks like.
|
||||||
|
- **A tracker that answers with a status still means "no dependencies here"**,
|
||||||
|
because an instance that HAS the endpoint can still have them turned off for a
|
||||||
|
repository, and a pull must bring the issue back either way. A dead connection
|
||||||
|
is not that answer and never was.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
|
Done and tested: all seven packages, and the commands `init`, `auth`, `config`,
|
||||||
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`,
|
||||||
`comment`, `close`, `labels`, `sync-evict`. 89 tests.
|
`comment`, `close`, `labels`, `sync-evict`. 99 tests.
|
||||||
|
|
||||||
Not done: the plugin still ships the Python scripts and the guard hook, and
|
Not done: the plugin still ships the Python scripts and the guard hook, and
|
||||||
still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating
|
still resolves `.tea/`. Rewiring `plugins/tea` onto this binary — and generating
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,17 @@
|
|||||||
module git.noodles.cam/claude-skills/marketplace/cli
|
module git.noodles.cam/claude-skills/marketplace/cli
|
||||||
|
|
||||||
go 1.24
|
go 1.26
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.25.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/42wim/httpsig v1.2.4 // indirect
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
+34
@@ -1,3 +1,37 @@
|
|||||||
|
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
|
||||||
|
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
|
||||||
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
|
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||||
|
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
@@ -111,7 +113,14 @@ first, or unlink them.`,
|
|||||||
if !t.key.Repo.Zero() {
|
if !t.key.Repo.Zero() {
|
||||||
c = client.For(t.key.Repo)
|
c = client.For(t.key.Repo)
|
||||||
}
|
}
|
||||||
req := wire.IssueRequest{State: wire.Set(state)}
|
// STATE AND NOTHING ELSE. Every other field of an edit is
|
||||||
|
// left at its zero value, which the SDK sends as null and
|
||||||
|
// Gitea reads as "no opinion" — bar the title, which goes
|
||||||
|
// as the empty string Gitea reads the same way. Building
|
||||||
|
// this by hand rather than through mapping.ToEdit is the
|
||||||
|
// point: a translated issue would carry its body.
|
||||||
|
want := sdk.StateType(state)
|
||||||
|
req := sdk.EditIssueOption{State: &want}
|
||||||
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
|
got, err := c.EditIssue(t.key.Number, req, fmt.Sprintf("state-%d", t.key.Number))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -120,9 +129,9 @@ first, or unlink them.`,
|
|||||||
// file is about to say something the tracker had better agree
|
// file is about to say something the tracker had better agree
|
||||||
// with. An answer counts only when it is the very issue that was
|
// with. An answer counts only when it is the very issue that was
|
||||||
// patched, in the state that was asked for.
|
// patched, in the state that was asked for.
|
||||||
if got.Number != t.key.Number || got.State != state {
|
if int(got.Index) != t.key.Number || got.State != want {
|
||||||
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
|
return Fail("%s: the %s did not go through — the tracker answered for issue #%d in state %q. Nothing local was changed.",
|
||||||
t.key.In(c.Repo()), verb, got.Number, got.State)
|
t.key.In(c.Repo()), verb, got.Index, got.State)
|
||||||
}
|
}
|
||||||
fmt.Printf("%-8s %-24s %-20s %s\n",
|
fmt.Printf("%-8s %-24s %-20s %s\n",
|
||||||
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
|
past, closeName(t.id), t.key.In(c.Repo()), got.HTMLURL)
|
||||||
@@ -297,14 +306,14 @@ func closeOne(hits []closeEntry, arg, what string) (*closeEntry, error) {
|
|||||||
// the answer that authorized this write is also the newest thing the tracker has
|
// the answer that authorized this write is also the newest thing the tracker has
|
||||||
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
|
// said about the issue, so `synced:` and `remote-updated:` are stamped from it
|
||||||
// rather than left describing an older read.
|
// rather than left describing an older read.
|
||||||
func closeApply(root string, i *issue.Issue, state string, got *wire.Issue) (string, error) {
|
func closeApply(root string, i *issue.Issue, state string, got *sdk.Issue) (string, error) {
|
||||||
i.State = state
|
i.State = state
|
||||||
if i.Extra == nil {
|
if i.Extra == nil {
|
||||||
i.Extra = map[string]string{}
|
i.Extra = map[string]string{}
|
||||||
}
|
}
|
||||||
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
|
i.Extra[mapping.SyncedKey] = time.Now().UTC().Format(time.RFC3339)
|
||||||
if got.UpdatedAt != "" {
|
if stamp := mapping.Stamp(got.Updated); stamp != "" {
|
||||||
i.Extra[mapping.RemoteUpdatedKey] = got.UpdatedAt
|
i.Extra[mapping.RemoteUpdatedKey] = stamp
|
||||||
}
|
}
|
||||||
return issue.Save(root, i)
|
return issue.Save(root, i)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -81,7 +82,7 @@ comment id for that.`,
|
|||||||
// issue actually is — even when the store has ever pointed at two.
|
// issue actually is — even when the store has ever pointed at two.
|
||||||
client = client.For(key.Repo)
|
client = client.For(key.Repo)
|
||||||
|
|
||||||
var got *wire.Comment
|
var got *sdk.Comment
|
||||||
verb := "posted"
|
verb := "posted"
|
||||||
if *edit != 0 {
|
if *edit != 0 {
|
||||||
verb = "edited"
|
verb = "edited"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
@@ -109,7 +111,7 @@ thing this command exists to fix.`,
|
|||||||
if !ok {
|
if !ok {
|
||||||
return Fail("%s: the answer for %s does not confirm a state "+
|
return Fail("%s: the answer for %s does not confirm a state "+
|
||||||
"(issue #%d, state %q). Nothing was evicted.",
|
"(issue #%d, state %q). Nothing was evicted.",
|
||||||
c.id, c.key, got.Number, got.State)
|
c.id, c.key, got.Index, got.State)
|
||||||
}
|
}
|
||||||
fresh[c.id] = state
|
fresh[c.id] = state
|
||||||
}
|
}
|
||||||
@@ -216,13 +218,13 @@ func syncEvictCandidates(issues map[string]*issue.Issue, ids []string) (checkabl
|
|||||||
// of a yes here may delete a file. An answer counts only when it is about the
|
// of a yes here may delete a file. An answer counts only when it is about the
|
||||||
// very issue that was asked about and names a state the domain recognizes. A
|
// very issue that was asked about and names a state the domain recognizes. A
|
||||||
// non-2xx never reaches this: the transport has already returned an error.
|
// non-2xx never reaches this: the transport has already returned an error.
|
||||||
func syncEvictConfirms(got *wire.Issue, number int) (string, bool) {
|
func syncEvictConfirms(got *sdk.Issue, number int) (string, bool) {
|
||||||
if got == nil || got.Number != number {
|
if got == nil || int(got.Index) != number {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
for _, s := range issue.States {
|
for _, s := range issue.States {
|
||||||
if got.State == s {
|
if string(got.State) == s {
|
||||||
return got.State, true
|
return s, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
@@ -165,8 +167,8 @@ the store and never a child.`,
|
|||||||
// taxonomy says it should be, what the repository already has under that exact
|
// taxonomy says it should be, what the repository already has under that exact
|
||||||
// name (nil when it has nothing), and where the two disagree.
|
// name (nil when it has nothing), and where the two disagree.
|
||||||
type labelRow struct {
|
type labelRow struct {
|
||||||
spec wire.LabelRequest
|
spec sdk.CreateLabelOption
|
||||||
got *wire.Label
|
got *sdk.Label
|
||||||
drift []labelDiff
|
drift []labelDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,10 +189,10 @@ type labelLookalike struct {
|
|||||||
// In taxonomy order, because a bootstrap prints its plan in that order and a map
|
// In taxonomy order, because a bootstrap prints its plan in that order and a map
|
||||||
// would shuffle it on every run — two identical runs would look like different
|
// would shuffle it on every run — two identical runs would look like different
|
||||||
// ones.
|
// ones.
|
||||||
func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []labelLookalike) {
|
func labelPlan(specs []sdk.CreateLabelOption, existing []*sdk.Label) ([]labelRow, []labelLookalike) {
|
||||||
byName := make(map[string]*wire.Label, len(existing))
|
byName := make(map[string]*sdk.Label, len(existing))
|
||||||
for i := range existing {
|
for _, l := range existing {
|
||||||
byName[existing[i].Name] = &existing[i]
|
byName[l.Name] = l
|
||||||
}
|
}
|
||||||
|
|
||||||
canonical := make(map[string]map[string]bool, len(specs))
|
canonical := make(map[string]map[string]bool, len(specs))
|
||||||
@@ -205,8 +207,7 @@ func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []
|
|||||||
}
|
}
|
||||||
|
|
||||||
var similar []labelLookalike
|
var similar []labelLookalike
|
||||||
for i := range existing {
|
for _, l := range existing {
|
||||||
l := &existing[i]
|
|
||||||
if _, exact := canonical[l.Name]; exact {
|
if _, exact := canonical[l.Name]; exact {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -228,7 +229,7 @@ func labelPlan(specs []wire.LabelRequest, existing []wire.Label) ([]labelRow, []
|
|||||||
//
|
//
|
||||||
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
|
// Colour and `exclusive` only. A description somebody rewrote is theirs, and the
|
||||||
// name matched exactly or this row would not exist.
|
// name matched exactly or this row would not exist.
|
||||||
func labelDrift(spec wire.LabelRequest, got *wire.Label) []labelDiff {
|
func labelDrift(spec sdk.CreateLabelOption, got *sdk.Label) []labelDiff {
|
||||||
var out []labelDiff
|
var out []labelDiff
|
||||||
if labelHex(got.Color) != labelHex(spec.Color) {
|
if labelHex(got.Color) != labelHex(spec.Color) {
|
||||||
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
|
out = append(out, labelDiff{"color", labelHex(got.Color), labelHex(spec.Color)})
|
||||||
|
|||||||
+26
-19
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
@@ -172,7 +174,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
|
queue, err := pullSeed(client, keys, filtered, gitea.IssueFilter{
|
||||||
State: *state, Labels: labels, Query: query, Milestone: *milestone,
|
State: *state, Labels: labels, Query: query, Milestone: *milestone,
|
||||||
Limit: *limit,
|
Limit: *limit,
|
||||||
Keep: func(p *wire.Issue) bool {
|
Keep: func(p *sdk.Issue) bool {
|
||||||
return pullLandsInStore(p, dropClosed, namer)
|
return pullLandsInStore(p, dropClosed, namer)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -190,13 +192,18 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
|
|
||||||
seen := map[int]bool{}
|
seen := map[int]bool{}
|
||||||
for _, t := range queue {
|
for _, t := range queue {
|
||||||
seen[t.payload.Number] = true
|
seen[int(t.payload.Index)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
for len(queue) > 0 {
|
for len(queue) > 0 {
|
||||||
task := queue[0]
|
task := queue[0]
|
||||||
queue = queue[1:]
|
queue = queue[1:]
|
||||||
p := task.payload
|
p := task.payload
|
||||||
|
// The SDK spells an issue number `Index`, and int64. It is
|
||||||
|
// an int everywhere on this side of the transport — in the
|
||||||
|
// ledger, in a key, in `depends:` — so it is narrowed once,
|
||||||
|
// here, rather than cast at every use.
|
||||||
|
number := int(p.Index)
|
||||||
id, err := namer.idFor(p)
|
id, err := namer.idFor(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -208,13 +215,13 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
// links, and its blockers are not followed. The slug stays
|
// links, and its blockers are not followed. The slug stays
|
||||||
// unclaimed too, so no other issue ends up pointing
|
// unclaimed too, so no other issue ends up pointing
|
||||||
// `depends:` at a file that is not there.
|
// `depends:` at a file that is not there.
|
||||||
if dropClosed && p.State == "closed" && !stored {
|
if dropClosed && p.State == sdk.StateClosed && !stored {
|
||||||
dropped = append(dropped, p.Number)
|
dropped = append(dropped, number)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
namer.taken[id] = true
|
namer.taken[id] = true
|
||||||
numberOf[p.Number] = id
|
numberOf[number] = id
|
||||||
|
|
||||||
// The native links, fetched ONCE for the two things they are
|
// The native links, fetched ONCE for the two things they are
|
||||||
// for: filling this issue's `depends:` and telling the walk
|
// for: filling this issue's `depends:` and telling the walk
|
||||||
@@ -222,7 +229,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
// store, and only one.
|
// store, and only one.
|
||||||
var blockers []int
|
var blockers []int
|
||||||
if !*noDeps {
|
if !*noDeps {
|
||||||
if blockers, err = pullBlockers(client, p.Number, repo); err != nil {
|
if blockers, err = pullBlockers(client, number, repo); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,10 +255,10 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
if _, err := issue.Save(root, next); err != nil {
|
if _, err := issue.Save(root, next); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := pullSyncComments(client, root, id, p.Number, p.Comments); err != nil {
|
if _, err := pullSyncComments(client, root, id, number, p.Comments); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ledger.Set(wire.Key{Repo: repo, Number: p.Number}, id)
|
ledger.Set(wire.Key{Repo: repo, Number: number}, id)
|
||||||
written = append(written, id)
|
written = append(written, id)
|
||||||
pending = append(pending, unresolved{id, missing})
|
pending = append(pending, unresolved{id, missing})
|
||||||
}
|
}
|
||||||
@@ -320,7 +327,7 @@ for". The thread is pull-only; post with ` + "`kettle comment`" + `.`,
|
|||||||
|
|
||||||
// pullTask is one issue to walk, and how far from a seed it was found.
|
// pullTask is one issue to walk, and how far from a seed it was found.
|
||||||
type pullTask struct {
|
type pullTask struct {
|
||||||
payload *wire.Issue
|
payload *sdk.Issue
|
||||||
depth int
|
depth int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,8 +394,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
|
|||||||
len(listing.Issues), strings.Join(what, " + "), f.State)
|
len(listing.Issues), strings.Join(what, " + "), f.State)
|
||||||
|
|
||||||
out := make([]pullTask, 0, len(listing.Issues))
|
out := make([]pullTask, 0, len(listing.Issues))
|
||||||
for i := range listing.Issues {
|
for _, p := range listing.Issues {
|
||||||
out = append(out, pullTask{payload: &listing.Issues[i]})
|
out = append(out, pullTask{payload: p})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -401,8 +408,8 @@ func pullSeed(c *gitea.Client, keys []wire.Key, filtered bool, f gitea.IssueFilt
|
|||||||
// only when the store already has it (it is refreshed, and that is a write);
|
// only when the store already has it (it is refreshed, and that is a write);
|
||||||
// anything else counts, including one --cached will skip, because a skipped
|
// anything else counts, including one --cached will skip, because a skipped
|
||||||
// issue is still an issue the store holds when the run ends.
|
// issue is still an issue the store holds when the run ends.
|
||||||
func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
|
func pullLandsInStore(p *sdk.Issue, dropClosed bool, namer *pullNamer) bool {
|
||||||
if !dropClosed || p.State != "closed" {
|
if !dropClosed || p.State != sdk.StateClosed {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
id, err := namer.idFor(p)
|
id, err := namer.idFor(p)
|
||||||
@@ -423,13 +430,13 @@ func pullLandsInStore(p *wire.Issue, dropClosed bool, namer *pullNamer) bool {
|
|||||||
// either resolve to the wrong issue or invent an edge. The body still names it,
|
// either resolve to the wrong issue or invent an edge. The body still names it,
|
||||||
// so nothing is lost.
|
// so nothing is lost.
|
||||||
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
|
func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
|
||||||
deps, err := c.Dependencies(number)
|
keys, err := c.DependencyKeys(number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out []int
|
var out []int
|
||||||
for i := range deps {
|
for _, k := range keys {
|
||||||
if k := deps[i].KeyIn(repo); k.Repo == repo {
|
if k.Repo == repo {
|
||||||
out = append(out, k.Number)
|
out = append(out, k.Number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,7 +451,7 @@ func pullBlockers(c *gitea.Client, number int, repo wire.Repo) ([]int, error) {
|
|||||||
// absence of the file is the answer, not a gap in what was asked for.
|
// absence of the file is the answer, not a gap in what was asked for.
|
||||||
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
|
func pullSyncComments(c *gitea.Client, root, id string, number, count int) (string, error) {
|
||||||
path := commentsSidecarPath(root, id)
|
path := commentsSidecarPath(root, id)
|
||||||
var thread []wire.Comment
|
var thread []*sdk.Comment
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
var err error
|
var err error
|
||||||
if thread, err = c.ListComments(number); err != nil {
|
if thread, err = c.ListComments(number); err != nil {
|
||||||
@@ -530,8 +537,8 @@ type pullNamer struct {
|
|||||||
|
|
||||||
// idFor is the slug this remote issue belongs under. Three sources, in order —
|
// idFor is the slug this remote issue belongs under. Three sources, in order —
|
||||||
// see the command's own documentation for why that order and not another.
|
// see the command's own documentation for why that order and not another.
|
||||||
func (n *pullNamer) idFor(p *wire.Issue) (string, error) {
|
func (n *pullNamer) idFor(p *sdk.Issue) (string, error) {
|
||||||
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: p.Number}); got != "" {
|
if got := n.ledger.Slug(wire.Key{Repo: n.repo, Number: int(p.Index)}); got != "" {
|
||||||
return got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
marked := mapping.IDInBody(p.Body)
|
marked := mapping.IDInBody(p.Body)
|
||||||
|
|||||||
+73
-37
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
@@ -241,7 +243,7 @@ and a warning says so.`,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if m != nil {
|
if m != nil {
|
||||||
msID = wire.Set(m.ID)
|
msID = sdk.OptionalInt64(m.ID)
|
||||||
}
|
}
|
||||||
milestones[i.Milestone] = msID
|
milestones[i.Milestone] = msID
|
||||||
}
|
}
|
||||||
@@ -252,17 +254,17 @@ and a warning says so.`,
|
|||||||
|
|
||||||
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
|
opt := mapping.RequestOptions{LabelIDs: labelIDs, MilestoneID: msID}
|
||||||
sent, synced := mapping.NumberOf(i)
|
sent, synced := mapping.NumberOf(i)
|
||||||
var got *wire.Issue
|
var got *sdk.Issue
|
||||||
verb := "created"
|
verb := "created"
|
||||||
if synced {
|
if synced {
|
||||||
// An edit says what state it means; a create takes the
|
// An edit says what state it means; a create takes the
|
||||||
// tracker's default.
|
// tracker's default.
|
||||||
opt.IncludeState = true
|
opt.IncludeState = true
|
||||||
verb = "updated"
|
verb = "updated"
|
||||||
got, err = client.EditIssue(sent, *mapping.ToRequest(i, opt), "issue-"+id)
|
got, err = client.EditIssue(sent, mapping.ToEdit(i, opt), "issue-"+id)
|
||||||
} else {
|
} else {
|
||||||
sent = 0
|
sent = 0
|
||||||
got, err = client.CreateIssue(*mapping.ToRequest(i, opt), "issue-"+id)
|
got, err = client.CreateIssue(mapping.ToCreate(i, opt), "issue-"+id)
|
||||||
}
|
}
|
||||||
// THE GATE. Below this line a local file is going to be
|
// THE GATE. Below this line a local file is going to be
|
||||||
// deleted, so anything short of a confirmed write stops the
|
// deleted, so anything short of a confirmed write stops the
|
||||||
@@ -274,7 +276,7 @@ and a warning says so.`,
|
|||||||
if !confirmed {
|
if !confirmed {
|
||||||
return Fail("%s: the tracker's answer does not confirm the write "+
|
return Fail("%s: the tracker's answer does not confirm the write "+
|
||||||
"(it carries number %d) — %s is untouched, nothing was deleted",
|
"(it carries number %d) — %s is untouched, nothing was deleted",
|
||||||
id, got.Number, issue.PathOf(root, id))
|
id, got.Index, issue.PathOf(root, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The number is confirmed, so the ledger learns it NOW —
|
// The number is confirmed, so the ledger learns it NOW —
|
||||||
@@ -290,35 +292,30 @@ and a warning says so.`,
|
|||||||
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
|
"written (%v) — %s is untouched", id, number, err, issue.PathOf(root, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea occasionally drops labels handed to it on create, so
|
// THE LABELS THE TRACKER ENDED UP WITH HAVE TO BE THE
|
||||||
// the echo is checked and the set re-applied rather than
|
// ISSUE'S, and two different things leave them disagreeing:
|
||||||
// trusted. A failure here is a warning and not an abort: the
|
//
|
||||||
// issue IS in the tracker, and a run that stopped now would
|
// - a create can DROP labels handed to it, which Gitea has
|
||||||
// leave a file whose `gitea:` field was never written — which
|
// been seen to do, so the echo is checked rather than
|
||||||
// the next push would file all over again as a new issue.
|
// trusted;
|
||||||
applied := map[string]bool{}
|
// - an edit cannot carry labels AT ALL — Gitea's PATCH
|
||||||
for _, l := range got.Labels {
|
// takes none — so a label added or removed locally is
|
||||||
applied[l.Name] = true
|
// not in the answer either way.
|
||||||
}
|
//
|
||||||
var ids []int64
|
// One answer to both: compare the set the tracker echoed
|
||||||
var missing []string
|
// with the set this issue wants, and PUT the whole list when
|
||||||
for _, name := range i.Labels {
|
// they differ. A failure here is a warning and not an abort:
|
||||||
lid, in := labelIDs[name]
|
// the issue IS in the tracker, and a run that stopped now
|
||||||
if !in {
|
// would leave a file whose `gitea:` field was never written
|
||||||
continue
|
// — which the next push would file all over again as a new
|
||||||
}
|
// issue.
|
||||||
ids = append(ids, lid)
|
if drift := pushLabelDrift(got.Labels, i.Labels, labelIDs); len(drift) > 0 {
|
||||||
if !applied[name] {
|
if _, err := client.SetLabels(number, mapping.LabelIDsFor(i, opt), "labels-"+id); err != nil {
|
||||||
missing = append(missing, name)
|
fmt.Fprintf(os.Stderr, "warning: %s: could not set the labels (%s): %v\n",
|
||||||
}
|
id, strings.Join(drift, ", "), err)
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
if _, err := client.SetLabels(number, ids, "labels-"+id); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s: could not re-apply labels (%s): %v\n",
|
|
||||||
id, strings.Join(missing, ", "), err)
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "warning: %s: labels re-applied via PUT (%s)\n",
|
fmt.Fprintf(os.Stderr, "%s: labels set via PUT (%s)\n",
|
||||||
id, strings.Join(missing, ", "))
|
id, strings.Join(drift, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +451,44 @@ func pushLabelIDs(c *gitea.Client, names []string) (map[string]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushLabelDrift is the names on which the tracker's answer and the issue
|
||||||
|
// disagree — what a receipt says when the PUT that follows is made.
|
||||||
|
//
|
||||||
|
// BOTH DIRECTIONS. A wanted label the answer does not carry is one Gitea
|
||||||
|
// dropped or one an edit could not send; a label the answer carries that the
|
||||||
|
// issue no longer wants is one somebody removed locally, and leaving it would
|
||||||
|
// make `push --update` a command that can add a label but never take one off.
|
||||||
|
//
|
||||||
|
// Only labels this run resolved to an id count as wanted: an unknown name was
|
||||||
|
// already left off deliberately, and one the repository holds but the taxonomy
|
||||||
|
// does not know is not this command's to remove either — which is why the
|
||||||
|
// comparison is against the resolved set and not against `labels:` as written.
|
||||||
|
func pushLabelDrift(got []*sdk.Label, want []string, ids map[string]int64) []string {
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, l := range got {
|
||||||
|
if l != nil {
|
||||||
|
have[l.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wanted := map[string]bool{}
|
||||||
|
var drift []string
|
||||||
|
for _, name := range want {
|
||||||
|
if _, resolved := ids[name]; !resolved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wanted[name] = true
|
||||||
|
if !have[name] {
|
||||||
|
drift = append(drift, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range got {
|
||||||
|
if l != nil && !wanted[l.Name] {
|
||||||
|
drift = append(drift, "-"+l.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return drift
|
||||||
|
}
|
||||||
|
|
||||||
// pushDep is what one `depends:` entry is, as far as linking is concerned.
|
// pushDep is what one `depends:` entry is, as far as linking is concerned.
|
||||||
type pushDep struct {
|
type pushDep struct {
|
||||||
// Slug is the dependency as `depends:` spells it.
|
// Slug is the dependency as `depends:` spells it.
|
||||||
@@ -615,14 +650,15 @@ func pushLedgerKeys(m gitea.RemoteMap, repo wire.Repo) map[string]wire.Key {
|
|||||||
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
|
// because none of it gets this far: a non-2xx answer, a body that is not the JSON
|
||||||
// expected, or a `number` that is not a number — the transport fails all three
|
// expected, or a `number` that is not a number — the transport fails all three
|
||||||
// before returning, and the file survives by never reaching the delete.
|
// before returning, and the file survives by never reaching the delete.
|
||||||
func pushConfirmedNumber(got *wire.Issue, sent int) (int, bool) {
|
func pushConfirmedNumber(got *sdk.Issue, sent int) (int, bool) {
|
||||||
if got == nil || got.Number <= 0 {
|
if got == nil || got.Index <= 0 {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
if sent != 0 && got.Number != sent {
|
number := int(got.Index)
|
||||||
|
if sent != 0 && number != sent {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return got.Number, true
|
return number, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushGitBranch is the branch HEAD is on, or "".
|
// pushGitBranch is the branch HEAD is on, or "".
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||||
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/mapping"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,10 +81,9 @@ milestones or labels, or the web UI.`,
|
|||||||
ledger := gitea.LoadRemoteMap(root)
|
ledger := gitea.LoadRemoteMap(root)
|
||||||
repo := client.Repo()
|
repo := client.Repo()
|
||||||
|
|
||||||
for i := range listing.Issues {
|
for _, p := range listing.Issues {
|
||||||
p := &listing.Issues[i]
|
|
||||||
labels := "-"
|
labels := "-"
|
||||||
if names := p.LabelNames(); len(names) > 0 {
|
if names := mapping.LabelNames(p); len(names) > 0 {
|
||||||
labels = strings.Join(names, ", ")
|
labels = strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
// One line per issue is the whole point; a repository that
|
// One line per issue is the whole point; a repository that
|
||||||
@@ -91,8 +91,8 @@ milestones or labels, or the web UI.`,
|
|||||||
if len(labels) > labelColumn {
|
if len(labels) > labelColumn {
|
||||||
labels = labels[:labelColumn]
|
labels = labels[:labelColumn]
|
||||||
}
|
}
|
||||||
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Number, p.State, labels, p.Title)
|
fmt.Printf("#%-5d %-7s %-38s %s\n", p.Index, p.State, labels, p.Title)
|
||||||
if local := ledger.Slug(wire.Key{Repo: repo, Number: p.Number}); local != "" {
|
if local := ledger.Slug(wire.Key{Repo: repo, Number: int(p.Index)}); local != "" {
|
||||||
fmt.Printf("%13s└─ local: %s\n", "", local)
|
fmt.Printf("%13s└─ local: %s\n", "", local)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,17 @@ package cmd_test
|
|||||||
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
|
// KETTLE_URL / KETTLE_TOKEN / KETTLE_REPO, which is also what a CI run does.
|
||||||
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
|
// KETTLE_CONFIG_HOME points at a temp directory so no fixture can read or
|
||||||
// overwrite the developer's own tokens.
|
// overwrite the developer's own tokens.
|
||||||
|
//
|
||||||
|
// The fake answers /api/v1/version before anything else: the SDK asks an
|
||||||
|
// instance what it is before it hands back a client, so a fake that did not
|
||||||
|
// answer would fail every command at startup — and it is that answer the
|
||||||
|
// dependency gate is decided on, which is why it says a version new enough to
|
||||||
|
// have them.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -27,11 +34,27 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pullGiteaVersion is what both fakes in this package claim to be: new enough
|
||||||
|
// for the issue-dependency endpoints, which is what the transport gates on.
|
||||||
|
const pullGiteaVersion = "1.26.1"
|
||||||
|
|
||||||
|
// pullVersionRoute answers the version handshake and reports whether it did.
|
||||||
|
func pullVersionRoute(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if r.URL.Path != "/api/v1/version" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"version":"`+pullGiteaVersion+`"}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// the fake tracker
|
// the fake tracker
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -39,9 +62,9 @@ import (
|
|||||||
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
// pullFakeGitea is a Gitea instance with one repository in it, owner/repo.
|
||||||
type pullFakeGitea struct {
|
type pullFakeGitea struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
issues map[int]*wire.Issue
|
issues map[int]*sdk.Issue
|
||||||
deps map[int][]int
|
deps map[int][]int
|
||||||
comments map[int][]wire.Comment
|
comments map[int][]sdk.Comment
|
||||||
labels map[string]int64
|
labels map[string]int64
|
||||||
next int
|
next int
|
||||||
|
|
||||||
@@ -52,35 +75,35 @@ type pullFakeGitea struct {
|
|||||||
|
|
||||||
func pullNewGitea() *pullFakeGitea {
|
func pullNewGitea() *pullFakeGitea {
|
||||||
return &pullFakeGitea{
|
return &pullFakeGitea{
|
||||||
issues: map[int]*wire.Issue{},
|
issues: map[int]*sdk.Issue{},
|
||||||
deps: map[int][]int{},
|
deps: map[int][]int{},
|
||||||
comments: map[int][]wire.Comment{},
|
comments: map[int][]sdk.Comment{},
|
||||||
labels: map[string]int64{},
|
labels: map[string]int64{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pullAdd puts an issue in the tracker the way the web UI would: it is there
|
// pullAdd puts an issue in the tracker the way the web UI would: it is there
|
||||||
// before this project ever hears about it.
|
// before this project ever hears about it.
|
||||||
func (g *pullFakeGitea) pullAdd(p wire.Issue) {
|
func (g *pullFakeGitea) pullAdd(p sdk.Issue) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
if p.State == "" {
|
if p.State == "" {
|
||||||
p.State = "open"
|
p.State = sdk.StateOpen
|
||||||
}
|
}
|
||||||
p.HTMLURL = pullURL(p.Number)
|
p.HTMLURL = pullURL(int(p.Index))
|
||||||
g.issues[p.Number] = &p
|
g.issues[int(p.Index)] = &p
|
||||||
if p.Number > g.next {
|
if int(p.Index) > g.next {
|
||||||
g.next = p.Number
|
g.next = int(p.Index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *pullFakeGitea) pullIssue(n int) wire.Issue {
|
func (g *pullFakeGitea) pullIssue(n int) sdk.Issue {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
if p := g.issues[n]; p != nil {
|
if p := g.issues[n]; p != nil {
|
||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
return wire.Issue{}
|
return sdk.Issue{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *pullFakeGitea) pullRetitle(n int, title string) {
|
func (g *pullFakeGitea) pullRetitle(n int, title string) {
|
||||||
@@ -108,6 +131,9 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
if pullVersionRoute(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
|
path, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/owner/repo/")
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
http.Error(w, `{"message":"no such repository"}`, http.StatusNotFound)
|
||||||
@@ -116,36 +142,36 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case path == "labels" && r.Method == http.MethodGet:
|
case path == "labels" && r.Method == http.MethodGet:
|
||||||
out := []wire.Label{}
|
out := []sdk.Label{}
|
||||||
for name, id := range g.labels {
|
for name, id := range g.labels {
|
||||||
out = append(out, wire.Label{ID: id, Name: name})
|
out = append(out, sdk.Label{ID: id, Name: name})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
pullJSON(w, out)
|
pullJSON(w, out)
|
||||||
|
|
||||||
case path == "labels" && r.Method == http.MethodPost:
|
case path == "labels" && r.Method == http.MethodPost:
|
||||||
var req wire.LabelRequest
|
var req sdk.CreateLabelOption
|
||||||
pullDecode(r, &req)
|
pullDecode(r, &req)
|
||||||
id := int64(1000 + len(g.labels))
|
id := int64(1000 + len(g.labels))
|
||||||
g.labels[req.Name] = id
|
g.labels[req.Name] = id
|
||||||
pullJSON(w, wire.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
pullJSON(w, sdk.Label{ID: id, Name: req.Name, Color: req.Color, Exclusive: req.Exclusive})
|
||||||
|
|
||||||
case path == "milestones" && r.Method == http.MethodGet:
|
case path == "milestones" && r.Method == http.MethodGet:
|
||||||
pullJSON(w, []wire.Milestone{})
|
pullJSON(w, []sdk.Milestone{})
|
||||||
|
|
||||||
case path == "issues" && r.Method == http.MethodPost:
|
case path == "issues" && r.Method == http.MethodPost:
|
||||||
if g.writesFail {
|
if g.writesFail {
|
||||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req wire.IssueRequest
|
var req sdk.CreateIssueOption
|
||||||
pullDecode(r, &req)
|
pullDecode(r, &req)
|
||||||
g.next++
|
g.next++
|
||||||
p := &wire.Issue{
|
p := &sdk.Issue{
|
||||||
Number: g.next, Title: pullStr(req.Title), Body: pullStr(req.Body),
|
Index: int64(g.next), Title: req.Title, Body: req.Body,
|
||||||
State: "open", HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
State: sdk.StateOpen, HTMLURL: pullURL(g.next), Labels: g.pullLabelsFor(req.Labels),
|
||||||
}
|
}
|
||||||
g.issues[p.Number] = p
|
g.issues[g.next] = p
|
||||||
pullJSON(w, p)
|
pullJSON(w, p)
|
||||||
|
|
||||||
case path == "issues" && r.Method == http.MethodGet:
|
case path == "issues" && r.Method == http.MethodGet:
|
||||||
@@ -163,10 +189,12 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
http.Error(w, `{"message":"the tracker is having a bad day"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req wire.IssueRequest
|
var req sdk.EditIssueOption
|
||||||
pullDecode(r, &req)
|
pullDecode(r, &req)
|
||||||
if req.Title != nil {
|
// An empty title is Gitea's "leave it alone" — the one field of an
|
||||||
p.Title = *req.Title
|
// edit that says so with a zero value rather than with null.
|
||||||
|
if req.Title != "" {
|
||||||
|
p.Title = req.Title
|
||||||
}
|
}
|
||||||
if req.Body != nil {
|
if req.Body != nil {
|
||||||
p.Body = *req.Body
|
p.Body = *req.Body
|
||||||
@@ -174,9 +202,10 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.State != nil {
|
if req.State != nil {
|
||||||
p.State = *req.State
|
p.State = *req.State
|
||||||
}
|
}
|
||||||
if req.Labels != nil {
|
// No labels here on purpose: Gitea's edit endpoint takes none, so
|
||||||
p.Labels = g.pullLabelsFor(req.Labels)
|
// an issue whose labels changed gets them through PUT ./labels
|
||||||
}
|
// below, and a fake that quietly accepted them would hide a push
|
||||||
|
// that never sent them.
|
||||||
}
|
}
|
||||||
pullJSON(w, p)
|
pullJSON(w, p)
|
||||||
|
|
||||||
@@ -185,7 +214,7 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
n, _ := strconv.Atoi(m[1])
|
n, _ := strconv.Atoi(m[1])
|
||||||
switch {
|
switch {
|
||||||
case m[2] == "dependencies" && r.Method == http.MethodGet:
|
case m[2] == "dependencies" && r.Method == http.MethodGet:
|
||||||
out := []wire.Issue{}
|
out := []sdk.Issue{}
|
||||||
for _, d := range g.deps[n] {
|
for _, d := range g.deps[n] {
|
||||||
if p := g.issues[d]; p != nil {
|
if p := g.issues[d]; p != nil {
|
||||||
out = append(out, *p)
|
out = append(out, *p)
|
||||||
@@ -202,15 +231,13 @@ func (g *pullFakeGitea) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
case m[2] == "comments" && r.Method == http.MethodGet:
|
case m[2] == "comments" && r.Method == http.MethodGet:
|
||||||
out := g.comments[n]
|
out := g.comments[n]
|
||||||
if out == nil {
|
if out == nil {
|
||||||
out = []wire.Comment{}
|
out = []sdk.Comment{}
|
||||||
}
|
}
|
||||||
pullJSON(w, out)
|
pullJSON(w, out)
|
||||||
case m[2] == "labels" && r.Method == http.MethodPut:
|
case m[2] == "labels" && r.Method == http.MethodPut:
|
||||||
var req struct {
|
var req sdk.IssueLabelsOption
|
||||||
Labels []int64 `json:"labels"`
|
|
||||||
}
|
|
||||||
pullDecode(r, &req)
|
pullDecode(r, &req)
|
||||||
g.issues[n].Labels = g.pullLabelsFor(&req.Labels)
|
g.issues[n].Labels = g.pullLabelsFor(req.Labels)
|
||||||
pullJSON(w, g.issues[n].Labels)
|
pullJSON(w, g.issues[n].Labels)
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
|
http.Error(w, `{"message":"not implemented"}`, http.StatusNotFound)
|
||||||
@@ -242,10 +269,10 @@ func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sort.Ints(numbers)
|
sort.Ints(numbers)
|
||||||
|
|
||||||
out := []wire.Issue{}
|
out := []sdk.Issue{}
|
||||||
for _, n := range numbers {
|
for _, n := range numbers {
|
||||||
p := g.issues[n]
|
p := g.issues[n]
|
||||||
if state != "" && state != "all" && p.State != state {
|
if state != "" && state != "all" && string(p.State) != state {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
has := map[string]bool{}
|
has := map[string]bool{}
|
||||||
@@ -273,7 +300,7 @@ func (g *pullFakeGitea) pullList(w http.ResponseWriter, r *http.Request) {
|
|||||||
pullJSON(w, out[start:end])
|
pullJSON(w, out[start:end])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
|
func (g *pullFakeGitea) pullLabelsFor(ids []int64) []*sdk.Label {
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -281,10 +308,10 @@ func (g *pullFakeGitea) pullLabelsFor(ids *[]int64) []wire.Label {
|
|||||||
for name, id := range g.labels {
|
for name, id := range g.labels {
|
||||||
byID[id] = name
|
byID[id] = name
|
||||||
}
|
}
|
||||||
var out []wire.Label
|
var out []*sdk.Label
|
||||||
for _, id := range *ids {
|
for _, id := range ids {
|
||||||
if name, ok := byID[id]; ok {
|
if name, ok := byID[id]; ok {
|
||||||
out = append(out, wire.Label{ID: id, Name: name})
|
out = append(out, &sdk.Label{ID: id, Name: name})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -304,13 +331,6 @@ func pullJSON(w http.ResponseWriter, v any) {
|
|||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pullStr(p *string) string {
|
|
||||||
if p == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *p
|
|
||||||
}
|
|
||||||
|
|
||||||
// pullEnv starts the fake and returns the environment that points the binary at
|
// pullEnv starts the fake and returns the environment that points the binary at
|
||||||
// it. The credential home is a temp directory: a test run may neither read nor
|
// it. The credential home is a temp directory: a test run may neither read nor
|
||||||
// overwrite the developer's own tokens.
|
// overwrite the developer's own tokens.
|
||||||
@@ -430,9 +450,9 @@ func TestPushLeavesTheFileWhenTheTrackerRefuses(t *testing.T) {
|
|||||||
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
||||||
dir := newProject(t)
|
dir := newProject(t)
|
||||||
g := pullNewGitea()
|
g := pullNewGitea()
|
||||||
g.pullAdd(wire.Issue{
|
g.pullAdd(sdk.Issue{
|
||||||
Number: 7, Title: "Closed but addressable", State: "closed",
|
Index: 7, Title: "Closed but addressable", State: sdk.StateClosed,
|
||||||
Body: "## Summary\nДело сделано.\n", UpdatedAt: "2026-08-01T10:00:00Z",
|
Body: "## Summary\nДело сделано.\n", Updated: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||||
})
|
})
|
||||||
env := pullEnv(t, g)
|
env := pullEnv(t, g)
|
||||||
|
|
||||||
@@ -456,8 +476,8 @@ func TestPullByNumberFetchesAClosedIssue(t *testing.T) {
|
|||||||
// --no-deps is how you ask for one row of it.
|
// --no-deps is how you ask for one row of it.
|
||||||
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
|
func TestPullBringsTheBlockerDownWithIt(t *testing.T) {
|
||||||
g := pullNewGitea()
|
g := pullNewGitea()
|
||||||
g.pullAdd(wire.Issue{Number: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
g.pullAdd(sdk.Issue{Index: 1, Title: "Migrate the schema", Body: "## Summary\nx\n"})
|
||||||
g.pullAdd(wire.Issue{Number: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
g.pullAdd(sdk.Issue{Index: 2, Title: "Wire sqlc into the layer", Body: "## Summary\nx\n"})
|
||||||
g.pullBlocks(2, 1)
|
g.pullBlocks(2, 1)
|
||||||
env := pullEnv(t, g)
|
env := pullEnv(t, g)
|
||||||
|
|
||||||
@@ -549,9 +569,9 @@ func TestAPushedIssueComesBackUnderItsOriginalSlug(t *testing.T) {
|
|||||||
// it.
|
// it.
|
||||||
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
||||||
g := pullNewGitea()
|
g := pullNewGitea()
|
||||||
bug := []wire.Label{{ID: 1, Name: "type/bug"}}
|
bug := []*sdk.Label{{ID: 1, Name: "type/bug"}}
|
||||||
g.pullAdd(wire.Issue{Number: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
g.pullAdd(sdk.Issue{Index: 1, Title: "Still broken", Body: "## Summary\nx\n", Labels: bug})
|
||||||
g.pullAdd(wire.Issue{Number: 2, Title: "Fixed last week", State: "closed",
|
g.pullAdd(sdk.Issue{Index: 2, Title: "Fixed last week", State: sdk.StateClosed,
|
||||||
Body: "## Summary\nx\n", Labels: bug})
|
Body: "## Summary\nx\n", Labels: bug})
|
||||||
env := pullEnv(t, g)
|
env := pullEnv(t, g)
|
||||||
|
|
||||||
@@ -581,10 +601,10 @@ func TestPullFilterModeLeavesClosedIssuesOut(t *testing.T) {
|
|||||||
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
|
func TestPushUpdateDropsTheLocalCopyAsWell(t *testing.T) {
|
||||||
dir := newProject(t)
|
dir := newProject(t)
|
||||||
g := pullNewGitea()
|
g := pullNewGitea()
|
||||||
g.pullAdd(wire.Issue{
|
g.pullAdd(sdk.Issue{
|
||||||
Number: 3, Title: "Came down and went back up",
|
Index: 3, Title: "Came down and went back up",
|
||||||
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
Body: "## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n",
|
||||||
Labels: []wire.Label{{ID: 1, Name: "type/task"}},
|
Labels: []*sdk.Label{{ID: 1, Name: "type/task"}},
|
||||||
})
|
})
|
||||||
env := pullEnv(t, g)
|
env := pullEnv(t, g)
|
||||||
|
|
||||||
@@ -637,8 +657,8 @@ func TestPushDryRunNeedsNoCredential(t *testing.T) {
|
|||||||
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
|
func TestRemoteListsWithoutWritingAnything(t *testing.T) {
|
||||||
dir := newProject(t)
|
dir := newProject(t)
|
||||||
g := pullNewGitea()
|
g := pullNewGitea()
|
||||||
g.pullAdd(wire.Issue{Number: 4, Title: "Something open", Body: "x"})
|
g.pullAdd(sdk.Issue{Index: 4, Title: "Something open", Body: "x"})
|
||||||
g.pullAdd(wire.Issue{Number: 5, Title: "Something closed", State: "closed", Body: "x"})
|
g.pullAdd(sdk.Issue{Index: 5, Title: "Something closed", State: sdk.StateClosed, Body: "x"})
|
||||||
env := pullEnv(t, g)
|
env := pullEnv(t, g)
|
||||||
|
|
||||||
r := runWith(t, dir, env, "", "remote")
|
r := runWith(t, dir, env, "", "remote")
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ func wrNewTracker() *wrTracker {
|
|||||||
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
tr.mu.Lock()
|
tr.mu.Lock()
|
||||||
defer tr.mu.Unlock()
|
defer tr.mu.Unlock()
|
||||||
|
|
||||||
|
// The version handshake, answered before the log: it is not a call any
|
||||||
|
// command made, and counting it would make every "how many requests did
|
||||||
|
// that send" assertion in this file one out.
|
||||||
|
if pullVersionRoute(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
|
tr.calls = append(tr.calls, r.Method+" "+r.URL.Path)
|
||||||
|
|
||||||
// The scheme Gitea uses and the client sends: the word `token`.
|
// The scheme Gitea uses and the client sends: the word `token`.
|
||||||
@@ -150,13 +157,21 @@ func (tr *wrTracker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.Method == http.MethodPatch {
|
if r.Method == http.MethodPatch {
|
||||||
var req struct {
|
var req struct {
|
||||||
State *string `json:"state"`
|
State *string `json:"state"`
|
||||||
Title *string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Body *string `json:"body"`
|
||||||
}
|
}
|
||||||
wrDecode(r, &req)
|
wrDecode(r, &req)
|
||||||
// A close is state and nothing else; a title arriving here would be
|
// A close is state and nothing else; a title or a body arriving
|
||||||
// the command editing an issue it was only asked to close.
|
// here would be the command editing an issue it was only asked to
|
||||||
if req.Title != nil {
|
// close.
|
||||||
http.Error(w, `{"message":"close sent a title"}`, http.StatusUnprocessableEntity)
|
//
|
||||||
|
// A title of "" is not one. The SDK's edit body has no pointer
|
||||||
|
// there and sends the key whatever happens, so an empty string is
|
||||||
|
// how "no opinion" is spelled for this one field — which is also
|
||||||
|
// how Gitea itself reads it, and Gitea is what this stands in for.
|
||||||
|
// Everything else is a pointer and must arrive as null.
|
||||||
|
if req.Title != "" || req.Body != nil {
|
||||||
|
http.Error(w, `{"message":"close sent more than a state"}`, http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.State != nil {
|
if req.State != nil {
|
||||||
|
|||||||
+273
-151
@@ -10,17 +10,25 @@
|
|||||||
// that layer is not imported here either: it sits above this package, not
|
// that layer is not imported here either: it sits above this package, not
|
||||||
// beside it.
|
// beside it.
|
||||||
//
|
//
|
||||||
// The JSON shapes and the issue keys are internal/wire's. They are not this
|
// The payload shapes are code.gitea.io/sdk/gitea's, aliased `sdk` everywhere it
|
||||||
// package's to own, because the bridge needs exactly the same vocabulary and
|
// is imported so that one type has one spelling across the tree. They are not
|
||||||
// cannot import a transport to get it; a copy on each side is two structs that
|
// this package's to own and never were: the bridge needs exactly the same
|
||||||
// drift and a command that copies fields between them by hand.
|
// vocabulary and cannot import a transport to get it, and a copy on each side
|
||||||
|
// is two structs that drift and a command that copies fields between them by
|
||||||
|
// hand. The issue KEYS are still internal/wire's — the SDK addresses an issue
|
||||||
|
// as (owner, repo, int64) and never parses `owner/repo#42` out of anything.
|
||||||
//
|
//
|
||||||
// Every request goes through Call. One place sets the header, one place reads
|
// WHAT THIS PACKAGE IS, NOW THAT THE SDK EXISTS: the one place that holds the
|
||||||
// a status code, one place files the request body. When this was a Python
|
// credentials, the scratchpad and the repository this project points at, so
|
||||||
// module shelling out to `tea api`, "why did that fail" meant reading a
|
// that no command has to. Every method here is a thin wrapper, and the three
|
||||||
// subprocess's stderr and guessing; here a failure is an *APIError carrying the
|
// things the wrapping is for are the three things the SDK does not do:
|
||||||
// status AND the body the server actually sent, because "500" on its own has
|
//
|
||||||
// never helped anybody.
|
// - every request body is filed under `.kettle/payload/` by a RoundTripper,
|
||||||
|
// so a retry or a post-mortem has the bytes that went out;
|
||||||
|
// - every failure comes back as an *APIError carrying the status AND what the
|
||||||
|
// server said, because "500" on its own has never helped anybody;
|
||||||
|
// - a listing stops when the caller has what it asked for, which a client
|
||||||
|
// that fetches whole pages into a slice cannot do.
|
||||||
package gitea
|
package gitea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,21 +38,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// apiPrefix is where every Gitea instance puts its REST API.
|
|
||||||
apiPrefix = "/api/v1"
|
|
||||||
// userAgent names this binary in the server's log. A tracker admin looking
|
// userAgent names this binary in the server's log. A tracker admin looking
|
||||||
// at a burst of requests should be able to tell what made them.
|
// at a burst of requests should be able to tell what made them.
|
||||||
userAgent = "kettle"
|
userAgent = "kettle"
|
||||||
@@ -59,25 +67,28 @@ const (
|
|||||||
|
|
||||||
// Client talks to one repository on one Gitea instance.
|
// Client talks to one repository on one Gitea instance.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
// HTTP is the transport, exported so a caller can change the timeout or
|
// api is the SDK client: one per run, shared by every copy For makes.
|
||||||
// hand in an instrumented one. Never nil after New.
|
api *sdk.Client
|
||||||
HTTP *http.Client
|
// http is the SDK's transport, kept because AddDependency still sends one
|
||||||
|
// request by hand — see there.
|
||||||
|
http *http.Client
|
||||||
|
// dump is the RoundTripper that files request bodies. Shared with every
|
||||||
|
// copy For makes, because the scratchpad is one directory per run.
|
||||||
|
dump *dumper
|
||||||
|
|
||||||
base string // instance URL with the API prefix, no trailing slash
|
base string // instance URL, no API prefix and no trailing slash
|
||||||
token string
|
token string
|
||||||
repo wire.Repo
|
repo wire.Repo
|
||||||
|
|
||||||
// payloadRoot is resolved once, by New, and is never taken from a caller.
|
|
||||||
// The one time where a request body lands was an argument, it got pointed
|
|
||||||
// at the issue store — see writePayload.
|
|
||||||
payloadRoot string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a client for the repository this project points at.
|
// New builds a client for the repository this project points at.
|
||||||
//
|
//
|
||||||
// It refuses a half-filled configuration instead of letting the first call come
|
// It refuses a half-filled configuration instead of letting the first call come
|
||||||
// back 401 or 404: those answers name nothing an operator can act on, and every
|
// back 401 or 404: those answers name nothing an operator can act on, and every
|
||||||
// field missing here has exactly one command that supplies it.
|
// field missing here has exactly one command that supplies it. That check comes
|
||||||
|
// first because building the client now DIALS — the SDK asks the instance for
|
||||||
|
// its version before it hands one back — and a missing token reported as a
|
||||||
|
// connection failure sends the operator to the wrong place.
|
||||||
func New(cfg *config.Resolved) (*Client, error) {
|
func New(cfg *config.Resolved) (*Client, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, errors.New("no resolved configuration — call config.Require first")
|
return nil, errors.New("no resolved configuration — call config.Require first")
|
||||||
@@ -95,98 +106,61 @@ func New(cfg *config.Resolved) (*Client, error) {
|
|||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base := strings.TrimRight(cfg.URL, "/")
|
||||||
|
dump := &dumper{next: http.DefaultTransport, root: project.PayloadRoot("")}
|
||||||
|
hc := &http.Client{Timeout: requestTimeout, Transport: dump}
|
||||||
|
|
||||||
|
api, err := sdk.NewClient(base,
|
||||||
|
sdk.SetToken(cfg.Token),
|
||||||
|
sdk.SetHTTPClient(hc),
|
||||||
|
sdk.SetUserAgent(userAgent))
|
||||||
|
if err != nil {
|
||||||
|
// A version string the SDK cannot parse is not a cosmetic failure, and
|
||||||
|
// it is refused here rather than shrugged off: the SDK hands back a
|
||||||
|
// usable-looking client that has quietly decided the server is Gitea
|
||||||
|
// 1.11, and its 1.11 compatibility path rewrites an issue's URL from a
|
||||||
|
// `repository` field a modern payload need not carry — a nil
|
||||||
|
// dereference on the first issue read. Saying so at the handshake beats
|
||||||
|
// crashing three calls later.
|
||||||
|
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
|
||||||
|
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
|
||||||
|
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
|
||||||
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
HTTP: &http.Client{Timeout: requestTimeout},
|
api: api,
|
||||||
base: strings.TrimRight(cfg.URL, "/") + apiPrefix,
|
http: hc,
|
||||||
token: cfg.Token,
|
dump: dump,
|
||||||
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
|
base: base,
|
||||||
payloadRoot: project.PayloadRoot(""),
|
token: cfg.Token,
|
||||||
|
repo: wire.Repo{Owner: cfg.Owner, Name: cfg.Repo},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repo is the repository every path is built against.
|
// Repo is the repository every call is made against.
|
||||||
func (c *Client) Repo() wire.Repo { return c.repo }
|
func (c *Client) Repo() wire.Repo { return c.repo }
|
||||||
|
|
||||||
// For returns a copy of this client pointed at another repository, for the run
|
// For returns a copy of this client pointed at another repository, for the run
|
||||||
// that was given an explicit owner/name. The credentials and the scratchpad
|
// that was given an explicit owner/name.
|
||||||
// come along; only the paths change.
|
//
|
||||||
|
// Bookkeeping and not a second connection: the SDK takes the owner and the name
|
||||||
|
// per call, so what changes is which pair this client passes. The credentials,
|
||||||
|
// the negotiated server version and the scratchpad are all shared, which is
|
||||||
|
// what makes `kettle pull owner/repo#42` cost nothing extra.
|
||||||
func (c *Client) For(r wire.Repo) *Client {
|
func (c *Client) For(r wire.Repo) *Client {
|
||||||
out := *c
|
out := *c
|
||||||
out.repo = r
|
out.repo = r
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body is a request payload and the name its dump is filed under.
|
// owned is the owner and name every SDK call takes, escaped by the SDK itself.
|
||||||
//
|
func (c *Client) owned() (string, string) { return c.repo.Owner, c.repo.Name }
|
||||||
// The name is the caller's label for this call, not a path: it becomes
|
|
||||||
// `<name>.json` in the scratchpad, and something that identifies the call in a
|
|
||||||
// post-mortem — an issue's slug, a label's name — is worth more there than a
|
|
||||||
// serial number.
|
|
||||||
type Body struct {
|
|
||||||
Name string
|
|
||||||
Data any
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call makes one request and decodes the answer into out, which may be nil when
|
// --------------------------------------------------------------------------
|
||||||
// there is nothing to read.
|
// what a failure says
|
||||||
//
|
// --------------------------------------------------------------------------
|
||||||
// body may be nil. When it is not, its Data is marshalled once: the bytes filed
|
|
||||||
// in the scratchpad and the bytes on the wire are the same bytes, so a retry
|
|
||||||
// from the file sends what this call sent.
|
|
||||||
//
|
|
||||||
// An empty response body leaves out untouched — a 204 from a PATCH is a
|
|
||||||
// success, not a decode failure.
|
|
||||||
func (c *Client) Call(method, path string, body *Body, out any) error {
|
|
||||||
var payload []byte
|
|
||||||
if body != nil {
|
|
||||||
var err error
|
|
||||||
if payload, err = c.writePayload(body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := c.base + "/" + strings.TrimLeft(path, "/")
|
|
||||||
var reader io.Reader
|
|
||||||
if payload != nil {
|
|
||||||
reader = bytes.NewReader(payload)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, endpoint, reader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s %s: %w", method, endpoint, err)
|
|
||||||
}
|
|
||||||
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
|
||||||
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
if payload != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
// The token travels in a header and never in the URL, so an error is
|
|
||||||
// free to quote the URL in full.
|
|
||||||
return fmt.Errorf("%s %s: %w", method, endpoint, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s %s: reading the response: %w", method, endpoint, err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
||||||
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(raw)}
|
|
||||||
}
|
|
||||||
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, out); err != nil {
|
|
||||||
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected (%w): %s",
|
|
||||||
method, endpoint, resp.StatusCode, err, truncate(string(raw)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// APIError is a non-2xx answer, carrying both halves of what happened.
|
// APIError is a non-2xx answer, carrying both halves of what happened.
|
||||||
//
|
//
|
||||||
@@ -223,6 +197,36 @@ func StatusIs(err error, status int) bool {
|
|||||||
return errors.As(err, &apiErr) && apiErr.Status == status
|
return errors.As(err, &apiErr) && apiErr.Status == status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fail turns one SDK call's (response, error) pair into this package's error.
|
||||||
|
//
|
||||||
|
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error
|
||||||
|
// and then closes it, so the body is only ever available through err; the
|
||||||
|
// status and the request line are only ever available through resp. Neither is
|
||||||
|
// a diagnosis on its own, and dropping either is how "the tracker said no"
|
||||||
|
// becomes a message nobody can act on.
|
||||||
|
//
|
||||||
|
// A 2xx that still errored is a decode failure, not an answer the server
|
||||||
|
// refused: it keeps the status out of the message and the shape out of
|
||||||
|
// StatusIs, because a caller asking "was that a 409" must not be told yes by a
|
||||||
|
// body it could not parse.
|
||||||
|
func fail(resp *sdk.Response, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp == nil || resp.Response == nil {
|
||||||
|
return err // never reached the server; the URL is already in the error
|
||||||
|
}
|
||||||
|
method, endpoint := "", ""
|
||||||
|
if r := resp.Request; r != nil {
|
||||||
|
method, endpoint = r.Method, r.URL.String()
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return &APIError{Method: method, URL: endpoint, Status: resp.StatusCode, Body: err.Error()}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s: %d answered with what is not the JSON expected: %w",
|
||||||
|
method, endpoint, resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string) string {
|
func truncate(s string) string {
|
||||||
if len(s) <= maxErrorBody {
|
if len(s) <= maxErrorBody {
|
||||||
return s
|
return s
|
||||||
@@ -240,11 +244,17 @@ func truncate(s string) string {
|
|||||||
// where request bodies land
|
// where request bodies land
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
// writePayload marshals a request body, files a copy under `.kettle/payload/`,
|
// dumper is the RoundTripper that files a copy of every request body under
|
||||||
// and returns the bytes to send.
|
// `.kettle/payload/`.
|
||||||
//
|
//
|
||||||
// The file survives the call, for a retry or a post-mortem.
|
// The file survives the call, for a retry or a post-mortem.
|
||||||
//
|
//
|
||||||
|
// A RoundTripper and not a call site's decision, because a call site can forget
|
||||||
|
// and a RoundTripper cannot: it sees every request the SDK builds, including
|
||||||
|
// the ones no method of this package spelled out. What a call site still
|
||||||
|
// supplies is the NAME — see label — because an issue's slug identifies the
|
||||||
|
// call in a post-mortem and a serial number does not.
|
||||||
|
//
|
||||||
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
|
// WHERE IT LANDS IS NOT THE CALLER'S BUSINESS, and never was. The directory is
|
||||||
// this package's scratchpad — a SIBLING of the issue store under the same
|
// this package's scratchpad — a SIBLING of the issue store under the same
|
||||||
// marker, resolved by the same walk, so which command wrote a body cannot
|
// marker, resolved by the same walk, so which command wrote a body cannot
|
||||||
@@ -255,58 +265,179 @@ func truncate(s string) string {
|
|||||||
// are debris of the transport, and when they share a path `ls` starts lying
|
// are debris of the transport, and when they share a path `ls` starts lying
|
||||||
// about what the project holds.
|
// about what the project holds.
|
||||||
//
|
//
|
||||||
// It is created lazily, by the first write of a run and only then, so a dry run
|
// It is created lazily, by the first write of a run and only then, so a run
|
||||||
// or a run with nothing to send leaves no directory behind.
|
// with nothing to send — every read-only command, and the version handshake
|
||||||
func (c *Client) writePayload(b *Body) ([]byte, error) {
|
// every run opens with — leaves no directory behind.
|
||||||
if c.payloadRoot == "" {
|
type dumper struct {
|
||||||
return nil, project.NotFoundError("")
|
next http.RoundTripper
|
||||||
|
// root is resolved once, by New, and is never taken from a caller.
|
||||||
|
root string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// label names the file the next request body lands in. One label serves one
|
||||||
|
// request: it is taken, not read, so a request the SDK makes on its own account
|
||||||
|
// cannot end up filed under the last thing a command was doing.
|
||||||
|
func (d *dumper) label(name string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.name = name
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dumper) take() string {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
name := d.name
|
||||||
|
d.name = ""
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundTrip files the body and then sends the request.
|
||||||
|
//
|
||||||
|
// A dump that cannot be written fails the call before it is made, which is the
|
||||||
|
// order the old hand-rolled client had and worth keeping: the point of the file
|
||||||
|
// is to hold what was sent, and one that does not exist for a request that did
|
||||||
|
// is worse than not having sent it.
|
||||||
|
func (d *dumper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if err := d.file(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d.next.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dumper) file(req *http.Request) error {
|
||||||
|
name := d.take()
|
||||||
|
if req.Body == nil || req.GetBody == nil {
|
||||||
|
return nil // a read: nothing to file
|
||||||
|
}
|
||||||
|
body, err := req.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
raw, err := io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(raw)) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.root == "" {
|
||||||
|
return project.NotFoundError("")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = derivedName(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(d.root, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(d.root, safeName(name)+".json"), readable(raw), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readable is the request body as a person reads it: indented, and with the
|
||||||
|
// markup left alone.
|
||||||
|
//
|
||||||
|
// The SDK marshals with encoding/json's defaults, which escape `<`, `>` and `&`
|
||||||
|
// into their \u00xx spellings. An issue body carries `<!-- … -->` markers and
|
||||||
|
// prose full of `&`, and a dump escaped that way is unreadable exactly when
|
||||||
|
// somebody is reading it because something went wrong.
|
||||||
|
//
|
||||||
|
// So the bytes are re-encoded rather than filed verbatim: same JSON VALUE, and
|
||||||
|
// numbers verbatim (UseNumber, so an id is not rounded through a float), but
|
||||||
|
// not the same bytes. What that costs is byte-for-byte fidelity with the wire —
|
||||||
|
// what it buys is a file anybody can read and re-POST. Anything that will not
|
||||||
|
// parse is filed as it came, because a dump of something surprising is exactly
|
||||||
|
// the dump worth having.
|
||||||
|
func readable(raw []byte) []byte {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
var v any
|
||||||
|
if err := dec.Decode(&v); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
enc := json.NewEncoder(buf)
|
enc := json.NewEncoder(buf)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
// An issue body carries `<!-- … -->` markers and prose full of `&`.
|
|
||||||
// Escaping those to < would make the dump unreadable exactly when
|
|
||||||
// somebody is reading it because something went wrong.
|
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
if err := enc.Encode(b.Data); err != nil {
|
if err := enc.Encode(v); err != nil {
|
||||||
return nil, fmt.Errorf("encoding the %s request body: %w", b.name(), err)
|
return raw
|
||||||
}
|
}
|
||||||
raw := buf.Bytes()
|
return buf.Bytes()
|
||||||
|
|
||||||
if err := os.MkdirAll(c.payloadRoot, 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
path := filepath.Join(c.payloadRoot, b.name()+".json")
|
|
||||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return raw, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// name is the file stem, with everything that is not plainly a file name folded
|
// derivedName is what an unnamed request is filed under: its method and its
|
||||||
// away.
|
// path. Nobody has to remember to name a call for its body to be kept — a name
|
||||||
|
// only makes the file easier to find.
|
||||||
|
func derivedName(req *http.Request) string {
|
||||||
|
return strings.ToLower(req.Method) + "-" + strings.TrimPrefix(req.URL.Path, "/api/v1/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeName is the file stem, with everything that is not plainly a file name
|
||||||
|
// folded away.
|
||||||
//
|
//
|
||||||
// Sanitizing here rather than trusting callers: label names are namespaced
|
// Sanitizing here rather than trusting callers: label names are namespaced
|
||||||
// (`type/bug`), and a name passed straight through would write outside the
|
// (`type/bug`), and a name passed straight through would write outside the
|
||||||
// scratchpad — which is the one thing this directory exists to prevent.
|
// scratchpad — which is the one thing this directory exists to prevent.
|
||||||
func (b *Body) name() string {
|
func safeName(name string) string {
|
||||||
if b.Name == "" {
|
|
||||||
return "request"
|
|
||||||
}
|
|
||||||
safe := strings.Map(func(r rune) rune {
|
safe := strings.Map(func(r rune) rune {
|
||||||
switch {
|
switch {
|
||||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
return '-'
|
return '-'
|
||||||
}, b.Name)
|
}, name)
|
||||||
if safe = strings.Trim(safe, "-"); safe == "" {
|
if safe = strings.Trim(safe, "-"); safe == "" {
|
||||||
return "request"
|
return "request"
|
||||||
}
|
}
|
||||||
return safe
|
return safe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// the one request the SDK cannot express
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// post sends one JSON body to a path under this instance's API and ignores
|
||||||
|
// whatever comes back.
|
||||||
|
//
|
||||||
|
// It exists for AddDependency and for nothing else — see there for what the SDK
|
||||||
|
// leaves out. It goes through the same http.Client, so the body is filed and a
|
||||||
|
// failure carries the status and the server's words exactly as every other call
|
||||||
|
// in this package does.
|
||||||
|
func (c *Client) post(path string, body any, name string) error {
|
||||||
|
raw, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Gitea's own scheme, and what the `tea` CLI this replaces sent: the word
|
||||||
|
// `token`, not `Bearer`. An instance answers 401 to the other spelling.
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
c.dump.label(name)
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// The token travels in a header and never in the URL, so an error is
|
||||||
|
// free to quote the URL in full.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
answer, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return &APIError{Method: http.MethodPost, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// pagination
|
// pagination
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -327,21 +458,19 @@ const (
|
|||||||
PageSlack = 4
|
PageSlack = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// pages GETs a list endpoint page by page and hands each page to each as it
|
// pages calls fetch page by page and hands each page to each as it arrives,
|
||||||
// arrives, stopping when each returns false, when a short page says the list is
|
// stopping when each returns false, when a short page says the list is
|
||||||
// exhausted, or when budget pages have been read.
|
// exhausted, or when budget pages have been read.
|
||||||
//
|
//
|
||||||
// A callback rather than a slice, because a caller whose budget is spent on
|
// A callback rather than a slice, because a caller whose budget is spent on
|
||||||
// what it KEEPS cannot be served by a function that fetches everything first:
|
// what it KEEPS cannot be served by a function that fetches everything first:
|
||||||
// the page after the one that completed the budget must never be requested.
|
// the page after the one that completed the budget must never be requested.
|
||||||
func pages[T any](c *Client, path string, limit, budget int, each func([]T) (bool, error)) error {
|
// That is the one thing the SDK's own list options cannot do — they describe a
|
||||||
sep := "?"
|
// page, and this describes when to stop asking for another.
|
||||||
if strings.Contains(path, "?") {
|
func pages[T any](fetch func(page, limit int) ([]T, error), limit, budget int, each func([]T) (bool, error)) error {
|
||||||
sep = "&"
|
|
||||||
}
|
|
||||||
for page := 1; page <= budget; page++ {
|
for page := 1; page <= budget; page++ {
|
||||||
var batch []T
|
batch, err := fetch(page, limit)
|
||||||
if err := c.Call(http.MethodGet, fmt.Sprintf("%s%spage=%d&limit=%d", path, sep, page, limit), nil, &batch); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
@@ -359,23 +488,16 @@ func pages[T any](c *Client, path string, limit, budget int, each func([]T) (boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// paginate follows a list endpoint to exhaustion and returns the whole list.
|
// paginate follows a list endpoint to exhaustion and returns the whole list.
|
||||||
func paginate[T any](c *Client, path string, limit int) ([]T, error) {
|
func paginate[T any](fetch func(page, limit int) ([]T, error), limit int) ([]T, error) {
|
||||||
var out []T
|
var out []T
|
||||||
err := pages(c, path, limit, maxPages, func(batch []T) (bool, error) {
|
err := pages(fetch, limit, maxPages, func(batch []T) (bool, error) {
|
||||||
out = append(out, batch...)
|
out = append(out, batch...)
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// repoPath builds an endpoint under this client's repository. Owner and name
|
// listOptions is one page, as the SDK asks for it.
|
||||||
// are escaped: they arrive from a config file, and a file is a thing people
|
func listOptions(page, limit int) sdk.ListOptions {
|
||||||
// type into.
|
return sdk.ListOptions{Page: page, PageSize: limit}
|
||||||
func (c *Client) repoPath(suffix string) string {
|
|
||||||
return "repos/" + url.PathEscape(c.repo.Owner) + "/" + url.PathEscape(c.repo.Name) + "/" + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
// repoPathf is repoPath with the issue or label number formatted in.
|
|
||||||
func (c *Client) repoPathf(format string, args ...any) string {
|
|
||||||
return c.repoPath(fmt.Sprintf(format, args...))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ package gitea_test
|
|||||||
// — and a request dump would land in the developer's own project. Nothing here
|
// — and a request dump would land in the developer's own project. Nothing here
|
||||||
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
|
// reads a login file either, but KETTLE_CONFIG_HOME is redirected all the same,
|
||||||
// so a run can neither read nor overwrite the developer's own tokens.
|
// so a run can neither read nor overwrite the developer's own tokens.
|
||||||
|
//
|
||||||
|
// EVERY FAKE ANSWERS /api/v1/version, because building a client is now a
|
||||||
|
// request: the SDK asks the instance what it is before it hands one back, and
|
||||||
|
// that answer is what the dependency gate is decided on later. A fake that did
|
||||||
|
// not answer it would be a fake no client can be built against — see
|
||||||
|
// versionRoute.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -19,15 +25,22 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// modernGitea is what a fake says it is: new enough for everything this
|
||||||
|
// transport asks for, dependency endpoints included.
|
||||||
|
const modernGitea = "1.26.1"
|
||||||
|
|
||||||
// newProject makes an initialized project and points the walk at it. Returns
|
// newProject makes an initialized project and points the walk at it. Returns
|
||||||
// the project root.
|
// the project root.
|
||||||
func newProject(t *testing.T) string {
|
func newProject(t *testing.T) string {
|
||||||
@@ -41,6 +54,31 @@ func newProject(t *testing.T) string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// versionRoute answers the SDK's version handshake and reports whether it did,
|
||||||
|
// so every other handler can be written as though the request were not there.
|
||||||
|
func versionRoute(w http.ResponseWriter, r *http.Request, version string) bool {
|
||||||
|
if r.URL.Path != "/api/v1/version" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"version":"`+version+`"}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve is an httptest server that speaks the handshake and hands everything
|
||||||
|
// else to next.
|
||||||
|
func serve(t *testing.T, version string, next http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if versionRoute(w, r, version) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
func newClient(t *testing.T, url string) *gitea.Client {
|
func newClient(t *testing.T, url string) *gitea.Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
|
c, err := gitea.New(&config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"})
|
||||||
@@ -65,7 +103,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
|||||||
|
|
||||||
var asked []string
|
var asked []string
|
||||||
var auth string
|
var auth string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
asked = append(asked, r.URL.RequestURI())
|
asked = append(asked, r.URL.RequestURI())
|
||||||
auth = r.Header.Get("Authorization")
|
auth = r.Header.Get("Authorization")
|
||||||
|
|
||||||
@@ -82,8 +120,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
|||||||
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
|
out = append(out, map[string]any{"id": (page-1)*limit + i + 1, "body": "hello"})
|
||||||
}
|
}
|
||||||
writeJSON(t, w, out)
|
writeJSON(t, w, out)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := newClient(t, srv.URL).ListComments(42)
|
got, err := newClient(t, srv.URL).ListComments(42)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,7 +139,7 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
|||||||
if auth != "token s3cret" {
|
if auth != "token s3cret" {
|
||||||
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
|
t.Errorf("Authorization was %q, want %q", auth, "token s3cret")
|
||||||
}
|
}
|
||||||
want := "/api/v1/repos/acme/widgets/issues/42/comments?page=1&limit=50"
|
want := "/api/v1/repos/acme/widgets/issues/42/comments?limit=50&page=1"
|
||||||
if asked[0] != want {
|
if asked[0] != want {
|
||||||
t.Errorf("first request was %s, want %s", asked[0], want)
|
t.Errorf("first request was %s, want %s", asked[0], want)
|
||||||
}
|
}
|
||||||
@@ -113,11 +150,10 @@ func TestPaginationFollowsToTheLastPage(t *testing.T) {
|
|||||||
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
|
func TestErrorNamesTheStatusAndTheBody(t *testing.T) {
|
||||||
newProject(t)
|
newProject(t)
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
|
io.WriteString(w, `{"message":"label already exists","url":"https://example.test/docs"}`)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
_, err := newClient(t, srv.URL).GetIssue(7)
|
_, err := newClient(t, srv.URL).GetIssue(7)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -152,21 +188,20 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
|||||||
root := newProject(t)
|
root := newProject(t)
|
||||||
|
|
||||||
var sent []byte
|
var sent []byte
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
sent, _ = io.ReadAll(r.Body)
|
sent, _ = io.ReadAll(r.Body)
|
||||||
writeJSON(t, w, map[string]any{
|
writeJSON(t, w, map[string]any{
|
||||||
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
|
"number": 42, "id": 5, "html_url": "https://example.test/acme/widgets/issues/42"})
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
body := "<!-- kettle:id wire-sqlc --> a & b"
|
body := "<!-- kettle:id wire-sqlc --> a & b"
|
||||||
got, err := newClient(t, srv.URL).CreateIssue(
|
got, err := newClient(t, srv.URL).CreateIssue(
|
||||||
wire.IssueRequest{Title: wire.Set("wire sqlc"), Body: wire.Set(body)}, "issue-wire-sqlc")
|
sdk.CreateIssueOption{Title: "wire sqlc", Body: body}, "issue-wire-sqlc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateIssue: %v", err)
|
t.Fatalf("CreateIssue: %v", err)
|
||||||
}
|
}
|
||||||
if got.Number != 42 {
|
if got.Index != 42 {
|
||||||
t.Errorf("got issue #%d, want #42", got.Number)
|
t.Errorf("got issue #%d, want #42", got.Index)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
|
path := filepath.Join(root, ".kettle", "payload", "issue-wire-sqlc.json")
|
||||||
@@ -174,19 +209,33 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the request body was not filed at %s: %v", path, err)
|
t.Fatalf("the request body was not filed at %s: %v", path, err)
|
||||||
}
|
}
|
||||||
if string(raw) != string(sent) {
|
// The same JSON VALUE as went out, and not the same bytes: the SDK marshals
|
||||||
|
// with encoding/json's defaults, so what is on the wire has its markup
|
||||||
|
// escaped and no indentation. A dump nobody can read is a dump nobody
|
||||||
|
// reads, and a re-POST of this file sends what this call sent.
|
||||||
|
var filed, onTheWire any
|
||||||
|
if err := json.Unmarshal(raw, &filed); err != nil {
|
||||||
|
t.Fatalf("the filed body is not JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(sent, &onTheWire); err != nil {
|
||||||
|
t.Fatalf("what was sent is not JSON: %v\n%s", err, sent)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(filed, onTheWire) {
|
||||||
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
|
t.Errorf("the filed body is not the body that was sent:\nfiled: %s\nsent: %s", raw, sent)
|
||||||
}
|
}
|
||||||
// A dump escaped to \u003c is unreadable exactly when it is being read.
|
// A dump escaped to \u003c is unreadable exactly when it is being read.
|
||||||
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
|
if !strings.Contains(string(raw), "<!-- kettle:id wire-sqlc --> a & b") {
|
||||||
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
|
t.Errorf("the dump escaped the markup it was meant to preserve:\n%s", raw)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(raw), "\n \"") {
|
||||||
|
t.Errorf("the dump is not indented:\n%s", raw)
|
||||||
|
}
|
||||||
// The whole reason the scratchpad is a sibling.
|
// The whole reason the scratchpad is a sibling.
|
||||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "issues")); !os.IsNotExist(err) {
|
||||||
t.Errorf("writing a request body materialized the issue store (%v)", err)
|
t.Errorf("writing a request body materialized the issue store (%v)", err)
|
||||||
}
|
}
|
||||||
// A namespaced name must not climb out of the scratchpad.
|
// A namespaced name must not climb out of the scratchpad.
|
||||||
if _, err := newClient(t, srv.URL).CreateLabel(wire.LabelRequest{Name: "type/bug", Color: "#ee0701"}); err != nil {
|
if _, err := newClient(t, srv.URL).CreateLabel(sdk.CreateLabelOption{Name: "type/bug", Color: "#ee0701"}); err != nil {
|
||||||
t.Fatalf("CreateLabel: %v", err)
|
t.Fatalf("CreateLabel: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload", "label-type-bug.json")); err != nil {
|
||||||
@@ -198,14 +247,14 @@ func TestPayloadLandsBesideTheStoreAndNeverInIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A run that sends no body leaves no directory behind — the scratchpad is
|
// A run that sends no body leaves no directory behind — the scratchpad is
|
||||||
// created by the first write and only then.
|
// created by the first write and only then. The version handshake every client
|
||||||
|
// opens with is a read, so building one is not "a run that sent something".
|
||||||
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
||||||
root := newProject(t)
|
root := newProject(t)
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(t, w, map[string]any{"number": 42})
|
writeJSON(t, w, map[string]any{"number": 42})
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
|
if _, err := newClient(t, srv.URL).GetIssue(42); err != nil {
|
||||||
t.Fatalf("GetIssue: %v", err)
|
t.Fatalf("GetIssue: %v", err)
|
||||||
@@ -221,7 +270,7 @@ func TestAReadOnlyCallCreatesNoScratchpad(t *testing.T) {
|
|||||||
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
||||||
newProject(t)
|
newProject(t)
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.Contains(r.URL.Path, "/milestones") {
|
if strings.Contains(r.URL.Path, "/milestones") {
|
||||||
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
|
writeJSON(t, w, []map[string]any{{"id": 3, "title": "v1"}, {"id": 9, "title": "later"}})
|
||||||
return
|
return
|
||||||
@@ -237,8 +286,7 @@ func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
|||||||
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
|
{"number": 4, "title": "a pull request", "milestone": map[string]any{"id": 3, "title": "v1"},
|
||||||
"pull_request": map[string]any{"merged": false}},
|
"pull_request": map[string]any{"merged": false}},
|
||||||
})
|
})
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{Milestone: "v1", Limit: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -247,7 +295,7 @@ func TestListIssuesRechecksWhatTheServerIgnored(t *testing.T) {
|
|||||||
if got.Milestone != "v1" {
|
if got.Milestone != "v1" {
|
||||||
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
|
t.Errorf("resolved milestone is %q, want v1", got.Milestone)
|
||||||
}
|
}
|
||||||
if len(got.Issues) != 1 || got.Issues[0].Number != 1 {
|
if len(got.Issues) != 1 || got.Issues[0].Index != 1 {
|
||||||
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
|
t.Fatalf("got %d issue(s) %v, want only #1 — the backlog was not re-filtered", len(got.Issues), got.Issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +313,7 @@ func TestListIssuesStopsAtThePageBudget(t *testing.T) {
|
|||||||
newProject(t)
|
newProject(t)
|
||||||
|
|
||||||
pages := 0
|
pages := 0
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
pages++
|
pages++
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
@@ -273,12 +321,11 @@ func TestListIssuesStopsAtThePageBudget(t *testing.T) {
|
|||||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
|
out = append(out, map[string]any{"number": pages*100 + i, "state": "closed"})
|
||||||
}
|
}
|
||||||
writeJSON(t, w, out)
|
writeJSON(t, w, out)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||||
Limit: 2,
|
Limit: 2,
|
||||||
Keep: func(i *wire.Issue) bool { return i.State == "open" },
|
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListIssues: %v", err)
|
t.Fatalf("ListIssues: %v", err)
|
||||||
@@ -302,7 +349,7 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
|||||||
newProject(t)
|
newProject(t)
|
||||||
|
|
||||||
pages := 0
|
pages := 0
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
pages++
|
pages++
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
out := []map[string]any{}
|
out := []map[string]any{}
|
||||||
@@ -310,12 +357,11 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
|||||||
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
|
out = append(out, map[string]any{"number": pages*100 + i, "state": "open"})
|
||||||
}
|
}
|
||||||
writeJSON(t, w, out)
|
writeJSON(t, w, out)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
got, err := newClient(t, srv.URL).ListIssues(gitea.IssueFilter{
|
||||||
Limit: 2,
|
Limit: 2,
|
||||||
Keep: func(i *wire.Issue) bool { return i.State == "open" },
|
Keep: func(i *sdk.Issue) bool { return i.State == sdk.StateOpen },
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListIssues: %v", err)
|
t.Fatalf("ListIssues: %v", err)
|
||||||
@@ -328,29 +374,122 @@ func TestListIssuesStopsAtTheLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A dependency endpoint the instance does not have is "no dependencies", not a
|
// A dependency endpoint the instance has but this repository does not is "no
|
||||||
// failed pull. A dead connection still is one.
|
// dependencies", not a failed pull. A dead connection still is one.
|
||||||
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
|
func TestDependenciesToleratesAnInstanceWithoutThem(t *testing.T) {
|
||||||
newProject(t)
|
newProject(t)
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "not implemented", http.StatusNotImplemented)
|
http.Error(w, "not implemented", http.StatusNotImplemented)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
got, err := newClient(t, srv.URL).DependencyKeys(42)
|
// Both clients are built while the server is up, because building one is
|
||||||
|
// itself a request now — and the question this asks is what a call does
|
||||||
|
// when the connection dies UNDER it, which is a different failure from a
|
||||||
|
// tracker that was never there.
|
||||||
|
live := newClient(t, srv.URL)
|
||||||
|
dead := newClient(t, srv.URL)
|
||||||
|
|
||||||
|
got, err := live.DependencyKeys(42)
|
||||||
if err != nil || len(got) != 0 {
|
if err != nil || len(got) != 0 {
|
||||||
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
|
t.Errorf("DependencyKeys = %v, %v; want no keys and no error", got, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.Close()
|
srv.Close()
|
||||||
if _, err := newClient(t, srv.URL).Dependencies(42); err == nil {
|
if _, err := dead.Dependencies(42); err == nil {
|
||||||
t.Error("a dead connection was reported as an instance without dependency support")
|
t.Error("a dead connection was reported as an instance without dependency support")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gate the SDK made possible: an instance too old to have the endpoint at
|
||||||
|
// all is answered from its version, without a request.
|
||||||
|
//
|
||||||
|
// It matters because the alternative is guessing from a status code. An old
|
||||||
|
// Gitea answers a route it does not have with the same 404 it answers for an
|
||||||
|
// issue that does not exist, and a pull that read the second as "no blockers"
|
||||||
|
// would quietly drop half the unit of work.
|
||||||
|
func TestDependenciesAreNotAskedForOnAnInstanceTooOldToHaveThem(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
asked := 0
|
||||||
|
srv := serve(t, "1.19.4", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
asked++
|
||||||
|
writeJSON(t, w, []map[string]any{{"number": 7}})
|
||||||
|
})
|
||||||
|
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
got, err := c.Dependencies(42)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Errorf("Dependencies = %v, %v; want none and no error", got, err)
|
||||||
|
}
|
||||||
|
if asked != 0 {
|
||||||
|
t.Errorf("%d request(s) went out — the version had already answered", asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing one says so out loud instead: push reports it beside the issue it
|
||||||
|
// could not link, and a warning naming the version is something an operator
|
||||||
|
// can act on.
|
||||||
|
err = c.AddDependency(42, wire.Key{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a link was attempted against an instance that has no endpoint for it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "1.20.0") {
|
||||||
|
t.Errorf("the refusal does not name the version that would work: %v", err)
|
||||||
|
}
|
||||||
|
if asked != 0 {
|
||||||
|
t.Errorf("%d request(s) went out for a write the instance cannot take", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the other side of the same gate: a modern instance is asked, and the
|
||||||
|
// answer comes back as cross-repo keys — a dependency is allowed to live
|
||||||
|
// somewhere else, and a bare number would not say where.
|
||||||
|
func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
body = string(raw)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(t, w, []map[string]any{
|
||||||
|
{"number": 7},
|
||||||
|
{"number": 3, "repository": map[string]any{"full_name": "other/repo"}},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
c := newClient(t, srv.URL)
|
||||||
|
got, err := c.DependencyKeys(42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DependencyKeys: %v", err)
|
||||||
|
}
|
||||||
|
want := []wire.Key{
|
||||||
|
{Repo: wire.Repo{Owner: "acme", Name: "widgets"}, Number: 7},
|
||||||
|
{Repo: wire.Repo{Owner: "other", Name: "repo"}, Number: 3},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("keys = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one endpoint left with a hand-rolled request, because the SDK's
|
||||||
|
// IssueMeta carries an index and nothing else: a link to another repository
|
||||||
|
// needs the owner and the name with it.
|
||||||
|
if err := c.AddDependency(42, want[1]); err != nil {
|
||||||
|
t.Fatalf("AddDependency: %v", err)
|
||||||
|
}
|
||||||
|
for _, part := range []string{`"index":3`, `"owner":"other"`, `"repo":"repo"`} {
|
||||||
|
if !strings.Contains(body, part) {
|
||||||
|
t.Errorf("the link body does not carry %s: %s", part, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A half-filled configuration is refused here rather than at the first 401,
|
// A half-filled configuration is refused here rather than at the first 401,
|
||||||
// because a 401 names nothing an operator can act on.
|
// because a 401 names nothing an operator can act on — and before the client is
|
||||||
|
// built at all, because building one dials.
|
||||||
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
|
func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
what string
|
what string
|
||||||
@@ -372,14 +511,54 @@ func TestNewRefusesAHalfFilledConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An instance nobody can reach is named as one. The handshake is the first
|
||||||
|
// request of every run, so this is the failure an operator meets when the URL
|
||||||
|
// is wrong or the tracker is down, and it has to say which instance.
|
||||||
|
func TestNewSaysWhichInstanceItCouldNotReach(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a client was built against a tracker that is not there")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), srv.URL) {
|
||||||
|
t.Errorf("the error does not name the instance: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A version nobody can parse is refused at the handshake, not carried.
|
||||||
|
//
|
||||||
|
// The SDK hands back a client that has silently decided the server is 1.11 and
|
||||||
|
// then rewrites issue URLs from a field a modern payload need not carry, which
|
||||||
|
// is a nil dereference on the first issue read. A refusal that names the
|
||||||
|
// instance is the answer an operator can act on.
|
||||||
|
func TestNewRefusesAnInstanceWhoseVersionIsNotOne(t *testing.T) {
|
||||||
|
newProject(t)
|
||||||
|
|
||||||
|
srv := serve(t, "not-a-version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Errorf("a call went out to %s after the handshake had already failed", r.URL.Path)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := gitea.New(&config.Resolved{URL: srv.URL, Token: "t", Owner: "a", Repo: "b"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an unreadable version was accepted — the first issue read would panic inside the SDK")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), srv.URL) || !strings.Contains(err.Error(), config.EnvURL) {
|
||||||
|
t.Errorf("the refusal names neither the instance nor the setting that points at it: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The layering rule, from this side. The transport knows numbers, logins, HTTP
|
// The layering rule, from this side. The transport knows numbers, logins, HTTP
|
||||||
// and JSON; the domain knows none of those, and neither may reach the other.
|
// and JSON; the domain knows none of those, and neither may reach the other.
|
||||||
//
|
//
|
||||||
// The bridge is out too, and for a reason of its own: it is the layer that
|
// The bridge is out too, and for a reason of its own: it is the layer that
|
||||||
// translates between the two, so it sits ABOVE both. A transport that imported
|
// translates between the two, so it sits ABOVE both. A transport that imported
|
||||||
// it would be a transport that knows what an issue is, one indirection later —
|
// it would be a transport that knows what an issue is, one indirection later —
|
||||||
// and the protocol both of them share, internal/wire, exists precisely so that
|
// and the vocabulary both of them share, the SDK's payloads, exists precisely
|
||||||
// neither has to reach for the other to name a payload.
|
// so that neither has to reach for the other to name one.
|
||||||
func TestTransportDoesNotImportTheDomain(t *testing.T) {
|
func TestTransportDoesNotImportTheDomain(t *testing.T) {
|
||||||
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
out, err := exec.Command("go", "list", "-deps", ".").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+147
-80
@@ -3,10 +3,11 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,86 +15,98 @@ import (
|
|||||||
//
|
//
|
||||||
// A number is an address, not a query: this answers for a closed issue exactly
|
// A number is an address, not a query: this answers for a closed issue exactly
|
||||||
// as it does for an open one.
|
// as it does for an open one.
|
||||||
func (c *Client) GetIssue(number int) (*wire.Issue, error) {
|
func (c *Client) GetIssue(number int) (*sdk.Issue, error) {
|
||||||
var got wire.Issue
|
owner, repo := c.owned()
|
||||||
if err := c.Call(http.MethodGet, c.repoPathf("issues/%d", number), nil, &got); err != nil {
|
got, resp, err := c.api.GetIssue(owner, repo, int64(number))
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// A 200 that carries no number is not this issue. Gitea has answered that
|
// A 200 that carries no number is not this issue. Gitea has answered that
|
||||||
// way for a repository whose issue tracker is disabled.
|
// way for a repository whose issue tracker is disabled.
|
||||||
if got.Number == 0 {
|
if got == nil || got.Index == 0 {
|
||||||
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
|
return nil, fmt.Errorf("issue #%d not found in %s", number, c.repo)
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssue files a new issue. name labels the request body in the
|
// CreateIssue files a new issue. name labels the request body in the
|
||||||
// scratchpad; the issue's slug is what makes that dump worth keeping.
|
// scratchpad; the issue's slug is what makes that dump worth keeping.
|
||||||
func (c *Client) CreateIssue(req wire.IssueRequest, name string) (*wire.Issue, error) {
|
func (c *Client) CreateIssue(opt sdk.CreateIssueOption, name string) (*sdk.Issue, error) {
|
||||||
var got wire.Issue
|
owner, repo := c.owned()
|
||||||
body := &Body{Name: name, Data: req}
|
c.dump.label(name)
|
||||||
if err := c.Call(http.MethodPost, c.repoPath("issues"), body, &got); err != nil {
|
got, resp, err := c.api.CreateIssue(owner, repo, opt)
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditIssue patches an existing issue. Only the fields set on req are sent.
|
// EditIssue patches an existing issue. Only the fields set on opt are sent.
|
||||||
func (c *Client) EditIssue(number int, req wire.IssueRequest, name string) (*wire.Issue, error) {
|
//
|
||||||
var got wire.Issue
|
// LABELS DO NOT GO THROUGH HERE. Gitea's edit endpoint takes no label list and
|
||||||
body := &Body{Name: name, Data: req}
|
// neither does the SDK's EditIssueOption, so an issue whose labels changed
|
||||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/%d", number), body, &got); err != nil {
|
// needs SetLabels after this — push does exactly that, and says so.
|
||||||
|
func (c *Client) EditIssue(number int, opt sdk.EditIssueOption, name string) (*sdk.Issue, error) {
|
||||||
|
owner, repo := c.owned()
|
||||||
|
c.dump.label(name)
|
||||||
|
got, resp, err := c.api.EditIssue(owner, repo, int64(number), opt)
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLabels replaces an issue's labels with exactly these ids.
|
// SetLabels replaces an issue's labels with exactly these ids.
|
||||||
//
|
//
|
||||||
// It exists because Gitea occasionally drops labels handed to it on create, and
|
// It exists because Gitea occasionally drops labels handed to it on create, and
|
||||||
// the answer to that is to re-apply them rather than to trust the echo.
|
// the answer to that is to re-apply them rather than to trust the echo. It is
|
||||||
func (c *Client) SetLabels(number int, ids []int64, name string) ([]wire.Label, error) {
|
// also the only way to change the labels of an issue that already exists — see
|
||||||
|
// EditIssue.
|
||||||
|
func (c *Client) SetLabels(number int, ids []int64, name string) ([]*sdk.Label, error) {
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
ids = []int64{}
|
ids = []int64{}
|
||||||
}
|
}
|
||||||
var got []wire.Label
|
owner, repo := c.owned()
|
||||||
body := &Body{Name: name, Data: struct {
|
c.dump.label(name)
|
||||||
Labels []int64 `json:"labels"`
|
got, resp, err := c.api.ReplaceIssueLabels(owner, repo, int64(number), sdk.IssueLabelsOption{Labels: ids})
|
||||||
}{ids}}
|
if err := fail(resp, err); err != nil {
|
||||||
if err := c.Call(http.MethodPut, c.repoPathf("issues/%d/labels", number), body, &got); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListComments is an issue's whole thread, every page of it.
|
// ListComments is an issue's whole thread, every page of it.
|
||||||
func (c *Client) ListComments(number int) ([]wire.Comment, error) {
|
func (c *Client) ListComments(number int) ([]*sdk.Comment, error) {
|
||||||
return paginate[wire.Comment](c, c.repoPathf("issues/%d/comments", number), pageLimit)
|
owner, repo := c.owned()
|
||||||
|
return paginate(func(page, limit int) ([]*sdk.Comment, error) {
|
||||||
|
got, resp, err := c.api.ListIssueComments(owner, repo, int64(number),
|
||||||
|
sdk.ListIssueCommentOptions{ListOptions: listOptions(page, limit)})
|
||||||
|
return got, fail(resp, err)
|
||||||
|
}, pageLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateComment posts a comment on an issue.
|
// CreateComment posts a comment on an issue.
|
||||||
func (c *Client) CreateComment(number int, text, name string) (*wire.Comment, error) {
|
func (c *Client) CreateComment(number int, text, name string) (*sdk.Comment, error) {
|
||||||
var got wire.Comment
|
owner, repo := c.owned()
|
||||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
c.dump.label(name)
|
||||||
if err := c.Call(http.MethodPost, c.repoPathf("issues/%d/comments", number), body, &got); err != nil {
|
got, resp, err := c.api.CreateIssueComment(owner, repo, int64(number),
|
||||||
|
sdk.CreateIssueCommentOption{Body: text})
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditComment rewrites one comment, addressed by its own id and not by the
|
// EditComment rewrites one comment, addressed by its own id and not by the
|
||||||
// issue it is on — which is how Gitea addresses it.
|
// issue it is on — which is how Gitea addresses it.
|
||||||
func (c *Client) EditComment(id int64, text, name string) (*wire.Comment, error) {
|
func (c *Client) EditComment(id int64, text, name string) (*sdk.Comment, error) {
|
||||||
var got wire.Comment
|
owner, repo := c.owned()
|
||||||
body := &Body{Name: name, Data: commentBody{Body: text}}
|
c.dump.label(name)
|
||||||
if err := c.Call(http.MethodPatch, c.repoPathf("issues/comments/%d", id), body, &got); err != nil {
|
got, resp, err := c.api.EditIssueComment(owner, repo, id, sdk.EditIssueCommentOption{Body: text})
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
|
||||||
|
|
||||||
type commentBody struct {
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -121,13 +134,13 @@ type IssueFilter struct {
|
|||||||
// something to say about them ("11 closed, not stored") still can.
|
// something to say about them ("11 closed, not stored") still can.
|
||||||
//
|
//
|
||||||
// What Keep means is the caller's business; this package only counts.
|
// What Keep means is the caller's business; this package only counts.
|
||||||
Keep func(*wire.Issue) bool
|
Keep func(*sdk.Issue) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueListing is what a filtered read found.
|
// IssueListing is what a filtered read found.
|
||||||
type IssueListing struct {
|
type IssueListing struct {
|
||||||
// Issues are every payload that passed the filter, kept or not.
|
// Issues are every payload that passed the filter, kept or not.
|
||||||
Issues []wire.Issue
|
Issues []*sdk.Issue
|
||||||
// Milestone is the resolved milestone title, for a receipt.
|
// Milestone is the resolved milestone title, for a receipt.
|
||||||
Milestone string
|
Milestone string
|
||||||
// Warning is set when a Keep-bounded read ran out of page budget with the
|
// Warning is set when a Keep-bounded read ran out of page budget with the
|
||||||
@@ -167,23 +180,27 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
|||||||
milestoneID, out.Milestone = ms.ID, ms.Title
|
milestoneID, out.Milestone = ms.ID, ms.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
|
||||||
state := f.State
|
state := f.State
|
||||||
if state == "" {
|
if state == "" {
|
||||||
state = "open"
|
state = "open"
|
||||||
}
|
}
|
||||||
params.Set("state", state)
|
owner, repo := c.owned()
|
||||||
params.Set("type", "issues")
|
fetch := func(page, limit int) ([]*sdk.Issue, error) {
|
||||||
if len(f.Labels) > 0 {
|
opt := sdk.ListIssueOption{
|
||||||
params.Set("labels", strings.Join(f.Labels, ","))
|
ListOptions: listOptions(page, limit),
|
||||||
|
State: sdk.StateType(state),
|
||||||
|
// Issues and not pull requests. The server has been known to
|
||||||
|
// ignore this, which is why matches re-checks it.
|
||||||
|
Type: sdk.IssueTypeIssue,
|
||||||
|
Labels: f.Labels,
|
||||||
|
KeyWord: f.Query,
|
||||||
|
}
|
||||||
|
if out.Milestone != "" {
|
||||||
|
opt.Milestones = []string{out.Milestone}
|
||||||
|
}
|
||||||
|
got, resp, err := c.api.ListRepoIssues(owner, repo, opt)
|
||||||
|
return got, fail(resp, err)
|
||||||
}
|
}
|
||||||
if f.Query != "" {
|
|
||||||
params.Set("q", f.Query)
|
|
||||||
}
|
|
||||||
if out.Milestone != "" {
|
|
||||||
params.Set("milestones", out.Milestone)
|
|
||||||
}
|
|
||||||
path := c.repoPath("issues?" + params.Encode())
|
|
||||||
|
|
||||||
perPage := min(f.Limit, pageLimit)
|
perPage := min(f.Limit, pageLimit)
|
||||||
ideal := max(1, (f.Limit+perPage-1)/perPage)
|
ideal := max(1, (f.Limit+perPage-1)/perPage)
|
||||||
@@ -193,15 +210,14 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kept, seen, lastFull := 0, 0, false
|
kept, seen, lastFull := 0, 0, false
|
||||||
err := pages(c, path, perPage, budget, func(batch []wire.Issue) (bool, error) {
|
err := pages(fetch, perPage, budget, func(batch []*sdk.Issue) (bool, error) {
|
||||||
seen++
|
seen++
|
||||||
lastFull = len(batch) == perPage
|
lastFull = len(batch) == perPage
|
||||||
for i := range batch {
|
for _, p := range batch {
|
||||||
p := &batch[i]
|
|
||||||
if !matches(p, milestoneID, f.Labels) {
|
if !matches(p, milestoneID, f.Labels) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.Issues = append(out.Issues, *p)
|
out.Issues = append(out.Issues, p)
|
||||||
if f.Keep == nil || f.Keep(p) {
|
if f.Keep == nil || f.Keep(p) {
|
||||||
kept++
|
kept++
|
||||||
if kept >= f.Limit {
|
if kept >= f.Limit {
|
||||||
@@ -230,10 +246,10 @@ func (c *Client) ListIssues(f IssueFilter) (*IssueListing, error) {
|
|||||||
// matters most — a pull request rendered as a unit of work is not a bug the
|
// matters most — a pull request rendered as a unit of work is not a bug the
|
||||||
// operator can see until it is in the store.
|
// operator can see until it is in the store.
|
||||||
//
|
//
|
||||||
// A function and not a method: the payload is the protocol's, and re-checking a
|
// A function and not a method: the payload is the SDK's, and re-checking a
|
||||||
// filter the server ignored is this package's business, not the protocol's.
|
// filter the server ignored is this package's business, not the payload's.
|
||||||
func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
|
func matches(i *sdk.Issue, milestoneID int64, labels []string) bool {
|
||||||
if i.IsPullRequest() {
|
if i.PullRequest != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
|
if milestoneID != 0 && (i.Milestone == nil || i.Milestone.ID != milestoneID) {
|
||||||
@@ -255,28 +271,46 @@ func matches(i *wire.Issue, milestoneID int64, labels []string) bool {
|
|||||||
// dependencies
|
// dependencies
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
// issueMeta is Gitea's IssueMeta: how a dependency names another issue.
|
// dependenciesSince is the first Gitea release that answers at
|
||||||
type issueMeta struct {
|
// /issues/{index}/dependencies at all.
|
||||||
Index int `json:"index"`
|
//
|
||||||
Owner string `json:"owner"`
|
// Checked against the release tags themselves and not guessed: the routes are
|
||||||
Repo string `json:"repo"`
|
// absent from routers/api/v1/api.go through 1.19 and present in 1.20. Asking
|
||||||
|
// the version rather than the endpoint is what turns "some error came back"
|
||||||
|
// into an answer — and it costs no request, because the SDK already negotiated
|
||||||
|
// the version when the client was built.
|
||||||
|
const dependenciesSince = ">= 1.20.0"
|
||||||
|
|
||||||
|
// hasDependencies reports whether this instance is new enough to have the
|
||||||
|
// dependency endpoints.
|
||||||
|
func (c *Client) hasDependencies() bool {
|
||||||
|
return c.api.CheckServerVersionConstraint(dependenciesSince) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependencies are the issues that block this one — Gitea's own dependency
|
// Dependencies are the issues that block this one — Gitea's own dependency
|
||||||
// links, read in the direction AddDependency writes them.
|
// links, read in the direction AddDependency writes them.
|
||||||
//
|
//
|
||||||
// An instance that does not have the endpoint, or has dependencies turned off
|
// TWO WAYS FOR THERE TO BE NO ANSWER, and both are reported as "no
|
||||||
// for this repository, answers with a status rather than a list. That is
|
// dependencies" rather than as a failure, because a pull must still bring the
|
||||||
// reported as "no dependencies" and not as a failure: a pull must still bring
|
// issue itself back:
|
||||||
// the issue itself back from a tracker whose dependency support is off.
|
//
|
||||||
|
// - the instance predates the endpoint, which the version says before a
|
||||||
|
// request is made;
|
||||||
|
// - the instance has it but this repository does not — dependencies turned
|
||||||
|
// off, a tracker disabled — which only the tracker's own answer can say.
|
||||||
//
|
//
|
||||||
// Deliberately narrower than the Python it replaces, which swallowed every
|
// Deliberately narrower than the Python it replaces, which swallowed every
|
||||||
// failure here including a dead connection. "The server said no" and "there was
|
// failure here including a dead connection. "The server said no" and "there was
|
||||||
// no server" are different answers, and only the first one means the feature is
|
// no server" are different answers, and only the first one means the feature is
|
||||||
// missing.
|
// missing.
|
||||||
func (c *Client) Dependencies(number int) ([]wire.Issue, error) {
|
func (c *Client) Dependencies(number int) ([]*sdk.Issue, error) {
|
||||||
var got []wire.Issue
|
if !c.hasDependencies() {
|
||||||
err := c.Call(http.MethodGet, c.repoPathf("issues/%d/dependencies", number), nil, &got)
|
return nil, nil
|
||||||
|
}
|
||||||
|
owner, repo := c.owned()
|
||||||
|
got, resp, err := c.api.ListIssueDependencies(owner, repo, int64(number),
|
||||||
|
sdk.ListIssueDependenciesOptions{ListOptions: listOptions(1, pageLimit)})
|
||||||
|
err = fail(resp, err)
|
||||||
var apiErr *APIError
|
var apiErr *APIError
|
||||||
if errors.As(err, &apiErr) {
|
if errors.As(err, &apiErr) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -298,12 +332,38 @@ func (c *Client) DependencyKeys(number int) ([]wire.Key, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]wire.Key, 0, len(deps))
|
out := make([]wire.Key, 0, len(deps))
|
||||||
for i := range deps {
|
for _, d := range deps {
|
||||||
out = append(out, deps[i].KeyIn(c.repo))
|
out = append(out, keyIn(d, c.repo))
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyIn is a payload's cross-repo handle. The payload's own repository wins
|
||||||
|
// when it carries one — a dependency listing answers with issues from other
|
||||||
|
// repositories — and fallback is the repository that was asked.
|
||||||
|
func keyIn(p *sdk.Issue, fallback wire.Repo) wire.Key {
|
||||||
|
repo := fallback
|
||||||
|
if p.Repository != nil {
|
||||||
|
if r, err := wire.ParseRepo(p.Repository.FullName); err == nil {
|
||||||
|
repo = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wire.Key{Repo: repo, Number: int(p.Index)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueMeta is Gitea's own IssueMeta: how a dependency names another issue.
|
||||||
|
//
|
||||||
|
// The SDK has a type of this name too and it carries only `index`, so it can
|
||||||
|
// only ever link inside one repository. Gitea's has taken an owner and a repo
|
||||||
|
// since the endpoint existed, and a `depends:` entry is allowed to live
|
||||||
|
// somewhere else — so this one struct and the raw POST that sends it are all
|
||||||
|
// that is left of the hand-rolled client.
|
||||||
|
type issueMeta struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
// AddDependency makes issue number depend on dep.
|
// AddDependency makes issue number depend on dep.
|
||||||
//
|
//
|
||||||
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
|
// Confirmed against an instance's own swagger.v1.json (Gitea 1.26.1):
|
||||||
@@ -324,9 +384,16 @@ func (c *Client) AddDependency(number int, dep wire.Key) error {
|
|||||||
if dep.Number < 1 {
|
if dep.Number < 1 {
|
||||||
return fmt.Errorf("dependency %s names no issue number", dep)
|
return fmt.Errorf("dependency %s names no issue number", dep)
|
||||||
}
|
}
|
||||||
body := &Body{
|
if !c.hasDependencies() {
|
||||||
Name: fmt.Sprintf("dep-%d-%d", number, dep.Number),
|
return fmt.Errorf("this Gitea has no issue-dependency API (it is not %s) — link #%d -> %s by hand",
|
||||||
Data: issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
strings.TrimPrefix(dependenciesSince, ">= "), number, dep)
|
||||||
}
|
}
|
||||||
return c.Call(http.MethodPost, c.repoPathf("issues/%d/dependencies", number), body, nil)
|
// Owner and name are escaped, the way the SDK escapes them for every other
|
||||||
|
// call: they arrive from a config file, and a file is a thing people type
|
||||||
|
// into.
|
||||||
|
return c.post(
|
||||||
|
fmt.Sprintf("repos/%s/%s/issues/%d/dependencies",
|
||||||
|
url.PathEscape(c.repo.Owner), url.PathEscape(c.repo.Name), number),
|
||||||
|
issueMeta{Index: dep.Number, Owner: dep.Repo.Owner, Repo: dep.Repo.Name},
|
||||||
|
fmt.Sprintf("dep-%d-%d", number, dep.Number))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListLabels is every label in the repository, every page of it.
|
// ListLabels is every label in the repository, every page of it.
|
||||||
@@ -14,8 +13,13 @@ import (
|
|||||||
// A bootstrap decides its plan against this and never against a cache: a cache
|
// A bootstrap decides its plan against this and never against a cache: a cache
|
||||||
// answers "what did we create last time", and the question is "what does the
|
// answers "what did we create last time", and the question is "what does the
|
||||||
// repository have right now".
|
// repository have right now".
|
||||||
func (c *Client) ListLabels() ([]wire.Label, error) {
|
func (c *Client) ListLabels() ([]*sdk.Label, error) {
|
||||||
return paginate[wire.Label](c, c.repoPath("labels"), 100)
|
owner, repo := c.owned()
|
||||||
|
return paginate(func(page, limit int) ([]*sdk.Label, error) {
|
||||||
|
got, resp, err := c.api.ListRepoLabels(owner, repo,
|
||||||
|
sdk.ListLabelsOptions{ListOptions: listOptions(page, limit)})
|
||||||
|
return got, fail(resp, err)
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLabel adds a label to the repository.
|
// CreateLabel adds a label to the repository.
|
||||||
@@ -26,26 +30,40 @@ func (c *Client) ListLabels() ([]wire.Label, error) {
|
|||||||
//
|
//
|
||||||
// What a label MEANS is not decided here either: this creates what it is
|
// What a label MEANS is not decided here either: this creates what it is
|
||||||
// handed.
|
// handed.
|
||||||
func (c *Client) CreateLabel(req wire.LabelRequest) (*wire.Label, error) {
|
func (c *Client) CreateLabel(opt sdk.CreateLabelOption) (*sdk.Label, error) {
|
||||||
var got wire.Label
|
owner, repo := c.owned()
|
||||||
body := &Body{Name: "label-" + req.Name, Data: req}
|
c.dump.label("label-" + opt.Name)
|
||||||
if err := c.Call(http.MethodPost, c.repoPath("labels"), body, &got); err != nil {
|
got, resp, err := c.api.CreateLabel(owner, repo, opt)
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if got.ID == 0 {
|
if got == nil || got.ID == 0 {
|
||||||
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", req.Name)
|
return nil, fmt.Errorf("creating label %q: the tracker's answer carries no id", opt.Name)
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditLabel patches an existing label by id.
|
// EditLabel patches an existing label by id.
|
||||||
func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error) {
|
//
|
||||||
var got wire.Label
|
// It takes the same spec a create takes, and sends every field of it. The SDK
|
||||||
body := &Body{Name: "label-" + req.Name, Data: req}
|
// spells an edit with pointers, where nil means "leave it alone" — but a label
|
||||||
if err := c.Call(http.MethodPatch, c.repoPathf("labels/%d", id), body, &got); err != nil {
|
// edit is rare enough that sending the unchanged name and description along
|
||||||
|
// costs nothing and removes a way to lose them on a server that reads an absent
|
||||||
|
// field as empty. The caller decides what the label should BE; this makes the
|
||||||
|
// tracker say that and nothing less.
|
||||||
|
func (c *Client) EditLabel(id int64, opt sdk.CreateLabelOption) (*sdk.Label, error) {
|
||||||
|
owner, repo := c.owned()
|
||||||
|
c.dump.label("label-" + opt.Name)
|
||||||
|
got, resp, err := c.api.EditLabel(owner, repo, id, sdk.EditLabelOption{
|
||||||
|
Name: &opt.Name,
|
||||||
|
Color: &opt.Color,
|
||||||
|
Description: &opt.Description,
|
||||||
|
Exclusive: &opt.Exclusive,
|
||||||
|
})
|
||||||
|
if err := fail(resp, err); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &got, nil
|
return got, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMilestones is every milestone in the repository, open and closed.
|
// ListMilestones is every milestone in the repository, open and closed.
|
||||||
@@ -53,8 +71,15 @@ func (c *Client) EditLabel(id int64, req wire.LabelRequest) (*wire.Label, error)
|
|||||||
// Both states, always: a milestone is closed the moment its work is done, and a
|
// Both states, always: a milestone is closed the moment its work is done, and a
|
||||||
// listing that hid those would fail to resolve exactly the filter somebody
|
// listing that hid those would fail to resolve exactly the filter somebody
|
||||||
// types when they want to see what was in it.
|
// types when they want to see what was in it.
|
||||||
func (c *Client) ListMilestones() ([]wire.Milestone, error) {
|
func (c *Client) ListMilestones() ([]*sdk.Milestone, error) {
|
||||||
return paginate[wire.Milestone](c, c.repoPath("milestones?state=all"), 100)
|
owner, repo := c.owned()
|
||||||
|
return paginate(func(page, limit int) ([]*sdk.Milestone, error) {
|
||||||
|
got, resp, err := c.api.ListRepoMilestones(owner, repo, sdk.ListMilestoneOption{
|
||||||
|
ListOptions: listOptions(page, limit),
|
||||||
|
State: sdk.StateAll,
|
||||||
|
})
|
||||||
|
return got, fail(resp, err)
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveMilestone finds a milestone by id or by title, and fails when there is
|
// ResolveMilestone finds a milestone by id or by title, and fails when there is
|
||||||
@@ -64,14 +89,19 @@ func (c *Client) ListMilestones() ([]wire.Milestone, error) {
|
|||||||
// Gitea silently ignores a `milestones=` filter it cannot resolve and answers
|
// Gitea silently ignores a `milestones=` filter it cannot resolve and answers
|
||||||
// with the entire backlog. A typo in a milestone name would otherwise read as
|
// with the entire backlog. A typo in a milestone name would otherwise read as
|
||||||
// "your milestone has 300 issues in it".
|
// "your milestone has 300 issues in it".
|
||||||
func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
|
//
|
||||||
|
// Against the whole listing rather than the SDK's GetMilestoneByName, because a
|
||||||
|
// failure has to say what the repository actually HAS — and because that helper
|
||||||
|
// matches case-insensitively, which would resolve two different milestones to
|
||||||
|
// one on a repository that has both.
|
||||||
|
func (c *Client) ResolveMilestone(value string) (*sdk.Milestone, error) {
|
||||||
got, err := c.ListMilestones()
|
got, err := c.ListMilestones()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for i := range got {
|
for _, m := range got {
|
||||||
if got[i].Title == value || strconv.FormatInt(got[i].ID, 10) == value {
|
if m.Title == value || strconv.FormatInt(m.ID, 10) == value {
|
||||||
return &got[i], nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
have := make([]string, 0, len(got))
|
have := make([]string, 0, len(got))
|
||||||
@@ -91,7 +121,7 @@ func (c *Client) ResolveMilestone(value string) (*wire.Milestone, error) {
|
|||||||
// milestone the tracker does not have is filed without one, because refusing
|
// milestone the tracker does not have is filed without one, because refusing
|
||||||
// the whole push over a field the tracker will happily accept as empty helps
|
// the whole push over a field the tracker will happily accept as empty helps
|
||||||
// nobody. "none" and "" are both "no milestone".
|
// nobody. "none" and "" are both "no milestone".
|
||||||
func (c *Client) FindMilestone(title string) (*wire.Milestone, error) {
|
func (c *Client) FindMilestone(title string) (*sdk.Milestone, error) {
|
||||||
if title == "" || title == "none" {
|
if title == "" || title == "none" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -99,9 +129,9 @@ func (c *Client) FindMilestone(title string) (*wire.Milestone, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for i := range got {
|
for _, m := range got {
|
||||||
if got[i].Title == title {
|
if m.Title == title {
|
||||||
return &got[i], nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
@@ -39,7 +42,7 @@ type PayloadOptions struct {
|
|||||||
// transport bookkeeping, and the caller has already read the slug off it to
|
// transport bookkeeping, and the caller has already read the slug off it to
|
||||||
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
|
// decide which id to pass. Everything downstream — checkboxes, `#N` references,
|
||||||
// what lands on disk — sees the body the author wrote.
|
// what lands on disk — sees the body the author wrote.
|
||||||
func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
|
func FromPayload(p *sdk.Issue, id string, repo wire.Repo, opt PayloadOptions) (*issue.Issue, []int) {
|
||||||
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
|
body := MergeCheckboxState(StripIDMarker(strings.TrimSpace(p.Body)), opt.LocalBody)
|
||||||
|
|
||||||
numbers := NumbersInBody(body)
|
numbers := NumbersInBody(body)
|
||||||
@@ -68,15 +71,15 @@ func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (
|
|||||||
// dependency listing answers with issues from elsewhere, and this is the
|
// dependency listing answers with issues from elsewhere, and this is the
|
||||||
// handle for the copy landing in THIS store.
|
// handle for the copy landing in THIS store.
|
||||||
extra := map[string]string{
|
extra := map[string]string{
|
||||||
GiteaKey: wire.Key{Repo: repo, Number: p.Number}.String(),
|
GiteaKey: wire.Key{Repo: repo, Number: int(p.Index)}.String(),
|
||||||
URLKey: p.HTMLURL,
|
URLKey: p.HTMLURL,
|
||||||
SyncedKey: opt.Synced,
|
SyncedKey: opt.Synced,
|
||||||
}
|
}
|
||||||
if p.Ref != "" {
|
if p.Ref != "" {
|
||||||
extra[BranchKey] = p.Ref
|
extra[BranchKey] = p.Ref
|
||||||
}
|
}
|
||||||
if p.UpdatedAt != "" {
|
if stamp := Stamp(p.Updated); stamp != "" {
|
||||||
extra[RemoteUpdatedKey] = p.UpdatedAt
|
extra[RemoteUpdatedKey] = stamp
|
||||||
}
|
}
|
||||||
// Zero comments is not a fact worth a line in the file — every issue that
|
// Zero comments is not a fact worth a line in the file — every issue that
|
||||||
// has never been discussed would carry one.
|
// has never been discussed would carry one.
|
||||||
@@ -84,41 +87,84 @@ func FromPayload(p *wire.Issue, id string, repo wire.Repo, opt PayloadOptions) (
|
|||||||
extra[CommentsKey] = strconv.Itoa(p.Comments)
|
extra[CommentsKey] = strconv.Itoa(p.Comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
state := p.State
|
state := string(p.State)
|
||||||
if state == "" {
|
if state == "" {
|
||||||
state = "open"
|
state = "open"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appended into nil slices, so an issue with no labels is the same value as
|
|
||||||
// one loaded from a file — the store's own parser yields nothing, not an
|
|
||||||
// empty list, and two spellings of "none" is a comparison bug waiting.
|
|
||||||
var labels []string
|
|
||||||
for _, l := range p.Labels {
|
|
||||||
labels = append(labels, l.Name)
|
|
||||||
}
|
|
||||||
var assignees []string
|
|
||||||
for _, a := range p.Assignees {
|
|
||||||
assignees = append(assignees, a.Login)
|
|
||||||
}
|
|
||||||
milestone := ""
|
|
||||||
if p.Milestone != nil {
|
|
||||||
milestone = p.Milestone.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
return &issue.Issue{
|
return &issue.Issue{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: p.Title,
|
Title: p.Title,
|
||||||
Body: body,
|
Body: body,
|
||||||
State: state,
|
State: state,
|
||||||
Labels: labels,
|
Labels: LabelNames(p),
|
||||||
Assignees: assignees,
|
Assignees: AssigneeLogins(p),
|
||||||
Milestone: milestone,
|
Milestone: MilestoneTitle(p),
|
||||||
Depends: deps,
|
Depends: deps,
|
||||||
Origin: Origin,
|
Origin: Origin,
|
||||||
Extra: extra,
|
Extra: extra,
|
||||||
}, unresolved
|
}, unresolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LabelNames are a payload's label names, in the order the tracker listed them.
|
||||||
|
//
|
||||||
|
// Appended into a nil slice, so an issue with no labels is the same value as
|
||||||
|
// one loaded from a file — the store's own parser yields nothing, not an empty
|
||||||
|
// list, and two spellings of "none" is a comparison bug waiting. The same goes
|
||||||
|
// for the two below.
|
||||||
|
func LabelNames(p *sdk.Issue) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range p.Labels {
|
||||||
|
if l != nil {
|
||||||
|
out = append(out, l.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssigneeLogins are a payload's assignees, as logins.
|
||||||
|
//
|
||||||
|
// Only the login crosses this boundary — it is the one field of a Gitea user
|
||||||
|
// that means anything to a command, it is what `assignees:` holds, and a
|
||||||
|
// display name is not an identity anything can be pushed against.
|
||||||
|
func AssigneeLogins(p *sdk.Issue) []string {
|
||||||
|
var out []string
|
||||||
|
for _, a := range p.Assignees {
|
||||||
|
if a != nil {
|
||||||
|
out = append(out, a.UserName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MilestoneTitle is a payload's milestone title, or "" when it has none. The
|
||||||
|
// domain carries the title; the id exists only long enough to be sent back.
|
||||||
|
func MilestoneTitle(p *sdk.Issue) string {
|
||||||
|
if p.Milestone == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.Milestone.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp is how a tracker timestamp is written into an issue's metadata, and ""
|
||||||
|
// for a time the payload did not carry.
|
||||||
|
//
|
||||||
|
// The zero time is not a date: an issue whose `updated_at` was absent would
|
||||||
|
// otherwise be stamped `0001-01-01`, which reads as a fact and is not one.
|
||||||
|
//
|
||||||
|
// RFC3339 both ways. These values are written into a file, compared as opaque
|
||||||
|
// strings and handed back; the SDK parses them into a time.Time on the way in,
|
||||||
|
// so something has to spell them out again, and the format Gitea sends is the
|
||||||
|
// format they go back out in. What was true when this was a string end to end —
|
||||||
|
// that no round trip could change the spelling — is not any more: a timestamp
|
||||||
|
// with a fraction of a second in it comes back without one.
|
||||||
|
func Stamp(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
// NumbersInBody is every `#N` referenced from the body's dependency sections.
|
// NumbersInBody is every `#N` referenced from the body's dependency sections.
|
||||||
// Used only to seed `depends:` on the first pull — after that the metadata
|
// Used only to seed `depends:` on the first pull — after that the metadata
|
||||||
// field is the graph and the prose is prose.
|
// field is the graph and the prose is prose.
|
||||||
@@ -188,19 +234,26 @@ func MergeCheckboxState(remoteBody, localBody string) string {
|
|||||||
|
|
||||||
// RenderComments flattens a comment thread to markdown. Read-only: nothing
|
// RenderComments flattens a comment thread to markdown. Read-only: nothing
|
||||||
// writes it back, which is why it may be as lossy as a reader needs.
|
// writes it back, which is why it may be as lossy as a reader needs.
|
||||||
func RenderComments(comments []wire.Comment) string {
|
func RenderComments(comments []*sdk.Comment) string {
|
||||||
var out []string
|
var out []string
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
day := c.CreatedAt
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
day := Stamp(c.Created)
|
||||||
if len(day) > 10 {
|
if len(day) > 10 {
|
||||||
day = day[:10]
|
day = day[:10]
|
||||||
}
|
}
|
||||||
|
who := ""
|
||||||
|
if c.Poster != nil {
|
||||||
|
who = c.Poster.UserName
|
||||||
|
}
|
||||||
body := strings.TrimSpace(c.Body)
|
body := strings.TrimSpace(c.Body)
|
||||||
if body == "" {
|
if body == "" {
|
||||||
body = "(empty)"
|
body = "(empty)"
|
||||||
}
|
}
|
||||||
out = append(out,
|
out = append(out,
|
||||||
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+c.User.Login+" — "+day,
|
"## comment "+strconv.FormatInt(c.ID, 10)+" — "+who+" — "+day,
|
||||||
"", body, "")
|
"", body, "")
|
||||||
}
|
}
|
||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package mapping
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
// How the taxonomy is painted in Gitea's UI. A hex code says nothing about what
|
||||||
@@ -43,20 +44,22 @@ func LabelColor(name string) string {
|
|||||||
|
|
||||||
// LabelSpecs is the request body for each name, in the order given.
|
// LabelSpecs is the request body for each name, in the order given.
|
||||||
//
|
//
|
||||||
// A wire.LabelRequest and not a shape of this package's own: it is field for
|
// The SDK's own CreateLabelOption and not a shape of this package's own: it is
|
||||||
// field what a label create takes, and a second spelling of it would mean the
|
// field for field what a label create takes, and a second spelling of it would
|
||||||
// bootstrap command copying four fields across on its way to the transport.
|
// mean the bootstrap command copying four fields across on its way to the
|
||||||
|
// transport. It is what an EDIT is built from too — see Client.EditLabel — so
|
||||||
|
// one value says what a label should be, whether or not it exists yet.
|
||||||
// Exclusivity and meaning come from the domain taxonomy; only the color is
|
// Exclusivity and meaning come from the domain taxonomy; only the color is
|
||||||
// decided here.
|
// decided here.
|
||||||
//
|
//
|
||||||
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
|
// A slice and not a map: the order is the taxonomy's, and a bootstrap prints
|
||||||
// its plan in that order — a map would shuffle the plan on every run and make
|
// its plan in that order — a map would shuffle the plan on every run and make
|
||||||
// two identical runs look like different ones.
|
// two identical runs look like different ones.
|
||||||
func LabelSpecs(names []string) []wire.LabelRequest {
|
func LabelSpecs(names []string) []sdk.CreateLabelOption {
|
||||||
ns := exclusiveNamespaces()
|
ns := exclusiveNamespaces()
|
||||||
out := make([]wire.LabelRequest, 0, len(names))
|
out := make([]sdk.CreateLabelOption, 0, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
out = append(out, wire.LabelRequest{
|
out = append(out, sdk.CreateLabelOption{
|
||||||
Name: name,
|
Name: name,
|
||||||
Color: LabelColor(name),
|
Color: LabelColor(name),
|
||||||
Description: typeMeaning(name),
|
Description: typeMeaning(name),
|
||||||
@@ -72,7 +75,7 @@ func LabelSpecs(names []string) []wire.LabelRequest {
|
|||||||
// Derived from the domain's own list rather than restated: add a type over in
|
// Derived from the domain's own list rather than restated: add a type over in
|
||||||
// the taxonomy and the next bootstrap creates it, with no line changing here
|
// the taxonomy and the next bootstrap creates it, with no line changing here
|
||||||
// except the color it is painted with.
|
// except the color it is painted with.
|
||||||
func CanonicalLabelSpecs() []wire.LabelRequest { return LabelSpecs(issue.CanonicalLabels()) }
|
func CanonicalLabelSpecs() []sdk.CreateLabelOption { return LabelSpecs(issue.CanonicalLabels()) }
|
||||||
|
|
||||||
// exclusiveNamespaces are the namespaces at most one label may come from, read
|
// exclusiveNamespaces are the namespaces at most one label may come from, read
|
||||||
// off the canonical set rather than listed again — the domain publishes exactly
|
// off the canonical set rather than listed again — the domain publishes exactly
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
package mapping
|
package mapping
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sdkPath is the one third-party import this package is allowed: the payload
|
||||||
|
// shapes it translates to and from.
|
||||||
|
const sdkPath = "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
// The bridge translates values and nothing else: no network, no filesystem, no
|
// The bridge translates values and nothing else: no network, no filesystem, no
|
||||||
// clock, no configuration. Every one of those is a caller's to supply, which is
|
// clock, no configuration. Every one of those is a caller's to supply, which is
|
||||||
// what lets this package be reasoned about and tested without a Gitea anywhere.
|
// what lets this package be reasoned about and tested without a Gitea anywhere.
|
||||||
//
|
//
|
||||||
// Two imports and no more: internal/issue for what an issue is, and
|
// THE RULE THIS TEST USED TO MAKE was stronger and is no longer true. The
|
||||||
// internal/wire for the shapes on the other side. wire is allowed precisely
|
// shapes lived in internal/wire, which imported the standard library and
|
||||||
// because it is inert — shapes and identifiers over the standard library, with
|
// nothing else, so "the bridge cannot reach a transport" held by construction:
|
||||||
// a layering test of its own — so naming a payload here costs nothing and
|
// there was nothing in its dependency graph that could open a socket. The SDK's
|
||||||
// reaches nowhere.
|
// types come with the SDK's client attached, so the graph now contains an HTTP
|
||||||
|
// client whatever this package does with it — and a test that claimed otherwise
|
||||||
|
// would be a test that lies.
|
||||||
//
|
//
|
||||||
// DIRECT imports, not the dependency walk internal/issue does. The domain
|
// So it asserts the part that survives, which is also the part that catches a
|
||||||
// reaches os through internal/project and that is the domain's business; what
|
// real mistake: what THIS package reaches for. DIRECT imports, not the
|
||||||
// this test is about is what this package itself reaches for. A transport that
|
// dependency walk internal/issue does — the domain reaches os through
|
||||||
// grew a helper here — or a lookup that quietly opened a config file — is what
|
// internal/project and that is the domain's business. A transport that grew a
|
||||||
// it catches.
|
// helper here, or a lookup that quietly opened a config file, is what this
|
||||||
|
// catches, and it still fails on `os`, on `net/http` and on internal/gitea.
|
||||||
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
|
func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
|
||||||
forbidden := map[string]string{
|
forbidden := map[string]string{
|
||||||
"net/http": "an HTTP call belongs in the transport",
|
"net/http": "an HTTP call belongs in the transport",
|
||||||
@@ -28,19 +37,78 @@ func TestTheBridgeTranslatesAndNothingElse(t *testing.T) {
|
|||||||
"os": "a pure function reads no file and no environment",
|
"os": "a pure function reads no file and no environment",
|
||||||
"os/exec": "nothing here shells out",
|
"os/exec": "nothing here shells out",
|
||||||
"io/ioutil": "a pure function reads no file",
|
"io/ioutil": "a pure function reads no file",
|
||||||
"time": "the clock is the caller's; a timestamp arrives as a string",
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea": "the transport imports this package, never the reverse",
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/config": "credentials and repositories are the transport's",
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/project": "nothing here resolves a path",
|
||||||
}
|
}
|
||||||
|
allowed := map[string]bool{
|
||||||
|
sdkPath: true,
|
||||||
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue": true,
|
||||||
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dep := range directImports(t) {
|
||||||
|
if why, bad := forbidden[dep]; bad {
|
||||||
|
t.Errorf("mapping imports %s — %s", dep, why)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A standard-library import path has no dot in its first element,
|
||||||
|
// because it has no domain name in front of it. Everything else has to
|
||||||
|
// be named above: one third party is a decision, two is a habit.
|
||||||
|
first, _, _ := strings.Cut(dep, "/")
|
||||||
|
if strings.Contains(first, ".") && !allowed[dep] {
|
||||||
|
t.Errorf("mapping imports %s — the only payload vocabulary here is %s", dep, sdkPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clock is the caller's, and `time` alone can no longer say so: the SDK
|
||||||
|
// hands over a time.Time, so this package imports the package to format one
|
||||||
|
// back into the string an issue file holds. What it must never do is ASK what
|
||||||
|
// time it is — a `synced:` stamped here would be stamped at translation rather
|
||||||
|
// than at the write it describes, and two issues pushed in one run would carry
|
||||||
|
// two different times for one run.
|
||||||
|
//
|
||||||
|
// The source, then, rather than the import graph: the difference between
|
||||||
|
// formatting a timestamp and having a clock is not visible in `go list`.
|
||||||
|
func TestTheBridgeHasNoClock(t *testing.T) {
|
||||||
|
for _, path := range sourceFiles(t) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, banned := range []string{"time.Now(", "time.Since(", "time.Until("} {
|
||||||
|
if strings.Contains(string(raw), banned) {
|
||||||
|
t.Errorf("%s calls %s) — the clock belongs to the caller", filepath.Base(path), banned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func directImports(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("go list: %v", err)
|
t.Fatalf("go list: %v", err)
|
||||||
}
|
}
|
||||||
for _, dep := range strings.Fields(string(out)) {
|
return strings.Fields(string(out))
|
||||||
if why, bad := forbidden[dep]; bad {
|
}
|
||||||
t.Errorf("mapping imports %s — %s", dep, why)
|
|
||||||
}
|
// sourceFiles is this package's own .go files, tests excluded: a test may look
|
||||||
}
|
// at a clock, and one of them does.
|
||||||
|
func sourceFiles(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("go", "list", "-f", `{{range .GoFiles}}{{.}}
|
||||||
|
{{end}}`, ".").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("go list: %v", err)
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, name := range strings.Fields(string(out)) {
|
||||||
|
paths = append(paths, name)
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
t.Fatal("go list named no source files — this test would pass on an empty package")
|
||||||
|
}
|
||||||
|
return paths
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package mapping is md <-> Gitea JSON. The whole translation, and only the
|
// Package mapping is md <-> Gitea's payloads. The whole translation, and only
|
||||||
// translation.
|
// the translation.
|
||||||
//
|
//
|
||||||
// Pure functions: no network, no filesystem, no flags, no clock. Give it a
|
// Pure functions: no network, no filesystem, no flags, no clock. Give it a
|
||||||
// payload and it hands back a domain issue; give it an issue and it hands back
|
// payload and it hands back a domain issue; give it an issue and it hands back
|
||||||
@@ -7,13 +7,21 @@
|
|||||||
// tested without a Gitea anywhere, and it is the one package to open when the
|
// tested without a Gitea anywhere, and it is the one package to open when the
|
||||||
// two representations disagree.
|
// two representations disagree.
|
||||||
//
|
//
|
||||||
// Direction of knowledge: this package imports the domain and the protocol
|
// Direction of knowledge: this package imports the domain and the Gitea SDK,
|
||||||
// (internal/wire), and nothing imports it but the command layer. The domain
|
// and nothing imports it but the command layer. The domain never imports it,
|
||||||
// never imports it, and TestDomainDependsOnNothing over in internal/issue fails
|
// and TestDomainDependsOnNothing over in internal/issue fails the moment it
|
||||||
// the moment it does; the transport never imports it either, and
|
// does; the transport never imports it either, and
|
||||||
// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both
|
// TestTransportDoesNotImportTheDomain over in internal/gitea says so. Both
|
||||||
// sides speak wire's shapes, which is what lets the two meet without either one
|
// sides speak the SDK's shapes, which is what lets the two meet without either
|
||||||
// reaching into the other.
|
// one reaching into the other.
|
||||||
|
//
|
||||||
|
// WHAT THAT COSTS, SAID OUT LOUD. The shapes used to be internal/wire's, a
|
||||||
|
// package that imported the standard library and nothing else, so "the bridge
|
||||||
|
// cannot reach the network" was a fact about the import graph. code.gitea.io/
|
||||||
|
// sdk/gitea carries an HTTP client, so it is not any more. What is still true
|
||||||
|
// is that nothing HERE does I/O, and layering_test.go asserts the version of
|
||||||
|
// the rule that can still be checked: no os, no net/http, no transport, no
|
||||||
|
// configuration, no clock, and no third party but the SDK.
|
||||||
//
|
//
|
||||||
// What crosses the boundary, and what does not:
|
// What crosses the boundary, and what does not:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
@@ -14,6 +17,17 @@ import (
|
|||||||
// handle in `gitea:` is a key, and a key is a repository and a number.
|
// handle in `gitea:` is a key, and a key is a repository and a number.
|
||||||
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
|
var tea = wire.Repo{Owner: "claude-skills", Name: "tea"}
|
||||||
|
|
||||||
|
// when parses a tracker timestamp the way the SDK hands one over, so a fixture
|
||||||
|
// can be written in the spelling Gitea actually sends.
|
||||||
|
func when(t *testing.T, s string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
got, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing %q: %v", s, err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
// A file exactly as the store holds it: domain fields, then the sync fields the
|
// A file exactly as the store holds it: domain fields, then the sync fields the
|
||||||
// domain carries and never reads.
|
// domain carries and never reads.
|
||||||
const stored = `---
|
const stored = `---
|
||||||
@@ -58,51 +72,60 @@ func roundTripOptions() RequestOptions {
|
|||||||
// preserved comes back, and the body comes back byte for byte.
|
// preserved comes back, and the body comes back byte for byte.
|
||||||
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
|
func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
|
||||||
local := issue.FromText(stored, "wire-sqlc-appclick")
|
local := issue.FromText(stored, "wire-sqlc-appclick")
|
||||||
req := ToRequest(local, roundTripOptions())
|
req := ToCreate(local, roundTripOptions())
|
||||||
|
|
||||||
if req.Title == nil || *req.Title != local.Title {
|
if req.Title != local.Title {
|
||||||
t.Errorf("title = %v, want %q", req.Title, local.Title)
|
t.Errorf("title = %q, want %q", req.Title, local.Title)
|
||||||
}
|
}
|
||||||
if req.Body == nil {
|
if req.Body == "" {
|
||||||
t.Fatal("the request carries no body — a create would file an empty issue")
|
t.Fatal("the request carries no body — a create would file an empty issue")
|
||||||
}
|
}
|
||||||
if got := IDInBody(*req.Body); got != local.ID {
|
if got := IDInBody(req.Body); got != local.ID {
|
||||||
t.Errorf("the request body does not claim the slug: %q", got)
|
t.Errorf("the request body does not claim the slug: %q", got)
|
||||||
}
|
}
|
||||||
if got := StripIDMarker(*req.Body); got != strings.TrimSpace(local.Body) {
|
if got := StripIDMarker(req.Body); got != strings.TrimSpace(local.Body) {
|
||||||
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
|
t.Errorf("the prose was rewritten on the way up:\n--- got ---\n%s\n--- want ---\n%s",
|
||||||
got, strings.TrimSpace(local.Body))
|
got, strings.TrimSpace(local.Body))
|
||||||
}
|
}
|
||||||
if want := []int64{11, 12}; req.Labels == nil || !reflect.DeepEqual(*req.Labels, want) {
|
if want := []int64{11, 12}; !reflect.DeepEqual(req.Labels, want) {
|
||||||
t.Errorf("labels = %v, want %v", req.Labels, want)
|
t.Errorf("labels = %v, want %v", req.Labels, want)
|
||||||
}
|
}
|
||||||
if want := []string{"naudachu"}; req.Assignees == nil || !reflect.DeepEqual(*req.Assignees, want) {
|
if want := []string{"naudachu"}; !reflect.DeepEqual(req.Assignees, want) {
|
||||||
t.Errorf("assignees = %v, want %v", req.Assignees, want)
|
t.Errorf("assignees = %v, want %v", req.Assignees, want)
|
||||||
}
|
}
|
||||||
if req.Milestone == nil || *req.Milestone != 5 {
|
if req.Milestone != 5 {
|
||||||
t.Errorf("milestone = %v, want 5", req.Milestone)
|
t.Errorf("milestone = %v, want 5", req.Milestone)
|
||||||
}
|
}
|
||||||
if req.State == nil || *req.State != "open" {
|
if req.Ref != "feat/wire-sqlc" {
|
||||||
t.Errorf("state = %v", req.State)
|
t.Errorf("ref = %q — branch: is a sync field and must ride along", req.Ref)
|
||||||
}
|
}
|
||||||
if req.Ref == nil || *req.Ref != "feat/wire-sqlc" {
|
// An edit is the other half of the same translation, and the two must not
|
||||||
t.Errorf("ref = %v — branch: is a sync field and must ride along", req.Ref)
|
// disagree about the issue they describe.
|
||||||
|
edit := ToEdit(local, roundTripOptions())
|
||||||
|
if edit.Body == nil || *edit.Body != req.Body || edit.Title != req.Title {
|
||||||
|
t.Errorf("a create and an edit describe different issues: %q / %v", edit.Title, edit.Body)
|
||||||
|
}
|
||||||
|
if edit.State == nil || *edit.State != sdk.StateOpen {
|
||||||
|
t.Errorf("state = %v", edit.State)
|
||||||
|
}
|
||||||
|
if edit.Ref == nil || *edit.Ref != "feat/wire-sqlc" {
|
||||||
|
t.Errorf("ref = %v — branch: is a sync field and must ride along", edit.Ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the tracker hands back is the body it was given, plus its own
|
// What the tracker hands back is the body it was given, plus its own
|
||||||
// bookkeeping.
|
// bookkeeping.
|
||||||
echo := &wire.Issue{
|
echo := &sdk.Issue{
|
||||||
Number: 42,
|
Index: 42,
|
||||||
Title: *req.Title,
|
Title: req.Title,
|
||||||
Body: *req.Body,
|
Body: req.Body,
|
||||||
State: "open",
|
State: sdk.StateOpen,
|
||||||
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
|
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/42",
|
||||||
UpdatedAt: "2026-08-09T18:24:01Z",
|
Updated: when(t, "2026-08-09T18:24:01Z"),
|
||||||
Ref: *req.Ref,
|
Ref: req.Ref,
|
||||||
Comments: 3,
|
Comments: 3,
|
||||||
Labels: []wire.Label{{Name: "type/task"}, {Name: "tech/sql"}},
|
Labels: []*sdk.Label{{Name: "type/task"}, {Name: "tech/sql"}},
|
||||||
Assignees: []wire.User{{Login: "naudachu"}},
|
Assignees: []*sdk.User{{UserName: "naudachu"}},
|
||||||
Milestone: &wire.Milestone{ID: 5, Title: "v0.2"},
|
Milestone: &sdk.Milestone{ID: 5, Title: "v0.2"},
|
||||||
}
|
}
|
||||||
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
|
back, unresolved := FromPayload(echo, local.ID, tea, PayloadOptions{
|
||||||
IDForNumber: map[int]string{7: "migrate-schema"},
|
IDForNumber: map[int]string{7: "migrate-schema"},
|
||||||
@@ -153,59 +176,72 @@ func TestRoundTripPreservesEveryFieldTheFormatKeeps(t *testing.T) {
|
|||||||
|
|
||||||
// And the strongest form of "no churn": pushing what came back sends
|
// And the strongest form of "no churn": pushing what came back sends
|
||||||
// exactly what was sent the first time.
|
// exactly what was sent the first time.
|
||||||
if again := ToRequest(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
|
if again := ToCreate(back, roundTripOptions()); !reflect.DeepEqual(again, req) {
|
||||||
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
|
t.Errorf("a second push differs from the first:\n--- again ---\n%+v\n--- first ---\n%+v", again, req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other shape an issue comes in: nothing scheduled, nobody assigned.
|
// The other shape an issue comes in: nothing scheduled, nobody assigned.
|
||||||
|
//
|
||||||
|
// On an EDIT that is a statement and not an absence, which is why this asserts
|
||||||
|
// on the bytes. Gitea reads a null as "no opinion" and a value as "make it
|
||||||
|
// this", and the SDK's edit body sends every key — so `"assignees":null` is the
|
||||||
|
// spelling that leaves the tracker's assignees alone, and `"assignees":[]`
|
||||||
|
// would clear them.
|
||||||
func TestNoMilestoneAndNoAssignees(t *testing.T) {
|
func TestNoMilestoneAndNoAssignees(t *testing.T) {
|
||||||
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
|
local := issue.FromText("---\nid: lone\nstate: open\nlabels: [type/task]\n"+
|
||||||
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
|
"assignees: []\nmilestone: none\ndepends: []\norigin: local\n---\n"+
|
||||||
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
|
"# A lone issue\n\n## Summary\nОдин.\n", "lone")
|
||||||
|
|
||||||
req := ToRequest(local, RequestOptions{LabelIDs: map[string]int64{"type/task": 11}})
|
opt := RequestOptions{LabelIDs: map[string]int64{"type/task": 11}}
|
||||||
if req.Assignees != nil {
|
edit := ToEdit(local, opt)
|
||||||
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", req.Assignees)
|
if edit.Assignees != nil {
|
||||||
|
t.Errorf("assignees = %v — an empty list would clear whoever the tracker has", edit.Assignees)
|
||||||
}
|
}
|
||||||
if req.Milestone != nil {
|
if edit.Milestone != nil {
|
||||||
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", req.Milestone)
|
t.Errorf("milestone = %v — a missing milestone is no opinion, not a detach", edit.Milestone)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := json.Marshal(req)
|
raw, err := json.Marshal(edit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal: %v", err)
|
t.Fatalf("marshal: %v", err)
|
||||||
}
|
}
|
||||||
body := string(raw)
|
body := string(raw)
|
||||||
for _, key := range []string{`"assignees"`, `"milestone"`, `"state"`, `"ref"`} {
|
for _, key := range []string{`"assignees":null`, `"milestone":null`, `"state":null`, `"ref":null`} {
|
||||||
if strings.Contains(body, key) {
|
if !strings.Contains(body, key) {
|
||||||
t.Errorf("%s is in the request body; on a PATCH that overwrites what the tracker holds: %s", key, body)
|
t.Errorf("%s is not in the edit body; anything else there overwrites what the tracker holds: %s", key, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A resolved-but-empty label set is the opposite statement and must be sent.
|
// The title is the one field of an edit that is not a pointer. Gitea reads
|
||||||
if !strings.Contains(body, `"labels":[11]`) {
|
// an empty one as "leave it alone" too, but this issue has a title and it
|
||||||
t.Errorf("labels missing from %s", body)
|
// has to go up.
|
||||||
}
|
if !strings.Contains(body, `"title":"A lone issue"`) {
|
||||||
empty, err := json.Marshal(ToRequest(local, RequestOptions{LabelIDs: map[string]int64{}}))
|
t.Errorf("the title is missing from %s", body)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(empty), `"labels":[]`) {
|
|
||||||
t.Errorf("a resolved label set that matched nothing must still be sent as []: %s", empty)
|
|
||||||
}
|
|
||||||
silent, err := json.Marshal(ToRequest(local, RequestOptions{}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(silent), `"labels"`) {
|
|
||||||
t.Errorf("a caller that resolved no ids must not clear the tracker's labels: %s", silent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
back, unresolved := FromPayload(&wire.Issue{
|
// A create is the opposite: there is nothing on the tracker's side to
|
||||||
Number: 9,
|
// overwrite, so the resolved label ids are sent as they stand.
|
||||||
|
created, err := json.Marshal(ToCreate(local, opt))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(created), `"labels":[11]`) {
|
||||||
|
t.Errorf("labels missing from %s", created)
|
||||||
|
}
|
||||||
|
// A resolved label set that matched nothing is still an answer, and it is
|
||||||
|
// the same answer a PUT sends at an issue that already exists.
|
||||||
|
if got := LabelIDsFor(local, RequestOptions{LabelIDs: map[string]int64{}}); got == nil || len(got) != 0 {
|
||||||
|
t.Errorf("a resolved label set that matched nothing must be an empty list, got %v", got)
|
||||||
|
}
|
||||||
|
if got := LabelIDsFor(local, RequestOptions{}); got != nil {
|
||||||
|
t.Errorf("a caller that resolved no ids has no opinion about labels, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
back, unresolved := FromPayload(&sdk.Issue{
|
||||||
|
Index: 9,
|
||||||
Title: "A lone issue",
|
Title: "A lone issue",
|
||||||
Body: WithIDMarker("## Summary\nОдин.", "lone"),
|
Body: WithIDMarker("## Summary\nОдин.", "lone"),
|
||||||
State: "open",
|
State: sdk.StateOpen,
|
||||||
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
|
HTMLURL: "https://git.noodles.cam/claude-skills/tea/issues/9",
|
||||||
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
|
}, "lone", tea, PayloadOptions{Synced: "2026-08-11T10:00:00Z"})
|
||||||
|
|
||||||
@@ -231,7 +267,7 @@ func TestNoMilestoneAndNoAssignees(t *testing.T) {
|
|||||||
// a made-up slug is an edge to a file that does not exist.
|
// a made-up slug is an edge to a file that does not exist.
|
||||||
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
|
func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
|
||||||
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
|
body := "## Summary\nx\n\n## Depends on\n- #7\n- #8\n"
|
||||||
back, unresolved := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body},
|
back, unresolved := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body},
|
||||||
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
|
"here", wire.Repo{Owner: "o", Name: "r"}, PayloadOptions{IDForNumber: map[int]string{7: "known"}})
|
||||||
|
|
||||||
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
|
if want := []string{"known"}; !reflect.DeepEqual(back.Depends, want) {
|
||||||
@@ -247,7 +283,7 @@ func TestUnresolvedNumbersAreReportedNotInvented(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
|
func TestExtraNumbersJoinTheOnesTheBodyNames(t *testing.T) {
|
||||||
body := "## Summary\nx\n\n## Depends on\n- #7\n"
|
body := "## Summary\nx\n\n## Depends on\n- #7\n"
|
||||||
back, _ := FromPayload(&wire.Issue{Number: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
|
back, _ := FromPayload(&sdk.Issue{Index: 1, Title: "T", Body: body}, "here", wire.Repo{Owner: "o", Name: "r"},
|
||||||
PayloadOptions{
|
PayloadOptions{
|
||||||
IDForNumber: map[int]string{7: "seven", 9: "nine"},
|
IDForNumber: map[int]string{7: "seven", 9: "nine"},
|
||||||
ExtraNumbers: []int{7, 9},
|
ExtraNumbers: []int{7, 9},
|
||||||
@@ -352,10 +388,10 @@ func TestNumberOf(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
|
func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
|
||||||
local := &issue.Issue{ID: "x", Origin: issue.Local}
|
local := &issue.Issue{ID: "x", Origin: issue.Local}
|
||||||
ApplyRemote(local, &wire.Issue{
|
ApplyRemote(local, &sdk.Issue{
|
||||||
Number: 42,
|
Index: 42,
|
||||||
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
|
HTMLURL: "https://git.noodles.cam/o/r/issues/42",
|
||||||
UpdatedAt: "2026-08-09T18:24:01Z",
|
Updated: when(t, "2026-08-09T18:24:01Z"),
|
||||||
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
|
}, wire.Repo{Owner: "o", Name: "r"}, "2026-08-11T10:00:00Z")
|
||||||
|
|
||||||
if local.IsLocal() {
|
if local.IsLocal() {
|
||||||
@@ -368,13 +404,16 @@ func TestApplyRemoteStampsTheSyncFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A thread is flattened for a reader, so it may be as lossy as a reader needs —
|
||||||
|
// but a payload that carries no date and no author still renders, because a
|
||||||
|
// comment that is there is worth showing whatever the tracker left out of it.
|
||||||
func TestRenderComments(t *testing.T) {
|
func TestRenderComments(t *testing.T) {
|
||||||
got := RenderComments([]wire.Comment{
|
got := RenderComments([]*sdk.Comment{
|
||||||
{ID: 1, User: wire.User{Login: "naudachu"}, CreatedAt: "2026-08-09T18:24:01Z", Body: " привет "},
|
{ID: 1, Poster: &sdk.User{UserName: "naudachu"}, Created: when(t, "2026-08-09T18:24:01Z"), Body: " привет "},
|
||||||
{ID: 2, User: wire.User{Login: "bot"}, CreatedAt: "", Body: ""},
|
{ID: 2, Poster: nil, Body: ""},
|
||||||
})
|
})
|
||||||
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
|
want := "## comment 1 — naudachu — 2026-08-09\n\nпривет\n\n" +
|
||||||
"## comment 2 — bot — \n\n(empty)\n"
|
"## comment 2 — — \n\n(empty)\n"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
t.Errorf("got:\n%q\nwant:\n%q", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIDInBodyReadsBothSpellings(t *testing.T) {
|
func TestIDInBodyReadsBothSpellings(t *testing.T) {
|
||||||
@@ -44,7 +44,7 @@ func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
|
|||||||
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
|
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
|
iss, _ := FromPayload(&sdk.Issue{Index: 42, Title: "Wire sqlc", Body: inTracker},
|
||||||
id, tea, PayloadOptions{})
|
id, tea, PayloadOptions{})
|
||||||
if strings.Contains(iss.Body, "tea:id") {
|
if strings.Contains(iss.Body, "tea:id") {
|
||||||
t.Errorf("the old marker reached the local copy: %q", iss.Body)
|
t.Errorf("the old marker reached the local copy: %q", iss.Body)
|
||||||
@@ -55,7 +55,7 @@ func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
|
|||||||
|
|
||||||
// And the next push rewrites it into the current spelling, without ever
|
// And the next push rewrites it into the current spelling, without ever
|
||||||
// having two.
|
// having two.
|
||||||
up := *ToRequest(iss, RequestOptions{}).Body
|
up := ToCreate(iss, RequestOptions{}).Body
|
||||||
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
|
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
|
||||||
t.Errorf("the marker was not rewritten: %q", up)
|
t.Errorf("the marker was not rewritten: %q", up)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "code.gitea.io/sdk/gitea"
|
||||||
|
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
|
||||||
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
|
||||||
)
|
)
|
||||||
@@ -17,21 +19,24 @@ import (
|
|||||||
// here — this package never learns which repository it is translating for
|
// here — this package never learns which repository it is translating for
|
||||||
// beyond the name it is handed.
|
// beyond the name it is handed.
|
||||||
type RequestOptions struct {
|
type RequestOptions struct {
|
||||||
// LabelIDs is name -> id for the labels this repository holds. A nil map
|
// LabelIDs is name -> id for the labels this repository holds. A label the
|
||||||
// leaves `labels` out of the request; a non-nil one sends the list, empty
|
// repository does not have is silently left off rather than failing the
|
||||||
// included, and a label the repository does not have is silently left off
|
// write — an unknown label is a bootstrap that has not run, not a reason to
|
||||||
// rather than failing the write — an unknown label is a bootstrap that has
|
// lose the issue.
|
||||||
// not run, not a reason to lose the issue.
|
//
|
||||||
|
// It is read by ToCreate and ignored by ToEdit, because Gitea's edit
|
||||||
|
// endpoint carries no labels at all. Changing them on an issue that exists
|
||||||
|
// is a PUT of its own; push makes it.
|
||||||
LabelIDs map[string]int64
|
LabelIDs map[string]int64
|
||||||
// MilestoneID is the resolved milestone. nil leaves the key out, which on
|
// MilestoneID is the resolved milestone. nil leaves the key out, which on
|
||||||
// an edit means "leave whatever is attached alone".
|
// an edit means "leave whatever is attached alone".
|
||||||
MilestoneID *int64
|
MilestoneID *int64
|
||||||
// IncludeState sends `state`. An edit that means to open or close says so;
|
// IncludeState sends `state` on an edit. An edit that means to open or
|
||||||
// a create takes the tracker's default.
|
// close says so; a create takes the tracker's default.
|
||||||
IncludeState bool
|
IncludeState bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToRequest is the request body for creating or editing an issue.
|
// ToCreate is the body of a create.
|
||||||
//
|
//
|
||||||
// The prose is sent verbatim — see the package doc on why slugs in
|
// The prose is sent verbatim — see the package doc on why slugs in
|
||||||
// `## Depends on` are not rewritten to `#N`. The one addition is the id marker,
|
// `## Depends on` are not rewritten to `#N`. The one addition is the id marker,
|
||||||
@@ -39,59 +44,100 @@ type RequestOptions struct {
|
|||||||
// deleted the local file. FromPayload takes it straight back off, so the body
|
// deleted the local file. FromPayload takes it straight back off, so the body
|
||||||
// still round-trips byte for byte.
|
// still round-trips byte for byte.
|
||||||
//
|
//
|
||||||
// A create needs a title and a body, so those two are always filled. Every
|
// Every field of a create is a value and every one of them is sent, which is
|
||||||
// other key is left out unless the caller has an opinion about it: on a PATCH
|
// safe in a way an edit is not: there is nothing on the tracker's side yet for
|
||||||
// an absent key leaves the tracker's value alone, and a present one overwrites
|
// an empty field to overwrite.
|
||||||
// it — see wire.IssueRequest for what each of them clears when it is sent
|
func ToCreate(i *issue.Issue, opt RequestOptions) sdk.CreateIssueOption {
|
||||||
// empty.
|
out := sdk.CreateIssueOption{
|
||||||
func ToRequest(i *issue.Issue, opt RequestOptions) *wire.IssueRequest {
|
Title: i.Title,
|
||||||
r := &wire.IssueRequest{
|
Body: WithIDMarker(strings.TrimSpace(i.Body), i.ID),
|
||||||
Title: wire.Set(i.Title),
|
// Copied, so the request body and the issue it came from cannot alias
|
||||||
Body: wire.Set(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
|
// one slice: whatever a caller does to either afterwards is not a
|
||||||
}
|
// change to what was sent.
|
||||||
if opt.LabelIDs != nil {
|
Assignees: slices.Clone(i.Assignees),
|
||||||
ids := []int64{}
|
Labels: LabelIDsFor(i, opt),
|
||||||
for _, name := range i.Labels {
|
Ref: strings.TrimSpace(i.Extra[BranchKey]),
|
||||||
if id, ok := opt.LabelIDs[name]; ok {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Labels = &ids
|
|
||||||
}
|
|
||||||
// Copied, so the request body and the issue it came from cannot alias one
|
|
||||||
// slice: whatever a caller does to either afterwards is not a change to
|
|
||||||
// what was sent.
|
|
||||||
if len(i.Assignees) > 0 {
|
|
||||||
r.Assignees = wire.Set(slices.Clone(i.Assignees))
|
|
||||||
}
|
}
|
||||||
if opt.MilestoneID != nil {
|
if opt.MilestoneID != nil {
|
||||||
r.Milestone = opt.MilestoneID
|
out.Milestone = *opt.MilestoneID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToEdit is the body of an edit.
|
||||||
|
//
|
||||||
|
// EVERY FIELD IS A POINTER AND MOST OF THEM ARE LEFT NIL, because Gitea reads
|
||||||
|
// an absent value as "no opinion" and a present one as "make it this", and the
|
||||||
|
// difference is not academic: an empty `ref` CLEARS the branch an issue is
|
||||||
|
// pinned to, and an empty `assignees` clears its assignees. A caller meaning to
|
||||||
|
// change only the state would do both by accident with plain zero values.
|
||||||
|
//
|
||||||
|
// The exception is the title, which the SDK spells as a plain string and always
|
||||||
|
// sends. Gitea reads an EMPTY title as "leave it alone" — it is the one field
|
||||||
|
// of an edit where the zero value already means no opinion — so a caller that
|
||||||
|
// wants to rename says so by filling it, and `kettle close` stays a change of
|
||||||
|
// state and nothing else.
|
||||||
|
func ToEdit(i *issue.Issue, opt RequestOptions) sdk.EditIssueOption {
|
||||||
|
out := sdk.EditIssueOption{
|
||||||
|
Title: i.Title,
|
||||||
|
Body: sdk.OptionalString(WithIDMarker(strings.TrimSpace(i.Body), i.ID)),
|
||||||
|
Milestone: opt.MilestoneID,
|
||||||
|
}
|
||||||
|
if len(i.Assignees) > 0 {
|
||||||
|
out.Assignees = slices.Clone(i.Assignees)
|
||||||
}
|
}
|
||||||
if opt.IncludeState {
|
if opt.IncludeState {
|
||||||
r.State = wire.Set(i.State)
|
state := sdk.StateType(i.State)
|
||||||
|
out.State = &state
|
||||||
}
|
}
|
||||||
// An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
// An empty `branch:` is "no opinion", not "no branch": sending ref="" would
|
||||||
// clear whatever is set on the Gitea side, so the key is left out instead.
|
// clear whatever is set on the Gitea side, so the key is left out instead.
|
||||||
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
|
if branch := strings.TrimSpace(i.Extra[BranchKey]); branch != "" {
|
||||||
r.Ref = wire.Set(branch)
|
out.Ref = sdk.OptionalString(branch)
|
||||||
}
|
}
|
||||||
return r
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabelIDsFor is the ids this issue's labels resolve to, in the issue's own
|
||||||
|
// order — what a create sends, and what a push PUTs at an issue whose labels
|
||||||
|
// have to be made to match afterwards.
|
||||||
|
//
|
||||||
|
// One answer to one question, exported so those two cannot derive it
|
||||||
|
// differently: an edit carries no labels at all, so every issue that already
|
||||||
|
// exists gets its label set through a PUT, and a PUT that disagreed with what a
|
||||||
|
// create would have sent would make a pushed issue and a re-pushed one two
|
||||||
|
// different things.
|
||||||
|
//
|
||||||
|
// nil for a caller that resolved no ids, and an empty list — never nil — for
|
||||||
|
// one that resolved some and matched none. The difference is a statement to the
|
||||||
|
// tracker: `[]` clears every label on the issue.
|
||||||
|
func LabelIDsFor(i *issue.Issue, opt RequestOptions) []int64 {
|
||||||
|
if opt.LabelIDs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := []int64{}
|
||||||
|
for _, name := range i.Labels {
|
||||||
|
if id, ok := opt.LabelIDs[name]; ok {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
|
// ApplyRemote stamps the sync-owned fields onto an issue after a successful
|
||||||
// write. Mutates and returns it; `origin` is the one domain field this touches,
|
// write. Mutates and returns it; `origin` is the one domain field this touches,
|
||||||
// and it touches it because "this work exists somewhere else now" is exactly
|
// and it touches it because "this work exists somewhere else now" is exactly
|
||||||
// what has just become true.
|
// what has just become true.
|
||||||
func ApplyRemote(i *issue.Issue, p *wire.Issue, repo wire.Repo, synced string) *issue.Issue {
|
func ApplyRemote(i *issue.Issue, p *sdk.Issue, repo wire.Repo, synced string) *issue.Issue {
|
||||||
if i.Extra == nil {
|
if i.Extra == nil {
|
||||||
i.Extra = map[string]string{}
|
i.Extra = map[string]string{}
|
||||||
}
|
}
|
||||||
i.Origin = Origin
|
i.Origin = Origin
|
||||||
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: p.Number}.String()
|
i.Extra[GiteaKey] = wire.Key{Repo: repo, Number: int(p.Index)}.String()
|
||||||
i.Extra[URLKey] = p.HTMLURL
|
i.Extra[URLKey] = p.HTMLURL
|
||||||
i.Extra[SyncedKey] = synced
|
i.Extra[SyncedKey] = synced
|
||||||
if p.UpdatedAt != "" {
|
if stamp := Stamp(p.Updated); stamp != "" {
|
||||||
i.Extra[RemoteUpdatedKey] = p.UpdatedAt
|
i.Extra[RemoteUpdatedKey] = stamp
|
||||||
}
|
}
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
// Package wire is the protocol's identifiers: the way this project addresses
|
||||||
|
// one repository and one issue, and nothing else.
|
||||||
|
//
|
||||||
|
// The JSON shapes used to live here too, because the transport and the bridge
|
||||||
|
// both had to name a Gitea issue and neither may import the other. They are
|
||||||
|
// code.gitea.io/sdk/gitea's now — one vocabulary, maintained by the people who
|
||||||
|
// maintain the server — and the reason the shapes were lifted out of the
|
||||||
|
// transport in the first place still holds: two structs for one payload drift,
|
||||||
|
// and the first field only one of them learns is a field the other silently
|
||||||
|
// drops.
|
||||||
|
//
|
||||||
|
// WHAT THE SDK HAS NO ANSWER FOR IS ADDRESSING. `42`, `#42`, `owner/repo#42`
|
||||||
|
// and an issue URL are four spellings of one thing, all four are what somebody
|
||||||
|
// has in hand, and the SDK takes an owner, a name and an int64 — it never
|
||||||
|
// parses. So the parsing stays, and so does the pair of types it produces: Repo
|
||||||
|
// and Key, the values that go into the ledger, into the `gitea:` metadata field
|
||||||
|
// and into every receipt.
|
||||||
|
//
|
||||||
|
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
|
||||||
|
// filesystem, no configuration, no SDK, and above all not internal/issue. An
|
||||||
|
// identifier that reached for any of those would drag every user of it into
|
||||||
|
// that layer. layering_test.go fails the moment it stops being true.
|
||||||
package wire
|
package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The protocol is shared by two layers that may not import each other, and it
|
// The identifiers are shared by two layers that may not import each other, and
|
||||||
// can only be shared because it reaches for nothing itself: no domain, no
|
// they can only be shared because they reach for nothing themselves: no domain,
|
||||||
// configuration, no path resolution, no third party. One import from any of
|
// no configuration, no path resolution, no third party — the Gitea SDK
|
||||||
// those would drag every user of this package into that layer — which is the
|
// included. One import from any of those would drag every user of this package
|
||||||
// whole reason these shapes were lifted out of the transport rather than left
|
// into that layer, which is the whole reason an issue key is parsed here rather
|
||||||
// there for the bridge to reimplement.
|
// than wherever it is first needed.
|
||||||
//
|
//
|
||||||
// The dependency walk, so a helper pulled in three packages deep is caught as
|
// The dependency walk, so a helper pulled in three packages deep is caught as
|
||||||
// the same violation as one written at the top of a file.
|
// the same violation as one written at the top of a file.
|
||||||
@@ -28,7 +28,7 @@ func TestWireDependsOnNothing(t *testing.T) {
|
|||||||
// because it has no domain name in front of it.
|
// because it has no domain name in front of it.
|
||||||
first, _, _ := strings.Cut(dep, "/")
|
first, _, _ := strings.Cut(dep, "/")
|
||||||
if strings.Contains(first, ".") {
|
if strings.Contains(first, ".") {
|
||||||
t.Errorf("the protocol imports %s — these are shapes and identifiers, and nothing else belongs here", dep)
|
t.Errorf("the protocol imports %s — these are identifiers, and nothing else belongs here", dep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,10 +43,10 @@ func TestWireReachesNeitherTheNetworkNorTheDisk(t *testing.T) {
|
|||||||
forbidden := map[string]string{
|
forbidden := map[string]string{
|
||||||
"net/http": "an HTTP call belongs in the transport",
|
"net/http": "an HTTP call belongs in the transport",
|
||||||
"net": "an HTTP call belongs in the transport",
|
"net": "an HTTP call belongs in the transport",
|
||||||
"os": "a shape reads no file and no environment",
|
"os": "an identifier reads no file and no environment",
|
||||||
"os/exec": "nothing here shells out",
|
"os/exec": "nothing here shells out",
|
||||||
"io": "nothing here is a stream",
|
"io": "nothing here is a stream",
|
||||||
"time": "a timestamp crosses as the string the tracker sent",
|
"time": "an address has no timestamp in it",
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
out, err := exec.Command("go", "list", "-f", `{{join .Imports "\n"}}`, ".").Output()
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package wire
|
|
||||||
|
|
||||||
// The bodies that go up, and the shorthand that fills them.
|
|
||||||
//
|
|
||||||
// The omitted keys carry meaning of their own on a PATCH: a key that is absent
|
|
||||||
// leaves the tracker's value alone, and a key that is present overwrites it. So
|
|
||||||
// "no opinion" and "empty" must not marshal the same way, which is what every
|
|
||||||
// pointer and every omitempty below is for.
|
|
||||||
|
|
||||||
// IssueRequest is the body of a create or an edit.
|
|
||||||
//
|
|
||||||
// Every field is a pointer because Gitea reads an absent key as "no opinion"
|
|
||||||
// and a present one as "make it this", and the difference is not academic: an
|
|
||||||
// empty `ref` CLEARS the branch an issue is pinned to, and an empty `labels`
|
|
||||||
// clears its labels. A caller meaning to change only the state would do both by
|
|
||||||
// accident with plain zero values. Set fills a field; leaving it nil leaves the
|
|
||||||
// tracker's copy alone.
|
|
||||||
type IssueRequest struct {
|
|
||||||
Title *string `json:"title,omitempty"`
|
|
||||||
Body *string `json:"body,omitempty"`
|
|
||||||
// Labels is a pointer because `[]` is a statement — it clears every label
|
|
||||||
// on the issue — while a caller that has not resolved label ids at all has
|
|
||||||
// no business making it. A plain slice with omitempty cannot say both.
|
|
||||||
Labels *[]int64 `json:"labels,omitempty"`
|
|
||||||
Assignees *[]string `json:"assignees,omitempty"`
|
|
||||||
// Milestone is a pointer for the same reason, and because 0 is Gitea's
|
|
||||||
// "detach from its milestone" — a value somebody may well mean.
|
|
||||||
Milestone *int64 `json:"milestone,omitempty"`
|
|
||||||
State *string `json:"state,omitempty"`
|
|
||||||
Ref *string `json:"ref,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LabelRequest is the body of a label create or edit — everything a repository
|
|
||||||
// needs to make one label.
|
|
||||||
//
|
|
||||||
// Value fields, not pointers, and every one of them is sent: Gitea 1.26 patches
|
|
||||||
// only what it is given, but an older server reads an absent field as empty and
|
|
||||||
// blanks it. A label edit is rare enough that sending the unchanged name and
|
|
||||||
// description along costs nothing and removes a way to lose them.
|
|
||||||
//
|
|
||||||
// It goes up as a request body of its own because `tea labels create` could not
|
|
||||||
// set `exclusive` — the flag that makes `type/*` behave like a single choice —
|
|
||||||
// which is the whole reason label creation went through the API rather than a
|
|
||||||
// CLI wrapper.
|
|
||||||
//
|
|
||||||
// What a label MEANS — which namespaces are exclusive, what colour a severity
|
|
||||||
// is — is not decided here. This is the shape; the taxonomy is the domain's and
|
|
||||||
// the palette is the bridge's.
|
|
||||||
type LabelRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Exclusive bool `json:"exclusive"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set is a pointer to v, for filling the optional fields of a request. Gitea
|
|
||||||
// reads an absent key as "no opinion" and a present one as "make it this", so
|
|
||||||
// those fields are pointers and this is the shorthand that fills them.
|
|
||||||
func Set[T any](v T) *T { return &v }
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// Package wire is the protocol: the JSON shapes a Gitea instance sends and
|
|
||||||
// takes, the identifiers that address them, and nothing else.
|
|
||||||
//
|
|
||||||
// It is a package because two layers need the same vocabulary and neither may
|
|
||||||
// import the other. internal/gitea is the transport — HTTP verbs, pagination,
|
|
||||||
// status codes, credentials — and internal/mapping is the bridge — md <-> JSON,
|
|
||||||
// pure functions, no network. Both have to name a Gitea issue, and when each
|
|
||||||
// named it with a struct of its own, every command written on top of the two
|
|
||||||
// would have had to copy a payload field by field from one spelling into the
|
|
||||||
// other. Two copies of a shape also drift: the first field only one of them
|
|
||||||
// learns is a field the other silently drops.
|
|
||||||
//
|
|
||||||
// THIS PACKAGE IMPORTS THE STANDARD LIBRARY AND NOTHING ELSE — no HTTP, no
|
|
||||||
// filesystem, no configuration, and above all not internal/issue. That is what
|
|
||||||
// lets the transport and the bridge share it without either one landing inside
|
|
||||||
// the other's layer, and layering_test.go fails the moment it stops being true.
|
|
||||||
//
|
|
||||||
// Structs and not map[string]any, because the two representations disagreeing
|
|
||||||
// is the failure this vocabulary exists to make debuggable: a typo in a key is
|
|
||||||
// a compile error here and a silently dropped field there. Anything Gitea sends
|
|
||||||
// that is not named below is not read by anybody — decoding is lossy on
|
|
||||||
// purpose, since the tracker is not the record for anything the domain owns.
|
|
||||||
package wire
|
|
||||||
|
|
||||||
// User is whoever wrote or was assigned something.
|
|
||||||
//
|
|
||||||
// Only the login crosses this boundary — it is the one field of a Gitea user
|
|
||||||
// that means anything to a command, it is what `assignees:` holds, and a
|
|
||||||
// display name is not an identity anything can be pushed against. A transport
|
|
||||||
// that carries the rest invites somebody to use it.
|
|
||||||
type User struct {
|
|
||||||
Login string `json:"login"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label as the tracker holds it.
|
|
||||||
//
|
|
||||||
// Color is hex. Gitea returns it without the leading `#` (`ee0701`) and accepts
|
|
||||||
// it either way; both spellings are the same color, so a comparison has to
|
|
||||||
// strip before it compares.
|
|
||||||
type Label struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Exclusive bool `json:"exclusive"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Milestone as the tracker holds it. The domain carries its title; the id
|
|
||||||
// exists only long enough to be sent back.
|
|
||||||
type Milestone struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepoRef is the repository an issue payload says it belongs to. Present on a
|
|
||||||
// dependency listing, where the answer may well be another repository.
|
|
||||||
type RepoRef struct {
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
FullName string `json:"full_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PullRequest is non-nil on a row that is a pull request rather than an issue.
|
|
||||||
// Gitea's issue endpoints return both, and `type=issues` is a filter the server
|
|
||||||
// has been known to ignore — which is why every listing re-checks it.
|
|
||||||
type PullRequest struct {
|
|
||||||
Merged bool `json:"merged"`
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue is a tracker row: a Gitea issue as the API reports it.
|
|
||||||
//
|
|
||||||
// Timestamps stay strings. They are written into an issue's metadata verbatim
|
|
||||||
// and compared as opaque values; parsing them here would mean formatting them
|
|
||||||
// back, and a round trip through a time package is a chance to hand the store a
|
|
||||||
// different string than the tracker sent.
|
|
||||||
type Issue struct {
|
|
||||||
Number int `json:"number"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
State string `json:"state"`
|
|
||||||
// Ref is the branch the issue is pinned to.
|
|
||||||
Ref string `json:"ref"`
|
|
||||||
// HTMLURL and UpdatedAt are the tracker's own bookkeeping and land in the
|
|
||||||
// domain's Extra untouched.
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
// Comments is a count, not a thread: the thread is fetched separately and
|
|
||||||
// parked beside the issue as a sidecar.
|
|
||||||
Comments int `json:"comments"`
|
|
||||||
Labels []Label `json:"labels"`
|
|
||||||
Assignees []User `json:"assignees"`
|
|
||||||
Milestone *Milestone `json:"milestone"`
|
|
||||||
Repository *RepoRef `json:"repository"`
|
|
||||||
PullRequest *PullRequest `json:"pull_request"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsPullRequest reports whether this row is a pull request.
|
|
||||||
func (i *Issue) IsPullRequest() bool { return i.PullRequest != nil }
|
|
||||||
|
|
||||||
// LabelNames are the label names, in the order the tracker listed them.
|
|
||||||
func (i *Issue) LabelNames() []string {
|
|
||||||
out := make([]string, 0, len(i.Labels))
|
|
||||||
for _, l := range i.Labels {
|
|
||||||
out = append(out, l.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssigneeLogins are the assignees, as logins.
|
|
||||||
func (i *Issue) AssigneeLogins() []string {
|
|
||||||
out := make([]string, 0, len(i.Assignees))
|
|
||||||
for _, a := range i.Assignees {
|
|
||||||
out = append(out, a.Login)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// MilestoneTitle is the milestone's title, or "" when there is none.
|
|
||||||
func (i *Issue) MilestoneTitle() string {
|
|
||||||
if i.Milestone == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return i.Milestone.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
// KeyIn is this issue's cross-repo handle. The payload's own repository wins
|
|
||||||
// when it carries one — a dependency listing answers with issues from other
|
|
||||||
// repositories — and fallback is the repository that was asked.
|
|
||||||
func (i *Issue) KeyIn(fallback Repo) Key {
|
|
||||||
repo := fallback
|
|
||||||
if i.Repository != nil {
|
|
||||||
if r, err := ParseRepo(i.Repository.FullName); err == nil {
|
|
||||||
repo = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Key{Repo: repo, Number: i.Number}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comment is one entry in an issue's thread.
|
|
||||||
//
|
|
||||||
// Read only, in practice: a thread is flattened to markdown for a reader and
|
|
||||||
// nothing writes that markdown back, which is why the rendering may be as lossy
|
|
||||||
// as a reader needs.
|
|
||||||
type Comment struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
HTMLURL string `json:"html_url"`
|
|
||||||
User User `json:"user"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2016 The Gitea Authors
|
||||||
|
Copyright (c) 2014 The Gogs Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionTask represents a workflow run task (from /actions/tasks endpoint)
|
||||||
|
// This is the format returned by older Gitea versions
|
||||||
|
type ActionTask struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"` // Workflow name
|
||||||
|
HeadBranch string `json:"head_branch"`
|
||||||
|
HeadSHA string `json:"head_sha"`
|
||||||
|
RunNumber int64 `json:"run_number"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
DisplayTitle string `json:"display_title"` // PR title or commit message
|
||||||
|
Status string `json:"status"`
|
||||||
|
WorkflowID string `json:"workflow_id"` // e.g. "ci.yml"
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
RunStartedAt time.Time `json:"run_started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionTaskResponse holds the response for listing action tasks
|
||||||
|
type ActionTaskResponse struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
WorkflowRuns []*ActionTask `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowRun represents a workflow run (from /actions/runs endpoint)
|
||||||
|
// This is the format returned by newer Gitea versions
|
||||||
|
type ActionWorkflowRun struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
DisplayTitle string `json:"display_title"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
HeadBranch string `json:"head_branch,omitempty"`
|
||||||
|
HeadSha string `json:"head_sha"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
RunAttempt int64 `json:"run_attempt"`
|
||||||
|
RunNumber int64 `json:"run_number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
Actor *User `json:"actor,omitempty"`
|
||||||
|
TriggerActor *User `json:"trigger_actor,omitempty"`
|
||||||
|
Repository *Repository `json:"repository,omitempty"`
|
||||||
|
HeadRepository *Repository `json:"head_repository,omitempty"`
|
||||||
|
RepositoryID int64 `json:"repository_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowRunsResponse holds the response for listing workflow runs
|
||||||
|
type ActionWorkflowRunsResponse struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
WorkflowRuns []*ActionWorkflowRun `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowJob represents a job within a workflow run
|
||||||
|
type ActionWorkflowJob struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RunID int64 `json:"run_id"`
|
||||||
|
RunURL string `json:"run_url"`
|
||||||
|
RunAttempt int64 `json:"run_attempt"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HeadBranch string `json:"head_branch,omitempty"`
|
||||||
|
HeadSha string `json:"head_sha"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion,omitempty"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
RunnerID int64 `json:"runner_id,omitempty"`
|
||||||
|
RunnerName string `json:"runner_name,omitempty"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
Steps []*ActionWorkflowStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowJobsResponse holds the response for listing workflow jobs
|
||||||
|
type ActionWorkflowJobsResponse struct {
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
Jobs []*ActionWorkflowJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowStep represents a step within a job
|
||||||
|
type ActionWorkflowStep struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion,omitempty"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionRunsOptions options for listing repository action runs
|
||||||
|
type ListRepoActionRunsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Branch string // Filter by branch
|
||||||
|
Event string // Filter by triggering event
|
||||||
|
Status string // Filter by status (pending, queued, in_progress, failure, success, skipped)
|
||||||
|
Actor string // Filter by actor (user who triggered the run)
|
||||||
|
HeadSHA string // Filter by the SHA of the head commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode encodes the options to URL query parameters
|
||||||
|
func (opt *ListRepoActionRunsOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Branch != "" {
|
||||||
|
query.Add("branch", opt.Branch)
|
||||||
|
}
|
||||||
|
if opt.Event != "" {
|
||||||
|
query.Add("event", opt.Event)
|
||||||
|
}
|
||||||
|
if opt.Status != "" {
|
||||||
|
query.Add("status", opt.Status)
|
||||||
|
}
|
||||||
|
if opt.Actor != "" {
|
||||||
|
query.Add("actor", opt.Actor)
|
||||||
|
}
|
||||||
|
if opt.HeadSHA != "" {
|
||||||
|
query.Add("head_sha", opt.HeadSHA)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionJobsOptions options for listing repository action jobs
|
||||||
|
type ListRepoActionJobsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Status string // Filter by status (pending, queued, in_progress, failure, success, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode encodes the options to URL query parameters
|
||||||
|
func (opt *ListRepoActionJobsOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Status != "" {
|
||||||
|
query.Add("status", opt.Status)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionRuns lists workflow runs for a repository.
|
||||||
|
// Requires Gitea 1.26.0 or later. For older versions, use ListRepoActionTasks.
|
||||||
|
func (c *Client) ListRepoActionRuns(owner, repo string, opt ListRepoActionRunsOptions) (*ActionWorkflowRunsResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/runs", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionWorkflowRunsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionRun gets a single workflow run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) GetRepoActionRun(owner, repo string, runID int64) (*ActionWorkflowRun, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
run := new(ActionWorkflowRun)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/runs/%d", owner, repo, runID), jsonHeader, nil, run)
|
||||||
|
return run, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionRunJobs lists jobs for a workflow run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) ListRepoActionRunJobs(owner, repo string, runID int64, opt ListRepoActionJobsOptions) (*ActionWorkflowJobsResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs", owner, repo, runID))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionWorkflowJobsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionJobs lists all jobs for a repository.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) ListRepoActionJobs(owner, repo string, opt ListRepoActionJobsOptions) (*ActionWorkflowJobsResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/jobs", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionWorkflowJobsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionJob gets a single job.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) GetRepoActionJob(owner, repo string, jobID int64) (*ActionWorkflowJob, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
job := new(ActionWorkflowJob)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/jobs/%d", owner, repo, jobID), jsonHeader, nil, job)
|
||||||
|
return job, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionJobLogs gets the logs for a specific job.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) GetRepoActionJobLogs(owner, repo string, jobID int64) ([]byte, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", owner, repo, jobID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionTasks lists workflow tasks for a repository (Gitea 1.24.x and earlier)
|
||||||
|
// Use this for older Gitea versions that don't have /actions/runs endpoint
|
||||||
|
func (c *Client) ListRepoActionTasks(owner, repo string, opt ListOptions) (*ActionTaskResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/tasks", owner, repo))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
|
||||||
|
resp := new(ActionTaskResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoActionRun deletes a workflow run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) DeleteRepoActionRun(owner, repo string, runID int64) (*Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/actions/runs/%d", owner, repo, runID), jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerunRepoActionRun reruns an entire workflow run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) RerunRepoActionRun(owner, repo string, runID int64) (*ActionWorkflowRun, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
run := new(ActionWorkflowRun)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/actions/runs/%d/rerun", owner, repo, runID), jsonHeader, nil, run)
|
||||||
|
return run, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerunRepoActionRunFailedJobs reruns all failed jobs in a workflow run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) RerunRepoActionRunFailedJobs(owner, repo string, runID int64) (*Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", owner, repo, runID), jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerunRepoActionJob reruns a specific workflow job in a run.
|
||||||
|
// Requires Gitea 1.26.0 or later.
|
||||||
|
func (c *Client) RerunRepoActionJob(owner, repo string, runID, jobID int64) (*ActionWorkflowJob, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
job := new(ActionWorkflowJob)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs/%d/rerun", owner, repo, runID, jobID), jsonHeader, nil, job)
|
||||||
|
return job, resp, err
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistrationToken is returned when creating an Actions runner registration token.
|
||||||
|
type RegistrationToken struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrUpdateSecretOption contains the data for creating or updating an Actions secret.
|
||||||
|
type CreateOrUpdateSecretOption struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether a secret payload can be sent to the API.
|
||||||
|
func (opt CreateOrUpdateSecretOption) Validate() error {
|
||||||
|
if len(opt.Data) == 0 {
|
||||||
|
return errors.New("empty Data field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionVariable represents an Actions variable.
|
||||||
|
type ActionVariable struct {
|
||||||
|
OwnerID int64 `json:"owner_id"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateActionVariableOption is used to create an Actions variable.
|
||||||
|
type CreateActionVariableOption struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the variable create payload is valid.
|
||||||
|
func (opt CreateActionVariableOption) Validate() error {
|
||||||
|
if len(opt.Value) == 0 {
|
||||||
|
return errors.New("empty Value field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActionVariableOption is used to update an Actions variable.
|
||||||
|
type UpdateActionVariableOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the variable update payload is valid.
|
||||||
|
func (opt UpdateActionVariableOption) Validate() error {
|
||||||
|
if len(opt.Value) == 0 {
|
||||||
|
return errors.New("empty Value field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActionRunnersOptions controls runner listing requests.
|
||||||
|
type ListActionRunnersOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Disabled *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns the runner list options into a query string.
|
||||||
|
func (opt *ListActionRunnersOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Disabled != nil {
|
||||||
|
query.Add("disabled", fmt.Sprintf("%t", *opt.Disabled))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRunnerLabel represents a runner label.
|
||||||
|
type ActionRunnerLabel struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRunner represents an Actions runner.
|
||||||
|
type ActionRunner struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Busy bool `json:"busy"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
Ephemeral bool `json:"ephemeral"`
|
||||||
|
Labels []*ActionRunnerLabel `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditActionRunnerOption contains editable runner fields.
|
||||||
|
type EditActionRunnerOption struct {
|
||||||
|
Disabled *bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the runner update payload is valid.
|
||||||
|
func (opt EditActionRunnerOption) Validate() error {
|
||||||
|
if opt.Disabled == nil {
|
||||||
|
return errors.New("nil Disabled field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionRunnersResponse contains a page of runners.
|
||||||
|
type ActionRunnersResponse struct {
|
||||||
|
Runners []*ActionRunner `json:"runners"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflow represents a repository workflow definition.
|
||||||
|
type ActionWorkflow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
BadgeURL string `json:"badge_url"`
|
||||||
|
DeletedAt time.Time `json:"deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionWorkflowResponse contains a workflow list response.
|
||||||
|
type ActionWorkflowResponse struct {
|
||||||
|
Workflows []*ActionWorkflow `json:"workflows"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateActionWorkflowDispatchOption triggers a workflow_dispatch event.
|
||||||
|
type CreateActionWorkflowDispatchOption struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Inputs map[string]string `json:"inputs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the dispatch payload is valid.
|
||||||
|
func (opt CreateActionWorkflowDispatchOption) Validate() error {
|
||||||
|
if len(opt.Ref) == 0 {
|
||||||
|
return errors.New("empty Ref field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDetails contains the workflow run identifiers returned by workflow dispatch.
|
||||||
|
type RunDetails struct {
|
||||||
|
WorkflowRunID int64 `json:"workflow_run_id"`
|
||||||
|
RunURL string `json:"run_url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionArtifact represents an Actions artifact.
|
||||||
|
type ActionArtifact struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SizeInBytes int64 `json:"size_in_bytes"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ArchiveDownloadURL string `json:"archive_download_url"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
WorkflowRun *ActionWorkflowRun `json:"workflow_run"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionArtifactsResponse contains a page of artifacts.
|
||||||
|
type ActionArtifactsResponse struct {
|
||||||
|
Artifacts []*ActionArtifact `json:"artifacts"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActionArtifactsOptions controls artifact listing requests.
|
||||||
|
type ListActionArtifactsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns the artifact list options into a query string.
|
||||||
|
func (opt *ListActionArtifactsOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Name != "" {
|
||||||
|
query.Add("name", opt.Name)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) createActionRegistrationToken(path string) (*RegistrationToken, *Response, error) {
|
||||||
|
token := new(RegistrationToken)
|
||||||
|
resp, err := c.getParsedResponse("POST", path, nil, nil, token)
|
||||||
|
return token, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listActionRuns(path string, opt ListRepoActionRunsOptions) (*ActionWorkflowRunsResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(path)
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionWorkflowRunsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listActionJobs(path string, opt ListRepoActionJobsOptions) (*ActionWorkflowJobsResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_26_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(path)
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionWorkflowJobsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listActionRunners(path string, opt ListActionRunnersOptions) (*ActionRunnersResponse, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(path)
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionRunnersResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getActionRunner(path string) (*ActionRunner, *Response, error) {
|
||||||
|
runner := new(ActionRunner)
|
||||||
|
resp, err := c.getParsedResponse("GET", path, jsonHeader, nil, runner)
|
||||||
|
return runner, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) updateActionRunner(path string, opt EditActionRunnerOption) (*ActionRunner, *Response, error) {
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
runner := new(ActionRunner)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", path, jsonHeader, bytes.NewReader(body), runner)
|
||||||
|
return runner, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listActionArtifacts(path string, opt ListActionArtifactsOptions) (*ActionArtifactsResponse, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(path)
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
resp := new(ActionArtifactsResponse)
|
||||||
|
response, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, resp)
|
||||||
|
return resp, response, err
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Activity represents a user or organization activity
|
||||||
|
type Activity struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ActUserID int64 `json:"act_user_id"`
|
||||||
|
ActUser *User `json:"act_user"`
|
||||||
|
OpType string `json:"op_type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Repo *Repository `json:"repo"`
|
||||||
|
CommentID int64 `json:"comment_id"`
|
||||||
|
Comment *Comment `json:"comment"`
|
||||||
|
RefName string `json:"ref_name"`
|
||||||
|
IsPrivate bool `json:"is_private"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivityPub represents an ActivityPub object
|
||||||
|
type ActivityPub map[string]interface{}
|
||||||
|
|
||||||
|
// GetActivityPubPerson returns the Person actor for a user
|
||||||
|
func (c *Client) GetActivityPubPerson(userID int64) (ActivityPub, *Response, error) {
|
||||||
|
result := make(ActivityPub)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/activitypub/user-id/%d", userID),
|
||||||
|
jsonHeader, nil, &result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendActivityPubInbox sends an ActivityPub message to a user's inbox
|
||||||
|
func (c *Client) SendActivityPubInbox(userID int64, activity ActivityPub) (*Response, error) {
|
||||||
|
body, err := json.Marshal(activity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/activitypub/user-id/%d/inbox", userID),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivityPubPersonResponse returns the raw ActivityPub Person response
|
||||||
|
func (c *Client) GetActivityPubPersonResponse(userID int64) ([]byte, *Response, error) {
|
||||||
|
return c.getResponse("GET",
|
||||||
|
fmt.Sprintf("/activitypub/user-id/%d", userID),
|
||||||
|
jsonHeader, nil)
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ListAdminActionJobs lists all admin-scope Actions jobs.
|
||||||
|
func (c *Client) ListAdminActionJobs(opt ListRepoActionJobsOptions) (*ActionWorkflowJobsResponse, *Response, error) {
|
||||||
|
return c.listActionJobs("/admin/actions/jobs", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminActionRuns lists all admin-scope Actions workflow runs.
|
||||||
|
func (c *Client) ListAdminActionRuns(opt ListRepoActionRunsOptions) (*ActionWorkflowRunsResponse, *Response, error) {
|
||||||
|
return c.listActionRuns("/admin/actions/runs", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminActionRunners lists all admin-scope Actions runners.
|
||||||
|
func (c *Client) ListAdminActionRunners(opt ListActionRunnersOptions) (*ActionRunnersResponse, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionRunners("/admin/actions/runners", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminActionRunner gets one admin-scope Actions runner.
|
||||||
|
func (c *Client) GetAdminActionRunner(runnerID int64) (*ActionRunner, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getActionRunner(fmt.Sprintf("/admin/actions/runners/%d", runnerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAdminActionRunner deletes one admin-scope Actions runner.
|
||||||
|
func (c *Client) DeleteAdminActionRunner(runnerID int64) (*Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/admin/actions/runners/%d", runnerID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAdminActionRunner updates one admin-scope Actions runner.
|
||||||
|
func (c *Client) UpdateAdminActionRunner(runnerID int64, opt EditActionRunnerOption) (*ActionRunner, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.updateActionRunner(fmt.Sprintf("/admin/actions/runners/%d", runnerID), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdminActionRunnerRegistrationToken creates an admin-scope runner registration token.
|
||||||
|
func (c *Client) CreateAdminActionRunnerRegistrationToken() (*RegistrationToken, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.createActionRegistrationToken("/admin/actions/runners/registration-token")
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Badge represents a user badge
|
||||||
|
type Badge struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserBadges lists badges of a user
|
||||||
|
func (c *Client) ListUserBadges(username string) ([]*Badge, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&username); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
badges := make([]*Badge, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/admin/users/%s/badges", username),
|
||||||
|
jsonHeader, nil, &badges)
|
||||||
|
return badges, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBadgeOption represents options for adding badges to a user
|
||||||
|
type UserBadgeOption struct {
|
||||||
|
BadgeSlugs []string `json:"badge_slugs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUserBadges adds badges to a user by their slugs
|
||||||
|
func (c *Client) AddUserBadges(username string, opt UserBadgeOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/admin/users/%s/badges", username),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent && status != http.StatusCreated {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserBadge deletes a user's badge
|
||||||
|
func (c *Client) DeleteUserBadge(username string, opt UserBadgeOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/admin/users/%s/badges", username),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CronTask represents a Cron task
|
||||||
|
type CronTask struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Schedule string `json:"schedule"`
|
||||||
|
Next time.Time `json:"next"`
|
||||||
|
Prev time.Time `json:"prev"`
|
||||||
|
ExecTimes int64 `json:"exec_times"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCronTaskOptions list options for ListCronTasks
|
||||||
|
type ListCronTaskOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCronTasks list available cron tasks
|
||||||
|
func (c *Client) ListCronTasks(opt ListCronTaskOptions) ([]*CronTask, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
ct := make([]*CronTask, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/cron?%s", opt.getURLQuery().Encode()), jsonHeader, nil, &ct)
|
||||||
|
return ct, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCronTasks run a cron task
|
||||||
|
func (c *Client) RunCronTasks(task string) (*Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&task); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/admin/cron/%s", task), jsonHeader, nil)
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListAdminEmailsOptions options for listing all emails
|
||||||
|
type ListAdminEmailsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminEmails lists all email addresses
|
||||||
|
func (c *Client) ListAdminEmails(opt ListAdminEmailsOptions) ([]*Email, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse("/admin/emails")
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
|
||||||
|
emails := make([]*Email, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &emails)
|
||||||
|
return emails, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAdminEmailsOptions options for searching emails
|
||||||
|
type SearchAdminEmailsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Query string `json:"q,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAdminEmails searches email addresses
|
||||||
|
func (c *Client) SearchAdminEmails(opt SearchAdminEmailsOptions) ([]*Email, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse("/admin/emails/search")
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Query != "" {
|
||||||
|
query.Add("q", opt.Query)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
emails := make([]*Email, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &emails)
|
||||||
|
return emails, resp, err
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListAdminHooksOptions options for listing admin hooks
|
||||||
|
type ListAdminHooksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
// Type of hooks to list: system, default, or all
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdminHooks lists all system webhooks
|
||||||
|
func (c *Client) ListAdminHooks(opt ListAdminHooksOptions) ([]*Hook, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse("/admin/hooks")
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Type != "" {
|
||||||
|
query.Add("type", opt.Type)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
hooks := make([]*Hook, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &hooks)
|
||||||
|
return hooks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdminHook creates a system webhook
|
||||||
|
func (c *Client) CreateAdminHook(opt CreateHookOption) (*Hook, *Response, error) {
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
hook := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/admin/hooks", jsonHeader, bytes.NewReader(body), hook)
|
||||||
|
return hook, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminHook gets a system webhook by ID
|
||||||
|
func (c *Client) GetAdminHook(id int64) (*Hook, *Response, error) {
|
||||||
|
hook := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/hooks/%d", id), jsonHeader, nil, hook)
|
||||||
|
return hook, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditAdminHook edits a system webhook
|
||||||
|
func (c *Client) EditAdminHook(id int64, opt EditHookOption) (*Hook, *Response, error) {
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
hook := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/admin/hooks/%d", id), jsonHeader, bytes.NewReader(body), hook)
|
||||||
|
return hook, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAdminHook deletes a system webhook
|
||||||
|
func (c *Client) DeleteAdminHook(id int64) (*Response, error) {
|
||||||
|
status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/admin/hooks/%d", id), jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminListOrgsOptions options for listing admin's organizations
|
||||||
|
type AdminListOrgsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListOrgs lists all orgs
|
||||||
|
func (c *Client) AdminListOrgs(opt AdminListOrgsOptions) ([]*Organization, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
orgs := make([]*Organization, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/orgs?%s", opt.getURLQuery().Encode()), nil, nil, &orgs)
|
||||||
|
return orgs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateOrg create an organization
|
||||||
|
func (c *Client) AdminCreateOrg(user string, opt CreateOrgOption) (*Organization, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
org := new(Organization)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/orgs", user), jsonHeader, bytes.NewReader(body), org)
|
||||||
|
return org, resp, err
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminCreateRepo create a repo
|
||||||
|
func (c *Client) AdminCreateRepo(user string, opt CreateRepoOption) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/repos", user), jsonHeader, bytes.NewReader(body), repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnadoptedReposOptions options for listing unadopted repositories
|
||||||
|
type ListUnadoptedReposOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnadoptedRepos lists unadopted repositories
|
||||||
|
func (c *Client) ListUnadoptedRepos(opt ListUnadoptedReposOptions) ([]string, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse("/admin/unadopted")
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Pattern != "" {
|
||||||
|
query.Add("pattern", opt.Pattern)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
repos := make([]string, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &repos)
|
||||||
|
return repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdoptUnadoptedRepo adopts an unadopted repository
|
||||||
|
func (c *Client) AdoptUnadoptedRepo(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST",
|
||||||
|
fmt.Sprintf("/admin/unadopted/%s/%s", owner, repo),
|
||||||
|
jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUnadoptedRepo deletes an unadopted repository
|
||||||
|
func (c *Client) DeleteUnadoptedRepo(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/admin/unadopted/%s/%s", owner, repo),
|
||||||
|
jsonHeader, nil)
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminListUsersOptions options for listing admin users
|
||||||
|
type AdminListUsersOptions struct {
|
||||||
|
ListOptions
|
||||||
|
SourceID int64
|
||||||
|
LoginName string
|
||||||
|
Query string
|
||||||
|
Sort string // "name", "created", "updated", "id"
|
||||||
|
Order string // "asc", "desc"
|
||||||
|
Visibility string
|
||||||
|
IsActive *bool
|
||||||
|
IsAdmin *bool
|
||||||
|
IsRestricted *bool
|
||||||
|
Is2FAEnabled *bool
|
||||||
|
IsProhibitLogin *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *AdminListUsersOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.SourceID > 0 {
|
||||||
|
query.Add("source_id", fmt.Sprintf("%d", opt.SourceID))
|
||||||
|
}
|
||||||
|
if opt.LoginName != "" {
|
||||||
|
query.Add("login_name", opt.LoginName)
|
||||||
|
}
|
||||||
|
if opt.Query != "" {
|
||||||
|
query.Add("q", opt.Query)
|
||||||
|
}
|
||||||
|
if opt.Sort != "" {
|
||||||
|
query.Add("sort", opt.Sort)
|
||||||
|
}
|
||||||
|
if opt.Order != "" {
|
||||||
|
query.Add("order", opt.Order)
|
||||||
|
}
|
||||||
|
if opt.Visibility != "" {
|
||||||
|
query.Add("visibility", opt.Visibility)
|
||||||
|
}
|
||||||
|
if opt.IsActive != nil {
|
||||||
|
query.Add("is_active", fmt.Sprintf("%t", *opt.IsActive))
|
||||||
|
}
|
||||||
|
if opt.IsAdmin != nil {
|
||||||
|
query.Add("is_admin", fmt.Sprintf("%t", *opt.IsAdmin))
|
||||||
|
}
|
||||||
|
if opt.IsRestricted != nil {
|
||||||
|
query.Add("is_restricted", fmt.Sprintf("%t", *opt.IsRestricted))
|
||||||
|
}
|
||||||
|
if opt.Is2FAEnabled != nil {
|
||||||
|
query.Add("is_2fa_enabled", fmt.Sprintf("%t", *opt.Is2FAEnabled))
|
||||||
|
}
|
||||||
|
if opt.IsProhibitLogin != nil {
|
||||||
|
query.Add("is_prohibit_login", fmt.Sprintf("%t", *opt.IsProhibitLogin))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListUsers lists all users
|
||||||
|
func (c *Client) AdminListUsers(opt AdminListUsersOptions) ([]*User, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
users := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/admin/users?%s", opt.QueryEncode()), nil, nil, &users)
|
||||||
|
return users, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserOption create user options
|
||||||
|
type CreateUserOption struct {
|
||||||
|
SourceID int64 `json:"source_id"`
|
||||||
|
LoginName string `json:"login_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
MustChangePassword *bool `json:"must_change_password"`
|
||||||
|
SendNotify bool `json:"send_notify"`
|
||||||
|
Visibility *VisibleType `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateUserOption struct
|
||||||
|
func (opt CreateUserOption) Validate() error {
|
||||||
|
if len(opt.Email) == 0 {
|
||||||
|
return fmt.Errorf("email is empty")
|
||||||
|
}
|
||||||
|
if len(opt.Username) == 0 {
|
||||||
|
return fmt.Errorf("username is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateUser create a user
|
||||||
|
func (c *Client) AdminCreateUser(opt CreateUserOption) (*User, *Response, error) {
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
user := new(User)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/admin/users", jsonHeader, bytes.NewReader(body), user)
|
||||||
|
return user, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditUserOption edit user options
|
||||||
|
type EditUserOption struct {
|
||||||
|
SourceID int64 `json:"source_id"`
|
||||||
|
LoginName string `json:"login_name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
FullName *string `json:"full_name"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
MustChangePassword *bool `json:"must_change_password"`
|
||||||
|
Website *string `json:"website"`
|
||||||
|
Location *string `json:"location"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
Admin *bool `json:"admin"`
|
||||||
|
AllowGitHook *bool `json:"allow_git_hook"`
|
||||||
|
AllowImportLocal *bool `json:"allow_import_local"`
|
||||||
|
MaxRepoCreation *int `json:"max_repo_creation"`
|
||||||
|
ProhibitLogin *bool `json:"prohibit_login"`
|
||||||
|
AllowCreateOrganization *bool `json:"allow_create_organization"`
|
||||||
|
Restricted *bool `json:"restricted"`
|
||||||
|
Visibility *VisibleType `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminEditUser modify user informations
|
||||||
|
func (c *Client) AdminEditUser(user string, opt EditUserOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/admin/users/%s", user), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteUser delete one user according name
|
||||||
|
func (c *Client) AdminDeleteUser(user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/admin/users/%s", user), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateUserPublicKey adds a public key for the user
|
||||||
|
func (c *Client) AdminCreateUserPublicKey(user string, opt CreateKeyOption) (*PublicKey, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
key := new(PublicKey)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/admin/users/%s/keys", user), jsonHeader, bytes.NewReader(body), key)
|
||||||
|
return key, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteUserPublicKey deletes a user's public key
|
||||||
|
func (c *Client) AdminDeleteUserPublicKey(user string, keyID int) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/admin/users/%s/keys/%d", user, keyID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameUserOption options for renaming a user
|
||||||
|
type RenameUserOption struct {
|
||||||
|
NewUsername string `json:"new_username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRenameUser renames a user
|
||||||
|
func (c *Client) AdminRenameUser(username string, opt RenameUserOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST",
|
||||||
|
fmt.Sprintf("/admin/users/%s/rename", username),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasAgent returns true if the ssh agent is available
|
||||||
|
func hasAgent() bool {
|
||||||
|
if _, err := os.Stat(os.Getenv("SSH_AUTH_SOCK")); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent returns a ssh agent
|
||||||
|
func GetAgent() (agent.Agent, error) {
|
||||||
|
if !hasAgent() {
|
||||||
|
return nil, fmt.Errorf("no ssh agent available")
|
||||||
|
}
|
||||||
|
|
||||||
|
sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return agent.NewClient(sshAgent), nil
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/davidmz/go-pageant"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasAgent returns true if pageant is available
|
||||||
|
func hasAgent() bool {
|
||||||
|
return pageant.Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgent returns a ssh agent
|
||||||
|
func GetAgent() (agent.Agent, error) {
|
||||||
|
if !hasAgent() {
|
||||||
|
return nil, fmt.Errorf("no pageant available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageant.New(), nil
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea // import "code.gitea.io/sdk/gitea"
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attachment a generic attachment
|
||||||
|
type Attachment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DownloadCount int64 `json:"download_count"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
DownloadURL string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReleaseAttachmentsOptions options for listing release's attachments
|
||||||
|
type ListReleaseAttachmentsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReleaseAttachments list release's attachments
|
||||||
|
func (c *Client) ListReleaseAttachments(user, repo string, release int64, opt ListReleaseAttachmentsOptions) ([]*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
attachments := make([]*Attachment, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d/assets?%s", user, repo, release, opt.getURLQuery().Encode()),
|
||||||
|
nil, nil, &attachments)
|
||||||
|
return attachments, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReleaseAttachment returns the requested attachment
|
||||||
|
func (c *Client) GetReleaseAttachment(user, repo string, release, id int64) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
a := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, id),
|
||||||
|
nil, nil, &a)
|
||||||
|
return a, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReleaseAttachment creates an attachment for the given release
|
||||||
|
func (c *Client) CreateReleaseAttachment(user, repo string, release int64, file io.Reader, filename string) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// Write file to body
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("attachment", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(part, file); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d/assets", user, repo, release),
|
||||||
|
http.Header{"Content-Type": []string{writer.FormDataContentType()}}, body, &attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditAttachmentOptions options for editing attachments
|
||||||
|
type EditAttachmentOptions struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditReleaseAttachment updates the given attachment with the given options
|
||||||
|
func (c *Client) EditReleaseAttachment(user, repo string, release, attachment int64, form EditAttachmentOptions) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&form)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attach := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, attachment), jsonHeader, bytes.NewReader(body), attach)
|
||||||
|
return attach, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReleaseAttachment deletes the given attachment including the uploaded file
|
||||||
|
func (c *Client) DeleteReleaseAttachment(user, repo string, release, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/releases/%d/assets/%d", user, repo, release, id), nil, nil)
|
||||||
|
}
|
||||||
+536
@@ -0,0 +1,536 @@
|
|||||||
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
version "github.com/hashicorp/go-version"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jsonHeader = http.Header{"content-type": []string{"application/json"}}
|
||||||
|
|
||||||
|
// Version return the library version
|
||||||
|
func Version() string {
|
||||||
|
return "0.16.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a thread-safe Gitea API client.
|
||||||
|
type Client struct {
|
||||||
|
url string
|
||||||
|
accessToken string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
otp string
|
||||||
|
sudo string
|
||||||
|
userAgent string
|
||||||
|
debug bool
|
||||||
|
httpsigner *HTTPSign
|
||||||
|
client *http.Client
|
||||||
|
ctx context.Context
|
||||||
|
mutex sync.RWMutex
|
||||||
|
serverVersion *version.Version
|
||||||
|
getVersionOnce sync.Once
|
||||||
|
ignoreVersion bool // only set by SetGiteaVersion so don't need a mutex lock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response represents the gitea response
|
||||||
|
type Response struct {
|
||||||
|
*http.Response
|
||||||
|
|
||||||
|
FirstPage int
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
LastPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientOption are functions used to init a new client
|
||||||
|
type ClientOption func(*Client) error
|
||||||
|
|
||||||
|
// NewClient initializes and returns a API client.
|
||||||
|
// Usage of all gitea.Client methods is concurrency-safe.
|
||||||
|
func NewClient(url string, options ...ClientOption) (*Client, error) {
|
||||||
|
client := &Client{
|
||||||
|
url: strings.TrimSuffix(url, "/"),
|
||||||
|
client: &http.Client{},
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
for _, opt := range options {
|
||||||
|
if err := opt(client); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.checkServerVersionGreaterThanOrEqual(version1_11_0); err != nil {
|
||||||
|
if errors.Is(err, &ErrUnknownVersion{}) {
|
||||||
|
return client, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWithHTTP creates an API client with a custom http client
|
||||||
|
// Deprecated use SetHTTPClient option
|
||||||
|
func NewClientWithHTTP(url string, httpClient *http.Client) *Client {
|
||||||
|
client, _ := NewClient(url, SetHTTPClient(httpClient))
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTPClient is an option for NewClient to set custom http client
|
||||||
|
func SetHTTPClient(httpClient *http.Client) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetHTTPClient(httpClient)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTPClient replaces default http.Client with user given one.
|
||||||
|
func (c *Client) SetHTTPClient(client *http.Client) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.client = client
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetToken is an option for NewClient to set token
|
||||||
|
func SetToken(token string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.mutex.Lock()
|
||||||
|
client.accessToken = token
|
||||||
|
client.mutex.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBasicAuth is an option for NewClient to set username and password
|
||||||
|
func SetBasicAuth(username, password string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetBasicAuth(username, password)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseSSHCert is an option for NewClient to enable SSH certificate authentication via HTTPSign
|
||||||
|
// If you want to auth against the ssh-agent you'll need to set a principal, if you want to
|
||||||
|
// use a file on disk you'll need to specify sshKey.
|
||||||
|
// If you have an encrypted sshKey you'll need to also set the passphrase.
|
||||||
|
func UseSSHCert(principal, sshKey, passphrase string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.mutex.Lock()
|
||||||
|
defer client.mutex.Unlock()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
client.httpsigner, err = NewHTTPSignWithCert(principal, sshKey, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseSSHPubkey is an option for NewClient to enable SSH pubkey authentication via HTTPSign
|
||||||
|
// If you want to auth against the ssh-agent you'll need to set a fingerprint, if you want to
|
||||||
|
// use a file on disk you'll need to specify sshKey.
|
||||||
|
// If you have an encrypted sshKey you'll need to also set the passphrase.
|
||||||
|
func UseSSHPubkey(fingerprint, sshKey, passphrase string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
if err := client.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client.mutex.Lock()
|
||||||
|
defer client.mutex.Unlock()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
client.httpsigner, err = NewHTTPSignWithPubkey(fingerprint, sshKey, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBasicAuth sets username and password
|
||||||
|
func (c *Client) SetBasicAuth(username, password string) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.username, c.password = username, password
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOTP is an option for NewClient to set OTP for 2FA
|
||||||
|
func SetOTP(otp string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetOTP(otp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOTP sets OTP for 2FA
|
||||||
|
func (c *Client) SetOTP(otp string) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.otp = otp
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContext is an option for NewClient to set the default context
|
||||||
|
func SetContext(ctx context.Context) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetContext(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetContext set default context witch is used for http requests
|
||||||
|
func (c *Client) SetContext(ctx context.Context) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.ctx = ctx
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSudo is an option for NewClient to set sudo header
|
||||||
|
func SetSudo(sudo string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetSudo(sudo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSudo sets username to impersonate.
|
||||||
|
func (c *Client) SetSudo(sudo string) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.sudo = sudo
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAgent is an option for NewClient to set user-agent header
|
||||||
|
func SetUserAgent(userAgent string) ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.SetUserAgent(userAgent)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAgent sets the user-agent to send with every request.
|
||||||
|
func (c *Client) SetUserAgent(userAgent string) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
c.userAgent = userAgent
|
||||||
|
c.mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDebugMode is an option for NewClient to enable debug mode
|
||||||
|
func SetDebugMode() ClientOption {
|
||||||
|
return func(client *Client) error {
|
||||||
|
client.mutex.Lock()
|
||||||
|
client.debug = true
|
||||||
|
client.mutex.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponse(r *http.Response) *Response {
|
||||||
|
response := &Response{Response: r}
|
||||||
|
response.parseLinkHeader()
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Response) parseLinkHeader() {
|
||||||
|
link := r.Header.Get("Link")
|
||||||
|
if link == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
links := strings.Split(link, ",")
|
||||||
|
for _, l := range links {
|
||||||
|
u, param, ok := strings.Cut(l, ";")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u = strings.Trim(u, " <>")
|
||||||
|
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(param), "=")
|
||||||
|
if !ok || key != "rel" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
value = strings.Trim(value, "\"")
|
||||||
|
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
page := parsed.Query().Get("page")
|
||||||
|
if page == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value {
|
||||||
|
case "first":
|
||||||
|
r.FirstPage, _ = strconv.Atoi(page)
|
||||||
|
case "prev":
|
||||||
|
r.PrevPage, _ = strconv.Atoi(page)
|
||||||
|
case "next":
|
||||||
|
r.NextPage, _ = strconv.Atoi(page)
|
||||||
|
case "last":
|
||||||
|
r.LastPage, _ = strconv.Atoi(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getWebResponse(method, path string, body io.Reader) ([]byte, *Response, error) {
|
||||||
|
c.mutex.RLock()
|
||||||
|
debug := c.debug
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("%s: %s\nBody: %v\n", method, c.url+path, body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, method, c.url+path, body)
|
||||||
|
|
||||||
|
client := c.client // client ref can change from this point on so safe it
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("Response: %v\n\n", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, newResponse(resp), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(method, path string, header http.Header, body io.Reader) (*Response, error) {
|
||||||
|
c.mutex.RLock()
|
||||||
|
debug := c.debug
|
||||||
|
if debug {
|
||||||
|
var bodyStr string
|
||||||
|
if body != nil {
|
||||||
|
bs, _ := io.ReadAll(body)
|
||||||
|
body = bytes.NewReader(bs)
|
||||||
|
bodyStr = string(bs)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %s\nHeader: %v\nBody: %s\n", method, c.url+"/api/v1"+path, header, bodyStr)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, method, c.url+"/api/v1"+path, body)
|
||||||
|
if err != nil {
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(c.accessToken) != 0 {
|
||||||
|
req.Header.Set("Authorization", "token "+c.accessToken)
|
||||||
|
}
|
||||||
|
if len(c.otp) != 0 {
|
||||||
|
req.Header.Set("X-GITEA-OTP", c.otp)
|
||||||
|
}
|
||||||
|
if len(c.username) != 0 {
|
||||||
|
req.SetBasicAuth(c.username, c.password)
|
||||||
|
}
|
||||||
|
if len(c.sudo) != 0 {
|
||||||
|
req.Header.Set("Sudo", c.sudo)
|
||||||
|
}
|
||||||
|
if len(c.userAgent) != 0 {
|
||||||
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := c.client // client ref can change from this point on so safe it
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
|
for k, v := range header {
|
||||||
|
req.Header[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.httpsigner != nil {
|
||||||
|
err = c.SignRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if debug {
|
||||||
|
fmt.Printf("Response: %v\n\n", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newResponse(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a response for a HTTP status code indicating an error condition
|
||||||
|
// (non-2XX) to a well-known error value and response body. For non-problematic
|
||||||
|
// (2XX) status codes nil will be returned. Note that on a non-2XX response, the
|
||||||
|
// response body stream will have been read and, hence, is closed on return.
|
||||||
|
func statusCodeToErr(resp *Response) (body []byte, err error) {
|
||||||
|
// no error
|
||||||
|
if resp.StatusCode/100 == 2 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// error: body will be read for details
|
||||||
|
//
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("body read on HTTP error %d: %v", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unmarshal and get an error message
|
||||||
|
errMap := make(map[string]interface{})
|
||||||
|
if err = json.Unmarshal(data, &errMap); err != nil {
|
||||||
|
// when the JSON can't be parsed, data was probably empty or a
|
||||||
|
// plain string, so we try to return a helpful error anyway
|
||||||
|
path := resp.Request.URL.Path
|
||||||
|
method := resp.Request.Method
|
||||||
|
return data, fmt.Errorf("unknown API error: %d\nRequest: '%s' with '%s' method and '%s' body", resp.StatusCode, path, method, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg, ok := errMap["message"]; ok {
|
||||||
|
return data, fmt.Errorf("%v", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no error message, at least give status and data
|
||||||
|
return data, fmt.Errorf("%s: %s", resp.Status, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getResponseReader(method, path string, header http.Header, body io.Reader) (io.ReadCloser, *Response, error) {
|
||||||
|
resp, err := c.doRequest(method, path, header, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for errors
|
||||||
|
data, err := statusCodeToErr(resp)
|
||||||
|
if err != nil {
|
||||||
|
return io.NopCloser(bytes.NewReader(data)), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Body, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequestWithStatusHandle(method, path string, header http.Header, body io.Reader) (*Response, error) {
|
||||||
|
resp, err := c.doRequest(method, path, header, body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for errors
|
||||||
|
if _, err = statusCodeToErr(resp); err != nil {
|
||||||
|
// resp.Body has already been closed in statusCodeToErr
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getResponse(method, path string, header http.Header, body io.Reader) ([]byte, *Response, error) {
|
||||||
|
resp, err := c.doRequest(method, path, header, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// check for errors
|
||||||
|
data, err := statusCodeToErr(resp)
|
||||||
|
if err != nil {
|
||||||
|
return data, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// success (2XX), read body
|
||||||
|
data, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getParsedResponse(method, path string, header http.Header, body io.Reader, obj interface{}) (*Response, error) {
|
||||||
|
data, resp, err := c.getResponse(method, path, header, body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
return resp, json.Unmarshal(data, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getStatusCode(method, path string, header http.Header, body io.Reader) (int, *Response, error) {
|
||||||
|
resp, err := c.doRequest(method, path, header, body)
|
||||||
|
if err != nil {
|
||||||
|
return -1, resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return resp.StatusCode, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathEscapeSegments escapes segments of a path while not escaping forward slash
|
||||||
|
func pathEscapeSegments(path string) string {
|
||||||
|
slice := strings.Split(path, "/")
|
||||||
|
for index := range slice {
|
||||||
|
slice[index] = url.PathEscape(slice[index])
|
||||||
|
}
|
||||||
|
escapedPath := strings.Join(slice, "/")
|
||||||
|
return escapedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeValidatePathSegments is a help function to validate and encode url path segments
|
||||||
|
func escapeValidatePathSegments(seg ...*string) error {
|
||||||
|
for i := range seg {
|
||||||
|
if seg[i] == nil || len(*seg[i]) == 0 {
|
||||||
|
return fmt.Errorf("path segment [%d] is empty", i)
|
||||||
|
}
|
||||||
|
*seg[i] = url.PathEscape(*seg[i])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package gitea implements a client for the Gitea API.
|
||||||
|
// The version corresponds to the highest supported version
|
||||||
|
// of the gitea API, but backwards-compatibility is mostly
|
||||||
|
// given.
|
||||||
|
package gitea // import "code.gitea.io/sdk/gitea"
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListForksOptions options for listing repository's forks
|
||||||
|
type ListForksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForks list a repository's forks
|
||||||
|
func (c *Client) ListForks(user, repo string, opt ListForksOptions) ([]*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
forks := make([]*Repository, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/forks?%s", user, repo, opt.getURLQuery().Encode()),
|
||||||
|
nil, nil, &forks)
|
||||||
|
return forks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForkOption options for creating a fork
|
||||||
|
type CreateForkOption struct {
|
||||||
|
// organization name, if forking into an organization
|
||||||
|
Organization *string `json:"organization"`
|
||||||
|
// name of the forked repository
|
||||||
|
Name *string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFork create a fork of a repository
|
||||||
|
func (c *Client) CreateFork(user, repo string, form CreateForkOption) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(form)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
fork := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/forks", user, repo), jsonHeader, bytes.NewReader(body), &fork)
|
||||||
|
return fork, resp, err
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitBlobResponse represents a git blob
|
||||||
|
type GitBlobResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlob get the blob of a repository file
|
||||||
|
func (c *Client) GetBlob(user, repo, sha string) (*GitBlobResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &sha); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
blob := new(GitBlobResponse)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/blobs/%s", user, repo, sha), nil, nil, blob)
|
||||||
|
return blob, resp, err
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHook represents a Git repository hook
|
||||||
|
type GitHook struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoGitHooksOptions options for listing repository's githooks
|
||||||
|
type ListRepoGitHooksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoGitHooks list all the Git hooks of one repository
|
||||||
|
func (c *Client) ListRepoGitHooks(user, repo string, opt ListRepoGitHooksOptions) ([]*GitHook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
hooks := make([]*GitHook, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/git?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &hooks)
|
||||||
|
return hooks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoGitHook get a Git hook of a repository
|
||||||
|
func (c *Client) GetRepoGitHook(user, repo, id string) (*GitHook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &id); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(GitHook)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), nil, nil, h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditGitHookOption options when modifying one Git hook
|
||||||
|
type EditGitHookOption struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRepoGitHook modify one Git hook of a repository
|
||||||
|
func (c *Client) EditRepoGitHook(user, repo, id string, opt EditGitHookOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoGitHook delete one Git hook from a repository
|
||||||
|
func (c *Client) DeleteRepoGitHook(user, repo, id string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/hooks/git/%s", user, repo, id), nil, nil)
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
// OptionalBool convert a bool to a bool reference
|
||||||
|
func OptionalBool(v bool) *bool {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalString convert a string to a string reference
|
||||||
|
func OptionalString(v string) *string {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalInt64 convert a int64 to a int64 reference
|
||||||
|
func OptionalInt64(v int64) *int64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hook a hook is a web hook when one repository changed
|
||||||
|
type Hook struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"-"`
|
||||||
|
BranchFilter string `json:"branch_filter"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
AuthorizationHeader string `json:"authorization_header"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HookType represent all webhook types gitea currently offer
|
||||||
|
type HookType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// HookTypeDingtalk webhook that dingtalk understand
|
||||||
|
HookTypeDingtalk HookType = "dingtalk"
|
||||||
|
// HookTypeDiscord webhook that discord understand
|
||||||
|
HookTypeDiscord HookType = "discord"
|
||||||
|
// HookTypeGitea webhook that gitea understand
|
||||||
|
HookTypeGitea HookType = "gitea"
|
||||||
|
// HookTypeGogs webhook that gogs understand
|
||||||
|
HookTypeGogs HookType = "gogs"
|
||||||
|
// HookTypeMsteams webhook that msteams understand
|
||||||
|
HookTypeMsteams HookType = "msteams"
|
||||||
|
// HookTypeSlack webhook that slack understand
|
||||||
|
HookTypeSlack HookType = "slack"
|
||||||
|
// HookTypeTelegram webhook that telegram understand
|
||||||
|
HookTypeTelegram HookType = "telegram"
|
||||||
|
// HookTypeFeishu webhook that feishu understand
|
||||||
|
HookTypeFeishu HookType = "feishu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListHooksOptions options for listing hooks
|
||||||
|
type ListHooksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgHooks list all the hooks of one organization
|
||||||
|
func (c *Client) ListOrgHooks(org string, opt ListHooksOptions) ([]*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
hooks := make([]*Hook, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/hooks?%s", org, opt.getURLQuery().Encode()), nil, nil, &hooks)
|
||||||
|
return hooks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyHooks list all the hooks of the authenticated user
|
||||||
|
func (c *Client) ListMyHooks(opt ListHooksOptions) ([]*Hook, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
hooks := make([]*Hook, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/hooks?%s", opt.getURLQuery().Encode()), nil, nil, &hooks)
|
||||||
|
return hooks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoHooks list all the hooks of one repository
|
||||||
|
func (c *Client) ListRepoHooks(user, repo string, opt ListHooksOptions) ([]*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
hooks := make([]*Hook, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &hooks)
|
||||||
|
return hooks, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgHook get a hook of an organization
|
||||||
|
func (c *Client) GetOrgHook(org string, id int64) (*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), nil, nil, h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyHook get a hook of the authenticated user
|
||||||
|
func (c *Client) GetMyHook(id int64) (*Hook, *Response, error) {
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/hooks/%d", id), nil, nil, h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoHook get a hook of a repository
|
||||||
|
func (c *Client) GetRepoHook(user, repo string, id int64) (*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), nil, nil, h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateHookOption options when create a hook
|
||||||
|
type CreateHookOption struct {
|
||||||
|
Type HookType `json:"type"`
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
BranchFilter string `json:"branch_filter"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
AuthorizationHeader string `json:"authorization_header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateHookOption struct
|
||||||
|
func (opt CreateHookOption) Validate() error {
|
||||||
|
if len(opt.Type) == 0 {
|
||||||
|
return fmt.Errorf("hook type needed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgHook create one hook for an organization, with options
|
||||||
|
func (c *Client) CreateOrgHook(org string, opt CreateHookOption) (*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/hooks", org), jsonHeader, bytes.NewReader(body), h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMyHook create one hook for the authenticated user, with options
|
||||||
|
func (c *Client) CreateMyHook(opt CreateHookOption) (*Hook, *Response, error) {
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/user/hooks", jsonHeader, bytes.NewReader(body), h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepoHook create one hook for a repository, with options
|
||||||
|
func (c *Client) CreateRepoHook(user, repo string, opt CreateHookOption) (*Hook, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
h := new(Hook)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/hooks", user, repo), jsonHeader, bytes.NewReader(body), h)
|
||||||
|
return h, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditHookOption options when modify one hook
|
||||||
|
type EditHookOption struct {
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
BranchFilter string `json:"branch_filter"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
AuthorizationHeader string `json:"authorization_header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgHook modify one hook of an organization, with hook id and options
|
||||||
|
func (c *Client) EditOrgHook(org string, id int64, opt EditHookOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMyHook modify one hook of the authenticated user, with hook id and options
|
||||||
|
func (c *Client) EditMyHook(id int64, opt EditHookOption) (*Response, error) {
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/user/hooks/%d", id), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRepoHook modify one hook of a repository, with hook id and options
|
||||||
|
func (c *Client) EditRepoHook(user, repo string, id int64, opt EditHookOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgHook delete one hook from an organization, with hook id
|
||||||
|
func (c *Client) DeleteOrgHook(org string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s/hooks/%d", org, id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMyHook delete one hook from the authenticated user, with hook id
|
||||||
|
func (c *Client) DeleteMyHook(id int64) (*Response, error) {
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/user/hooks/%d", id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoHook delete one hook from a repository, with hook id
|
||||||
|
func (c *Client) DeleteRepoHook(user, repo string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/hooks/%d", user, repo, id), nil, nil)
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyWebhookSignature verifies that a payload matches the X-Gitea-Signature based on a secret
|
||||||
|
func VerifyWebhookSignature(secret, expected string, payload []byte) (bool, error) {
|
||||||
|
hash := hmac.New(sha256.New, []byte(secret))
|
||||||
|
if _, err := hash.Write(payload); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
expectedSum, err := hex.DecodeString(expected)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return hmac.Equal(hash.Sum(nil), expectedSum), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWebhookSignatureMiddleware is a http.Handler for verifying X-Gitea-Signature on incoming webhooks
|
||||||
|
func VerifyWebhookSignatureMiddleware(secret string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
if _, err := io.Copy(&b, r.Body); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := r.Header.Get("X-Gitea-Signature")
|
||||||
|
if expected == "" {
|
||||||
|
http.Error(w, "no signature found", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := VerifyWebhookSignature(secret, expected, b.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "invalid payload", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = io.NopCloser(&b)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/42wim/httpsig"
|
||||||
|
legacyhttpsig "github.com/go-fed/httpsig"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPSign contains the signer used for signing requests
|
||||||
|
type HTTPSign struct {
|
||||||
|
ssh.Signer
|
||||||
|
cert bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPSignConfig contains the configuration for creating a HTTPSign
|
||||||
|
type HTTPSignConfig struct {
|
||||||
|
fingerprint string
|
||||||
|
principal string
|
||||||
|
pubkey bool
|
||||||
|
cert bool
|
||||||
|
sshKey string
|
||||||
|
passphrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPSignWithPubkey can be used to create a HTTPSign with a public key
|
||||||
|
// if no fingerprint is specified it returns the first public key found
|
||||||
|
func NewHTTPSignWithPubkey(fingerprint, sshKey, passphrase string) (*HTTPSign, error) {
|
||||||
|
return newHTTPSign(&HTTPSignConfig{
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
pubkey: true,
|
||||||
|
sshKey: sshKey,
|
||||||
|
passphrase: passphrase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPSignWithCert can be used to create a HTTPSign with a certificate
|
||||||
|
// if no principal is specified it returns the first certificate found
|
||||||
|
func NewHTTPSignWithCert(principal, sshKey, passphrase string) (*HTTPSign, error) {
|
||||||
|
return newHTTPSign(&HTTPSignConfig{
|
||||||
|
principal: principal,
|
||||||
|
cert: true,
|
||||||
|
sshKey: sshKey,
|
||||||
|
passphrase: passphrase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPSign returns a new HTTPSign
|
||||||
|
// It will check the ssh-agent or a local file is config.sshKey is set.
|
||||||
|
// Depending on the configuration it will either use a certificate or a public key
|
||||||
|
func newHTTPSign(config *HTTPSignConfig) (*HTTPSign, error) {
|
||||||
|
var signer ssh.Signer
|
||||||
|
|
||||||
|
if config.sshKey != "" {
|
||||||
|
priv, err := os.ReadFile(config.sshKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.passphrase == "" {
|
||||||
|
signer, err = ssh.ParsePrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(priv, []byte(config.passphrase))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.cert {
|
||||||
|
certbytes, err := os.ReadFile(config.sshKey + "-cert.pub")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, _, _, _, err := ssh.ParseAuthorizedKey(certbytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, ok := pub.(*ssh.Certificate)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err = ssh.NewCertSigner(cert, signer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if no sshKey is specified, check if we have a ssh-agent and use it
|
||||||
|
agent, err := GetAgent()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signers, err := agent.Signers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(signers) == 0 {
|
||||||
|
return nil, fmt.Errorf("no signers found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.cert {
|
||||||
|
signer = findCertSigner(signers, config.principal)
|
||||||
|
if signer == nil {
|
||||||
|
return nil, fmt.Errorf("no certificate found for %s", config.principal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.pubkey {
|
||||||
|
signer = findPubkeySigner(signers, config.fingerprint)
|
||||||
|
if signer == nil {
|
||||||
|
return nil, fmt.Errorf("no public key found for %s", config.fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HTTPSign{
|
||||||
|
Signer: signer,
|
||||||
|
cert: config.cert,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignWithAlgorithm implements ssh.AlgorithmSigner, required by 42wim/httpsig v1.2.4+
|
||||||
|
// when signing with RSA keys.
|
||||||
|
func (h *HTTPSign) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
|
||||||
|
if as, ok := h.Signer.(ssh.AlgorithmSigner); ok {
|
||||||
|
return as.SignWithAlgorithm(rand, data, algorithm)
|
||||||
|
}
|
||||||
|
return h.Sign(rand, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignRequest signs a HTTP request
|
||||||
|
func (c *Client) SignRequest(r *http.Request) error {
|
||||||
|
var contents []byte
|
||||||
|
|
||||||
|
headersToSign := []string{httpsig.RequestTarget, "(created)", "(expires)"}
|
||||||
|
|
||||||
|
if c.httpsigner.cert {
|
||||||
|
// add our certificate to the headers to sign
|
||||||
|
pubkey, _ := ssh.ParsePublicKey(c.httpsigner.PublicKey().Marshal())
|
||||||
|
if cert, ok := pubkey.(*ssh.Certificate); ok {
|
||||||
|
certString := base64.RawStdEncoding.EncodeToString(cert.Marshal())
|
||||||
|
r.Header.Add("x-ssh-certificate", certString)
|
||||||
|
|
||||||
|
headersToSign = append(headersToSign, "x-ssh-certificate")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no ssh certificate found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we have a body, the Digest header will be added and we'll include this also in
|
||||||
|
// our signature.
|
||||||
|
if r.Body != nil {
|
||||||
|
body, err := r.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getBody() failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err = io.ReadAll(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed reading body: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headersToSign = append(headersToSign, "Digest")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a signer for the request and headers, the signature will be valid for 10 seconds
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// use legacyhttpsig to sign with RSA-SHA1 on older gitea releases
|
||||||
|
if err = c.checkServerVersionGreaterThanOrEqual(version1_23_0); err != nil {
|
||||||
|
// Legacy signer
|
||||||
|
legacySigner, _, err := legacyhttpsig.NewSSHSigner(c.httpsigner, httpsig.DigestSha512, headersToSign, legacyhttpsig.Signature, 10)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("legacy httpsig.NewSSHSigner failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign the request, use the fingerprint if we don't have a certificate
|
||||||
|
keyID := "gitea"
|
||||||
|
if !c.httpsigner.cert {
|
||||||
|
keyID = ssh.FingerprintSHA256(c.httpsigner.PublicKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
return legacySigner.SignRequest(keyID, r, contents)
|
||||||
|
}
|
||||||
|
// Modern signer
|
||||||
|
modernSigner, _, err := httpsig.NewSSHSigner(c.httpsigner, httpsig.DigestSha512, headersToSign, httpsig.Signature, 10)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("httpsig.NewSSHSigner failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign the request, use the fingerprint if we don't have a certificate
|
||||||
|
keyID := "gitea"
|
||||||
|
if !c.httpsigner.cert {
|
||||||
|
keyID = ssh.FingerprintSHA256(c.httpsigner.PublicKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
return modernSigner.SignRequest(keyID, r, contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCertSigner returns the Signer containing a valid certificate
|
||||||
|
// if no principal is specified it returns the first certificate found
|
||||||
|
func findCertSigner(sshsigners []ssh.Signer, principal string) ssh.Signer {
|
||||||
|
for _, s := range sshsigners {
|
||||||
|
// Check if the key is a certificate
|
||||||
|
if !strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert the ssh.Signer to a ssh.Certificate
|
||||||
|
mpubkey, _ := ssh.ParsePublicKey(s.PublicKey().Marshal())
|
||||||
|
cryptopub := mpubkey.(crypto.PublicKey)
|
||||||
|
cert := cryptopub.(*ssh.Certificate)
|
||||||
|
t := time.Unix(int64(cert.ValidBefore), 0)
|
||||||
|
|
||||||
|
// make sure the certificate is at least 10 seconds valid
|
||||||
|
if time.Until(t) <= time.Second*10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if principal == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range cert.ValidPrincipals {
|
||||||
|
if p == principal {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPubkeySigner returns the Signer containing a valid public key
|
||||||
|
// if no fingerprint is specified it returns the first public key found
|
||||||
|
func findPubkeySigner(sshsigners []ssh.Signer, fingerprint string) ssh.Signer {
|
||||||
|
for _, s := range sshsigners {
|
||||||
|
// Check if the key is a certificate
|
||||||
|
if strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if fingerprint == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey()))) == fingerprint {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssh.FingerprintSHA256(s.PublicKey()) == fingerprint {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PullRequestMeta PR info if an issue is a PR
|
||||||
|
type PullRequestMeta struct {
|
||||||
|
HasMerged bool `json:"merged"`
|
||||||
|
Merged *time.Time `json:"merged_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepositoryMeta basic repository information
|
||||||
|
type RepositoryMeta struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue represents an issue in a repository
|
||||||
|
type Issue struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Index int64 `json:"number"`
|
||||||
|
Poster *User `json:"user"`
|
||||||
|
OriginalAuthor string `json:"original_author"`
|
||||||
|
OriginalAuthorID int64 `json:"original_author_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Labels []*Label `json:"labels"`
|
||||||
|
Milestone *Milestone `json:"milestone"`
|
||||||
|
Assignees []*User `json:"assignees"`
|
||||||
|
// Whether the issue is open or closed
|
||||||
|
State StateType `json:"state"`
|
||||||
|
IsLocked bool `json:"is_locked"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
Closed *time.Time `json:"closed_at"`
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
PullRequest *PullRequestMeta `json:"pull_request"`
|
||||||
|
Repository *RepositoryMeta `json:"repository"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueOption list issue options
|
||||||
|
type ListIssueOption struct {
|
||||||
|
ListOptions
|
||||||
|
State StateType
|
||||||
|
Type IssueType
|
||||||
|
Labels []string
|
||||||
|
Milestones []string
|
||||||
|
KeyWord string
|
||||||
|
Since time.Time
|
||||||
|
Before time.Time
|
||||||
|
// filter by created by username
|
||||||
|
CreatedBy string
|
||||||
|
// filter by assigned to username
|
||||||
|
AssignedBy string
|
||||||
|
// filter by username mentioned
|
||||||
|
MentionedBy string
|
||||||
|
// filter by owner (only works on ListIssues on User)
|
||||||
|
Owner string
|
||||||
|
// filter by team (requires organization owner parameter to be provided and only works on ListIssues on User)
|
||||||
|
Team string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateType issue state type
|
||||||
|
type StateType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StateOpen pr/issue is opend
|
||||||
|
StateOpen StateType = "open"
|
||||||
|
// StateClosed pr/issue is closed
|
||||||
|
StateClosed StateType = "closed"
|
||||||
|
// StateAll is all
|
||||||
|
StateAll StateType = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueType is issue a pull or only an issue
|
||||||
|
type IssueType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// IssueTypeAll pr and issue
|
||||||
|
IssueTypeAll IssueType = ""
|
||||||
|
// IssueTypeIssue only issues
|
||||||
|
IssueTypeIssue IssueType = "issues"
|
||||||
|
// IssueTypePull only pulls
|
||||||
|
IssueTypePull IssueType = "pulls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListIssueOption) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
|
||||||
|
if len(opt.State) > 0 {
|
||||||
|
query.Add("state", string(opt.State))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.Labels) > 0 {
|
||||||
|
query.Add("labels", strings.Join(opt.Labels, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.KeyWord) > 0 {
|
||||||
|
query.Add("q", opt.KeyWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Add("type", string(opt.Type))
|
||||||
|
|
||||||
|
if len(opt.Milestones) > 0 {
|
||||||
|
query.Add("milestones", strings.Join(opt.Milestones, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opt.Since.IsZero() {
|
||||||
|
query.Add("since", opt.Since.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !opt.Before.IsZero() {
|
||||||
|
query.Add("before", opt.Before.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.CreatedBy) > 0 {
|
||||||
|
query.Add("created_by", opt.CreatedBy)
|
||||||
|
}
|
||||||
|
if len(opt.AssignedBy) > 0 {
|
||||||
|
query.Add("assigned_by", opt.AssignedBy)
|
||||||
|
}
|
||||||
|
if len(opt.MentionedBy) > 0 {
|
||||||
|
query.Add("mentioned_by", opt.MentionedBy)
|
||||||
|
}
|
||||||
|
if len(opt.Owner) > 0 {
|
||||||
|
query.Add("owner", opt.Owner)
|
||||||
|
}
|
||||||
|
if len(opt.Team) > 0 {
|
||||||
|
query.Add("team", opt.Team)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssues returns all issues assigned the authenticated user
|
||||||
|
func (c *Client) ListIssues(opt ListIssueOption) ([]*Issue, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
issues := make([]*Issue, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse("/repos/issues/search")
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues)
|
||||||
|
if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil {
|
||||||
|
for i := 0; i < len(issues); i++ {
|
||||||
|
if issues[i].Repository != nil {
|
||||||
|
issues[i].Repository.Owner = strings.Split(issues[i].Repository.FullName, "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range issues {
|
||||||
|
c.issueBackwardsCompatibility(issues[i])
|
||||||
|
}
|
||||||
|
return issues, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoIssues returns all issues for a given repository
|
||||||
|
func (c *Client) ListRepoIssues(owner, repo string, opt ListIssueOption) ([]*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
issues := make([]*Issue, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues)
|
||||||
|
if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil {
|
||||||
|
for i := 0; i < len(issues); i++ {
|
||||||
|
if issues[i].Repository != nil {
|
||||||
|
issues[i].Repository.Owner = strings.Split(issues[i].Repository.FullName, "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range issues {
|
||||||
|
c.issueBackwardsCompatibility(issues[i])
|
||||||
|
}
|
||||||
|
return issues, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssue returns a single issue for a given repository
|
||||||
|
func (c *Client) GetIssue(owner, repo string, index int64) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index), nil, nil, issue)
|
||||||
|
if e := c.checkServerVersionGreaterThanOrEqual(version1_12_0); e != nil && issue.Repository != nil {
|
||||||
|
issue.Repository.Owner = strings.Split(issue.Repository.FullName, "/")[0]
|
||||||
|
}
|
||||||
|
c.issueBackwardsCompatibility(issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueOption options to create one issue
|
||||||
|
type CreateIssueOption struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
// milestone id
|
||||||
|
Milestone int64 `json:"milestone"`
|
||||||
|
// list of label ids
|
||||||
|
Labels []int64 `json:"labels"`
|
||||||
|
Closed bool `json:"closed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateIssueOption struct
|
||||||
|
func (opt CreateIssueOption) Validate() error {
|
||||||
|
if len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssue create a new issue for a given repository
|
||||||
|
func (c *Client) CreateIssue(owner, repo string, opt CreateIssueOption) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), issue)
|
||||||
|
c.issueBackwardsCompatibility(issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueOption options for editing an issue
|
||||||
|
type EditIssueOption struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Ref *string `json:"ref"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
Milestone *int64 `json:"milestone"`
|
||||||
|
State *StateType `json:"state"`
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
RemoveDeadline *bool `json:"unset_due_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditIssueOption struct
|
||||||
|
func (opt EditIssueOption) Validate() error {
|
||||||
|
if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssue modify an existing issue for a given repository
|
||||||
|
func (c *Client) EditIssue(owner, repo string, index int64, opt EditIssueOption) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), issue)
|
||||||
|
c.issueBackwardsCompatibility(issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssue delete a issue from a repository
|
||||||
|
func (c *Client) DeleteIssue(user, repo string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d", user, repo, id),
|
||||||
|
nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) issueBackwardsCompatibility(issue *Issue) {
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_12_0) != nil {
|
||||||
|
c.mutex.RLock()
|
||||||
|
issue.HTMLURL = fmt.Sprintf("%s/%s/issues/%d", c.url, issue.Repository.FullName, issue.Index)
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListIssueAttachments lists all attachments for an issue.
|
||||||
|
func (c *Client) ListIssueAttachments(owner, repo string, index int64) ([]*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachments := make([]*Attachment, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/assets", owner, repo, index),
|
||||||
|
nil, nil, &attachments)
|
||||||
|
return attachments, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueAttachment gets an issue attachment.
|
||||||
|
func (c *Client) GetIssueAttachment(owner, repo string, index, attachmentID int64) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, index, attachmentID),
|
||||||
|
nil, nil, attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueAttachment uploads an attachment for an issue.
|
||||||
|
func (c *Client) CreateIssueAttachment(owner, repo string, index int64, file io.Reader, filename string) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("attachment", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(part, file); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/assets", owner, repo, index))
|
||||||
|
link.RawQuery = url.Values{"name": []string{filename}}.Encode()
|
||||||
|
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("POST", link.String(), http.Header{"Content-Type": []string{writer.FormDataContentType()}}, body, attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueAttachment updates an issue attachment.
|
||||||
|
func (c *Client) EditIssueAttachment(owner, repo string, index, attachmentID int64, form EditAttachmentOptions) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&form)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, index, attachmentID),
|
||||||
|
jsonHeader, bytes.NewReader(body), attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueAttachment deletes an issue attachment.
|
||||||
|
func (c *Client) DeleteIssueAttachment(owner, repo string, index, attachmentID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, index, attachmentID), nil, nil)
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment represents a comment on a commit or issue
|
||||||
|
type Comment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
PRURL string `json:"pull_request_url"`
|
||||||
|
IssueURL string `json:"issue_url"`
|
||||||
|
Poster *User `json:"user"`
|
||||||
|
OriginalAuthor string `json:"original_author"`
|
||||||
|
OriginalAuthorID int64 `json:"original_author_id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
Attachments []*Attachment `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueCommentOptions list comment options
|
||||||
|
type ListIssueCommentOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Since time.Time
|
||||||
|
Before time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListIssueCommentOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if !opt.Since.IsZero() {
|
||||||
|
query.Add("since", opt.Since.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !opt.Before.IsZero() {
|
||||||
|
query.Add("before", opt.Before.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueComments list comments on an issue.
|
||||||
|
func (c *Client) ListIssueComments(owner, repo string, index int64, opt ListIssueCommentOptions) ([]*Comment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
comments := make([]*Comment, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments)
|
||||||
|
return comments, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoIssueComments list comments for a given repo.
|
||||||
|
func (c *Client) ListRepoIssueComments(owner, repo string, opt ListIssueCommentOptions) ([]*Comment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/comments", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
comments := make([]*Comment, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &comments)
|
||||||
|
return comments, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueComment get a comment for a given repo by id.
|
||||||
|
func (c *Client) GetIssueComment(owner, repo string, id int64) (*Comment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
comment := new(Comment)
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return comment, nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, id), nil, nil, &comment)
|
||||||
|
return comment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueCommentOption options for creating a comment on an issue
|
||||||
|
type CreateIssueCommentOption struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateIssueCommentOption struct
|
||||||
|
func (opt CreateIssueCommentOption) Validate() error {
|
||||||
|
if len(opt.Body) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueComment create comment on an issue.
|
||||||
|
func (c *Client) CreateIssueComment(owner, repo string, index int64, opt CreateIssueCommentOption) (*Comment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
comment := new(Comment)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index), jsonHeader, bytes.NewReader(body), comment)
|
||||||
|
return comment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueCommentOption options for editing a comment
|
||||||
|
type EditIssueCommentOption struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditIssueCommentOption struct
|
||||||
|
func (opt EditIssueCommentOption) Validate() error {
|
||||||
|
if len(opt.Body) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueComment edits an issue comment.
|
||||||
|
func (c *Client) EditIssueComment(owner, repo string, commentID int64, opt EditIssueCommentOption) (*Comment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
comment := new(Comment)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, commentID), jsonHeader, bytes.NewReader(body), comment)
|
||||||
|
return comment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueComment deletes an issue comment.
|
||||||
|
func (c *Client) DeleteIssueComment(owner, repo string, commentID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/comments/%d", owner, repo, commentID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueCommentAttachments lists all attachments for a comment
|
||||||
|
func (c *Client) ListIssueCommentAttachments(owner, repo string, commentID int64) ([]*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachments := make([]*Attachment, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets", owner, repo, commentID),
|
||||||
|
nil, nil, &attachments)
|
||||||
|
return attachments, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueCommentAttachment uploads an attachment for a comment.
|
||||||
|
func (c *Client) CreateIssueCommentAttachment(owner, repo string, commentID int64, file io.Reader, filename string) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("attachment", filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(part, file); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets", owner, repo, commentID))
|
||||||
|
link.RawQuery = url.Values{"name": []string{filename}}.Encode()
|
||||||
|
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("POST", link.String(), http.Header{"Content-Type": []string{writer.FormDataContentType()}}, body, attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueCommentAttachment gets a comment attachment
|
||||||
|
func (c *Client) GetIssueCommentAttachment(owner, repo string, commentID, attachmentID int64) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, commentID, attachmentID),
|
||||||
|
nil, nil, &attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditIssueCommentAttachment updates a comment attachment
|
||||||
|
func (c *Client) EditIssueCommentAttachment(owner, repo string, commentID, attachmentID int64, form EditAttachmentOptions) (*Attachment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&form)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
attachment := new(Attachment)
|
||||||
|
resp, err := c.getParsedResponse("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, commentID, attachmentID),
|
||||||
|
jsonHeader, bytes.NewReader(body), attachment)
|
||||||
|
return attachment, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueCommentAttachment deletes a comment attachment
|
||||||
|
func (c *Client) DeleteIssueCommentAttachment(owner, repo string, commentID, attachmentID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, commentID, attachmentID), nil, nil)
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueBlockedBy represents an issue that blocks another issue
|
||||||
|
type IssueBlockedBy struct {
|
||||||
|
Index int64 `json:"index"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueBlocksOptions options for listing issue blocks
|
||||||
|
type ListIssueBlocksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueBlocks lists issues that are blocked by the specified issue with pagination
|
||||||
|
func (c *Client) ListIssueBlocks(owner, repo string, index int64, opt ListIssueBlocksOptions) ([]*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/blocks", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
issues := make([]*Issue, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues)
|
||||||
|
return issues, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueMeta represents issue reference for blocking/dependency operations
|
||||||
|
type IssueMeta struct {
|
||||||
|
Index int64 `json:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueBlocking blocks an issue with another issue
|
||||||
|
func (c *Client) CreateIssueBlocking(owner, repo string, index int64, opt IssueMeta) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/blocks", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), &issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIssueBlocking removes an issue block
|
||||||
|
func (c *Client) RemoveIssueBlocking(owner, repo string, index int64, opt IssueMeta) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/blocks", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), &issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueDependenciesOptions options for listing issue dependencies
|
||||||
|
type ListIssueDependenciesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueDependencies lists issues that block the specified issue (its dependencies) with pagination
|
||||||
|
func (c *Client) ListIssueDependencies(owner, repo string, index int64, opt ListIssueDependenciesOptions) ([]*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
issues := make([]*Issue, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &issues)
|
||||||
|
return issues, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssueDependency creates a new issue dependency
|
||||||
|
func (c *Client) CreateIssueDependency(owner, repo string, index int64, opt IssueMeta) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), &issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIssueDependency removes an issue dependency
|
||||||
|
func (c *Client) RemoveIssueDependency(owner, repo string, index int64, opt IssueMeta) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), &issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockIssueOption represents options for locking an issue
|
||||||
|
type LockIssueOption struct {
|
||||||
|
LockReason string `json:"lock_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockIssue locks an issue
|
||||||
|
func (c *Client) LockIssue(owner, repo string, index int64, opt LockIssueOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PUT",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/lock", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockIssue unlocks an issue
|
||||||
|
func (c *Client) UnlockIssue(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/lock", owner, repo, index),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditDeadlineOption represents options for updating issue deadline
|
||||||
|
type EditDeadlineOption struct {
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateIssueDeadline updates an issue's deadline
|
||||||
|
func (c *Client) UpdateIssueDeadline(owner, repo string, index int64, opt EditDeadlineOption) (*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issue := new(Issue)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/deadline", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), &issue)
|
||||||
|
return issue, resp, err
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetIssueLabels get labels of one issue via issue id
|
||||||
|
func (c *Client) GetIssueLabels(owner, repo string, index int64, opts ListLabelsOptions) ([]*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
labels := make([]*Label, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/labels?%s", owner, repo, index, opts.getURLQuery().Encode()), nil, nil, &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueLabelsOption a collection of labels
|
||||||
|
type IssueLabelsOption struct {
|
||||||
|
// list of label IDs
|
||||||
|
Labels []int64 `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIssueLabels add one or more labels to one issue
|
||||||
|
func (c *Client) AddIssueLabels(owner, repo string, index int64, opt IssueLabelsOption) ([]*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var labels []*Label
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), jsonHeader, bytes.NewReader(body), &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceIssueLabels replace old labels of issue with new labels
|
||||||
|
func (c *Client) ReplaceIssueLabels(owner, repo string, index int64, opt IssueLabelsOption) ([]*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var labels []*Label
|
||||||
|
resp, err := c.getParsedResponse("PUT", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), jsonHeader, bytes.NewReader(body), &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueLabel delete one label of one issue by issue id and label id
|
||||||
|
// TODO: maybe we need delete by label name and issue id
|
||||||
|
func (c *Client) DeleteIssueLabel(owner, repo string, index, label int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, index, label), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearIssueLabels delete all the labels of one issue.
|
||||||
|
func (c *Client) ClearIssueLabels(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/labels", owner, repo, index), nil, nil)
|
||||||
|
}
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Milestone milestone is a collection of issues on one repository
|
||||||
|
type Milestone struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
State StateType `json:"state"`
|
||||||
|
OpenIssues int `json:"open_issues"`
|
||||||
|
ClosedIssues int `json:"closed_issues"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated *time.Time `json:"updated_at"`
|
||||||
|
Closed *time.Time `json:"closed_at"`
|
||||||
|
Deadline *time.Time `json:"due_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMilestoneOption list milestone options
|
||||||
|
type ListMilestoneOption struct {
|
||||||
|
ListOptions
|
||||||
|
// open, closed, all
|
||||||
|
State StateType
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListMilestoneOption) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.State != "" {
|
||||||
|
query.Add("state", string(opt.State))
|
||||||
|
}
|
||||||
|
if len(opt.Name) != 0 {
|
||||||
|
query.Add("name", opt.Name)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoMilestones list all the milestones of one repository
|
||||||
|
func (c *Client) ListRepoMilestones(owner, repo string, opt ListMilestoneOption) ([]*Milestone, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
milestones := make([]*Milestone, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/milestones", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &milestones)
|
||||||
|
return milestones, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilestone get one milestone by repo name and milestone id
|
||||||
|
func (c *Client) GetMilestone(owner, repo string, id int64) (*Milestone, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
milestone := new(Milestone)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), nil, nil, milestone)
|
||||||
|
return milestone, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMilestoneByName get one milestone by repo and milestone name
|
||||||
|
func (c *Client) GetMilestoneByName(owner, repo, name string) (*Milestone, *Response, error) {
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
|
||||||
|
// backwards compatibility mode
|
||||||
|
m, resp, err := c.resolveMilestoneByName(owner, repo, name)
|
||||||
|
return m, resp, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
milestone := new(Milestone)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), nil, nil, milestone)
|
||||||
|
return milestone, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMilestoneOption options for creating a milestone
|
||||||
|
type CreateMilestoneOption struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
State StateType `json:"state"`
|
||||||
|
Deadline *time.Time `json:"due_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateMilestoneOption struct
|
||||||
|
func (opt CreateMilestoneOption) Validate() error {
|
||||||
|
if len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMilestone create one milestone with options
|
||||||
|
func (c *Client) CreateMilestone(owner, repo string, opt CreateMilestoneOption) (*Milestone, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
milestone := new(Milestone)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/milestones", owner, repo), jsonHeader, bytes.NewReader(body), milestone)
|
||||||
|
|
||||||
|
// make creating closed milestones need gitea >= v1.13.0
|
||||||
|
// this make it backwards compatible
|
||||||
|
if err == nil && opt.State == StateClosed && milestone.State != StateClosed {
|
||||||
|
closed := StateClosed
|
||||||
|
return c.EditMilestone(owner, repo, milestone.ID, EditMilestoneOption{
|
||||||
|
State: &closed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return milestone, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMilestoneOption options for editing a milestone
|
||||||
|
type EditMilestoneOption struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
State *StateType `json:"state"`
|
||||||
|
Deadline *time.Time `json:"due_on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditMilestoneOption struct
|
||||||
|
func (opt EditMilestoneOption) Validate() error {
|
||||||
|
if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMilestone modify milestone with options
|
||||||
|
func (c *Client) EditMilestone(owner, repo string, id int64, opt EditMilestoneOption) (*Milestone, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
milestone := new(Milestone)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), jsonHeader, bytes.NewReader(body), milestone)
|
||||||
|
return milestone, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMilestoneByName modify milestone with options
|
||||||
|
func (c *Client) EditMilestoneByName(owner, repo, name string, opt EditMilestoneOption) (*Milestone, *Response, error) {
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
|
||||||
|
// backwards compatibility mode
|
||||||
|
m, _, err := c.resolveMilestoneByName(owner, repo, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.EditMilestone(owner, repo, m.ID, opt)
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
milestone := new(Milestone)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), jsonHeader, bytes.NewReader(body), milestone)
|
||||||
|
return milestone, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMilestone delete one milestone by id
|
||||||
|
func (c *Client) DeleteMilestone(owner, repo string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/milestones/%d", owner, repo, id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMilestoneByName delete one milestone by name
|
||||||
|
func (c *Client) DeleteMilestoneByName(owner, repo, name string) (*Response, error) {
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
|
||||||
|
// backwards compatibility mode
|
||||||
|
m, _, err := c.resolveMilestoneByName(owner, repo, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.DeleteMilestone(owner, repo, m.ID)
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/milestones/%s", owner, repo, name), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMilestoneByName is a fallback method to find milestone id by name
|
||||||
|
func (c *Client) resolveMilestoneByName(owner, repo, name string) (*Milestone, *Response, error) {
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
miles, resp, err := c.ListRepoMilestones(owner, repo, ListMilestoneOption{
|
||||||
|
ListOptions: ListOptions{
|
||||||
|
Page: i,
|
||||||
|
},
|
||||||
|
State: "all",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if len(miles) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("milestone '%s' do not exist", name)
|
||||||
|
}
|
||||||
|
for _, m := range miles {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(m.Title), strings.TrimSpace(name)) {
|
||||||
|
return m, resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRepoPinnedIssues lists a repo's pinned issues
|
||||||
|
func (c *Client) ListRepoPinnedIssues(owner, repo string) ([]*Issue, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
issues := make([]*Issue, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/pinned", owner, repo),
|
||||||
|
jsonHeader, nil, &issues)
|
||||||
|
return issues, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinIssue pins an issue
|
||||||
|
func (c *Client) PinIssue(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/pin", owner, repo, index),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnpinIssue unpins an issue
|
||||||
|
func (c *Client) UnpinIssue(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/pin", owner, repo, index),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveIssuePin moves a pinned issue to the given position
|
||||||
|
func (c *Client) MoveIssuePin(owner, repo string, index, position int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/pin/%d", owner, repo, index, position),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reaction contain one reaction
|
||||||
|
type Reaction struct {
|
||||||
|
User *User `json:"user"`
|
||||||
|
Reaction string `json:"content"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueReactionsOptions options for listing issue reactions
|
||||||
|
type ListIssueReactionsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueReactions get a list reactions of an issue
|
||||||
|
//
|
||||||
|
// Deprecated: Use ListIssueReactions instead, which supports pagination.
|
||||||
|
func (c *Client) GetIssueReactions(owner, repo string, index int64) ([]*Reaction, *Response, error) {
|
||||||
|
return c.ListIssueReactions(owner, repo, index, ListIssueReactionsOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueReactions get a list of reactions for an issue with pagination
|
||||||
|
func (c *Client) ListIssueReactions(owner, repo string, index int64, opt ListIssueReactionsOptions) ([]*Reaction, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
reactions := make([]*Reaction, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &reactions)
|
||||||
|
return reactions, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueCommentReactions get a list of reactions from a comment of an issue
|
||||||
|
func (c *Client) GetIssueCommentReactions(owner, repo string, commentID int64) ([]*Reaction, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
reactions := make([]*Reaction, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID), nil, nil, &reactions)
|
||||||
|
return reactions, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// editReactionOption contain the reaction type
|
||||||
|
type editReactionOption struct {
|
||||||
|
Reaction string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostIssueReaction add a reaction to an issue
|
||||||
|
func (c *Client) PostIssueReaction(owner, repo string, index int64, reaction string) (*Reaction, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
reactionResponse := new(Reaction)
|
||||||
|
body, err := json.Marshal(&editReactionOption{Reaction: reaction})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), reactionResponse)
|
||||||
|
return reactionResponse, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueReaction remove a reaction from an issue
|
||||||
|
func (c *Client) DeleteIssueReaction(owner, repo string, index int64, reaction string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&editReactionOption{Reaction: reaction})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", owner, repo, index), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostIssueCommentReaction add a reaction to a comment of an issue
|
||||||
|
func (c *Client) PostIssueCommentReaction(owner, repo string, commentID int64, reaction string) (*Reaction, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
reactionResponse := new(Reaction)
|
||||||
|
body, err := json.Marshal(&editReactionOption{Reaction: reaction})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID),
|
||||||
|
jsonHeader, bytes.NewReader(body), reactionResponse)
|
||||||
|
return reactionResponse, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueCommentReaction remove a reaction from a comment of an issue
|
||||||
|
func (c *Client) DeleteIssueCommentReaction(owner, repo string, commentID int64, reaction string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&editReactionOption{Reaction: reaction})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", owner, repo, commentID),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopWatch represents a running stopwatch of an issue / pr
|
||||||
|
type StopWatch struct {
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
IssueIndex int64 `json:"issue_index"`
|
||||||
|
IssueTitle string `json:"issue_title"`
|
||||||
|
RepoOwnerName string `json:"repo_owner_name"`
|
||||||
|
RepoName string `json:"repo_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStopwatchesOptions options for listing stopwatches
|
||||||
|
type ListStopwatchesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyStopwatches list all stopwatches
|
||||||
|
//
|
||||||
|
// Deprecated: Use ListMyStopwatches instead, which supports pagination.
|
||||||
|
func (c *Client) GetMyStopwatches() ([]*StopWatch, *Response, error) {
|
||||||
|
return c.ListMyStopwatches(ListStopwatchesOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyStopwatches list all stopwatches with pagination
|
||||||
|
func (c *Client) ListMyStopwatches(opt ListStopwatchesOptions) ([]*StopWatch, *Response, error) {
|
||||||
|
link, _ := url.Parse("/user/stopwatches")
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
stopwatches := make([]*StopWatch, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &stopwatches)
|
||||||
|
return stopwatches, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueStopwatch delete / cancel a specific stopwatch
|
||||||
|
func (c *Client) DeleteIssueStopwatch(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/delete", owner, repo, index), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartIssueStopWatch starts a stopwatch for an existing issue for a given
|
||||||
|
// repository
|
||||||
|
func (c *Client) StartIssueStopWatch(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/start", owner, repo, index), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopIssueStopWatch stops an existing stopwatch for an issue in a given
|
||||||
|
// repository
|
||||||
|
func (c *Client) StopIssueStopWatch(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/issues/%d/stopwatch/stop", owner, repo, index), nil, nil)
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListIssueSubscribersOptions options for listing issue subscribers
|
||||||
|
type ListIssueSubscribersOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueSubscribers get list of users who subscribed on an issue
|
||||||
|
//
|
||||||
|
// Deprecated: Use ListIssueSubscribers instead, which supports pagination.
|
||||||
|
func (c *Client) GetIssueSubscribers(owner, repo string, index int64) ([]*User, *Response, error) {
|
||||||
|
return c.ListIssueSubscribers(owner, repo, index, ListIssueSubscribersOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueSubscribers get list of users who subscribed on an issue with pagination
|
||||||
|
func (c *Client) ListIssueSubscribers(owner, repo string, index int64, opt ListIssueSubscribersOptions) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
subscribers := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &subscribers)
|
||||||
|
return subscribers, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIssueSubscription Subscribe user to issue
|
||||||
|
func (c *Client) AddIssueSubscription(owner, repo string, index int64, user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PUT", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/%s", owner, repo, index, user), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if status == http.StatusOK {
|
||||||
|
return resp, fmt.Errorf("already subscribed")
|
||||||
|
}
|
||||||
|
return resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIssueSubscription unsubscribe user from issue
|
||||||
|
func (c *Client) DeleteIssueSubscription(owner, repo string, index int64, user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/%s", owner, repo, index, user), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if status == http.StatusOK {
|
||||||
|
return resp, fmt.Errorf("already unsubscribed")
|
||||||
|
}
|
||||||
|
return resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIssueSubscription check if current user is subscribed to an issue
|
||||||
|
func (c *Client) CheckIssueSubscription(owner, repo string, index int64) (*WatchInfo, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
wi := new(WatchInfo)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issues/%d/subscriptions/check", owner, repo, index), nil, nil, wi)
|
||||||
|
return wi, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueSubscribe subscribe current user to an issue
|
||||||
|
func (c *Client) IssueSubscribe(owner, repo string, index int64) (*Response, error) {
|
||||||
|
u, _, err := c.GetMyUserInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.AddIssueSubscription(owner, repo, index, u.UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueUnSubscribe unsubscribe current user from an issue
|
||||||
|
func (c *Client) IssueUnSubscribe(owner, repo string, index int64) (*Response, error) {
|
||||||
|
u, _, err := c.GetMyUserInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.DeleteIssueSubscription(owner, repo, index, u.UserName)
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueTemplate provides metadata and content on an issue template.
|
||||||
|
// There are two types of issue templates: .Markdown- and .Form-based.
|
||||||
|
type IssueTemplate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
About string `json:"about"`
|
||||||
|
Filename string `json:"file_name"`
|
||||||
|
IssueTitle string `json:"title"`
|
||||||
|
IssueLabels []string `json:"labels"`
|
||||||
|
IssueRef string `json:"ref"`
|
||||||
|
// If non-nil, this is a form-based template
|
||||||
|
Form []IssueFormElement `json:"body"`
|
||||||
|
// Should only be used when .Form is nil.
|
||||||
|
MarkdownContent string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueFormElement describes a part of a IssueTemplate form
|
||||||
|
type IssueFormElement struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type IssueFormElementType `json:"type"`
|
||||||
|
Attributes IssueFormElementAttributes `json:"attributes"`
|
||||||
|
Validations IssueFormElementValidations `json:"validations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueFormElementAttributes contains the combined set of attributes available on all element types.
|
||||||
|
type IssueFormElementAttributes struct {
|
||||||
|
// required for all element types.
|
||||||
|
// A brief description of the expected user input, which is also displayed in the form.
|
||||||
|
Label string `json:"label"`
|
||||||
|
// required for element types "dropdown", "checkboxes"
|
||||||
|
// for dropdown, contains the available options
|
||||||
|
Options []string `json:"options"`
|
||||||
|
// for element types "markdown", "textarea", "input"
|
||||||
|
// Text that is pre-filled in the input
|
||||||
|
Value string `json:"value"`
|
||||||
|
// for element types "textarea", "input", "dropdown", "checkboxes"
|
||||||
|
// A description of the text area to provide context or guidance, which is displayed in the form.
|
||||||
|
Description string `json:"description"`
|
||||||
|
// for element types "textarea", "input"
|
||||||
|
// A semi-opaque placeholder that renders in the text area when empty.
|
||||||
|
Placeholder string `json:"placeholder"`
|
||||||
|
// for element types "textarea"
|
||||||
|
// A language specifier. If set, the input is rendered as codeblock with syntax highlighting.
|
||||||
|
SyntaxHighlighting string `json:"render"`
|
||||||
|
// for element types "dropdown"
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueFormElementValidations contains the combined set of validations available on all element types.
|
||||||
|
type IssueFormElementValidations struct {
|
||||||
|
// for all element types
|
||||||
|
Required bool `json:"required"`
|
||||||
|
// for element types "input"
|
||||||
|
IsNumber bool `json:"is_number"`
|
||||||
|
// for element types "input"
|
||||||
|
Regex string `json:"regex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueFormElementType is an enum
|
||||||
|
type IssueFormElementType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// IssueFormElementMarkdown is markdown rendered to the form for context, but omitted in the resulting issue
|
||||||
|
IssueFormElementMarkdown IssueFormElementType = "markdown"
|
||||||
|
// IssueFormElementTextarea is a multi line input
|
||||||
|
IssueFormElementTextarea IssueFormElementType = "textarea"
|
||||||
|
// IssueFormElementInput is a single line input
|
||||||
|
IssueFormElementInput IssueFormElementType = "input"
|
||||||
|
// IssueFormElementDropdown is a select form
|
||||||
|
IssueFormElementDropdown IssueFormElementType = "dropdown"
|
||||||
|
// IssueFormElementCheckboxes are a multi checkbox input
|
||||||
|
IssueFormElementCheckboxes IssueFormElementType = "checkboxes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetIssueTemplates lists all issue templates of the repository
|
||||||
|
func (c *Client) GetIssueTemplates(owner, repo string) ([]*IssueTemplate, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
templates := new([]*IssueTemplate)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issue_templates", owner, repo), nil, nil, templates)
|
||||||
|
return *templates, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsForm tells if this template is a form instead of a markdown-based template.
|
||||||
|
func (t IssueTemplate) IsForm() bool {
|
||||||
|
return t.Form != nil
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2025 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Comment represents a comment on a commit or issue
|
||||||
|
type TimelineComment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
PRURL string `json:"pull_request_url"`
|
||||||
|
IssueURL string `json:"issue_url"`
|
||||||
|
Poster *User `json:"user"`
|
||||||
|
OriginalAuthor string `json:"original_author"`
|
||||||
|
OriginalAuthorID int64 `json:"original_author_id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Label []*Label `json:"label"`
|
||||||
|
NewMilestone *Milestone `json:"milestone"`
|
||||||
|
OldMilestone *Milestone `json:"old_milestone"`
|
||||||
|
NewTitle string `json:"new_title"`
|
||||||
|
OldTitle string `json:"old_title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueTimeline list timeline on an issue.
|
||||||
|
func (c *Client) ListIssueTimeline(owner, repo string, index int64, opt ListIssueCommentOptions) ([]*TimelineComment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/timeline", owner, repo, index))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
timelineComments := make([]*TimelineComment, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &timelineComments)
|
||||||
|
return timelineComments, resp, err
|
||||||
|
}
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrackedTime worked time for an issue / pr
|
||||||
|
type TrackedTime struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
// Time in seconds
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
// deprecated (only for backwards compatibility)
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
// deprecated (only for backwards compatibility)
|
||||||
|
IssueID int64 `json:"issue_id"`
|
||||||
|
Issue *Issue `json:"issue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrackedTimesOptions options for listing repository's tracked times
|
||||||
|
type ListTrackedTimesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Since time.Time
|
||||||
|
Before time.Time
|
||||||
|
// User filter is only used by ListRepoTrackedTimes !!!
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListTrackedTimesOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
|
||||||
|
if !opt.Since.IsZero() {
|
||||||
|
query.Add("since", opt.Since.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !opt.Before.IsZero() {
|
||||||
|
query.Add("before", opt.Before.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.User) != 0 {
|
||||||
|
query.Add("user", opt.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoTrackedTimes list tracked times of a repository
|
||||||
|
func (c *Client) ListRepoTrackedTimes(owner, repo string, opt ListTrackedTimesOptions) ([]*TrackedTime, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/times", owner, repo))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
times := make([]*TrackedTime, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ×)
|
||||||
|
return times, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyTrackedTimes list tracked times of the current user
|
||||||
|
//
|
||||||
|
// Deprecated: Use ListMyTrackedTimes instead, which supports pagination and filtering.
|
||||||
|
func (c *Client) GetMyTrackedTimes() ([]*TrackedTime, *Response, error) {
|
||||||
|
return c.ListMyTrackedTimes(ListTrackedTimesOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyTrackedTimes list tracked times of the current user with pagination and filtering
|
||||||
|
func (c *Client) ListMyTrackedTimes(opt ListTrackedTimesOptions) ([]*TrackedTime, *Response, error) {
|
||||||
|
link, _ := url.Parse("/user/times")
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
times := make([]*TrackedTime, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ×)
|
||||||
|
return times, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTimeOption options for adding time to an issue
|
||||||
|
type AddTimeOption struct {
|
||||||
|
// time in seconds
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
// optional
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
// optional
|
||||||
|
User string `json:"user_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the AddTimeOption struct
|
||||||
|
func (opt AddTimeOption) Validate() error {
|
||||||
|
if opt.Time == 0 {
|
||||||
|
return fmt.Errorf("no time to add")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTime adds time to issue with the given index
|
||||||
|
func (c *Client) AddTime(owner, repo string, index int64, opt AddTimeOption) (*TrackedTime, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
t := new(TrackedTime)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), t)
|
||||||
|
return t, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListIssueTrackedTimes list tracked times of a single issue for a given repository
|
||||||
|
func (c *Client) ListIssueTrackedTimes(owner, repo string, index int64, opt ListTrackedTimesOptions) ([]*TrackedTime, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
times := make([]*TrackedTime, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ×)
|
||||||
|
return times, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetIssueTime reset tracked time of a single issue for a given repository
|
||||||
|
func (c *Client) ResetIssueTime(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/times", owner, repo, index), jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTime delete a specific tracked time by id of a single issue for a given repository
|
||||||
|
func (c *Client) DeleteTime(owner, repo string, index, timeID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/issues/%d/times/%d", owner, repo, index, timeID), jsonHeader, nil)
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOptions options for using Gitea's API pagination
|
||||||
|
type ListOptions struct {
|
||||||
|
// Setting Page to -1 disables pagination on endpoints that support it.
|
||||||
|
// Page numbering starts at 1.
|
||||||
|
Page int
|
||||||
|
// The default value depends on the server config DEFAULT_PAGING_NUM
|
||||||
|
// The highest valid value depends on the server config MAX_RESPONSE_ITEMS
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o ListOptions) getURLQuery() url.Values {
|
||||||
|
query := make(url.Values)
|
||||||
|
query.Add("page", fmt.Sprintf("%d", o.Page))
|
||||||
|
query.Add("limit", fmt.Sprintf("%d", o.PageSize))
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDefaults applies default pagination options.
|
||||||
|
// If .Page is set to -1, it will disable pagination.
|
||||||
|
// WARNING: This function is not idempotent, make sure to never call this method twice!
|
||||||
|
func (o *ListOptions) setDefaults() {
|
||||||
|
if o.Page < 0 {
|
||||||
|
o.Page, o.PageSize = 0, 0
|
||||||
|
return
|
||||||
|
} else if o.Page == 0 {
|
||||||
|
o.Page = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitignoreTemplateInfo represents a gitignore template
|
||||||
|
type GitignoreTemplateInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabelTemplate represents a label template
|
||||||
|
type LabelTemplate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Exclusive bool `json:"exclusive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicensesTemplateListEntry represents a license in the list
|
||||||
|
type LicensesTemplateListEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseTemplateInfo represents a license template
|
||||||
|
type LicenseTemplateInfo struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Implementation string `json:"implementation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkdownOption represents options for rendering markdown
|
||||||
|
type MarkdownOption struct {
|
||||||
|
Text string `json:"Text"`
|
||||||
|
Mode string `json:"Mode"`
|
||||||
|
Context string `json:"Context"`
|
||||||
|
Wiki bool `json:"Wiki"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkupOption represents options for rendering markup
|
||||||
|
type MarkupOption struct {
|
||||||
|
Text string `json:"Text"`
|
||||||
|
Mode string `json:"Mode"`
|
||||||
|
Context string `json:"Context"`
|
||||||
|
FilePath string `json:"FilePath"`
|
||||||
|
Wiki bool `json:"Wiki"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfo represents nodeinfo about the server
|
||||||
|
type NodeInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Software NodeInfoSoftware `json:"software"`
|
||||||
|
Protocols []string `json:"protocols"`
|
||||||
|
Services NodeInfoServices `json:"services"`
|
||||||
|
OpenRegistrations bool `json:"openRegistrations"`
|
||||||
|
Usage NodeInfoUsage `json:"usage"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfoSoftware represents software information
|
||||||
|
type NodeInfoSoftware struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Homepage string `json:"homepage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfoServices represents third party services
|
||||||
|
type NodeInfoServices struct {
|
||||||
|
Inbound []string `json:"inbound"`
|
||||||
|
Outbound []string `json:"outbound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfoUsage represents usage statistics
|
||||||
|
type NodeInfoUsage struct {
|
||||||
|
Users NodeInfoUsageUsers `json:"users"`
|
||||||
|
LocalPosts int64 `json:"localPosts"`
|
||||||
|
LocalComments int64 `json:"localComments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeInfoUsageUsers represents user statistics
|
||||||
|
type NodeInfoUsageUsers struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
ActiveHalfyear int64 `json:"activeHalfyear"`
|
||||||
|
ActiveMonth int64 `json:"activeMonth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGitignoresTemplates lists all gitignore templates
|
||||||
|
func (c *Client) ListGitignoresTemplates() ([]string, *Response, error) {
|
||||||
|
templates := make([]string, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", "/gitignore/templates", jsonHeader, nil, &templates)
|
||||||
|
return templates, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGitignoreTemplateInfo gets information about a gitignore template
|
||||||
|
func (c *Client) GetGitignoreTemplateInfo(name string) (*GitignoreTemplateInfo, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
template := new(GitignoreTemplateInfo)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/gitignore/templates/%s", name),
|
||||||
|
jsonHeader, nil, &template)
|
||||||
|
return template, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLabelTemplates lists all label templates
|
||||||
|
func (c *Client) ListLabelTemplates() ([]string, *Response, error) {
|
||||||
|
templates := make([]string, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", "/label/templates", jsonHeader, nil, &templates)
|
||||||
|
return templates, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLabelTemplate gets all labels in a template
|
||||||
|
func (c *Client) GetLabelTemplate(name string) ([]*LabelTemplate, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
labels := make([]*LabelTemplate, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/label/templates/%s", name),
|
||||||
|
jsonHeader, nil, &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLicenseTemplates lists all license templates
|
||||||
|
func (c *Client) ListLicenseTemplates() ([]*LicensesTemplateListEntry, *Response, error) {
|
||||||
|
licenses := make([]*LicensesTemplateListEntry, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", "/licenses", jsonHeader, nil, &licenses)
|
||||||
|
return licenses, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLicenseTemplateInfo gets information about a license template
|
||||||
|
func (c *Client) GetLicenseTemplateInfo(name string) (*LicenseTemplateInfo, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
license := new(LicenseTemplateInfo)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/licenses/%s", name),
|
||||||
|
jsonHeader, nil, &license)
|
||||||
|
return license, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkdown renders a markdown document as HTML
|
||||||
|
func (c *Client) RenderMarkdown(opt MarkdownOption) (string, *Response, error) {
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
html, resp, err := c.getResponse("POST", "/markdown", jsonHeader, bytes.NewReader(body))
|
||||||
|
return string(html), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkdownRaw renders raw markdown as HTML
|
||||||
|
func (c *Client) RenderMarkdownRaw(markdown string) (string, *Response, error) {
|
||||||
|
html, resp, err := c.getResponse("POST", "/markdown/raw",
|
||||||
|
map[string][]string{"Content-Type": {"text/plain"}},
|
||||||
|
bytes.NewReader([]byte(markdown)))
|
||||||
|
if err != nil {
|
||||||
|
return "", resp, err
|
||||||
|
}
|
||||||
|
return string(html), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkup renders a markup document as HTML
|
||||||
|
func (c *Client) RenderMarkup(opt MarkupOption) (string, *Response, error) {
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
html, resp, err := c.getResponse("POST", "/markup", jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", resp, err
|
||||||
|
}
|
||||||
|
return string(html), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeInfo gets the nodeinfo of the Gitea application
|
||||||
|
func (c *Client) GetNodeInfo() (*NodeInfo, *Response, error) {
|
||||||
|
nodeInfo := new(NodeInfo)
|
||||||
|
resp, err := c.getParsedResponse("GET", "/nodeinfo", jsonHeader, nil, &nodeInfo)
|
||||||
|
return nodeInfo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigningKeyGPG gets the default GPG signing key
|
||||||
|
func (c *Client) GetSigningKeyGPG() (string, *Response, error) {
|
||||||
|
key, resp, err := c.getResponse("GET", "/signing-key.gpg", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", resp, err
|
||||||
|
}
|
||||||
|
return string(key), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSigningKeySSH gets the default SSH signing key
|
||||||
|
func (c *Client) GetSigningKeySSH() (string, *Response, error) {
|
||||||
|
key, resp, err := c.getResponse("GET", "/signing-key.pub", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", resp, err
|
||||||
|
}
|
||||||
|
return string(key), resp, err
|
||||||
|
}
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationThread expose Notification on API
|
||||||
|
type NotificationThread struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Repository *Repository `json:"repository"`
|
||||||
|
Subject *NotificationSubject `json:"subject"`
|
||||||
|
Unread bool `json:"unread"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationSubject contains the notification subject (Issue/Pull/Commit)
|
||||||
|
type NotificationSubject struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
LatestCommentURL string `json:"latest_comment_url"`
|
||||||
|
LatestCommentHTMLURL string `json:"latest_comment_html_url"`
|
||||||
|
Type NotifySubjectType `json:"type"`
|
||||||
|
State NotifySubjectState `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyStatus notification status type
|
||||||
|
type NotifyStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NotifyStatusUnread was not read
|
||||||
|
NotifyStatusUnread NotifyStatus = "unread"
|
||||||
|
// NotifyStatusRead was already read by user
|
||||||
|
NotifyStatusRead NotifyStatus = "read"
|
||||||
|
// NotifyStatusPinned notification is pinned by user
|
||||||
|
NotifyStatusPinned NotifyStatus = "pinned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifySubjectType represent type of notification subject
|
||||||
|
type NotifySubjectType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NotifySubjectIssue an issue is subject of an notification
|
||||||
|
NotifySubjectIssue NotifySubjectType = "Issue"
|
||||||
|
// NotifySubjectPull an pull is subject of an notification
|
||||||
|
NotifySubjectPull NotifySubjectType = "Pull"
|
||||||
|
// NotifySubjectCommit an commit is subject of an notification
|
||||||
|
NotifySubjectCommit NotifySubjectType = "Commit"
|
||||||
|
// NotifySubjectRepository an repository is subject of an notification
|
||||||
|
NotifySubjectRepository NotifySubjectType = "Repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifySubjectState reflect state of notification subject
|
||||||
|
type NotifySubjectState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// NotifySubjectOpen if subject is a pull/issue and is open at the moment
|
||||||
|
NotifySubjectOpen NotifySubjectState = "open"
|
||||||
|
// NotifySubjectClosed if subject is a pull/issue and is closed at the moment
|
||||||
|
NotifySubjectClosed NotifySubjectState = "closed"
|
||||||
|
// NotifySubjectMerged if subject is a pull and got merged
|
||||||
|
NotifySubjectMerged NotifySubjectState = "merged"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListNotificationOptions represents the filter options
|
||||||
|
type ListNotificationOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Since time.Time
|
||||||
|
Before time.Time
|
||||||
|
Status []NotifyStatus
|
||||||
|
SubjectTypes []NotifySubjectType
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationOptions represents the filter & modify options
|
||||||
|
type MarkNotificationOptions struct {
|
||||||
|
LastReadAt time.Time
|
||||||
|
Status []NotifyStatus
|
||||||
|
ToStatus NotifyStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode encode options to url query
|
||||||
|
func (opt *ListNotificationOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if !opt.Since.IsZero() {
|
||||||
|
query.Add("since", opt.Since.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !opt.Before.IsZero() {
|
||||||
|
query.Add("before", opt.Before.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
for _, s := range opt.Status {
|
||||||
|
query.Add("status-types", string(s))
|
||||||
|
}
|
||||||
|
for _, s := range opt.SubjectTypes {
|
||||||
|
query.Add("subject-type", string(s))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateUserOption struct
|
||||||
|
func (opt ListNotificationOptions) Validate(c *Client) error {
|
||||||
|
if len(opt.Status) != 0 {
|
||||||
|
return c.checkServerVersionGreaterThanOrEqual(version1_12_3)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode encode options to url query
|
||||||
|
func (opt *MarkNotificationOptions) QueryEncode() string {
|
||||||
|
query := make(url.Values)
|
||||||
|
if !opt.LastReadAt.IsZero() {
|
||||||
|
query.Add("last_read_at", opt.LastReadAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
for _, s := range opt.Status {
|
||||||
|
query.Add("status-types", string(s))
|
||||||
|
}
|
||||||
|
if len(opt.ToStatus) != 0 {
|
||||||
|
query.Add("to-status", string(opt.ToStatus))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateUserOption struct
|
||||||
|
func (opt MarkNotificationOptions) Validate(c *Client) error {
|
||||||
|
if len(opt.Status) != 0 || len(opt.ToStatus) != 0 {
|
||||||
|
return c.checkServerVersionGreaterThanOrEqual(version1_12_3)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNotifications list users's notification threads
|
||||||
|
func (c *Client) CheckNotifications() (int64, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
new := struct {
|
||||||
|
New int64 `json:"new"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
resp, err := c.getParsedResponse("GET", "/notifications/new", jsonHeader, nil, &new)
|
||||||
|
return new.New, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotification get notification thread by ID
|
||||||
|
func (c *Client) GetNotification(id int64) (*NotificationThread, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
thread := new(NotificationThread)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/notifications/threads/%d", id), nil, nil, thread)
|
||||||
|
return thread, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadNotification mark notification thread as read by ID
|
||||||
|
// It optionally takes a second argument if status has to be set other than 'read'
|
||||||
|
// The relevant notification will be returned as the first parameter when the Gitea server is 1.16.0 or higher.
|
||||||
|
func (c *Client) ReadNotification(id int64, status ...NotifyStatus) (*NotificationThread, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link := fmt.Sprintf("/notifications/threads/%d", id)
|
||||||
|
if len(status) != 0 {
|
||||||
|
link += fmt.Sprintf("?to-status=%s", status[0])
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil {
|
||||||
|
thread := &NotificationThread{}
|
||||||
|
resp, err := c.getParsedResponse("PATCH", link, nil, nil, thread)
|
||||||
|
return thread, resp, err
|
||||||
|
}
|
||||||
|
resp, err := c.doRequestWithStatusHandle("PATCH", link, nil, nil)
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNotifications list users's notification threads
|
||||||
|
func (c *Client) ListNotifications(opt ListNotificationOptions) ([]*NotificationThread, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse("/notifications")
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
threads := make([]*NotificationThread, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &threads)
|
||||||
|
return threads, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadNotifications mark notification threads as read
|
||||||
|
// The relevant notifications will only be returned as the first parameter when the Gitea server is 1.16.0 or higher.
|
||||||
|
func (c *Client) ReadNotifications(opt MarkNotificationOptions) ([]*NotificationThread, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse("/notifications")
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil {
|
||||||
|
threads := make([]*NotificationThread, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("PUT", link.String(), nil, nil, &threads)
|
||||||
|
return threads, resp, err
|
||||||
|
}
|
||||||
|
resp, err := c.doRequestWithStatusHandle("PUT", link.String(), nil, nil)
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoNotifications list users's notification threads on a specific repo
|
||||||
|
func (c *Client) ListRepoNotifications(owner, repo string, opt ListNotificationOptions) ([]*NotificationThread, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/notifications", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
threads := make([]*NotificationThread, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &threads)
|
||||||
|
return threads, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadRepoNotifications mark notification threads as read on a specific repo
|
||||||
|
// The relevant notifications will only be returned as the first parameter when the Gitea server is 1.16.0 or higher.
|
||||||
|
func (c *Client) ReadRepoNotifications(owner, repo string, opt MarkNotificationOptions) ([]*NotificationThread, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/notifications", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err == nil {
|
||||||
|
threads := make([]*NotificationThread, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("PUT", link.String(), nil, nil, &threads)
|
||||||
|
return threads, resp, err
|
||||||
|
}
|
||||||
|
resp, err := c.doRequestWithStatusHandle("PUT", link.String(), nil, nil)
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Oauth2 represents an Oauth2 Application
|
||||||
|
type Oauth2 struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
ConfidentialClient bool `json:"confidential_client"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOauth2Option for listing Oauth2 Applications
|
||||||
|
type ListOauth2Option struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOauth2Option required options for creating an Application
|
||||||
|
type CreateOauth2Option struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ConfidentialClient bool `json:"confidential_client"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOauth2 create an Oauth2 Application and returns a completed Oauth2 object.
|
||||||
|
func (c *Client) CreateOauth2(opt CreateOauth2Option) (*Oauth2, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
oauth := new(Oauth2)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/user/applications/oauth2", jsonHeader, bytes.NewReader(body), oauth)
|
||||||
|
return oauth, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOauth2 a specific Oauth2 Application by ID and return a completed Oauth2 object.
|
||||||
|
func (c *Client) UpdateOauth2(oauth2id int64, opt CreateOauth2Option) (*Oauth2, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
oauth := new(Oauth2)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), jsonHeader, bytes.NewReader(body), oauth)
|
||||||
|
return oauth, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOauth2 a specific Oauth2 Application by ID.
|
||||||
|
func (c *Client) GetOauth2(oauth2id int64) (*Oauth2, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
oauth2s := &Oauth2{}
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), nil, nil, &oauth2s)
|
||||||
|
return oauth2s, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOauth2 all of your Oauth2 Applications.
|
||||||
|
func (c *Client) ListOauth2(opt ListOauth2Option) ([]*Oauth2, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
oauth2s := make([]*Oauth2, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/applications/oauth2?%s", opt.getURLQuery().Encode()), nil, nil, &oauth2s)
|
||||||
|
return oauth2s, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOauth2 delete an Oauth2 application by ID
|
||||||
|
func (c *Client) DeleteOauth2(oauth2id int64) (*Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/user/applications/oauth2/%d", oauth2id), nil, nil)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organization represents an organization
|
||||||
|
type Organization struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Deprecated: Use Name instead. See https://github.com/go-gitea/gitea/blob/main/modules/structs/org.go#L29
|
||||||
|
UserName string `json:"username"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisibleType defines the visibility
|
||||||
|
type VisibleType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VisibleTypePublic Visible for everyone
|
||||||
|
VisibleTypePublic VisibleType = "public"
|
||||||
|
|
||||||
|
// VisibleTypeLimited Visible for every connected user
|
||||||
|
VisibleTypeLimited VisibleType = "limited"
|
||||||
|
|
||||||
|
// VisibleTypePrivate Visible only for organization's members
|
||||||
|
VisibleTypePrivate VisibleType = "private"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOrgsOptions options for listing organizations
|
||||||
|
type ListOrgsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgs lists all public organizations
|
||||||
|
func (c *Client) ListOrgs(opt ListOrgsOptions) ([]*Organization, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
link, _ := url.Parse("/orgs")
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
orgs := make([]*Organization, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &orgs)
|
||||||
|
return orgs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyOrgs list all of current user's organizations
|
||||||
|
func (c *Client) ListMyOrgs(opt ListOrgsOptions) ([]*Organization, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
orgs := make([]*Organization, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/orgs?%s", opt.getURLQuery().Encode()), nil, nil, &orgs)
|
||||||
|
return orgs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserOrgs list all of some user's organizations
|
||||||
|
func (c *Client) ListUserOrgs(user string, opt ListOrgsOptions) ([]*Organization, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
orgs := make([]*Organization, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/orgs?%s", user, opt.getURLQuery().Encode()), nil, nil, &orgs)
|
||||||
|
return orgs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrg get one organization by name
|
||||||
|
func (c *Client) GetOrg(orgname string) (*Organization, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgname); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
org := new(Organization)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s", orgname), nil, nil, org)
|
||||||
|
return org, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgOption options for creating an organization
|
||||||
|
type CreateOrgOption struct {
|
||||||
|
Name string `json:"username"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Visibility VisibleType `json:"visibility"`
|
||||||
|
RepoAdminChangeTeamAccess bool `json:"repo_admin_change_team_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVisibilityOpt check if mode exist
|
||||||
|
func checkVisibilityOpt(v VisibleType) bool {
|
||||||
|
return v == VisibleTypePublic || v == VisibleTypeLimited || v == VisibleTypePrivate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateOrgOption struct
|
||||||
|
func (opt CreateOrgOption) Validate() error {
|
||||||
|
if len(opt.Name) == 0 {
|
||||||
|
return fmt.Errorf("empty org name")
|
||||||
|
}
|
||||||
|
if len(opt.Visibility) != 0 && !checkVisibilityOpt(opt.Visibility) {
|
||||||
|
return fmt.Errorf("invalid visibility option")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrg creates an organization
|
||||||
|
func (c *Client) CreateOrg(opt CreateOrgOption) (*Organization, *Response, error) {
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
org := new(Organization)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/orgs", jsonHeader, bytes.NewReader(body), org)
|
||||||
|
return org, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgOption options for editing an organization
|
||||||
|
type EditOrgOption struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Visibility VisibleType `json:"visibility"`
|
||||||
|
RepoAdminChangeTeamAccess *bool `json:"repo_admin_change_team_access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditOrgOption struct
|
||||||
|
func (opt EditOrgOption) Validate() error {
|
||||||
|
if len(opt.Visibility) != 0 && !checkVisibilityOpt(opt.Visibility) {
|
||||||
|
return fmt.Errorf("invalid visibility option")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrg modify one organization via options
|
||||||
|
func (c *Client) EditOrg(orgname string, opt EditOrgOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgname); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/orgs/%s", orgname), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrg deletes an organization
|
||||||
|
func (c *Client) DeleteOrg(orgname string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgname); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s", orgname), jsonHeader, nil)
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOrgActionSecretOption list OrgActionSecret options
|
||||||
|
type ListOrgActionSecretOption struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionSecret list an organization's secrets
|
||||||
|
func (c *Client) ListOrgActionSecret(org string, opt ListOrgActionSecretOption) ([]*Secret, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
secrets := make([]*Secret, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/actions/secrets", org))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &secrets)
|
||||||
|
return secrets, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionVariableOption lists ActionVariable options
|
||||||
|
type ListOrgActionVariableOption struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionVariable lists an organization's action variables
|
||||||
|
func (c *Client) ListOrgActionVariable(org string, opt ListOrgActionVariableOption) ([]*ActionVariable, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
variables := make([]*ActionVariable, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/actions/variables", org))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &variables)
|
||||||
|
return variables, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgActionVariable gets a single organization's action variable by name
|
||||||
|
func (c *Client) GetOrgActionVariable(org, name string) (*ActionVariable, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var variable ActionVariable
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name),
|
||||||
|
jsonHeader, nil, &variable)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
return &variable, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgActionVariable creates a variable for the specified organization in the Gitea Actions.
|
||||||
|
func (c *Client) CreateOrgActionVariable(org, name string, opt CreateActionVariableOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgActionVariable updates a variable for the specified organization in the Gitea Actions.
|
||||||
|
func (c *Client) UpdateOrgActionVariable(org, name string, opt UpdateActionVariableOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgActionSecret creates a secret for the specified organization in the Gitea Actions.
|
||||||
|
func (c *Client) CreateOrgActionSecret(org, secretName string, opt CreateOrUpdateSecretOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &secretName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/orgs/%s/actions/secrets/%s", org, secretName), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgActionSecret deletes an organization's Actions secret.
|
||||||
|
func (c *Client) DeleteOrgActionSecret(org, secretName string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &secretName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s/actions/secrets/%s", org, secretName), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgActionVariable deletes an organization's Actions variable.
|
||||||
|
func (c *Client) DeleteOrgActionVariable(org, name string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgActionRunnerRegistrationToken creates an organization runner registration token.
|
||||||
|
func (c *Client) CreateOrgActionRunnerRegistrationToken(org string) (*RegistrationToken, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.createActionRegistrationToken(fmt.Sprintf("/orgs/%s/actions/runners/registration-token", org))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionRunners lists organization-scoped Actions runners.
|
||||||
|
func (c *Client) ListOrgActionRunners(org string, opt ListActionRunnersOptions) (*ActionRunnersResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionRunners(fmt.Sprintf("/orgs/%s/actions/runners", org), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgActionRunner gets one organization-scoped Actions runner.
|
||||||
|
func (c *Client) GetOrgActionRunner(org string, runnerID int64) (*ActionRunner, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getActionRunner(fmt.Sprintf("/orgs/%s/actions/runners/%d", org, runnerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgActionRunner deletes one organization-scoped Actions runner.
|
||||||
|
func (c *Client) DeleteOrgActionRunner(org string, runnerID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s/actions/runners/%d", org, runnerID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgActionRunner updates one organization-scoped Actions runner.
|
||||||
|
func (c *Client) UpdateOrgActionRunner(org string, runnerID int64, opt EditActionRunnerOption) (*ActionRunner, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.updateActionRunner(fmt.Sprintf("/orgs/%s/actions/runners/%d", org, runnerID), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionJobs lists organization-scoped Actions jobs.
|
||||||
|
func (c *Client) ListOrgActionJobs(org string, opt ListRepoActionJobsOptions) (*ActionWorkflowJobsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionJobs(fmt.Sprintf("/orgs/%s/actions/jobs", org), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActionRuns lists organization-scoped Actions workflow runs.
|
||||||
|
func (c *Client) ListOrgActionRuns(org string, opt ListRepoActionRunsOptions) (*ActionWorkflowRunsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionRuns(fmt.Sprintf("/orgs/%s/actions/runs", org), opt)
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOrgBlocksOptions options for listing organization blocks
|
||||||
|
type ListOrgBlocksOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgBlocks lists users blocked by the organization
|
||||||
|
func (c *Client) ListOrgBlocks(org string, opt ListOrgBlocksOptions) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/blocks", org))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
|
||||||
|
users := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &users)
|
||||||
|
return users, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckOrgBlock checks if a user is blocked by the organization
|
||||||
|
func (c *Client) CheckOrgBlock(org, username string) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &username); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("GET",
|
||||||
|
fmt.Sprintf("/orgs/%s/blocks/%s", org, username),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
return status == http.StatusNoContent, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockOrgUser blocks a user from the organization
|
||||||
|
func (c *Client) BlockOrgUser(org, username string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PUT",
|
||||||
|
fmt.Sprintf("/orgs/%s/blocks/%s", org, username),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockOrgUser unblocks a user from the organization
|
||||||
|
func (c *Client) UnblockOrgUser(org, username string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &username); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/orgs/%s/blocks/%s", org, username),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOrgLabelsOptions options for listing organization labels
|
||||||
|
type ListOrgLabelsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgLabels returns the labels defined at the org level
|
||||||
|
func (c *Client) ListOrgLabels(orgName string, opt ListOrgLabelsOptions) ([]*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
labels := make([]*Label, 0, opt.PageSize)
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/labels", orgName))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateOrgLabelOption struct {
|
||||||
|
// Name of the label
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Color of the label in hex format without #
|
||||||
|
Color string `json:"color"`
|
||||||
|
// Description of the label
|
||||||
|
Description string `json:"description"`
|
||||||
|
// Whether this is an exclusive label
|
||||||
|
Exclusive bool `json:"exclusive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateLabelOption struct
|
||||||
|
func (opt CreateOrgLabelOption) Validate() error {
|
||||||
|
aw, err := regexp.MatchString("^#?[0-9,a-f,A-F]{6}$", opt.Color)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !aw {
|
||||||
|
return fmt.Errorf("invalid color format")
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(opt.Name)) == 0 {
|
||||||
|
return fmt.Errorf("empty name not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgLabel creates a new label under an organization
|
||||||
|
func (c *Client) CreateOrgLabel(orgName string, opt CreateOrgLabelOption) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/labels", orgName), jsonHeader, bytes.NewReader(body), label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgLabel get one label of organization by org it
|
||||||
|
func (c *Client) GetOrgLabel(orgName string, labelID int64) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/labels/%d", orgName, labelID), nil, nil, label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditOrgLabelOption struct {
|
||||||
|
// New name of the label
|
||||||
|
Name *string `json:"name"`
|
||||||
|
// New color of the label in hex format without #
|
||||||
|
Color *string `json:"color"`
|
||||||
|
// New description of the label
|
||||||
|
Description *string `json:"description"`
|
||||||
|
// Whether this is an exclusive label
|
||||||
|
Exclusive *bool `json:"exclusive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditOrgLabel edits an existing org-level label by ID
|
||||||
|
func (c *Client) EditOrgLabel(orgName string, labelID int64, opt EditOrgLabelOption) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/orgs/%s/labels/%d", orgName, labelID), jsonHeader, bytes.NewReader(body), label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgLabel deletes a org label by ID
|
||||||
|
func (c *Client) DeleteOrgLabel(orgName string, labelID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&orgName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, resp, err := c.getResponse("DELETE", fmt.Sprintf("/orgs/%s/labels/%d", orgName, labelID), jsonHeader, nil)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeleteOrgMembership remove a member from an organization
|
||||||
|
func (c *Client) DeleteOrgMembership(org, user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/orgs/%s/members/%s", org, user), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgMembershipOption list OrgMembership options
|
||||||
|
type ListOrgMembershipOption struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgMembership list an organization's members
|
||||||
|
func (c *Client) ListOrgMembership(org string, opt ListOrgMembershipOption) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
users := make([]*User, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/members", org))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &users)
|
||||||
|
return users, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicOrgMembership list an organization's members
|
||||||
|
func (c *Client) ListPublicOrgMembership(org string, opt ListOrgMembershipOption) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
users := make([]*User, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/public_members", org))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &users)
|
||||||
|
return users, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckOrgMembership Check if a user is a member of an organization
|
||||||
|
func (c *Client) CheckOrgMembership(org, user string) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &user); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/orgs/%s/members/%s", org, user), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
return true, resp, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return false, resp, nil
|
||||||
|
default:
|
||||||
|
return false, resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPublicOrgMembership Check if a user is a member of an organization
|
||||||
|
func (c *Client) CheckPublicOrgMembership(org, user string) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &user); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
return true, resp, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return false, resp, nil
|
||||||
|
default:
|
||||||
|
return false, resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPublicOrgMembership publicize/conceal a user's membership
|
||||||
|
func (c *Client) SetPublicOrgMembership(org, user string, visible bool) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
status int
|
||||||
|
err error
|
||||||
|
resp *Response
|
||||||
|
)
|
||||||
|
if visible {
|
||||||
|
status, resp, err = c.getStatusCode("PUT", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil)
|
||||||
|
} else {
|
||||||
|
status, resp, err = c.getStatusCode("DELETE", fmt.Sprintf("/orgs/%s/public_members/%s", org, user), nil, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusNoContent:
|
||||||
|
return resp, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return resp, fmt.Errorf("forbidden")
|
||||||
|
default:
|
||||||
|
return resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgPermissions represents the permissions for an user in an organization
|
||||||
|
type OrgPermissions struct {
|
||||||
|
CanCreateRepository bool `json:"can_create_repository"`
|
||||||
|
CanRead bool `json:"can_read"`
|
||||||
|
CanWrite bool `json:"can_write"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
IsOwner bool `json:"is_owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPermissions returns user permissions for specific organization.
|
||||||
|
func (c *Client) GetOrgPermissions(org, user string) (*OrgPermissions, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
perm := &OrgPermissions{}
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/orgs/%s/permissions", user, org), jsonHeader, nil, &perm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
return perm, resp, nil
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateOrgAvatar updates the organization's avatar
|
||||||
|
func (c *Client) UpdateOrgAvatar(org string, opt UpdateUserAvatarOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/orgs/%s/avatar", org),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgAvatar deletes the organization's avatar
|
||||||
|
func (c *Client) DeleteOrgAvatar(org string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/orgs/%s/avatar", org),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameOrgOption options for renaming an organization
|
||||||
|
type RenameOrgOption struct {
|
||||||
|
NewName string `json:"new_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameOrg renames an organization
|
||||||
|
func (c *Client) RenameOrg(org string, opt RenameOrgOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/orgs/%s/rename", org),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActivityFeedsOptions options for listing organization activity feeds
|
||||||
|
type ListOrgActivityFeedsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgActivityFeeds lists the organization's activity feeds
|
||||||
|
func (c *Client) ListOrgActivityFeeds(org string, opt ListOrgActivityFeedsOptions) ([]*Activity, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/orgs/%s/activities/feeds", org))
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Date != "" {
|
||||||
|
query.Add("date", opt.Date)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
activities := make([]*Activity, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &activities)
|
||||||
|
return activities, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamActivityFeedsOptions options for listing team activity feeds
|
||||||
|
type ListTeamActivityFeedsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamActivityFeeds lists the team's activity feeds
|
||||||
|
func (c *Client) ListTeamActivityFeeds(teamID int64, opt ListTeamActivityFeedsOptions) ([]*Activity, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/teams/%d/activities/feeds", teamID))
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Date != "" {
|
||||||
|
query.Add("date", opt.Date)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
activities := make([]*Activity, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &activities)
|
||||||
|
return activities, resp, err
|
||||||
|
}
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Team represents a team in an organization
|
||||||
|
type Team struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Organization *Organization `json:"organization"`
|
||||||
|
Permission AccessMode `json:"permission"`
|
||||||
|
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
||||||
|
IncludesAllRepositories bool `json:"includes_all_repositories"`
|
||||||
|
Units []RepoUnitType `json:"units"`
|
||||||
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoUnitType represent all unit types of a repo gitea currently offer
|
||||||
|
type RepoUnitType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RepoUnitCode represent file view of a repository
|
||||||
|
RepoUnitCode RepoUnitType = "repo.code"
|
||||||
|
// RepoUnitIssues represent issues of a repository
|
||||||
|
RepoUnitIssues RepoUnitType = "repo.issues"
|
||||||
|
// RepoUnitPulls represent pulls of a repository
|
||||||
|
RepoUnitPulls RepoUnitType = "repo.pulls"
|
||||||
|
// RepoUnitExtIssues represent external issues of a repository
|
||||||
|
RepoUnitExtIssues RepoUnitType = "repo.ext_issues"
|
||||||
|
// RepoUnitWiki represent wiki of a repository
|
||||||
|
RepoUnitWiki RepoUnitType = "repo.wiki"
|
||||||
|
// RepoUnitExtWiki represent external wiki of a repository
|
||||||
|
RepoUnitExtWiki RepoUnitType = "repo.ext_wiki"
|
||||||
|
// RepoUnitReleases represent releases of a repository
|
||||||
|
RepoUnitReleases RepoUnitType = "repo.releases"
|
||||||
|
// RepoUnitProjects represent projects of a repository
|
||||||
|
RepoUnitProjects RepoUnitType = "repo.projects"
|
||||||
|
// RepoUnitPackages represents packages of a repository
|
||||||
|
RepoUnitPackages RepoUnitType = "repo.packages"
|
||||||
|
// RepoUnitActions represents actions of a repository
|
||||||
|
RepoUnitActions RepoUnitType = "repo.actions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListTeamsOptions options for listing teams
|
||||||
|
type ListTeamsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgTeams lists all teams of an organization
|
||||||
|
func (c *Client) ListOrgTeams(org string, opt ListTeamsOptions) ([]*Team, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
teams := make([]*Team, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/teams?%s", org, opt.getURLQuery().Encode()), nil, nil, &teams)
|
||||||
|
return teams, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyTeams lists all the teams of the current user
|
||||||
|
func (c *Client) ListMyTeams(opt *ListTeamsOptions) ([]*Team, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
teams := make([]*Team, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/teams?%s", opt.getURLQuery().Encode()), nil, nil, &teams)
|
||||||
|
return teams, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeam gets a team by ID
|
||||||
|
func (c *Client) GetTeam(id int64) (*Team, *Response, error) {
|
||||||
|
t := new(Team)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d", id), nil, nil, t)
|
||||||
|
return t, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTeamsOptions options for searching teams.
|
||||||
|
type SearchTeamsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Query string
|
||||||
|
IncludeDescription bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o SearchTeamsOptions) getURLQuery() url.Values {
|
||||||
|
query := make(url.Values)
|
||||||
|
query.Add("page", fmt.Sprintf("%d", o.Page))
|
||||||
|
query.Add("limit", fmt.Sprintf("%d", o.PageSize))
|
||||||
|
query.Add("q", o.Query)
|
||||||
|
query.Add("include_desc", fmt.Sprintf("%t", o.IncludeDescription))
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamSearchResults is the JSON struct that is returned from Team search API.
|
||||||
|
type TeamSearchResults struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Data []*Team `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOrgTeams search for teams in a org.
|
||||||
|
func (c *Client) SearchOrgTeams(org string, opt *SearchTeamsOptions) ([]*Team, *Response, error) {
|
||||||
|
responseBody := TeamSearchResults{}
|
||||||
|
opt.setDefaults()
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/teams/search?%s", org, opt.getURLQuery().Encode()), nil, nil, &responseBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
if !responseBody.OK {
|
||||||
|
return nil, resp, fmt.Errorf("gitea error: %v", responseBody.Error)
|
||||||
|
}
|
||||||
|
return responseBody.Data, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTeamOption options for creating a team
|
||||||
|
type CreateTeamOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permission AccessMode `json:"permission"`
|
||||||
|
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
||||||
|
IncludesAllRepositories bool `json:"includes_all_repositories"`
|
||||||
|
Units []RepoUnitType `json:"units"`
|
||||||
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateTeamOption struct
|
||||||
|
func (opt *CreateTeamOption) Validate() error {
|
||||||
|
if opt.Permission == AccessModeOwner {
|
||||||
|
opt.Permission = AccessModeAdmin
|
||||||
|
} else if opt.Permission != AccessModeRead && opt.Permission != AccessModeWrite && opt.Permission != AccessModeAdmin {
|
||||||
|
return fmt.Errorf("permission mode invalid")
|
||||||
|
}
|
||||||
|
if len(opt.Name) == 0 {
|
||||||
|
return fmt.Errorf("name required")
|
||||||
|
}
|
||||||
|
if len(opt.Name) > 255 {
|
||||||
|
return fmt.Errorf("name too long")
|
||||||
|
}
|
||||||
|
if len(opt.Description) > 255 {
|
||||||
|
return fmt.Errorf("description too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTeam creates a team for an organization
|
||||||
|
func (c *Client) CreateTeam(org string, opt CreateTeamOption) (*Team, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := (&opt).Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
t := new(Team)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/teams", org), jsonHeader, bytes.NewReader(body), t)
|
||||||
|
return t, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditTeamOption options for editing a team
|
||||||
|
type EditTeamOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Permission AccessMode `json:"permission"`
|
||||||
|
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
|
||||||
|
IncludesAllRepositories *bool `json:"includes_all_repositories"`
|
||||||
|
Units []RepoUnitType `json:"units"`
|
||||||
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditTeamOption struct
|
||||||
|
func (opt *EditTeamOption) Validate() error {
|
||||||
|
if opt.Permission == AccessModeOwner {
|
||||||
|
opt.Permission = AccessModeAdmin
|
||||||
|
} else if opt.Permission != AccessModeRead && opt.Permission != AccessModeWrite && opt.Permission != AccessModeAdmin {
|
||||||
|
return fmt.Errorf("permission mode invalid")
|
||||||
|
}
|
||||||
|
if len(opt.Name) == 0 {
|
||||||
|
return fmt.Errorf("name required")
|
||||||
|
}
|
||||||
|
if len(opt.Name) > 30 {
|
||||||
|
return fmt.Errorf("name to long")
|
||||||
|
}
|
||||||
|
if opt.Description != nil && len(*opt.Description) > 255 {
|
||||||
|
return fmt.Errorf("description to long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditTeam edits a team of an organization
|
||||||
|
func (c *Client) EditTeam(id int64, opt EditTeamOption) (*Response, error) {
|
||||||
|
if err := (&opt).Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PATCH", fmt.Sprintf("/teams/%d", id), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTeam deletes a team of an organization
|
||||||
|
func (c *Client) DeleteTeam(id int64) (*Response, error) {
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/teams/%d", id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamMembersOptions options for listing team's members
|
||||||
|
type ListTeamMembersOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamMembers lists all members of a team
|
||||||
|
func (c *Client) ListTeamMembers(id int64, opt ListTeamMembersOptions) ([]*User, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
members := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/members?%s", id, opt.getURLQuery().Encode()), nil, nil, &members)
|
||||||
|
return members, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamMember gets a member of a team
|
||||||
|
func (c *Client) GetTeamMember(id int64, user string) (*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
m := new(User)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil, m)
|
||||||
|
return m, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTeamMember adds a member to a team
|
||||||
|
func (c *Client) AddTeamMember(id int64, user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTeamMember removes a member from a team
|
||||||
|
func (c *Client) RemoveTeamMember(id int64, user string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/teams/%d/members/%s", id, user), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamRepositoriesOptions options for listing team's repositories
|
||||||
|
type ListTeamRepositoriesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamRepositories lists all repositories of a team
|
||||||
|
func (c *Client) ListTeamRepositories(id int64, opt ListTeamRepositoriesOptions) ([]*Repository, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
repos := make([]*Repository, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/repos?%s", id, opt.getURLQuery().Encode()), nil, nil, &repos)
|
||||||
|
return repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamRepository gets a repository that belongs to a team.
|
||||||
|
func (c *Client) GetTeamRepository(id int64, org, repo string) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/teams/%d/repos/%s/%s", id, org, repo), nil, nil, result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTeamRepository adds a repository to a team
|
||||||
|
func (c *Client) AddTeamRepository(id int64, org, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/teams/%d/repos/%s/%s", id, org, repo), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTeamRepository removes a repository from a team
|
||||||
|
func (c *Client) RemoveTeamRepository(id int64, org, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/teams/%d/repos/%s/%s", id, org, repo), nil, nil)
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Package represents a package
|
||||||
|
type Package struct {
|
||||||
|
// the package's id
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
// the package's owner
|
||||||
|
Owner *User `json:"owner"`
|
||||||
|
// the repo this package belongs to (if any)
|
||||||
|
Repository *Repository `json:"repository"`
|
||||||
|
// the package's creator
|
||||||
|
Creator *User `json:"creator"`
|
||||||
|
// the type of package:
|
||||||
|
Type string `json:"type"`
|
||||||
|
// the name of the package
|
||||||
|
Name string `json:"name"`
|
||||||
|
// the version of the package
|
||||||
|
Version string `json:"version"`
|
||||||
|
// the HTML URL for viewing the package
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
// the date the package was uploaded
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageFile represents a file from a package
|
||||||
|
type PackageFile struct {
|
||||||
|
// the file's ID
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
// the size of the file in bytes
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
// the name of the file
|
||||||
|
Name string `json:"name"`
|
||||||
|
// the md5 hash of the file
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
// the sha1 hash of the file
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
// the sha256 hash of the file
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
// the sha512 hash of the file
|
||||||
|
SHA512 string `json:"sha512"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackagesOptions options for listing packages
|
||||||
|
type ListPackagesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
// type, and q are only used for ListPackages, not ListPackageVersions
|
||||||
|
Type string
|
||||||
|
Q string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt ListPackagesOptions) getURLQuery() url.Values {
|
||||||
|
query := opt.ListOptions.getURLQuery()
|
||||||
|
if opt.Type != "" {
|
||||||
|
query.Set("type", opt.Type)
|
||||||
|
}
|
||||||
|
if opt.Q != "" {
|
||||||
|
query.Set("q", opt.Q)
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackages lists all the packages owned by a given owner (user, organisation)
|
||||||
|
func (c *Client) ListPackages(owner string, opt ListPackagesOptions) ([]*Package, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
packages := make([]*Package, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s?%s", owner, opt.getURLQuery().Encode()), nil, nil, &packages)
|
||||||
|
return packages, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackageVersions lists all versions of a package.
|
||||||
|
func (c *Client) ListPackageVersions(owner, packageType, name string, opt ListPackagesOptions) ([]*Package, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
packages := make([]*Package, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s?%s", owner, packageType, name, opt.ListOptions.getURLQuery().Encode()), nil, nil, &packages)
|
||||||
|
return packages, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackage gets the details of a specific package version
|
||||||
|
func (c *Client) GetPackage(owner, packageType, name, version string) (*Package, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
foundPackage := new(Package)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s/%s", owner, packageType, name, version), nil, nil, foundPackage)
|
||||||
|
return foundPackage, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePackage deletes a specific package version
|
||||||
|
func (c *Client) DeletePackage(owner, packageType, name, version string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/packages/%s/%s/%s/%s", owner, packageType, name, version), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPackageFiles lists the files within a package
|
||||||
|
func (c *Client) ListPackageFiles(owner, packageType, name, version string) ([]*PackageFile, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name, &version); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
packageFiles := make([]*PackageFile, 0)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s/%s/files", owner, packageType, name, version), nil, nil, &packageFiles)
|
||||||
|
return packageFiles, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestPackage gets the details of the latest version of a package
|
||||||
|
func (c *Client) GetLatestPackage(owner, packageType, name string) (*Package, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
foundPackage := new(Package)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/packages/%s/%s/%s/-/latest", owner, packageType, name), nil, nil, foundPackage)
|
||||||
|
return foundPackage, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkPackage links a package to a repository
|
||||||
|
func (c *Client) LinkPackage(owner, packageType, name, repoName string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name, &repoName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/packages/%s/%s/%s/-/link/%s", owner, packageType, name, repoName), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkPackage unlinks a package from a repository
|
||||||
|
func (c *Client) UnlinkPackage(owner, packageType, name string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &packageType, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/packages/%s/%s/%s/-/unlink", owner, packageType, name), nil, nil)
|
||||||
|
}
|
||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PRBranchInfo information about a branch
|
||||||
|
type PRBranchInfo struct {
|
||||||
|
Name string `json:"label"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Repository *Repository `json:"repo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest represents a pull request
|
||||||
|
type PullRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Index int64 `json:"number"`
|
||||||
|
Poster *User `json:"user"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Labels []*Label `json:"labels"`
|
||||||
|
Milestone *Milestone `json:"milestone"`
|
||||||
|
Assignee *User `json:"assignee"`
|
||||||
|
Assignees []*User `json:"assignees"`
|
||||||
|
RequestedReviewers []*User `json:"requested_reviewers"`
|
||||||
|
RequestedReviewersTeams []*Team `json:"requested_reviewers_teams"`
|
||||||
|
State StateType `json:"state"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
IsLocked bool `json:"is_locked"`
|
||||||
|
Comments int `json:"comments"`
|
||||||
|
ReviewComments int `json:"review_comments,omitempty"`
|
||||||
|
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
DiffURL string `json:"diff_url"`
|
||||||
|
PatchURL string `json:"patch_url"`
|
||||||
|
|
||||||
|
Mergeable bool `json:"mergeable"`
|
||||||
|
HasMerged bool `json:"merged"`
|
||||||
|
Merged *time.Time `json:"merged_at"`
|
||||||
|
MergedCommitID *string `json:"merge_commit_sha"`
|
||||||
|
MergedBy *User `json:"merged_by"`
|
||||||
|
AllowMaintainerEdit bool `json:"allow_maintainer_edit"`
|
||||||
|
|
||||||
|
Base *PRBranchInfo `json:"base"`
|
||||||
|
Head *PRBranchInfo `json:"head"`
|
||||||
|
MergeBase string `json:"merge_base"`
|
||||||
|
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
Created *time.Time `json:"created_at"`
|
||||||
|
Updated *time.Time `json:"updated_at"`
|
||||||
|
Closed *time.Time `json:"closed_at"`
|
||||||
|
|
||||||
|
Additions *int `json:"additions,omitempty"`
|
||||||
|
Deletions *int `json:"deletions,omitempty"`
|
||||||
|
ChangedFiles *int `json:"changed_files,omitempty"`
|
||||||
|
PinOrder int `json:"pin_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangedFile is a changed file in a diff
|
||||||
|
type ChangedFile struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
PreviousFilename string `json:"previous_filename"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
Changes int `json:"changes"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
ContentsURL string `json:"contents_url"`
|
||||||
|
RawURL string `json:"raw_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullRequestsOptions options for listing pull requests
|
||||||
|
type ListPullRequestsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
State StateType `json:"state"`
|
||||||
|
// oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority
|
||||||
|
Sort string
|
||||||
|
Milestone int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeStyle is used specify how a pull is merged
|
||||||
|
type MergeStyle string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MergeStyleMerge merge pull as usual
|
||||||
|
MergeStyleMerge MergeStyle = "merge"
|
||||||
|
// MergeStyleRebase rebase pull
|
||||||
|
MergeStyleRebase MergeStyle = "rebase"
|
||||||
|
// MergeStyleRebaseMerge rebase and merge pull
|
||||||
|
MergeStyleRebaseMerge MergeStyle = "rebase-merge"
|
||||||
|
// MergeStyleSquash squash and merge pull
|
||||||
|
MergeStyleSquash MergeStyle = "squash"
|
||||||
|
// MergeStyleFastForwardOnly fast-forward merge
|
||||||
|
MergeStyleFastForwardOnly MergeStyle = "fast-forward-only"
|
||||||
|
// MergeStyleManuallyMerged manually merged
|
||||||
|
MergeStyleManuallyMerged MergeStyle = "manually-merged"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListPullRequestsOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if len(opt.State) > 0 {
|
||||||
|
query.Add("state", string(opt.State))
|
||||||
|
}
|
||||||
|
if len(opt.Sort) > 0 {
|
||||||
|
query.Add("sort", opt.Sort)
|
||||||
|
}
|
||||||
|
if opt.Milestone > 0 {
|
||||||
|
query.Add("milestone", fmt.Sprintf("%d", opt.Milestone))
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoPullRequests list PRs of one repository
|
||||||
|
func (c *Client) ListRepoPullRequests(owner, repo string, opt ListPullRequestsOptions) ([]*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
prs := make([]*PullRequest, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls", owner, repo))
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &prs)
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
|
||||||
|
for i := range prs {
|
||||||
|
if err := fixPullHeadSha(c, prs[i]); err != nil {
|
||||||
|
return prs, resp, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequest get information of one PR
|
||||||
|
func (c *Client) GetPullRequest(owner, repo string, index int64) (*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pr := new(PullRequest)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index), nil, nil, pr)
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
|
||||||
|
if err := fixPullHeadSha(c, pr); err != nil {
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequestByBaseHead gets a pull request by its base and head branches.
|
||||||
|
func (c *Client) GetPullRequestByBaseHead(owner, repo, base, head string) (*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &base); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
head = pathEscapeSegments(head)
|
||||||
|
pr := new(PullRequest)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%s/%s", owner, repo, base, head), nil, nil, pr)
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
|
||||||
|
if err := fixPullHeadSha(c, pr); err != nil {
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullRequestOption options when creating a pull request
|
||||||
|
type CreatePullRequestOption struct {
|
||||||
|
Head string `json:"head"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Assignee string `json:"assignee"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
Reviewers []string `json:"reviewers"`
|
||||||
|
TeamReviewers []string `json:"team_reviewers"`
|
||||||
|
Milestone int64 `json:"milestone"`
|
||||||
|
Labels []int64 `json:"labels"`
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullRequest create pull request with options
|
||||||
|
func (c *Client) CreatePullRequest(owner, repo string, opt CreatePullRequestOption) (*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pr := new(PullRequest)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), pr)
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditPullRequestOption options when modify pull request
|
||||||
|
type EditPullRequestOption struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body *string `json:"body"`
|
||||||
|
Base string `json:"base"`
|
||||||
|
Assignee string `json:"assignee"`
|
||||||
|
Assignees []string `json:"assignees"`
|
||||||
|
Milestone int64 `json:"milestone"`
|
||||||
|
Labels []int64 `json:"labels"`
|
||||||
|
State *StateType `json:"state"`
|
||||||
|
Deadline *time.Time `json:"due_date"`
|
||||||
|
RemoveDeadline *bool `json:"unset_due_date"`
|
||||||
|
AllowMaintainerEdit *bool `json:"allow_maintainer_edit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditPullRequestOption struct
|
||||||
|
func (opt EditPullRequestOption) Validate(c *Client) error {
|
||||||
|
if len(opt.Title) != 0 && len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
if len(opt.Base) != 0 {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return fmt.Errorf("can not change base gitea to old")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditPullRequest modify pull request with PR id and options
|
||||||
|
func (c *Client) EditPullRequest(owner, repo string, index int64, opt EditPullRequestOption) (*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pr := new(PullRequest)
|
||||||
|
resp, err := c.getParsedResponse("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), pr)
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergePullRequestOption options when merging a pull request
|
||||||
|
type MergePullRequestOption struct {
|
||||||
|
Style MergeStyle `json:"Do"`
|
||||||
|
MergeCommitID string `json:"MergeCommitID"`
|
||||||
|
Title string `json:"MergeTitleField"`
|
||||||
|
Message string `json:"MergeMessageField"`
|
||||||
|
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge,omitempty"`
|
||||||
|
ForceMerge bool `json:"force_merge"`
|
||||||
|
HeadCommitId string `json:"head_commit_id"`
|
||||||
|
MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the MergePullRequestOption struct
|
||||||
|
func (opt MergePullRequestOption) Validate(c *Client) error {
|
||||||
|
if opt.Style == MergeStyleSquash {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_11_5); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergePullRequest merge a PR to repository by PR id
|
||||||
|
func (c *Client) MergePullRequest(owner, repo string, index int64, opt MergePullRequestOption) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST", fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index), jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
return status == 200 || status == 201, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPullRequestMerged test if one PR is merged to one repository
|
||||||
|
func (c *Client) IsPullRequestMerged(owner, repo string, index int64) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return status == 204, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequestDiffOptions options for GET /repos/<owner>/<repo>/pulls/<idx>.[diff|patch]
|
||||||
|
type PullRequestDiffOptions struct {
|
||||||
|
// Include binary file changes when requesting a .diff
|
||||||
|
Binary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode converts the options to a query string
|
||||||
|
func (o PullRequestDiffOptions) QueryEncode() string {
|
||||||
|
query := make(url.Values)
|
||||||
|
query.Add("binary", fmt.Sprintf("%v", o.Binary))
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
type pullRequestDiffType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
pullRequestDiffTypeDiff pullRequestDiffType = "diff"
|
||||||
|
pullRequestDiffTypePatch pullRequestDiffType = "patch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getPullRequestDiffOrPatch gets the patch or diff file as bytes for a PR
|
||||||
|
func (c *Client) getPullRequestDiffOrPatch(owner, repo string, kind pullRequestDiffType, index int64, opts PullRequestDiffOptions) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
r, _, err2 := c.GetRepo(owner, repo)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if r.Private {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("/%s/%s/pulls/%d.%s?%s", owner, repo, index, kind, opts.QueryEncode())
|
||||||
|
return c.getWebResponse("GET", url, nil)
|
||||||
|
}
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d.%s", owner, repo, index, kind), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequestPatch gets the git patchset of a PR
|
||||||
|
func (c *Client) GetPullRequestPatch(owner, repo string, index int64) ([]byte, *Response, error) {
|
||||||
|
return c.getPullRequestDiffOrPatch(owner, repo, pullRequestDiffTypePatch, index, PullRequestDiffOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullRequestDiff gets the diff of a PR. For Gitea >= 1.16, you must set includeBinary to get an applicable diff
|
||||||
|
func (c *Client) GetPullRequestDiff(owner, repo string, index int64, opts PullRequestDiffOptions) ([]byte, *Response, error) {
|
||||||
|
return c.getPullRequestDiffOrPatch(owner, repo, pullRequestDiffTypeDiff, index, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelScheduledAutoMerge cancels a scheduled automatic merge for a pull request.
|
||||||
|
func (c *Client) CancelScheduledAutoMerge(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_18_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullRequestCommitsOptions options for listing pull requests
|
||||||
|
type ListPullRequestCommitsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullRequestCommits list commits for a pull request
|
||||||
|
func (c *Client) ListPullRequestCommits(owner, repo string, index int64, opt ListPullRequestCommitsOptions) ([]*Commit, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/commits", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
commits := make([]*Commit, 0, opt.PageSize)
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &commits)
|
||||||
|
return commits, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixPullHeadSha is a workaround for https://github.com/go-gitea/gitea/issues/12675
|
||||||
|
// When no head sha is available, this is because the branch got deleted in the base repo.
|
||||||
|
// pr.Head.Ref points in this case not to the head repo branch name, but the base repo ref,
|
||||||
|
// which stays available to resolve the commit sha. This is fixed for gitea >= 1.14.0
|
||||||
|
func fixPullHeadSha(client *Client, pr *PullRequest) error {
|
||||||
|
if pr.Base != nil && pr.Base.Repository != nil && pr.Base.Repository.Owner != nil &&
|
||||||
|
pr.Head != nil && pr.Head.Ref != "" && pr.Head.Sha == "" {
|
||||||
|
owner := pr.Base.Repository.Owner.UserName
|
||||||
|
repo := pr.Base.Repository.Name
|
||||||
|
refs, _, err := client.GetRepoRefs(owner, repo, pr.Head.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(refs) == 0 {
|
||||||
|
return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref)
|
||||||
|
}
|
||||||
|
pr.Head.Sha = refs[0].Object.SHA
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullRequestFilesOptions options for listing pull request files
|
||||||
|
type ListPullRequestFilesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullRequestFiles list changed files for a pull request
|
||||||
|
func (c *Client) ListPullRequestFiles(owner, repo string, index int64, opt ListPullRequestFilesOptions) ([]*ChangedFile, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/files", owner, repo, index))
|
||||||
|
opt.setDefaults()
|
||||||
|
files := make([]*ChangedFile, 0, opt.PageSize)
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &files)
|
||||||
|
return files, resp, err
|
||||||
|
}
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReviewStateType review state type
|
||||||
|
type ReviewStateType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ReviewStateApproved pr is approved
|
||||||
|
ReviewStateApproved ReviewStateType = "APPROVED"
|
||||||
|
// ReviewStatePending pr state is pending
|
||||||
|
ReviewStatePending ReviewStateType = "PENDING"
|
||||||
|
// ReviewStateComment is a comment review
|
||||||
|
ReviewStateComment ReviewStateType = "COMMENT"
|
||||||
|
// ReviewStateRequestChanges changes for pr are requested
|
||||||
|
ReviewStateRequestChanges ReviewStateType = "REQUEST_CHANGES"
|
||||||
|
// ReviewStateRequestReview review is requested from user
|
||||||
|
ReviewStateRequestReview ReviewStateType = "REQUEST_REVIEW"
|
||||||
|
// ReviewStateUnknown state of pr is unknown
|
||||||
|
ReviewStateUnknown ReviewStateType = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// PullReview represents a pull request review
|
||||||
|
type PullReview struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Reviewer *User `json:"user"`
|
||||||
|
ReviewerTeam *Team `json:"team"`
|
||||||
|
State ReviewStateType `json:"state"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
CommitID string `json:"commit_id"`
|
||||||
|
// Stale indicates if the pull has changed since the review
|
||||||
|
Stale bool `json:"stale"`
|
||||||
|
// Official indicates if the review counts towards the required approval limit, if PR base is a protected branch
|
||||||
|
Official bool `json:"official"`
|
||||||
|
Dismissed bool `json:"dismissed"`
|
||||||
|
CodeCommentsCount int `json:"comments_count"`
|
||||||
|
Submitted time.Time `json:"submitted_at"`
|
||||||
|
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
HTMLPullURL string `json:"pull_request_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullReviewComment represents a comment on a pull request review
|
||||||
|
type PullReviewComment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Reviewer *User `json:"user"`
|
||||||
|
ReviewID int64 `json:"pull_request_review_id"`
|
||||||
|
Resolver *User `json:"resolver"`
|
||||||
|
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
Path string `json:"path"`
|
||||||
|
CommitID string `json:"commit_id"`
|
||||||
|
OrigCommitID string `json:"original_commit_id"`
|
||||||
|
DiffHunk string `json:"diff_hunk"`
|
||||||
|
LineNum uint64 `json:"position"`
|
||||||
|
OldLineNum uint64 `json:"original_position"`
|
||||||
|
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
HTMLPullURL string `json:"pull_request_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewOptions are options to create a pull review
|
||||||
|
type CreatePullReviewOptions struct {
|
||||||
|
State ReviewStateType `json:"event"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
CommitID string `json:"commit_id"`
|
||||||
|
Comments []CreatePullReviewComment `json:"comments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewComment represent a review comment for creation api
|
||||||
|
type CreatePullReviewComment struct {
|
||||||
|
// the tree path
|
||||||
|
Path string `json:"path"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// if comment to old file line or 0
|
||||||
|
OldLineNum int64 `json:"old_position"`
|
||||||
|
// if comment to new file line or 0
|
||||||
|
NewLineNum int64 `json:"new_position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment.
|
||||||
|
type CreatePullReviewCommentReplyOptions struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitPullReviewOptions are options to submit a pending pull review
|
||||||
|
type SubmitPullReviewOptions struct {
|
||||||
|
State ReviewStateType `json:"event"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissPullReviewOptions are options to dismiss a pull review
|
||||||
|
type DismissPullReviewOptions struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullReviewRequestOptions are options to add or remove pull review requests
|
||||||
|
type PullReviewRequestOptions struct {
|
||||||
|
Reviewers []string `json:"reviewers"`
|
||||||
|
TeamReviewers []string `json:"team_reviewers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullReviewsOptions options for listing PullReviews
|
||||||
|
type ListPullReviewsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreatePullReviewOptions struct
|
||||||
|
func (opt CreatePullReviewOptions) Validate() error {
|
||||||
|
if opt.State != ReviewStateApproved && len(opt.Comments) == 0 && len(strings.TrimSpace(opt.Body)) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
for i := range opt.Comments {
|
||||||
|
if err := opt.Comments[i].Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the SubmitPullReviewOptions struct
|
||||||
|
func (opt SubmitPullReviewOptions) Validate() error {
|
||||||
|
if opt.State != ReviewStateApproved && len(strings.TrimSpace(opt.Body)) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreatePullReviewComment struct
|
||||||
|
func (opt CreatePullReviewComment) Validate() error {
|
||||||
|
if len(strings.TrimSpace(opt.Body)) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
if opt.NewLineNum != 0 && opt.OldLineNum != 0 {
|
||||||
|
return fmt.Errorf("old and new line num are set, cant identify the code comment position")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreatePullReviewCommentReplyOptions struct.
|
||||||
|
func (opt CreatePullReviewCommentReplyOptions) Validate() error {
|
||||||
|
if len(strings.TrimSpace(opt.Body)) == 0 {
|
||||||
|
return fmt.Errorf("body is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullReviews lists all reviews of a pull request
|
||||||
|
func (c *Client) ListPullReviews(owner, repo string, index int64, opt ListPullReviewsOptions) ([]*PullReview, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
rs := make([]*PullReview, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index))
|
||||||
|
link.RawQuery = opt.ListOptions.getURLQuery().Encode()
|
||||||
|
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rs)
|
||||||
|
return rs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPullReview gets a specific review of a pull request
|
||||||
|
func (c *Client) GetPullReview(owner, repo string, index, id int64) (*PullReview, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := new(PullReview)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil, &r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPullReviewComments lists all comments of a pull request review
|
||||||
|
func (c *Client) ListPullReviewComments(owner, repo string, index, id int64) ([]*PullReviewComment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rcl := make([]*PullReviewComment, 0, 4)
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/comments", owner, repo, index, id))
|
||||||
|
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &rcl)
|
||||||
|
return rcl, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePullReview delete a specific review from a pull request
|
||||||
|
func (c *Client) DeletePullReview(owner, repo string, index, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id), jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullReview create a review to an pull request
|
||||||
|
func (c *Client) CreatePullReview(owner, repo string, index int64, opt CreatePullReviewOptions) (*PullReview, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := new(PullReview)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body), r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePullReviewCommentReply replies to a pull request review comment.
|
||||||
|
// Available on Gitea main/nightly; first released version not assigned yet.
|
||||||
|
// Upstream: gitea/gitea#36683 (331450b17a025ce78b5bf9405ca8d684607680ef).
|
||||||
|
func (c *Client) CreatePullReviewCommentReply(owner, repo string, index, id int64, opt CreatePullReviewCommentReplyOptions) (*PullReviewComment, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := new(PullReviewComment)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/comments/%d/replies", owner, repo, index, id),
|
||||||
|
jsonHeader, bytes.NewReader(body), r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitPullReview submit a pending review to an pull request
|
||||||
|
func (c *Client) SubmitPullReview(owner, repo string, index, id int64, opt SubmitPullReviewOptions) (*PullReview, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := new(PullReview)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d", owner, repo, index, id),
|
||||||
|
jsonHeader, bytes.NewReader(body), r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReviewRequests create review requests to an pull request
|
||||||
|
func (c *Client) CreateReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReviewRequests delete review requests to an pull request
|
||||||
|
func (c *Client) DeleteReviewRequests(owner, repo string, index int64, opt PullReviewRequestOptions) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", owner, repo, index),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissPullReview dismiss a review for a pull request
|
||||||
|
func (c *Client) DismissPullReview(owner, repo string, index, id int64, opt DismissPullReviewOptions) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/dismissals", owner, repo, index, id),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnDismissPullReview cancel to dismiss a review for a pull request
|
||||||
|
func (c *Client) UnDismissPullReview(owner, repo string, index, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews/%d/undismissals", owner, repo, index, id),
|
||||||
|
jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePullReviewComment resolves a pull-request review comment conversation.
|
||||||
|
// Available on Gitea main/nightly; first released version not assigned yet.
|
||||||
|
// Upstream: gitea/gitea#36441 (c2dea22926f9e7a40aa47296e7b9bc3d1c5b039e).
|
||||||
|
func (c *Client) ResolvePullReviewComment(owner, repo string, commentID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/pulls/comments/%d/resolve", owner, repo, commentID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnresolvePullReviewComment unresolves a pull-request review comment conversation.
|
||||||
|
// Available on Gitea main/nightly; first released version not assigned yet.
|
||||||
|
// Upstream: gitea/gitea#36441 (c2dea22926f9e7a40aa47296e7b9bc3d1c5b039e).
|
||||||
|
func (c *Client) UnresolvePullReviewComment(owner, repo string, commentID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/pulls/comments/%d/unresolve", owner, repo, commentID), nil, nil)
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Release represents a repository release
|
||||||
|
type Release struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Target string `json:"target_commitish"`
|
||||||
|
Title string `json:"name"`
|
||||||
|
Note string `json:"body"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
TarURL string `json:"tarball_url"`
|
||||||
|
ZipURL string `json:"zipball_url"`
|
||||||
|
IsDraft bool `json:"draft"`
|
||||||
|
IsPrerelease bool `json:"prerelease"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
PublishedAt time.Time `json:"published_at"`
|
||||||
|
Publisher *User `json:"author"`
|
||||||
|
Attachments []*Attachment `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReleasesOptions options for listing repository's releases
|
||||||
|
type ListReleasesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
IsDraft *bool
|
||||||
|
IsPreRelease *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListReleasesOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
|
||||||
|
if opt.IsDraft != nil {
|
||||||
|
query.Add("draft", fmt.Sprintf("%t", *opt.IsDraft))
|
||||||
|
}
|
||||||
|
if opt.IsPreRelease != nil {
|
||||||
|
query.Add("pre-release", fmt.Sprintf("%t", *opt.IsPreRelease))
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReleases list releases of a repository
|
||||||
|
func (c *Client) ListReleases(owner, repo string, opt ListReleasesOptions) ([]*Release, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
releases := make([]*Release, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases?%s", owner, repo, opt.QueryEncode()),
|
||||||
|
nil, nil, &releases)
|
||||||
|
return releases, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRelease get a release of a repository by id
|
||||||
|
func (c *Client) GetRelease(owner, repo string, id int64) (*Release, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
r := new(Release)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d", owner, repo, id),
|
||||||
|
jsonHeader, nil, &r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestRelease get the latest release of a repository
|
||||||
|
func (c *Client) GetLatestRelease(owner, repo string) (*Release, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
r := new(Release)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/latest", owner, repo),
|
||||||
|
jsonHeader, nil, &r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReleaseByTag get a release of a repository by tag
|
||||||
|
func (c *Client) GetReleaseByTag(owner, repo, tag string) (*Release, *Response, error) {
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
|
||||||
|
return c.fallbackGetReleaseByTag(owner, repo, tag)
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &tag); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
r := new(Release)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/tags/%s", owner, repo, tag),
|
||||||
|
nil, nil, &r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReleaseOption options when creating a release
|
||||||
|
type CreateReleaseOption struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Target string `json:"target_commitish"`
|
||||||
|
Title string `json:"name"`
|
||||||
|
Note string `json:"body"`
|
||||||
|
IsDraft bool `json:"draft"`
|
||||||
|
IsPrerelease bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateReleaseOption struct
|
||||||
|
func (opt CreateReleaseOption) Validate() error {
|
||||||
|
if len(strings.TrimSpace(opt.Title)) == 0 {
|
||||||
|
return fmt.Errorf("title is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRelease create a release
|
||||||
|
func (c *Client) CreateRelease(owner, repo string, opt CreateReleaseOption) (*Release, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
r := new(Release)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditReleaseOption options when editing a release
|
||||||
|
type EditReleaseOption struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Target string `json:"target_commitish"`
|
||||||
|
Title string `json:"name"`
|
||||||
|
Note string `json:"body"`
|
||||||
|
IsDraft *bool `json:"draft"`
|
||||||
|
IsPrerelease *bool `json:"prerelease"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRelease edit a release
|
||||||
|
func (c *Client) EditRelease(owner, repo string, id int64, form EditReleaseOption) (*Release, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(form)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
r := new(Release)
|
||||||
|
resp, err := c.getParsedResponse("PATCH",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d", owner, repo, id),
|
||||||
|
jsonHeader, bytes.NewReader(body), r)
|
||||||
|
return r, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRelease delete a release from a repository, keeping its tag
|
||||||
|
func (c *Client) DeleteRelease(user, repo string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/%d", user, repo, id),
|
||||||
|
nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReleaseByTag deletes a release frm a repository by tag
|
||||||
|
func (c *Client) DeleteReleaseByTag(user, repo, tag string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &tag); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_14_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/releases/tags/%s", user, repo, tag),
|
||||||
|
nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackGetReleaseByTag is fallback for old gitea installations ( < 1.13.0 )
|
||||||
|
func (c *Client) fallbackGetReleaseByTag(owner, repo, tag string) (*Release, *Response, error) {
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
rl, resp, err := c.ListReleases(owner, repo, ListReleasesOptions{ListOptions: ListOptions{Page: i}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
if len(rl) == 0 {
|
||||||
|
return nil,
|
||||||
|
newResponse(&http.Response{StatusCode: 404}),
|
||||||
|
fmt.Errorf("release with tag '%s' not found", tag)
|
||||||
|
}
|
||||||
|
for _, r := range rl {
|
||||||
|
if r.TagName == tag {
|
||||||
|
return r, resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+629
@@ -0,0 +1,629 @@
|
|||||||
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permission represents a set of permissions
|
||||||
|
type Permission struct {
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Push bool `json:"push"`
|
||||||
|
Pull bool `json:"pull"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InternalTracker represents settings for internal tracker
|
||||||
|
type InternalTracker struct {
|
||||||
|
// Enable time tracking (Built-in issue tracker)
|
||||||
|
EnableTimeTracker bool `json:"enable_time_tracker"`
|
||||||
|
// Let only contributors track time (Built-in issue tracker)
|
||||||
|
AllowOnlyContributorsToTrackTime bool `json:"allow_only_contributors_to_track_time"`
|
||||||
|
// Enable dependencies for issues and pull requests (Built-in issue tracker)
|
||||||
|
EnableIssueDependencies bool `json:"enable_issue_dependencies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalTracker represents settings for external tracker
|
||||||
|
type ExternalTracker struct {
|
||||||
|
// URL of external issue tracker.
|
||||||
|
ExternalTrackerURL string `json:"external_tracker_url"`
|
||||||
|
// External Issue Tracker URL Format. Use the placeholders {user}, {repo} and {index} for the username, repository name and issue index.
|
||||||
|
ExternalTrackerFormat string `json:"external_tracker_format"`
|
||||||
|
// External Issue Tracker Number Format, either `numeric` or `alphanumeric`
|
||||||
|
ExternalTrackerStyle string `json:"external_tracker_style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalWiki represents setting for external wiki
|
||||||
|
type ExternalWiki struct {
|
||||||
|
// URL of external wiki.
|
||||||
|
ExternalWikiURL string `json:"external_wiki_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectsMode is used specify which kinds of projects to show for a repository
|
||||||
|
type ProjectsMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProjectsModeRepo only allow repo-level projects
|
||||||
|
ProjectsModeRepo ProjectsMode = "repo"
|
||||||
|
// ProjectsModeOwner only allow owner projects
|
||||||
|
ProjectsModeOwner ProjectsMode = "owner"
|
||||||
|
// ProjectsModeAll only allow all projects
|
||||||
|
ProjectsModeAll ProjectsMode = "all"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepoTransfer represents a pending repository transfer
|
||||||
|
type RepoTransfer struct {
|
||||||
|
Doer *User `json:"doer"`
|
||||||
|
Recipient *User `json:"recipient"`
|
||||||
|
Teams []*Team `json:"teams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository represents a repository
|
||||||
|
type Repository struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Owner *User `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Fork bool `json:"fork"`
|
||||||
|
Template bool `json:"template"`
|
||||||
|
Parent *Repository `json:"parent"`
|
||||||
|
Mirror bool `json:"mirror"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
LanguagesURL string `json:"languages_url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
SSHURL string `json:"ssh_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
OriginalURL string `json:"original_url"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Stars int `json:"stars_count"`
|
||||||
|
Forks int `json:"forks_count"`
|
||||||
|
Watchers int `json:"watchers_count"`
|
||||||
|
OpenIssues int `json:"open_issues_count"`
|
||||||
|
OpenPulls int `json:"open_pr_counter"`
|
||||||
|
Releases int `json:"release_counter"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
ArchivedAt time.Time `json:"archived_at"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
Permissions *Permission `json:"permissions,omitempty"`
|
||||||
|
HasIssues bool `json:"has_issues"`
|
||||||
|
HasCode bool `json:"has_code"`
|
||||||
|
InternalTracker *InternalTracker `json:"internal_tracker,omitempty"`
|
||||||
|
ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"`
|
||||||
|
HasWiki bool `json:"has_wiki"`
|
||||||
|
ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"`
|
||||||
|
HasPullRequests bool `json:"has_pull_requests"`
|
||||||
|
HasProjects bool `json:"has_projects"`
|
||||||
|
HasReleases bool `json:"has_releases,omitempty"`
|
||||||
|
HasPackages bool `json:"has_packages,omitempty"`
|
||||||
|
HasActions bool `json:"has_actions,omitempty"`
|
||||||
|
IgnoreWhitespaceConflicts bool `json:"ignore_whitespace_conflicts"`
|
||||||
|
AllowFastForwardOnlyMerge bool `json:"allow_fast_forward_only_merge"`
|
||||||
|
AllowMerge bool `json:"allow_merge_commits"`
|
||||||
|
AllowRebase bool `json:"allow_rebase"`
|
||||||
|
AllowRebaseMerge bool `json:"allow_rebase_explicit"`
|
||||||
|
AllowRebaseUpdate bool `json:"allow_rebase_update"`
|
||||||
|
AllowSquash bool `json:"allow_squash_merge"`
|
||||||
|
DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Internal bool `json:"internal"`
|
||||||
|
MirrorInterval string `json:"mirror_interval"`
|
||||||
|
MirrorUpdated time.Time `json:"mirror_updated,omitempty"`
|
||||||
|
DefaultMergeStyle MergeStyle `json:"default_merge_style"`
|
||||||
|
ProjectsMode *ProjectsMode `json:"projects_mode"`
|
||||||
|
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
|
||||||
|
ObjectFormatName string `json:"object_format_name"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Licenses []string `json:"licenses"`
|
||||||
|
RepoTransfer *RepoTransfer `json:"repo_transfer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoType represent repo type
|
||||||
|
type RepoType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RepoTypeNone dont specify a type
|
||||||
|
RepoTypeNone RepoType = ""
|
||||||
|
// RepoTypeSource is the default repo type
|
||||||
|
RepoTypeSource RepoType = "source"
|
||||||
|
// RepoTypeFork is a repo witch was forked from an other one
|
||||||
|
RepoTypeFork RepoType = "fork"
|
||||||
|
// RepoTypeMirror represents an mirror repo
|
||||||
|
RepoTypeMirror RepoType = "mirror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrustModel represent how git signatures are handled in a repository
|
||||||
|
type TrustModel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TrustModelDefault use TM set by global config
|
||||||
|
TrustModelDefault TrustModel = "default"
|
||||||
|
// TrustModelCollaborator gpg signature has to be owned by a repo collaborator
|
||||||
|
TrustModelCollaborator TrustModel = "collaborator"
|
||||||
|
// TrustModelCommitter gpg signature has to match committer
|
||||||
|
TrustModelCommitter TrustModel = "committer"
|
||||||
|
// TrustModelCollaboratorCommitter gpg signature has to match committer and owned by a repo collaborator
|
||||||
|
TrustModelCollaboratorCommitter TrustModel = "collaboratorcommitter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListReposOptions options for listing repositories
|
||||||
|
type ListReposOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyRepos lists all repositories for the authenticated user that has access to.
|
||||||
|
func (c *Client) ListMyRepos(opt ListReposOptions) ([]*Repository, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
repos := make([]*Repository, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/user/repos?%s", opt.getURLQuery().Encode()), nil, nil, &repos)
|
||||||
|
return repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserRepos list all repositories of one user by user's name
|
||||||
|
func (c *Client) ListUserRepos(user string, opt ListReposOptions) ([]*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
repos := make([]*Repository, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/users/%s/repos?%s", user, opt.getURLQuery().Encode()), nil, nil, &repos)
|
||||||
|
return repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgReposOptions options for a organization's repositories
|
||||||
|
type ListOrgReposOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrgRepos list all repositories of one organization by organization's name
|
||||||
|
func (c *Client) ListOrgRepos(org string, opt ListOrgReposOptions) ([]*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
repos := make([]*Repository, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/orgs/%s/repos?%s", org, opt.getURLQuery().Encode()), nil, nil, &repos)
|
||||||
|
return repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRepoOptions options for searching repositories
|
||||||
|
type SearchRepoOptions struct {
|
||||||
|
ListOptions
|
||||||
|
|
||||||
|
// The keyword to query
|
||||||
|
Keyword string
|
||||||
|
// Limit search to repositories with keyword as topic
|
||||||
|
KeywordIsTopic bool
|
||||||
|
// Include search of keyword within repository description
|
||||||
|
KeywordInDescription bool
|
||||||
|
|
||||||
|
/*
|
||||||
|
User Filter
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Repo Owner
|
||||||
|
OwnerID int64
|
||||||
|
// Stared By UserID
|
||||||
|
StarredByUserID int64
|
||||||
|
|
||||||
|
/*
|
||||||
|
Repo Attributes
|
||||||
|
*/
|
||||||
|
|
||||||
|
// pubic, private or all repositories (defaults to all)
|
||||||
|
IsPrivate *bool
|
||||||
|
// archived, non-archived or all repositories (defaults to all)
|
||||||
|
IsArchived *bool
|
||||||
|
// Exclude template repos from search
|
||||||
|
ExcludeTemplate bool
|
||||||
|
// Filter by "fork", "source", "mirror"
|
||||||
|
Type RepoType
|
||||||
|
|
||||||
|
/*
|
||||||
|
Sort Filters
|
||||||
|
*/
|
||||||
|
|
||||||
|
// sort repos by attribute. Supported values are "alpha", "created", "updated", "size", and "id". Default is "alpha"
|
||||||
|
Sort string
|
||||||
|
// sort order, either "asc" (ascending) or "desc" (descending). Default is "asc", ignored if "sort" is not specified.
|
||||||
|
Order string
|
||||||
|
// Repo owner to prioritize in the results
|
||||||
|
PrioritizedByOwnerID int64
|
||||||
|
|
||||||
|
/*
|
||||||
|
Cover EdgeCases
|
||||||
|
*/
|
||||||
|
// if set all other options are ignored and this string is used as query
|
||||||
|
RawQuery string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *SearchRepoOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Keyword != "" {
|
||||||
|
query.Add("q", opt.Keyword)
|
||||||
|
}
|
||||||
|
if opt.KeywordIsTopic {
|
||||||
|
query.Add("topic", "true")
|
||||||
|
}
|
||||||
|
if opt.KeywordInDescription {
|
||||||
|
query.Add("includeDesc", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Filter
|
||||||
|
if opt.OwnerID > 0 {
|
||||||
|
query.Add("uid", fmt.Sprintf("%d", opt.OwnerID))
|
||||||
|
query.Add("exclusive", "true")
|
||||||
|
}
|
||||||
|
if opt.StarredByUserID > 0 {
|
||||||
|
query.Add("starredBy", fmt.Sprintf("%d", opt.StarredByUserID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo Attributes
|
||||||
|
if opt.IsPrivate != nil {
|
||||||
|
query.Add("is_private", fmt.Sprintf("%t", *opt.IsPrivate))
|
||||||
|
}
|
||||||
|
if opt.IsArchived != nil {
|
||||||
|
query.Add("archived", fmt.Sprintf("%t", *opt.IsArchived))
|
||||||
|
}
|
||||||
|
if opt.ExcludeTemplate {
|
||||||
|
query.Add("template", "false")
|
||||||
|
}
|
||||||
|
if len(opt.Type) != 0 {
|
||||||
|
query.Add("mode", string(opt.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort Filters
|
||||||
|
if opt.Sort != "" {
|
||||||
|
query.Add("sort", opt.Sort)
|
||||||
|
}
|
||||||
|
if opt.PrioritizedByOwnerID > 0 {
|
||||||
|
query.Add("priority_owner_id", fmt.Sprintf("%d", opt.PrioritizedByOwnerID))
|
||||||
|
}
|
||||||
|
if opt.Order != "" {
|
||||||
|
query.Add("order", opt.Order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchRepoResponse struct {
|
||||||
|
Repos []*Repository `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRepos searches for repositories matching the given filters
|
||||||
|
func (c *Client) SearchRepos(opt SearchRepoOptions) ([]*Repository, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
repos := new(searchRepoResponse)
|
||||||
|
|
||||||
|
link, _ := url.Parse("/repos/search")
|
||||||
|
|
||||||
|
if len(opt.RawQuery) != 0 {
|
||||||
|
link.RawQuery = opt.RawQuery
|
||||||
|
} else {
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
// IsPrivate only works on gitea >= 1.12.0
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil && opt.IsPrivate != nil {
|
||||||
|
if *opt.IsPrivate {
|
||||||
|
// private repos only not supported on gitea <= 1.11.x
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
newQuery := link.Query()
|
||||||
|
newQuery.Add("private", "false")
|
||||||
|
link.RawQuery = newQuery.Encode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &repos)
|
||||||
|
return repos.Repos, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepoOption options when creating repository
|
||||||
|
type CreateRepoOption struct {
|
||||||
|
// Name of the repository to create
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Description of the repository to create
|
||||||
|
Description string `json:"description"`
|
||||||
|
// Whether the repository is private
|
||||||
|
Private bool `json:"private"`
|
||||||
|
// Issue Label set to use
|
||||||
|
IssueLabels string `json:"issue_labels"`
|
||||||
|
// Whether the repository should be auto-intialized?
|
||||||
|
AutoInit bool `json:"auto_init"`
|
||||||
|
// Whether the repository is template
|
||||||
|
Template bool `json:"template"`
|
||||||
|
// Gitignores to use
|
||||||
|
Gitignores string `json:"gitignores"`
|
||||||
|
// License to use
|
||||||
|
License string `json:"license"`
|
||||||
|
// Readme of the repository to create
|
||||||
|
Readme string `json:"readme"`
|
||||||
|
// DefaultBranch of the repository (used when initializes and in template)
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
// TrustModel of the repository
|
||||||
|
TrustModel TrustModel `json:"trust_model"`
|
||||||
|
// ObjectFormatName of the repository, could be sha1 or sha256, depends on Gitea version
|
||||||
|
ObjectFormatName string `json:"object_format_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateRepoOption struct
|
||||||
|
func (opt CreateRepoOption) Validate(c *Client) error {
|
||||||
|
if len(strings.TrimSpace(opt.Name)) == 0 {
|
||||||
|
return fmt.Errorf("name is empty")
|
||||||
|
}
|
||||||
|
if len(opt.Name) > 100 {
|
||||||
|
return fmt.Errorf("name has more than 100 chars")
|
||||||
|
}
|
||||||
|
if len(opt.Description) > 2048 {
|
||||||
|
return fmt.Errorf("description has more than 2048 chars")
|
||||||
|
}
|
||||||
|
if len(opt.DefaultBranch) > 100 {
|
||||||
|
return fmt.Errorf("default branch name has more than 100 chars")
|
||||||
|
}
|
||||||
|
if len(opt.TrustModel) != 0 {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opt.ObjectFormatName) != 0 {
|
||||||
|
if opt.ObjectFormatName != "sha1" && opt.ObjectFormatName != "sha256" {
|
||||||
|
return fmt.Errorf("object format must be sha1 or sha256")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepo creates a repository for authenticated user.
|
||||||
|
func (c *Client) CreateRepo(opt CreateRepoOption) (*Repository, *Response, error) {
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
// object_format_name is only supported on gitea >= 1.22.0
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_22_0) != nil {
|
||||||
|
opt.ObjectFormatName = ""
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/user/repos", jsonHeader, bytes.NewReader(body), repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgRepo creates an organization repository for authenticated user.
|
||||||
|
func (c *Client) CreateOrgRepo(org string, opt CreateRepoOption) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&org); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/orgs/%s/repos", org), jsonHeader, bytes.NewReader(body), repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepo returns information of a repository of given owner.
|
||||||
|
func (c *Client) GetRepo(owner, reponame string) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &reponame); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s", owner, reponame), nil, nil, repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoByID returns information of a repository by a giver repository ID.
|
||||||
|
func (c *Client) GetRepoByID(id int64) (*Repository, *Response, error) {
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repositories/%d", id), nil, nil, repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRepoOption options when editing a repository's properties
|
||||||
|
type EditRepoOption struct {
|
||||||
|
// name of the repository
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
// a short description of the repository.
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
// a URL with more information about the repository.
|
||||||
|
Website *string `json:"website,omitempty"`
|
||||||
|
// either `true` to make the repository private or `false` to make it public.
|
||||||
|
// Note: you will get a 422 error if the organization restricts changing repository visibility to organization
|
||||||
|
// owners and a non-owner tries to change the value of private.
|
||||||
|
Private *bool `json:"private,omitempty"`
|
||||||
|
// either `true` to make this repository a template or `false` to make it a normal repository
|
||||||
|
Template *bool `json:"template,omitempty"`
|
||||||
|
// either `true` to enable issues for this repository or `false` to disable them.
|
||||||
|
HasIssues *bool `json:"has_issues,omitempty"`
|
||||||
|
// set this structure to configure internal issue tracker (requires has_issues)
|
||||||
|
InternalTracker *InternalTracker `json:"internal_tracker,omitempty"`
|
||||||
|
// set this structure to use external issue tracker (requires has_issues)
|
||||||
|
ExternalTracker *ExternalTracker `json:"external_tracker,omitempty"`
|
||||||
|
// either `true` to enable the wiki for this repository or `false` to disable it.
|
||||||
|
HasWiki *bool `json:"has_wiki,omitempty"`
|
||||||
|
// set this structure to use external wiki instead of internal (requires has_wiki)
|
||||||
|
ExternalWiki *ExternalWiki `json:"external_wiki,omitempty"`
|
||||||
|
// sets the default branch for this repository.
|
||||||
|
DefaultBranch *string `json:"default_branch,omitempty"`
|
||||||
|
// either `true` to allow pull requests, or `false` to prevent pull request.
|
||||||
|
HasPullRequests *bool `json:"has_pull_requests,omitempty"`
|
||||||
|
// either `true` to enable project unit, or `false` to disable them.
|
||||||
|
HasProjects *bool `json:"has_projects,omitempty"`
|
||||||
|
// either `true` to enable release, or `false` to disable them.
|
||||||
|
HasReleases *bool `json:"has_releases,omitempty"`
|
||||||
|
// either `true` to enable packages, or `false` to disable them.
|
||||||
|
HasPackages *bool `json:"has_packages,omitempty"`
|
||||||
|
// either `true` to enable actions, or `false` to disable them.
|
||||||
|
HasActions *bool `json:"has_actions,omitempty"`
|
||||||
|
// either `true` to ignore whitespace for conflicts, or `false` to not ignore whitespace. `has_pull_requests` must be `true`.
|
||||||
|
IgnoreWhitespaceConflicts *bool `json:"ignore_whitespace_conflicts,omitempty"`
|
||||||
|
// either `true` to allow merging pull requests with fast-forward only strategy, or `false` to prevent merging pull requests with a fast-forward only strategy. `has_pull_requests` must be `true`.
|
||||||
|
AllowFastForwardOnlyMerge *bool `json:"allow_fast_forward_only_merge"`
|
||||||
|
// either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits. `has_pull_requests` must be `true`.
|
||||||
|
AllowMerge *bool `json:"allow_merge_commits,omitempty"`
|
||||||
|
// either `true` to allow rebase-merging pull requests, or `false` to prevent rebase-merging. `has_pull_requests` must be `true`.
|
||||||
|
AllowRebase *bool `json:"allow_rebase,omitempty"`
|
||||||
|
// either `true` to allow rebase with explicit merge commits (--no-ff), or `false` to prevent rebase with explicit merge commits. `has_pull_requests` must be `true`.
|
||||||
|
AllowRebaseMerge *bool `json:"allow_rebase_explicit,omitempty"`
|
||||||
|
// either `true` to allow squash-merging pull requests, or `false` to prevent squash-merging. `has_pull_requests` must be `true`.
|
||||||
|
AllowSquash *bool `json:"allow_squash_merge,omitempty"`
|
||||||
|
// set to `true` to archive this repository.
|
||||||
|
Archived *bool `json:"archived,omitempty"`
|
||||||
|
// set to a string like `8h30m0s` to set the mirror interval time
|
||||||
|
MirrorInterval *string `json:"mirror_interval,omitempty"`
|
||||||
|
// either `true` to allow mark pr as merged manually, or `false` to prevent it. `has_pull_requests` must be `true`.
|
||||||
|
AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
|
||||||
|
// either `true` to enable AutodetectManualMerge, or `false` to prevent it. `has_pull_requests` must be `true`, Note: In some special cases, misjudgments can occur.
|
||||||
|
AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"`
|
||||||
|
// set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", or "squash". `has_pull_requests` must be `true`.
|
||||||
|
DefaultMergeStyle *MergeStyle `json:"default_merge_style,omitempty"`
|
||||||
|
// set to a projects mode to be used by this repository, to specify which kinds of projects to show: "repo" to only allow repo-level projects, "owner" to only allow owner projects, "all" to allow all projects. `has_projects` must be `true`.
|
||||||
|
ProjectsMode *ProjectsMode `json:"projects_mode"`
|
||||||
|
// set to `true` to delete the pull request branch after merge by default. `has_pull_requests` must be `true`.
|
||||||
|
DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditRepo edit the properties of a repository
|
||||||
|
func (c *Client) EditRepo(owner, reponame string, opt EditRepoOption) (*Repository, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &reponame); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s", owner, reponame), jsonHeader, bytes.NewReader(body), repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepo deletes a repository of user or organization.
|
||||||
|
func (c *Client) DeleteRepo(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s", owner, repo), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorSync adds a mirrored repository to the mirror sync queue.
|
||||||
|
func (c *Client) MirrorSync(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/mirror-sync", owner, repo), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoLanguages return language stats of a repo
|
||||||
|
func (c *Client) GetRepoLanguages(owner, repo string) (map[string]int64, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
langMap := make(map[string]int64)
|
||||||
|
|
||||||
|
data, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/languages", owner, repo), jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, &langMap); err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
return langMap, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveType represent supported archive formats by gitea
|
||||||
|
type ArchiveType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ZipArchive represent zip format
|
||||||
|
ZipArchive ArchiveType = ".zip"
|
||||||
|
// TarGZArchive represent tar.gz format
|
||||||
|
TarGZArchive ArchiveType = ".tar.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetArchive get an archive of a repository by git reference
|
||||||
|
// e.g.: ref -> master, 70b7c74b33, v1.2.1, ...
|
||||||
|
func (c *Client) GetArchive(owner, repo, ref string, ext ArchiveType) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ref = pathEscapeSegments(ref)
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/archive/%s%s", owner, repo, ref, ext), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveReader gets a `git archive` for a particular tree-ish git reference
|
||||||
|
// such as a branch name (`master`), a commit hash (`70b7c74b33`), a tag
|
||||||
|
// (`v1.2.1`). The archive is returned as a byte stream in a ReadCloser. It is
|
||||||
|
// the responsibility of the client to close the reader.
|
||||||
|
func (c *Client) GetArchiveReader(owner, repo, ref string, ext ArchiveType) (io.ReadCloser, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ref = pathEscapeSegments(ref)
|
||||||
|
return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/archive/%s%s", owner, repo, ref, ext), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoAvatarOption options for updating repository avatar
|
||||||
|
type UpdateRepoAvatarOption struct {
|
||||||
|
Image string `json:"image"` // base64 encoded image
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoAvatar updates a repository's avatar
|
||||||
|
func (c *Client) UpdateRepoAvatar(owner, repo string, opt UpdateRepoAvatarOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/avatar", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoAvatar deletes a repository's avatar
|
||||||
|
func (c *Client) DeleteRepoAvatar(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/avatar", owner, repo),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRepoActionSecretOption list RepoActionSecret options
|
||||||
|
type ListRepoActionSecretOption struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateActionsVariable represents body for creating a action variable.
|
||||||
|
type CreateRepoActionsVariable struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutActionsVariable represents body for updating a action variable.
|
||||||
|
type PutRepoActionsVariable struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionSecret list a repository's secrets
|
||||||
|
func (c *Client) ListRepoActionSecret(user, repo string, opt ListRepoActionSecretOption) ([]*Secret, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
secrets := make([]*Secret, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/secrets", user, repo))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &secrets)
|
||||||
|
return secrets, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionVariableOption lists RepoActionVariable options
|
||||||
|
type ListRepoActionVariableOption struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionVariable lists a repository's action variables
|
||||||
|
func (c *Client) ListRepoActionVariable(user, repo string, opt ListRepoActionVariableOption) ([]*RepoActionVariable, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
variables := make([]*RepoActionVariable, 0, opt.PageSize)
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/variables", user, repo))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &variables)
|
||||||
|
return variables, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepoActionSecret creates a secret for the specified repository in the Gitea Actions.
|
||||||
|
func (c *Client) CreateRepoActionSecret(user, repo, secretName string, opt CreateOrUpdateSecretOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &secretName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/repos/%s/%s/actions/secrets/%s", user, repo, secretName), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoActionSecret deletes a secret from the Gitea Actions.
|
||||||
|
// It takes the repository owner, name and the secret name as parameters.
|
||||||
|
// The function returns the HTTP response and an error, if any.
|
||||||
|
func (c *Client) DeleteRepoActionSecret(user, repo, secretName string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/actions/secrets/%s", user, repo, secretName), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionVariable returns a repository variable in the Gitea Actions.
|
||||||
|
// It takes the repository owner, name and the variable name as parameters.
|
||||||
|
// The function returns the HTTP response and an error, if any.
|
||||||
|
func (c *Client) GetRepoActionVariable(user, repo, variableName string) (*RepoActionVariable, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
variable := new(RepoActionVariable)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/variables/%s", user, repo, variableName), nil, nil, variable)
|
||||||
|
return variable, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRepoActionVariable creates a repository variable in the Gitea Actions.
|
||||||
|
// It takes the repository owner, name, variable name and the variable value as parameters.
|
||||||
|
// The function returns the HTTP response and an error, if any.
|
||||||
|
func (c *Client) CreateRepoActionVariable(user, repo, variableName, value string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
create := CreateRepoActionsVariable{
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(&create)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/actions/variables/%s", user, repo, variableName), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoActionVariable updates a repository variable in the Gitea Actions.
|
||||||
|
// It takes the repository owner, name, variable name and the variable value as parameters.
|
||||||
|
// The function returns the HTTP response and an error, if any.
|
||||||
|
func (c *Client) UpdateRepoActionVariable(user, repo, variableName, value string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
update := PutRepoActionsVariable{
|
||||||
|
Value: value,
|
||||||
|
Name: variableName,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(&update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/repos/%s/%s/actions/variables/%s", user, repo, variableName), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoActionVariable deletes a repository variable in the Gitea Actions.
|
||||||
|
// It takes the repository owner, name and the variable name as parameters.
|
||||||
|
// The function returns the HTTP response and an error, if any.
|
||||||
|
func (c *Client) DeleteRepoActionVariable(user, reponame, variableName string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &reponame); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/actions/variables/%s", user, reponame, variableName), nil, nil)
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateRepoActionRunnerRegistrationToken creates a repository-scope runner registration token.
|
||||||
|
func (c *Client) CreateRepoActionRunnerRegistrationToken(owner, repo string) (*RegistrationToken, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.createActionRegistrationToken(fmt.Sprintf("/repos/%s/%s/actions/runners/registration-token", owner, repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionRunners lists repository-scope Actions runners.
|
||||||
|
func (c *Client) ListRepoActionRunners(owner, repo string, opt ListActionRunnersOptions) (*ActionRunnersResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionRunners(fmt.Sprintf("/repos/%s/%s/actions/runners", owner, repo), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionRunner gets one repository-scope Actions runner.
|
||||||
|
func (c *Client) GetRepoActionRunner(owner, repo string, runnerID int64) (*ActionRunner, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getActionRunner(fmt.Sprintf("/repos/%s/%s/actions/runners/%d", owner, repo, runnerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoActionRunner deletes one repository-scope Actions runner.
|
||||||
|
func (c *Client) DeleteRepoActionRunner(owner, repo string, runnerID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/actions/runners/%d", owner, repo, runnerID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoActionRunner updates one repository-scope Actions runner.
|
||||||
|
func (c *Client) UpdateRepoActionRunner(owner, repo string, runnerID int64, opt EditActionRunnerOption) (*ActionRunner, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.updateActionRunner(fmt.Sprintf("/repos/%s/%s/actions/runners/%d", owner, repo, runnerID), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionWorkflows lists repository workflows.
|
||||||
|
func (c *Client) ListRepoActionWorkflows(owner, repo string) (*ActionWorkflowResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
workflows := new(ActionWorkflowResponse)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/workflows", owner, repo), jsonHeader, nil, workflows)
|
||||||
|
return workflows, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionWorkflow gets one repository workflow.
|
||||||
|
func (c *Client) GetRepoActionWorkflow(owner, repo, workflowID string) (*ActionWorkflow, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &workflowID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
workflow := new(ActionWorkflow)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/workflows/%s", owner, repo, workflowID), jsonHeader, nil, workflow)
|
||||||
|
return workflow, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableRepoActionWorkflow disables one repository workflow.
|
||||||
|
func (c *Client) DisableRepoActionWorkflow(owner, repo, workflowID string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &workflowID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/disable", owner, repo, workflowID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableRepoActionWorkflow enables one repository workflow.
|
||||||
|
func (c *Client) EnableRepoActionWorkflow(owner, repo, workflowID string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &workflowID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/enable", owner, repo, workflowID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchRepoActionWorkflow dispatches one repository workflow.
|
||||||
|
func (c *Client) DispatchRepoActionWorkflow(owner, repo, workflowID string, opt CreateActionWorkflowDispatchOption, returnRunDetails bool) (*RunDetails, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &workflowID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflowID))
|
||||||
|
if returnRunDetails {
|
||||||
|
link.RawQuery = url.Values{"return_run_details": []string{"true"}}.Encode()
|
||||||
|
details := new(RunDetails)
|
||||||
|
resp, err := c.getParsedResponse("POST", link.String(), jsonHeader, bytes.NewReader(body), details)
|
||||||
|
return details, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doRequestWithStatusHandle("POST", link.String(), jsonHeader, bytes.NewReader(body))
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionRunArtifacts lists artifacts for one workflow run.
|
||||||
|
func (c *Client) ListRepoActionRunArtifacts(owner, repo string, runID int64, opt ListActionArtifactsOptions) (*ActionArtifactsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionArtifacts(fmt.Sprintf("/repos/%s/%s/actions/runs/%d/artifacts", owner, repo, runID), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActionArtifacts lists repository artifacts.
|
||||||
|
func (c *Client) ListRepoActionArtifacts(owner, repo string, opt ListActionArtifactsOptions) (*ActionArtifactsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.listActionArtifacts(fmt.Sprintf("/repos/%s/%s/actions/artifacts", owner, repo), opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionArtifact gets one repository artifact.
|
||||||
|
func (c *Client) GetRepoActionArtifact(owner, repo string, artifactID int64) (*ActionArtifact, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
artifact := new(ActionArtifact)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/artifacts/%d", owner, repo, artifactID), jsonHeader, nil, artifact)
|
||||||
|
return artifact, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoActionArtifact deletes one repository artifact.
|
||||||
|
func (c *Client) DeleteRepoActionArtifact(owner, repo string, artifactID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/actions/artifacts/%d", owner, repo, artifactID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionArtifactArchive downloads one repository artifact zip archive.
|
||||||
|
func (c *Client) GetRepoActionArtifactArchive(owner, repo string, artifactID int64) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/actions/artifacts/%d/zip", owner, repo, artifactID), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoActionArtifactArchiveReader returns a reader for one repository artifact zip archive.
|
||||||
|
func (c *Client) GetRepoActionArtifactArchiveReader(owner, repo string, artifactID int64) (io.ReadCloser, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_25_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/actions/artifacts/%d/zip", owner, repo, artifactID), nil, nil)
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
// RepoActionVariable represents a action variable
|
||||||
|
type RepoActionVariable struct {
|
||||||
|
OwnerID int64 `json:"owner_id"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"data"`
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PayloadUser represents the author or committer of a commit
|
||||||
|
type PayloadUser struct {
|
||||||
|
// Full name of the commit author
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
UserName string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayloadCommit represents a commit
|
||||||
|
type PayloadCommit struct {
|
||||||
|
// sha1 hash of the commit
|
||||||
|
ID string `json:"id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Author *PayloadUser `json:"author"`
|
||||||
|
Committer *PayloadUser `json:"committer"`
|
||||||
|
Verification *PayloadCommitVerification `json:"verification"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Added []string `json:"added"`
|
||||||
|
Removed []string `json:"removed"`
|
||||||
|
Modified []string `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayloadCommitVerification represents the GPG verification of a commit
|
||||||
|
type PayloadCommitVerification struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch represents a repository branch
|
||||||
|
type Branch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Commit *PayloadCommit `json:"commit"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
|
EnableStatusCheck bool `json:"enable_status_check"`
|
||||||
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
|
UserCanPush bool `json:"user_can_push"`
|
||||||
|
UserCanMerge bool `json:"user_can_merge"`
|
||||||
|
EffectiveBranchProtectionName string `json:"effective_branch_protection_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoBranchesOptions options for listing a repository's branches
|
||||||
|
type ListRepoBranchesOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoBranches list all the branches of one repository
|
||||||
|
func (c *Client) ListRepoBranches(user, repo string, opt ListRepoBranchesOptions) ([]*Branch, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
branches := make([]*Branch, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branches?%s", user, repo, opt.getURLQuery().Encode()), nil, nil, &branches)
|
||||||
|
return branches, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoBranch get one branch's information of one repository
|
||||||
|
func (c *Client) GetRepoBranch(user, repo, branch string) (*Branch, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
branch = pathEscapeSegments(branch)
|
||||||
|
b := new(Branch)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), nil, nil, &b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
return b, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRepoBranch delete a branch in a repository
|
||||||
|
func (c *Client) DeleteRepoBranch(user, repo, branch string) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
branch = pathEscapeSegments(branch)
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
return status == 204, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRepoBranchOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt UpdateRepoBranchOption) Validate() error {
|
||||||
|
if len(opt.Name) == 0 {
|
||||||
|
return errors.New("empty Name field")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameRepoBranchOption renames a repository branch.
|
||||||
|
type RenameRepoBranchOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the rename payload is valid.
|
||||||
|
func (opt RenameRepoBranchOption) Validate() error {
|
||||||
|
if len(opt.Name) == 0 {
|
||||||
|
return errors.New("empty Name field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoBranchRefOption updates a branch ref to point to a new commit.
|
||||||
|
type UpdateRepoBranchRefOption struct {
|
||||||
|
NewCommitID string `json:"new_commit_id"`
|
||||||
|
OldCommitID string `json:"old_commit_id"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the branch update payload is valid.
|
||||||
|
func (opt UpdateRepoBranchRefOption) Validate() error {
|
||||||
|
if len(opt.NewCommitID) == 0 {
|
||||||
|
return errors.New("empty NewCommitID field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameRepoBranch renames a branch in a repository.
|
||||||
|
func (c *Client) RenameRepoBranch(user, repo, branch string, opt RenameRepoBranchOption) (successful bool, resp *Response, err error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
branch = pathEscapeSegments(branch)
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_24_0); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PATCH", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), jsonHeader, bytes.NewReader(body))
|
||||||
|
return status == 204, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoBranchRef updates the commit a branch points to.
|
||||||
|
// Available on Gitea main/nightly; first released version not assigned yet.
|
||||||
|
// Upstream: gitea/gitea#35951 (a440116a16c42956f21031bea8422ffbb003c732).
|
||||||
|
func (c *Client) UpdateRepoBranchRef(user, repo, branch string, opt UpdateRepoBranchRefOption) (successful bool, resp *Response, err error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
branch = pathEscapeSegments(branch)
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("PUT", fmt.Sprintf("/repos/%s/%s/branches/%s", user, repo, branch), jsonHeader, bytes.NewReader(body))
|
||||||
|
return status == 204, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRepoBranch renames a branch in a repository.
|
||||||
|
//
|
||||||
|
// Deprecated: Use RenameRepoBranch instead (identical behavior, clearer name).
|
||||||
|
// For updating the commit a branch points to, use UpdateRepoBranchRef (PUT).
|
||||||
|
func (c *Client) UpdateRepoBranch(user, repo, branch string, opt UpdateRepoBranchOption) (sucessful bool, resp *Response, err error) {
|
||||||
|
return c.RenameRepoBranch(user, repo, branch, RenameRepoBranchOption(opt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranchOption options when creating a branch in a repository
|
||||||
|
type CreateBranchOption struct {
|
||||||
|
// Name of the branch to create
|
||||||
|
BranchName string `json:"new_branch_name"`
|
||||||
|
// Name of the old branch to create from (optional)
|
||||||
|
OldBranchName string `json:"old_branch_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateBranchOption struct
|
||||||
|
func (opt CreateBranchOption) Validate() error {
|
||||||
|
if len(opt.BranchName) == 0 {
|
||||||
|
return fmt.Errorf("BranchName is empty")
|
||||||
|
}
|
||||||
|
if len(opt.BranchName) > 100 {
|
||||||
|
return fmt.Errorf("BranchName to long")
|
||||||
|
}
|
||||||
|
if len(opt.OldBranchName) > 100 {
|
||||||
|
return fmt.Errorf("OldBranchName to long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranch creates a branch for a user's repository
|
||||||
|
func (c *Client) CreateBranch(owner, repo string, opt CreateBranchOption) (*Branch, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
branch := new(Branch)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/branches", owner, repo), jsonHeader, bytes.NewReader(body), branch)
|
||||||
|
return branch, resp, err
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BranchProtection represents a branch protection for a repository
|
||||||
|
type BranchProtection struct {
|
||||||
|
BranchName string `json:"branch_name"`
|
||||||
|
RuleName string `json:"rule_name"`
|
||||||
|
EnablePush bool `json:"enable_push"`
|
||||||
|
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
|
||||||
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
EnableStatusCheck bool `json:"enable_status_check"`
|
||||||
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
|
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
||||||
|
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
||||||
|
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
||||||
|
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
|
||||||
|
RequireSignedCommits bool `json:"require_signed_commits"`
|
||||||
|
ProtectedFilePatterns string `json:"protected_file_patterns"`
|
||||||
|
UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
|
||||||
|
BlockAdminMergeOverride bool `json:"block_admin_merge_override"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Updated time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranchProtectionOption options for creating a branch protection
|
||||||
|
type CreateBranchProtectionOption struct {
|
||||||
|
BranchName string `json:"branch_name"`
|
||||||
|
RuleName string `json:"rule_name"`
|
||||||
|
EnablePush bool `json:"enable_push"`
|
||||||
|
EnablePushWhitelist bool `json:"enable_push_whitelist"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistDeployKeys bool `json:"push_whitelist_deploy_keys"`
|
||||||
|
EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
EnableStatusCheck bool `json:"enable_status_check"`
|
||||||
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
|
RequiredApprovals int64 `json:"required_approvals"`
|
||||||
|
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
|
||||||
|
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"`
|
||||||
|
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"`
|
||||||
|
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
|
||||||
|
RequireSignedCommits bool `json:"require_signed_commits"`
|
||||||
|
ProtectedFilePatterns string `json:"protected_file_patterns"`
|
||||||
|
UnprotectedFilePatterns string `json:"unprotected_file_patterns"`
|
||||||
|
BlockAdminMergeOverride bool `json:"block_admin_merge_override"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditBranchProtectionOption options for editing a branch protection
|
||||||
|
type EditBranchProtectionOption struct {
|
||||||
|
EnablePush *bool `json:"enable_push"`
|
||||||
|
EnablePushWhitelist *bool `json:"enable_push_whitelist"`
|
||||||
|
PushWhitelistUsernames []string `json:"push_whitelist_usernames"`
|
||||||
|
PushWhitelistTeams []string `json:"push_whitelist_teams"`
|
||||||
|
PushWhitelistDeployKeys *bool `json:"push_whitelist_deploy_keys"`
|
||||||
|
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
|
||||||
|
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
|
||||||
|
MergeWhitelistTeams []string `json:"merge_whitelist_teams"`
|
||||||
|
EnableStatusCheck *bool `json:"enable_status_check"`
|
||||||
|
StatusCheckContexts []string `json:"status_check_contexts"`
|
||||||
|
RequiredApprovals *int64 `json:"required_approvals"`
|
||||||
|
EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"`
|
||||||
|
ApprovalsWhitelistUsernames []string `json:"approvals_whitelist_username"`
|
||||||
|
ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"`
|
||||||
|
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
|
||||||
|
BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"`
|
||||||
|
BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"`
|
||||||
|
DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
|
||||||
|
RequireSignedCommits *bool `json:"require_signed_commits"`
|
||||||
|
ProtectedFilePatterns *string `json:"protected_file_patterns"`
|
||||||
|
UnprotectedFilePatterns *string `json:"unprotected_file_patterns"`
|
||||||
|
BlockAdminMergeOverride *bool `json:"block_admin_merge_override"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBranchProtectionsOptions list branch protection options
|
||||||
|
type ListBranchProtectionsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBranchProtections list branch protections for a repo
|
||||||
|
func (c *Client) ListBranchProtections(owner, repo string, opt ListBranchProtectionsOptions) ([]*BranchProtection, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bps := make([]*BranchProtection, 0, opt.PageSize)
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/branch_protections", owner, repo))
|
||||||
|
link.RawQuery = opt.getURLQuery().Encode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &bps)
|
||||||
|
return bps, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBranchProtection gets a branch protection
|
||||||
|
func (c *Client) GetBranchProtection(owner, repo, name string) (*BranchProtection, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bp := new(BranchProtection)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, nil, bp)
|
||||||
|
return bp, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranchProtection creates a branch protection for a repo
|
||||||
|
func (c *Client) CreateBranchProtection(owner, repo string, opt CreateBranchProtectionOption) (*BranchProtection, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bp := new(BranchProtection)
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/branch_protections", owner, repo), jsonHeader, bytes.NewReader(body), bp)
|
||||||
|
return bp, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditBranchProtection edits a branch protection for a repo
|
||||||
|
func (c *Client) EditBranchProtection(owner, repo, name string, opt EditBranchProtectionOption) (*BranchProtection, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bp := new(BranchProtection)
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, bytes.NewReader(body), bp)
|
||||||
|
return bp, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBranchProtection deletes a branch protection for a repo
|
||||||
|
func (c *Client) DeleteBranchProtection(owner, repo, name string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/branch_protections/%s", owner, repo, name), jsonHeader, nil)
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListCollaboratorsOptions options for listing a repository's collaborators
|
||||||
|
type ListCollaboratorsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollaboratorPermissionResult result type for CollaboratorPermission
|
||||||
|
type CollaboratorPermissionResult struct {
|
||||||
|
Permission AccessMode `json:"permission"`
|
||||||
|
Role string `json:"role_name"`
|
||||||
|
User *User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCollaborators list a repository's collaborators
|
||||||
|
func (c *Client) ListCollaborators(user, repo string, opt ListCollaboratorsOptions) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
collaborators := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/collaborators?%s", user, repo, opt.getURLQuery().Encode()),
|
||||||
|
nil, nil, &collaborators)
|
||||||
|
return collaborators, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCollaborator check if a user is a collaborator of a repository
|
||||||
|
func (c *Client) IsCollaborator(user, repo, collaborator string) (bool, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("GET", fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, resp, err
|
||||||
|
}
|
||||||
|
if status == 204 {
|
||||||
|
return true, resp, nil
|
||||||
|
}
|
||||||
|
return false, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollaboratorPermission gets collaborator permission of a repository
|
||||||
|
func (c *Client) CollaboratorPermission(user, repo, collaborator string) (*CollaboratorPermissionResult, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rv := new(CollaboratorPermissionResult)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", user, repo, collaborator),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
rv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
rv = nil
|
||||||
|
}
|
||||||
|
return rv, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCollaboratorOption options when adding a user as a collaborator of a repository
|
||||||
|
type AddCollaboratorOption struct {
|
||||||
|
Permission *AccessMode `json:"permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessMode represent the grade of access you have to something
|
||||||
|
type AccessMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessModeNone no access
|
||||||
|
AccessModeNone AccessMode = "none"
|
||||||
|
// AccessModeRead read access
|
||||||
|
AccessModeRead AccessMode = "read"
|
||||||
|
// AccessModeWrite write access
|
||||||
|
AccessModeWrite AccessMode = "write"
|
||||||
|
// AccessModeAdmin admin access
|
||||||
|
AccessModeAdmin AccessMode = "admin"
|
||||||
|
// AccessModeOwner owner
|
||||||
|
AccessModeOwner AccessMode = "owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate the AddCollaboratorOption struct
|
||||||
|
func (opt *AddCollaboratorOption) Validate() error {
|
||||||
|
if opt.Permission != nil {
|
||||||
|
if *opt.Permission == AccessModeOwner {
|
||||||
|
*opt.Permission = AccessModeAdmin
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *opt.Permission == AccessModeNone {
|
||||||
|
opt.Permission = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *opt.Permission != AccessModeRead && *opt.Permission != AccessModeWrite && *opt.Permission != AccessModeAdmin {
|
||||||
|
return fmt.Errorf("permission mode invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCollaborator add some user as a collaborator of a repository
|
||||||
|
func (c *Client) AddCollaborator(user, repo, collaborator string, opt AddCollaboratorOption) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := (&opt).Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("PUT", fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), jsonHeader, bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollaborator remove a collaborator from a repository
|
||||||
|
func (c *Client) DeleteCollaborator(user, repo, collaborator string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &collaborator); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/collaborators/%s", user, repo, collaborator), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReviewers return all users that can be requested to review in this repo
|
||||||
|
func (c *Client) GetReviewers(user, repo string) ([]*User, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
reviewers := make([]*User, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/reviewers", user, repo), nil, nil, &reviewers)
|
||||||
|
return reviewers, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssignees return all users that have write access and can be assigned to issues
|
||||||
|
func (c *Client) GetAssignees(user, repo string) ([]*User, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_15_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
assignees := make([]*User, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/assignees", user, repo), nil, nil, &assignees)
|
||||||
|
return assignees, resp, err
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2018 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity for a person's identity like an author or committer
|
||||||
|
type Identity struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitMeta contains meta information of a commit in terms of API.
|
||||||
|
type CommitMeta struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitUser contains information of a user in the context of a commit.
|
||||||
|
type CommitUser struct {
|
||||||
|
Identity
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoCommit contains information of a commit in the context of a repository.
|
||||||
|
type RepoCommit struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Author *CommitUser `json:"author"`
|
||||||
|
Committer *CommitUser `json:"committer"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Tree *CommitMeta `json:"tree"`
|
||||||
|
Verification *PayloadCommitVerification `json:"verification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitStats contains stats from a Git commit
|
||||||
|
type CommitStats struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit contains information generated from a Git commit.
|
||||||
|
type Commit struct {
|
||||||
|
*CommitMeta
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
RepoCommit *RepoCommit `json:"commit"`
|
||||||
|
Author *User `json:"author"`
|
||||||
|
Committer *User `json:"committer"`
|
||||||
|
Parents []*CommitMeta `json:"parents"`
|
||||||
|
Files []*CommitAffectedFiles `json:"files"`
|
||||||
|
Stats *CommitStats `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitDateOptions store dates for GIT_AUTHOR_DATE and GIT_COMMITTER_DATE
|
||||||
|
type CommitDateOptions struct {
|
||||||
|
Author time.Time `json:"author"`
|
||||||
|
Committer time.Time `json:"committer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitAffectedFiles store information about files affected by the commit
|
||||||
|
type CommitAffectedFiles struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSingleCommit returns a single commit
|
||||||
|
func (c *Client) GetSingleCommit(user, repo, commitID string) (*Commit, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &commitID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
commit := new(Commit)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s", user, repo, commitID), nil, nil, &commit)
|
||||||
|
return commit, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCommitOptions list commit options
|
||||||
|
type ListCommitOptions struct {
|
||||||
|
ListOptions
|
||||||
|
// SHA or branch to start listing commits from (usually 'master')
|
||||||
|
SHA string
|
||||||
|
// Path indicates that only commits that include the path's file/dir should be returned.
|
||||||
|
Path string
|
||||||
|
// Stat includes diff stats for every commit (disable for speedup)
|
||||||
|
Stat bool
|
||||||
|
// Verification includes verification for every commit (disable for speedup)
|
||||||
|
Verification bool
|
||||||
|
// Files includes a list of affected files for every commit (disable for speedup)
|
||||||
|
Files bool
|
||||||
|
// Not is a string used such that commits that match the given specifier will not be listed.
|
||||||
|
Not string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListCommitOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.SHA != "" {
|
||||||
|
query.Add("sha", opt.SHA)
|
||||||
|
}
|
||||||
|
if opt.Path != "" {
|
||||||
|
query.Add("path", opt.Path)
|
||||||
|
}
|
||||||
|
query.Add("stat", strconv.FormatBool(opt.Stat))
|
||||||
|
query.Add("verification", strconv.FormatBool(opt.Verification))
|
||||||
|
query.Add("files", strconv.FormatBool(opt.Files))
|
||||||
|
if opt.Not != "" {
|
||||||
|
query.Add("not", opt.Not)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoCommits return list of commits from a repo
|
||||||
|
func (c *Client) ListRepoCommits(user, repo string, opt ListCommitOptions) ([]*Commit, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/commits", user, repo))
|
||||||
|
opt.setDefaults()
|
||||||
|
commits := make([]*Commit, 0, opt.PageSize)
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &commits)
|
||||||
|
return commits, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommitDiff returns the commit's raw diff.
|
||||||
|
func (c *Client) GetCommitDiff(user, repo, commitID string) ([]byte, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s.%s", user, repo, commitID, pullRequestDiffTypeDiff), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommitPatch returns the commit's raw patch.
|
||||||
|
func (c *Client) GetCommitPatch(user, repo, commitID string) ([]byte, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_16_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/commits/%s.%s", user, repo, commitID, pullRequestDiffTypePatch), nil, nil)
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Compare represents a comparison between two commits.
|
||||||
|
type Compare struct {
|
||||||
|
TotalCommits int `json:"total_commits"` // Total number of commits in the comparison.
|
||||||
|
Commits []*Commit `json:"commits"` // List of commits in the comparison.
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareCommits compares two commits in a repository.
|
||||||
|
func (c *Client) CompareCommits(user, repo, prev, current string) (*Compare, *Response, error) {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_22_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &prev, ¤t); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
basehead := fmt.Sprintf("%s...%s", prev, current)
|
||||||
|
|
||||||
|
apiResp := new(Compare)
|
||||||
|
resp, err := c.getParsedResponse(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/compare/%s", user, repo, basehead),
|
||||||
|
nil, nil, apiResp,
|
||||||
|
)
|
||||||
|
return apiResp, resp, err
|
||||||
|
}
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Pinned pull requests ---
|
||||||
|
|
||||||
|
// ListRepoPinnedPullRequests lists a repo's pinned pull requests
|
||||||
|
func (c *Client) ListRepoPinnedPullRequests(owner, repo string) ([]*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
prs := make([]*PullRequest, 0, 5)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/pinned", owner, repo),
|
||||||
|
jsonHeader, nil, &prs)
|
||||||
|
return prs, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Webhooks ---
|
||||||
|
|
||||||
|
// TestWebhook tests a webhook
|
||||||
|
func (c *Client) TestWebhook(owner, repo string, hookID int64, ref string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt := map[string]string{"ref": ref}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/hooks/%d/tests", owner, repo, hookID),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Merge upstream ---
|
||||||
|
|
||||||
|
// MergeUpstreamRequest options for merging upstream
|
||||||
|
type MergeUpstreamRequest struct {
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
FfOnly bool `json:"ff_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeUpstreamResponse represents the response from merging upstream
|
||||||
|
type MergeUpstreamResponse struct {
|
||||||
|
MergeStyle string `json:"merge_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeUpstream merges upstream into a forked repository
|
||||||
|
func (c *Client) MergeUpstream(owner, repo string, opt MergeUpstreamRequest) (*MergeUpstreamResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(MergeUpstreamResponse)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/merge-upstream", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pin allowed ---
|
||||||
|
|
||||||
|
// NewIssuePinsAllowed represents whether new issue/PR pins are allowed
|
||||||
|
type NewIssuePinsAllowed struct {
|
||||||
|
Issues bool `json:"issues"`
|
||||||
|
PullRequests bool `json:"pull_requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPinAllowed checks if the current user can pin issues or PRs
|
||||||
|
func (c *Client) CheckPinAllowed(owner, repo string) (*NewIssuePinsAllowed, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(NewIssuePinsAllowed)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/new_pin_allowed", owner, repo),
|
||||||
|
jsonHeader, nil, result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Batch file operations ---
|
||||||
|
|
||||||
|
// ChangeFilesOptions options for batch file operations
|
||||||
|
type ChangeFilesOptions struct {
|
||||||
|
Files []*ChangeFileOperation `json:"files"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
NewBranch string `json:"new_branch,omitempty"`
|
||||||
|
ForcePush bool `json:"force_push,omitempty"`
|
||||||
|
Author Identity `json:"author"`
|
||||||
|
Committer Identity `json:"committer"`
|
||||||
|
Dates CommitDateOptions `json:"dates"`
|
||||||
|
Signoff bool `json:"signoff,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeFileOperation represents a file operation in batch
|
||||||
|
type ChangeFileOperation struct {
|
||||||
|
Operation string `json:"operation"` // create, update, upload, rename, delete
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"` // base64 encoded for create/update
|
||||||
|
SHA string `json:"sha,omitempty"` // required for update/delete
|
||||||
|
FromPath string `json:"from_path,omitempty"` // for rename
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeFiles creates, updates, or deletes multiple files
|
||||||
|
func (c *Client) ChangeFiles(owner, repo string, opt ChangeFilesOptions) (*FileResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(FileResponse)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/contents", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), &result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Branch protection priorities ---
|
||||||
|
|
||||||
|
// UpdateBranchProtectionPriorities updates the priorities of branch protection rules
|
||||||
|
func (c *Client) UpdateBranchProtectionPriorities(owner, repo string, ids []int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt := map[string][]int64{"ids": ids}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/branch_protections/priority", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File contents (batch lookup) ---
|
||||||
|
|
||||||
|
// GetFilesOptions controls batch file-content lookup requests.
|
||||||
|
type GetFilesOptions struct {
|
||||||
|
Files []string `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the batch file lookup request is valid.
|
||||||
|
func (opt GetFilesOptions) Validate() error {
|
||||||
|
if len(opt.Files) == 0 {
|
||||||
|
return errors.New("empty Files field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoFileContents fetches metadata and contents for multiple files through the GET endpoint.
|
||||||
|
// The file list is JSON-encoded in the "body" query parameter; for large file lists prefer
|
||||||
|
// PostRepoFileContents to avoid URL length limitations.
|
||||||
|
func (c *Client) GetRepoFileContents(owner, repo, ref string, opt GetFilesOptions) ([]*ContentsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/file-contents", owner, repo))
|
||||||
|
query := link.Query()
|
||||||
|
if ref != "" {
|
||||||
|
query.Add("ref", ref)
|
||||||
|
}
|
||||||
|
query.Add("body", string(body))
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
contents := make([]*ContentsResponse, 0, len(opt.Files))
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &contents)
|
||||||
|
return contents, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRepoFileContents fetches metadata and contents for multiple files through the POST endpoint.
|
||||||
|
func (c *Client) PostRepoFileContents(owner, repo, ref string, opt GetFilesOptions) ([]*ContentsResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/file-contents", owner, repo))
|
||||||
|
if ref != "" {
|
||||||
|
link.RawQuery = url.Values{"ref": []string{ref}}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
contents := make([]*ContentsResponse, 0, len(opt.Files))
|
||||||
|
resp, err := c.getParsedResponse("POST", link.String(), jsonHeader, bytes.NewReader(body), &contents)
|
||||||
|
return contents, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Issue config ---
|
||||||
|
|
||||||
|
// IssueConfigContactLink represents an issue config contact link.
|
||||||
|
type IssueConfigContactLink struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
About string `json:"about"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueConfig represents the parsed issue config for a repository.
|
||||||
|
type IssueConfig struct {
|
||||||
|
BlankIssuesEnabled bool `json:"blank_issues_enabled"`
|
||||||
|
ContactLinks []IssueConfigContactLink `json:"contact_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIssueConfig gets the issue config for a repository.
|
||||||
|
func (c *Client) GetIssueConfig(owner, repo string) (*IssueConfig, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
config := new(IssueConfig)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/issue_config", owner, repo), jsonHeader, nil, config)
|
||||||
|
return config, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Licenses ---
|
||||||
|
|
||||||
|
// GetRepoLicenses gets detected licenses for a repository.
|
||||||
|
func (c *Client) GetRepoLicenses(owner, repo string) ([]string, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
licenses := make([]string, 0, 2)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/licenses", owner, repo), jsonHeader, nil, &licenses)
|
||||||
|
return licenses, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signing keys ---
|
||||||
|
|
||||||
|
// GetRepoSigningKeyGPG gets the repository signing GPG public key.
|
||||||
|
func (c *Client) GetRepoSigningKeyGPG(owner, repo string) (string, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
key, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/signing-key.gpg", owner, repo), nil, nil)
|
||||||
|
return string(key), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoSigningKeySSH gets the repository signing SSH public key.
|
||||||
|
func (c *Client) GetRepoSigningKeySSH(owner, repo string) (string, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
key, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/signing-key.pub", owner, repo), nil, nil)
|
||||||
|
return string(key), resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subscribers ---
|
||||||
|
|
||||||
|
// ListRepoSubscribers lists repository watchers.
|
||||||
|
func (c *Client) ListRepoSubscribers(owner, repo string, opt ListOptions) ([]*User, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
subscribers := make([]*User, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/subscribers?%s", owner, repo, opt.getURLQuery().Encode()), jsonHeader, nil, &subscribers)
|
||||||
|
return subscribers, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Diff patch ---
|
||||||
|
|
||||||
|
// ApplyDiffPatchFileOptions applies a patch against repository contents.
|
||||||
|
type ApplyDiffPatchFileOptions struct {
|
||||||
|
FileOptions
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks whether the patch payload is valid.
|
||||||
|
func (opt ApplyDiffPatchFileOptions) Validate() error {
|
||||||
|
if len(opt.Content) == 0 {
|
||||||
|
return errors.New("empty Content field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRepoDiffPatch applies a patch to repository contents.
|
||||||
|
func (c *Client) ApplyRepoDiffPatch(owner, repo string, opt ApplyDiffPatchFileOptions) (*FileResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(FileResponse)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/diffpatch", owner, repo), jsonHeader, bytes.NewReader(body), result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Push mirrors ---
|
||||||
|
|
||||||
|
// TriggerPushMirrorsSync triggers push-mirror syncing for a repository.
|
||||||
|
func (c *Client) TriggerPushMirrorsSync(owner, repo string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("POST", fmt.Sprintf("/repos/%s/%s/push_mirrors-sync", owner, repo), nil, nil)
|
||||||
|
}
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileOptions options for all file APIs
|
||||||
|
type FileOptions struct {
|
||||||
|
// message (optional) for the commit of this file. if not supplied, a default message will be used
|
||||||
|
Message string `json:"message"`
|
||||||
|
// branch (optional) to base this file from. if not given, the default branch is used
|
||||||
|
BranchName string `json:"branch"`
|
||||||
|
// new_branch (optional) will make a new branch from `branch` before creating the file
|
||||||
|
NewBranchName string `json:"new_branch"`
|
||||||
|
// force_push (optional) will do a force-push if the new branch already exists
|
||||||
|
ForcePush bool `json:"force_push"`
|
||||||
|
// `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
|
||||||
|
Author Identity `json:"author"`
|
||||||
|
Committer Identity `json:"committer"`
|
||||||
|
Dates CommitDateOptions `json:"dates"`
|
||||||
|
// Add a Signed-off-by trailer by the committer at the end of the commit log message.
|
||||||
|
Signoff bool `json:"signoff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFileOptions options for creating files
|
||||||
|
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
|
||||||
|
type CreateFileOptions struct {
|
||||||
|
FileOptions
|
||||||
|
// content must be base64 encoded
|
||||||
|
// required: true
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileOptions options for deleting files (used for other File structs below)
|
||||||
|
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
|
||||||
|
type DeleteFileOptions struct {
|
||||||
|
FileOptions
|
||||||
|
// sha is the SHA for the file that already exists
|
||||||
|
// required: true
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFileOptions options for updating files
|
||||||
|
// Note: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
|
||||||
|
type UpdateFileOptions struct {
|
||||||
|
FileOptions
|
||||||
|
// sha is the SHA for the file that already exists
|
||||||
|
// required: true
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
// content must be base64 encoded
|
||||||
|
// required: true
|
||||||
|
Content string `json:"content"`
|
||||||
|
// from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL
|
||||||
|
FromPath string `json:"from_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileLinksResponse contains the links for a repo's file
|
||||||
|
type FileLinksResponse struct {
|
||||||
|
Self *string `json:"self"`
|
||||||
|
GitURL *string `json:"git"`
|
||||||
|
HTMLURL *string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content
|
||||||
|
type ContentsResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
LastCommitSha *string `json:"last_commit_sha,omitempty"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
LastCommitterDate *time.Time `json:"last_committer_date,omitempty"`
|
||||||
|
// swagger:strfmt date-time
|
||||||
|
LastAuthorDate *time.Time `json:"last_author_date,omitempty"`
|
||||||
|
LastCommitMessage *string `json:"last_commit_message,omitempty"`
|
||||||
|
// `type` will be `file`, `dir`, `symlink`, or `submodule`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
// `encoding` is populated when `type` is `file`, otherwise null
|
||||||
|
Encoding *string `json:"encoding"`
|
||||||
|
// `content` is populated when `type` is `file`, otherwise null
|
||||||
|
Content *string `json:"content"`
|
||||||
|
// `target` is populated when `type` is `symlink`, otherwise null
|
||||||
|
Target *string `json:"target"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
HTMLURL *string `json:"html_url"`
|
||||||
|
GitURL *string `json:"git_url"`
|
||||||
|
DownloadURL *string `json:"download_url"`
|
||||||
|
// `submodule_git_url` is populated when `type` is `submodule`, otherwise null
|
||||||
|
SubmoduleGitURL *string `json:"submodule_git_url"`
|
||||||
|
Links *FileLinksResponse `json:"_links"`
|
||||||
|
LfsOid *string `json:"lfs_oid,omitempty"`
|
||||||
|
LfsSize *int64 `json:"lfs_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCommitResponse contains information generated from a Git commit for a repo's file.
|
||||||
|
type FileCommitResponse struct {
|
||||||
|
CommitMeta
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Author *CommitUser `json:"author"`
|
||||||
|
Committer *CommitUser `json:"committer"`
|
||||||
|
Parents []*CommitMeta `json:"parents"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Tree *CommitMeta `json:"tree"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileResponse contains information about a repo's file
|
||||||
|
type FileResponse struct {
|
||||||
|
Content *ContentsResponse `json:"content"`
|
||||||
|
Commit *FileCommitResponse `json:"commit"`
|
||||||
|
Verification *PayloadCommitVerification `json:"verification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileDeleteResponse contains information about a repo's file that was deleted
|
||||||
|
type FileDeleteResponse struct {
|
||||||
|
Content interface{} `json:"content"` // to be set to nil
|
||||||
|
Commit *FileCommitResponse `json:"commit"`
|
||||||
|
Verification *PayloadCommitVerification `json:"verification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile downloads a file of repository, ref can be branch/tag/commit.
|
||||||
|
// it optional can resolve lfs pointers and server the file instead
|
||||||
|
// e.g.: ref -> master, filepath -> README.md (no leading slash)
|
||||||
|
func (c *Client) GetFile(owner, repo, ref, filepath string, resolveLFS ...bool) ([]byte, *Response, error) {
|
||||||
|
reader, resp, err := c.GetFileReader(owner, repo, ref, filepath, resolveLFS...)
|
||||||
|
if reader == nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := reader.Close(); closeErr != nil && err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
data, err2 := io.ReadAll(reader)
|
||||||
|
if err2 != nil {
|
||||||
|
return nil, resp, err2
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileReader return reader for download a file of repository, ref can be branch/tag/commit.
|
||||||
|
// it optional can resolve lfs pointers and server the file instead
|
||||||
|
// e.g.: ref -> master, filepath -> README.md (no leading slash)
|
||||||
|
func (c *Client) GetFileReader(owner, repo, ref, filepath string, resolveLFS ...bool) (io.ReadCloser, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve lfs
|
||||||
|
if len(resolveLFS) != 0 && resolveLFS[0] {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_17_0); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/media/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal get
|
||||||
|
filepath = pathEscapeSegments(filepath)
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
|
||||||
|
ref = pathEscapeSegments(ref)
|
||||||
|
return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/raw/%s/%s", owner, repo, ref, filepath), nil, nil)
|
||||||
|
}
|
||||||
|
return c.getResponseReader("GET", fmt.Sprintf("/repos/%s/%s/raw/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContents get the metadata and contents of a file in a repository
|
||||||
|
// ref is optional
|
||||||
|
func (c *Client) GetContents(owner, repo, ref, filepath string) (*ContentsResponse, *Response, error) {
|
||||||
|
data, resp, err := c.getDirOrFileContents(owner, repo, ref, filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
cr := new(ContentsResponse)
|
||||||
|
if json.Unmarshal(data, &cr) != nil {
|
||||||
|
return nil, resp, fmt.Errorf("expect file, got directory")
|
||||||
|
}
|
||||||
|
return cr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContents gets a list of entries in a dir
|
||||||
|
// ref is optional
|
||||||
|
func (c *Client) ListContents(owner, repo, ref, filepath string) ([]*ContentsResponse, *Response, error) {
|
||||||
|
data, resp, err := c.getDirOrFileContents(owner, repo, ref, filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
crl := make([]*ContentsResponse, 0)
|
||||||
|
if json.Unmarshal(data, &crl) != nil {
|
||||||
|
return nil, resp, fmt.Errorf("expect directory, got file")
|
||||||
|
}
|
||||||
|
return crl, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getDirOrFileContents(owner, repo, ref, filepath string) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
filepath = pathEscapeSegments(strings.TrimPrefix(filepath, "/"))
|
||||||
|
return c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/contents/%s?ref=%s", owner, repo, filepath, url.QueryEscape(ref)), jsonHeader, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile create a file in a repository
|
||||||
|
func (c *Client) CreateFile(owner, repo, filepath string, opt CreateFileOptions) (*FileResponse, *Response, error) {
|
||||||
|
var err error
|
||||||
|
if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
filepath = pathEscapeSegments(filepath)
|
||||||
|
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
fr := new(FileResponse)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body), fr)
|
||||||
|
return fr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFile update a file in a repository
|
||||||
|
func (c *Client) UpdateFile(owner, repo, filepath string, opt UpdateFileOptions) (*FileResponse, *Response, error) {
|
||||||
|
var err error
|
||||||
|
if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
filepath = pathEscapeSegments(filepath)
|
||||||
|
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
fr := new(FileResponse)
|
||||||
|
resp, err := c.getParsedResponse("PUT", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body), fr)
|
||||||
|
return fr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFile delete a file from repository
|
||||||
|
func (c *Client) DeleteFile(owner, repo, filepath string, opt DeleteFileOptions) (*Response, error) {
|
||||||
|
var err error
|
||||||
|
if opt.BranchName, err = c.setDefaultBranchForOldVersions(owner, repo, opt.BranchName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filepath = pathEscapeSegments(filepath)
|
||||||
|
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("DELETE", fmt.Sprintf("/repos/%s/%s/contents/%s", owner, repo, filepath), jsonHeader, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != 200 && status != 204 {
|
||||||
|
return resp, fmt.Errorf("unexpected Status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setDefaultBranchForOldVersions(owner, repo, branch string) (string, error) {
|
||||||
|
if len(branch) == 0 {
|
||||||
|
// Gitea >= 1.12.0 Use DefaultBranch on "", mimic this for older versions
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_12_0) != nil {
|
||||||
|
r, _, err := c.GetRepo(owner, repo)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.DefaultBranch, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branch, nil
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentsExtResponse contains extended information about a repo's contents
|
||||||
|
type ContentsExtResponse struct {
|
||||||
|
DirContents []*ContentsResponse `json:"dir_contents,omitempty"`
|
||||||
|
FileContents *ContentsResponse `json:"file_contents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContentsExtOptions options for getting extended contents
|
||||||
|
type GetContentsExtOptions struct {
|
||||||
|
// The name of the commit/branch/tag. Default to the repository's default branch
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
// Comma-separated includes options: file_content, lfs_metadata, commit_metadata, commit_message
|
||||||
|
Includes string `json:"includes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContentsExt gets extended file metadata and/or content from a repository
|
||||||
|
// The extended "contents" API, to get file metadata and/or content, or list a directory
|
||||||
|
func (c *Client) GetContentsExt(owner, repo, filepath string, opt GetContentsExtOptions) (*ContentsExtResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// filepath doesn't need escaping since it's already part of the path
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/contents-ext/%s", owner, repo, filepath))
|
||||||
|
query := link.Query()
|
||||||
|
|
||||||
|
if opt.Ref != "" {
|
||||||
|
query.Add("ref", opt.Ref)
|
||||||
|
}
|
||||||
|
if opt.Includes != "" {
|
||||||
|
query.Add("includes", opt.Includes)
|
||||||
|
}
|
||||||
|
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
result := new(ContentsExtResponse)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEditorConfig gets the EditorConfig definitions of a file in a repository
|
||||||
|
func (c *Client) GetEditorConfig(owner, repo, filepath string, ref ...string) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/editorconfig/%s", owner, repo, filepath))
|
||||||
|
|
||||||
|
if len(ref) > 0 && ref[0] != "" {
|
||||||
|
query := link.Query()
|
||||||
|
query.Add("ref", ref[0])
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", link.String(), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRawFileOrLFS gets a file or its LFS object from a repository
|
||||||
|
// This endpoint resolves LFS pointers and returns actual LFS objects
|
||||||
|
func (c *Client) GetRawFileOrLFS(owner, repo, filepath string, ref ...string) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/media/%s", owner, repo, filepath))
|
||||||
|
|
||||||
|
if len(ref) > 0 && ref[0] != "" {
|
||||||
|
query := link.Query()
|
||||||
|
query.Add("ref", ref[0])
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", link.String(), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRawFile gets a file from a repository
|
||||||
|
// Unlike GetRawFileOrLFS, this does NOT resolve LFS pointers
|
||||||
|
func (c *Client) GetRawFile(owner, repo, filepath string, ref ...string) ([]byte, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/raw/%s", owner, repo, filepath))
|
||||||
|
|
||||||
|
if len(ref) > 0 && ref[0] != "" {
|
||||||
|
query := link.Query()
|
||||||
|
query.Add("ref", ref[0])
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getResponse("GET", link.String(), nil, nil)
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note represents a git note
|
||||||
|
type Note struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Commit *Commit `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoNoteOptions options for getting a note
|
||||||
|
type GetRepoNoteOptions struct {
|
||||||
|
// include verification for every commit (disable for speedup, default 'true')
|
||||||
|
Verification *bool `json:"verification,omitempty"`
|
||||||
|
// include a list of affected files for every commit (disable for speedup, default 'true')
|
||||||
|
Files *bool `json:"files,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoNote gets a note corresponding to a single commit from a repository
|
||||||
|
func (c *Client) GetRepoNote(owner, repo, sha string, opt GetRepoNoteOptions) (*Note, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &sha); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/git/notes/%s", owner, repo, sha))
|
||||||
|
query := link.Query()
|
||||||
|
|
||||||
|
if opt.Verification != nil {
|
||||||
|
if *opt.Verification {
|
||||||
|
query.Add("verification", "true")
|
||||||
|
} else {
|
||||||
|
query.Add("verification", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opt.Files != nil {
|
||||||
|
if *opt.Files {
|
||||||
|
query.Add("files", "true")
|
||||||
|
} else {
|
||||||
|
query.Add("files", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
note := new(Note)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, ¬e)
|
||||||
|
return note, resp, err
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCommitPullRequest gets the pull request associated with a commit SHA
|
||||||
|
func (c *Client) GetCommitPullRequest(owner, repo, sha string) (*PullRequest, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &sha); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pr := new(PullRequest)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/commits/%s/pull", owner, repo, sha),
|
||||||
|
jsonHeader, nil, &pr)
|
||||||
|
return pr, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePullRequest updates a pull request with new commits from the base branch
|
||||||
|
func (c *Client) UpdatePullRequest(owner, repo string, index int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
status, resp, err := c.getStatusCode("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/pulls/%d/update", owner, repo, index),
|
||||||
|
jsonHeader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return resp, fmt.Errorf("unexpected status: %d", status)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserTrackedTimes gets all tracked times for a user in a repository
|
||||||
|
func (c *Client) GetUserTrackedTimes(owner, repo, user string) ([]*TrackedTime, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo, &user); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
times := make([]*TrackedTime, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/times/%s", owner, repo, user),
|
||||||
|
jsonHeader, nil, ×)
|
||||||
|
return times, resp, err
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2015 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeployKey a deploy key
|
||||||
|
type DeployKey struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
KeyID int64 `json:"key_id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
Repository *Repository `json:"repository,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeployKeysOptions options for listing a repository's deploy keys
|
||||||
|
type ListDeployKeysOptions struct {
|
||||||
|
ListOptions
|
||||||
|
KeyID int64
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryEncode turns options into querystring argument
|
||||||
|
func (opt *ListDeployKeysOptions) QueryEncode() string {
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.KeyID > 0 {
|
||||||
|
query.Add("key_id", fmt.Sprintf("%d", opt.KeyID))
|
||||||
|
}
|
||||||
|
if len(opt.Fingerprint) > 0 {
|
||||||
|
query.Add("fingerprint", opt.Fingerprint)
|
||||||
|
}
|
||||||
|
return query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeployKeys list all the deploy keys of one repository
|
||||||
|
func (c *Client) ListDeployKeys(user, repo string, opt ListDeployKeysOptions) ([]*DeployKey, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/keys", user, repo))
|
||||||
|
opt.setDefaults()
|
||||||
|
link.RawQuery = opt.QueryEncode()
|
||||||
|
keys := make([]*DeployKey, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), nil, nil, &keys)
|
||||||
|
return keys, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeployKey get one deploy key with key id
|
||||||
|
func (c *Client) GetDeployKey(user, repo string, keyID int64) (*DeployKey, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
key := new(DeployKey)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/keys/%d", user, repo, keyID), nil, nil, &key)
|
||||||
|
return key, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeployKey options when create one deploy key
|
||||||
|
func (c *Client) CreateDeployKey(user, repo string, opt CreateKeyOption) (*DeployKey, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
key := new(DeployKey)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/keys", user, repo), jsonHeader, bytes.NewReader(body), key)
|
||||||
|
return key, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeployKey delete deploy key with key id
|
||||||
|
func (c *Client) DeleteDeployKey(owner, repo string, keyID int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/keys/%d", owner, repo, keyID), nil, nil)
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Label a label to an issue or a pr
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// example: 00aabb
|
||||||
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Exclusive bool `json:"exclusive"`
|
||||||
|
IsArchived bool `json:"is_archived"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLabelsOptions options for listing repository's labels
|
||||||
|
type ListLabelsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoLabels list labels of one repository
|
||||||
|
func (c *Client) ListRepoLabels(owner, repo string, opt ListLabelsOptions) ([]*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
labels := make([]*Label, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/labels?%s", owner, repo, opt.getURLQuery().Encode()), nil, nil, &labels)
|
||||||
|
return labels, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoLabel get one label of repository by repo it
|
||||||
|
func (c *Client) GetRepoLabel(owner, repo string, id int64) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), nil, nil, label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLabelOption options for creating a label
|
||||||
|
type CreateLabelOption struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
// example: #00aabb
|
||||||
|
Color string `json:"color"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Exclusive bool `json:"exclusive"`
|
||||||
|
IsArchived bool `json:"is_archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CreateLabelOption struct
|
||||||
|
func (opt CreateLabelOption) Validate() error {
|
||||||
|
aw, err := regexp.MatchString("^#?[0-9,a-f,A-F]{6}$", opt.Color)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !aw {
|
||||||
|
return fmt.Errorf("invalid color format")
|
||||||
|
}
|
||||||
|
if len(strings.TrimSpace(opt.Name)) == 0 {
|
||||||
|
return fmt.Errorf("empty name not allowed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLabel create one label of repository
|
||||||
|
func (c *Client) CreateLabel(owner, repo string, opt CreateLabelOption) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if len(opt.Color) == 6 {
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_12_0); err != nil {
|
||||||
|
opt.Color = "#" + opt.Color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("POST",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/labels", owner, repo),
|
||||||
|
jsonHeader, bytes.NewReader(body), label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditLabelOption options for editing a label
|
||||||
|
type EditLabelOption struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Exclusive *bool `json:"exclusive"`
|
||||||
|
IsArchived *bool `json:"is_archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the EditLabelOption struct
|
||||||
|
func (opt EditLabelOption) Validate() error {
|
||||||
|
if opt.Color != nil {
|
||||||
|
aw, err := regexp.MatchString("^#?[0-9,a-f,A-F]{6}$", *opt.Color)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !aw {
|
||||||
|
return fmt.Errorf("invalid color format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opt.Name != nil {
|
||||||
|
if len(strings.TrimSpace(*opt.Name)) == 0 {
|
||||||
|
return fmt.Errorf("empty name not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditLabel modify one label with options
|
||||||
|
func (c *Client) EditLabel(owner, repo string, id int64, opt EditLabelOption) (*Label, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := opt.Validate(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
label := new(Label)
|
||||||
|
resp, err := c.getParsedResponse("PATCH", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), jsonHeader, bytes.NewReader(body), label)
|
||||||
|
return label, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLabel delete one label of repository by id
|
||||||
|
func (c *Client) DeleteLabel(owner, repo string, id int64) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/labels/%d", owner, repo, id), nil, nil)
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitServiceType represents a git service
|
||||||
|
type GitServiceType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// GitServicePlain represents a plain git service
|
||||||
|
GitServicePlain GitServiceType = "git"
|
||||||
|
// GitServiceGithub represents github.com
|
||||||
|
GitServiceGithub GitServiceType = "github"
|
||||||
|
// GitServiceGitlab represents a gitlab service
|
||||||
|
GitServiceGitlab GitServiceType = "gitlab"
|
||||||
|
// GitServiceGitea represents a gitea service
|
||||||
|
GitServiceGitea GitServiceType = "gitea"
|
||||||
|
// GitServiceGogs represents a gogs service
|
||||||
|
GitServiceGogs GitServiceType = "gogs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrateRepoOption options for migrating a repository from an external service
|
||||||
|
type MigrateRepoOption struct {
|
||||||
|
RepoName string `json:"repo_name"`
|
||||||
|
RepoOwner string `json:"repo_owner"`
|
||||||
|
// deprecated use RepoOwner
|
||||||
|
RepoOwnerID int64 `json:"uid"`
|
||||||
|
CloneAddr string `json:"clone_addr"`
|
||||||
|
Service GitServiceType `json:"service"`
|
||||||
|
AuthUsername string `json:"auth_username"`
|
||||||
|
AuthPassword string `json:"auth_password"`
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
Mirror bool `json:"mirror"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Wiki bool `json:"wiki"`
|
||||||
|
Milestones bool `json:"milestones"`
|
||||||
|
Labels bool `json:"labels"`
|
||||||
|
Issues bool `json:"issues"`
|
||||||
|
PullRequests bool `json:"pull_requests"`
|
||||||
|
Releases bool `json:"releases"`
|
||||||
|
MirrorInterval string `json:"mirror_interval"`
|
||||||
|
LFS bool `json:"lfs"`
|
||||||
|
LFSEndpoint string `json:"lfs_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the MigrateRepoOption struct
|
||||||
|
func (opt *MigrateRepoOption) Validate(c *Client) error {
|
||||||
|
// check user options
|
||||||
|
if len(opt.CloneAddr) == 0 {
|
||||||
|
return fmt.Errorf("clone addr required")
|
||||||
|
}
|
||||||
|
if len(opt.RepoName) == 0 {
|
||||||
|
return fmt.Errorf("repo name required")
|
||||||
|
} else if len(opt.RepoName) > 100 {
|
||||||
|
return fmt.Errorf("repo name too long")
|
||||||
|
}
|
||||||
|
if len(opt.Description) > 2048 {
|
||||||
|
return fmt.Errorf("description too long")
|
||||||
|
}
|
||||||
|
switch opt.Service {
|
||||||
|
case GitServiceGithub:
|
||||||
|
if len(opt.AuthToken) == 0 {
|
||||||
|
return fmt.Errorf("github requires token authentication")
|
||||||
|
}
|
||||||
|
case GitServiceGitlab, GitServiceGitea:
|
||||||
|
if len(opt.AuthToken) == 0 {
|
||||||
|
return fmt.Errorf("%s requires token authentication", opt.Service)
|
||||||
|
}
|
||||||
|
// Gitlab is supported since 1.12.0 but api cant handle it until 1.13.0
|
||||||
|
// https://github.com/go-gitea/gitea/pull/12672
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_13_0) != nil {
|
||||||
|
return fmt.Errorf("migrate from service %s need gitea >= 1.13.0", opt.Service)
|
||||||
|
}
|
||||||
|
case GitServiceGogs:
|
||||||
|
if len(opt.AuthToken) == 0 {
|
||||||
|
return fmt.Errorf("gogs requires token authentication")
|
||||||
|
}
|
||||||
|
if c.checkServerVersionGreaterThanOrEqual(version1_14_0) != nil {
|
||||||
|
return fmt.Errorf("migrate from service gogs need gitea >= 1.14.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateRepo migrates a repository from other Git hosting sources for the authenticated user.
|
||||||
|
//
|
||||||
|
// To migrate a repository for a organization, the authenticated user must be a
|
||||||
|
// owner of the specified organization.
|
||||||
|
func (c *Client) MigrateRepo(opt MigrateRepoOption) (*Repository, *Response, error) {
|
||||||
|
if err := opt.Validate(c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.checkServerVersionGreaterThanOrEqual(version1_13_0); err != nil {
|
||||||
|
if len(opt.AuthToken) != 0 {
|
||||||
|
// gitea <= 1.12 dont understand AuthToken
|
||||||
|
opt.AuthUsername = opt.AuthToken
|
||||||
|
opt.AuthPassword, opt.AuthToken = "", ""
|
||||||
|
}
|
||||||
|
if len(opt.RepoOwner) != 0 {
|
||||||
|
// gitea <= 1.12 dont understand RepoOwner
|
||||||
|
u, _, err := c.GetUserInfo(opt.RepoOwner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.RepoOwnerID = u.ID
|
||||||
|
} else if opt.RepoOwnerID == 0 {
|
||||||
|
// gitea <= 1.12 require RepoOwnerID
|
||||||
|
u, _, err := c.GetMyUserInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.RepoOwnerID = u.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
repo := new(Repository)
|
||||||
|
resp, err := c.getParsedResponse("POST", "/repos/migrate", jsonHeader, bytes.NewReader(body), repo)
|
||||||
|
return repo, resp, err
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreatePushMirrorOption struct {
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
RemoteAddress string `json:"remote_address"`
|
||||||
|
RemotePassword string `json:"remote_password"`
|
||||||
|
RemoteUsername string `json:"remote_username"`
|
||||||
|
SyncONCommit bool `json:"sync_on_commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMirrorResponse returns a git push mirror
|
||||||
|
type PushMirrorResponse struct {
|
||||||
|
Created string `json:"created"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
LastUpdate string `json:"last_update"`
|
||||||
|
RemoteAddress string `json:"remote_address"`
|
||||||
|
RemoteName string `json:"remote_name"`
|
||||||
|
RepoName string `json:"repo_name"`
|
||||||
|
SyncONCommit bool `json:"sync_on_commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMirrors add a push mirror to the repository
|
||||||
|
func (c *Client) PushMirrors(user, repo string, opt CreatePushMirrorOption) (*PushMirrorResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pm := new(PushMirrorResponse)
|
||||||
|
resp, err := c.getParsedResponse("POST", fmt.Sprintf("/repos/%s/%s/push_mirrors", user, repo), jsonHeader, bytes.NewReader(body), &pm)
|
||||||
|
return pm, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPushMirrors gets all push mirrors of a repository
|
||||||
|
func (c *Client) ListPushMirrors(user, repo string, opt ListOptions) ([]*PushMirrorResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
pms := make([]*PushMirrorResponse, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/push_mirrors?%s", user, repo, opt.getURLQuery().Encode()),
|
||||||
|
nil, nil, &pms)
|
||||||
|
return pms, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPushMirrorByRemoteName get a push mirror of the repository by remote name
|
||||||
|
func (c *Client) GetPushMirrorByRemoteName(user, repo, remoteName string) (*PushMirrorResponse, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &remoteName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
pm := new(PushMirrorResponse)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/push_mirrors/%s", user, repo, remoteName), nil, nil, &pm)
|
||||||
|
return pm, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePushMirror deletes a push mirror from a repository by remote name
|
||||||
|
func (c *Client) DeletePushMirror(user, repo, remoteName string) (*Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo, &remoteName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.doRequestWithStatusHandle("DELETE", fmt.Sprintf("/repos/%s/%s/push_mirrors/%s", user, repo, remoteName), nil, nil)
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRepoActivityFeedsOptions options for listing repository activity feeds
|
||||||
|
type ListRepoActivityFeedsOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Date string `json:"date"` // the date of the activities to be found (format: YYYY-MM-DD)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepoActivityFeeds lists activity feeds for a repository
|
||||||
|
func (c *Client) ListRepoActivityFeeds(owner, repo string, opt ListRepoActivityFeedsOptions) ([]*Activity, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse(fmt.Sprintf("/repos/%s/%s/activities/feeds", owner, repo))
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Date != "" {
|
||||||
|
query.Add("date", opt.Date)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
feeds := make([]*Activity, 0, opt.PageSize)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &feeds)
|
||||||
|
return feeds, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueConfigValidation represents the validation result for issue config
|
||||||
|
type IssueConfigValidation struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateIssueConfig validates the issue config file for a repository
|
||||||
|
func (c *Client) ValidateIssueConfig(owner, repo string) (*IssueConfigValidation, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
result := new(IssueConfigValidation)
|
||||||
|
resp, err := c.getParsedResponse("GET",
|
||||||
|
fmt.Sprintf("/repos/%s/%s/issue_config/validate", owner, repo),
|
||||||
|
jsonHeader, nil, &result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicSearchOptions options for searching topics
|
||||||
|
type TopicSearchOptions struct {
|
||||||
|
ListOptions
|
||||||
|
Query string `json:"q"` // query string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicSearchResult represents a topic search result
|
||||||
|
type TopicSearchResult struct {
|
||||||
|
Topics []*TopicResponse `json:"topics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopicResponse represents a topic
|
||||||
|
type TopicResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"topic_name"`
|
||||||
|
RepoCount int `json:"repo_count"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTopics searches for topics
|
||||||
|
func (c *Client) SearchTopics(opt TopicSearchOptions) (*TopicSearchResult, *Response, error) {
|
||||||
|
opt.setDefaults()
|
||||||
|
|
||||||
|
link, _ := url.Parse("/topics/search")
|
||||||
|
query := opt.getURLQuery()
|
||||||
|
if opt.Query != "" {
|
||||||
|
query.Add("q", opt.Query)
|
||||||
|
}
|
||||||
|
link.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
result := new(TopicSearchResult)
|
||||||
|
resp, err := c.getParsedResponse("GET", link.String(), jsonHeader, nil, &result)
|
||||||
|
return result, resp, err
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reference represents a Git reference.
|
||||||
|
type Reference struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Object *GitObject `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitObject represents a Git object.
|
||||||
|
type GitObject struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoRef gets one exact ref from a repository.
|
||||||
|
//
|
||||||
|
// The underlying API returns a filtered list for /git/refs/{ref}, so this
|
||||||
|
// method resolves the exact ref from that list. It may return HTTP errors from
|
||||||
|
// the underlying API call, or an error when the server response only contains
|
||||||
|
// partial matches.
|
||||||
|
func (c *Client) GetRepoRef(user, repo, ref string) (*Reference, *Response, error) {
|
||||||
|
refs, resp, err := c.GetRepoRefs(user, repo, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedRef := "refs/" + strings.TrimPrefix(ref, "refs/")
|
||||||
|
for _, repoRef := range refs {
|
||||||
|
if repoRef == nil || repoRef.Ref != normalizedRef {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return repoRef, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, resp, errors.New("no exact match found for this ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoRefs gets the refs from a repository that match a partial or full ref.
|
||||||
|
func (c *Client) GetRepoRefs(user, repo, ref string) ([]*Reference, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&user, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ref = strings.TrimPrefix(ref, "refs/")
|
||||||
|
ref = pathEscapeSegments(ref)
|
||||||
|
|
||||||
|
data, resp, err := c.getResponse("GET", fmt.Sprintf("/repos/%s/%s/git/refs/%s", user, repo, ref), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to unmarshal single returned ref.
|
||||||
|
r := new(Reference)
|
||||||
|
refErr := json.Unmarshal(data, r)
|
||||||
|
if refErr == nil {
|
||||||
|
return []*Reference{r}, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to unmarshal multiple refs.
|
||||||
|
var rs []*Reference
|
||||||
|
refsErr := json.Unmarshal(data, &rs)
|
||||||
|
if refsErr == nil {
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return nil, resp, errors.New("unexpected response: an array of refs with length 0")
|
||||||
|
}
|
||||||
|
return rs, resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, resp, fmt.Errorf("unmarshalling failed for both single and multiple refs: %s and %s", refErr, refsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllGitRefs gets all refs from a repository without filtering.
|
||||||
|
func (c *Client) ListAllGitRefs(owner, repo string) ([]*Reference, *Response, error) {
|
||||||
|
if err := escapeValidatePathSegments(&owner, &repo); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
refs := make([]*Reference, 0, 10)
|
||||||
|
resp, err := c.getParsedResponse("GET", fmt.Sprintf("/repos/%s/%s/git/refs", owner, repo), nil, nil, &refs)
|
||||||
|
return refs, resp, err
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user