mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-06 07:17:39 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f34697c5ed | |||
| a613a344de | |||
| 6435b12202 | |||
| 61b8536e4a | |||
| 73b6bf3e23 | |||
| 993eb37b57 | |||
| cd93d8561b | |||
| d664c01e18 | |||
| 2d6dcd062f | |||
| 7f0213940d | |||
| 3b5703177d | |||
| 2a9c8ff6fd | |||
| 12947f068a | |||
| d4545d8ed7 | |||
| 885381e3e4 | |||
| 6a57af24ad |
@@ -8,16 +8,28 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: git fetch --force --tags
|
- run: git fetch --force --tags
|
||||||
- uses: actions/setup-go@v6
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded
|
||||||
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
|
- name: check R2 configuration
|
||||||
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
|
env:
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- name: import gpg
|
- name: import gpg
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||||
@@ -25,10 +37,10 @@ jobs:
|
|||||||
id: sdk_version
|
id: sdk_version
|
||||||
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
version: "~> v1"
|
version: "~> v2"
|
||||||
args: release --nightly
|
args: release --nightly
|
||||||
env:
|
env:
|
||||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||||
@@ -38,6 +50,10 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
GORELEASER_FORCE_TOKEN: 'gitea'
|
GORELEASER_FORCE_TOKEN: 'gitea'
|
||||||
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||||
@@ -49,24 +65,24 @@ jobs:
|
|||||||
DOCKER_LATEST: nightly
|
DOCKER_LATEST: nightly
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
env:
|
env:
|
||||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -9,16 +9,28 @@ jobs:
|
|||||||
goreleaser:
|
goreleaser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- run: git fetch --force --tags
|
- run: git fetch --force --tags
|
||||||
- uses: actions/setup-go@v6
|
# Custom publishers (the R2 mirror below) run as the very last
|
||||||
|
# step of goreleaser's publish pipeline, after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded
|
||||||
|
# to S3. Fail here instead, before anything is built or
|
||||||
|
# published, if the R2 secrets are missing.
|
||||||
|
- name: check R2 configuration
|
||||||
|
run: sh scripts/upload-r2.sh --check-config
|
||||||
|
env:
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
- name: import gpg
|
- name: import gpg
|
||||||
id: import_gpg
|
id: import_gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v7
|
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||||
@@ -26,10 +38,10 @@ jobs:
|
|||||||
id: sdk_version
|
id: sdk_version
|
||||||
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
|
||||||
- name: goreleaser
|
- name: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser-pro
|
distribution: goreleaser-pro
|
||||||
version: "~> v1"
|
version: "~> v2"
|
||||||
args: release
|
args: release
|
||||||
env:
|
env:
|
||||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||||
@@ -39,6 +51,10 @@ jobs:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
S3_REGION: ${{ secrets.AWS_REGION }}
|
S3_REGION: ${{ secrets.AWS_REGION }}
|
||||||
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
GORELEASER_FORCE_TOKEN: 'gitea'
|
GORELEASER_FORCE_TOKEN: 'gitea'
|
||||||
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
||||||
@@ -50,18 +66,18 @@ jobs:
|
|||||||
DOCKER_LATEST: nightly
|
DOCKER_LATEST: nightly
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
@@ -71,7 +87,7 @@ jobs:
|
|||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||||
env:
|
env:
|
||||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ jobs:
|
|||||||
name: Lint Build And Unit Coverage
|
name: Lint Build And Unit Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
- name: lint and build
|
- name: lint and build
|
||||||
@@ -41,17 +41,28 @@ jobs:
|
|||||||
GITEA_TEA_TEST_USERNAME: "test01"
|
GITEA_TEA_TEST_USERNAME: "test01"
|
||||||
GITEA_TEA_TEST_PASSWORD: "test01"
|
GITEA_TEA_TEST_PASSWORD: "test01"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
|
- name: wait for the gitea instance to be ready
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl --noproxy "*" -sf http://gitea:3000/api/v1/version; then
|
||||||
|
echo "gitea is ready after ${i} attempt(s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "waiting for gitea, attempt ${i}/30"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "::error::gitea did not become ready within 60s"
|
||||||
|
exit 1
|
||||||
- name: integration test
|
- name: integration test
|
||||||
run: |
|
run: |
|
||||||
make integration-test
|
make integration-test
|
||||||
services:
|
services:
|
||||||
gitea:
|
gitea:
|
||||||
image: docker.gitea.com/gitea:1.26.2
|
image: docker.gitea.com/gitea:1.27.0
|
||||||
cmd:
|
cmd:
|
||||||
- bash
|
- bash
|
||||||
- -c
|
- -c
|
||||||
|
|||||||
+44
-3
@@ -1,3 +1,5 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
@@ -79,11 +81,50 @@ blobs:
|
|||||||
provider: s3
|
provider: s3
|
||||||
bucket: "{{ .Env.S3_BUCKET }}"
|
bucket: "{{ .Env.S3_BUCKET }}"
|
||||||
region: "{{ .Env.S3_REGION }}"
|
region: "{{ .Env.S3_REGION }}"
|
||||||
folder: "tea/{{.Version}}"
|
directory: "tea/{{.Version}}"
|
||||||
extra_files:
|
extra_files:
|
||||||
- glob: ./**.xz
|
- glob: ./**.xz
|
||||||
- glob: ./**.sha256
|
- glob: ./**.sha256
|
||||||
|
|
||||||
|
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
|
||||||
|
# parallel S3+R2 period (S3 will be removed once migration completes).
|
||||||
|
# A second `blobs:` entry is impossible here since the blob pipe
|
||||||
|
# authenticates from the global AWS_* env with no per-entry
|
||||||
|
# credentials; `publishers:` supports per-entry `env:` instead, so
|
||||||
|
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
|
||||||
|
# publishers inherit almost nothing from the environment, hence the
|
||||||
|
# explicit R2_* forwarding below.
|
||||||
|
#
|
||||||
|
# This publisher fires more than once per distinct key because
|
||||||
|
# goreleaser's release pipe already registers `release.extra_files`
|
||||||
|
# (./**.xz and ./**.xz.sha256, see the `release:` block below) as
|
||||||
|
# UploadableFile artifacts, and `internal/exec`'s filterArtifacts
|
||||||
|
# appends this block's own extra_files with no de-duplication. It
|
||||||
|
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
|
||||||
|
# has no substring-exclusion matcher. It's harmless: PUT is
|
||||||
|
# idempotent, and the `./**.xz` glob below is kept deliberately so
|
||||||
|
# this publisher declares its own complete file set rather than
|
||||||
|
# implicitly depending on the `release:` block's globs.
|
||||||
|
#
|
||||||
|
# checksum: true mirrors goreleaser's generated checksums.txt;
|
||||||
|
# signature: true additionally mirrors checksums.txt.sig, which the
|
||||||
|
# `signs:` block below produces by GPG-signing that checksum file.
|
||||||
|
# Without signature: true, artifacts downloaded from the R2 mirror
|
||||||
|
# would have no signature file to verify against.
|
||||||
|
publishers:
|
||||||
|
- name: cloudflare-r2
|
||||||
|
checksum: true
|
||||||
|
signature: true
|
||||||
|
extra_files:
|
||||||
|
- glob: ./**.xz
|
||||||
|
- glob: ./**.sha256
|
||||||
|
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} tea/{{ .Version }}/{{ .ArtifactName }}
|
||||||
|
env:
|
||||||
|
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
|
||||||
|
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
|
||||||
|
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
|
||||||
|
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: binary
|
- format: binary
|
||||||
name_template: "{{ .Binary }}"
|
name_template: "{{ .Binary }}"
|
||||||
@@ -104,10 +145,10 @@ signs:
|
|||||||
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
|
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Branch }}-devel"
|
version_template: "{{ .Branch }}-devel"
|
||||||
|
|
||||||
nightly:
|
nightly:
|
||||||
name_template: "{{ .Branch }}"
|
version_template: "{{ .Branch }}"
|
||||||
|
|
||||||
gitea_urls:
|
gitea_urls:
|
||||||
api: https://gitea.com/api/v1
|
api: https://gitea.com/api/v1
|
||||||
|
|||||||
+11
-12
@@ -7,7 +7,6 @@ import (
|
|||||||
stdctx "context"
|
stdctx "context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
@@ -32,7 +31,13 @@ var CmdCommentsAdd = cli.Command{
|
|||||||
Description: "Add a comment to an issue or pull request.",
|
Description: "Add a comment to an issue or pull request.",
|
||||||
ArgsUsage: "<issue / pr index> [<comment body>]",
|
ArgsUsage: "<issue / pr index> [<comment body>]",
|
||||||
Action: RunCommentsAdd,
|
Action: RunCommentsAdd,
|
||||||
Flags: flags.AllDefaultFlags,
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "description",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "comment body (alternative to the positional argument)",
|
||||||
|
},
|
||||||
|
}, flags.AllDefaultFlags...),
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunCommentsAdd creates a new comment.
|
// RunCommentsAdd creates a new comment.
|
||||||
@@ -54,18 +59,12 @@ func RunCommentsAdd(requestCtx stdctx.Context, cmd *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
body := strings.Join(ctx.Args().Tail(), " ")
|
stdinPiped := interact.IsStdinPiped()
|
||||||
// Only consume stdin if no positional body was given. interact.IsStdinPiped()
|
body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader)
|
||||||
// is true for any non-TTY stdin (CI, subshells, agent harnesses) — not just
|
if err != nil {
|
||||||
// piped data — so reading unconditionally would block forever in those
|
|
||||||
// contexts when the body is supplied via args.
|
|
||||||
if len(body) == 0 && interact.IsStdinPiped() {
|
|
||||||
if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil {
|
|
||||||
return err
|
return err
|
||||||
} else if len(bodyStdin) != 0 {
|
|
||||||
body = string(bodyStdin)
|
|
||||||
}
|
}
|
||||||
} else if len(body) == 0 {
|
if len(body) == 0 && !stdinPiped {
|
||||||
if err := huh.NewForm(
|
if err := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewText().
|
huh.NewText().
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package comments
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
// resolveBody picks the comment body from the non-interactive sources, in
|
||||||
|
// precedence order:
|
||||||
|
//
|
||||||
|
// 1. the positional argument (kept first for back-compat with the historical
|
||||||
|
// 'tea comment <idx> "<body>"' shorthand),
|
||||||
|
// 2. the -d/--description flag (mirrors the body flag on 'issue create',
|
||||||
|
// 'issue edit' and 'pr create'),
|
||||||
|
// 3. piped stdin.
|
||||||
|
//
|
||||||
|
// stdin is only read when stdinPiped is true (a non-TTY stdin, e.g. CI,
|
||||||
|
// subshells or agent harnesses) and no body was supplied otherwise, so the
|
||||||
|
// command never blocks reading an interactive terminal when a body is already
|
||||||
|
// given. An empty result means the caller should fall back to the editor (when
|
||||||
|
// interactive) or error out.
|
||||||
|
func resolveBody(positional, description string, stdinPiped bool, stdin io.Reader) (string, error) {
|
||||||
|
if len(positional) != 0 {
|
||||||
|
return positional, nil
|
||||||
|
}
|
||||||
|
if len(description) != 0 {
|
||||||
|
return description, nil
|
||||||
|
}
|
||||||
|
if stdinPiped {
|
||||||
|
stdinBytes, err := io.ReadAll(stdin)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(stdinBytes), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package comments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveBody(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
positional string
|
||||||
|
description string
|
||||||
|
stdinPiped bool
|
||||||
|
stdin string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "positional only",
|
||||||
|
positional: "from positional",
|
||||||
|
expected: "from positional",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description flag only",
|
||||||
|
description: "from -d",
|
||||||
|
expected: "from -d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "positional wins over description for back-compat",
|
||||||
|
positional: "from positional",
|
||||||
|
description: "from -d",
|
||||||
|
expected: "from positional",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description wins over piped stdin",
|
||||||
|
description: "from -d",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
expected: "from -d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "piped stdin used when nothing else given",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
expected: "from stdin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stdin ignored when not piped (interactive terminal)",
|
||||||
|
stdinPiped: false,
|
||||||
|
stdin: "should never be read",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty when no source provided",
|
||||||
|
stdinPiped: false,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "piped but empty stdin yields empty body",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body, err := resolveBody(tc.positional, tc.description, tc.stdinPiped, strings.NewReader(tc.stdin))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.expected, body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveBodyDoesNotReadStdinWhenBodyGiven guards the original bug: when a
|
||||||
|
// body is supplied positionally (or via -d), stdin must not be consumed, so the
|
||||||
|
// command can never block on a non-TTY stdin under CI / agent harnesses.
|
||||||
|
func TestResolveBodyDoesNotReadStdinWhenBodyGiven(t *testing.T) {
|
||||||
|
reader := &trackingReader{}
|
||||||
|
|
||||||
|
body, err := resolveBody("positional body", "", true, reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "positional body", body)
|
||||||
|
assert.False(t, reader.read, "stdin must not be read when a body is supplied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackingReader records whether Read was ever called.
|
||||||
|
type trackingReader struct {
|
||||||
|
read bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *trackingReader) Read(p []byte) (int, error) {
|
||||||
|
r.read = true
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
+12
-9
@@ -7,7 +7,6 @@ import (
|
|||||||
stdctx "context"
|
stdctx "context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
@@ -31,10 +30,16 @@ var CmdCommentsEdit = cli.Command{
|
|||||||
Usage: "Edit the body of an existing comment",
|
Usage: "Edit the body of an existing comment",
|
||||||
Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list <issue>' to find IDs.
|
Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list <issue>' to find IDs.
|
||||||
|
|
||||||
The new body can be supplied as a positional argument, piped on stdin, or (if neither is given and stdin is a terminal) entered in your $EDITOR.`,
|
The new body can be supplied as a positional argument, via -d/--description, piped on stdin, or (if none is given and stdin is a terminal) entered in your $EDITOR.`,
|
||||||
ArgsUsage: "<comment id> [<new body>]",
|
ArgsUsage: "<comment id> [<new body>]",
|
||||||
Action: RunCommentsEdit,
|
Action: RunCommentsEdit,
|
||||||
Flags: flags.AllDefaultFlags,
|
Flags: append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "description",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "new comment body (alternative to the positional argument)",
|
||||||
|
},
|
||||||
|
}, flags.AllDefaultFlags...),
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunCommentsEdit updates the body of an existing comment.
|
// RunCommentsEdit updates the body of an existing comment.
|
||||||
@@ -56,14 +61,12 @@ func RunCommentsEdit(requestCtx stdctx.Context, cmd *cli.Command) error {
|
|||||||
return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err)
|
return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := strings.Join(ctx.Args().Tail(), " ")
|
stdinPiped := interact.IsStdinPiped()
|
||||||
if len(body) == 0 && interact.IsStdinPiped() {
|
body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader)
|
||||||
if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if len(bodyStdin) != 0 {
|
|
||||||
body = string(bodyStdin)
|
|
||||||
}
|
}
|
||||||
} else if len(body) == 0 {
|
if len(body) == 0 && !stdinPiped {
|
||||||
// Fetch current body to pre-populate the editor.
|
// Fetch current body to pre-populate the editor.
|
||||||
client := ctx.Login.Client()
|
client := ctx.Login.Client()
|
||||||
current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id)
|
current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id)
|
||||||
|
|||||||
+20
-4
@@ -177,10 +177,18 @@ func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (
|
|||||||
|
|
||||||
// IssuePREditFlags defines flags for editing properties of issues and PRs
|
// IssuePREditFlags defines flags for editing properties of issues and PRs
|
||||||
var IssuePREditFlags = append([]cli.Flag{
|
var IssuePREditFlags = append([]cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "set-assignees",
|
||||||
|
Usage: "Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees",
|
||||||
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "add-assignees",
|
Name: "add-assignees",
|
||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "Comma-separated list of usernames to assign",
|
Usage: "Comma-separated list of usernames to assign. Takes precedence over --remove-assignees",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "remove-assignees",
|
||||||
|
Usage: "Comma-separated list of usernames to remove",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "add-labels",
|
Name: "add-labels",
|
||||||
@@ -224,17 +232,25 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
|
|||||||
opts.Deadline = &t
|
opts.Deadline = &t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ctx.IsSet("set-assignees") {
|
||||||
|
val := ctx.String("set-assignees")
|
||||||
|
opts.SetAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
|
||||||
|
}
|
||||||
if ctx.IsSet("add-assignees") {
|
if ctx.IsSet("add-assignees") {
|
||||||
val := ctx.String("add-assignees")
|
val := ctx.String("add-assignees")
|
||||||
opts.AddAssignees = strings.Split(val, ",")
|
opts.AddAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
|
||||||
|
}
|
||||||
|
if ctx.IsSet("remove-assignees") {
|
||||||
|
val := ctx.String("remove-assignees")
|
||||||
|
opts.RemoveAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
|
||||||
}
|
}
|
||||||
if ctx.IsSet("add-labels") {
|
if ctx.IsSet("add-labels") {
|
||||||
val := ctx.String("add-labels")
|
val := ctx.String("add-labels")
|
||||||
opts.AddLabels = strings.Split(val, ",")
|
opts.AddLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
|
||||||
}
|
}
|
||||||
if ctx.IsSet("remove-labels") {
|
if ctx.IsSet("remove-labels") {
|
||||||
val := ctx.String("remove-labels")
|
val := ctx.String("remove-labels")
|
||||||
opts.RemoveLabels = strings.Split(val, ",")
|
opts.RemoveLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
|
||||||
}
|
}
|
||||||
return &opts, nil
|
return &opts, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package issues
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/tea/modules/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunIssuesListWithSSHPubkeyLoginDoesNotDeadlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sshKeyPath, fingerprint := writeTestSSHKey(t)
|
||||||
|
|
||||||
|
var versionRequests atomic.Int32
|
||||||
|
var issueRequests atomic.Int32
|
||||||
|
var signedVersionRequests atomic.Int32
|
||||||
|
var signedIssueRequests atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/version":
|
||||||
|
versionRequests.Add(1)
|
||||||
|
if r.Header.Get("Signature") != "" {
|
||||||
|
signedVersionRequests.Add(1)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
|
||||||
|
case "/api/v1/repos/gitea/tea/issues":
|
||||||
|
issueRequests.Add(1)
|
||||||
|
if r.Header.Get("Signature") != "" {
|
||||||
|
signedIssueRequests.Add(1)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{
|
||||||
|
Logins: []config.Login{{
|
||||||
|
Name: "ssh-login",
|
||||||
|
URL: server.URL,
|
||||||
|
SSHKey: sshKeyPath,
|
||||||
|
SSHKeyFingerprint: fingerprint,
|
||||||
|
VersionCheck: true,
|
||||||
|
Default: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := cli.Command{
|
||||||
|
Name: CmdIssuesList.Name,
|
||||||
|
Flags: CmdIssuesList.Flags,
|
||||||
|
}
|
||||||
|
require.NoError(t, cmd.Set("login", "ssh-login"))
|
||||||
|
require.NoError(t, cmd.Set("repo", "gitea/tea"))
|
||||||
|
require.NoError(t, cmd.Set("output", "json"))
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- RunIssuesList(stdctx.Background(), &cmd)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("RunIssuesList deadlocked while bootstrapping the server version for HTTPSign authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, versionRequests.Load())
|
||||||
|
assert.EqualValues(t, 0, signedVersionRequests.Load())
|
||||||
|
assert.EqualValues(t, 1, issueRequests.Load())
|
||||||
|
assert.EqualValues(t, 1, signedIssueRequests.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSSHKey(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
||||||
|
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
|
||||||
|
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
|
||||||
|
}
|
||||||
@@ -63,12 +63,12 @@ func listNotifications(requestCtx stdctx.Context, cmd *cli.Command, status []git
|
|||||||
var news []*gitea.NotificationThread
|
var news []*gitea.NotificationThread
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
ctx, err := context.InitCommand(cmd)
|
all := cmd.Bool("mine")
|
||||||
|
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: all})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
client := ctx.Login.Client()
|
client := ctx.Login.Client()
|
||||||
all := ctx.Bool("mine")
|
|
||||||
|
|
||||||
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
|
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
|
||||||
listOpts := flags.GetListOptions(cmd)
|
listOpts := flags.GetListOptions(cmd)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/tea/modules/config"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunNotificationsListMineDoesNotProbeGitRepository(t *testing.T) {
|
||||||
|
gitPath := filepath.Join(t.TempDir(), "git")
|
||||||
|
gitScript := "#!/bin/sh\necho 'git should not be called' >&2\nexit 1\n"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
gitPath += ".bat"
|
||||||
|
gitScript = "@echo git should not be called 1>&2\r\nexit /b 1\r\n"
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(gitPath, []byte(gitScript), 0o755))
|
||||||
|
t.Setenv("PATH", filepath.Dir(gitPath))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/api/v1/notifications", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{
|
||||||
|
Logins: []config.Login{{
|
||||||
|
Name: "default",
|
||||||
|
URL: server.URL,
|
||||||
|
Token: "token",
|
||||||
|
User: "user",
|
||||||
|
Default: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := cli.Command{
|
||||||
|
Name: CmdNotificationsList.Name,
|
||||||
|
Flags: CmdNotificationsList.Flags,
|
||||||
|
}
|
||||||
|
require.NoError(t, cmd.Set("mine", "true"))
|
||||||
|
require.NoError(t, cmd.Set("output", "json"))
|
||||||
|
|
||||||
|
require.NoError(t, RunNotificationsList(stdctx.Background(), &cmd))
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ var CmdNotificationsMarkRead = cli.Command{
|
|||||||
ArgsUsage: "[all | <notification id>]",
|
ArgsUsage: "[all | <notification id>]",
|
||||||
Flags: flags.NotificationFlags,
|
Flags: flags.NotificationFlags,
|
||||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||||
ctx, err := context.InitCommand(cmd)
|
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ var CmdNotificationsMarkUnread = cli.Command{
|
|||||||
ArgsUsage: "[all | <notification id>]",
|
ArgsUsage: "[all | <notification id>]",
|
||||||
Flags: flags.NotificationFlags,
|
Flags: flags.NotificationFlags,
|
||||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||||
ctx, err := context.InitCommand(cmd)
|
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ var CmdNotificationsMarkPinned = cli.Command{
|
|||||||
ArgsUsage: "[all | <notification id>]",
|
ArgsUsage: "[all | <notification id>]",
|
||||||
Flags: flags.NotificationFlags,
|
Flags: flags.NotificationFlags,
|
||||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||||
ctx, err := context.InitCommand(cmd)
|
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ var CmdNotificationsUnpin = cli.Command{
|
|||||||
ArgsUsage: "[all | <notification id>]",
|
ArgsUsage: "[all | <notification id>]",
|
||||||
Flags: flags.NotificationFlags,
|
Flags: flags.NotificationFlags,
|
||||||
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||||
ctx, err := context.InitCommand(cmd)
|
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ var CmdPulls = cli.Command{
|
|||||||
&pulls.CmdPullsApprove,
|
&pulls.CmdPullsApprove,
|
||||||
&pulls.CmdPullsReject,
|
&pulls.CmdPullsReject,
|
||||||
&pulls.CmdPullsMerge,
|
&pulls.CmdPullsMerge,
|
||||||
|
&pulls.CmdPullsReply,
|
||||||
&pulls.CmdPullsReviewComments,
|
&pulls.CmdPullsReviewComments,
|
||||||
&pulls.CmdPullsResolve,
|
&pulls.CmdPullsResolve,
|
||||||
&pulls.CmdPullsUnresolve,
|
&pulls.CmdPullsUnresolve,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pulls
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
|
||||||
|
"gitea.dev/tea/cmd/flags"
|
||||||
|
"gitea.dev/tea/modules/context"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CmdPullsReply replies to a review comment on a pull request.
|
||||||
|
var CmdPullsReply = cli.Command{
|
||||||
|
Name: "reply",
|
||||||
|
Usage: "Reply to a pull request review comment",
|
||||||
|
Description: "Reply to a pull request review comment",
|
||||||
|
ArgsUsage: "<pull index> <comment id> [<reply>]",
|
||||||
|
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
|
||||||
|
ctx, err := context.InitCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runPullReviewReply(requestCtx, ctx)
|
||||||
|
},
|
||||||
|
Flags: flags.AllDefaultFlags,
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pulls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/tea/modules/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReply(t *testing.T) {
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{
|
||||||
|
Logins: []config.Login{{
|
||||||
|
Name: "testLogin",
|
||||||
|
URL: "https://gitea.example.com",
|
||||||
|
Token: "test-token",
|
||||||
|
User: "testUser",
|
||||||
|
Default: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.SetConfigForTesting(config.LocalConfig{})
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
args: []string{},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "pull request index and comment ID are required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing comment id",
|
||||||
|
args: []string{"1"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "pull request index and comment ID are required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pull index and comment id",
|
||||||
|
args: []string{"1", "2"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "no reply content provided",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := CmdPullsReply
|
||||||
|
args := append([]string{"reply"}, tt.args...)
|
||||||
|
args = append(args, "--login", "testLogin", "--repo", "user/repo")
|
||||||
|
err := cmd.Run(context.Background(), args)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,21 @@ package pulls
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
stdctx "context"
|
stdctx "context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
|
|
||||||
|
"gitea.dev/tea/modules/config"
|
||||||
"gitea.dev/tea/modules/context"
|
"gitea.dev/tea/modules/context"
|
||||||
|
"gitea.dev/tea/modules/interact"
|
||||||
"gitea.dev/tea/modules/task"
|
"gitea.dev/tea/modules/task"
|
||||||
|
"gitea.dev/tea/modules/theme"
|
||||||
"gitea.dev/tea/modules/utils"
|
"gitea.dev/tea/modules/utils"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runPullReview handles the common logic for approving/rejecting pull requests
|
// runPullReview handles the common logic for approving/rejecting pull requests
|
||||||
@@ -60,3 +67,62 @@ func runResolveComment(requestCtx stdctx.Context, ctx *context.TeaContext, actio
|
|||||||
|
|
||||||
return action(requestCtx, ctx, commentID)
|
return action(requestCtx, ctx, commentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPullReviewReply handles replying to a specific review comment on a pull request.
|
||||||
|
func runPullReviewReply(requestCtx stdctx.Context, ctx *context.TeaContext) error {
|
||||||
|
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Args().Len() < 2 {
|
||||||
|
return fmt.Errorf("pull request index and comment ID are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
commentID, err := utils.ArgToIndex(ctx.Args().Get(1))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.ReplyToPullReviewComment(requestCtx, ctx, idx, commentID, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) {
|
||||||
|
body := strings.Join(extraArgs, " ")
|
||||||
|
if interact.IsStdinPiped() {
|
||||||
|
bodyStdin, err := io.ReadAll(ctx.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(bodyStdin) != 0 {
|
||||||
|
body = strings.Join([]string{body, string(bodyStdin)}, "\n\n")
|
||||||
|
}
|
||||||
|
} else if len(body) == 0 {
|
||||||
|
if err := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewText().
|
||||||
|
Title(promptTitle).
|
||||||
|
ExternalEditor(config.GetPreferences().Editor).
|
||||||
|
EditorExtension("md").
|
||||||
|
Value(&body),
|
||||||
|
),
|
||||||
|
).WithTheme(theme.GetTheme()).Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strings.TrimSpace(body)) == 0 {
|
||||||
|
return "", errors.New("no " + noun + " content provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|||||||
+28
-2
@@ -239,7 +239,7 @@ Create an issue on repository
|
|||||||
|
|
||||||
Edit one or more issues
|
Edit one or more issues
|
||||||
|
|
||||||
**--add-assignees, -a**="": Comma-separated list of usernames to assign
|
**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees
|
||||||
|
|
||||||
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
|
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
|
||||||
|
|
||||||
@@ -255,10 +255,14 @@ Edit one or more issues
|
|||||||
|
|
||||||
**--remote, -R**="": Discover Gitea login from remote. Optional
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--remove-assignees**="": Comma-separated list of usernames to remove
|
||||||
|
|
||||||
**--remove-labels**="": Comma-separated list of labels to remove
|
**--remove-labels**="": Comma-separated list of labels to remove
|
||||||
|
|
||||||
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees
|
||||||
|
|
||||||
**--title, -t**="":
|
**--title, -t**="":
|
||||||
|
|
||||||
### reopen, open
|
### reopen, open
|
||||||
@@ -423,7 +427,7 @@ Change state of one or more pull requests to 'open'
|
|||||||
|
|
||||||
Edit one or more pull requests
|
Edit one or more pull requests
|
||||||
|
|
||||||
**--add-assignees, -a**="": Comma-separated list of usernames to assign
|
**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees
|
||||||
|
|
||||||
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
|
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
|
||||||
|
|
||||||
@@ -445,12 +449,16 @@ Edit one or more pull requests
|
|||||||
|
|
||||||
**--remote, -R**="": Discover Gitea login from remote. Optional
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--remove-assignees**="": Comma-separated list of usernames to remove
|
||||||
|
|
||||||
**--remove-labels**="": Comma-separated list of labels to remove
|
**--remove-labels**="": Comma-separated list of labels to remove
|
||||||
|
|
||||||
**--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers
|
**--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers
|
||||||
|
|
||||||
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
|
**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees
|
||||||
|
|
||||||
**--title, -t**="":
|
**--title, -t**="":
|
||||||
|
|
||||||
### review
|
### review
|
||||||
@@ -507,6 +515,18 @@ Merge a pull request
|
|||||||
|
|
||||||
**--title, -t**="": Merge commit title
|
**--title, -t**="": Merge commit title
|
||||||
|
|
||||||
|
### reply
|
||||||
|
|
||||||
|
Reply to a pull request review comment
|
||||||
|
|
||||||
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|
||||||
|
**--remote, -R**="": Discover Gitea login from remote. Optional
|
||||||
|
|
||||||
|
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
|
||||||
|
|
||||||
### review-comments, rc
|
### review-comments, rc
|
||||||
|
|
||||||
List review comments on a pull request
|
List review comments on a pull request
|
||||||
@@ -1915,6 +1935,8 @@ Update a webhook
|
|||||||
|
|
||||||
Manage comments on issues and pull requests
|
Manage comments on issues and pull requests
|
||||||
|
|
||||||
|
**--description, -d**="": comment body (alternative to the positional argument)
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
@@ -1927,6 +1949,8 @@ Manage comments on issues and pull requests
|
|||||||
|
|
||||||
Add a comment to an issue or pull request
|
Add a comment to an issue or pull request
|
||||||
|
|
||||||
|
**--description, -d**="": comment body (alternative to the positional argument)
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
@@ -1955,6 +1979,8 @@ List comments on an issue or pull request
|
|||||||
|
|
||||||
Edit the body of an existing comment
|
Edit the body of an existing comment
|
||||||
|
|
||||||
|
**--description, -d**="": new comment body (alternative to the positional argument)
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
|
||||||
|
|||||||
@@ -5,24 +5,24 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
charm.land/glamour/v2 v2.0.1
|
charm.land/glamour/v2 v2.0.1
|
||||||
charm.land/huh/v2 v2.0.3
|
charm.land/huh/v2 v2.0.3
|
||||||
charm.land/lipgloss/v2 v2.0.4
|
charm.land/lipgloss/v2 v2.0.5
|
||||||
code.gitea.io/gitea-vet v0.2.3
|
code.gitea.io/gitea-vet v0.2.3
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
|
||||||
gitea.dev/sdk v1.1.0
|
gitea.dev/sdk v1.2.0
|
||||||
github.com/adrg/xdg v0.5.3
|
github.com/adrg/xdg v0.5.3
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||||
github.com/enescakir/emoji v1.0.0
|
github.com/enescakir/emoji v1.0.0
|
||||||
github.com/go-authgate/sdk-go v0.14.0
|
github.com/go-signet/sdk-go v1.1.0
|
||||||
github.com/muesli/termenv v0.16.0
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0
|
github.com/urfave/cli-docs/v3 v3.1.0
|
||||||
github.com/urfave/cli/v3 v3.10.0
|
github.com/urfave/cli/v3 v3.10.1
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sys v0.46.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/term v0.44.0
|
golang.org/x/term v0.45.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,10 +56,8 @@ require (
|
|||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.6 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
@@ -72,18 +70,16 @@ require (
|
|||||||
github.com/olekukonko/ll v0.1.8 // indirect
|
github.com/olekukonko/ll v0.1.8 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
github.com/zalando/go-keyring v0.2.8 // indirect
|
github.com/zalando/go-keyring v0.2.8 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
retract v1.3.3 // accidental release, tag deleted
|
retract v1.3.3 // accidental release, tag deleted
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
|||||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||||
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
|
||||||
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
|
||||||
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
|
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
|
||||||
gitea.dev/sdk v1.1.0 h1:wLlz03WkLEiXa2bQpO1JQBTlYf7tQI2neYtZK1kU+TE=
|
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
||||||
gitea.dev/sdk v1.1.0/go.mod h1:Zfl+EZXdsGGCLkryDfsmvYrQo6GKMl4U3BJA8Beu+cs=
|
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
||||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
@@ -72,7 +72,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||||
@@ -90,27 +89,20 @@ github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog
|
|||||||
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
|
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
github.com/go-authgate/sdk-go v0.14.0 h1:s1i/UCX2Edf3A1pKDW6oXv+oACQfTroxiGY52eqKx+4=
|
github.com/go-signet/sdk-go v1.1.0 h1:wHKg9P+goQ14A1Q0gtC6m3mCzRFWwL1peAGz/zhmAZQ=
|
||||||
github.com/go-authgate/sdk-go v0.14.0/go.mod h1:sa0ige5wtayj2WcnXlxa8wGuyi5z/c/chc0mXPJTl/Q=
|
github.com/go-signet/sdk-go v1.1.0/go.mod h1:bmi7nDAu7o6MQnUE3K7ZNEKU4xqh3u/SMbPC5GanOR8=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
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/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
@@ -136,15 +128,11 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
|||||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||||
@@ -160,8 +148,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
|
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
|
||||||
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
|
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
|
||||||
github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM=
|
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
|
||||||
github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -174,48 +162,47 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ func NewClient(login *config.Login) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: httputil.WrapTransport(&http.Transport{
|
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
|
|||||||
+3
-10
@@ -201,9 +201,7 @@ func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL
|
|||||||
// createHTTPClient creates an HTTP client with optional insecure setting
|
// createHTTPClient creates an HTTP client with optional insecure setting
|
||||||
func createHTTPClient(insecure bool) *http.Client {
|
func createHTTPClient(insecure bool) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: httputil.WrapTransport(&http.Transport{
|
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,16 +410,11 @@ func createLoginFromToken(ctx context.Context, name, serverURL string, token *oa
|
|||||||
}
|
}
|
||||||
login.SSHHost = parsedURL.Host
|
login.SSHHost = parsedURL.Host
|
||||||
|
|
||||||
// Add login to config
|
// Save tokens and add login to config
|
||||||
if err := config.AddLogin(&login); err != nil {
|
if err := config.AddOAuthLogin(&login, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save tokens to credstore
|
|
||||||
if err := config.SaveOAuthToken(login.Name, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
|
|
||||||
return fmt.Errorf("failed to save token to secure store: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
|
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/adrg/xdg"
|
"github.com/adrg/xdg"
|
||||||
"github.com/go-authgate/sdk-go/credstore"
|
"github.com/go-signet/sdk-go/credstore"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
tokenStore *credstore.SecureStore[credstore.Token]
|
tokenStore *credstore.SecureStore[credstore.Token]
|
||||||
tokenStoreOnce sync.Once
|
tokenStoreOnce sync.Once
|
||||||
|
|
||||||
|
saveOAuthTokenToStore = func(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||||
|
return getTokenStore().Save(loginName, credstore.Token{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ClientID: loginName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
deleteOAuthTokenFromStore = func(loginName string) error {
|
||||||
|
return getTokenStore().Delete(loginName)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func getTokenStore() *credstore.SecureStore[credstore.Token] {
|
func getTokenStore() *credstore.SecureStore[credstore.Token] {
|
||||||
@@ -37,17 +49,12 @@ func LoadOAuthToken(loginName string) (*credstore.Token, error) {
|
|||||||
|
|
||||||
// SaveOAuthToken saves OAuth tokens to the secure store.
|
// SaveOAuthToken saves OAuth tokens to the secure store.
|
||||||
func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
|
func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||||
return getTokenStore().Save(loginName, credstore.Token{
|
return saveOAuthTokenToStore(loginName, accessToken, refreshToken, expiresAt)
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
ClientID: loginName,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteOAuthToken removes tokens from the secure store.
|
// DeleteOAuthToken removes tokens from the secure store.
|
||||||
func DeleteOAuthToken(loginName string) error {
|
func DeleteOAuthToken(loginName string) error {
|
||||||
return getTokenStore().Delete(loginName)
|
return deleteOAuthTokenFromStore(loginName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to
|
// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to
|
||||||
|
|||||||
+41
-7
@@ -269,6 +269,34 @@ func AddLogin(login *Login) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddOAuthLogin saves the OAuth token and login profile as one operation.
|
||||||
|
// The profile is only written after secure token storage succeeds.
|
||||||
|
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
|
||||||
|
return withConfigLock(func() error {
|
||||||
|
// Check for duplicate login names before touching credential storage.
|
||||||
|
for _, existing := range config.Logins {
|
||||||
|
if strings.EqualFold(existing.Name, login.Name) {
|
||||||
|
return fmt.Errorf("login name '%s' already exists", login.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
|
||||||
|
return fmt.Errorf("failed to save token to secure store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Logins = append(config.Logins, *login)
|
||||||
|
if err := saveConfigUnsafe(); err != nil {
|
||||||
|
config.Logins = config.Logins[:len(config.Logins)-1]
|
||||||
|
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
|
||||||
|
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SaveLoginTokens updates the token fields for an existing login.
|
// SaveLoginTokens updates the token fields for an existing login.
|
||||||
// This is used after browser-based re-authentication to save new tokens.
|
// This is used after browser-based re-authentication to save new tokens.
|
||||||
func SaveLoginTokens(login *Login) error {
|
func SaveLoginTokens(login *Login) error {
|
||||||
@@ -390,9 +418,7 @@ func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: httputil.WrapTransport(&http.Transport{
|
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||||
|
|
||||||
@@ -420,15 +446,19 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{}
|
// Configure transport-level timeouts so a stalled or unresponsive server
|
||||||
|
// fails fast instead of hanging forever. These bound connection setup and
|
||||||
|
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
|
||||||
|
// large attachment uploads) are unaffected.
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: httputil.WrapTransport(nil),
|
||||||
|
}
|
||||||
if l.Insecure {
|
if l.Insecure {
|
||||||
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
|
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
|
||||||
|
|
||||||
httpClient = &http.Client{
|
httpClient = &http.Client{
|
||||||
Jar: cookieJar,
|
Jar: cookieJar,
|
||||||
Transport: &http.Transport{
|
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +467,10 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
|
|||||||
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
|
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
|
||||||
|
// transport already sets: this is the SDK's own guarantee, so the UA survives
|
||||||
|
// even if the client is ever given a transport that didn't come from WrapTransport.
|
||||||
|
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
|
||||||
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
|
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
|
||||||
if debug.IsDebug() {
|
if debug.IsDebug() {
|
||||||
options = append(options, gitea.SetDebugMode())
|
options = append(options, gitea.SetDebugMode())
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginClientWithSSHPubkeyDoesNotDeadlockOnFirstRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sshKeyPath, fingerprint := writeTestSSHKey(t)
|
||||||
|
|
||||||
|
var versionRequests atomic.Int32
|
||||||
|
var issueRequests atomic.Int32
|
||||||
|
var signedVersionRequests atomic.Int32
|
||||||
|
var signedIssueRequests atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/version":
|
||||||
|
versionRequests.Add(1)
|
||||||
|
if r.Header.Get("Signature") != "" {
|
||||||
|
signedVersionRequests.Add(1)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
|
||||||
|
case "/api/v1/repos/gitea/tea/issues":
|
||||||
|
issueRequests.Add(1)
|
||||||
|
if r.Header.Get("Signature") != "" {
|
||||||
|
signedIssueRequests.Add(1)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected path %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
login := &Login{
|
||||||
|
Name: "ssh-login",
|
||||||
|
URL: server.URL,
|
||||||
|
SSHKey: sshKeyPath,
|
||||||
|
SSHKeyFingerprint: fingerprint,
|
||||||
|
VersionCheck: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
issues []*gitea.Issue
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
issues, _, err := login.Client().Issues.ListRepoIssues(context.Background(), "gitea", "tea", gitea.ListIssueOption{})
|
||||||
|
done <- result{issues: issues, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-done:
|
||||||
|
require.NoError(t, res.err)
|
||||||
|
assert.Empty(t, res.issues)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("ListRepoIssues deadlocked while bootstrapping the server version for SSH-signed requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, versionRequests.Load())
|
||||||
|
assert.EqualValues(t, 0, signedVersionRequests.Load())
|
||||||
|
assert.EqualValues(t, 1, issueRequests.Load())
|
||||||
|
assert.EqualValues(t, 1, signedIssueRequests.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSSHKey(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
||||||
|
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
|
||||||
|
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
|
||||||
|
}
|
||||||
@@ -37,6 +37,12 @@ type TeaContext struct {
|
|||||||
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
|
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitOptions controls which optional sources InitCommand may inspect.
|
||||||
|
type InitOptions struct {
|
||||||
|
// SkipLocalRepo avoids probing the current directory for a git repository.
|
||||||
|
SkipLocalRepo bool
|
||||||
|
}
|
||||||
|
|
||||||
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
|
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
|
||||||
// after ensuring a remote repo is present in the context.
|
// after ensuring a remote repo is present in the context.
|
||||||
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
|
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
|
||||||
@@ -61,6 +67,12 @@ func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
|
|||||||
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
|
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
|
||||||
// command flags. If a local git repo can't be found, repo slug values are unset.
|
// command flags. If a local git repo can't be found, repo slug values are unset.
|
||||||
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
||||||
|
return InitCommandWithOptions(cmd, InitOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitCommandWithOptions resolves the application context like InitCommand, with
|
||||||
|
// optional controls for commands that do not need repository context.
|
||||||
|
func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, error) {
|
||||||
// these flags are used as overrides to the context detection via local git repo
|
// these flags are used as overrides to the context detection via local git repo
|
||||||
repoFlag := cmd.String("repo")
|
repoFlag := cmd.String("repo")
|
||||||
loginFlag := cmd.String("login")
|
loginFlag := cmd.String("login")
|
||||||
@@ -76,7 +88,7 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// check if repoFlag can be interpreted as path to local repo.
|
// check if repoFlag can be interpreted as path to local repo.
|
||||||
if len(repoFlag) != 0 {
|
if len(repoFlag) != 0 && !opts.SkipLocalRepo {
|
||||||
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
|
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,6 +97,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
} else {
|
} else {
|
||||||
c.RepoSlug = repoFlag
|
c.RepoSlug = repoFlag
|
||||||
}
|
}
|
||||||
|
} else if len(repoFlag) != 0 {
|
||||||
|
c.RepoSlug = repoFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(remoteFlag) == 0 {
|
if len(remoteFlag) == 0 {
|
||||||
@@ -101,6 +115,7 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
extraLogins = append(extraLogins, *envLogin)
|
extraLogins = append(extraLogins, *envLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !opts.SkipLocalRepo {
|
||||||
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
|
||||||
// otherwise attempt PWD. if no repo is found, continue with default login
|
// otherwise attempt PWD. if no repo is found, continue with default login
|
||||||
if repoPath == "" {
|
if repoPath == "" {
|
||||||
@@ -120,6 +135,7 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
|
|||||||
if c.RepoSlug == "" && localSlug != "" {
|
if c.RepoSlug == "" && localSlug != "" {
|
||||||
c.RepoSlug = localSlug
|
c.RepoSlug = localSlug
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If env vars are set, always use the env login (but repo slug was already
|
// If env vars are set, always use the env login (but repo slug was already
|
||||||
// resolved by contextFromLocalRepo with the env login in the match list)
|
// resolved by contextFromLocalRepo with the env login in the match list)
|
||||||
@@ -150,7 +166,7 @@ and then run your command again`)
|
|||||||
if shouldPromptFallbackLogin(c.Login, canPrompt) {
|
if shouldPromptFallbackLogin(c.Login, canPrompt) {
|
||||||
fallback := false
|
fallback := false
|
||||||
if err := huh.NewConfirm().
|
if err := huh.NewConfirm().
|
||||||
Title(fmt.Sprintf("NOTE: no gitea login detected, whether falling back to login '%s'?", c.Login.Name)).
|
Title(fmt.Sprintf("NOTE: no login matched this repository. Fall back to login '%s'?", c.Login.Name)).
|
||||||
Value(&fallback).
|
Value(&fallback).
|
||||||
WithTheme(theme.GetTheme()).
|
WithTheme(theme.GetTheme()).
|
||||||
Run(); err != nil {
|
Run(); err != nil {
|
||||||
@@ -160,7 +176,7 @@ and then run your command again`)
|
|||||||
return nil, ErrCommandCanceled
|
return nil, ErrCommandCanceled
|
||||||
}
|
}
|
||||||
} else if !c.Login.Default {
|
} else if !c.Login.Default {
|
||||||
fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
fmt.Fprintf(os.Stderr, "NOTE: no login matched this repository, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,20 @@ func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *cliRepository) Checkout(ref ReferenceName) error {
|
func (r *cliRepository) Checkout(ref ReferenceName) error {
|
||||||
_, err := r.git(nil, nil, "checkout", ref.String())
|
args := []string{"checkout"}
|
||||||
|
switch {
|
||||||
|
case ref.IsBranch():
|
||||||
|
// `git checkout refs/heads/<branch>` detaches HEAD, while the short branch
|
||||||
|
// name switches to the local branch as intended.
|
||||||
|
args = append(args, ref.Short())
|
||||||
|
case ref.IsRemote():
|
||||||
|
// Be explicit about detached HEAD when checking out a remote-tracking ref.
|
||||||
|
args = append(args, "--detach", ref.String())
|
||||||
|
default:
|
||||||
|
args = append(args, ref.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.git(nil, nil, args...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package httputil
|
package httputil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -20,12 +21,14 @@ func UserAgent() string {
|
|||||||
return ua
|
return ua
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapTransport wraps an http.RoundTripper to add the User-Agent header.
|
// WrapTransport returns tea's standard HTTP transport: an *http.Transport
|
||||||
func WrapTransport(base http.RoundTripper) http.RoundTripper {
|
// preset with tea's connection / response-header timeouts (see timeoutTransport)
|
||||||
if base == nil {
|
// and decorated to add the User-Agent header on every request. The supplied
|
||||||
base = http.DefaultTransport
|
// tlsConfig is attached as-is (nil is fine); callers use it for insecure /
|
||||||
}
|
// skip-verify logins. This is the single entry point for building a tea HTTP
|
||||||
return &userAgentTransport{base: base}
|
// client transport, so the timeouts can't be accidentally omitted.
|
||||||
|
func WrapTransport(tlsConfig *tls.Config) http.RoundTripper {
|
||||||
|
return &userAgentTransport{base: timeoutTransport(tlsConfig)}
|
||||||
}
|
}
|
||||||
|
|
||||||
type userAgentTransport struct {
|
type userAgentTransport struct {
|
||||||
@@ -33,6 +36,11 @@ type userAgentTransport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
// Set the UA at the transport so every client built from WrapTransport
|
||||||
|
// identifies itself, including the non-SDK clients (oauth2 flow, token
|
||||||
|
// refresh) that never pass through the SDK's own SetUserAgent. For SDK
|
||||||
|
// clients this overlaps gitea.SetUserAgent; both use httputil.UserAgent(),
|
||||||
|
// so the duplicate Header.Set is a no-op.
|
||||||
req.Header.Set("User-Agent", UserAgent())
|
req.Header.Set("User-Agent", UserAgent())
|
||||||
return t.base.RoundTrip(req)
|
return t.base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Timeout values applied to every Gitea API request. These are deliberately
|
||||||
|
// connection-establishment and time-to-first-response-byte timeouts, NOT an
|
||||||
|
// overall request deadline: a large release-attachment upload can legitimately
|
||||||
|
// run for minutes, and as long as bytes keep flowing none of these fire. They
|
||||||
|
// only trip when a server accepts the connection but never (or far too slowly)
|
||||||
|
// starts responding — the "hangs forever" case from a stalled or unresponsive
|
||||||
|
// server (issue #1018).
|
||||||
|
const (
|
||||||
|
// DialTimeout bounds establishing the TCP connection.
|
||||||
|
DialTimeout = 10 * time.Second
|
||||||
|
// TLSHandshakeTimeout bounds completing the TLS handshake.
|
||||||
|
TLSHandshakeTimeout = 10 * time.Second
|
||||||
|
// ResponseHeaderTimeout bounds the wait, after the request is written, for
|
||||||
|
// the server to begin sending response headers. This is the only timeout
|
||||||
|
// that protects against a server which accepts the connection but then goes
|
||||||
|
// silent — the originally reported #1018 symptom; DialTimeout/
|
||||||
|
// TLSHandshakeTimeout do not, because the connection already succeeded.
|
||||||
|
//
|
||||||
|
// The value must clear Gitea's legitimate synchronous pre-response work.
|
||||||
|
// Profiling a self-hosted Gitea 1.24.6 (on hardware slower than gitea.com)
|
||||||
|
// showed creating a pull request that triggers conflict detection across
|
||||||
|
// ~1500 changed files takes ~10s before the first byte (3 runs: 10.06 /
|
||||||
|
// 10.08 / 10.24s); clean-diff PR creation was ~1s and large attachment
|
||||||
|
// uploads ~9ms. 120s is ~12x that measured worst case, leaving generous
|
||||||
|
// headroom for larger repos and busier servers while still failing in two
|
||||||
|
// minutes instead of hanging forever.
|
||||||
|
ResponseHeaderTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// timeoutTransport returns an *http.Transport configured with tea's standard
|
||||||
|
// timeouts. The supplied tlsConfig is attached as-is (callers use it for
|
||||||
|
// insecure / skip-verify logins). It is a clone of http.DefaultTransport so
|
||||||
|
// connection pooling, proxy support and HTTP/2 keep working. Callers obtain it
|
||||||
|
// through WrapTransport, which also adds the User-Agent header.
|
||||||
|
func timeoutTransport(tlsConfig *tls.Config) *http.Transport {
|
||||||
|
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
t.DialContext = (&net.Dialer{
|
||||||
|
Timeout: DialTimeout,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext
|
||||||
|
t.TLSHandshakeTimeout = TLSHandshakeTimeout
|
||||||
|
t.ResponseHeaderTimeout = ResponseHeaderTimeout
|
||||||
|
t.TLSClientConfig = tlsConfig
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package httputil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWrapTransportTimeouts verifies the transport returned by WrapTransport
|
||||||
|
// carries tea's standard timeout values, so a stalled server can't make tea
|
||||||
|
// hang forever (issue #1018).
|
||||||
|
func TestWrapTransportTimeouts(t *testing.T) {
|
||||||
|
rt := WrapTransport(nil)
|
||||||
|
uat, ok := rt.(*userAgentTransport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("WrapTransport returned %T, want *userAgentTransport", rt)
|
||||||
|
}
|
||||||
|
tr, ok := uat.base.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("underlying base is %T, want *http.Transport", uat.base)
|
||||||
|
}
|
||||||
|
if tr.TLSHandshakeTimeout != TLSHandshakeTimeout {
|
||||||
|
t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, TLSHandshakeTimeout)
|
||||||
|
}
|
||||||
|
if tr.ResponseHeaderTimeout != ResponseHeaderTimeout {
|
||||||
|
t.Errorf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, ResponseHeaderTimeout)
|
||||||
|
}
|
||||||
|
if tr.DialContext == nil {
|
||||||
|
t.Error("DialContext is nil, want a dialer with DialTimeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newStallListener returns a listener that accepts connections, reads the
|
||||||
|
// request, then goes silent without ever sending response headers — the
|
||||||
|
// "server accepts the connection but never responds" case ResponseHeaderTimeout
|
||||||
|
// guards against. The returned closer stops the listener.
|
||||||
|
func newStallListener(t *testing.T) (addr string, closer func()) {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func(c net.Conn) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
_, _ = c.Read(buf) // drain the request, then never respond
|
||||||
|
<-done // hold the connection open until the test ends
|
||||||
|
c.Close()
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ln.Addr().String(), func() {
|
||||||
|
close(done)
|
||||||
|
ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponseHeaderTimeoutFires proves a request to a server that accepts the
|
||||||
|
// connection and request but never sends response headers aborts via
|
||||||
|
// ResponseHeaderTimeout rather than hanging. It builds the transport the same
|
||||||
|
// way WrapTransport does, with a short ResponseHeaderTimeout so the test is fast.
|
||||||
|
func TestResponseHeaderTimeoutFires(t *testing.T) {
|
||||||
|
addr, closer := newStallListener(t)
|
||||||
|
defer closer()
|
||||||
|
|
||||||
|
tr := timeoutTransport(nil)
|
||||||
|
tr.ResponseHeaderTimeout = 2 * time.Second
|
||||||
|
client := &http.Client{Transport: &userAgentTransport{base: tr}}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := client.Get("http://" + addr + "/")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a timeout error from stalled server, got nil")
|
||||||
|
}
|
||||||
|
if elapsed > 10*time.Second {
|
||||||
|
t.Errorf("request took %v; ResponseHeaderTimeout did not fire", elapsed)
|
||||||
|
}
|
||||||
|
t.Logf("request failed as expected after %v: %v", elapsed, err)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
"gitea.dev/tea/modules/config"
|
"gitea.dev/tea/modules/config"
|
||||||
"gitea.dev/tea/modules/context"
|
"gitea.dev/tea/modules/context"
|
||||||
"gitea.dev/tea/modules/task"
|
"gitea.dev/tea/modules/task"
|
||||||
@@ -40,9 +41,10 @@ func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) (
|
|||||||
Deadline: i.Deadline,
|
Deadline: i.Deadline,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i.Assignees = cleanAssignees(i.Assignees)
|
||||||
if len(i.Assignees) != 0 {
|
if len(i.Assignees) != 0 {
|
||||||
for _, a := range i.Assignees {
|
for _, a := range i.Assignees {
|
||||||
opts.AddAssignees = append(opts.AddAssignees, a.UserName)
|
opts.SetAssignees = append(opts.SetAssignees, a.UserName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currAssignees := o.AddAssignees
|
currAssignees := o.SetAssignees
|
||||||
newAssignees := selectables.Assignees
|
newAssignees := selectables.Assignees
|
||||||
|
|
||||||
for _, c := range currAssignees {
|
for _, c := range currAssignees {
|
||||||
@@ -119,10 +121,14 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assignees
|
// assignees
|
||||||
if o.AddAssignees, err = promptMultiSelect("Add Assignees:", newAssignees, "[other]"); err != nil {
|
if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printTitleAndContent("Assignees:", strings.Join(o.AddAssignees, "\n"))
|
if len(currAssignees) == 0 && len(o.SetAssignees) > 0 {
|
||||||
|
o.RemoveAssignees = o.SetAssignees
|
||||||
|
}
|
||||||
|
o.SetAssignees = currAssignees
|
||||||
|
printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n"))
|
||||||
|
|
||||||
// milestone
|
// milestone
|
||||||
if len(selectables.MilestoneList) != 0 {
|
if len(selectables.MilestoneList) != 0 {
|
||||||
@@ -175,3 +181,13 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanAssignees(list []*gitea.User) []*gitea.User {
|
||||||
|
out := make([]*gitea.User, 0, len(list))
|
||||||
|
for _, a := range list {
|
||||||
|
if strings.TrimSpace(a.UserName) != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package interact
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.dev/tea/modules/theme"
|
"gitea.dev/tea/modules/theme"
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// printTitleAndContent prints a title and content with the gitea theme
|
// printTitleAndContent prints a title and content with the gitea theme
|
||||||
func printTitleAndContent(title, content string) {
|
func printTitleAndContent(title, content string) {
|
||||||
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
hasDarkBG := theme.HasDarkBackground()
|
||||||
style := lipgloss.NewStyle().
|
style := lipgloss.NewStyle().
|
||||||
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
|
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|||||||
@@ -92,10 +92,26 @@ func promptDatetime(prompt string) (val *time.Time, err error) {
|
|||||||
|
|
||||||
// promptSelect creates a generic multiselect prompt, with processing of custom values.
|
// promptSelect creates a generic multiselect prompt, with processing of custom values.
|
||||||
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
|
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
|
||||||
|
opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...)
|
||||||
|
return runMultiSelect(prompt, opts, customVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values.
|
||||||
|
func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) {
|
||||||
|
opts := make([]huh.Option[string], 0, len(selected)+len(options)+1)
|
||||||
|
for _, name := range selected {
|
||||||
|
opts = append(opts, huh.NewOption(name, name).Selected(true))
|
||||||
|
}
|
||||||
|
opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...)
|
||||||
|
|
||||||
|
return runMultiSelect(prompt, opts, customVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) {
|
||||||
var selection []string
|
var selection []string
|
||||||
if err := huh.NewMultiSelect[string]().
|
if err := huh.NewMultiSelect[string]().
|
||||||
Title(prompt).
|
Title(prompt).
|
||||||
Options(huh.NewOptions(makeSelectOpts(options, customVal, "")...)...).
|
Options(opts...).
|
||||||
Value(&selection).
|
Value(&selection).
|
||||||
WithTheme(theme.GetTheme()).
|
WithTheme(theme.GetTheme()).
|
||||||
Run(); err != nil {
|
Run(); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdctx "context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty.
|
||||||
|
func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption {
|
||||||
|
names = cleanAssignees(names)
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &gitea.IssueAssigneesOption{Assignees: names}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyAssigneeChanges adds and removes assignees on an issue or pull request.
|
||||||
|
func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error {
|
||||||
|
if rm != nil {
|
||||||
|
_, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not remove assignees: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if add != nil {
|
||||||
|
_, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not add assignees: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanAssignees(list []string) []string {
|
||||||
|
out := make([]string, 0, len(list))
|
||||||
|
for _, a := range list {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+18
-10
@@ -23,23 +23,26 @@ type EditIssueOption struct {
|
|||||||
Deadline *time.Time
|
Deadline *time.Time
|
||||||
AddLabels []string
|
AddLabels []string
|
||||||
RemoveLabels []string
|
RemoveLabels []string
|
||||||
|
SetAssignees []string
|
||||||
AddAssignees []string
|
AddAssignees []string
|
||||||
|
RemoveAssignees []string
|
||||||
AddReviewers []string
|
AddReviewers []string
|
||||||
RemoveReviewers []string
|
RemoveReviewers []string
|
||||||
// RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalizes the options into parameters that can be passed to the sdk.
|
// Normalizes the options into parameters that can be passed to the sdk.
|
||||||
// the returned value will be nil, when no change to this part of the issue is requested.
|
// the returned value will be nil, when no change to this part of the issue is requested.
|
||||||
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) {
|
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) {
|
||||||
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
|
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
|
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees)
|
||||||
|
rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees)
|
||||||
|
|
||||||
issueOpts := gitea.EditIssueOption{}
|
issueOpts := gitea.EditIssueOption{}
|
||||||
var issueOptsDirty bool
|
var issueOptsDirty bool
|
||||||
@@ -58,7 +61,7 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te
|
|||||||
if o.Milestone != nil {
|
if o.Milestone != nil {
|
||||||
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
|
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
issueOpts.Milestone = gitea.OptionalInt64(id)
|
issueOpts.Milestone = gitea.OptionalInt64(id)
|
||||||
issueOptsDirty = true
|
issueOptsDirty = true
|
||||||
@@ -70,15 +73,16 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te
|
|||||||
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
|
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(o.AddAssignees) != 0 {
|
o.SetAssignees = cleanAssignees(o.SetAssignees)
|
||||||
issueOpts.Assignees = o.AddAssignees
|
if len(o.SetAssignees) != 0 {
|
||||||
|
issueOpts.Assignees = o.SetAssignees
|
||||||
issueOptsDirty = true
|
issueOptsDirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if issueOptsDirty {
|
if issueOptsDirty {
|
||||||
return &issueOpts, addLabelOpts, rmLabelOpts, nil
|
return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
|
||||||
}
|
}
|
||||||
return nil, addLabelOpts, rmLabelOpts, nil
|
return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditIssue edits an issue and returns the updated issue.
|
// EditIssue edits an issue and returns the updated issue.
|
||||||
@@ -87,7 +91,7 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea
|
|||||||
client = ctx.Login.Client()
|
client = ctx.Login.Client()
|
||||||
}
|
}
|
||||||
|
|
||||||
issueOpts, addLabelOpts, rmLabelOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
|
issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,10 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var issue *gitea.Issue
|
var issue *gitea.Issue
|
||||||
if issueOpts != nil {
|
if issueOpts != nil {
|
||||||
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)
|
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ func doPRFetch(
|
|||||||
localRemote *local_git.Remote,
|
localRemote *local_git.Remote,
|
||||||
callback func(string) (string, error),
|
callback func(string) (string, error),
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
_ = callback
|
||||||
localRemoteName := localRemote.Config().Name
|
localRemoteName := localRemote.Config().Name
|
||||||
localBranchName := pr.Head.Ref
|
localBranchName := pr.Head.Ref
|
||||||
// get auth & fetch remote via its configured protocol
|
|
||||||
url, err := localRepo.TeaRemoteURL(localRemoteName)
|
url, err := localRepo.TeaRemoteURL(localRemoteName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees)
|
||||||
|
rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees)
|
||||||
|
|
||||||
prOpts := gitea.EditPullRequestOption{}
|
prOpts := gitea.EditPullRequestOption{}
|
||||||
var prOptsDirty bool
|
var prOptsDirty bool
|
||||||
@@ -51,8 +53,9 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
|
|||||||
prOpts.RemoveDeadline = gitea.OptionalBool(true)
|
prOpts.RemoveDeadline = gitea.OptionalBool(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(opts.AddAssignees) != 0 {
|
opts.SetAssignees = cleanAssignees(opts.SetAssignees)
|
||||||
prOpts.Assignees = opts.AddAssignees
|
if len(opts.SetAssignees) != 0 {
|
||||||
|
prOpts.Assignees = opts.SetAssignees
|
||||||
prOptsDirty = true
|
prOptsDirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,10 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
|
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ func ResolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplyToPullReviewComment replies to a review comment on a pull request.
|
||||||
|
func ReplyToPullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, idx, commentID int64, body string) error {
|
||||||
|
c := ctx.Login.Client()
|
||||||
|
|
||||||
|
comment, _, err := c.PullRequests.CreatePullReviewCommentReply(requestCtx, ctx.Owner, ctx.Repo, idx, commentID, gitea.CreatePullReviewCommentReplyOptions{
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(comment.HTMLURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnresolvePullReviewComment unresolves a review comment
|
// UnresolvePullReviewComment unresolves a review comment
|
||||||
func UnresolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, commentID int64) error {
|
func UnresolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, commentID int64) error {
|
||||||
c := ctx.Login.Client()
|
c := ctx.Login.Client()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultDarkBackground is the background to assume when we cannot detect one. It
|
||||||
|
// matches the default lipgloss falls back to.
|
||||||
|
const defaultDarkBackground = true
|
||||||
|
|
||||||
|
// HasDarkBackground reports whether the terminal has a dark background.
|
||||||
|
//
|
||||||
|
// It only asks the terminal when stdin and stdout are both terminals. Detection
|
||||||
|
// works by writing an escape sequence to the output and waiting for the terminal
|
||||||
|
// to answer on the input, and nothing answers when stdio is redirected, so asking
|
||||||
|
// means waiting on a reply that never comes. On Windows that wait is unbounded:
|
||||||
|
// lipgloss opens the console directly rather than giving up, which is why tea used
|
||||||
|
// to hang at start-up under a service or a CI runner.
|
||||||
|
func HasDarkBackground() bool {
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
|
return defaultDarkBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ package theme
|
|||||||
import (
|
import (
|
||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"charm.land/lipgloss/v2/compat"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeaTheme implements the huh.Theme interface with tea-cli styling.
|
// TeaTheme implements the huh.Theme interface with tea-cli styling.
|
||||||
@@ -16,7 +15,8 @@ type TeaTheme struct{}
|
|||||||
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
|
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
|
||||||
theme := huh.ThemeCharm(isDark)
|
theme := huh.ThemeCharm(isDark)
|
||||||
|
|
||||||
title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
|
lightDark := lipgloss.LightDark(isDark)
|
||||||
|
title := lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87"))
|
||||||
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
|
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
|
||||||
theme.Blurred = theme.Focused
|
theme.Blurred = theme.Focused
|
||||||
return theme
|
return theme
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compatPkg detects the terminal background from package-level vars, so importing
|
||||||
|
// it anywhere makes tea query the terminal before main() runs. On Windows that
|
||||||
|
// query can block forever when stdio is redirected, which hung every tea command,
|
||||||
|
// including tea --version.
|
||||||
|
//
|
||||||
|
// lipgloss.LightDark covers what we need without the package-level detection, so
|
||||||
|
// nothing in tea should depend on compat again.
|
||||||
|
const compatPkg = "charm.land/lipgloss/v2/compat"
|
||||||
|
|
||||||
|
func TestBinaryDoesNotImportLipglossCompat(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("go"); err != nil {
|
||||||
|
t.Skip("go is not on PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("go", "list", "-deps", "gitea.dev/tea").Output()
|
||||||
|
require.NoError(t, err, "go list -deps")
|
||||||
|
|
||||||
|
imported := slices.Contains(strings.Fields(string(out)), compatPkg)
|
||||||
|
assert.False(t, imported,
|
||||||
|
"%s is back in tea's import graph. It detects the terminal background from "+
|
||||||
|
"package-level vars, so tea queries the terminal before main() runs, and on "+
|
||||||
|
"Windows that hangs at start-up when stdio is redirected.", compatPkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under go test neither stdin nor stdout is a terminal, so HasDarkBackground must
|
||||||
|
// take the default and return, rather than querying and waiting for an answer.
|
||||||
|
func TestHasDarkBackgroundDoesNotBlockWithoutTTY(t *testing.T) {
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
done <- HasDarkBackground()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
assert.Equal(t, defaultDarkBackground, got)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("HasDarkBackground blocked when stdio is not a terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The title color has to come from the isDark we are handed. It used to come from
|
||||||
|
// a process-wide value that compat detected at init, which ignored this argument.
|
||||||
|
func TestThemeHonorsIsDark(t *testing.T) {
|
||||||
|
dark := GetTheme().Theme(true).Focused.Title.GetForeground()
|
||||||
|
light := GetTheme().Theme(false).Focused.Title.GetForeground()
|
||||||
|
|
||||||
|
assert.NotEqual(t, dark, light, "Theme ignored isDark when picking the title color")
|
||||||
|
}
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# upload-r2.sh uploads a single local file to a single object key in a
|
||||||
|
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
|
||||||
|
# S3-API compatible).
|
||||||
|
#
|
||||||
|
# This is the R2 half of the release process's parallel S3+R2 upload
|
||||||
|
# period: goreleaser's `blobs:` pipe still uploads every release
|
||||||
|
# artifact to AWS S3, and this script is invoked once per artifact
|
||||||
|
# (via a goreleaser `publishers:` entry) to mirror the same artifact
|
||||||
|
# into R2. Once the migration away from S3 is complete, the `blobs:`
|
||||||
|
# block and the AWS_* secrets can be dropped without touching this
|
||||||
|
# script.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# upload-r2.sh <local-file> <remote-key>
|
||||||
|
# upload-r2.sh --check-config
|
||||||
|
#
|
||||||
|
# The second form only validates that the required environment
|
||||||
|
# variables below are set (it does not touch the network or the
|
||||||
|
# filesystem beyond that), and is meant to be run as an early
|
||||||
|
# preflight step in CI: goreleaser custom publishers run as the very
|
||||||
|
# last step of the publish pipeline, so without a preflight check a
|
||||||
|
# missing R2_* secret would only be discovered after the Gitea release
|
||||||
|
# has already been created and every artifact already uploaded to S3.
|
||||||
|
#
|
||||||
|
# Required environment variables:
|
||||||
|
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
|
||||||
|
# https://<account>.r2.cloudflarestorage.com
|
||||||
|
# R2_BUCKET Destination bucket name.
|
||||||
|
# R2_ACCESS_KEY_ID R2 access key id.
|
||||||
|
# R2_SECRET_ACCESS_KEY R2 secret access key.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# check_env validates that all required R2_* environment variables are
|
||||||
|
# set and non-empty, printing a single "missing required environment
|
||||||
|
# variable(s): ..." message and exiting non-zero otherwise. Used by
|
||||||
|
# both the normal upload mode and --check-config, so the validation
|
||||||
|
# logic only exists in one place.
|
||||||
|
check_env() {
|
||||||
|
missing=""
|
||||||
|
|
||||||
|
if [ -z "${R2_ENDPOINT:-}" ]; then
|
||||||
|
missing="$missing R2_ENDPOINT"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_BUCKET:-}" ]; then
|
||||||
|
missing="$missing R2_BUCKET"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
|
||||||
|
missing="$missing R2_ACCESS_KEY_ID"
|
||||||
|
fi
|
||||||
|
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
|
||||||
|
missing="$missing R2_SECRET_ACCESS_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
|
||||||
|
check_env
|
||||||
|
echo "upload-r2.sh: R2 configuration OK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$#" -ne 2 ]; then
|
||||||
|
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
|
||||||
|
echo " upload-r2.sh --check-config" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local_file="$1"
|
||||||
|
remote_key="$2"
|
||||||
|
|
||||||
|
if [ ! -f "$local_file" ]; then
|
||||||
|
echo "upload-r2.sh: local file not found: $local_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_env
|
||||||
|
|
||||||
|
# Strip a single trailing slash from the endpoint, if present, so that
|
||||||
|
# building the path-style URL below never produces a double slash.
|
||||||
|
endpoint="${R2_ENDPOINT%/}"
|
||||||
|
url="$endpoint/$R2_BUCKET/$remote_key"
|
||||||
|
|
||||||
|
# Credentials are passed to curl through a config file read from
|
||||||
|
# stdin rather than as a command-line argument, so they never show up
|
||||||
|
# in `ps` output.
|
||||||
|
#
|
||||||
|
# --fail-with-body (instead of plain --fail) still exits non-zero on
|
||||||
|
# HTTP errors, but also prints R2's XML error body, which is where the
|
||||||
|
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
|
||||||
|
# AccessDenied, ...); with plain --fail that body is discarded and the
|
||||||
|
# failure is silent. --retry 3 (without --retry-all-errors) still
|
||||||
|
# retries the transient cases (5xx, 408, 429, connection failures);
|
||||||
|
# --retry-all-errors would additionally retry permanent 4xx responses
|
||||||
|
# three times with backoff, which only delays an inevitable failure.
|
||||||
|
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
|
||||||
|
--config - \
|
||||||
|
--fail-with-body \
|
||||||
|
--silent \
|
||||||
|
--show-error \
|
||||||
|
--retry 3 \
|
||||||
|
--aws-sigv4 "aws:amz:auto:s3" \
|
||||||
|
--upload-file "$local_file" \
|
||||||
|
"$url"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
teagit "gitea.dev/tea/modules/git"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTeaCheckoutRemoteReferenceKeepsWorktreeClean(t *testing.T) {
|
||||||
|
clonePath := setupGitCheckoutTestRepo(t)
|
||||||
|
t.Chdir(clonePath)
|
||||||
|
|
||||||
|
repo, err := teagit.RepoFromPath(clonePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.TeaCheckout(teagit.NewRemoteReferenceName("origin", "feature/test-branch"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain"))
|
||||||
|
assert.Equal(t, "HEAD", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTeaCreateBranchTracksRemoteBranch(t *testing.T) {
|
||||||
|
clonePath := setupGitCheckoutTestRepo(t)
|
||||||
|
t.Chdir(clonePath)
|
||||||
|
|
||||||
|
repo, err := teagit.RepoFromPath(clonePath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.TeaCreateBranch("pulls/123", "feature/test-branch", "origin")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.TeaCheckout(teagit.NewBranchReferenceName("pulls/123"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain"))
|
||||||
|
assert.Equal(t, "origin", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.remote"))
|
||||||
|
assert.Equal(t, "refs/heads/feature/test-branch", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.merge"))
|
||||||
|
assert.Equal(t, "pulls/123", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupGitCheckoutTestRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
remotePath := filepath.Join(tmpDir, "remote.git")
|
||||||
|
seedPath := filepath.Join(tmpDir, "seed")
|
||||||
|
clonePath := filepath.Join(tmpDir, "clone")
|
||||||
|
|
||||||
|
runGit(t, tmpDir, "init", "--bare", remotePath)
|
||||||
|
runGit(t, tmpDir, "init", seedPath)
|
||||||
|
runGit(t, seedPath, "config", "user.email", "test@example.com")
|
||||||
|
runGit(t, seedPath, "config", "user.name", "Test User")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(seedPath, "README.md"), []byte("# Test Repo\n"), 0o644))
|
||||||
|
runGit(t, seedPath, "add", "README.md")
|
||||||
|
runGit(t, seedPath, "commit", "-m", "Initial commit")
|
||||||
|
runGit(t, seedPath, "branch", "-M", "main")
|
||||||
|
runGit(t, seedPath, "remote", "add", "origin", remotePath)
|
||||||
|
runGit(t, seedPath, "push", "-u", "origin", "main")
|
||||||
|
|
||||||
|
runGit(t, seedPath, "checkout", "-b", "feature/test-branch")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(seedPath, "feature.txt"), []byte("feature\n"), 0o644))
|
||||||
|
runGit(t, seedPath, "add", "feature.txt")
|
||||||
|
runGit(t, seedPath, "commit", "-m", "Add feature")
|
||||||
|
runGit(t, seedPath, "push", "-u", "origin", "feature/test-branch")
|
||||||
|
|
||||||
|
runGit(t, tmpDir, "clone", remotePath, clonePath)
|
||||||
|
return clonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitOutput(t *testing.T, dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output)))
|
||||||
|
return strings.TrimSpace(string(output))
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ func TestRepoFromPath_Worktree(t *testing.T) {
|
|||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
|
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
cmd = exec.Command("git", "-C", mainRepoPath, "config", "commit.gpgsign", "false")
|
||||||
|
assert.NoError(t, cmd.Run())
|
||||||
|
|
||||||
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
|
||||||
assert.NoError(t, cmd.Run())
|
assert.NoError(t, cmd.Run())
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/tea/cmd/pulls"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPullsReply(t *testing.T) {
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
timestamp := time.Now().UnixNano()
|
||||||
|
repoName := fmt.Sprintf("tea-pr-reply-%d", timestamp)
|
||||||
|
featureBranch := fmt.Sprintf("reply-test-%d", timestamp)
|
||||||
|
replyBody := fmt.Sprintf("Thanks for the review %d", timestamp)
|
||||||
|
|
||||||
|
repo, _, err := client.Repositories.CreateRepo(t.Context(), gitea.CreateRepoOption{
|
||||||
|
Name: repoName,
|
||||||
|
AutoInit: true,
|
||||||
|
DefaultBranch: "main",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.Repositories.DeleteRepo(t.Context(), login.User, repoName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test repo %q: %v", repoName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baseBranch := repo.DefaultBranch
|
||||||
|
if baseBranch == "" {
|
||||||
|
baseBranch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = client.Repositories.CreateFile(t.Context(), login.User, repoName, "review.txt", gitea.CreateFileOptions{
|
||||||
|
FileOptions: gitea.FileOptions{
|
||||||
|
Message: "add review target",
|
||||||
|
BranchName: baseBranch,
|
||||||
|
NewBranchName: featureBranch,
|
||||||
|
},
|
||||||
|
Content: base64.StdEncoding.EncodeToString([]byte("line for review\n")),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pr, _, err := client.PullRequests.CreatePullRequest(t.Context(), login.User, repoName, gitea.CreatePullRequestOption{
|
||||||
|
Base: baseBranch,
|
||||||
|
Head: featureBranch,
|
||||||
|
Title: "Integration test for pr reply",
|
||||||
|
Body: "Adds a file so we can reply to a review comment.",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
review, _, err := client.PullRequests.CreatePullReview(t.Context(), login.User, repoName, pr.Index, gitea.CreatePullReviewOptions{
|
||||||
|
State: gitea.ReviewStateComment,
|
||||||
|
Body: "Please take another look.",
|
||||||
|
Comments: []gitea.CreatePullReviewComment{{
|
||||||
|
Path: "review.txt",
|
||||||
|
Body: "Could you clarify this line?",
|
||||||
|
NewLineNum: 1,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
comments, _, err := client.PullRequests.ListPullReviewComments(t.Context(), login.User, repoName, pr.Index, review.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, comments, 1)
|
||||||
|
|
||||||
|
pullsCmd := &cli.Command{
|
||||||
|
Name: "pulls",
|
||||||
|
Commands: []*cli.Command{&pulls.CmdPullsReply},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pullsCmd.Run(context.Background(), []string{
|
||||||
|
"pulls",
|
||||||
|
"reply",
|
||||||
|
strconv.FormatInt(pr.Index, 10),
|
||||||
|
strconv.FormatInt(comments[0].ID, 10),
|
||||||
|
replyBody,
|
||||||
|
"--login",
|
||||||
|
login.Name,
|
||||||
|
"--repo",
|
||||||
|
repo.FullName,
|
||||||
|
})
|
||||||
|
if err != nil && strings.Contains(err.Error(), "unknown API error: 405") {
|
||||||
|
t.Skip("pull review comment replies are not supported by this integration Gitea instance")
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
reviewComments, _, listErr := client.PullRequests.ListPullReviewComments(t.Context(), login.User, repoName, pr.Index, review.ID)
|
||||||
|
if listErr != nil {
|
||||||
|
t.Logf("failed to list review comments: %v", listErr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, reviewComment := range reviewComments {
|
||||||
|
if reviewComment.Body == replyBody && reviewComment.ReviewID == review.ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, 10*time.Second, 500*time.Millisecond)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
|
"gitea.dev/tea/cmd"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditIssue_ModifiesAssignees(t *testing.T) {
|
||||||
|
// This test verifies that EditIssue correctly modifies assignees of an issue via set, add, and remove.
|
||||||
|
// It sets up a test repository and organization with known users, then calls EditIssue and checks the results.
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
orgName := fmt.Sprintf("issue-org-%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
orgRepoName := fmt.Sprintf("issue-repo-%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Clean up any existing test data that might interfere with the test.
|
||||||
|
_, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName)
|
||||||
|
_, _ = client.Organizations.DeleteOrg(ctx, orgName)
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user1")
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user2")
|
||||||
|
|
||||||
|
_, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test org %q: %v", orgName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user1")
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user2")
|
||||||
|
})
|
||||||
|
|
||||||
|
permission := gitea.AccessModeOwner
|
||||||
|
team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, assigneeValid)
|
||||||
|
assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, assigneeValid)
|
||||||
|
|
||||||
|
orgIssue, _, err := client.Issues.CreateIssue(ctx, orgName, orgRepoName, gitea.CreateIssueOption{Title: "issue_integration_test", Assignees: []string{integrationUsername}, Closed: false})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, integrationUsername, orgIssue.Assignees[0].UserName)
|
||||||
|
curUser, _, err := client.Users.GetMyUserInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
getNames := func(issue *gitea.Issue) []string {
|
||||||
|
names := make([]string, len(issue.Assignees))
|
||||||
|
for i, u := range issue.Assignees {
|
||||||
|
names[i] = u.UserName
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAssignees := func(expected []string) {
|
||||||
|
updatedIssue, _, err := client.Issues.GetIssue(ctx, orgName, orgRepoName, orgIssue.Index)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, getNames(updatedIssue), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := cmd.App()
|
||||||
|
// test set overwrites add and remove
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user1.UserName})
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user2.UserName, curUser.UserName})
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user1.UserName})
|
||||||
|
|
||||||
|
// test remove one assignee
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{})
|
||||||
|
|
||||||
|
// test add multiple assignees and overwrites remove
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{curUser.UserName, user1.UserName})
|
||||||
|
|
||||||
|
// test add one assignee
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName})
|
||||||
|
|
||||||
|
// test remove multiple assignees
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{})
|
||||||
|
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) {
|
|||||||
runGit("commit", "--allow-empty", "-m", "Initial commit")
|
runGit("commit", "--allow-empty", "-m", "Initial commit")
|
||||||
runGit("push", "-u", "origin", "HEAD:branch-with-labels")
|
runGit("push", "-u", "origin", "HEAD:branch-with-labels")
|
||||||
|
|
||||||
waitForBranches(t, orgRepo.FullName)
|
waitForBranches(t, orgRepo.FullName, "branch-with-labels")
|
||||||
_ = runTeaCommand(
|
_ = runTeaCommand(
|
||||||
t, "pr", "create", "--repo", orgRepo.FullName,
|
t, "pr", "create", "--repo", orgRepo.FullName,
|
||||||
"--login", login.Name, "--base", "main", "--head", "branch-with-labels",
|
"--login", login.Name, "--base", "main", "--head", "branch-with-labels",
|
||||||
@@ -94,7 +94,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) {
|
|||||||
require.ElementsMatch(t, labels, []*gitea.Label{orgLabel, repoLabel})
|
require.ElementsMatch(t, labels, []*gitea.Label{orgLabel, repoLabel})
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForBranches(t *testing.T, repoFullName string) {
|
func waitForBranches(t *testing.T, repoFullName string, branchName string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/branches", os.Getenv("GITEA_TEA_TEST_URL"), repoFullName)
|
url := fmt.Sprintf("%s/api/v1/repos/%s/branches", os.Getenv("GITEA_TEA_TEST_URL"), repoFullName)
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ func waitForBranches(t *testing.T, repoFullName string) {
|
|||||||
for _, b := range branches {
|
for _, b := range branches {
|
||||||
have[b.Name] = true
|
have[b.Name] = true
|
||||||
}
|
}
|
||||||
if have["main"] && have["branch-with-labels"] {
|
if have["main"] && have[branchName] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gitea "gitea.dev/sdk"
|
||||||
|
"gitea.dev/tea/cmd"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditPull_ModifiesAssignees(t *testing.T) {
|
||||||
|
// This test verifies that EditPull correctly modifies assignees of an pull request via set, add, and remove.
|
||||||
|
// It sets up a test repository and organization with known users, then calls EditPull and checks the results.
|
||||||
|
login := createIntegrationLogin(t)
|
||||||
|
client := login.Client()
|
||||||
|
orgName := fmt.Sprintf("pull-org-%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
orgRepoName := fmt.Sprintf("pull-repo-%d", time.Now().UnixNano()%1_000_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Clean up any existing test data that might interfere with the test.
|
||||||
|
_, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName)
|
||||||
|
_, _ = client.Organizations.DeleteOrg(ctx, orgName)
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user1")
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user2")
|
||||||
|
|
||||||
|
_, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test org %q: %v", orgName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil {
|
||||||
|
t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user1")
|
||||||
|
_, _ = client.Admin.DeleteUser(ctx, "user2")
|
||||||
|
})
|
||||||
|
|
||||||
|
permission := gitea.AccessModeOwner
|
||||||
|
team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, assigneeValid)
|
||||||
|
assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, assigneeValid)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
runGit := func(args ...string) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
require.NoError(t, cmd.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
runGit("init")
|
||||||
|
runGit("config", "user.email", "test@test.com")
|
||||||
|
runGit("config", "user.name", "test")
|
||||||
|
httpsURL := fmt.Sprintf("%s/%s.git", login.URL, orgRepo.FullName)
|
||||||
|
httpsURL = strings.Replace(httpsURL, "://", fmt.Sprintf("://%s:%s@", login.Name, login.Token), 1)
|
||||||
|
|
||||||
|
runGit("remote", "add", "origin", httpsURL)
|
||||||
|
|
||||||
|
runGit("checkout", "-b", "main")
|
||||||
|
runGit("commit", "--allow-empty", "-m", "Initial commit")
|
||||||
|
runGit("push", "-u", "origin", "HEAD:main")
|
||||||
|
|
||||||
|
runGit("checkout", "-b", "branch-with-assignees")
|
||||||
|
runGit("commit", "--allow-empty", "-m", "Initial commit")
|
||||||
|
runGit("push", "-u", "origin", "HEAD:branch-with-assignees")
|
||||||
|
|
||||||
|
waitForBranches(t, orgRepo.FullName, "branch-with-assignees")
|
||||||
|
getNames := func(pr *gitea.PullRequest) []string {
|
||||||
|
names := make([]string, len(pr.Assignees))
|
||||||
|
for i, u := range pr.Assignees {
|
||||||
|
names[i] = u.UserName
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
checkAssignees := func(expected []string) {
|
||||||
|
updatedPr, _, err := client.PullRequests.GetPullRequest(ctx, orgName, orgRepoName, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.ElementsMatch(t, getNames(updatedPr), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := cmd.App()
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "create", "--repo", orgRepo.FullName, "--base", "main", "--head", "branch-with-assignees", "--a", integrationUsername})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{integrationUsername})
|
||||||
|
curUser, _, err := client.Users.GetMyUserInfo(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// test set overwrites add and remove
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user1.UserName})
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user2.UserName, curUser.UserName})
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{user1.UserName})
|
||||||
|
|
||||||
|
// test remove one assignee
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{})
|
||||||
|
|
||||||
|
// test add multiple assignees and overwrites remove
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{curUser.UserName, user1.UserName})
|
||||||
|
|
||||||
|
// test add one assignee
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName})
|
||||||
|
|
||||||
|
// test remove multiple assignees
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
checkAssignees([]string{})
|
||||||
|
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user_not_exists"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user