feat: reach the rest of Gitea with kettle api, and drop tea
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,11 @@ package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
|
||||
| `client.go` | `New`, `For`, `Do` and the endpoint rule, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail` |
|
||||
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, `ListIssues` and its budget, dependencies |
|
||||
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
|
||||
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
|
||||
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate, `Do` and what it refuses |
|
||||
| `remotemap_test.go` | load, merge, save |
|
||||
|
||||
## What this package is, now that the SDK exists
|
||||
@@ -102,6 +102,46 @@ somebody types when they want to see what was in it. `ListLabels` is read from t
|
||||
repository and never from a cache — a cache answers "what did we create last time",
|
||||
and the question is "what does this repository have right now".
|
||||
|
||||
## `Do` — the requests the SDK cannot express
|
||||
|
||||
`Do(method, path, body, name)` sends one request and returns the status and the
|
||||
body exactly as they came back. It was here before it was general: the dependency
|
||||
endpoint takes a body the SDK's own `IssueMeta` cannot spell, so a hand-rolled
|
||||
request already existed and `post` is now three lines on top of this one.
|
||||
|
||||
Exporting it is what lets [`kettle api`](../cmd/AGENTS.md) reach a release, a pull
|
||||
request or a webhook **without this package growing a method per entity** and
|
||||
without a second client holding the credentials all over again. It goes through
|
||||
the same `http.Client`, so it gets the same three services as everything else: the
|
||||
body is filed by the same `RoundTripper`, the same `token …` header authenticates
|
||||
it, and a non-2xx is the same `*APIError`.
|
||||
|
||||
Three things it deliberately does not do, and each of them is a way of not lying:
|
||||
|
||||
- **it does not paginate** — one call is one HTTP request, and `?page=`/`?limit=`
|
||||
are the caller's. The pagination below exists for a listing with a budget to
|
||||
spend; a passthrough that stitched pages together would report as one answer
|
||||
something that was several;
|
||||
- **it does not parse or reformat the answer** — bytes in, bytes out;
|
||||
- **it does not know what an issue is** — nothing about it names a domain concept,
|
||||
so `TestTransportDoesNotImportTheDomain` needed no change. A generic transport is
|
||||
no more a domain than a specific one was.
|
||||
|
||||
**The endpoint rule** is `tea api`'s, so a table of endpoints written for that tool
|
||||
still works: a bare path is under `/api/v1/`, a path starting `/api/` is taken as
|
||||
it stands (that is how anything outside v1 is reached), and a full URL is a full
|
||||
URL — **but only on this instance**. That last clause is the one place this is
|
||||
stricter than the tool it replaces, and it is not fussiness: every request carries
|
||||
the project's token in a header, so a URL on another host would hand the token to
|
||||
whatever was typed. Another instance is `KETTLE_URL`.
|
||||
|
||||
**A 403 answers with what to do about it.** Gitea scopes a token as
|
||||
`<read|write>:<category>` and names no scope in the refusal, so `APIError.Error`
|
||||
appends the one sentence that helps — everything outside issues is `repository`,
|
||||
and `kettle auth list` shows what each login recorded. It does not guess a
|
||||
specific scope, because the server did not say and a wrong guess is worse than
|
||||
none.
|
||||
|
||||
## The ledger
|
||||
|
||||
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
|
||||
@@ -142,9 +182,10 @@ blocking panel and refuse to close a blocked issue first.
|
||||
- **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 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 URL names the blocked issue and
|
||||
the body the blocker, which is the direction `Dependencies` reads back.
|
||||
So it goes out through `Do` — same HTTP client, same payload dump, same
|
||||
`*APIError`. This was the request `Do` was written for, back when it was the only
|
||||
one. The URL names the blocked issue and the body the blocker, which is the
|
||||
direction `Dependencies` reads back.
|
||||
- **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 older instance is
|
||||
answered from the version it already gave us rather than from a 404 — which on an
|
||||
|
||||
+116
-22
@@ -69,8 +69,9 @@ const (
|
||||
type Client struct {
|
||||
// api is the SDK client: one per run, shared by every copy For makes.
|
||||
api *sdk.Client
|
||||
// http is the SDK's transport, kept because AddDependency still sends one
|
||||
// request by hand — see there.
|
||||
// http is the SDK's transport, kept because Do sends requests by hand — the
|
||||
// dependency endpoint the SDK cannot spell, and every endpoint this package
|
||||
// has no method for.
|
||||
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.
|
||||
@@ -186,9 +187,31 @@ func (e *APIError) Error() string {
|
||||
if status != "" {
|
||||
status = " " + status
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
out := fmt.Sprintf("%s %s: %d%s: %s", e.Method, e.URL, e.Status, status, body)
|
||||
if e.Status == http.StatusForbidden {
|
||||
out += "\n" + scopeHint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeHint is what a 403 gets said after it.
|
||||
//
|
||||
// Gitea scopes a token as <read|write>:<category>, and a token minted to file
|
||||
// issues carries `write:issue` and nothing more — which is exactly right until
|
||||
// the first request outside issues, where releases, pull requests, branches and
|
||||
// tags all live under `repository` and the answer is a 403 that names no scope
|
||||
// at all. The server will not say which one is missing, so this does not guess
|
||||
// one; it names the two commands that show what was recorded and let it be
|
||||
// re-recorded.
|
||||
//
|
||||
// Blanket rather than per-call, because the transport does not know which
|
||||
// category an arbitrary endpoint belongs to — and a 403 on a request that had
|
||||
// the scope is a permissions problem on the repository, which this sentence does
|
||||
// not contradict.
|
||||
const scopeHint = "a 403 is usually the token's scopes rather than the request: Gitea scopes a token as " +
|
||||
"<read|write>:<category>, and everything outside issues (releases, pull requests, branches, tags, actions) " +
|
||||
"is `repository`. `kettle auth list` shows what each login on this machine records."
|
||||
|
||||
// StatusIs reports whether err is an API answer with this status code, for the
|
||||
// handful of places where one code means something specific — a 409 from a
|
||||
// dependency link that is already there, say.
|
||||
@@ -404,30 +427,58 @@ func safeName(name string) string {
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// the one request the SDK cannot express
|
||||
// the requests the SDK cannot express
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// post sends one JSON body to a path under this instance's API and ignores
|
||||
// whatever comes back.
|
||||
// Do sends one request to a path under this instance's API and returns the
|
||||
// status and the body exactly as they came 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)
|
||||
// It is the escape hatch, and it was here before it was one: the dependency
|
||||
// endpoint needed a body the SDK's own type cannot spell (see AddDependency),
|
||||
// so a hand-rolled request already existed. What has changed is that it is
|
||||
// exported, which is what lets `kettle api` reach a release, a pull request or a
|
||||
// webhook without this package growing a method per entity — and without a
|
||||
// second client that would hold the credentials all over again.
|
||||
//
|
||||
// It goes through the same http.Client as everything else, which is the whole
|
||||
// point: the same dump-RoundTripper files the body under `.kettle/payload/`, the
|
||||
// same `token …` header authenticates it, and a non-2xx comes back as the same
|
||||
// *APIError carrying the status AND what the server said.
|
||||
//
|
||||
// THREE THINGS IT DELIBERATELY DOES NOT DO:
|
||||
//
|
||||
// - IT DOES NOT PAGINATE. One call is one HTTP request. The pagination in this
|
||||
// package exists for a listing with a budget to spend, and a passthrough that
|
||||
// quietly stitched pages together would report as one answer something that
|
||||
// was several — `?page=` and `?limit=` are the caller's to spell.
|
||||
// - IT DOES NOT PARSE OR REFORMAT THE ANSWER. Bytes in, bytes out. Whoever
|
||||
// asked knows what they asked for; re-indenting it here would only be a
|
||||
// second opinion about somebody else's JSON.
|
||||
// - IT DOES NOT KNOW WHAT AN ISSUE IS. Nothing about it names a domain concept,
|
||||
// so the layering rule holds unchanged — this is still transport, and a
|
||||
// generic one is no more a domain than a specific one was.
|
||||
//
|
||||
// A nil body sends no body at all, which is what a GET and a DELETE want; the
|
||||
// Content-Type goes on only when there is something to type.
|
||||
func (c *Client) Do(method, path string, body []byte, name string) (int, []byte, error) {
|
||||
endpoint, err := c.endpoint(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, nil, err
|
||||
}
|
||||
endpoint := c.base + "/api/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, nil, 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")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
@@ -436,14 +487,57 @@ func (c *Client) post(path string, body any, name string) error {
|
||||
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
|
||||
return 0, nil, 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)}
|
||||
answer, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("%s %s: %d answered with a body that could not be read: %w",
|
||||
method, endpoint, resp.StatusCode, err)
|
||||
}
|
||||
return nil
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return resp.StatusCode, answer, &APIError{
|
||||
Method: method, URL: endpoint, Status: resp.StatusCode, Body: string(answer)}
|
||||
}
|
||||
return resp.StatusCode, answer, nil
|
||||
}
|
||||
|
||||
// endpoint resolves what a caller spelled into an absolute URL on this instance.
|
||||
//
|
||||
// The three spellings are the ones `tea api` accepted, so a table of endpoints
|
||||
// written for that tool still works here: a bare path is under `/api/v1/`, a
|
||||
// path already starting `/api/` is taken as it stands (that is how anything
|
||||
// outside v1 is reached), and a full URL is a full URL.
|
||||
//
|
||||
// A FULL URL MUST BE ON THIS INSTANCE, and that is the one place this is
|
||||
// stricter than the tool it replaces. Every request made here carries the
|
||||
// project's token in a header; a URL pointing somewhere else would hand that
|
||||
// token to whatever host was named, which is a credential leak spelled as a
|
||||
// convenience. Reaching another instance is what KETTLE_URL is for.
|
||||
func (c *Client) endpoint(path string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "http://"), strings.HasPrefix(path, "https://"):
|
||||
if path != c.base && !strings.HasPrefix(path, c.base+"/") {
|
||||
return "", fmt.Errorf("%s is not on %s — this token belongs to that instance and is sent nowhere else"+
|
||||
" (point %s at the other one instead)", path, c.base, config.EnvURL)
|
||||
}
|
||||
return path, nil
|
||||
case strings.HasPrefix(path, "/api/"):
|
||||
return c.base + path, nil
|
||||
default:
|
||||
return c.base + "/api/v1/" + strings.TrimLeft(path, "/"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// post sends one JSON body to a path under this instance's API and ignores
|
||||
// whatever comes back. AddDependency is what it is for.
|
||||
func (c *Client) post(path string, body any, name string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = c.Do(http.MethodPost, path, raw, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -487,6 +487,147 @@ func TestDependenciesAreAskedForOnAnInstanceThatHasThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The generic request: bytes out, bytes back, and the same three services every
|
||||
// other call in this package gets — the header, the scratchpad, the *APIError.
|
||||
func TestDoAnswersWithWhatTheServerSent(t *testing.T) {
|
||||
root := newProject(t)
|
||||
|
||||
var got struct{ method, uri, auth, ctype string }
|
||||
var sent []byte
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
got.method, got.uri = r.Method, r.URL.RequestURI()
|
||||
got.auth, got.ctype = r.Header.Get("Authorization"), r.Header.Get("Content-Type")
|
||||
sent, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"tag_name":"v0.2.0"}`)
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
// A read: no body out, and nothing filed — the scratchpad holds what was
|
||||
// SENT, and a run that sent nothing leaves no directory behind.
|
||||
code, answer, err := c.Do(http.MethodGet, "repos/acme/widgets/releases?limit=50", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if code != http.StatusCreated || string(answer) != `{"tag_name":"v0.2.0"}` {
|
||||
t.Errorf("got %d %q, want 201 and the server's bytes", code, answer)
|
||||
}
|
||||
if got.uri != "/api/v1/repos/acme/widgets/releases?limit=50" {
|
||||
t.Errorf("the endpoint was rewritten: %s", got.uri)
|
||||
}
|
||||
if got.auth != "token s3cret" {
|
||||
t.Errorf("Authorization was %q, want %q", got.auth, "token s3cret")
|
||||
}
|
||||
if got.ctype != "" {
|
||||
t.Errorf("a request with no body carried Content-Type %q", got.ctype)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".kettle", "payload")); !os.IsNotExist(err) {
|
||||
t.Errorf("a read created the payload directory (%v)", err)
|
||||
}
|
||||
|
||||
// A write: the body goes out verbatim and is filed under the name it was
|
||||
// given, by the same RoundTripper that files every other request.
|
||||
body := []byte(`{"tag_name":"v0.2.0","body":"a & b"}`)
|
||||
if _, _, err := c.Do(http.MethodPost, "/api/v1/repos/acme/widgets/releases", body, "release-v0-2-0"); err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
if got.method != http.MethodPost || got.ctype != "application/json" {
|
||||
t.Errorf("the write went out as %s %q", got.method, got.ctype)
|
||||
}
|
||||
if string(sent) != string(body) {
|
||||
t.Errorf("the server got %s, want %s — a passthrough reformatted the body", sent, body)
|
||||
}
|
||||
filed, err := os.ReadFile(filepath.Join(root, ".kettle", "payload", "release-v0-2-0.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the body was not filed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(filed), `"tag_name": "v0.2.0"`) {
|
||||
t.Errorf("the dump is not the body that was sent:\n%s", filed)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal comes back as this package's error, with the status and the
|
||||
// server's own words — and the status and body are returned as well, so a
|
||||
// caller that would rather print them than wrap them can.
|
||||
func TestDoReportsAStatusAndTheServersWords(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
io.WriteString(w, `{"message":"release does not exist"}`)
|
||||
})
|
||||
|
||||
code, answer, err := newClient(t, srv.URL).Do(http.MethodGet, "repos/acme/widgets/releases/9", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a 404 came back as success")
|
||||
}
|
||||
var apiErr *gitea.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is %T, want *gitea.APIError: %v", err, err)
|
||||
}
|
||||
if code != http.StatusNotFound || !strings.Contains(string(answer), "release does not exist") {
|
||||
t.Errorf("got %d %q; the status and the body are the caller's too", code, answer)
|
||||
}
|
||||
for _, want := range []string{"404", "release does not exist", "GET", "/api/v1/repos/acme/widgets/releases/9"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not mention %q:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A 403 is answered with what to do about it, because Gitea's own 403 names no
|
||||
// scope and a token minted for issues is the usual reason.
|
||||
func TestAForbiddenAnswerNamesTheScopeItMightBe(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, `{"message":"token does not have at least one of required scope(s)"}`)
|
||||
})
|
||||
|
||||
_, _, err := newClient(t, srv.URL).Do(http.MethodPost, "repos/acme/widgets/releases", []byte(`{}`), "")
|
||||
if err == nil {
|
||||
t.Fatal("a 403 came back as success")
|
||||
}
|
||||
for _, want := range []string{"403", "kettle auth list", "repository"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("a 403 does not say %q — the server named no scope, so this has to:\n%s", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The token is this instance's. A full URL somewhere else is refused before a
|
||||
// socket is opened, because sending it would hand the credential to whatever
|
||||
// host was typed.
|
||||
func TestDoRefusesAURLOnAnotherHost(t *testing.T) {
|
||||
newProject(t)
|
||||
|
||||
asked := 0
|
||||
srv := serve(t, modernGitea, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked++
|
||||
writeJSON(t, w, map[string]any{})
|
||||
})
|
||||
c := newClient(t, srv.URL)
|
||||
|
||||
_, _, err := c.Do(http.MethodGet, "https://gitea.example.invalid/api/v1/user", nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("a request to another host was allowed — that sends this project's token to it")
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Errorf("the refusal quotes the token:\n%s", err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("%d request(s) went out for an endpoint that was refused", asked)
|
||||
}
|
||||
// A full URL on the instance itself is the same request as the bare path.
|
||||
if _, _, err := c.Do(http.MethodGet, srv.URL+"/api/v1/user", nil, ""); err != nil {
|
||||
t.Errorf("a full URL on this instance was refused: %v", err)
|
||||
}
|
||||
if asked != 1 {
|
||||
t.Errorf("%d request(s) went out, want 1", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// A half-filled configuration is refused here rather than at the first 401,
|
||||
// because a 401 names nothing an operator can act on — and before the client is
|
||||
// built at all, because building one dials.
|
||||
|
||||
Reference in New Issue
Block a user