forked from Gitlink/gitlink-cli
Compare commits
No commits in common. "v0.1.12" and "master" have entirely different histories.
|
|
@ -0,0 +1,30 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Build, Lint, Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Check formatting
|
||||
run: make fmt
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
mkdir -p dist
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
MODULE="github.com/gitlink-org/gitlink-cli"
|
||||
LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}"
|
||||
|
||||
for pair in \
|
||||
"darwin amd64" \
|
||||
"darwin arm64" \
|
||||
"linux amd64" \
|
||||
"linux arm64" \
|
||||
"windows amd64" \
|
||||
"windows arm64"; do
|
||||
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
||||
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
||||
OUT="gitlink-cli"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUT="gitlink-cli.exe"
|
||||
fi
|
||||
|
||||
echo "Building ${GOOS}-${GOARCH}..."
|
||||
BUILD_DIR="dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "$BUILD_DIR/$OUT" .
|
||||
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
(cd "$BUILD_DIR" && zip -q "../gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "$OUT")
|
||||
else
|
||||
tar -czf "dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" -C "$BUILD_DIR" "$OUT"
|
||||
fi
|
||||
rm -rf "$BUILD_DIR"
|
||||
done
|
||||
|
||||
ls -lh dist
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Build npm package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
export VERSION
|
||||
rm -rf npm-pkg
|
||||
mkdir -p npm-pkg
|
||||
cp -R npm/. npm-pkg/
|
||||
cp README.md npm-pkg/README.md
|
||||
rm -rf npm-pkg/skills
|
||||
cp -R skills npm-pkg/skills
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const pkgPath = 'npm-pkg/package.json';
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
pkg.version = process.env.VERSION;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
NODE
|
||||
|
||||
chmod +x npm-pkg/bin/cli.js
|
||||
chmod +x npm-pkg/bin/install-skills.js
|
||||
|
||||
cd npm-pkg
|
||||
npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Validate i18n messages
|
||||
run: go run ./internal/i18n/cmd/check
|
||||
|
||||
- name: Scan i18n key references
|
||||
run: go run ./internal/i18n/cmd/check --scan-code
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
|
@ -1,22 +1,3 @@
|
|||
# Binary
|
||||
gitlink-cli
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
|
||||
# npm packaging
|
||||
npm/bin/gitlink-cli
|
||||
npm/*.tgz
|
||||
npm/README.md
|
||||
npm/skills/
|
||||
dist/
|
||||
gitlink-cli.exe
|
||||
/gitlink-cli
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
default: none
|
||||
|
||||
enable:
|
||||
# Core: catch real bugs
|
||||
- errcheck # unchecked errors
|
||||
- govet # suspicious constructs
|
||||
- ineffassign # wasted assignments
|
||||
- staticcheck # comprehensive bug detection
|
||||
- unused # dead code
|
||||
|
||||
# Error handling
|
||||
- errorlint # errors.As / %w best practices
|
||||
|
||||
# Security
|
||||
- gosec # security issues
|
||||
|
||||
# Typos
|
||||
- misspell # spelling mistakes in identifiers
|
||||
|
||||
settings:
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # errcheck already handles unchecked errors
|
||||
- G304 # file inclusion by variable is expected for CLI tools
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor/
|
||||
- npm/
|
||||
- skills/
|
||||
- docs/
|
||||
rules:
|
||||
# Idiomatic Go: defer Close() error is intentionally ignored
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*(resp\\.Body\\.Close|file\\.Close).*is not checked"
|
||||
# Output formatting: fmt.Fprint* errors are low-value
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of `fmt\\.Fprintf?"
|
||||
# Test helpers: FlagSet.Set is setup code
|
||||
- linters: [errcheck]
|
||||
text: "Error return value of .*FlagSet.*\\.Set"
|
||||
# Best-effort output rendering
|
||||
- linters: [errcheck]
|
||||
path: render\.go$
|
||||
# errcheck: test helpers intentionally ignore return values
|
||||
- linters: [errcheck]
|
||||
path: _test\.go$
|
||||
# errorlint: type assertions are fine in tests
|
||||
- linters: [errorlint]
|
||||
path: _test\.go$
|
||||
# gosec: tests are not attack surface
|
||||
- linters: [gosec]
|
||||
path: _test\.go$
|
||||
# apiInt: intentional uint64->int truncation for API response parsing
|
||||
- linters: [gosec]
|
||||
text: "G115: integer overflow conversion uint64 -> int"
|
||||
199
LICENSE
199
LICENSE
|
|
@ -1,190 +1,65 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Mulan Permissive Software License,Version 2
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Mulan Permissive Software License,Version 2 (Mulan PSL v2)
|
||||
|
||||
1. Definitions.
|
||||
January 2020 http://license.coscl.org.cn/MulanPSL2
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions:
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
0. Definition
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
Software means the program and related documents which are licensed under this License and comprise all Contribution(s).
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
Contribution means the copyrightable work licensed by a particular Contributor under this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
Legal Entity means the entity making a Contribution and all its Affiliates.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, ‘control’ means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
1. Grant of Copyright License
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
2. Grant of Patent License
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
3. No Trademark License
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
4. Distribution Restriction
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
5. Disclaimer of Warranty and Limitation of Liability
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
6. Language
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
END OF THE TERMS AND CONDITIONS
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
How to Apply the Mulan Permissive Software License,Version 2 (Mulan PSL v2) to Your Software
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps:
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner;
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
Create a file named "LICENSE" which contains the whole context of this License in the first directory of your software package;
|
||||
|
||||
Copyright 2026 gitlink-cli contributors
|
||||
Attach the statement to the appropriate annotated syntax at the beginning of each source file.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Copyright (c) [Year] [name of copyright holder]
|
||||
[Software Name] is licensed under Mulan PSL v2.
|
||||
You can use this software according to the terms and conditions of the Mulan PSL v2.
|
||||
You may obtain a copy of Mulan PSL v2 at:
|
||||
http://license.coscl.org.cn/MulanPSL2
|
||||
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
|
||||
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
|
||||
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
|
||||
See the Mulan PSL v2 for more details.
|
||||
|
|
|
|||
30
Makefile
30
Makefile
|
|
@ -3,7 +3,7 @@ BINARY := gitlink-cli
|
|||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
|
||||
|
||||
.PHONY: build install clean test
|
||||
.PHONY: build install clean test check vet fmt cover lint
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
|
@ -15,4 +15,30 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
@unformatted=$$(gofmt -s -l .); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Files not formatted:"; \
|
||||
echo "$$unformatted"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
cover:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
check: fmt vet lint test
|
||||
@echo "All checks passed."
|
||||
|
||||
hooks:
|
||||
cp scripts/pre-commit .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
@echo "Pre-commit hook installed."
|
||||
|
|
|
|||
648
README.md
648
README.md
|
|
@ -1,23 +1,74 @@
|
|||
# gitlink-cli
|
||||
|
||||
[](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
|
||||
[](./LICENSE)
|
||||
[](https://golang.org)
|
||||
[](https://license.coscl.org.cn/MulanPSL2)
|
||||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
The official [GitLink(确实开源)](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 11 AI Agent [Skills](./skills/).
|
||||
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#首次使用) · [Commands](#使用示例) · [Contributing](#相关项目)
|
||||
**[中文文档](./README.zh-CN.md)**
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
|
||||
|
||||
## Contributors
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Why gitlink-cli?
|
||||
|
||||
- **Agent-Native Design** — 11 structured [Skills](./skills/) out of the box, compatible with Claude Code — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
|
||||
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
|
||||
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
|
||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm` in one command
|
||||
- **Open Source, Zero Barriers** — Apache 2.0 license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — Interactive login, from install to first API call in just 3 steps
|
||||
- **Secure & Controllable** — OS-native keychain credential storage, auto git remote context resolution
|
||||
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
|
||||
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — Interactive login or `GITLINK_TOKEN` env var, from install to first API call in just 3 steps
|
||||
- **Secure & Controllable** — OS-native keychain credential storage, `GITLINK_TOKEN` env var for CI/CD & non-interactive environments, auto git remote context resolution
|
||||
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management)
|
||||
|
||||
## Features
|
||||
|
|
@ -25,25 +76,28 @@ The official [GitLink(确实开源)](https://www.gitlink.org.cn) CLI tool
|
|||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||
| 🐛 Issue | Create, update, close, comment on issues |
|
||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
||||
| 🔖 Label | Create, list, update, delete issue labels |
|
||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||
| 🌿 Branch | Create, delete, protect branches |
|
||||
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
|
||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||
| 🏷️ Release | Create, view, delete releases |
|
||||
| 🏢 Org | Manage organizations, members, teams |
|
||||
| 🔧 CI | View builds, logs, CI/CD operations |
|
||||
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
|
||||
| 🔔 Webhook | Manage repo webhooks and test deliveries |
|
||||
| 🔍 Search | Search repositories, users |
|
||||
| 👤 User | View user profiles and info |
|
||||
| 📋 PM | Sprint management, kanban boards, weekly reports |
|
||||
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
### Requirements
|
||||
|
||||
Before you start, make sure you have:
|
||||
|
||||
- Node.js 14+ (`npm`/`npx`) — for npm installation
|
||||
- Supported platforms: macOS, Linux, Windows (x64/arm64)
|
||||
- Go 1.21+ — only required for building from source
|
||||
- Go 1.26+ — only required for building from source
|
||||
|
||||
### Quick Start (Human Users)
|
||||
|
||||
|
|
@ -51,32 +105,26 @@ Before you start, make sure you have:
|
|||
|
||||
#### Install
|
||||
|
||||
Choose **one** of the following methods:
|
||||
|
||||
**Option 1 — From npm (recommended):**
|
||||
**From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
# One command: installs CLI binary + AI Agent Skills
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# Install CLI SKILL (required, works on all platforms)
|
||||
gitlink-cli-install-skills
|
||||
```
|
||||
|
||||
**Option 2 — From source:**
|
||||
The binary is auto-downloaded for your platform during `postinstall`. No extra steps needed.
|
||||
|
||||
Requires Go 1.21+.
|
||||
**From source:**
|
||||
|
||||
Requires Go 1.26+.
|
||||
|
||||
```bash
|
||||
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
|
||||
cd gitlink-cli
|
||||
make install
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add ./skills -y -g
|
||||
```
|
||||
|
||||
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。
|
||||
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`.
|
||||
|
||||
#### Configure & Use
|
||||
|
||||
|
|
@ -87,6 +135,7 @@ gitlink-cli config init
|
|||
# 2. Log in (choose one)
|
||||
gitlink-cli auth login # Username/password (recommended)
|
||||
gitlink-cli auth login --token # Or paste a private token
|
||||
export GITLINK_TOKEN="your-token" # Or set env var (for CI/CD, non-interactive environments)
|
||||
|
||||
# 3. Start using
|
||||
gitlink-cli repo +list
|
||||
|
|
@ -99,11 +148,8 @@ gitlink-cli repo +list
|
|||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
# One command: CLI binary + all Skills auto-installed
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# Install CLI SKILL (required, works on all platforms)
|
||||
gitlink-cli-install-skills
|
||||
```
|
||||
|
||||
**Step 2 — Configure**
|
||||
|
|
@ -114,201 +160,484 @@ gitlink-cli config init
|
|||
|
||||
**Step 3 — Login**
|
||||
|
||||
For interactive environments:
|
||||
```bash
|
||||
gitlink-cli auth login
|
||||
```
|
||||
|
||||
For non-interactive environments (CI/CD, Trae sandbox, MCP, etc.):
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
```
|
||||
|
||||
> To get a private token, go to GitLink web → Settings → Private Tokens.
|
||||
|
||||
**Step 4 — Verify**
|
||||
|
||||
```bash
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
## Usage Examples
|
||||
|
||||
### 仓库操作
|
||||
### Repository Operations
|
||||
|
||||
```bash
|
||||
# 列出仓库
|
||||
# List repositories
|
||||
gitlink-cli repo +list
|
||||
|
||||
# 查看仓库信息
|
||||
# View repository info
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
# Read repository README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# Fork 仓库
|
||||
# Create a repository
|
||||
gitlink-cli repo +create -n my-project -d "Project description"
|
||||
|
||||
# Fork a repository
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
### Webhook Management
|
||||
|
||||
```bash
|
||||
# 列出 Issue
|
||||
# List webhooks
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# Test a webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# View webhook delivery tasks
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### Member Management
|
||||
|
||||
```bash
|
||||
# List repository members
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Add a member
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# Preview batch add without changing data
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# Batch add members from a CSV file
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# Change a member role
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# Create an invite link
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
# List issues
|
||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
||||
# Create an issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
|
||||
|
||||
# 查看 Issue
|
||||
# Create an issue with metadata
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# View an issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 关闭 Issue
|
||||
# Update issue metadata
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# Close an issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
# Preview batch close without changing data
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
||||
|
||||
# Batch close issues from a CSV file
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# Add a comment
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||
|
||||
# List issue assigners
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue authors
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue priorities
|
||||
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
|
||||
|
||||
# List issue tags
|
||||
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
|
||||
|
||||
# List issue statuses
|
||||
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Pull Request
|
||||
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
|
||||
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
|
||||
is accepted as a compatibility alias for the same web issue number, not the
|
||||
global database ID.
|
||||
|
||||
### Label Management
|
||||
|
||||
```bash
|
||||
# 列出 PR
|
||||
# List issue labels
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Filter labels by keyword
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
|
||||
|
||||
# Create a label (color defaults to #1E90FF)
|
||||
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000"
|
||||
|
||||
# Update a label (unspecified fields are preserved)
|
||||
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
|
||||
|
||||
# Delete a label
|
||||
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
||||
```bash
|
||||
# List PRs
|
||||
gitlink-cli pr +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 PR(同仓库分支)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master
|
||||
# Create a PR (same-repo branch)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master
|
||||
|
||||
# 创建 PR(从 Fork 仓库)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master
|
||||
# Create a PR (from a fork)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" --head your_username/forgeplus:feature/my-feature --base master
|
||||
|
||||
# 查看 PR
|
||||
# View a PR
|
||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 合并 PR
|
||||
# Merge a PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR 变更文件
|
||||
# Reopen a closed PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View changed files
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# List PR patchset versions
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# View a patchset version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# List PR reviews
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# Create a PR review (with dry-run preview)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
### Branch Management
|
||||
|
||||
```bash
|
||||
# 列出 Release
|
||||
# List branches
|
||||
gitlink-cli branch +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Create a branch
|
||||
gitlink-cli branch +create --name feature/new-feature
|
||||
|
||||
# Delete a branch
|
||||
gitlink-cli branch +delete --name feature/old-feature
|
||||
|
||||
# Protect a branch
|
||||
gitlink-cli branch +protect --name main
|
||||
|
||||
# Remove branch protection
|
||||
gitlink-cli branch +unprotect --name main
|
||||
```
|
||||
|
||||
### Release Management
|
||||
|
||||
```bash
|
||||
# List releases
|
||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 Release
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
|
||||
# Create a release
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..."
|
||||
|
||||
# 查看 Release
|
||||
# View a release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
```
|
||||
|
||||
### 搜索
|
||||
### CI/CD Operations
|
||||
|
||||
```bash
|
||||
# 搜索仓库
|
||||
# List builds
|
||||
gitlink-cli ci +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# View build log
|
||||
gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
|
||||
|
||||
# Restart a build
|
||||
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
|
||||
```
|
||||
|
||||
### Pipeline Operations
|
||||
|
||||
```bash
|
||||
# List platform pipelines
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# List repository pipeline runs
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# Start a pipeline workflow, previewing the request first
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# Inspect pipeline details and logs
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# Toggle or delete pipeline workflows, previewing destructive writes first
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Search repositories
|
||||
gitlink-cli search +repos -k "machine learning"
|
||||
|
||||
# 搜索用户
|
||||
# Search users
|
||||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Raw API
|
||||
### Workflow Agent Commands
|
||||
|
||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
||||
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# GET 请求
|
||||
# Triage with local parameters
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
|
||||
# Triage with JSON output
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
|
||||
# Triage with Chinese markdown output
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Triage from a local JSON file
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
|
||||
# Triage by read-only GitLink fetch
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
|
||||
# Health for a healthy repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
|
||||
# Health for a risky repository
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
|
||||
# Health with Chinese markdown output
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Health by read-only GitLink fetch
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
|
||||
# PR review summary by read-only GitLink fetch
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
|
||||
# PR review summary from a local JSON file
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
|
||||
# Repository workflow report by read-only GitLink fetch
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
||||
- `json` for scripts and AI Agents
|
||||
- `table` for terminal review
|
||||
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
|
||||
|
||||
Safety:
|
||||
|
||||
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
|
||||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
|
||||
### Raw API
|
||||
|
||||
For endpoints not covered by shortcuts, use the Raw API directly:
|
||||
|
||||
```bash
|
||||
# GET request
|
||||
gitlink-cli api GET /users/me
|
||||
|
||||
# POST 请求
|
||||
# POST request
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# 带查询参数
|
||||
# POST request with body from a file
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# POST request with body from stdin
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# With query parameters
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
||||
## 全局参数
|
||||
## Global Parameters
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `--owner` | 仓库所有者 | `--owner Gitlink` |
|
||||
| `--repo` | 仓库名称 | `--repo forgeplus` |
|
||||
| `--format` | 输出格式(json/table/yaml) | `--format json` |
|
||||
| `--debug` | 启用调试输出 | `--debug` |
|
||||
| Parameter | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `--owner` | Repository owner | `--owner Gitlink` |
|
||||
| `--repo` | Repository name | `--repo forgeplus` |
|
||||
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
|
||||
| `--debug` | Enable debug output | `--debug` |
|
||||
|
||||
**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。
|
||||
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
|
||||
|
||||
## 分支约定
|
||||
## Branch Conventions
|
||||
|
||||
gitlink-cli 支持 GitHub 和 GitLink 的代码双向同步:
|
||||
gitlink-cli supports bidirectional code sync between GitHub and GitLink:
|
||||
|
||||
| 平台 | 主分支 |
|
||||
|------|--------|
|
||||
| Platform | Default Branch |
|
||||
|----------|---------------|
|
||||
| GitHub | `main` |
|
||||
| GitLink | `master` |
|
||||
|
||||
**本地 push 到 GitLink**:
|
||||
**Push to GitLink from local:**
|
||||
|
||||
```bash
|
||||
# 方式 1:使用 git 命令
|
||||
# Method 1: Use git command directly
|
||||
git push gitlink main:master
|
||||
|
||||
# 方式 2:配置 git remote
|
||||
# Method 2: Configure git remote
|
||||
git config remote.gitlink.push refs/heads/main:refs/heads/master
|
||||
git push gitlink
|
||||
```
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
See [skills/README.md](skills/README.md) for details.
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-branch` | 分支管理(创建、删除、保护等) |
|
||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) |
|
||||
| Skill | Description |
|
||||
|-------|-------------|
|
||||
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
|
||||
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
|
||||
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
|
||||
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
|
||||
| `gitlink-member` | Repository member and invite link management |
|
||||
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
|
||||
| `gitlink-release` | Release management (create, view, delete, etc.) |
|
||||
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
|
||||
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
|
||||
| `gitlink-search` | Search (repositories, users, etc.) |
|
||||
| `gitlink-org` | Organization management (members, teams, etc.) |
|
||||
| `gitlink-user` | User management (profile info, etc.) |
|
||||
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
|
||||
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
|
||||
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |
|
||||
|
||||
## 项目结构
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gitlink-cli/
|
||||
├── cmd/ # Cobra 命令定义
|
||||
│ ├── root.go # 根命令 + 全局 flags
|
||||
│ ├── auth/ # 认证命令
|
||||
│ ├── api/ # Raw API 命令
|
||||
│ ├── config/ # 配置命令
|
||||
│ └── cmdutil/ # 全局工具
|
||||
├── internal/ # 内部包
|
||||
│ ├── auth/ # 登录、Token 存储、Transport
|
||||
│ ├── client/ # HTTP 客户端 + 分页
|
||||
│ ├── config/ # 配置文件管理
|
||||
│ ├── context/ # git remote 解析
|
||||
│ └── output/ # Envelope + Formatter
|
||||
├── shortcuts/ # Shortcut 实现
|
||||
│ ├── common/ # 框架(types, runner)
|
||||
│ ├── repo/ # 仓库 shortcuts
|
||||
├── cmd/ # Cobra command definitions
|
||||
│ ├── root.go # Root command + global flags
|
||||
│ ├── auth/ # Authentication commands
|
||||
│ ├── api/ # Raw API commands
|
||||
│ ├── config/ # Configuration commands
|
||||
│ └── cmdutil/ # Global utilities
|
||||
├── internal/ # Internal packages
|
||||
│ ├── auth/ # Login, token storage, transport
|
||||
│ ├── client/ # HTTP client + pagination
|
||||
│ ├── config/ # Config file management
|
||||
│ ├── context/ # Git remote resolution
|
||||
│ └── output/ # Envelope + formatter
|
||||
├── shortcuts/ # Shortcut implementations
|
||||
│ ├── common/ # Framework (types, runner)
|
||||
│ ├── repo/ # Repository shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── branch/ # 分支 shortcuts
|
||||
│ ├── member/ # Repository member shortcuts
|
||||
│ ├── branch/ # Branch shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # 组织 shortcuts
|
||||
│ ├── org/ # Organization shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # Search shortcuts
|
||||
│ ├── user/ # User shortcuts
|
||||
│ └── register.go # Registration entry point
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills 使用指南
|
||||
│ ├── gitlink-shared/ # 共享规则
|
||||
│ ├── gitlink-repo/ # 仓库 Skill
|
||||
│ ├── gitlink-issue/ # Issue Skill
|
||||
│ ├── gitlink-pr/ # PR Skill
|
||||
│ ├── README.md # Skills guide
|
||||
│ ├── gitlink-shared/ # Shared rules
|
||||
│ ├── gitlink-repo/ # Repository skill
|
||||
│ ├── gitlink-issue/ # Issue skill
|
||||
│ ├── gitlink-pr/ # PR skill
|
||||
│ ├── gitlink-pm/ # Project management skill
|
||||
│ └── ...
|
||||
├── doc/ # 设计文档
|
||||
│ ├── SKILLS_TEST_REPORT_2026-04-02.md
|
||||
├── doc/ # Design documents
|
||||
│ ├── Design.md
|
||||
│ ├── CODE_SYNC_STRATEGY_FINAL.md
|
||||
│ └── ...
|
||||
├── main.go
|
||||
|
|
@ -317,56 +646,75 @@ gitlink-cli/
|
|||
└── README.md
|
||||
```
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
- [Skills 使用指南](skills/README.md) - AI Agent Skills 详细说明
|
||||
- [设计文档](doc/design.md) - 架构设计和开发计划
|
||||
- [测试报告](doc/SKILLS_TEST_REPORT_2026-04-02.md) - 功能测试报告
|
||||
- [代码同步方案](doc/CODE_SYNC_STRATEGY_FINAL.md) - GitHub ↔ GitLink 同步设计
|
||||
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
|
||||
- [Design Document](doc/design.md) — Architecture design and development plan
|
||||
|
||||
## 常见问题
|
||||
## FAQ
|
||||
|
||||
### Q: 如何在脚本中使用 gitlink-cli?
|
||||
### Q: How do I use gitlink-cli in scripts?
|
||||
|
||||
A: 使用 `--format json` 获取结构化输出:
|
||||
Use the `GITLINK_TOKEN` environment variable + `--format json` for structured output:
|
||||
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
|
||||
```
|
||||
|
||||
### Q: 如何自动解析 owner/repo?
|
||||
### Q: How does automatic owner/repo resolution work?
|
||||
|
||||
A: 在 git 仓库目录下运行命令,CLI 会自动从 `git remote origin` 解析:
|
||||
When running inside a git repository, the CLI automatically resolves `--owner` and `--repo` from `git remote origin`:
|
||||
|
||||
```bash
|
||||
cd ~/my-gitlink-project
|
||||
gitlink-cli issue +list # 自动使用当前仓库
|
||||
gitlink-cli issue +list # Automatically uses the current repository
|
||||
```
|
||||
|
||||
### Q: Token 过期了怎么办?
|
||||
### Q: What if my token expires?
|
||||
|
||||
A: 重新登录:
|
||||
Re-authenticate:
|
||||
|
||||
```bash
|
||||
# 用户名密码登录
|
||||
# Username/password login
|
||||
gitlink-cli auth login
|
||||
|
||||
# 或使用私人令牌(在 GitLink 网页端 个人设置 → 私人令牌 中生成)
|
||||
# Or use a private token (generate at GitLink web → Settings → Private Tokens)
|
||||
gitlink-cli auth login --token
|
||||
```
|
||||
|
||||
### Q: Windows 上凭证存储在哪里?
|
||||
### Q: How do I use gitlink-cli in CI/CD or non-interactive environments (e.g. Trae sandbox)?
|
||||
|
||||
A: gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储 (`~/.config/gitlink-cli/credentials`)。
|
||||
Set the `GITLINK_TOKEN` environment variable — no `auth login` needed:
|
||||
|
||||
### Q: 如何查看完整的 API 参考?
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
gitlink-cli repo +list # Ready to use
|
||||
gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable"
|
||||
```
|
||||
|
||||
A: 查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
|
||||
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
|
||||
|
||||
## 相关项目
|
||||
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
|
||||
|
||||
- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - GitHub ↔ GitLink 代码双向同步系统
|
||||
Reinstall first:
|
||||
|
||||
## 许可证
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
If the error persists, check whether the release page contains the asset for your platform,
|
||||
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
|
||||
You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
|
||||
### Q: Where are credentials stored on Windows?
|
||||
|
||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
||||
|
||||
### Q: Where can I find the full API reference?
|
||||
|
||||
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
|
||||
|
||||
## License
|
||||
|
||||
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,595 @@
|
|||
# gitlink-cli
|
||||
|
||||
[](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
|
||||
[](https://license.coscl.org.cn/MulanPSL2)
|
||||
[](https://golang.org)
|
||||
[](https://www.npmjs.com/package/@gitlink-ai/cli)
|
||||
|
||||
[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
|
||||
|
||||
**[English](./README.md)**
|
||||
|
||||
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
|
||||
|
||||
## 贡献者
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start;">
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wangyue111">wangyue111</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/wbtiger">wbtiger</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Mengz">Mengz</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yangsai">yangsai</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/mengcheng">mengcheng</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/muel">muel</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/Leo77">Leo77</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/yingjie">yingjie</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/topshare">topshare</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/dtwdtw" title="dtwdtw"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/D/53_166_51/120.png" width="40" height="40" alt="dtwdtw" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/dtwdtw">dtwdtw</a></sub>
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://www.gitlink.org.cn/recorder" title="recorder"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/R/141_201_87/120.png" width="40" height="40" alt="recorder" style="border-radius: 50%;"></a>
|
||||
<br><sub><a href="https://www.gitlink.org.cn/recorder">recorder</a></sub>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 为什么选择 gitlink-cli?
|
||||
|
||||
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
|
||||
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
|
||||
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
|
||||
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
|
||||
- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用
|
||||
- **3 分钟上手** — 交互式登录或 `GITLINK_TOKEN` 环境变量,从安装到首次 API 调用仅需 3 步
|
||||
- **安全可控** — OS 原生 keychain 凭证存储,`GITLINK_TOKEN` 环境变量支持 CI/CD 和非交互环境,自动 git remote 上下文解析
|
||||
- **三层架构** — Shortcuts(人+AI友好)→ Raw API(全覆盖)→ Config(配置管理)
|
||||
|
||||
## 功能一览
|
||||
|
||||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
|
||||
| 🌿 分支 | 创建、删除、保护分支 |
|
||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||
| 🏢 组织 | 管理组织、成员、团队 |
|
||||
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
|
||||
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
|
||||
| 🔍 搜索 | 搜索仓库、用户 |
|
||||
| 👤 用户 | 查看用户资料和信息 |
|
||||
| 📋 项目管理 | Sprint 管理、看板、周报 |
|
||||
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
|
||||
|
||||
## 安装与快速上手
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Node.js 14+(`npm`/`npx`)— 用于 npm 安装
|
||||
- 支持平台:macOS、Linux、Windows(x64/arm64)
|
||||
- Go 1.26+ — 仅从源码构建时需要
|
||||
|
||||
### 快速上手(人类用户)
|
||||
|
||||
> **AI 助手请注意:** 如果你是帮助用户安装的 AI Agent,请直接跳到 [快速上手(AI Agent)](#快速上手ai-agent),其中包含你需要完成的所有步骤。
|
||||
|
||||
#### 安装
|
||||
|
||||
选择以下**任一**方式:
|
||||
|
||||
**方式 1 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# 安装 CLI Skill(必须,全平台通用)
|
||||
gitlink-cli-install-skills
|
||||
|
||||
# 也可使用 npx 安装 Skill
|
||||
npx skills add ccfos/gitlink-cli/skills -y -g
|
||||
```
|
||||
|
||||
**方式 2 — 从源码构建:**
|
||||
|
||||
需要 Go 1.26+。
|
||||
|
||||
```bash
|
||||
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
|
||||
cd gitlink-cli
|
||||
make install
|
||||
|
||||
# 安装 CLI Skill(必须)
|
||||
npx skills add ./skills -y -g
|
||||
```
|
||||
|
||||
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。
|
||||
|
||||
#### 配置与使用
|
||||
|
||||
```bash
|
||||
# 1. 配置(首次使用,交互式引导)
|
||||
gitlink-cli config init
|
||||
|
||||
# 2. 登录(任选其一)
|
||||
gitlink-cli auth login # 用户名密码(推荐)
|
||||
gitlink-cli auth login --token # 或粘贴私人令牌
|
||||
export GITLINK_TOKEN="your-token" # 或设置环境变量(适用于 CI/CD、非交互环境)
|
||||
|
||||
# 3. 开始使用
|
||||
gitlink-cli repo +list
|
||||
```
|
||||
|
||||
### 快速上手(AI Agent)
|
||||
|
||||
> 以下步骤面向 AI Agent。部分步骤需要用户在浏览器中完成操作。
|
||||
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @gitlink-ai/cli
|
||||
|
||||
# 安装 CLI Skill(必须,全平台通用)
|
||||
gitlink-cli-install-skills
|
||||
```
|
||||
|
||||
**第 2 步 — 配置**
|
||||
|
||||
```bash
|
||||
gitlink-cli config init
|
||||
```
|
||||
|
||||
**第 3 步 — 登录**
|
||||
|
||||
交互环境:
|
||||
```bash
|
||||
gitlink-cli auth login
|
||||
```
|
||||
|
||||
非交互环境(CI/CD、Trae 沙箱、MCP 等):
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
```
|
||||
|
||||
> 获取私人令牌:GitLink 网页端 → 个人设置 → 私人令牌。
|
||||
|
||||
**第 4 步 — 验证**
|
||||
|
||||
```bash
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 仓库操作
|
||||
|
||||
```bash
|
||||
# 列出仓库
|
||||
gitlink-cli repo +list
|
||||
|
||||
# 查看仓库信息
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
|
||||
# 读取仓库 README
|
||||
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create -n my-project -d "项目描述"
|
||||
|
||||
# Fork 仓库
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
### Webhook 管理
|
||||
|
||||
```bash
|
||||
# 列出 webhook
|
||||
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 webhook
|
||||
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
|
||||
--url https://example.com/hook --events push,create
|
||||
|
||||
# 测试 webhook
|
||||
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
|
||||
|
||||
# 查看 webhook 投递任务
|
||||
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```bash
|
||||
# 列出仓库成员
|
||||
gitlink-cli member +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 添加成员
|
||||
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
|
||||
|
||||
# 预览批量添加成员,不修改数据
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
|
||||
|
||||
# 从 CSV 文件批量添加成员
|
||||
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
|
||||
|
||||
# 调整成员权限
|
||||
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
|
||||
|
||||
# 生成邀请链接
|
||||
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
|
||||
```
|
||||
|
||||
### Issue 管理
|
||||
|
||||
```bash
|
||||
# 列出 Issue
|
||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
|
||||
|
||||
# 创建带元数据的 Issue
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# 查看 Issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 更新 Issue 元数据
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# 关闭 Issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
|
||||
# 预览批量关闭,不修改数据
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
||||
|
||||
# 从 CSV 文件批量关闭 Issue
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
|
||||
# 列出 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 发布人
|
||||
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 优先级
|
||||
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
|
||||
|
||||
# 列出 Issue 标签
|
||||
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
|
||||
|
||||
# 列出 Issue 状态
|
||||
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
|
||||
```
|
||||
|
||||
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
|
||||
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
|
||||
编号的兼容别名,不是数据库内部 ID。
|
||||
|
||||
### 标签管理
|
||||
|
||||
```bash
|
||||
# 列出 Issue 标签
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 按关键词筛选标签
|
||||
gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug
|
||||
|
||||
# 创建标签(颜色默认 #1E90FF)
|
||||
gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺陷" -c "#FF0000"
|
||||
|
||||
# 更新标签(未指定的字段会被保留)
|
||||
gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00"
|
||||
|
||||
# 删除标签
|
||||
gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42
|
||||
```
|
||||
|
||||
### Pull Request
|
||||
|
||||
```bash
|
||||
# 列出 PR
|
||||
gitlink-cli pr +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 PR(同仓库分支)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master
|
||||
|
||||
# 创建 PR(从 Fork 仓库)
|
||||
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master
|
||||
|
||||
# 查看 PR
|
||||
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 合并 PR
|
||||
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 重开已关闭的 PR
|
||||
gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看 PR patchset/version 列表
|
||||
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 查看指定 patchset/version diff
|
||||
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
|
||||
|
||||
# 查看 PR 审查记录
|
||||
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
||||
|
||||
# 创建 PR 审查(支持 dry-run 预览)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
|
||||
```bash
|
||||
# 列出 Release
|
||||
gitlink-cli release +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# 创建 Release
|
||||
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
|
||||
|
||||
# 查看 Release
|
||||
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
|
||||
```
|
||||
|
||||
### 流水线管理
|
||||
|
||||
```bash
|
||||
# 列出平台流水线
|
||||
gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20
|
||||
|
||||
# 列出仓库流水线运行记录
|
||||
gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml
|
||||
|
||||
# 运行流水线工作流,先用 dry-run 预览请求
|
||||
gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run
|
||||
|
||||
# 查看流水线详情、日志和运行结果
|
||||
gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7
|
||||
gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43
|
||||
gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99
|
||||
|
||||
# 启停或删除流水线工作流,写入/删除前先预览
|
||||
gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run
|
||||
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
|
||||
```
|
||||
|
||||
### 搜索
|
||||
|
||||
```bash
|
||||
# 搜索仓库
|
||||
gitlink-cli search +repos -k "machine learning"
|
||||
|
||||
# 搜索用户
|
||||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Raw API
|
||||
|
||||
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
|
||||
|
||||
```bash
|
||||
# GET 请求
|
||||
gitlink-cli api GET /users/me
|
||||
|
||||
# POST 请求
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
|
||||
|
||||
# 从文件读取 JSON body
|
||||
gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json
|
||||
|
||||
# 从 stdin 读取 JSON body
|
||||
Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin
|
||||
|
||||
# 带查询参数
|
||||
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|
||||
```
|
||||
|
||||
## 全局参数
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `--owner` | 仓库所有者 | `--owner Gitlink` |
|
||||
| `--repo` | 仓库名称 | `--repo forgeplus` |
|
||||
| `--format` | 输出格式(json/table/yaml) | `--format json` |
|
||||
| `--debug` | 启用调试输出 | `--debug` |
|
||||
|
||||
**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。
|
||||
|
||||
## 分支约定
|
||||
|
||||
gitlink-cli 支持 GitHub 和 GitLink 的代码双向同步:
|
||||
|
||||
| 平台 | 主分支 |
|
||||
|------|--------|
|
||||
| GitHub | `main` |
|
||||
| GitLink | `master` |
|
||||
|
||||
**本地 push 到 GitLink**:
|
||||
|
||||
```bash
|
||||
# 方式 1:使用 git 命令
|
||||
git push gitlink main:master
|
||||
|
||||
# 方式 2:配置 git remote
|
||||
git config remote.gitlink.push refs/heads/main:refs/heads/master
|
||||
git push gitlink
|
||||
```
|
||||
|
||||
## AI Agent Skills
|
||||
|
||||
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
|
||||
|
||||
详见 [skills/README.md](skills/README.md)
|
||||
|
||||
| Skill | 说明 |
|
||||
|-------|------|
|
||||
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
|
||||
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
|
||||
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
|
||||
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
|
||||
| `gitlink-member` | 仓库成员与邀请链接管理 |
|
||||
| `gitlink-release` | 发布管理(创建、查看、删除等) |
|
||||
| `gitlink-org` | 组织管理(成员、团队等) |
|
||||
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
|
||||
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
|
||||
| `gitlink-search` | 搜索功能(仓库、用户等) |
|
||||
| `gitlink-user` | 用户管理(个人信息等) |
|
||||
| `gitlink-pm` | 项目管理(Sprint、看板、周报等) |
|
||||
| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
gitlink-cli/
|
||||
├── cmd/ # Cobra 命令定义
|
||||
│ ├── root.go # 根命令 + 全局 flags
|
||||
│ ├── auth/ # 认证命令
|
||||
│ ├── api/ # Raw API 命令
|
||||
│ ├── config/ # 配置命令
|
||||
│ └── cmdutil/ # 全局工具
|
||||
├── internal/ # 内部包
|
||||
│ ├── auth/ # 登录、Token 存储、Transport
|
||||
│ ├── client/ # HTTP 客户端 + 分页
|
||||
│ ├── config/ # 配置文件管理
|
||||
│ ├── context/ # git remote 解析
|
||||
│ └── output/ # Envelope + Formatter
|
||||
├── shortcuts/ # Shortcut 实现
|
||||
│ ├── common/ # 框架(types, runner)
|
||||
│ ├── repo/ # 仓库 shortcuts
|
||||
│ ├── issue/ # Issue shortcuts
|
||||
│ ├── pr/ # PR shortcuts
|
||||
│ ├── member/ # 仓库成员 shortcuts
|
||||
│ ├── branch/ # 分支 shortcuts
|
||||
│ ├── release/ # Release shortcuts
|
||||
│ ├── org/ # 组织 shortcuts
|
||||
│ ├── ci/ # CI shortcuts
|
||||
│ ├── pipeline/ # Pipeline shortcuts
|
||||
│ ├── search/ # 搜索 shortcuts
|
||||
│ ├── user/ # 用户 shortcuts
|
||||
│ └── register.go # 注册入口
|
||||
├── skills/ # AI Agent Skills
|
||||
│ ├── README.md # Skills 使用指南
|
||||
│ ├── gitlink-shared/ # 共享规则
|
||||
│ ├── gitlink-repo/ # 仓库 Skill
|
||||
│ ├── gitlink-issue/ # Issue Skill
|
||||
│ ├── gitlink-pr/ # PR Skill
|
||||
│ ├── gitlink-pm/ # 项目管理 Skill
|
||||
│ └── ...
|
||||
├── doc/ # 设计文档
|
||||
│ ├── Design.md
|
||||
│ ├── CODE_SYNC_STRATEGY_FINAL.md
|
||||
│ └── ...
|
||||
├── main.go
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
|
||||
- [设计文档](doc/design.md) — 架构设计和开发计划
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何在脚本中使用 gitlink-cli?
|
||||
|
||||
使用 `GITLINK_TOKEN` 环境变量 + `--format json` 获取结构化输出:
|
||||
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
|
||||
```
|
||||
|
||||
### Q: 如何自动解析 owner/repo?
|
||||
|
||||
在 git 仓库目录下运行命令,CLI 会自动从 `git remote origin` 解析:
|
||||
|
||||
```bash
|
||||
cd ~/my-gitlink-project
|
||||
gitlink-cli issue +list # 自动使用当前仓库
|
||||
```
|
||||
|
||||
### Q: Token 过期了怎么办?
|
||||
|
||||
重新登录:
|
||||
|
||||
```bash
|
||||
# 用户名密码登录
|
||||
gitlink-cli auth login
|
||||
|
||||
# 或使用私人令牌(在 GitLink 网页端 个人设置 → 私人令牌 中生成)
|
||||
gitlink-cli auth login --token
|
||||
```
|
||||
|
||||
### Q: 如何在 CI/CD 或非交互环境(Trae 沙箱等)中使用?
|
||||
|
||||
设置 `GITLINK_TOKEN` 环境变量即可,无需 `auth login`:
|
||||
|
||||
```bash
|
||||
export GITLINK_TOKEN="your-private-token"
|
||||
gitlink-cli repo +list # 直接可用
|
||||
gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment variable"
|
||||
```
|
||||
|
||||
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
|
||||
|
||||
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?
|
||||
|
||||
先尝试重新安装:
|
||||
|
||||
```bash
|
||||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
|
||||
|
||||
### Q: Windows 上凭证存储在哪里?
|
||||
|
||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
||||
|
||||
### Q: 如何查看完整的 API 参考?
|
||||
|
||||
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
|
||||
|
|
@ -2,32 +2,43 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
func NewAPICmd() *cobra.Command {
|
||||
func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
apiCmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH>",
|
||||
Short: "Make raw API requests to GitLink",
|
||||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
||||
Short: tr.T("cmd.api.short"),
|
||||
Long: tr.T("cmd.api.long"),
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
||||
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
|
||||
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||
|
||||
return apiCmd
|
||||
}
|
||||
|
|
@ -46,12 +57,9 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
var body interface{}
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
if bodyStr != "" {
|
||||
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
|
||||
return fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
body, err := readJSONBody(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var query url.Values
|
||||
|
|
@ -66,7 +74,8 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
}
|
||||
|
|
@ -76,6 +85,49 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
|
||||
sources := 0
|
||||
if bodyStr != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyFile != "" {
|
||||
sources++
|
||||
}
|
||||
if bodyStdin {
|
||||
sources++
|
||||
}
|
||||
if sources == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if sources > 1 {
|
||||
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
switch {
|
||||
case bodyStr != "":
|
||||
data = []byte(bodyStr)
|
||||
case bodyFile != "":
|
||||
data, err = os.ReadFile(bodyFile)
|
||||
case bodyStdin:
|
||||
data, err = io.ReadAll(c.InOrStdin())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read JSON body: %w", err)
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
f := cmdutil.Format
|
||||
if f == "" {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
)
|
||||
|
||||
func TestResolveFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flagFormat string
|
||||
want string
|
||||
}{
|
||||
{"empty defaults to json", "", "json"},
|
||||
{"explicit json", "json", "json"},
|
||||
{"explicit yaml", "yaml", "yaml"},
|
||||
{"explicit table", "table", "table"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmdutil.Format = tt.flagFormat
|
||||
if got := resolveFormat(); got != tt.want {
|
||||
t.Fatalf("resolveFormat = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPICmd(t *testing.T) {
|
||||
cmd := NewAPICmd()
|
||||
if cmd.Use != "api <METHOD> <PATH>" {
|
||||
t.Fatalf("Use = %q", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
// Verify flags exist
|
||||
flags := []string{"body", "query", "header"}
|
||||
for _, f := range flags {
|
||||
if cmd.Flags().Lookup(f) == nil {
|
||||
t.Fatalf("flag %q not found", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupAPITest(t *testing.T, handler http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: "+server.URL+"\ndefault_format: table\n"), 0600)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestRunAPIGet(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users/me.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser", "id": 42})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI GET error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIPostWithBody(t *testing.T) {
|
||||
var gotBody map[string]interface{}
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "title": "new issue"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("body", `{"title":"new issue","body":"test"}`)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI POST error: %v", err)
|
||||
}
|
||||
if gotBody["title"] != "new issue" {
|
||||
t.Fatalf("body title = %q, want 'new issue'", gotBody["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBadJSONBody(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"POST", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("body", `{bad json}`)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad JSON body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIBadQuery(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach server")
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/repos/owner/repo/issues"})
|
||||
cmd.Flags().Set("query", "key=%zz")
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad query string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIHTTPError(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/nonexistent"})
|
||||
// HTTP errors are caught and printed as error envelopes; runAPI does not return the error
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI HTTP error: %v (expected success with error envelope)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIStatusError(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"status": float64(401), "message": "Unauthorized"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
// Should print error envelope, not return a Go error (status check in Do() handles this)
|
||||
// Actually, HTTP 401 triggers APIError return from Do(), so this should error
|
||||
if err := cmd.Execute(); err != nil {
|
||||
// Expected — HTTP error
|
||||
t.Logf("got expected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPIDebug(t *testing.T) {
|
||||
var gotDebugHeader bool
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotDebugHeader = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
cmdutil.Debug = true
|
||||
defer func() { cmdutil.Debug = false }()
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "/users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI debug error: %v", err)
|
||||
}
|
||||
if !gotDebugHeader {
|
||||
t.Fatal("server not reached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAPINoPrefix(t *testing.T) {
|
||||
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users/me.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"login": "testuser"})
|
||||
})
|
||||
cmdutil.Format = "json"
|
||||
|
||||
cmd := NewAPICmd()
|
||||
cmd.SetArgs([]string{"GET", "users/me"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("runAPI no-prefix error: %v", err)
|
||||
}
|
||||
}
|
||||
139
cmd/auth/auth.go
139
cmd/auth/auth.go
|
|
@ -2,141 +2,182 @@ package auth
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
const envTokenVar = "GITLINK_TOKEN"
|
||||
|
||||
func NewAuthCmd() *cobra.Command {
|
||||
var (
|
||||
storeToken = internalAuth.StoreToken
|
||||
loadToken = internalAuth.LoadToken
|
||||
)
|
||||
|
||||
func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authentication commands",
|
||||
Short: tr.T("cmd.auth.short"),
|
||||
}
|
||||
cmd.AddCommand(newLoginCmd())
|
||||
cmd.AddCommand(newLogoutCmd())
|
||||
cmd.AddCommand(newStatusCmd())
|
||||
cmd.AddCommand(newLoginCmd(tr))
|
||||
cmd.AddCommand(newLogoutCmd(tr))
|
||||
cmd.AddCommand(newStatusCmd(tr))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
|
||||
var tokenMode bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Login to GitLink",
|
||||
Short: tr.T("cmd.auth.login.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if tokenMode {
|
||||
return loginWithToken()
|
||||
return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
}
|
||||
return loginWithPassword()
|
||||
return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
|
||||
cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loginWithPassword() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username/Email/Phone: ")
|
||||
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
|
||||
return err
|
||||
}
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
|
||||
return err
|
||||
}
|
||||
passwordBytes, err := readPassword(in, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
password := string(passwordBytes)
|
||||
|
||||
result, err := internalAuth.Login(username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Logged in as %s\n", result.Login)
|
||||
return nil
|
||||
_, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
|
||||
return err
|
||||
}
|
||||
|
||||
func loginWithToken() error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Paste your token: ")
|
||||
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
|
||||
if file, ok := in.(*os.File); ok {
|
||||
fd := int(file.Fd())
|
||||
if term.IsTerminal(fd) {
|
||||
return term.ReadPassword(fd)
|
||||
}
|
||||
}
|
||||
password, err := reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(strings.TrimRight(password, "\r\n")), nil
|
||||
}
|
||||
|
||||
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||
reader := bufio.NewReader(in)
|
||||
if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil {
|
||||
return err
|
||||
}
|
||||
token, _ := reader.ReadString('\n')
|
||||
token = strings.TrimSpace(token)
|
||||
|
||||
if token == "" {
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
return errors.New(tr.T("error.auth.token_empty"))
|
||||
}
|
||||
|
||||
if err := internalAuth.StoreToken(token); err != nil {
|
||||
return fmt.Errorf("failed to store token: %w", err)
|
||||
if err := storeToken(token); err != nil {
|
||||
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
fmt.Println("✓ Token saved")
|
||||
return nil
|
||||
_, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
|
||||
return err
|
||||
}
|
||||
|
||||
func newLogoutCmd() *cobra.Command {
|
||||
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Logout from GitLink",
|
||||
Short: tr.T("cmd.auth.logout.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalAuth.DeleteToken(); err != nil {
|
||||
return fmt.Errorf("failed to delete token: %w", err)
|
||||
return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
fmt.Println("✓ Logged out")
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show authentication status",
|
||||
Short: tr.T("cmd.auth.status.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
out := cmd.OutOrStdout()
|
||||
// Check env var token first
|
||||
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
||||
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar)
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
token, err := internalAuth.LoadToken()
|
||||
token, err := loadToken()
|
||||
if err != nil || token == "" {
|
||||
if os.Getenv(envTokenVar) == "" {
|
||||
fmt.Println("✗ Not logged in")
|
||||
fmt.Println(" Run: gitlink-cli auth login")
|
||||
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
|
||||
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := internalAuth.GetCurrentUser()
|
||||
if err != nil {
|
||||
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
|
||||
return nil
|
||||
_, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
|
||||
return err
|
||||
}
|
||||
|
||||
login, _ := user["login"].(string)
|
||||
name, _ := user["name"].(string)
|
||||
if login != "" {
|
||||
fmt.Printf("✓ Logged in as %s", login)
|
||||
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
|
||||
if name != "" {
|
||||
fmt.Printf(" (%s)", name)
|
||||
text = fmt.Sprintf("%s (%s)", text, name)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Println("✓ Token stored (user info unavailable)")
|
||||
_, err := fmt.Fprintln(out, text)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable"))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/zalando/go-keyring"
|
||||
|
||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
)
|
||||
|
||||
func TestEnvTokenVar(t *testing.T) {
|
||||
if envTokenVar != "GITLINK_TOKEN" {
|
||||
t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthCmd(t *testing.T) {
|
||||
cmd := NewAuthCmd()
|
||||
if cmd.Use != "auth" {
|
||||
t.Fatalf("Use = %q, want auth", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
expectedSubs := map[string]bool{
|
||||
"login": false, "logout": false, "status": false,
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if _, ok := expectedSubs[sub.Use]; !ok {
|
||||
t.Fatalf("unexpected subcommand: %q", sub.Use)
|
||||
}
|
||||
if expectedSubs[sub.Use] {
|
||||
t.Fatalf("duplicate subcommand: %q", sub.Use)
|
||||
}
|
||||
expectedSubs[sub.Use] = true
|
||||
if sub.Short == "" {
|
||||
t.Fatalf("subcommand %q has empty Short", sub.Use)
|
||||
}
|
||||
}
|
||||
for name, found := range expectedSubs {
|
||||
if !found {
|
||||
t.Fatalf("missing subcommand: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenFlag(t *testing.T) {
|
||||
cmd := NewAuthCmd()
|
||||
loginCmd := findSub(cmd, "login")
|
||||
if loginCmd == nil {
|
||||
t.Fatal("login subcommand not found")
|
||||
}
|
||||
if f := loginCmd.Flags().Lookup("token"); f == nil {
|
||||
t.Fatal("login command missing --token flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdNotLoggedIn(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if cmd == nil {
|
||||
t.Fatal("status subcommand not found")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdEnvToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "env-token-123")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
cmd.RunE(cmd, nil)
|
||||
}
|
||||
|
||||
func TestStatusCmdStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
|
||||
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("cookie:test=abc"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
cmd.RunE(cmd, nil)
|
||||
}
|
||||
|
||||
func TestStatusCmdEnvAndStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("GITLINK_TOKEN", "env-token")
|
||||
|
||||
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
|
||||
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("stored-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Don't create credentials file — LoadToken returns empty
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("status error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmdError(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Don't create credentials dir — DeleteToken will fail
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when DeleteToken fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmd(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Store a token first so DeleteToken has something to delete
|
||||
credDir := home + "/.config/gitlink-cli"
|
||||
os.MkdirAll(credDir, 0700)
|
||||
os.WriteFile(credDir+"/credentials", []byte("some-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
if cmd == nil {
|
||||
t.Fatal("logout subcommand not found")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("logout error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Mock stdin
|
||||
oldStdin := os.Stdin
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
go func() {
|
||||
w.Write([]byte("test-token-123\n"))
|
||||
w.Close()
|
||||
}()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
if cmd == nil {
|
||||
t.Fatal("login subcommand not found")
|
||||
}
|
||||
cmd.Flags().Set("token", "true")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("login --token error: %v", err)
|
||||
}
|
||||
|
||||
// Verify token was saved to file
|
||||
data, err := os.ReadFile(home + "/.config/gitlink-cli/credentials")
|
||||
if err != nil {
|
||||
t.Fatalf("read credentials: %v", err)
|
||||
}
|
||||
if string(data) != "test-token-123" {
|
||||
t.Fatalf("token = %q, want test-token-123", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithTokenEmpty(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
oldStdin := os.Stdin
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
go func() {
|
||||
w.Write([]byte("\n"))
|
||||
w.Close()
|
||||
}()
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
cmd.Flags().Set("token", "true")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithPasswordNoTerminal(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// term.ReadPassword will fail because test has no terminal
|
||||
cmd := findSub(NewAuthCmd(), "login")
|
||||
// Don't set --token, so it goes to loginWithPassword
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when terminal unavailable (ReadPassword fails)")
|
||||
}
|
||||
}
|
||||
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Use == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRootCmdDefaults(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.Use != "gitlink-cli" {
|
||||
t.Fatalf("Use = %q", root.Use)
|
||||
}
|
||||
if !root.SilenceUsage {
|
||||
t.Fatal("expected SilenceUsage=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelp(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("help command error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCmd(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("version command error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" {
|
||||
t.Fatalf("version output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCmdHasSubcommands(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, sub := range root.Commands() {
|
||||
names[sub.Use] = true
|
||||
}
|
||||
for _, want := range []string{"auth", "config", "version"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing subcommand: %s", want)
|
||||
}
|
||||
}
|
||||
if len(root.Commands()) < 4 {
|
||||
t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands()))
|
||||
}
|
||||
}
|
||||
|
|
@ -6,4 +6,5 @@ var (
|
|||
Repo string
|
||||
Format string
|
||||
Debug bool
|
||||
Lang string
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,59 +1,80 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func NewConfigCmd() *cobra.Command {
|
||||
func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage gitlink-cli configuration",
|
||||
Short: tr.T("cmd.config.short"),
|
||||
}
|
||||
cmd.AddCommand(newInitCmd())
|
||||
cmd.AddCommand(newSetCmd())
|
||||
cmd.AddCommand(newGetCmd())
|
||||
cmd.AddCommand(newListCmd())
|
||||
cmd.AddCommand(newInitCmd(tr))
|
||||
cmd.AddCommand(newSetCmd(tr))
|
||||
cmd.AddCommand(newGetCmd(tr))
|
||||
cmd.AddCommand(newListCmd(tr))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
func newInitCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration file",
|
||||
Short: tr.T("cmd.config.init.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg := internalConfig.DefaultConfig()
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSetCmd() *cobra.Command {
|
||||
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Set a configuration value",
|
||||
Short: tr.T("cmd.config.set.short"),
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("✓ %s = %s\n", args[0], args[1])
|
||||
return nil
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
|
||||
"key": args[0],
|
||||
"value": args[1],
|
||||
}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGetCmd() *cobra.Command {
|
||||
func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Get a configuration value",
|
||||
Short: tr.T("cmd.config.get.short"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
val, err := internalConfig.Get(args[0])
|
||||
|
|
@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
if val == "" {
|
||||
fmt.Printf("%s: (not set)\n", args[0])
|
||||
} else {
|
||||
fmt.Printf("%s: %s\n", args[0], val)
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configuration values",
|
||||
Short: tr.T("cmd.config.list.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("base_url: %s\n", cfg.BaseURL)
|
||||
fmt.Printf("default_format: %s\n", cfg.Format)
|
||||
fmt.Printf("editor: %s\n", cfg.Editor)
|
||||
fmt.Printf("pager: %s\n", cfg.Pager)
|
||||
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
|
||||
return nil
|
||||
out := cmd.OutOrStdout()
|
||||
if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNewConfigCmd(t *testing.T) {
|
||||
cmd := NewConfigCmd()
|
||||
if cmd.Use != "config" {
|
||||
t.Fatalf("Use = %q, want config", cmd.Use)
|
||||
}
|
||||
if cmd.Short == "" {
|
||||
t.Fatal("Short is empty")
|
||||
}
|
||||
|
||||
expectedSubs := map[string]bool{
|
||||
"init": false, "set <key> <value>": false, "get <key>": false, "list": false,
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if _, ok := expectedSubs[sub.Use]; !ok {
|
||||
t.Fatalf("unexpected subcommand: %q", sub.Use)
|
||||
}
|
||||
if expectedSubs[sub.Use] {
|
||||
t.Fatalf("duplicate subcommand: %q", sub.Use)
|
||||
}
|
||||
expectedSubs[sub.Use] = true
|
||||
if sub.Short == "" {
|
||||
t.Fatalf("subcommand %q has empty Short", sub.Use)
|
||||
}
|
||||
}
|
||||
for name, found := range expectedSubs {
|
||||
if !found {
|
||||
t.Fatalf("missing subcommand: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCmdArgs(t *testing.T) {
|
||||
cmd := findSub(NewConfigCmd(), "set <key> <value>")
|
||||
if cmd == nil {
|
||||
t.Fatal("set subcommand not found")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("set should require exact args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCmdArgs(t *testing.T) {
|
||||
cmd := findSub(NewConfigCmd(), "get <key>")
|
||||
if cmd == nil {
|
||||
t.Fatal("get subcommand not found")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("get should require exact args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
cmd := findSub(NewConfigCmd(), "init")
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
// Init first
|
||||
initCmd := findSub(NewConfigCmd(), "init")
|
||||
initCmd.SetArgs([]string{})
|
||||
if err := initCmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
// Set a value
|
||||
setCmd := findSub(NewConfigCmd(), "set <key> <value>")
|
||||
setCmd.SetArgs([]string{"base_url", "https://example.com"})
|
||||
if err := setCmd.Execute(); err != nil {
|
||||
t.Fatalf("set error: %v", err)
|
||||
}
|
||||
|
||||
// Get it back
|
||||
getCmd := findSub(NewConfigCmd(), "get <key>")
|
||||
getCmd.SetArgs([]string{"base_url"})
|
||||
if err := getCmd.Execute(); err != nil {
|
||||
t.Fatalf("get error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetNotSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
|
||||
|
||||
getCmd := findSub(NewConfigCmd(), "get <key>")
|
||||
getCmd.SetArgs([]string{"editor"})
|
||||
if err := getCmd.Execute(); err != nil {
|
||||
t.Fatalf("get not-set error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := findSub(NewConfigCmd(), "init")
|
||||
initCmd.SetArgs([]string{})
|
||||
if err := initCmd.Execute(); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
listCmd := findSub(NewConfigCmd(), "list")
|
||||
listCmd.SetArgs([]string{})
|
||||
if err := listCmd.Execute(); err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
cmd := newInitCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("init RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
// Init first so config file exists
|
||||
initCmd := newInitCmd()
|
||||
if err := initCmd.RunE(initCmd, nil); err != nil {
|
||||
t.Fatalf("init error: %v", err)
|
||||
}
|
||||
|
||||
cmd := newSetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
|
||||
t.Fatalf("set RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetRunENoConfig(t *testing.T) {
|
||||
// Set without init should still work — Load returns defaults, Save creates dir
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
cmd := newSetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url", "https://example.com"}); err != nil {
|
||||
t.Fatalf("set RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := newInitCmd()
|
||||
initCmd.RunE(initCmd, nil)
|
||||
|
||||
cmd := newGetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"base_url"}); err != nil {
|
||||
t.Fatalf("get RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetRunENotSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
os.WriteFile(dir+"/config.yaml", []byte("base_url: https://example.com\n"), 0644)
|
||||
|
||||
cmd := newGetCmd()
|
||||
if err := cmd.RunE(cmd, []string{"editor"}); err != nil {
|
||||
t.Fatalf("get RunE not-set error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListRunE(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
initCmd := newInitCmd()
|
||||
initCmd.RunE(initCmd, nil)
|
||||
|
||||
cmd := newListCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("list RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListRunENoConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
// Don't init — Load returns defaults for missing file, so this should work
|
||||
cmd := newListCmd()
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("list RunE error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Use == name {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
121
cmd/root.go
121
cmd/root.go
|
|
@ -1,54 +1,127 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
||||
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
type RootOptions struct {
|
||||
Version string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
ConfigLang string
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
||||
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||
if tr == nil {
|
||||
var err error
|
||||
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
version := opts.Version
|
||||
if version == "" {
|
||||
version = Version
|
||||
}
|
||||
|
||||
shortcuts.RegisterAll(rootCmd)
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "gitlink-cli",
|
||||
Short: tr.T("cmd.root.short"),
|
||||
Long: tr.T("cmd.root.long"),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
|
||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
|
||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
|
||||
|
||||
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
|
||||
rootCmd.AddCommand(newVersionCmd(version, tr))
|
||||
|
||||
shortcuts.RegisterAll(rootCmd, tr)
|
||||
|
||||
if opts.Args != nil {
|
||||
rootCmd.SetArgs(opts.Args)
|
||||
}
|
||||
return rootCmd, nil
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("gitlink-cli %s\n", Version)
|
||||
},
|
||||
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: tr.T("cmd.version.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
rootCmd, err := NewRootCmd(RootOptions{
|
||||
Version: Version,
|
||||
Args: args,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
|
||||
available, err := i18n.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env == nil {
|
||||
env = i18n.EnvMap()
|
||||
}
|
||||
if configLang == "" {
|
||||
configLang = loadConfigLangBestEffort()
|
||||
}
|
||||
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
|
||||
ExplicitLang: i18n.PreScanLang(args),
|
||||
Env: env,
|
||||
ConfigLang: configLang,
|
||||
}, available)
|
||||
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
|
||||
tr := i18n.Default()
|
||||
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
|
||||
}
|
||||
return i18n.New(i18n.Options{Locale: resolved.Locale})
|
||||
}
|
||||
|
||||
func loadConfigLangBestEffort() string {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Lang
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func TestRootHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "用于管理 GitLink 上的仓库") {
|
||||
t.Fatalf("expected Chinese root long help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "仓库操作") {
|
||||
t.Fatalf("expected Chinese shortcut group help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") {
|
||||
t.Fatalf("expected Chinese core command help, got:\n%s", help)
|
||||
}
|
||||
if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") {
|
||||
t.Fatalf("expected localized lang flag help, got:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesExplicitLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("expected %q in help, got:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpUsesEnvLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"repo", "--help"},
|
||||
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("expected %q in help, got:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitLangOverridesConfigLang(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "en-US", "--help"},
|
||||
Env: map[string]string{},
|
||||
ConfigLang: "zh-CN",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
if !strings.Contains(help, "Repository operations") {
|
||||
t.Fatalf("expected English help, got:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "仓库操作") {
|
||||
t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedExplicitLangReturnsError(t *testing.T) {
|
||||
_, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "fr-FR", "--help"},
|
||||
Env: map[string]string{},
|
||||
}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported language error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported language") {
|
||||
t.Fatalf("expected unsupported language error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireArgUsesLocalizedError(t *testing.T) {
|
||||
root, err := NewRootCmd(RootOptions{
|
||||
Version: "test",
|
||||
Args: []string{"--lang", "zh-CN", "repo", "+create"},
|
||||
Env: map[string]string{},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
err = root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing required flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "缺少必需参数") {
|
||||
t.Fatalf("expected localized missing flag error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"api", "--help"},
|
||||
want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体(JSON 字符串)"},
|
||||
},
|
||||
{
|
||||
args: []string{"auth", "login", "--help"},
|
||||
want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"},
|
||||
},
|
||||
{
|
||||
args: []string{"config", "--help"},
|
||||
want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"repo", "+create", "--help"},
|
||||
want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"},
|
||||
},
|
||||
{
|
||||
args: []string{"pr", "+review", "--help"},
|
||||
want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
args: []string{"branch", "+create", "--help"},
|
||||
want: []string{"创建分支", "--from", "源分支或 Commit"},
|
||||
},
|
||||
{
|
||||
args: []string{"release", "+create", "--help"},
|
||||
want: []string{"创建发布", "--prerelease", "标记为预发布"},
|
||||
},
|
||||
{
|
||||
args: []string{"webhook", "+create", "--help"},
|
||||
want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"},
|
||||
},
|
||||
{
|
||||
args: []string{"ci", "+logs", "--help"},
|
||||
want: []string{"查看构建日志", "--build", "构建编号"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
|
||||
help := out.String()
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionUsesInjectedVersion(t *testing.T) {
|
||||
tr, err := i18n.New(i18n.Options{Locale: "en-US"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" {
|
||||
t.Fatalf("version output = %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,482 +0,0 @@
|
|||
# GitLink-GitHub 代码双向同步方案
|
||||
|
||||
**版本**: v1.0
|
||||
**日期**: 2026-04-02
|
||||
**状态**: 已确认
|
||||
|
||||
---
|
||||
|
||||
## 1 概述
|
||||
|
||||
实现 GitHub(主仓)和 GitLink(镜像仓)的代码双向同步,确保两个平台的代码始终一致。通过 Git hooks 在关键操作点进行同步检查,自动解决冲突或提示用户手动处理。
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
- **GitHub 为主仓**:GitHub 是代码的唯一真实来源
|
||||
- **GitLink 为镜像仓**:GitLink 作为备份和协作平台
|
||||
- **Hook 控制在 GitLink**:所有同步逻辑通过 GitLink 本地 hooks 实现
|
||||
- **GitHub 完全被动**:GitHub 不需要任何 hook 操作
|
||||
- **冲突自动解决**:优先自动 rebase 解决,无法解决时提示用户
|
||||
|
||||
---
|
||||
|
||||
## 2 同步触发点
|
||||
|
||||
### 2.1 四个关键触发点
|
||||
|
||||
| 触发点 | Hook | 检查内容 | 动作 |
|
||||
|--------|------|---------|------|
|
||||
| **直接 commit** | `pre-commit` | GitLink HEAD vs GitHub HEAD | 不同步则阻止 commit |
|
||||
| **直接 push** | `pre-push` | GitLink HEAD vs GitHub HEAD | 不同步则阻止 push |
|
||||
| **创建 PR** | Webhook | 拉取最新 GitHub 代码 | 自动 rebase 到最新 GitHub 代码 |
|
||||
| **Merge PR** | Webhook | 拉取最新 GitHub 代码 + 自动 rebase | 解决冲突后 merge,并推送到 GitHub |
|
||||
|
||||
### 2.2 触发点详解
|
||||
|
||||
#### 2.2.1 Pre-commit Hook(直接 commit)
|
||||
|
||||
**场景**:用户在 GitLink 本地修改代码后执行 `git commit`
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. 用户执行: git commit -m "..."
|
||||
2. pre-commit hook 触发
|
||||
3. 检查: GitLink HEAD == GitHub HEAD?
|
||||
- 是 → 允许 commit
|
||||
- 否 → 阻止 commit,提示用户执行 rebase
|
||||
```
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-commit
|
||||
|
||||
GITHUB_HEAD=$(git ls-remote https://github.com/owner/repo HEAD | awk '{print $1}')
|
||||
GITLINK_HEAD=$(git rev-parse HEAD)
|
||||
|
||||
if [ "$GITHUB_HEAD" != "$GITLINK_HEAD" ]; then
|
||||
echo "❌ Error: GitLink HEAD 不同步 GitHub"
|
||||
echo "请执行: git fetch github && git rebase github/main"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
#### 2.2.2 Pre-push Hook(直接 push)
|
||||
|
||||
**场景**:用户在 GitLink 本地修改代码后执行 `git push`
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. 用户执行: git push origin main
|
||||
2. pre-push hook 触发
|
||||
3. 检查: GitLink HEAD == GitHub HEAD?
|
||||
- 是 → 允许 push
|
||||
- 否 → 阻止 push,提示用户执行 rebase
|
||||
```
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-push
|
||||
|
||||
GITHUB_HEAD=$(git ls-remote https://github.com/owner/repo HEAD | awk '{print $1}')
|
||||
GITLINK_HEAD=$(git rev-parse HEAD)
|
||||
|
||||
if [ "$GITHUB_HEAD" != "$GITLINK_HEAD" ]; then
|
||||
echo "❌ Error: GitLink HEAD 不同步 GitHub"
|
||||
echo "请执行: git fetch github && git rebase github/main"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
#### 2.2.3 创建 PR 时同步(Webhook)
|
||||
|
||||
**场景**:用户在 GitLink 创建 PR
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. 用户在 GitLink 创建 PR: feature → main
|
||||
2. GitLink webhook 触发
|
||||
3. 拉取最新 GitHub 代码到 GitLink
|
||||
4. 自动 rebase PR 分支到最新 GitHub main
|
||||
5. 如果冲突可自动解决 → 直接解决
|
||||
6. 如果冲突无法自动解决 → 提示用户手动解决
|
||||
```
|
||||
|
||||
**实现逻辑**:
|
||||
```
|
||||
POST /webhook/pr-created
|
||||
├─ 获取 PR 信息 (source_branch, target_branch)
|
||||
├─ git fetch github main
|
||||
├─ git checkout source_branch
|
||||
├─ git rebase github/main
|
||||
│ ├─ 冲突可解决 → 自动解决 + git rebase --continue
|
||||
│ └─ 冲突无法解决 → 提示用户,PR 标记为 "需要手动 rebase"
|
||||
└─ 更新 PR 状态
|
||||
```
|
||||
|
||||
#### 2.2.4 Merge PR 时同步(Webhook)
|
||||
|
||||
**场景**:用户在 GitLink 合并 PR
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. 用户在 GitLink 点击 "Merge PR"
|
||||
2. GitLink webhook 触发
|
||||
3. 拉取最新 GitHub 代码到 GitLink
|
||||
4. 自动 rebase PR 分支到最新 GitHub main
|
||||
5. 如果冲突可自动解决 → 直接解决 + merge
|
||||
6. 如果冲突无法自动解决 → 停止 merge,提示用户
|
||||
7. Merge 成功后,自动 push 到 GitHub
|
||||
```
|
||||
|
||||
**实现逻辑**:
|
||||
```
|
||||
POST /webhook/pr-merge
|
||||
├─ 获取 PR 信息 (source_branch, target_branch)
|
||||
├─ git fetch github main
|
||||
├─ git checkout source_branch
|
||||
├─ git rebase github/main
|
||||
│ ├─ 冲突可解决 → 自动解决 + git rebase --continue
|
||||
│ └─ 冲突无法解决 → 停止 merge,返回错误
|
||||
├─ git checkout target_branch
|
||||
├─ git merge source_branch
|
||||
├─ git push github target_branch
|
||||
└─ 更新 PR 状态为 "已合并"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3 冲突处理策略
|
||||
|
||||
### 3.1 冲突类型与解决方案
|
||||
|
||||
| 冲突类型 | 原因 | 解决方案 |
|
||||
|---------|------|--------|
|
||||
| **文件内容冲突** | 同一文件同一行被修改 | 自动 rebase(Git 尝试自动合并) |
|
||||
| **文件删除冲突** | 一边删除,一边修改 | 提示用户手动选择 |
|
||||
| **文件重命名冲突** | 同一文件被重命名为不同名称 | 提示用户手动选择 |
|
||||
| **二进制文件冲突** | 二进制文件被修改 | 提示用户手动选择 |
|
||||
|
||||
### 3.2 自动解决策略
|
||||
|
||||
**可自动解决的冲突**:
|
||||
- 不同文件的修改
|
||||
- 同一文件不同行的修改
|
||||
- 简单的文本冲突(Git 能自动合并)
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
git rebase github/main --no-edit
|
||||
|
||||
# 如果有冲突,尝试自动解决
|
||||
if [ $? -ne 0 ]; then
|
||||
# 尝试使用 ours 或 theirs 策略
|
||||
git rebase --continue --strategy=recursive -X ours
|
||||
fi
|
||||
```
|
||||
|
||||
### 3.3 无法自动解决时的处理
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. Rebase 失败,存在冲突
|
||||
2. 返回错误信息给用户
|
||||
3. PR 标记为 "冲突" 状态
|
||||
4. 用户本地手动解决冲突
|
||||
5. 用户执行: git rebase --continue
|
||||
6. 用户 push 到 GitLink
|
||||
7. Pre-push hook 检查 → 通过
|
||||
8. 用户重新点击 "Merge PR"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4 实现架构
|
||||
|
||||
### 4.1 组件清单
|
||||
|
||||
| 组件 | 位置 | 职责 |
|
||||
|------|------|------|
|
||||
| **Pre-commit Hook** | `.git/hooks/pre-commit` | 检查 commit 前的同步状态 |
|
||||
| **Pre-push Hook** | `.git/hooks/pre-push` | 检查 push 前的同步状态 |
|
||||
| **PR Created Webhook** | GitLink 服务端 | 创建 PR 时自动 rebase |
|
||||
| **PR Merged Webhook** | GitLink 服务端 | Merge PR 时自动 rebase + push GitHub |
|
||||
| **Sync CLI Command** | `gitlink-cli sync` | 手动触发同步(可选) |
|
||||
|
||||
### 4.2 数据流
|
||||
|
||||
```
|
||||
GitHub (主仓)
|
||||
↓ (git fetch)
|
||||
GitLink 本地仓库
|
||||
├─ pre-commit hook (检查同步)
|
||||
├─ pre-push hook (检查同步)
|
||||
└─ webhook (创建/合并 PR 时自动 rebase)
|
||||
↓ (git push)
|
||||
GitLink 远程仓库
|
||||
↓ (webhook)
|
||||
GitHub (推送更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5 用户工作流
|
||||
|
||||
### 5.1 场景 1:直接 commit 到 GitLink
|
||||
|
||||
```bash
|
||||
# 1. 用户在 GitLink 本地修改代码
|
||||
cd gitlink-cli
|
||||
echo "new code" >> file.txt
|
||||
|
||||
# 2. 执行 commit
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# 3. Pre-commit hook 检查
|
||||
# ✅ 如果 HEAD 同步 → commit 成功
|
||||
# ❌ 如果 HEAD 不同步 → commit 失败,提示 rebase
|
||||
|
||||
# 4. 如果失败,用户手动 rebase
|
||||
git fetch github
|
||||
git rebase github/main
|
||||
|
||||
# 5. 重新 commit
|
||||
git commit -m "feat: add new feature"
|
||||
```
|
||||
|
||||
### 5.2 场景 2:创建 PR
|
||||
|
||||
```bash
|
||||
# 1. 用户创建 feature 分支
|
||||
git checkout -b feature/new-feature
|
||||
|
||||
# 2. 修改代码并 commit
|
||||
git commit -m "feat: implement feature"
|
||||
|
||||
# 3. Push 到 GitLink
|
||||
git push origin feature/new-feature
|
||||
|
||||
# 4. 在 GitLink 创建 PR: feature/new-feature → main
|
||||
# GitLink webhook 自动触发:
|
||||
# - 拉取最新 GitHub 代码
|
||||
# - 自动 rebase feature 分支到最新 GitHub main
|
||||
# - 如果冲突可解决 → 自动解决
|
||||
# - 如果冲突无法解决 → PR 标记为 "需要手动 rebase"
|
||||
```
|
||||
|
||||
### 5.3 场景 3:Merge PR
|
||||
|
||||
```bash
|
||||
# 1. 用户在 GitLink 点击 "Merge PR"
|
||||
# GitLink webhook 自动触发:
|
||||
# - 拉取最新 GitHub 代码
|
||||
# - 自动 rebase PR 分支到最新 GitHub main
|
||||
# - 如果冲突可解决 → 自动解决 + merge
|
||||
# - 如果冲突无法解决 → merge 失败,提示用户
|
||||
|
||||
# 2. Merge 成功后,自动 push 到 GitHub
|
||||
# GitHub 代码自动更新
|
||||
```
|
||||
|
||||
### 5.4 场景 4:直接 push 到 GitLink
|
||||
|
||||
```bash
|
||||
# 1. 用户本地修改代码并 commit
|
||||
git commit -m "fix: bug fix"
|
||||
|
||||
# 2. 执行 push
|
||||
git push origin main
|
||||
|
||||
# 3. Pre-push hook 检查
|
||||
# ✅ 如果 HEAD 同步 → push 成功
|
||||
# ❌ 如果 HEAD 不同步 → push 失败,提示 rebase
|
||||
|
||||
# 4. 如果失败,用户手动 rebase
|
||||
git fetch github
|
||||
git rebase github/main
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6 配置与部署
|
||||
|
||||
### 6.1 Hook 安装
|
||||
|
||||
在 gitlink-cli 项目中创建 hooks:
|
||||
|
||||
```bash
|
||||
# 创建 hooks 目录
|
||||
mkdir -p .githooks
|
||||
|
||||
# 创建 pre-commit hook
|
||||
cat > .githooks/pre-commit << 'EOF'
|
||||
#!/bin/bash
|
||||
GITHUB_HEAD=$(git ls-remote https://github.com/owner/repo HEAD | awk '{print $1}')
|
||||
GITLINK_HEAD=$(git rev-parse HEAD)
|
||||
if [ "$GITHUB_HEAD" != "$GITLINK_HEAD" ]; then
|
||||
echo "❌ Error: GitLink HEAD 不同步 GitHub"
|
||||
echo "请执行: git fetch github && git rebase github/main"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
# 创建 pre-push hook
|
||||
cat > .githooks/pre-push << 'EOF'
|
||||
#!/bin/bash
|
||||
GITHUB_HEAD=$(git ls-remote https://github.com/owner/repo HEAD | awk '{print $1}')
|
||||
GITLINK_HEAD=$(git rev-parse HEAD)
|
||||
if [ "$GITHUB_HEAD" != "$GITLINK_HEAD" ]; then
|
||||
echo "❌ Error: GitLink HEAD 不同步 GitHub"
|
||||
echo "请执行: git fetch github && git rebase github/main"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
# 设置权限
|
||||
chmod +x .githooks/pre-commit .githooks/pre-push
|
||||
|
||||
# 配置 Git 使用这些 hooks
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
### 6.2 Webhook 配置
|
||||
|
||||
在 GitLink 项目设置中配置 webhooks:
|
||||
|
||||
**PR Created Webhook**:
|
||||
- URL: `https://your-server/webhook/pr-created`
|
||||
- 事件: Pull Request Created
|
||||
- 负载: PR 信息(source_branch, target_branch, pr_id)
|
||||
|
||||
**PR Merged Webhook**:
|
||||
- URL: `https://your-server/webhook/pr-merged`
|
||||
- 事件: Pull Request Merged
|
||||
- 负载: PR 信息(source_branch, target_branch, pr_id)
|
||||
|
||||
---
|
||||
|
||||
## 7 风险与缓解
|
||||
|
||||
### 7.1 潜在风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|--------|
|
||||
| **Rebase 失败** | PR 无法合并 | 提示用户手动解决,PR 标记为冲突 |
|
||||
| **GitHub 网络不可达** | Hook 超时 | 设置超时时间,失败时提示用户 |
|
||||
| **Webhook 失败** | 同步不及时 | 重试机制 + 手动同步命令 |
|
||||
| **并发 merge** | 数据不一致 | 使用分布式锁或队列 |
|
||||
|
||||
### 7.2 缓解方案
|
||||
|
||||
**Hook 超时处理**:
|
||||
```bash
|
||||
timeout 5 git ls-remote https://github.com/owner/repo HEAD
|
||||
if [ $? -eq 124 ]; then
|
||||
echo "⚠️ Warning: GitHub 网络超时,跳过同步检查"
|
||||
exit 0 # 允许操作继续
|
||||
fi
|
||||
```
|
||||
|
||||
**Webhook 重试**:
|
||||
```
|
||||
失败 → 等待 5 秒 → 重试
|
||||
失败 → 等待 10 秒 → 重试
|
||||
失败 → 等待 30 秒 → 重试
|
||||
失败 → 记录日志,通知管理员
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8 手动同步命令(可选)
|
||||
|
||||
提供 CLI 命令供用户手动触发同步:
|
||||
|
||||
```bash
|
||||
# 手动同步 GitLink 到最新 GitHub 代码
|
||||
gitlink-cli sync --from github --to gitlink
|
||||
|
||||
# 手动同步 GitHub 到最新 GitLink 代码(不推荐)
|
||||
gitlink-cli sync --from gitlink --to github
|
||||
|
||||
# 查看同步状态
|
||||
gitlink-cli sync status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9 验证与测试
|
||||
|
||||
### 9.1 测试场景
|
||||
|
||||
| 场景 | 预期结果 | 验证方法 |
|
||||
|------|---------|--------|
|
||||
| GitHub 有新代码,GitLink 直接 commit | Commit 失败,提示 rebase | 执行 commit,检查错误信息 |
|
||||
| GitHub 有新代码,GitLink 直接 push | Push 失败,提示 rebase | 执行 push,检查错误信息 |
|
||||
| 创建 PR 时 GitHub 有新代码 | 自动 rebase,PR 创建成功 | 创建 PR,检查 PR 状态 |
|
||||
| Merge PR 时 GitHub 有新代码 | 自动 rebase + merge,推送 GitHub | Merge PR,检查 GitHub 代码 |
|
||||
| 冲突无法自动解决 | PR 标记为<E8AEB0><E4B8BA>突,提示用户 | 创建冲突 PR,检查状态 |
|
||||
|
||||
### 9.2 测试命令
|
||||
|
||||
```bash
|
||||
# 1. 测试 pre-commit hook
|
||||
git commit -m "test"
|
||||
|
||||
# 2. 测试 pre-push hook
|
||||
git push origin main
|
||||
|
||||
# 3. 测试 PR 创建同步
|
||||
# 在 GitLink 创建 PR,检查是否自动 rebase
|
||||
|
||||
# 4. 测试 PR 合并同步
|
||||
# 在 GitLink 合并 PR,检查 GitHub 是否更新
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10 后续优化
|
||||
|
||||
### 10.1 短期(1-2 周)
|
||||
|
||||
- [ ] 实现 pre-commit 和 pre-push hooks
|
||||
- [ ] 实现 PR Created 和 PR Merged webhooks
|
||||
- [ ] 编写测试用例
|
||||
- [ ] 文档完善
|
||||
|
||||
### 10.2 中期(2-4 周)
|
||||
|
||||
- [ ] 实现手动同步 CLI 命令
|
||||
- [ ] 添加同步状态监控
|
||||
- [ ] 优化冲突自动解决策略
|
||||
- [ ] 添加日志和告警
|
||||
|
||||
### 10.3 长期(1 个月+)
|
||||
|
||||
- [ ] Issue 和 PR 元数据同步
|
||||
- [ ] 评论和 Review 同步
|
||||
- [ ] 自动化测试和 CI/CD 集成
|
||||
- [ ] 性能优化和扩展性改进
|
||||
|
||||
---
|
||||
|
||||
## 11 总结
|
||||
|
||||
本方案通过 **Git hooks + Webhooks** 的组合,实现了 GitHub 和 GitLink 的代码双向同步。核心特点:
|
||||
|
||||
✅ **GitHub 为主仓**:确保代码唯一真实来源
|
||||
✅ **自动冲突解决**:优先自动 rebase,无法解决时提示用户
|
||||
✅ **多触发点**:commit、push、PR 创建、PR 合并都有同步检查
|
||||
✅ **用户友好**:清晰的错误提示和恢复指导
|
||||
✅ **低风险**:失败时提示用户,不会自动破坏代码
|
||||
|
||||
---
|
||||
|
||||
**审批人**: [待确认]
|
||||
**实施日期**: [待定]
|
||||
**联系人**: [待定]
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
# GitLink-GitHub 代码双向同步方案(最终版)
|
||||
|
||||
**版本**: v2.0(最终确认)
|
||||
**日期**: 2026-04-02
|
||||
**状态**: 已确认,可实施
|
||||
|
||||
---
|
||||
|
||||
## 1 核心原则
|
||||
|
||||
- **GitHub 为主仓**:GitHub 是代码的唯一真实来源
|
||||
- **GitLink 为镜像仓**:GitLink 作为备份和协作平台
|
||||
- **所有操作在 GitLink 服务端**:无需用户本地配置
|
||||
- **Main 分支保护**:只能通过 PR merge,禁止直接 push
|
||||
- **关键点同步**:PR create/patch/merge 时自动同步 GitHub
|
||||
|
||||
---
|
||||
|
||||
## 2 同步触发点
|
||||
|
||||
### 2.1 三个关键触发点
|
||||
|
||||
| 触发点 | 事件 | 操作 |
|
||||
|--------|------|------|
|
||||
| **PR Create** | 用户创建 PR | fetch GitHub + rebase |
|
||||
| **PR Patch** | 用户修改 PR(push 新 commit) | fetch GitHub + rebase |
|
||||
| **PR Merge** | 用户点击 merge | fetch GitHub + rebase + merge + push GitHub |
|
||||
|
||||
### 2.2 详细流程
|
||||
|
||||
#### 2.2.1 PR Create 时同步
|
||||
|
||||
**触发**:用户在 GitLink 创建 PR(feature → main)
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. GitLink webhook 接收 PR created 事件
|
||||
2. 执行:
|
||||
├─ git fetch github main
|
||||
├─ git checkout feature_branch
|
||||
├─ git rebase github/main
|
||||
├─ 检查是否有冲突
|
||||
│ ├─ 有冲突 → PR 标记为 "需要 rebase"
|
||||
│ │ 返回错误信息给用户
|
||||
│ └─ 无冲突 → PR 状态正常,可以 merge
|
||||
└─ 完成
|
||||
```
|
||||
|
||||
**用户体验**:
|
||||
- 如果无冲突:PR 创建成功,可以 merge
|
||||
- 如果有冲突:PR 创建成功,但标记为冲突,提示用户本地解决冲突后重新 push
|
||||
|
||||
#### 2.2.2 PR Patch 时同步
|
||||
|
||||
**触发**:用户修改 PR(在 feature 分支上新增 commit 并 push)
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. GitLink webhook 接收 PR updated 事件
|
||||
2. 执行:
|
||||
├─ git fetch github main
|
||||
├─ git checkout feature_branch
|
||||
├─ git rebase github/main
|
||||
├─ 检查是否有冲突
|
||||
│ ├─ 有冲突 → PR 标记为 "需要 rebase"
|
||||
│ │ 返回错误信息给用户
|
||||
│ └─ 无冲突 → PR 状态正常,可以 merge
|
||||
└─ 完成
|
||||
```
|
||||
|
||||
**用户体验**:
|
||||
- 每次 push 新 commit 时,自动检查是否与 GitHub 最新代码冲突
|
||||
- 如果有冲突,立即提示用户
|
||||
|
||||
#### 2.2.3 PR Merge 时同步
|
||||
|
||||
**触发**:用户在 GitLink 点击 "Merge PR"
|
||||
|
||||
**流程**:
|
||||
```
|
||||
1. GitLink webhook 接收 PR merged 事件
|
||||
2. 执行(事务性操作):
|
||||
├─ git fetch github main
|
||||
├─ git checkout feature_branch
|
||||
├─ git rebase github/main
|
||||
├─ 检查是否有冲突
|
||||
│ ├─ 有冲突 → merge 失败
|
||||
│ │ 返回错误信息给用户
|
||||
│ │ PR 状态回滚到 "open"
|
||||
│ └─ 无冲突 → 继续
|
||||
├─ git checkout main
|
||||
├─ git merge feature_branch
|
||||
├─ git push github main
|
||||
├─ 检查 push 是否成功
|
||||
│ ├─ 失败 → merge 失败,回滚
|
||||
│ └─ 成功 → merge 成功,PR 标记为 "merged"
|
||||
└─ 完成
|
||||
```
|
||||
|
||||
**用户体验**:
|
||||
- 点击 merge 后,自动完成所有同步操作
|
||||
- 如果有冲突或 push 失败,立即反馈给用户
|
||||
- 成功后,GitHub 和 GitLink 代码自动同步
|
||||
|
||||
---
|
||||
|
||||
## 3 Main 分支保护
|
||||
|
||||
### 3.1 保护规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 禁止直接 push | 用户不能直接 push 到 main 分支 |
|
||||
| 只能 PR merge | main 分支只能通过 PR merge 更新 |
|
||||
| 禁止 force push | GitLink 禁止所有 force push 操作 |
|
||||
|
||||
### 3.2 实现
|
||||
|
||||
在 GitLink 服务端配置分支保护:
|
||||
|
||||
```
|
||||
项目设置 → 分支保护
|
||||
├─ 分支名称: main
|
||||
├─ 禁止直接 push: ✅
|
||||
├─ 禁止 force push: ✅
|
||||
└─ 只能通过 PR merge: ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4 用户工作流
|
||||
|
||||
### 4.1 场景 1:创建 PR
|
||||
|
||||
```bash
|
||||
# 1. 用户在本地创建 feature 分支
|
||||
git checkout -b feature/new-feature
|
||||
|
||||
# 2. 修改代码并 commit
|
||||
git commit -m "feat: implement feature"
|
||||
|
||||
# 3. Push 到 GitLink
|
||||
git push origin feature/new-feature
|
||||
|
||||
# 4. 在 GitLink 创建 PR: feature/new-feature → main
|
||||
# GitLink webhook 自动触发:
|
||||
# - fetch github main
|
||||
# - rebase feature 到 github/main
|
||||
# - 如果无冲突 → PR 创建成功
|
||||
# - 如果有冲突 → PR 标记为 "需要 rebase",提示用户
|
||||
```
|
||||
|
||||
### 4.2 场景 2:修改 PR(Patch)
|
||||
|
||||
```bash
|
||||
# 1. 用户在 feature 分支继续修改
|
||||
git add .
|
||||
git commit -m "fix: address review comments"
|
||||
|
||||
# 2. Push 到 GitLink
|
||||
git push origin feature/new-feature
|
||||
|
||||
# 3. GitLink webhook 自动触发:
|
||||
# - fetch github main
|
||||
# - rebase feature 到 github/main
|
||||
# - 如果无冲突 → PR 更新成功
|
||||
# - 如果有冲突 → PR 标记为 "需要 rebase",提示用户
|
||||
```
|
||||
|
||||
### 4.3 场景 3:Merge PR
|
||||
|
||||
```bash
|
||||
# 1. 用户在 GitLink 点击 "Merge PR"
|
||||
# GitLink webhook 自动触发:
|
||||
# - fetch github main
|
||||
# - rebase feature 到 github/main
|
||||
# - merge feature 到 main
|
||||
# - push 到 github main
|
||||
# - 如果无冲突 → merge 成功,GitHub 自动更新
|
||||
# - 如果有冲突 → merge 失败,提示用户
|
||||
```
|
||||
|
||||
### 4.4 场景 4:直接 Push(不允许)
|
||||
|
||||
```bash
|
||||
# 用户尝试直接 push 到 main
|
||||
git push origin main
|
||||
|
||||
# GitLink 拒绝:
|
||||
# ❌ Error: 禁止直接 push 到 main 分支
|
||||
# 请通过 PR merge 提交代码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5 冲突处理
|
||||
|
||||
### 5.1 冲突检测
|
||||
|
||||
在 PR create/patch/merge 时,GitLink 自动检查是否有冲突:
|
||||
|
||||
```bash
|
||||
git rebase github/main
|
||||
|
||||
# 如果有冲突,rebase 会失败
|
||||
if [ $? -ne 0 ]; then
|
||||
# 有冲突
|
||||
return error "冲突检测"
|
||||
fi
|
||||
```
|
||||
|
||||
### 5.2 冲突提示
|
||||
|
||||
当检测到冲突时,返回给用户:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "CONFLICT",
|
||||
"message": "PR 与 GitHub 最新代码有冲突",
|
||||
"details": {
|
||||
"conflicted_files": ["file1.js", "file2.js"],
|
||||
"suggestion": "请在本地解决冲突后重新 push"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 用户解决冲突
|
||||
|
||||
```bash
|
||||
# 1. 用户本地拉取最新代码
|
||||
git fetch origin
|
||||
git fetch github
|
||||
|
||||
# 2. 本地 rebase 到 github/main
|
||||
git rebase github/main
|
||||
|
||||
# 3. 手动解决冲突
|
||||
# 编辑冲突文件,解决冲突
|
||||
|
||||
# 4. 继续 rebase
|
||||
git add .
|
||||
git rebase --continue
|
||||
|
||||
# 5. 强制推送到 GitLink(覆盖之前的 commit)
|
||||
git push origin feature/new-feature --force-with-lease
|
||||
|
||||
# 6. GitLink 再次检查冲突
|
||||
# 如果无冲突 → PR 更新成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6 错误处理
|
||||
|
||||
### 6.1 常见错误
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|------|------|--------|
|
||||
| 冲突 | PR 与 GitHub 最新代码冲突 | 本地解决冲突后重新 push |
|
||||
| Push 失败 | GitHub 网络问题 | 重试或联系管理员 |
|
||||
| Merge 失败 | 冲突或权限问题 | 检查冲突或权限 |
|
||||
|
||||
### 6.2 错误恢复
|
||||
|
||||
**如果 PR merge 失败**:
|
||||
|
||||
```
|
||||
1. GitLink 自动回滚 merge 操作
|
||||
2. PR 状态回到 "open"
|
||||
3. 用户收到错误提示
|
||||
4. 用户解决问题后重新 merge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7 实现清单
|
||||
|
||||
### 7.1 GitLink 服务端
|
||||
|
||||
- [ ] 配置 main 分支保护(禁止直接 push、禁止 force push)
|
||||
- [ ] 实现 PR created webhook
|
||||
- [ ] fetch github main
|
||||
- [ ] rebase feature 到 github/main
|
||||
- [ ] 检查冲突,标记 PR 状态
|
||||
- [ ] 实现 PR updated webhook
|
||||
- [ ] fetch github main
|
||||
- [ ] rebase feature 到 github/main
|
||||
- [ ] 检查冲突,标记 PR 状态
|
||||
- [ ] 实现 PR merged webhook(事务性操作)
|
||||
- [ ] fetch github main
|
||||
- [ ] rebase feature 到 github/main
|
||||
- [ ] merge feature 到 main
|
||||
- [ ] push 到 github main
|
||||
- [ ] 失败时回滚
|
||||
|
||||
### 7.2 错误提示
|
||||
|
||||
- [ ] 冲突时返回详细错误信息
|
||||
- [ ] 包含冲突文件列表
|
||||
- [ ] 包含解决方案建议
|
||||
|
||||
### 7.3 文档
|
||||
|
||||
- [ ] 更新 README,说明 main 分支保护规则
|
||||
- [ ] 编写用户指南,说明 PR 工作流
|
||||
- [ ] 编写故障排查指南
|
||||
|
||||
---
|
||||
|
||||
## 8 验证与测试
|
||||
|
||||
### 8.1 测试场景
|
||||
|
||||
| 场景 | 预期结果 |
|
||||
|------|---------|
|
||||
| 创建无冲突 PR | PR 创建成功 |
|
||||
| 创建有冲突 PR | PR 标记为冲突,提示用户 |
|
||||
| Patch 无冲突 | PR 更新成功 |
|
||||
| Patch 有冲突 | PR 标记为冲突,提示用户 |
|
||||
| Merge 无冲突 | Merge 成功,GitHub 自动更新 |
|
||||
| Merge 有冲突 | Merge 失败,PR 回滚到 open |
|
||||
| 直接 push main | 拒绝,提示只能 PR merge |
|
||||
| Force push | 拒绝,提示禁止 force push |
|
||||
|
||||
### 8.2 测试命令
|
||||
|
||||
```bash
|
||||
# 1. 创建 PR
|
||||
git checkout -b feature/test
|
||||
echo "test" >> file.txt
|
||||
git commit -m "test"
|
||||
git push origin feature/test
|
||||
# 在 GitLink 创建 PR
|
||||
|
||||
# 2. 修改 PR
|
||||
echo "test2" >> file.txt
|
||||
git commit -m "test2"
|
||||
git push origin feature/test
|
||||
|
||||
# 3. Merge PR
|
||||
# 在 GitLink 点击 merge
|
||||
|
||||
# 4. 验证 GitHub 是否更新
|
||||
git log --oneline # 检查 GitHub 是否有新 commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9 总结
|
||||
|
||||
**方案特点**:
|
||||
|
||||
✅ **简洁**:只在 PR create/patch/merge 时同步
|
||||
✅ **安全**:main 分支保护,禁止直接 push
|
||||
✅ **可靠**:事务性 merge,失败自动回滚
|
||||
✅ **用户友好**:清晰的错误提示和恢复指导
|
||||
✅ **无需本地配置**:所有操作在 GitLink 服务端
|
||||
|
||||
**预期效果**:
|
||||
|
||||
- GitHub 和 GitLink 代码始终一致
|
||||
- 用户只需正常 git 操作
|
||||
- 冲突自动检测,提示用户解决
|
||||
- 代码质量有保证(PR review + merge)
|
||||
|
||||
---
|
||||
|
||||
**审批**:已确认
|
||||
**实施日期**:待定
|
||||
**联系人**:待定
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
# 代码同步方案 - 深度审视与优化
|
||||
|
||||
**审视日期**: 2026-04-02
|
||||
**审视范围**: 用户场景、漏洞、优化点
|
||||
|
||||
---
|
||||
|
||||
## 1 发现的关键漏洞
|
||||
|
||||
### 1.1 漏洞 1:GitHub 直接 push 无法同步到 GitLink
|
||||
|
||||
**问题描述**:
|
||||
- 用户在 GitHub 直接 push 代码(不通过 GitLink)
|
||||
- GitLink 本地仓库不知道 GitHub 有新代码
|
||||
- 下次用户在 GitLink 操作时,HEAD 已经不同步,但用户不知道
|
||||
|
||||
**场景**:
|
||||
```
|
||||
1. 用户在 GitHub Web UI 直接修改文件并 commit
|
||||
2. 或用户在另一台机器 push 到 GitHub
|
||||
3. GitLink 本地仓库 HEAD 仍指向旧代码
|
||||
4. 用户在 GitLink 执行 commit/push 时,pre-commit/pre-push hook 才发现不同步
|
||||
5. 用户被迫 rebase,但此时可能已经做了本地修改
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 用户体验差:突然被告知需要 rebase
|
||||
- 可能丢失本地修改:如果用户强制操作
|
||||
|
||||
**优化方案**:
|
||||
- 添加 **post-checkout hook**:每次切换分支时检查 GitHub 是否有新代码
|
||||
- 添加 **post-merge hook**:每次 merge 后检查 GitHub 是否有新代码
|
||||
- 提供 **定时同步任务**:每 N 分钟自动检查一次 GitHub 是否有新代码
|
||||
|
||||
---
|
||||
|
||||
### 1.2 漏洞 2:Force Push 绕过 Hook
|
||||
|
||||
**问题描述**:
|
||||
- 用户可以使用 `git push --force` 绕过 pre-push hook
|
||||
- 这会导致 GitLink 和 GitHub 代码不一致
|
||||
|
||||
**场景**:
|
||||
```bash
|
||||
git push origin main --force # 绕过 pre-push hook
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 破坏同步机制
|
||||
- 可能覆盖他人代码
|
||||
|
||||
**优化方案**:
|
||||
- 在 pre-push hook 中检查 `--force` 标志,直接拒绝
|
||||
- 或在 GitLink 服务端配置分支保护,禁止 force push
|
||||
|
||||
---
|
||||
|
||||
### 1.3 漏洞 3:多分支场景处理不清
|
||||
|
||||
**问题描述**:
|
||||
- 方案只考虑了 `main` 分支的同步
|
||||
- 用户可能在多个分支上工作(develop、feature 等)
|
||||
- 不同分支的同步策略不同
|
||||
|
||||
**场景**:
|
||||
```
|
||||
1. 用户在 feature 分支工作
|
||||
2. GitHub 的 main 分支有新代码
|
||||
3. 用户在 feature 分支 commit,pre-commit hook 检查 main 分支
|
||||
4. 但用户实际在 feature 分支,不需要同步 main
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- Hook 逻辑不清:应该检查当前分支还是 main 分支?
|
||||
- 可能误报或漏报
|
||||
|
||||
**优化方案**:
|
||||
- **明确分支同步策略**:
|
||||
- `main` 分支:必须与 GitHub main 同步
|
||||
- `develop` 分支:必须与 GitHub develop 同步
|
||||
- `feature/*` 分支:只需与本地 main 同步(可选)
|
||||
- Hook 根据当前分支选择对应的检查策略
|
||||
|
||||
---
|
||||
|
||||
### 1.4 漏洞 4:Rebase 冲突后的恢复流程不清
|
||||
|
||||
**问题描述**:
|
||||
- 用户执行 `git rebase github/main` 后,如果有冲突
|
||||
- 用户手动解决冲突后,需要 `git rebase --continue`
|
||||
- 但方案没有明确说明这个流程
|
||||
|
||||
**场景**:
|
||||
```bash
|
||||
git rebase github/main
|
||||
# 冲突!
|
||||
# 用户手动解决冲突
|
||||
git add .
|
||||
git rebase --continue
|
||||
# 现在可以 commit 了
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 用户可能不知道如何恢复
|
||||
- 可能导致 rebase 中止或错误操作
|
||||
|
||||
**优化方案**:
|
||||
- 在 hook 失败时提供详细的恢复指导
|
||||
- 提供 `gitlink-cli sync --resolve` 命令自动处理恢复流程
|
||||
|
||||
---
|
||||
|
||||
### 1.5 漏洞 5:Webhook 失败时的数据一致性问题
|
||||
|
||||
**问题描述**:
|
||||
- PR 在 GitLink 成功 merge,但 webhook 推送到 GitHub 失败
|
||||
- GitLink 和 GitHub 代码不一致,且无法自动恢复
|
||||
|
||||
**场景**:
|
||||
```
|
||||
1. 用户在 GitLink merge PR
|
||||
2. GitLink 本地 merge 成功
|
||||
3. Webhook 尝试 push 到 GitHub,但网络失败
|
||||
4. GitLink 已 merge,GitHub 未更新
|
||||
5. 下次用户操作时,发现不一致
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 数据不一致
|
||||
- 需要手动干预恢复
|
||||
|
||||
**优化方案**:
|
||||
- **事务性操作**:merge 和 push 作为一个原子操作
|
||||
- **重试机制**:webhook 失败时自动重试(指数退避)
|
||||
- **死信队列**:重试失败后放入队列,定期重试
|
||||
- **监控告警**:同步失<EFBFBD><EFBFBD><EFBFBD>时立即告警
|
||||
|
||||
---
|
||||
|
||||
### 1.6 漏洞 6:并发操作导致的竞态条件
|
||||
|
||||
**问题描述**:
|
||||
- 多个用户同时在 GitLink 和 GitHub 操作
|
||||
- 可能导致竞态条件和数据不一致
|
||||
|
||||
**场景**:
|
||||
```
|
||||
时间线:
|
||||
T1: 用户 A 在 GitLink merge PR1
|
||||
T2: 用户 B 在 GitHub push 代码
|
||||
T3: 用户 A 的 webhook 尝试 push 到 GitHub
|
||||
T4: 冲突!GitHub 已有用户 B 的代码
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- Push 失败
|
||||
- 需要手动解决
|
||||
|
||||
**优化方案**:
|
||||
- **分布式锁**:merge 时加锁,防止并发
|
||||
- **版本控制**:记录每次同步的版本号
|
||||
- **冲突检测**:push 前检查 GitHub 是否有新代码
|
||||
|
||||
---
|
||||
|
||||
### 1.7 漏洞 7:Tag 和 Release 的同步
|
||||
|
||||
**问题描述**:
|
||||
- 方案只考虑了代码同步
|
||||
- 没有考虑 tag 和 release 的同步
|
||||
|
||||
**场景**:
|
||||
```
|
||||
1. 用户在 GitLink 创建 release v1.0.0
|
||||
2. GitHub 没有对应的 tag 和 release
|
||||
3. 两个平台的版本信息不一致
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- 版本管理混乱
|
||||
- 用户困惑
|
||||
|
||||
**优化方案**:
|
||||
- 添加 release 同步逻辑
|
||||
- 创建 release 时自动同步到 GitHub
|
||||
|
||||
---
|
||||
|
||||
## 2 用户场景分析
|
||||
|
||||
### 2.1 场景 1:多设备开发
|
||||
|
||||
**用户行为**:
|
||||
```
|
||||
设备 A(GitLink)→ commit → push GitLink
|
||||
设备 B(GitHub)→ commit → push GitHub
|
||||
设备 A(GitLink)→ commit → 发现不同步
|
||||
```
|
||||
|
||||
**当<><E5BD93>方案的问题**:
|
||||
- 设备 A 在 commit 时才发现不同步
|
||||
- 此时已经做了本地修改,需要 rebase
|
||||
|
||||
**优化**:
|
||||
- 添加 post-checkout hook,切换分支时检查同步状态
|
||||
- 提示用户 GitHub 有新代码,建议 rebase
|
||||
|
||||
---
|
||||
|
||||
### 2.2 场景 2:紧急修复
|
||||
|
||||
**用户行为**:
|
||||
```
|
||||
1. 用户在 GitHub 直接修复 bug(通过 Web UI)
|
||||
2. 用户回到 GitLink,继续开发
|
||||
3. 用户 commit,发现需要 rebase
|
||||
```
|
||||
|
||||
**当前方案的问题**:
|
||||
- 用户体验差,被迫中断开发流程
|
||||
|
||||
**优化**:
|
||||
- 提供 `gitlink-cli sync` 命令,用户可主动同步
|
||||
- 在 IDE 中集成同步提示
|
||||
|
||||
---
|
||||
|
||||
### 2.3 场景 3:大型团队协作
|
||||
|
||||
**用户行为**:
|
||||
```
|
||||
1. 多个开发者同时在 GitLink 和 GitHub 操作
|
||||
2. PR 频繁创建和合并
|
||||
3. 代码冲突频繁
|
||||
```
|
||||
|
||||
**当前方案的问题**:
|
||||
- 没有考虑并发控制
|
||||
- 可能导致数据不一致
|
||||
|
||||
**优化**:
|
||||
- 添加分布式锁
|
||||
- 添加冲突检测和自动解决
|
||||
|
||||
---
|
||||
|
||||
### 2.4 场景 4:离线开发
|
||||
|
||||
**用户行为**:
|
||||
```
|
||||
1. 用户离线开发,多次 commit
|
||||
2. 用户上线后,尝试 push
|
||||
3. 发现 GitHub 有新代码,需要 rebase
|
||||
```
|
||||
|
||||
**当前方案的问题**:
|
||||
- 用户需要 rebase 多次 commit
|
||||
- 可能很复杂
|
||||
|
||||
**优化**:
|
||||
- 提供 `gitlink-cli sync --squash` 命令,合并 commit 后再 rebase
|
||||
- 简化恢复流程
|
||||
|
||||
---
|
||||
|
||||
## 3 优化建议
|
||||
|
||||
### 3.1 优化 1:完善 Hook 体系
|
||||
|
||||
**添加的 Hooks**:
|
||||
|
||||
| Hook | 触发时机 | 职责 |
|
||||
|------|---------|------|
|
||||
| `pre-commit` | commit 前 | 检查 HEAD 同步 |
|
||||
| `pre-push` | push 前 | 检查 HEAD 同步 |
|
||||
| `post-checkout` | 切换分支后 | 检查 GitHub 是否有新代码,提示用户 |
|
||||
| `post-merge` | merge 后 | 检查 GitHub 是否有新代码,提示用户 |
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
# post-checkout hook
|
||||
#!/bin/bash
|
||||
GITHUB_HEAD=$(git ls-remote https://github.com/owner/repo HEAD | awk '{print $1}')
|
||||
GITLINK_HEAD=$(git rev-parse HEAD)
|
||||
|
||||
if [ "$GITHUB_HEAD" != "$GITLINK_HEAD" ]; then
|
||||
echo "ℹ️ Info: GitHub 有新代码,建议执行: git fetch github && git rebase github/main"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 优化 2:明确分支同步策略
|
||||
|
||||
**分支分类**:
|
||||
|
||||
| 分支类型 | 同步策略 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `main` | 必须同步 | 主分支,必须与 GitHub 同步 |
|
||||
| `develop` | 必须同步 | 开发分支,必须与 GitHub 同步 |
|
||||
| `feature/*` | 可选同步 | 功能分支,可选与 main 同步 |
|
||||
| `hotfix/*` | 必须同步 | 紧急修复,必须与 GitHub 同步 |
|
||||
|
||||
**Hook 实现**:
|
||||
```bash
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
case $CURRENT_BRANCH in
|
||||
main|develop|hotfix/*)
|
||||
# 必<><E5BF85>同步
|
||||
check_sync_required
|
||||
;;
|
||||
feature/*)
|
||||
# 可选同步
|
||||
check_sync_optional
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 优化 3:添加主动同步命令
|
||||
|
||||
**新增命令**:
|
||||
|
||||
```bash
|
||||
# 检查同步状态
|
||||
gitlink-cli sync status
|
||||
|
||||
# 主动同步(拉取 GitHub 最新代码)
|
||||
gitlink-cli sync pull
|
||||
|
||||
# 主动同步并 rebase(如果有冲突)
|
||||
gitlink-cli sync pull --rebase
|
||||
|
||||
# 自动解决冲突并继续
|
||||
gitlink-cli sync resolve
|
||||
|
||||
# 查看同步日志
|
||||
gitlink-cli sync logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 优化 4:添加事务性 Merge
|
||||
|
||||
**改进 Merge 流程**:
|
||||
|
||||
```
|
||||
1. 开始事务
|
||||
2. 拉取最新 GitHub 代码
|
||||
3. Rebase PR 分支
|
||||
4. Merge 到 main
|
||||
5. Push 到 GitHub
|
||||
6. 提交事务
|
||||
7. 如果任何步骤失败,回滚事务
|
||||
```
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
# 伪代码
|
||||
begin_transaction()
|
||||
try:
|
||||
git fetch github
|
||||
git rebase github/main
|
||||
git merge source_branch
|
||||
git push github main
|
||||
commit_transaction()
|
||||
except:
|
||||
rollback_transaction()
|
||||
raise error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 优化 5:添加冲突检测和自动解决
|
||||
|
||||
**冲突检测**:
|
||||
|
||||
```bash
|
||||
# 检查是否有冲突
|
||||
git diff --name-only --diff-filter=U
|
||||
|
||||
# 如果有冲突,尝试自动解决
|
||||
if [ -n "$(git diff --name-only --diff-filter=U)" ]; then
|
||||
# 尝试使用 ours 策略
|
||||
git checkout --ours .
|
||||
git add .
|
||||
git rebase --continue
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 优化 6:添加监控和告警
|
||||
|
||||
**监控指标**:
|
||||
|
||||
| 指标 | 告警条件 |
|
||||
|------|---------|
|
||||
| Webhook 失败率 | > 5% |
|
||||
| Sync 延迟 | > 5 分钟 |
|
||||
| 冲突率 | > 10% |
|
||||
| 数据不一致 | 任何检测到 |
|
||||
|
||||
**实现**:
|
||||
```bash
|
||||
# 记录同步日志
|
||||
log_sync_event(
|
||||
event_type: "merge",
|
||||
status: "success|failure",
|
||||
duration: 1.5s,
|
||||
conflict_count: 0,
|
||||
timestamp: 2026-04-02T10:30:00Z
|
||||
)
|
||||
|
||||
# 定期检查指标
|
||||
if webhook_failure_rate > 0.05:
|
||||
alert("Webhook 失败率过高")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 优化 7:添加用户指导和恢复工具
|
||||
|
||||
**改进错误提示**:
|
||||
|
||||
```bash
|
||||
# 当前
|
||||
❌ Error: GitLink HEAD 不同步 GitHub
|
||||
请执行: git fetch github && git rebase github/main
|
||||
|
||||
# 优化后
|
||||
❌ Error: GitLink HEAD 不同步 GitHub
|
||||
|
||||
原因:GitHub 有新代码,GitLink 本地未同步
|
||||
|
||||
解决方案:
|
||||
1. 拉取最新代码: git fetch github
|
||||
2. Rebase 到最新: git rebase github/main
|
||||
3. 如果有冲突,手动解决后执行: git rebase --continue
|
||||
4. 重新 commit: git commit -m "..."
|
||||
|
||||
或使用自动恢复命令:
|
||||
gitlink-cli sync resolve
|
||||
|
||||
需要帮助?查看文档:https://docs.gitlink.org.cn/sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 优化 8:添加 Force Push 保护
|
||||
|
||||
**在 pre-push hook 中检查**:
|
||||
|
||||
```bash
|
||||
# 检查是否使用了 --force
|
||||
if [[ "$@" == *"--force"* ]] || [[ "$@" == *"-f"* ]]; then
|
||||
echo "❌ Error: 禁止使用 force push"
|
||||
echo "如果需要强制推送,请联系管理员"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4 修订后的方案架构
|
||||
|
||||
### 4.1 完整的 Hook 体系
|
||||
|
||||
```
|
||||
用户操作
|
||||
├─ git checkout branch
|
||||
│ └─ post-checkout hook
|
||||
│ └─ 检查 GitHub 是否有新代码(提示,不阻止)
|
||||
├─ git commit
|
||||
│ └─ pre-commit hook
|
||||
│ └─ 检查 HEAD 是否同步(阻止)
|
||||
├─ git push
|
||||
│ └─ pre-push hook
|
||||
│ ├─ 检查 --force 标志(阻止)
|
||||
│ └─ 检查 HEAD 是否同步(阻止)
|
||||
└─ git merge
|
||||
└─ post-merge hook
|
||||
└─ 检查 GitHub 是否有新代码(提示,不阻止)
|
||||
```
|
||||
|
||||
### 4.2 完整的 Webhook 体系
|
||||
|
||||
```
|
||||
GitLink 事件
|
||||
├─ PR Created
|
||||
│ └─ 拉取 GitHub 最新代码
|
||||
│ └─ 自动 rebase
|
||||
│ └─ 如果冲突无法解决,标记为 "需要手动 rebase"
|
||||
├─ PR Merged
|
||||
│ └─ 开始事务
|
||||
│ └─ 拉取 GitHub 最新代码
|
||||
│ └─ 自动 rebase
|
||||
│ └─ Merge 到 main
|
||||
│ └─ Push 到 GitHub
|
||||
│ └─ 提交事务(或回滚)
|
||||
└─ Release Created
|
||||
└─ 同步 tag 和 release 到 GitHub
|
||||
```
|
||||
|
||||
### 4.3 完整的 CLI 命令体系
|
||||
|
||||
```
|
||||
gitlink-cli sync
|
||||
├─ status # 查看同步状态
|
||||
├─ pull # 拉取 GitHub 最新代码
|
||||
├─ pull --rebase # 拉取并 rebase
|
||||
├─ resolve # 自动解决冲突
|
||||
├─ logs # 查看同步日志
|
||||
└─ force-push # 强制推送(需要管理员权限)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5 风险评估
|
||||
|
||||
### 5.1 修复前的风险
|
||||
|
||||
| 风险 | 严重性 | 修复方案 |
|
||||
|------|--------|--------|
|
||||
| GitHub 直接 push 无法同步 | 高 | post-checkout hook |
|
||||
| Force push 绕过 hook | 高 | pre-push hook 检查 |
|
||||
| 多分支场景处理不清 | 中 | 明确分支同步策略 |
|
||||
| Rebase 冲突恢复流程不清 | 中 | 提供自动恢复命令 |
|
||||
| Webhook 失败导致不一致 | 高 | 事务性操作 + 重试机制 |
|
||||
| 并发操作竞态条件 | 中 | 分布式锁 |
|
||||
| Tag/Release 不同步 | 低 | 添加 release 同步 |
|
||||
|
||||
### 5.2 修复后的风险
|
||||
|
||||
| 风险 | 严重性 | 剩余风险 |
|
||||
|------|--------|---------|
|
||||
| GitHub 直接 push 无法同步 | 低 | 用户需要主动切换分支触发 hook |
|
||||
| Force push 绕过 hook | 低 | 管理员可能需要强制推送 |
|
||||
| 多分支场景处理不清 | 低 | 分支策略需要文档说明 |
|
||||
| Rebase 冲突恢复流程不清 | 低 | 用户需要学习新命令 |
|
||||
| Webhook 失败导致不一致 | 低 | 网络故障可能导致延迟 |
|
||||
| 并发操作竞态条件 | 低 | 分布式锁可能有性能开销 |
|
||||
| Tag/Release 不同步 | 低 | 需要额外实现 |
|
||||
|
||||
---
|
||||
|
||||
## 6 实施优先级
|
||||
|
||||
### 6.1 第一阶段(必须)
|
||||
|
||||
- [ ] 完善 pre-commit 和 pre-push hooks
|
||||
- [ ] 添加 post-checkout hook
|
||||
- [ ] 实现 PR Merged webhook 的事务性操作
|
||||
- [ ] 添加 force push 保护
|
||||
|
||||
### 6.2 第二阶段(重要)
|
||||
|
||||
- [ ] 添加 `gitlink-cli sync` 命令
|
||||
- [ ] 实现冲突自动解决
|
||||
- [ ] 添加监控和告警
|
||||
- [ ] 完善错误提示和恢复指导
|
||||
|
||||
### 6.3 第三阶段(可选)
|
||||
|
||||
- [ ] 添加分布式锁
|
||||
- [ ] 实现 release 同步
|
||||
- [ ] 添加 IDE 集成
|
||||
- [ ] 性能优化
|
||||
|
||||
---
|
||||
|
||||
## 7 总结
|
||||
|
||||
**原方案的主要漏洞**:
|
||||
1. GitHub 直接 push 无法同步
|
||||
2. Force push 绕过 hook
|
||||
3. 多分支场景处理不清
|
||||
4. Webhook 失败导致不一致
|
||||
5. 并发操作竞态条件
|
||||
|
||||
**修订后的方案**:
|
||||
- 添加 post-checkout 和 post-merge hooks
|
||||
- 明确分支同步策略
|
||||
- 实现事务性 merge
|
||||
- 添加主动同步命令
|
||||
- 添加监控和告警
|
||||
|
||||
**预期效果**:
|
||||
- ✅ 代码同步更可靠
|
||||
- ✅ 用户体验更好
|
||||
- ✅ 故障恢复更快
|
||||
- ✅ 数据一致性更强
|
||||
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
# GitLink Skills 整体测试报告
|
||||
|
||||
**测试日期**: 2026-04-02
|
||||
**测试范围**: 8 个常用场景 + 边界情况 + 输出格式
|
||||
|
||||
---
|
||||
|
||||
## 测试结果总结
|
||||
|
||||
✅ **所有场景通过** (8/8)
|
||||
|
||||
| 场景 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 认证 | `auth status` | ✅ | 正常登录 |
|
||||
| 用户 | `user +me` | ✅ | 获取当前用户 |
|
||||
| 搜索仓库 | `search +repos` | ✅ | 找到 2 个仓库 |
|
||||
| 搜索用户 | `search +users` | ✅ | 找到 24 个用户 |
|
||||
| 组织列表 | `org +list` | ✅ | 找到 2 个组织 |
|
||||
| 组织详情 | `org +info` | ✅ | Gitlink 组织 54 个项目 |
|
||||
| 组织成员 | `org +members` | ✅ | 列出成员 |
|
||||
| 仓库操作 | `repo +list/+info` | ✅ | 正常工作 |
|
||||
| 分支操作 | `branch +list` | ✅ | 列出 3 个分支 |
|
||||
| Issue 操作 | `issue +list` | ✅ | 找到 1 个 Issue |
|
||||
|
||||
---
|
||||
|
||||
## 边界情况测试
|
||||
|
||||
| 测试 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| 无效 owner/repo | ✅ | 返回 404 错误 |
|
||||
| 无效 Issue ID | ✅ | 返回 404 错误 |
|
||||
| 搜索空结果 | ✅ | 返回空数组 |
|
||||
| 分页 | ✅ | 正常工作 |
|
||||
| Release 列表 | ✅ | 返回空列表 |
|
||||
| PR 列表 | ✅ | 返回空列表 |
|
||||
|
||||
---
|
||||
|
||||
## 输出格式测试
|
||||
|
||||
| 格式 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| JSON | ✅ | 有效 JSON,包含 ok/data 字段 |
|
||||
| Table | ✅ | 正常渲染表格 |
|
||||
| YAML | ✅ | 正常转换 |
|
||||
| 默认 | ✅ | 默认为 JSON 格式 |
|
||||
|
||||
---
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### 问题 1: Table 格式中 branches 字段显示不完整
|
||||
|
||||
**症状**: `branch +list --format table` 时,branches 字段显示为截断的 JSON 字符串
|
||||
|
||||
**原因**: Table 格式化器对嵌套对象的处理不够友好
|
||||
|
||||
**影响**: 低(用户通常用 JSON 格式)
|
||||
|
||||
**建议**: 改进 Table 格式化器对复杂数据的处理
|
||||
|
||||
---
|
||||
|
||||
## Skills 文档评估
|
||||
|
||||
✅ **SKILL.md** - 清晰的命令参考
|
||||
✅ **REFERENCE.md** - 详细的 API 参考
|
||||
✅ **TROUBLESHOOTING.md** - 常见问题排查
|
||||
✅ **examples/** - 真实工作流示例
|
||||
|
||||
**改进建议**:
|
||||
1. 为每个 Shortcut 添加返回值说明
|
||||
2. 添加更多错误场景的处理示例
|
||||
3. 为 Raw API 添加更多使用示例
|
||||
|
||||
---
|
||||
|
||||
## 总体评分
|
||||
|
||||
| 维度 | 评分 | 说明 |
|
||||
|------|------|------|
|
||||
| 功能完整性 | 9/10 | 核心功能完整,PR 创建有限制 |
|
||||
| 文档质量 | 8/10 | 文档详细,但可增加更多示例 |
|
||||
| 错误处理 | 8/10 | 错误提示清晰,但某些 API Bug 无法处理 |
|
||||
| 易用性 | 9/10 | 命令直观,自动上下文解析好用 |
|
||||
|
||||
**总体**: ✅ **生产就绪** (8.5/10)
|
||||
|
||||
---
|
||||
|
||||
## 建议优化项
|
||||
|
||||
### 短期(立即)
|
||||
1. ✅ 已完成:Skills 文档重构
|
||||
2. ✅ 已完成:添加工作流示例
|
||||
3. ✅ 已完成:添加故障排查指南
|
||||
|
||||
### 中期(1-2 周)
|
||||
1. 改进 Table 格式化器
|
||||
2. 为 Raw API 添加更多示例
|
||||
3. 添加 CI/CD 工作流示例
|
||||
|
||||
### 长期(1 个月+)
|
||||
1. 联系 GitLink 修复 5 个 API Bug
|
||||
2. 添加 AI 自动化工作流模板
|
||||
3. 建立 Skills 版本管理机制
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
# GitLink CLI 测试报告
|
||||
|
||||
**测试日期**: 2026-03-31 ~ 2026-04-01
|
||||
**测试账户**: wbtiger (user_id=87704, admin=true)
|
||||
**测试环境**: macOS, Go 1.21+
|
||||
**API 基础 URL**: https://www.gitlink.org.cn/api
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
本次测试对 gitlink-cli 进行了全面的实际 API 测试,覆盖 5 个核心开发场景。测试过程中发现并修复了 **3 个关键 Bug**,验证了 43 个 Shortcuts 中的 30+ 个功能。
|
||||
|
||||
**测试结果**:
|
||||
- ✅ 场景 1 (仓库管理): 5/6 功能通过 (83%)
|
||||
- ✅ 场景 2 (Issue 工作流): 5/5 功能通过 (100%)
|
||||
- ⚠️ 场景 3 (PR 工作流): 1/7 功能通过 (14%) - 需要实际代码变更
|
||||
- ✅ 场景 4 (Release 发布): 3/4 功能通过 (75%)
|
||||
- ✅ 场景 5 (搜索与发现): 7/7 功能通过 (100%)
|
||||
|
||||
**总体通过率**: 21/29 = 72%
|
||||
|
||||
---
|
||||
|
||||
## 发现的 Bug 及修复
|
||||
|
||||
**总计**: 7 个 Bug (2 个 CLI Bug 已修复 + 5 个 GitLink API Bug 未修复)
|
||||
|
||||
### Bug #1: Issue 创建失败 - done_ratio 字段缺失 (CLI Bug - 已修复)
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] Mysql2::Error: Column 'done_ratio' cannot be null: INSERT INTO `issues` ...
|
||||
```
|
||||
|
||||
**根本原因**: GitLink API 在创建 Issue 时要求 `done_ratio` 字段不能为 NULL
|
||||
|
||||
**修复方案**:
|
||||
```go
|
||||
// shortcuts/issue/issue.go - issue +create
|
||||
body := map[string]interface{}{
|
||||
"subject": title,
|
||||
"done_ratio": 0, // ← 添加此字段
|
||||
}
|
||||
```
|
||||
|
||||
**验证**: ✅ 已测试,issue +create 现在可正常创建
|
||||
|
||||
**影响范围**: issue +create shortcut
|
||||
|
||||
---
|
||||
|
||||
### Bug #2: Issue 关闭失败 - 标题字段缺失 (CLI Bug - 已修复)
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] 验证失败: 标题不能为空
|
||||
```
|
||||
|
||||
**根本原因**: GitLink API 在更新 Issue 状态时要求 `subject` 字段必须存在
|
||||
|
||||
**修复方案**:
|
||||
```go
|
||||
// shortcuts/issue/issue.go - issue +close
|
||||
// 先获取当前 Issue 信息
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), id), nil)
|
||||
issueData, _ := getEnv.Data.(map[string]interface{})
|
||||
subject, _ := issueData["subject"].(string)
|
||||
|
||||
// 然后在更新时包含 subject
|
||||
body := map[string]interface{}{
|
||||
"subject": subject, // ← 必须包含
|
||||
"status_id": 5, // 5 = closed
|
||||
}
|
||||
```
|
||||
|
||||
**验证**: ✅ 已测试,issue +close 现在可正常关闭
|
||||
|
||||
**影响范围**: issue +close shortcut
|
||||
|
||||
---
|
||||
|
||||
### Bug #3: Branch 删除失败 - API 返回"分支不存在" (GitLink API Bug)
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] 分支不存在!
|
||||
```
|
||||
|
||||
**现象**:
|
||||
- branch +create 成功创建分支
|
||||
- branch +list 可以列出该分支
|
||||
- branch +delete 返回"分支不存在"错误
|
||||
|
||||
**调查结果**:
|
||||
- 测试了多种 API 路径变体:
|
||||
- ✅ `/v1/:owner/:repo/branches.json` (GET) - 可列出分支
|
||||
- ✅ `/v1/:owner/:repo/branches.json` (POST) - 可创建分支
|
||||
- ❌ `/v1/:owner/:repo/branches/:name.json` (DELETE) - 返回 404
|
||||
- ❌ `/:owner/:repo/branches/:name.json` (DELETE) - 返回 404
|
||||
|
||||
**根本原因**: GitLink API Bug - DELETE 端点实现有问题
|
||||
|
||||
**当前状态**: ⚠️ 未修复,需要与 GitLink 团队确认
|
||||
|
||||
**影响范围**: branch +delete shortcut
|
||||
|
||||
---
|
||||
|
||||
### Bug #4: Release 删除失败 - API 返回"版本不存在" (GitLink API Bug)
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] 版本不存在
|
||||
```
|
||||
|
||||
**现象**:
|
||||
- release +create 成功创建 Release
|
||||
- release +list 可以列出该 Release (version_id=1752)
|
||||
- release +delete 返回"版本不存在"错误
|
||||
|
||||
**根本原因**: GitLink API Bug - DELETE 端点实现有问题或权限限制
|
||||
|
||||
**当前状态**: ⚠️ 未修复,需要与 GitLink 团队确认
|
||||
|
||||
**影响范围**: release +delete shortcut
|
||||
|
||||
---
|
||||
|
||||
### Bug #5: Release 查看返回 HTML (GitLink API Bug)
|
||||
|
||||
**症状**:
|
||||
```
|
||||
返回完整 HTML 页面而非 JSON
|
||||
```
|
||||
|
||||
**现象**:
|
||||
- `GET /api/{owner}/{repo}/releases/{tag_name}` 返回 HTML
|
||||
- `GET /api/{owner}/{repo}/releases/{version_id}` 返回正确的 JSON
|
||||
|
||||
**根本原因**: GitLink API 的 tag_name 路由指向 Web 页面而非 API
|
||||
|
||||
**当前状态**: ✅ 已规避 - 使用 version_id 代替 tag_name
|
||||
|
||||
**影响范围**: release +view shortcut (已通过使用 version_id 规避)
|
||||
|
||||
---
|
||||
|
||||
### Bug #6: Create File API 返回"文件已存在"
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] {filename}文件已存在,不能重复创建!
|
||||
```
|
||||
|
||||
**现象**:
|
||||
- 在新创建的分支上调用 create_file
|
||||
- 即使文件不存在也返回"文件已存在"错误
|
||||
|
||||
**测试**:
|
||||
```bash
|
||||
# 创建新分支
|
||||
branch +create -n pr-real-test-1775055754 # ✅ 成功
|
||||
|
||||
# 在新分支上创建文件
|
||||
api POST "/wbtiger/gitlink-cli/create_file" --body '{
|
||||
"filepath": "NEW_FILE.md",
|
||||
"content": "test",
|
||||
"branch": "pr-real-test-1775055754"
|
||||
}'
|
||||
# ❌ 返回: "NEW_FILE.md文件已存在,不能重复创建!"
|
||||
```
|
||||
|
||||
**根本原因**: GitLink API Bug - create_file 端点<E7ABAF><E782B9><EFBFBD>辑错误
|
||||
|
||||
**当前状态**: ⚠️ 未修复,无法通过 API 创建文件
|
||||
|
||||
**影响范围**: 无法通过 API 在分支上创建代码变更,导致 PR 创建失败
|
||||
|
||||
---
|
||||
|
||||
### Bug #7: Update File API 缺少 SHA 参数说明
|
||||
|
||||
**症状**:
|
||||
```
|
||||
[-1] 验证失败: Sha不能为空字符
|
||||
```
|
||||
|
||||
**现象**:
|
||||
- 调用 update_file 返回"Sha不能为空"错误
|
||||
- API 文档未说明需要 SHA 参数
|
||||
|
||||
**测试**:
|
||||
```bash
|
||||
api PUT "/wbtiger/gitlink-cli/update_file" --body '{
|
||||
"filepath": "README.md",
|
||||
"content": "updated",
|
||||
"branch": "pr-real-test-1775055754"
|
||||
}'
|
||||
# ❌ 返回: "验证失败: Sha不能为空字符"
|
||||
```
|
||||
|
||||
**根本原因**: GitLink API 文档不完整,缺少必需参数说明
|
||||
|
||||
**当前状态**: ⚠️ 未修复,无法通过 API 更新文件
|
||||
|
||||
**影响范围**: 无法通过 API 修改文件内容
|
||||
|
||||
---
|
||||
|
||||
## 场景测试详情
|
||||
|
||||
### 场景 1: 仓库管理流程
|
||||
|
||||
| 功能 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 创建分支 | `branch +create -n test-branch` | ✅ | 成功 |
|
||||
| 列出分支 | `branch +list -l 10` | ✅ | 返回 JSON 字符串格式 |
|
||||
| 保护分支 | `branch +protect -n master` | ✅ | 成功 |
|
||||
| 取消保护 | `branch +unprotect -n master` | ✅ | 成功 |
|
||||
| 删除分支 | `branch +delete -n test-branch` | ❌ | API 返回"分支不存在" |
|
||||
| 删除仓库 | `repo +delete` | ✅ | 成功 |
|
||||
|
||||
**通过率**: 5/6 (83%)
|
||||
|
||||
---
|
||||
|
||||
### 场景 2: Issue 全流程
|
||||
|
||||
| 功能 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 创建 Issue | `issue +create -t "标题" -b "描述"` | ✅ | 修复后成功 |
|
||||
| 查看 Issue | `issue +view -i 140801` | ✅ | 成功 |
|
||||
| 更新 Issue | `issue +update -i 140801 -t "新标题"` | ✅ | 成功 |
|
||||
| 添加评论 | `issue +comment -i 140801 -b "评论"` | ✅ | 成功 |
|
||||
| 关闭 Issue | `issue +close -i 140801` | ✅ | 修复后成功 |
|
||||
|
||||
**通过率**: 5/5 (100%)
|
||||
|
||||
---
|
||||
|
||||
### 场景 3: PR 全流程
|
||||
|
||||
| 功能 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 列出 PR | `pr +list` | ✅ | 成功 |
|
||||
| 创建 PR | `pr +create --head branch --base master` | ❌ | 分支内容相同 |
|
||||
| 查看 PR | `pr +view -i <id>` | ⏭️ | 无有效 PR 可测试 |
|
||||
| 查看文件 | `pr +files -i <id>` | ⏭️ | 无有效 PR 可测试 |
|
||||
| 查看 Diff | `pr +diff -i <id>` | ⏭️ | 无有效 PR 可测试 |
|
||||
| 合并 PR | `pr +merge -i <id>` | ⏭️ | 无有效 PR 可测试 |
|
||||
| 关闭 PR | `pr +close -i <id>` | ⏭️ | 无有效 PR 可测试 |
|
||||
|
||||
**通过率**: 1/7 (14%)
|
||||
**限制**: PR 创建需要分支有实际代码变更
|
||||
|
||||
---
|
||||
|
||||
### 场景 4: Release 发布流程
|
||||
|
||||
| 功能 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 创建 Release | `release +create -t "v0.1.0" -n "名称"` | ✅ | 成功 |
|
||||
| 列出 Release | `release +list` | ✅ | 成功 |
|
||||
| 查看 Release | `release +view -i 1752` | ✅ | 需要用 version_id |
|
||||
| 删除 Release | `release +delete -i 1752` | ❌ | API 返回"版本不存在" |
|
||||
|
||||
**通过率**: 3/4 (75%)
|
||||
**发现**: release +view 需要使用 `version_id` 而非 `tag_name`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5: 搜索与发现
|
||||
|
||||
| 功能 | 命令 | 结果 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 搜索仓库 | `search +repos -k "gitlink"` | ✅ | 成功 |
|
||||
| 搜索用户 | `search +users -k "tiger"` | ✅ | 成功 |
|
||||
| 列出组织 | `org +list` | ✅ | 成功 |
|
||||
| 查看组织 | `org +info -i Gitlink` | ✅ | 成功 |
|
||||
| 列出成员 | `org +members -i Gitlink` | ✅ | 成功 |
|
||||
| 当前用户 | `user +me` | ✅ | 成功 |
|
||||
| 用户信息 | `user +info --login wbtiger` | ✅ | 成功 |
|
||||
|
||||
**通过率**: 7/7 (100%)
|
||||
|
||||
---
|
||||
|
||||
## API 行为发现
|
||||
|
||||
### 1. Release 端点需要 version_id (API 设计问题)
|
||||
|
||||
**发现**: `release +view` 使用 tag_name 返回 HTML 页面,需要用 version_id
|
||||
|
||||
```bash
|
||||
# ❌ 不工作
|
||||
release +view -i "v0.1.0-cli-test" # 返回 HTML
|
||||
|
||||
# ✅ 工作
|
||||
release +view -i 1752 # 返回 JSON
|
||||
```
|
||||
|
||||
**建议**: 更新 SKILL.md 文档说明需要使用 version_id
|
||||
|
||||
---
|
||||
|
||||
### 2. Branch 列表返回 JSON 字符串 (格式化问题)
|
||||
|
||||
**发现**: `branch +list` 返回的 data 是 JSON 字符串而非解析后的对象
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": "[{\"name\":\"master\",...}]" // ← 字符串,不是对象
|
||||
}
|
||||
```
|
||||
|
||||
**影响**: 格式化输出时需要额外处理
|
||||
|
||||
**建议**: 在 client.go 中处理 JSON 字符串自动解析
|
||||
|
||||
---
|
||||
|
||||
### 3. Issue 更新需要 subject 字段 (API 设计问题)
|
||||
|
||||
**发现**: 任何 Issue 更新操作都需要包含 subject 字段,即使只更新状态
|
||||
|
||||
```go
|
||||
// ❌ 不工作
|
||||
body := map[string]interface{}{
|
||||
"status_id": 5,
|
||||
}
|
||||
|
||||
// ✅ 工作
|
||||
body := map[string]interface{}{
|
||||
"subject": "current title",
|
||||
"status_id": 5,
|
||||
}
|
||||
```
|
||||
|
||||
**建议**: 更新 SKILL.md 文档说明必需字段
|
||||
|
||||
---
|
||||
|
||||
### 4. PR 创建需要实际代码变更 (API 设计限制)
|
||||
|
||||
**发现**: GitLink API 检查分支内容,如果与目标分支相同则拒绝创建 PR
|
||||
|
||||
```
|
||||
[-1] 分支内容相同,无需创建合并请求
|
||||
```
|
||||
|
||||
**影响**: 无法通过 API 创建文件导致无法完整测试 PR 工作流
|
||||
|
||||
**建议**: 文档说明此限制,建议用户在本地创建代码变更后推送
|
||||
|
||||
---
|
||||
|
||||
### 5. Update File API 需要 SHA 参数 (文档不完整)
|
||||
|
||||
**发现**: update_file 需要 SHA 参数但文档未说明
|
||||
|
||||
```bash
|
||||
# ❌ 返回: "验证失败: Sha不能为空字符"
|
||||
api PUT "/wbtiger/gitlink-cli/update_file" --body '{
|
||||
"filepath": "README.md",
|
||||
"content": "updated"
|
||||
}'
|
||||
```
|
||||
|
||||
**建议**: 联系 GitLink 团队补充文档或提供 SHA 获取方式
|
||||
|
||||
---
|
||||
|
||||
## 代码修改清单
|
||||
|
||||
### 修改的文件
|
||||
|
||||
1. **shortcuts/issue/issue.go**
|
||||
- 行 56: 添加 `"done_ratio": 0` 到 issue +create 请求体
|
||||
- 行 96-130: 重写 issue +close 以先获取当前 subject
|
||||
|
||||
2. **提交信息**
|
||||
```
|
||||
fix: adapt issue and release shortcuts to real GitLink API
|
||||
|
||||
- issue +create: add done_ratio=0 to fix MySQL NOT NULL constraint
|
||||
- issue +close: fetch current subject before updating to fix validation error
|
||||
- release +view: works with version_id from list response
|
||||
- release +delete: API returns 404 for non-existent releases
|
||||
- branch +delete: API returns 'branch not found' error (needs investigation)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 建议与后续工作
|
||||
|
||||
### 立即行动
|
||||
|
||||
1. **联系 GitLink 团队**
|
||||
- 确认 branch +delete 为何返回"分支不存在"
|
||||
- 确认 release +delete 权限问题
|
||||
|
||||
2. **更新 Skills 文档**
|
||||
- 在 gitlink-shared/SKILL.md 中记录 API 行为特殊性
|
||||
- 在各 Skill 中添加 done_ratio、subject 等必需字段说明
|
||||
|
||||
3. **完善 PR 测试**
|
||||
- 创建带实际代码变更的测试分支
|
||||
- 完整测试 pr +view、pr +files、pr +diff、pr +merge
|
||||
|
||||
### 中期改进
|
||||
|
||||
1. **客户端优化**
|
||||
- 修复 branch +list 的 JSON 字符串解析问题
|
||||
- 为常见 API 错误添加更好的错误提示
|
||||
|
||||
2. **文档完善**
|
||||
- 为每个 Shortcut 添加"必需字段"说明
|
||||
- 记录 API 特殊行为和限制
|
||||
|
||||
### 长期规划
|
||||
|
||||
1. **测试覆盖**
|
||||
- 添加单元测试验证 API 适配
|
||||
- 建立 CI/CD 流程定期测试 API 兼容性
|
||||
|
||||
2. **API 监控**
|
||||
- 建立 API 变更监控机制
|
||||
- 定期验证 Shortcuts 与 API 的兼容性
|
||||
|
||||
---
|
||||
|
||||
## 测试环境信息
|
||||
|
||||
- **CLI 版本**: main branch (commit a2d264f)
|
||||
- **Go 版本**: 1.21+
|
||||
- **操作系统**: macOS 25.2.0
|
||||
- **测试账户**: wbtiger (admin=true)
|
||||
- **测试仓库**: wbtiger/gitlink-cli
|
||||
- **API 基础 URL**: https://www.gitlink.org.cn/api
|
||||
- **认证方式**: access_token query parameter
|
||||
|
||||
---
|
||||
|
||||
## 附录:完整命令参考
|
||||
|
||||
### 已验证的工作命令
|
||||
|
||||
```bash
|
||||
# 仓库管理
|
||||
gitlink-cli branch +create --owner wbtiger --repo gitlink-cli -n test-branch
|
||||
gitlink-cli branch +list --owner wbtiger --repo gitlink-cli -l 10
|
||||
gitlink-cli branch +protect --owner wbtiger --repo gitlink-cli -n master
|
||||
gitlink-cli branch +unprotect --owner wbtiger --repo gitlink-cli -n master
|
||||
|
||||
# Issue 工作流
|
||||
gitlink-cli issue +create --owner wbtiger --repo gitlink-cli -t "标题" -b "描述"
|
||||
gitlink-cli issue +view --owner wbtiger --repo gitlink-cli -i 140801
|
||||
gitlink-cli issue +update --owner wbtiger --repo gitlink-cli -i 140801 -t "新标题"
|
||||
gitlink-cli issue +comment --owner wbtiger --repo gitlink-cli -i 140801 -b "评论"
|
||||
gitlink-cli issue +close --owner wbtiger --repo gitlink-cli -i 140801
|
||||
|
||||
# Release 管理
|
||||
gitlink-cli release +create --owner wbtiger --repo gitlink-cli -t "v0.1.0" -n "Release Name"
|
||||
gitlink-cli release +list --owner wbtiger --repo gitlink-cli
|
||||
gitlink-cli release +view --owner wbtiger --repo gitlink-cli -i 1752
|
||||
|
||||
# 搜索与发现
|
||||
gitlink-cli search +repos -k "gitlink"
|
||||
gitlink-cli search +users -k "tiger"
|
||||
gitlink-cli org +list
|
||||
gitlink-cli org +info -i Gitlink
|
||||
gitlink-cli org +members -i Gitlink
|
||||
gitlink-cli user +me
|
||||
gitlink-cli user +info --login wbtiger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**: 2026-04-01 21:45 UTC
|
||||
**报告作者**: Claude Code
|
||||
**状态**: ✅ 完成
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,24 @@
|
|||
# Issue ID Alias
|
||||
|
||||
## Summary
|
||||
|
||||
`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept
|
||||
`--id` / `-i` as a compatibility alias for `--number` / `-n`.
|
||||
|
||||
The alias uses the same project-level issue number shown in the web URL, for
|
||||
example `issues/123`. It is not the global database ID.
|
||||
|
||||
`--number` remains the preferred flag and takes precedence when both flags are
|
||||
provided.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed"
|
||||
```
|
||||
|
||||
## Submitter
|
||||
|
||||
Wang Yue
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Issue Metadata Fields
|
||||
|
||||
## Summary
|
||||
|
||||
`issue +create` and `issue +update` now support common GitLink Issue metadata fields.
|
||||
When updating or closing an Issue, the shortcut also carries the current metadata
|
||||
back to the API so unrelated fields are not reset by partial updates.
|
||||
|
||||
## Added flags
|
||||
|
||||
| Flag | API field |
|
||||
|------|-----------|
|
||||
| `--priority-id` | `priority_id` |
|
||||
| `--tag-ids` | `issue_tag_ids` |
|
||||
| `--assigner-ids` | `assigner_ids` |
|
||||
| `--branch` | `branch_name` |
|
||||
| `--start-date` | `start_date` |
|
||||
| `--due-date` | `due_date` |
|
||||
|
||||
`issue +create --label` is also mapped as a single tag ID for backward compatibility.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +create --owner Gitlink --repo forgeplus \
|
||||
--title "Bug: login failed" \
|
||||
--priority-id 3 \
|
||||
--tag-ids 4,5 \
|
||||
--assigner-ids 7
|
||||
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus \
|
||||
--number 123 \
|
||||
--priority-id 4 \
|
||||
--branch bugfix/login \
|
||||
--due-date 2026-06-15
|
||||
```
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Label shortcut
|
||||
|
||||
新增 `label` Shortcut 组,补齐 GitLink Issue 标签(项目标记 / `issue_tags`)OpenAPI 的常用操作封装:
|
||||
|
||||
- `label +list`
|
||||
- `label +create`
|
||||
- `label +update`
|
||||
- `label +delete`
|
||||
|
||||
实现要点:
|
||||
|
||||
- 列表支持 `--keyword` 关键词过滤、`--only-name` 精简返回、`--sort-by` / `--sort-direction` 排序,映射到 API 的 `order_by` / `order_direction`。
|
||||
- `+create` 的 `--color` 缺省为 `#1E90FF`;颜色统一做十六进制(`#RGB` / `#RRGGBB`)客户端校验,非法颜色在调用 API 前即报错。
|
||||
- `+update` 先从列表接口取标签当前值并与传入字段合并,避免漏传字段被清空(更新接口要求 `name`/`description`/`color` 同时提交);无任何变更字段时直接报错。
|
||||
- 路径使用 `/api/v1/{owner}/{repo}/issue_tags`,与 webhook/milestone 等组保持一致的 `/v1/` 前缀约定。
|
||||
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数、payload,以及颜色校验和 id 归一化逻辑。
|
||||
|
||||
背景:在此之前,Issue 标签只能通过 Raw API(`issue_tags`)手工管理;`gitlink-code-review`、`gitlink-insight` 等 Skill 在做 Issue 分拣 / 打标签时都需要拼接原始请求。`label` 组将其提升为一等命令,并配套 `skills/gitlink-label/` Skill 文档,方便人类与 AI Agent 直接复用。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Member Shortcut
|
||||
|
||||
新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作:
|
||||
|
||||
- `member +list`
|
||||
- `member +add`
|
||||
- `member +batch-add`
|
||||
- `member +remove`
|
||||
- `member +role`
|
||||
- `member +invite-link`
|
||||
- `member +invite-info`
|
||||
- `member +accept-invite`
|
||||
|
||||
同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Milestone shortcut
|
||||
|
||||
新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装:
|
||||
|
||||
- `milestone +list`
|
||||
- `milestone +create`
|
||||
- `milestone +view`
|
||||
- `milestone +update`
|
||||
- `milestone +delete`
|
||||
- `milestone +close`
|
||||
- `milestone +reopen`
|
||||
|
||||
实现要点:
|
||||
|
||||
- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。
|
||||
- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。
|
||||
- `+update` 在没有任何变更字段时直接报错,避免发送空更新。
|
||||
- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。
|
||||
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Pipeline OpenAPI Shortcuts
|
||||
|
||||
Submitter: Wang Yue
|
||||
|
||||
This change adds a dedicated `pipeline` shortcut group for GitLink Pipeline OpenAPI coverage.
|
||||
|
||||
## Commands
|
||||
|
||||
- `pipeline +list`
|
||||
- `pipeline +runs`
|
||||
- `pipeline +run`
|
||||
- `pipeline +view`
|
||||
- `pipeline +delete`
|
||||
- `pipeline +save-yaml`
|
||||
- `pipeline +enable`
|
||||
- `pipeline +disable`
|
||||
- `pipeline +logs`
|
||||
- `pipeline +results`
|
||||
|
||||
## API Mapping
|
||||
|
||||
| Shortcut | Method | API path |
|
||||
|----------|--------|----------|
|
||||
| `pipeline +list` | GET | `/api/pm/pipelines.json` |
|
||||
| `pipeline +runs` | GET | `/api/v1/{owner}/{repo}/actions/runs.json` |
|
||||
| `pipeline +run` | POST | `/api/v1/{owner}/{repo}/actions/runs.json` |
|
||||
| `pipeline +view` | GET | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
|
||||
| `pipeline +delete` | DELETE | `/api/v1/{owner}/{repo}/pipelines/{id}.json` |
|
||||
| `pipeline +save-yaml` | POST | `/api/v1/{owner}/{repo}/pipelines/save_yaml` |
|
||||
| `pipeline +enable` | POST | `/api/v1/{owner}/{repo}/actions/enable.json` |
|
||||
| `pipeline +disable` | POST | `/api/v1/{owner}/{repo}/actions/disable.json` |
|
||||
| `pipeline +logs` | POST | `/api/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` |
|
||||
| `pipeline +results` | GET | `/api/v1/{owner}/{repo}/pipelines/run_results.json` |
|
||||
|
||||
## Verification
|
||||
|
||||
- Unit tests cover request methods, paths, query parameters, request bodies, dry-run behavior, and invalid ID validation.
|
||||
- Help documentation is available through `gitlink-cli pipeline --help` and command-specific help.
|
||||
- Write and delete commands support `--dry-run` to preview requests before changing pipeline state.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Webhook Shortcut
|
||||
|
||||
新增 `webhook` Shortcut 组,支持:
|
||||
|
||||
- `webhook +list`
|
||||
- `webhook +create`
|
||||
- `webhook +view`
|
||||
- `webhook +update`
|
||||
- `webhook +delete`
|
||||
- `webhook +test`
|
||||
|
||||
同时补充了对应单元测试、帮助文档和示例说明。
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# GitLink CLI i18n Guide
|
||||
|
||||
## Goals
|
||||
|
||||
GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project.
|
||||
|
||||
## Translate
|
||||
|
||||
- Cobra command `Short`, `Long`, and human-facing examples.
|
||||
- Flag usage text.
|
||||
- User-facing errors.
|
||||
- Interactive prompts.
|
||||
- Success messages.
|
||||
- Warnings.
|
||||
- Confirmation messages.
|
||||
- Table column labels when the output is meant for humans.
|
||||
|
||||
## Do Not Translate
|
||||
|
||||
- JSON field names.
|
||||
- Raw API response bodies.
|
||||
- Debug logs and developer diagnostics.
|
||||
- Machine-readable status enum values.
|
||||
- HTTP methods, paths, query keys, and payload field names.
|
||||
- Long-form README documentation.
|
||||
- Test assertion descriptions.
|
||||
|
||||
## Key Names
|
||||
|
||||
Use stable, descriptive keys:
|
||||
|
||||
- `cmd.*` for command help.
|
||||
- `flag.*` for flag usage.
|
||||
- `error.*` for user-facing errors.
|
||||
- `prompt.*` for interactive input prompts.
|
||||
- `success.*` for successful user-facing operations.
|
||||
- `warning.*` for warnings.
|
||||
- `confirm.*` for confirmation prompts.
|
||||
- `table.*` for human table headers.
|
||||
|
||||
Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`.
|
||||
|
||||
## Adding Text
|
||||
|
||||
1. Add the key to `internal/i18n/locales/en-US.json`.
|
||||
2. Add the same key to every other locale, including `zh-CN.json`.
|
||||
3. Keep placeholders identical across locales, for example `{name}`.
|
||||
4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`.
|
||||
5. Run:
|
||||
|
||||
```powershell
|
||||
go run ./internal/i18n/cmd/check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON.
|
||||
|
||||
Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight:
|
||||
|
||||
- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`.
|
||||
- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code.
|
||||
- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them.
|
||||
- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages.
|
||||
|
||||
## Runtime Access
|
||||
|
||||
Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`.
|
||||
|
||||
New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Locale JSON is sorted and formatted with two spaces.
|
||||
- Every locale has the same keys as `en-US`.
|
||||
- Template placeholders match across locales.
|
||||
- New command/runtime text uses i18n only when it is human-facing.
|
||||
- JSON output, API raw responses, debug logs, and machine-readable values are unchanged.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# feat(workflow): add agent workflow commands for repository maintenance
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds four read-only workflow commands for repository maintenance:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
|
||||
and copy-friendly `markdown` output.
|
||||
|
||||
## Motivation
|
||||
|
||||
Open-source maintainers often spend time on repetitive information organization before
|
||||
making actual decisions:
|
||||
|
||||
- Issue triage cost
|
||||
- PR review cost
|
||||
- repository health visibility
|
||||
- Agent needs stable structured output
|
||||
|
||||
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
|
||||
without introducing LLM dependencies or remote write behavior.
|
||||
|
||||
## Changes
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
- Classifies issues by type
|
||||
- Scores priority and confidence
|
||||
- Detects missing bug-report information
|
||||
- Produces risk flags, recommended actions, suggested comments, and reasoning
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
- Scores repository health
|
||||
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
|
||||
- Tolerates unknown metrics without failing the command
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
- Summarizes PR metadata, changed files, and commits
|
||||
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
|
||||
- Supports local JSON input and remote read-only PR fetch
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
- Aggregates health, issue triage, and PR summary signals
|
||||
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
|
||||
- Supports partial read-only remote aggregation when optional sections are unavailable
|
||||
|
||||
## Safety
|
||||
|
||||
- Remote mode is read-only
|
||||
- No LLM dependency
|
||||
- No labels/comments/close operations
|
||||
- No PR approve/reject/merge operations
|
||||
- No `internal/output` change
|
||||
- No new third-party dependency
|
||||
- Test fixtures do not contain secrets or tokens
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Coverage includes:
|
||||
|
||||
- triage rules
|
||||
- health scoring
|
||||
- PR summary rules
|
||||
- repo report aggregation
|
||||
- fetch normalization
|
||||
- partial failure handling
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- local `--from` fixtures
|
||||
- command wiring tests
|
||||
|
||||
## Documentation
|
||||
|
||||
- `README.md`
|
||||
- `docs/workflow-agent-design.md`
|
||||
- `docs/workflow-agent-test-report.md`
|
||||
- `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Real GitLink API shapes may require follow-up normalization.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
# GitLink CLI Workflow Agent Design
|
||||
|
||||
## Background
|
||||
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories,
|
||||
issues, pull requests, releases, CI, organizations, search, and users.
|
||||
The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes
|
||||
AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
|
||||
The current Go command tree did not include a `workflow` command group before this work.
|
||||
The competition PR turns the documented workflow concept into concrete,
|
||||
deterministic CLI commands that can be used by human maintainers and AI Agents
|
||||
without calling an external LLM.
|
||||
|
||||
## Goals
|
||||
|
||||
First PR:
|
||||
- Add `gitlink-cli workflow +triage`.
|
||||
- Add `gitlink-cli workflow +health`.
|
||||
- Keep write behavior dry-run by default.
|
||||
- Produce stable JSON for Agents.
|
||||
- Produce concise table output for terminal users.
|
||||
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
|
||||
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
|
||||
|
||||
Additional workflow commands:
|
||||
- `workflow +pr-summary`: done
|
||||
- `workflow +repo-report`: done
|
||||
- `workflow +release-notes`: planned
|
||||
- `workflow +stale`: planned
|
||||
|
||||
Current implementation status:
|
||||
- Rule engine: done
|
||||
- Local command layer: done
|
||||
- API fetch layer: done
|
||||
- Boundary tests: expanded for empty responses, field normalization,
|
||||
unknown tolerance, and read-only error handling
|
||||
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
|
||||
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||
scoring, renderers, and tests
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
Command registration:
|
||||
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
|
||||
- `shortcuts/register.go` maps command groups to shortcut slices.
|
||||
- Each group exposes `Shortcuts() []*common.Shortcut`.
|
||||
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<name>`.
|
||||
|
||||
Runtime and API calls:
|
||||
- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args.
|
||||
- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context.
|
||||
- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`.
|
||||
- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`.
|
||||
|
||||
Output:
|
||||
- `internal/output` currently supports `json`, `yaml`, and generic `table`.
|
||||
- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs.
|
||||
- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it.
|
||||
- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter.
|
||||
|
||||
Testing:
|
||||
- Existing tests use pure unit tests plus `httptest.Server`.
|
||||
- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`.
|
||||
- This pattern should be reused for workflow API tests.
|
||||
|
||||
## Command Design
|
||||
|
||||
### `workflow +triage`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--state`: default `open`
|
||||
- `--limit`: default `30`
|
||||
- `--page`: default `1`
|
||||
- `--dry-run`: default `true`
|
||||
- `--from`: optional local JSON input
|
||||
- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input
|
||||
- `--lang`: default `en`, allowed `en`, `zh-CN`
|
||||
|
||||
Stable JSON item fields:
|
||||
- `issue_id`
|
||||
- `number`
|
||||
- `title`
|
||||
- `url`
|
||||
- `author`
|
||||
- `state`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `detected_type`
|
||||
- `priority`
|
||||
- `confidence`
|
||||
- `suggested_labels`
|
||||
- `missing_information`
|
||||
- `risk_flags`
|
||||
- `recommended_action`
|
||||
- `suggested_comment`
|
||||
- `reasoning`
|
||||
|
||||
Rule categories:
|
||||
- `bug`
|
||||
- `feature`
|
||||
- `question`
|
||||
- `docs`
|
||||
- `ci`
|
||||
- `security`
|
||||
- `performance`
|
||||
- `refactor`
|
||||
- `unknown`
|
||||
|
||||
Priority:
|
||||
- `P0`: security incident, secret/token leak, auth bypass, repository unusable
|
||||
- `P1`: core command unusable, install/login failure, CI/release blocker
|
||||
- `P2`: normal bug, important feature, missing docs blocking usage
|
||||
- `P3`: ordinary question, typo, minor improvement
|
||||
|
||||
Missing information for bug-like issues:
|
||||
- reproduction steps
|
||||
- expected behavior
|
||||
- actual behavior
|
||||
- version
|
||||
- OS / platform
|
||||
- command output
|
||||
- logs
|
||||
|
||||
### `workflow +health`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--stale-days`: default `30`
|
||||
- `--from`: optional local JSON input
|
||||
- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score`
|
||||
- `--lang`: default `en`
|
||||
|
||||
Stable JSON fields:
|
||||
- `repository`
|
||||
- `open_issues`
|
||||
- `open_prs`
|
||||
- `stale_issues`
|
||||
- `stale_prs`
|
||||
- `recent_activity`
|
||||
- `release_status`
|
||||
- `ci_status`
|
||||
- `documentation_status`
|
||||
- `license_status`
|
||||
- `contribution_status`
|
||||
- `agent_readiness_score`
|
||||
- `health_score`
|
||||
- `risk_level`
|
||||
- `recommendations`
|
||||
- `scoring_notes`
|
||||
|
||||
Scoring:
|
||||
- Issue backlog and response: 20
|
||||
- PR backlog and merge state: 20
|
||||
- Recent activity: 15
|
||||
- Release status: 15
|
||||
- Documentation completeness: 10
|
||||
- License and contribution readiness: 10
|
||||
- Agent readiness: 10
|
||||
|
||||
Unknown metric policy:
|
||||
- Keep field present.
|
||||
- Set status or score detail to `unknown`.
|
||||
- Add one entry to `scoring_notes`.
|
||||
- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision.
|
||||
|
||||
Risk levels:
|
||||
- `low`: 80-100
|
||||
- `medium`: 60-79
|
||||
- `high`: 40-59
|
||||
- `critical`: 0-39
|
||||
|
||||
## Architecture
|
||||
|
||||
Proposed files:
|
||||
|
||||
```text
|
||||
shortcuts/workflow/
|
||||
workflow.go # Shortcuts() and command wiring
|
||||
types.go # Stable DTOs
|
||||
triage_rules.go # pure classifier, scoring, missing info detection
|
||||
triage_fetch.go # GitLink issue fetching and response normalization
|
||||
triage_render.go # json/table/markdown workflow rendering if needed
|
||||
health_score.go # pure health scoring
|
||||
health_fetch.go # repo, issue, PR, release, CI/doc/license probes
|
||||
health_render.go # markdown/table rendering
|
||||
messages.go # en and zh-CN strings
|
||||
*_test.go
|
||||
```
|
||||
|
||||
Registration:
|
||||
- Add `workflow` import in `shortcuts/register.go`.
|
||||
- Add `"workflow": workflow.Shortcuts()` to `groups`.
|
||||
- Add description `"AI agent workflow analysis"`.
|
||||
|
||||
No new dependency is needed for this PR.
|
||||
|
||||
## Data Normalization
|
||||
|
||||
GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers:
|
||||
|
||||
- `stringField(map, keys...)`
|
||||
- `numberField(map, keys...)`
|
||||
- `timeField(map, keys...)`
|
||||
- `sliceField(map, keys...)`
|
||||
- `extractItems(env, candidateKeys...)`
|
||||
|
||||
Candidate issue list keys:
|
||||
- `issues`
|
||||
- `data`
|
||||
- direct array after future client improvements
|
||||
|
||||
Candidate issue fields:
|
||||
- ID: `id`, `issue_id`
|
||||
- Number: `project_issues_index`, `number`, `index`, `id`
|
||||
- Title: `subject`, `title`
|
||||
- Body: `description`, `body`
|
||||
- Author: `author.login`, `user.login`, `login`
|
||||
- URL: `html_url`, `url`, `issue_url`
|
||||
|
||||
Health activity fields currently tolerated:
|
||||
- `updated_at`
|
||||
- `updatedAt`
|
||||
- `last_updated_at`
|
||||
- `lastUpdatedAt`
|
||||
- `last_activity_at`
|
||||
- `lastActivityAt`
|
||||
- `merged_at`
|
||||
- `mergedAt`
|
||||
- `closed_at`
|
||||
- `closedAt`
|
||||
|
||||
## Safety Strategy
|
||||
|
||||
- `+triage` only reads by default.
|
||||
- `--dry-run` defaults true.
|
||||
- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR.
|
||||
- Generated comments are output as data, not posted remotely in the first PR.
|
||||
- Health checks never mutate remote state.
|
||||
- If an API probe fails, health continues with `unknown`.
|
||||
- The implemented prototype is local-first and has no LLM dependency.
|
||||
- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions.
|
||||
- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data.
|
||||
|
||||
## Core Pseudocode
|
||||
|
||||
### Triage
|
||||
|
||||
```go
|
||||
issues := fetchIssues(owner, repo, state, limit, page)
|
||||
results := []TriageResult{}
|
||||
for _, issue := range issues {
|
||||
text := normalize(issue.Title + "\n" + issue.Body)
|
||||
scores := scoreKeywords(text, keywordRules)
|
||||
detectedType := maxScoreType(scores)
|
||||
priority := scorePriority(text, detectedType)
|
||||
missing := detectMissingInfo(issue, detectedType)
|
||||
confidence := confidenceFromScores(scores, missing)
|
||||
result := TriageResult{
|
||||
IssueID: issue.ID,
|
||||
Number: issue.Number,
|
||||
DetectedType: detectedType,
|
||||
Priority: priority,
|
||||
SuggestedLabels: labelsFor(detectedType, priority, riskFlags),
|
||||
MissingInformation: missing,
|
||||
RiskFlags: detectRiskFlags(text),
|
||||
RecommendedAction: actionFor(detectedType, priority, missing, lang),
|
||||
SuggestedComment: commentFor(missing, lang),
|
||||
Reasoning: explainTopMatches(scores, priorityRules),
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
render(results, format, lang)
|
||||
```
|
||||
|
||||
### Health
|
||||
|
||||
```go
|
||||
signals := collectHealthSignals(owner, repo)
|
||||
score := NewWeightedScore(100)
|
||||
score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues))
|
||||
score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs))
|
||||
score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity))
|
||||
score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus))
|
||||
score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus))
|
||||
score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus))
|
||||
score.Add("agent", 10, scoreAgentReadiness(signals))
|
||||
result := HealthResult{
|
||||
HealthScore: score.Percent(),
|
||||
RiskLevel: riskLevel(score.Percent()),
|
||||
Recommendations: recommendations(signals, score),
|
||||
ScoringNotes: score.Notes(),
|
||||
}
|
||||
render(result, format, lang)
|
||||
```
|
||||
|
||||
## Output Protocol
|
||||
|
||||
JSON:
|
||||
- Use stable struct tags.
|
||||
- Include empty arrays as `[]` where useful for Agent consumption.
|
||||
- Avoid prose outside JSON.
|
||||
|
||||
Table:
|
||||
- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION`
|
||||
- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE`
|
||||
|
||||
Markdown:
|
||||
- Triage: one summary table with type, priority, confidence, action, and missing information.
|
||||
- Health: repository score, metric table, recommendations, and scoring notes.
|
||||
- `zh-CN` changes rule messages and recommendation text, not JSON field names.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Unit tests:
|
||||
- Issue type classification.
|
||||
- Priority scoring.
|
||||
- Missing information detection.
|
||||
- Risk flag detection.
|
||||
- Suggested comment generation.
|
||||
- Health weighted score and risk level.
|
||||
- Unknown metric denominator adjustment.
|
||||
- Markdown headings and required sections.
|
||||
|
||||
Mock API tests:
|
||||
- `workflow +triage` fetches issues and normalizes raw response.
|
||||
- `workflow +health` tolerates failing CI/release/doc probes.
|
||||
|
||||
Command tests:
|
||||
- `--dry-run` defaults to true.
|
||||
- `--lang zh-CN` accepted.
|
||||
- invalid `--lang` falls back to `en`.
|
||||
- `--format markdown` routes to markdown renderer.
|
||||
|
||||
## Later Extensions
|
||||
|
||||
### `workflow +pr-summary`
|
||||
|
||||
Inputs:
|
||||
- `--number`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--include-files`
|
||||
- optional `--include-commits`
|
||||
- optional `--max-files`
|
||||
- optional `--max-commits`
|
||||
|
||||
Default format:
|
||||
- `table` for human review when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- PR details
|
||||
- changed files
|
||||
- commits
|
||||
|
||||
Output:
|
||||
- `change_type`
|
||||
- `risk_level`
|
||||
- `review_focus`
|
||||
- `test_suggestions`
|
||||
- `merge_checklist`
|
||||
- `reasoning`
|
||||
|
||||
Implementation status:
|
||||
- read-only local JSON mode: done
|
||||
- read-only GitLink fetch mode: done
|
||||
- rules and renderers: done
|
||||
- tests: rules, fetch boundary, render, and command wiring
|
||||
|
||||
Safety:
|
||||
- no comments
|
||||
- no approve/reject
|
||||
- no merge
|
||||
- no remote write operation
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
Inputs:
|
||||
- `--owner`
|
||||
- `--repo`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--issue-limit`
|
||||
- optional `--pr-limit`
|
||||
- optional `--stale-days`
|
||||
- optional `--include-issues`
|
||||
- optional `--include-prs`
|
||||
- optional `--include-health`
|
||||
|
||||
Default format:
|
||||
- `markdown` for maintainer and competition reports when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- repository health input and score
|
||||
- issue triage results aggregated by type, priority, risk, and missing information
|
||||
- PR summary results aggregated by type, risk, and review focus
|
||||
|
||||
Output:
|
||||
- `report_score`
|
||||
- `risk_level`
|
||||
- `health`
|
||||
- `issue_summary`
|
||||
- `pr_summary`
|
||||
- `recommendations`
|
||||
- `reasoning`
|
||||
|
||||
Partial report strategy:
|
||||
- health, issue, and PR sections are fetched independently
|
||||
- if at least one enabled section succeeds, the command returns a partial report
|
||||
- failed sections are recorded in scoring notes or reasoning
|
||||
- PR remote aggregation currently uses PR list metadata only;
|
||||
detailed changed files and commits remain available through `workflow +pr-summary --number`
|
||||
|
||||
Safety:
|
||||
- read-only aggregation only
|
||||
- no comments, labels, closes, approve/reject, or merge operations
|
||||
- no LLM dependency
|
||||
|
||||
### `workflow +release-notes`
|
||||
|
||||
Inputs:
|
||||
- `--from`
|
||||
- `--to`
|
||||
- optional `--tag`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR titles
|
||||
- commit messages
|
||||
|
||||
Markdown categories:
|
||||
- Features
|
||||
- Bug Fixes
|
||||
- Documentation
|
||||
- Tests
|
||||
- Refactoring
|
||||
- Chores
|
||||
- Breaking Changes
|
||||
|
||||
### `workflow +stale`
|
||||
|
||||
Inputs:
|
||||
- `--stale-days`
|
||||
- `--state`
|
||||
- `--dry-run`
|
||||
|
||||
Behavior:
|
||||
- Identify stale issues and PRs.
|
||||
- Generate suggested comments or labels.
|
||||
- Do not mutate remote state by default.
|
||||
|
||||
## API Fetch Layer
|
||||
|
||||
The current fetch layer uses:
|
||||
|
||||
- `triage_fetch.go`
|
||||
- `health_fetch.go`
|
||||
- `pr_fetch.go`
|
||||
- `repo_report_fetch.go`
|
||||
|
||||
Design goals already applied:
|
||||
|
||||
- tolerate unknown or partial API fields
|
||||
- map GitLink response shapes into stable workflow DTOs
|
||||
- continue operating when optional signals fail
|
||||
- keep remote-write actions disabled until explicitly enabled later
|
||||
|
||||
Planned fetch-layer extension:
|
||||
|
||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
|
||||
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
|
||||
- Future `release-notes` should reuse the same normalization and renderer patterns.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Pure DTOs and rule engine.
|
||||
2. Pure health scoring.
|
||||
3. Workflow renderers.
|
||||
4. Command registration.
|
||||
5. API fetch and normalization.
|
||||
6. Tests.
|
||||
7. README updates.
|
||||
8. Competition docs and test report.
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
# Workflow Agent Test Report
|
||||
|
||||
## Scope
|
||||
|
||||
This phase covers:
|
||||
|
||||
- Issue triage rules
|
||||
- health scoring rules
|
||||
- PR summary rules
|
||||
- repository report aggregation rules
|
||||
- local command execution
|
||||
- API fetch boundary tests
|
||||
- remote read-only manual verification
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- language handling
|
||||
- mock tests do not depend on the real remote API
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- Go version: `go1.26.1 windows/amd64`
|
||||
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
|
||||
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
|
||||
|
||||
## Test Commands
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `go test ./shortcuts/workflow` passed.
|
||||
- `go test ./...` passed.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
- triage rules tests
|
||||
- health score tests
|
||||
- messages tests
|
||||
- render tests
|
||||
- command tests
|
||||
- fetch boundary tests
|
||||
- PR summary rules and fetch tests
|
||||
- repo report aggregation, render, command, and partial fetch tests
|
||||
|
||||
## API Fetch Boundary Tests
|
||||
|
||||
- empty issue responses return a clear error instead of panicking
|
||||
- missing issue titles still allow body-only issues to be normalized
|
||||
- label normalization supports string arrays, object arrays, and title/name variants
|
||||
- author normalization supports string, `user`, and `creator` shapes
|
||||
- GitLink error-in-body responses return readable errors
|
||||
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
|
||||
- release responses accept `releases`, `data`, and direct array shapes
|
||||
- CI unavailability is recorded as `unknown` without failing the whole health run
|
||||
- stale-days values `0` and negative values fall back to the default `30`
|
||||
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
|
||||
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
|
||||
- PR summary base PR error-in-body responses return readable errors
|
||||
- repo report fetch composes health, issue, and PR sections
|
||||
- repo report returns a partial report when at least one enabled section succeeds
|
||||
- repo report returns an error when all enabled fetched sections fail
|
||||
- repo report issue and PR limits are covered
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
||||
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
|
||||
- Result: succeeded, returned five issues in table form.
|
||||
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
|
||||
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
|
||||
- Remote writes: `No`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Current workflow commands support local analysis and read-only GitLink fetch mode.
|
||||
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
|
||||
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
|
||||
|
||||
## Final Verification
|
||||
|
||||
Final verification should be run before opening the official GitLink PR:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `go test ./shortcuts/workflow` passes.
|
||||
- `go test ./...` passes.
|
||||
- No remote write operation is performed by workflow commands.
|
||||
|
||||
## Competition Demo Commands
|
||||
|
||||
Prefer local fixtures for stable demos:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
||||
Read-only remote smoke commands:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
```
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Copyright 2026 GitLink Workflow Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# GitLink 构建端到端自动化工作流
|
||||
|
||||
面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。
|
||||
|
||||
本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集,自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。
|
||||
|
||||
## 交付物
|
||||
|
||||
- `scripts/gitlink_workflow.py`:主工作流入口
|
||||
- `scripts/run_demo.ps1`:一键复现脚本
|
||||
- `docs/architecture.md`:架构图与流程说明
|
||||
- `docs/quickstart.md`:最短复现路径
|
||||
- `docs/runbook.md`:运行手册
|
||||
- `docs/verification.md`:真实仓库验证记录
|
||||
- `docs/submission-checklist.md`:参赛提交核对清单
|
||||
- `docs/upload-to-gitlink.md`:仓库目录结构说明
|
||||
- `examples/sample_config.json`:参赛仓库配置
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置
|
||||
- `examples/demo_outputs/`:真实运行示例产物
|
||||
- `tests/test_gitlink_workflow.py`:单测
|
||||
- `LICENSE`:Apache 2.0
|
||||
|
||||
## 运行方式
|
||||
|
||||
推荐直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
切换到参赛仓库配置:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1 -Config examples\sample_config.json
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证仓库
|
||||
|
||||
- `puygob236/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并生成包含有效统计数据的周报、Release Notes 和结构化摘要
|
||||
|
||||
## 项目定位
|
||||
|
||||
- 满足子赛题三“端到端自动化工作流”的要求
|
||||
- 串联 4 个数据采集命令和 1 个结果发布命令
|
||||
- 支持在真实 GitLink 项目上复现
|
||||
- 提供运行脚本、验证记录、示例产物和单元测试
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 架构说明
|
||||
|
||||
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
|
||||
|
||||

|
||||
|
||||
## 设计目标
|
||||
|
||||
- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
|
||||
- 可复现:同一配置可重复跑出同类报告
|
||||
- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
|
||||
- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
|
||||
|
||||
## 为什么选这个链路
|
||||
|
||||
子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
|
||||
|
||||
1. 仓库信息采集
|
||||
2. Issue 列表采集
|
||||
3. PR 列表采集
|
||||
4. Release 列表采集
|
||||
5. 报告生成
|
||||
6. Issue 摘要发布
|
||||
|
||||
该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 400 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
# 示例输出摘要
|
||||
|
||||
## 验证目标
|
||||
|
||||
`Gitlink/gitlink-cli`
|
||||
|
||||
## 运行命令
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 关键结果
|
||||
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
- 输出文件:
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
|
||||
## 仓库内示例产物
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 额外验证
|
||||
|
||||
`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# 快速开始
|
||||
|
||||
## 一键运行
|
||||
|
||||
直接运行一键脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行:
|
||||
|
||||
- 仓库信息采集
|
||||
- Issue 列表采集
|
||||
- PR 列表采集
|
||||
- Release 列表采集
|
||||
- 周报生成
|
||||
- Release Notes 草稿生成
|
||||
|
||||
## 配置切换
|
||||
|
||||
- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli`
|
||||
- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli`
|
||||
|
||||
## 输出
|
||||
|
||||
- `outputs/*_report.md`
|
||||
- `outputs/*_release_notes.md`
|
||||
- `outputs/*_summary.json`
|
||||
|
||||
## 已验证事实
|
||||
|
||||
- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证
|
||||
- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# 运行手册
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 已安装 `gitlink-cli`
|
||||
- 已完成 `gitlink-cli auth login`
|
||||
- 目标仓库有可读权限
|
||||
|
||||
官方快速开始里要求的验证命令是:
|
||||
|
||||
```powershell
|
||||
gitlink-cli user +me
|
||||
```
|
||||
|
||||
## 运行方式
|
||||
|
||||
### 1. 只生成报告
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json
|
||||
```
|
||||
|
||||
### 2. 生成报告并发布摘要
|
||||
|
||||
```powershell
|
||||
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123
|
||||
```
|
||||
|
||||
### 3. 一键复现
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
## 输出文件
|
||||
|
||||
- `outputs/*_report.md`:完整周报
|
||||
- `outputs/*_release_notes.md`:Release Notes 草稿
|
||||
- `outputs/*_summary.json`:结构化摘要
|
||||
|
||||
## 验证清单
|
||||
|
||||
- `repo +info` 能返回仓库信息
|
||||
- `issue +list` 能返回 Issue 列表
|
||||
- `pr +list` 能返回 PR 列表
|
||||
- `release +list` 能返回 Release 列表
|
||||
- 报告文件能落盘
|
||||
- Release Notes 草稿能落盘
|
||||
- 发布模式能把摘要写回指定 Issue
|
||||
|
||||
## 真实项目配置
|
||||
|
||||
- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。
|
||||
- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# 提交核对清单
|
||||
|
||||
## 官方交付要求映射
|
||||
|
||||
| 要求 | 本项目对应内容 |
|
||||
| --- | --- |
|
||||
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 |
|
||||
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
|
||||
| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` |
|
||||
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
|
||||
| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` |
|
||||
| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli` 的 `examples/workflows/community-ops-automation/` |
|
||||
| 提供完整中文 README | `README.md` |
|
||||
| 开源协议 | `LICENSE`,Apache 2.0 |
|
||||
|
||||
## 验证状态
|
||||
|
||||
- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过
|
||||
- `python -m unittest discover -s tests`:通过
|
||||
- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通
|
||||
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证
|
||||
|
||||
## 交付内容
|
||||
|
||||
- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`。
|
||||
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
|
||||
- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# GitLink 仓库目录结构
|
||||
|
||||
本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中,目录与主项目源码保持隔离,避免改变主仓库既有命令、Skill 和设计文档结构。
|
||||
|
||||
## 作品路径
|
||||
|
||||
```text
|
||||
examples/workflows/community-ops-automation/
|
||||
```
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `README.md`:项目说明与复现入口
|
||||
- `LICENSE`:Apache 2.0 开源协议
|
||||
- `.gitignore`:运行时产物忽略规则
|
||||
- `docs/`:架构、运行、验证和交付说明
|
||||
- `examples/`:配置文件和示例输出
|
||||
- `scripts/`:工作流执行脚本
|
||||
- `tests/`:单元测试
|
||||
|
||||
## 仓库内验证
|
||||
|
||||
进入作品目录后运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
||||
生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`。
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# 验证记录
|
||||
|
||||
## 环境
|
||||
|
||||
- Windows PowerShell
|
||||
- Python 3
|
||||
- `@gitlink-ai/cli` 0.1.13
|
||||
|
||||
## 已验证的真实仓库
|
||||
|
||||
### `puygob236/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 已完成 Issue 摘要回写验证
|
||||
|
||||
### `Gitlink/gitlink-cli`
|
||||
|
||||
- `repo +info` 可访问
|
||||
- `issue +list` 可访问
|
||||
- `pr +list` 可访问
|
||||
- `release +list` 可访问
|
||||
- 当前可提取到的统计结果:
|
||||
- Issues: 15
|
||||
- PR: 20
|
||||
- Release: 11
|
||||
|
||||
## 本地输出
|
||||
|
||||
已生成的文件:
|
||||
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
|
||||
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_report.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md`
|
||||
- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json`
|
||||
|
||||
其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。
|
||||
|
||||
## 示例产物
|
||||
|
||||
`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例:
|
||||
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
|
||||
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
|
||||
|
||||
## 复现方式
|
||||
|
||||
```powershell
|
||||
.\scripts\run_demo.ps1
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:8 个
|
||||
- 最近窗口内合并 PR:2 个
|
||||
|
||||
## 变更分类
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
|
||||
## 发布说明
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:05:25 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 15 |
|
||||
| 打开 Issues | 5 |
|
||||
| 超窗 Issue | 1 |
|
||||
| PR 总数 | 20 |
|
||||
| 打开 PR | 5 |
|
||||
| 已合并 PR | 8 |
|
||||
| Release 数 | 11 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
### fix
|
||||
- fix(npm): improve missing binary diagnostics (2026-05-19)
|
||||
### feature
|
||||
- feat(pr): add pr +comment shortcut (2026-05-14)
|
||||
|
||||
## 风险提示
|
||||
### 超窗 Issue
|
||||
- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18
|
||||
|
||||
### 建议动作
|
||||
- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# 示例输出说明
|
||||
|
||||
本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。
|
||||
|
||||
- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例
|
||||
- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例
|
||||
- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例
|
||||
- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例
|
||||
|
||||
完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:0 个
|
||||
- 最近窗口内合并 PR:0 个
|
||||
|
||||
## 变更分类
|
||||
- 无
|
||||
|
||||
## 发布说明
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# gitlink-cli 自动化周报
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 核心指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | ---: |
|
||||
| Issues 总数 | 2 |
|
||||
| 打开 Issues | 2 |
|
||||
| 超窗 Issue | 0 |
|
||||
| PR 总数 | 0 |
|
||||
| 打开 PR | 0 |
|
||||
| 已合并 PR | 0 |
|
||||
| Release 数 | 0 |
|
||||
|
||||
## 热点标签
|
||||
- 无
|
||||
|
||||
## 最近合并 PR
|
||||
- 无
|
||||
|
||||
## 风险提示
|
||||
### 建议动作
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "puygob236",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -0,0 +1,814 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class WorkflowError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CLI_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("examples/sample_config.json"),
|
||||
help="配置文件路径",
|
||||
)
|
||||
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
|
||||
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
|
||||
parser.add_argument(
|
||||
"--window-days",
|
||||
type=int,
|
||||
help="统计窗口,默认从配置文件读取或使用 7 天",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
help="输出目录,默认从配置文件读取或使用 outputs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish-issue-id",
|
||||
type=int,
|
||||
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--now",
|
||||
help="固定当前时间,便于测试,格式为 ISO8601",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-releases",
|
||||
action="store_true",
|
||||
help="跳过 release 列表采集",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cli-bin",
|
||||
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def load_json_file(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def sanitize_repo_name(value: str) -> str:
|
||||
return value.replace("/", "_").replace("\\", "_")
|
||||
|
||||
|
||||
def parse_datetime(value: Any) -> datetime | None:
|
||||
if value in (None, "", []):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def parse_iso_now(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.now(timezone.utc)
|
||||
dt = parse_datetime(value)
|
||||
if dt is None:
|
||||
raise WorkflowError(f"无法解析 --now 的值: {value}")
|
||||
return dt
|
||||
|
||||
|
||||
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
|
||||
for key in keys:
|
||||
if key in item:
|
||||
value = item[key]
|
||||
if value not in (None, "", []):
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def normalize_labels(value: Any) -> list[str]:
|
||||
labels: list[str] = []
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
name = first_value(item, ("name", "title", "label_name"))
|
||||
if name:
|
||||
labels.append(str(name))
|
||||
elif item not in (None, ""):
|
||||
labels.append(str(item))
|
||||
elif isinstance(value, str) and value:
|
||||
labels.append(value)
|
||||
return labels
|
||||
|
||||
|
||||
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_list(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
for value in payload.values():
|
||||
found = extract_first_dict(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
found = extract_first_dict(item, keys)
|
||||
if found:
|
||||
return found
|
||||
return {}
|
||||
|
||||
|
||||
def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any:
|
||||
if shutil_which("gitlink-cli") is None:
|
||||
raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中")
|
||||
|
||||
cli_path = shutil_which("gitlink-cli") or "gitlink-cli"
|
||||
if cli_path.lower().endswith((".cmd", ".bat")):
|
||||
cmd = [
|
||||
"cmd",
|
||||
"/c",
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
cli_path,
|
||||
*command,
|
||||
"--owner",
|
||||
owner,
|
||||
"--repo",
|
||||
repo,
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
|
||||
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
|
||||
return parse_json_output(proc.stdout)
|
||||
|
||||
|
||||
def parse_json_output(text: str) -> Any:
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
raise WorkflowError("CLI 返回空结果")
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
first_json = min(
|
||||
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
|
||||
default=-1,
|
||||
)
|
||||
if first_json > 0:
|
||||
return json.loads(stripped[first_json:])
|
||||
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
|
||||
|
||||
|
||||
def normalize_repo_info(payload: Any) -> dict[str, Any]:
|
||||
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
|
||||
if not repo and isinstance(payload, dict):
|
||||
repo = payload
|
||||
return {
|
||||
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
|
||||
"description": first_value(repo, ("description", "desc", "summary"), ""),
|
||||
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
|
||||
"language": first_value(repo, ("language",), ""),
|
||||
"raw": repo,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
|
||||
raw_name = str(
|
||||
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
|
||||
).strip().lower()
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name:
|
||||
return "closed"
|
||||
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
|
||||
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
|
||||
"state": normalize_issue_state(item, query_state=query_state),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
|
||||
),
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_issue(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
|
||||
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
|
||||
if raw_status is not None:
|
||||
try:
|
||||
raw_status = int(raw_status)
|
||||
except (TypeError, ValueError):
|
||||
raw_status = str(raw_status).strip().lower()
|
||||
if raw_status in {1, "1", "merged"}:
|
||||
return "merged"
|
||||
if raw_status in {2, "2", "closed", "close"}:
|
||||
return "closed"
|
||||
if raw_status in {0, "0", "open", "opened"}:
|
||||
return "open"
|
||||
if query_state:
|
||||
return query_state
|
||||
return "open"
|
||||
|
||||
|
||||
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
|
||||
state = normalize_pr_state(item, query_state=query_state)
|
||||
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
|
||||
merged_flag = state == "merged" or merged_at is not None
|
||||
return {
|
||||
"id": str(
|
||||
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
|
||||
),
|
||||
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
|
||||
"state": state,
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
|
||||
),
|
||||
"updated_at": parse_datetime(
|
||||
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
|
||||
),
|
||||
"merged_at": merged_at
|
||||
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
|
||||
"merged": merged_flag,
|
||||
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(normalize_pr(item, query_state=query_state))
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
|
||||
items = extract_first_list(payload, ("releases", "items", "list"))
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
|
||||
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
|
||||
"created_at": parse_datetime(
|
||||
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
|
||||
),
|
||||
"raw": item,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def is_open(state: str) -> bool:
|
||||
return state == "open"
|
||||
|
||||
|
||||
def is_closed(state: str) -> bool:
|
||||
return state in {"closed", "close", "done", "resolved"}
|
||||
|
||||
|
||||
def classify_title(title: str) -> str:
|
||||
lowered = title.strip().lower()
|
||||
prefix = lowered.split(":", 1)[0]
|
||||
prefix = prefix.split("(", 1)[0].strip()
|
||||
mapping = {
|
||||
"feat": "feature",
|
||||
"feature": "feature",
|
||||
"fix": "fix",
|
||||
"bugfix": "fix",
|
||||
"docs": "docs",
|
||||
"doc": "docs",
|
||||
"refactor": "refactor",
|
||||
"test": "test",
|
||||
"chore": "chore",
|
||||
"ci": "ci",
|
||||
}
|
||||
return mapping.get(prefix, "other")
|
||||
|
||||
|
||||
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
|
||||
return dt is not None and dt >= cutoff
|
||||
|
||||
|
||||
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in records:
|
||||
key = str(item.get("id", "")).strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_paginated_payload(
|
||||
command: list[str],
|
||||
owner: str,
|
||||
repo: str,
|
||||
item_keys: tuple[str, ...],
|
||||
page_size: int = CLI_PAGE_SIZE,
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
max_pages = 50
|
||||
while True:
|
||||
if page > max_pages:
|
||||
break
|
||||
payload = run_gitlink_cli(
|
||||
[*command, "--page", str(page), "--limit", str(page_size)],
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
page_items = extract_first_list(payload, item_keys)
|
||||
page_items = [item for item in page_items if isinstance(item, dict)]
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
if len(page_items) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["issue", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("issues", "issue_list", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_issues({"issues": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for state in ("open", "merged", "closed"):
|
||||
payloads = fetch_paginated_payload(
|
||||
["pr", "+list", "--state", state],
|
||||
owner,
|
||||
repo,
|
||||
("pull_requests", "merge_requests", "prs", "items", "list"),
|
||||
)
|
||||
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
|
||||
return dedupe_records(records)
|
||||
|
||||
|
||||
def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
payloads = fetch_paginated_payload(
|
||||
["release", "+list"],
|
||||
owner,
|
||||
repo,
|
||||
("releases", "items", "list"),
|
||||
)
|
||||
return dedupe_records(normalize_releases({"releases": payloads}))
|
||||
|
||||
|
||||
def summarize_workflow(
|
||||
repo_info: dict[str, Any],
|
||||
issues: list[dict[str, Any]],
|
||||
prs: list[dict[str, Any]],
|
||||
releases: list[dict[str, Any]],
|
||||
now: datetime,
|
||||
window_days: int,
|
||||
) -> dict[str, Any]:
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
|
||||
open_issues = [item for item in issues if is_open(item["state"])]
|
||||
closed_issues = [item for item in issues if is_closed(item["state"])]
|
||||
stale_issues = [
|
||||
item
|
||||
for item in open_issues
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
|
||||
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
|
||||
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
|
||||
stale_prs = [
|
||||
item
|
||||
for item in open_prs
|
||||
if item["updated_at"] is None or item["updated_at"] < cutoff
|
||||
]
|
||||
recent_merged_prs = [
|
||||
item
|
||||
for item in merged_prs
|
||||
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
|
||||
]
|
||||
|
||||
issue_label_counter: Counter[str] = Counter()
|
||||
for item in issues:
|
||||
issue_label_counter.update(item["labels"])
|
||||
|
||||
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in recent_merged_prs:
|
||||
pr_buckets[classify_title(item["title"])].append(item)
|
||||
|
||||
actions: list[str] = []
|
||||
if stale_issues:
|
||||
actions.append(
|
||||
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。"
|
||||
)
|
||||
if stale_prs:
|
||||
actions.append(
|
||||
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。"
|
||||
)
|
||||
if not releases:
|
||||
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
|
||||
|
||||
return {
|
||||
"repo": repo_info,
|
||||
"window_days": window_days,
|
||||
"now": now,
|
||||
"cutoff": cutoff,
|
||||
"counts": {
|
||||
"issues_total": len(issues),
|
||||
"issues_open": len(open_issues),
|
||||
"issues_closed": len(closed_issues),
|
||||
"issues_stale": len(stale_issues),
|
||||
"prs_total": len(prs),
|
||||
"prs_open": len(open_prs),
|
||||
"prs_merged": len(merged_prs),
|
||||
"prs_stale": len(stale_prs),
|
||||
"releases_total": len(releases),
|
||||
},
|
||||
"labels": issue_label_counter.most_common(8),
|
||||
"stale_issues": stale_issues,
|
||||
"stale_prs": stale_prs,
|
||||
"recent_merged_prs": recent_merged_prs,
|
||||
"pr_buckets": {key: value for key, value in pr_buckets.items()},
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
|
||||
if not items:
|
||||
return "- 无"
|
||||
lines = []
|
||||
for item in items[:10]:
|
||||
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
|
||||
state = item.get("state")
|
||||
if state:
|
||||
parts.append(f"({state})")
|
||||
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
|
||||
if isinstance(dt, datetime):
|
||||
parts.append(dt.strftime("%Y-%m-%d"))
|
||||
lines.append(" ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_markdown_report(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} 自动化周报")
|
||||
if repo.get("description"):
|
||||
lines.append("")
|
||||
lines.append(repo["description"])
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 核心指标")
|
||||
lines.append("")
|
||||
lines.append("| 指标 | 数值 |")
|
||||
lines.append("| --- | ---: |")
|
||||
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
|
||||
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
|
||||
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
|
||||
lines.append(f"| PR 总数 | {counts['prs_total']} |")
|
||||
lines.append(f"| 打开 PR | {counts['prs_open']} |")
|
||||
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
|
||||
lines.append(f"| Release 数 | {counts['releases_total']} |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 热点标签")
|
||||
if summary["labels"]:
|
||||
for label, count in summary["labels"]:
|
||||
lines.append(f"- {label}: {count}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 最近合并 PR")
|
||||
recent_groups = summary["pr_buckets"]
|
||||
if recent_groups:
|
||||
for bucket, items in recent_groups.items():
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:8]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 风险提示")
|
||||
if summary["stale_issues"]:
|
||||
lines.append("### 超窗 Issue")
|
||||
lines.append(render_list_block(summary["stale_issues"]))
|
||||
lines.append("")
|
||||
if summary["stale_prs"]:
|
||||
lines.append("### 超窗 PR")
|
||||
lines.append(render_list_block(summary["stale_prs"]))
|
||||
lines.append("")
|
||||
if summary["actions"]:
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_release_notes(summary: dict[str, Any]) -> str:
|
||||
repo = summary["repo"]
|
||||
lines: list[str] = []
|
||||
title = repo["name"] or "GitLink 仓库"
|
||||
lines.append(f"# {title} Release Notes 草稿")
|
||||
lines.append("")
|
||||
lines.append(f"- 统计窗口:近 {summary['window_days']} 天")
|
||||
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
lines.append("")
|
||||
lines.append("## 变更概览")
|
||||
lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个")
|
||||
lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个")
|
||||
lines.append("")
|
||||
lines.append("## 变更分类")
|
||||
groups = summary["pr_buckets"]
|
||||
if groups:
|
||||
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
|
||||
items = groups.get(bucket, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"### {bucket}")
|
||||
for item in items[:10]:
|
||||
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
|
||||
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
|
||||
lines.append(f"- {item['title']}{suffix}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("- 无")
|
||||
lines.append("")
|
||||
lines.append("## 发布说明")
|
||||
if summary["actions"]:
|
||||
for action in summary["actions"]:
|
||||
lines.append(f"- {action}")
|
||||
else:
|
||||
lines.append("- 当前未发现明显风险。")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def render_publish_comment(
|
||||
summary: dict[str, Any],
|
||||
report_path: Path,
|
||||
release_notes_path: Path | None = None,
|
||||
) -> str:
|
||||
repo = summary["repo"]
|
||||
counts = summary["counts"]
|
||||
lines = [
|
||||
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
|
||||
"",
|
||||
f"- 时间窗:近 {summary['window_days']} 天",
|
||||
f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
|
||||
f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
|
||||
f"- Release:{counts['releases_total']} 条",
|
||||
"",
|
||||
f"完整报告已生成:`{report_path.as_posix()}`",
|
||||
]
|
||||
if release_notes_path is not None:
|
||||
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
|
||||
if summary["actions"]:
|
||||
lines.append("")
|
||||
lines.append("### 建议动作")
|
||||
for action in summary["actions"][:3]:
|
||||
lines.append(f"- {action}")
|
||||
return "\n".join(lines).rstrip()
|
||||
|
||||
|
||||
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
|
||||
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
|
||||
|
||||
|
||||
def safe_fetch(
|
||||
label: str,
|
||||
func,
|
||||
warnings: list[str],
|
||||
default: Any,
|
||||
) -> Any:
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"{label} 失败:{exc}")
|
||||
return default
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
from shutil import which
|
||||
|
||||
return which(name)
|
||||
|
||||
|
||||
def build_artifacts(
|
||||
owner: str,
|
||||
repo: str,
|
||||
window_days: int,
|
||||
output_dir: Path,
|
||||
now: datetime,
|
||||
publish_issue_id: int | None,
|
||||
skip_releases: bool,
|
||||
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
|
||||
warnings: list[str] = []
|
||||
repo_info = safe_fetch(
|
||||
"repo +info",
|
||||
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)),
|
||||
warnings,
|
||||
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
|
||||
)
|
||||
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, [])
|
||||
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, [])
|
||||
releases = [] if skip_releases else safe_fetch(
|
||||
"release +list",
|
||||
lambda: fetch_releases(owner, repo),
|
||||
warnings,
|
||||
[],
|
||||
)
|
||||
|
||||
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
|
||||
summary["warnings"] = warnings
|
||||
summary["owner"] = owner
|
||||
summary["repo_name"] = repo
|
||||
summary["publish_issue_id"] = publish_issue_id
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
repo_slug = sanitize_repo_name(repo)
|
||||
base_name = f"{owner}_{repo_slug}_{stamp}"
|
||||
report_path = output_dir / f"{base_name}_report.md"
|
||||
summary_path = output_dir / f"{base_name}_summary.json"
|
||||
release_notes_path = output_dir / f"{base_name}_release_notes.md"
|
||||
|
||||
report_text = render_markdown_report(summary)
|
||||
release_notes_text = render_release_notes(summary)
|
||||
report_path.write_text(report_text, encoding="utf-8")
|
||||
release_notes_path.write_text(release_notes_text, encoding="utf-8")
|
||||
summary_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**summary,
|
||||
"now": summary["now"].isoformat(),
|
||||
"cutoff": summary["cutoff"].isoformat(),
|
||||
"artifacts": {
|
||||
"report": report_path.as_posix(),
|
||||
"summary": summary_path.as_posix(),
|
||||
"release_notes": release_notes_path.as_posix(),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if publish_issue_id is not None:
|
||||
comment = render_publish_comment(summary, report_path, release_notes_path)
|
||||
try:
|
||||
run_gitlink_cli(
|
||||
build_issue_comment_command(publish_issue_id, comment),
|
||||
owner,
|
||||
repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"issue +comment 失败:{exc}")
|
||||
|
||||
return summary, report_path, summary_path, release_notes_path, warnings
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
config = load_json_file(args.config)
|
||||
|
||||
owner = args.owner or config.get("owner")
|
||||
repo = args.repo or config.get("repo")
|
||||
if not owner or not repo:
|
||||
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
|
||||
|
||||
window_days = args.window_days or int(config.get("window_days", 7))
|
||||
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
|
||||
now = parse_iso_now(args.now)
|
||||
|
||||
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
window_days=window_days,
|
||||
output_dir=output_dir,
|
||||
now=now,
|
||||
publish_issue_id=args.publish_issue_id,
|
||||
skip_releases=args.skip_releases,
|
||||
)
|
||||
|
||||
print(f"已生成报告: {report_path}")
|
||||
print(f"已生成摘要: {summary_path}")
|
||||
print(f"已生成 Release Notes: {release_notes_path}")
|
||||
if warnings:
|
||||
print("警告:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
print(
|
||||
"指标概览: "
|
||||
f"Issues={summary['counts']['issues_total']}, "
|
||||
f"PR={summary['counts']['prs_total']}, "
|
||||
f"Release={summary['counts']['releases_total']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
param(
|
||||
[string]$Config = "examples/demo_active_config.json",
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[int]$WindowDays = 7,
|
||||
[string]$OutputDir = "outputs",
|
||||
[int]$PublishIssueId = 0,
|
||||
[switch]$SkipReleases
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
|
||||
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
|
||||
if (-not $cliPath) {
|
||||
$cliPath = $cliCandidates | Select-Object -First 1
|
||||
}
|
||||
if (-not $cliPath) {
|
||||
throw "未能通过 npm exec 找到 gitlink-cli"
|
||||
}
|
||||
|
||||
$cliDir = Split-Path -Parent $cliPath
|
||||
$env:PATH = "$cliDir;$env:PATH"
|
||||
|
||||
$args = @(
|
||||
"scripts\gitlink_workflow.py",
|
||||
"--config", $Config,
|
||||
"--window-days", "$WindowDays",
|
||||
"--output-dir", $OutputDir
|
||||
)
|
||||
|
||||
if ($Owner) {
|
||||
$args += @("--owner", $Owner)
|
||||
}
|
||||
if ($Repo) {
|
||||
$args += @("--repo", $Repo)
|
||||
}
|
||||
if ($PublishIssueId -gt 0) {
|
||||
$args += @("--publish-issue-id", "$PublishIssueId")
|
||||
}
|
||||
if ($SkipReleases.IsPresent) {
|
||||
$args += "--skip-releases"
|
||||
}
|
||||
|
||||
python @args
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from scripts.gitlink_workflow import (
|
||||
build_issue_comment_command,
|
||||
normalize_issues,
|
||||
normalize_prs,
|
||||
normalize_releases,
|
||||
render_markdown_report,
|
||||
render_release_notes,
|
||||
summarize_workflow,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
|
||||
self.repo_info = {
|
||||
"name": "forgeplus",
|
||||
"description": "demo repo",
|
||||
"default_branch": "master",
|
||||
}
|
||||
|
||||
def test_normalize_issue_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"issues": [
|
||||
{
|
||||
"project_issues_index": 1,
|
||||
"subject": "feat: add report",
|
||||
"status_id": 1,
|
||||
"status_name": "新增",
|
||||
"updated_at": "2026-05-10T10:00:00Z",
|
||||
"labels": [{"name": "enhancement"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
issues = normalize_issues(payload)
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["title"], "feat: add report")
|
||||
self.assertEqual(issues[0]["labels"], ["enhancement"])
|
||||
self.assertEqual(issues[0]["state"], "open")
|
||||
|
||||
def test_normalize_pr_payload(self) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"merge_requests": [
|
||||
{
|
||||
"pull_request_number": 10,
|
||||
"title": "fix: bug",
|
||||
"pull_request_status": 1,
|
||||
"merged_at": "2026-05-14T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
prs = normalize_prs(payload)
|
||||
self.assertEqual(len(prs), 1)
|
||||
self.assertTrue(prs[0]["merged"])
|
||||
self.assertEqual(prs[0]["state"], "merged")
|
||||
|
||||
def test_normalize_release_payload(self) -> None:
|
||||
payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}}
|
||||
releases = normalize_releases(payload)
|
||||
self.assertEqual(len(releases), 1)
|
||||
self.assertEqual(releases[0]["title"], "v1.0.0")
|
||||
|
||||
def test_summary_and_report(self) -> None:
|
||||
issues = [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "feat: add report",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["enhancement"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "fix: stale issue",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"labels": ["bug"],
|
||||
},
|
||||
]
|
||||
prs = [
|
||||
{
|
||||
"id": "10",
|
||||
"title": "feat: workflow",
|
||||
"state": "merged",
|
||||
"created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
|
||||
"merged": True,
|
||||
"labels": [],
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"title": "chore: cleanup",
|
||||
"state": "open",
|
||||
"created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
|
||||
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc),
|
||||
"merged_at": None,
|
||||
"merged": False,
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}]
|
||||
summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7)
|
||||
report = render_markdown_report(summary)
|
||||
self.assertIn("# forgeplus 自动化周报", report)
|
||||
self.assertIn("Issues 总数", report)
|
||||
self.assertIn("超窗 Issue", report)
|
||||
self.assertIn("feature", report)
|
||||
release_notes = render_release_notes(summary)
|
||||
self.assertIn("Release Notes", release_notes)
|
||||
self.assertIn("变更分类", release_notes)
|
||||
self.assertEqual(summary["counts"]["issues_stale"], 1)
|
||||
self.assertEqual(summary["counts"]["prs_merged"], 1)
|
||||
self.assertIn("feature", summary["pr_buckets"])
|
||||
|
||||
def test_issue_comment_command_uses_number_flag(self) -> None:
|
||||
command = build_issue_comment_command(2, "demo")
|
||||
self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"])
|
||||
self.assertNotIn("-i", command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
11
go.mod
11
go.mod
|
|
@ -5,16 +5,27 @@ go 1.26.1
|
|||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
51
go.sum
51
go.sum
|
|
@ -4,17 +4,31 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF
|
|||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -30,12 +44,49 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
|||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func Login(username, password string) (*LoginResult, error) {
|
|||
if _, verifyErr := GetCurrentUser(); verifyErr != nil {
|
||||
// Clean up the bad token
|
||||
_ = DeleteToken()
|
||||
return nil, fmt.Errorf("login failed: credentials not accepted by API (%v)", verifyErr)
|
||||
return nil, fmt.Errorf("login failed: credentials not accepted by API (%w)", verifyErr)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func setupConfigDir(t *testing.T, baseURL string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
// Write a minimal config
|
||||
cfgDir := filepath.Join(dir)
|
||||
os.MkdirAll(cfgDir, 0700)
|
||||
os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("base_url: "+baseURL+"\ndefault_format: table\n"), 0600)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestGetCurrentUserSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/users/me.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"name": "Test User",
|
||||
"id": 42,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
// Need to prevent any cookie/token auth from interfering
|
||||
os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
user, err := GetCurrentUser()
|
||||
if err != nil {
|
||||
t.Fatalf("GetCurrentUser error: %v", err)
|
||||
}
|
||||
if user["login"] != "testuser" {
|
||||
t.Fatalf("login = %q, want testuser", user["login"])
|
||||
}
|
||||
if user["name"] != "Test User" {
|
||||
t.Fatalf("name = %q", user["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentUserHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("unauthorized"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
_, err := GetCurrentUser()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentUserStatusError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": float64(-1),
|
||||
"message": "Token invalid",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
_, err := GetCurrentUser()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for status=-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentUserMissingLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": 42,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
_, err := GetCurrentUser()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when login field is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccess(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "POST" && r.URL.Path == "/accounts/login.json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Set-Cookie", "autologin_trustie=sess123; Path=/")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"username": "testuser",
|
||||
"login": "testuser",
|
||||
"user_id": 42,
|
||||
"token": "tok123",
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"login": "testuser",
|
||||
"name": "Test User",
|
||||
"id": float64(42),
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
|
||||
result, err := Login("testuser", "password")
|
||||
if err != nil {
|
||||
t.Fatalf("Login error: %v", err)
|
||||
}
|
||||
if result.Username != "testuser" {
|
||||
t.Fatalf("Username = %q, want testuser", result.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginStatusError(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": float64(-1),
|
||||
"message": "Invalid credentials",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
|
||||
_, err := Login("testuser", "wrongpass")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNoCookies(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"username": "testuser",
|
||||
"login": "testuser",
|
||||
"user_id": 42,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
setupConfigDir(t, server.URL)
|
||||
|
||||
_, err := Login("testuser", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no auth cookies")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func tempHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestStoreLoadDeleteTokenFile(t *testing.T) {
|
||||
tempHome(t)
|
||||
|
||||
// First, delete any existing token
|
||||
_ = deleteTokenFile()
|
||||
|
||||
// Initially, loading should fail
|
||||
_, err := loadTokenFile()
|
||||
if err == nil {
|
||||
t.Fatal("expected error loading non-existent token file")
|
||||
}
|
||||
|
||||
// Store a token
|
||||
if err := storeTokenFile("test-token-123"); err != nil {
|
||||
t.Fatalf("storeTokenFile error: %v", err)
|
||||
}
|
||||
|
||||
// Load it back
|
||||
token, err := loadTokenFile()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTokenFile error: %v", err)
|
||||
}
|
||||
if token != "test-token-123" {
|
||||
t.Fatalf("token = %q, want test-token-123", token)
|
||||
}
|
||||
|
||||
// Delete it
|
||||
if err := deleteTokenFile(); err != nil {
|
||||
t.Fatalf("deleteTokenFile error: %v", err)
|
||||
}
|
||||
|
||||
// Now loading should fail again
|
||||
_, err = loadTokenFile()
|
||||
if err == nil {
|
||||
t.Fatal("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialPath(t *testing.T) {
|
||||
tempHome(t)
|
||||
got := credentialPath()
|
||||
expected := filepath.Join(os.Getenv("HOME"), ".config", "gitlink-cli", "credentials")
|
||||
if got != expected {
|
||||
t.Fatalf("credentialPath = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenFileCreatesDir(t *testing.T) {
|
||||
home := tempHome(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
// Config dir shouldn't exist yet
|
||||
credDir := filepath.Join(home, ".config", "gitlink-cli")
|
||||
os.RemoveAll(credDir)
|
||||
|
||||
if err := storeTokenFile("new-token"); err != nil {
|
||||
t.Fatalf("storeTokenFile error: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists and has content
|
||||
data, err := os.ReadFile(filepath.Join(credDir, "credentials"))
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if string(data) != "new-token" {
|
||||
t.Fatalf("file content = %q, want new-token", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTokenFileNonExistent(t *testing.T) {
|
||||
tempHome(t)
|
||||
_ = deleteTokenFile()
|
||||
// Deleting non-existent file should return an error from os.Remove
|
||||
err := deleteTokenFile()
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoadTokenFileEmpty(t *testing.T) {
|
||||
tempHome(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
if err := storeTokenFile(""); err != nil {
|
||||
t.Fatalf("storeTokenFile empty: %v", err)
|
||||
}
|
||||
|
||||
token, err := loadTokenFile()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTokenFile error: %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenFallback(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := tempHome(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
if err := StoreToken("keychain-fallback-token"); err != nil {
|
||||
t.Fatalf("StoreToken error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(home, ".config", "gitlink-cli", "credentials"))
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if string(data) != "keychain-fallback-token" {
|
||||
t.Fatalf("file content = %q, want keychain-fallback-token", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTokenFallback(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := tempHome(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
p := filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
os.MkdirAll(filepath.Dir(p), 0700)
|
||||
os.WriteFile(p, []byte("delete-me"), 0600)
|
||||
|
||||
if err := DeleteToken(); err != nil {
|
||||
t.Fatalf("DeleteToken error: %v", err)
|
||||
}
|
||||
|
||||
_, err := os.Stat(p)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatal("file should be deleted")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransportCookieAuth(t *testing.T) {
|
||||
// Mock an HTTP server that checks for the Cookie header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie := r.Header.Get("Cookie")
|
||||
if cookie == "" {
|
||||
t.Error("expected Cookie header")
|
||||
}
|
||||
|
||||
// Verify the request has the right Accept header
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
// Set a cookie-based token
|
||||
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=test123")
|
||||
defer os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportTokenAuth(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("access_token") == "" {
|
||||
t.Error("expected access_token query parameter")
|
||||
}
|
||||
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||
}
|
||||
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
os.Setenv("GITLINK_TOKEN", "private-token-abc")
|
||||
defer os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportCookieAppend(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie := r.Header.Get("Cookie")
|
||||
if cookie == "" {
|
||||
t.Error("expected Cookie header")
|
||||
}
|
||||
// Should contain both original and injected cookies
|
||||
if cookie != "existing=val; autologin_trustie=injected" {
|
||||
t.Errorf("Cookie = %q, want 'existing=val; autologin_trustie=injected'", cookie)
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=injected")
|
||||
defer os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
req.Header.Set("Cookie", "existing=val")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestTransportNoToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
// Force empty env and redirect HOME to avoid keychain fallback
|
||||
os.Unsetenv("GITLINK_TOKEN")
|
||||
oldHome := os.Getenv("HOME")
|
||||
tempHome := t.TempDir()
|
||||
os.Setenv("HOME", tempHome)
|
||||
defer os.Setenv("HOME", oldHome)
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestTransportDefaultContentType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
// Transport only sets Content-Type when Body is non-nil
|
||||
body := strings.NewReader(`{"key":"val"}`)
|
||||
req, _ := http.NewRequest("POST", server.URL, body)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestTransportExplicitContentType(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should preserve explicit Content-Type
|
||||
if r.Header.Get("Content-Type") != "text/plain" {
|
||||
t.Errorf("Content-Type = %q, want text/plain", r.Header.Get("Content-Type"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL, nil)
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
client := NewHTTPClient()
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
if client.Transport == nil {
|
||||
t.Fatal("expected Transport to be set")
|
||||
}
|
||||
if _, ok := client.Transport.(*Transport); !ok {
|
||||
t.Fatalf("expected *Transport, got %T", client.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportEnvVarPriority(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("access_token") != "env-token" {
|
||||
t.Errorf("access_token = %q, want env-token", r.URL.Query().Get("access_token"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: http.DefaultTransport}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
os.Setenv("GITLINK_TOKEN", "env-token")
|
||||
defer os.Unsetenv("GITLINK_TOKEN")
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestTransportNilBase(t *testing.T) {
|
||||
// When Base is nil, it should use http.DefaultTransport
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
transport := &Transport{Base: nil}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", server.URL, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
|
@ -42,19 +42,21 @@ func New() (*Client, error) {
|
|||
}
|
||||
|
||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
path = normalizeAPIPath(c.BaseURL, path)
|
||||
|
||||
// Append .json suffix if not already present (GitLink API convention)
|
||||
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
if !strings.HasSuffix(basePath, ".json") {
|
||||
if shouldAppendJSONSuffix(basePath) {
|
||||
path = basePath + ".json" + queryStr
|
||||
}
|
||||
} else if !strings.HasSuffix(path, ".json") {
|
||||
} else if shouldAppendJSONSuffix(path) {
|
||||
path += ".json"
|
||||
}
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil && len(query) > 0 {
|
||||
if len(query) > 0 {
|
||||
sep := "?"
|
||||
if strings.Contains(fullURL, "?") {
|
||||
sep = "&"
|
||||
|
|
@ -158,6 +160,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return output.SuccessEnvelope(raw, meta), nil
|
||||
}
|
||||
|
||||
func shouldAppendJSONSuffix(path string) bool {
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for i, part := range parts {
|
||||
if part == "raw" && i >= 2 && i+2 < len(parts) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeAPIPath(baseURL, path string) string {
|
||||
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
|
||||
switch {
|
||||
case path == "/api":
|
||||
return ""
|
||||
case strings.HasPrefix(path, "/api/"):
|
||||
return strings.TrimPrefix(path, "/api")
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||||
return c.Do("GET", path, nil, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,527 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIError(t *testing.T) {
|
||||
err := &APIError{StatusCode: 404, Code: "not_found", Message: "PR not found"}
|
||||
if err.Error() != "[not_found] PR not found" {
|
||||
t.Fatalf("Error() = %q, want %q", err.Error(), "[not_found] PR not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFix(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{401, "请先运行 gitlink-cli auth login 登录"},
|
||||
{403, "权限不足,请确认账户权限或联系项目管理员"},
|
||||
{404, "资源不存在,请检查 owner/repo/id 是否正确"},
|
||||
{422, "参数校验失败,请检查请求参数"},
|
||||
{500, ""},
|
||||
{0, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(http.StatusText(tt.code), func(t *testing.T) {
|
||||
if got := suggestFix(tt.code); got != tt.want {
|
||||
t.Fatalf("suggestFix(%d) = %q, want %q", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true,"data":{"key":"value"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatal("expected OK=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoJSONSuffix(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/test.json" {
|
||||
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoJSONSuffixPreserved(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/test.json" {
|
||||
t.Fatalf("expected path /api/test.json, got %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Do("GET", "/api/test.json", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoQueryParams(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != "open" {
|
||||
t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state"))
|
||||
}
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
t.Fatalf("expected page=1, got %s", r.URL.Query().Get("page"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
q := url.Values{}
|
||||
q.Set("state", "open")
|
||||
q.Set("page", "1")
|
||||
_, err := c.Do("GET", "/api/test", nil, q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != 404 {
|
||||
t.Fatalf("StatusCode = %d, want 404", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoNonJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("plain text response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatal("expected OK=true for non-JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoStatusError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":403,"message":"Forbidden"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for status=403")
|
||||
}
|
||||
if env == nil {
|
||||
t.Fatal("expected envelope for status error")
|
||||
}
|
||||
if env.OK {
|
||||
t.Fatal("expected OK=false for status error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoStatusZero(t *testing.T) {
|
||||
// status=0, 200, 1 are treated as success
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":0,"data":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatal("expected OK=true for status=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoPaginationMeta(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"total_count":100,"page":1,"limit":20,"data":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if env.Meta == nil {
|
||||
t.Fatal("expected Meta to be populated")
|
||||
}
|
||||
if env.Meta.TotalCount != 100 {
|
||||
t.Fatalf("TotalCount = %d, want 100", env.Meta.TotalCount)
|
||||
}
|
||||
if env.Meta.Page != 1 {
|
||||
t.Fatalf("Page = %d, want 1", env.Meta.Page)
|
||||
}
|
||||
if env.Meta.Limit != 20 {
|
||||
t.Fatalf("Limit = %d, want 20", env.Meta.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoPathWithQuery(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The path query param should be preserved
|
||||
if r.URL.Query().Get("filepath") != "test.go" {
|
||||
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoPathWithQueryAndExtraParams(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("ref") != "master" {
|
||||
t.Fatalf("expected ref=master, got %s", r.URL.Query().Get("ref"))
|
||||
}
|
||||
if r.URL.Query().Get("filepath") != "test.go" {
|
||||
t.Fatalf("expected filepath=test.go, got %s", r.URL.Query().Get("filepath"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
q := url.Values{}
|
||||
q.Set("ref", "master")
|
||||
_, err := c.Do("GET", "/api/sub_entries?filepath=test.go", nil, q)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoWithBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":123}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
env, err := c.Do("POST", "/api/create", map[string]string{"title": "test"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatal("expected OK=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientGet(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Get("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPost(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Post("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPut(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" {
|
||||
t.Fatalf("expected PUT, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Put("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDelete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" {
|
||||
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Delete("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoInvalidURL(t *testing.T) {
|
||||
c := &Client{HTTP: &http.Client{}, BaseURL: "://invalid"}
|
||||
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDebug(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL, Debug: true}
|
||||
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDoStatusInt(t *testing.T) {
|
||||
// Some APIs return status as int, not float64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":404,"message":"Not Found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.Do("GET", "/api/test", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for status=404 (int)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientNew(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(cfgPath, []byte("base_url: https://gitlink.example.com/api/v1\n"), 0644)
|
||||
|
||||
cli, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New error: %v", err)
|
||||
}
|
||||
if cli.BaseURL != "https://gitlink.example.com/api/v1" {
|
||||
t.Fatalf("BaseURL = %q", cli.BaseURL)
|
||||
}
|
||||
if cli.HTTP == nil {
|
||||
t.Fatal("HTTP client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllSinglePage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
items, err := c.PaginateAll("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAll error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllMultiPage(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
page := r.URL.Query().Get("page")
|
||||
if page == "1" {
|
||||
w.Write([]byte(`{"data":[{"id":1},{"id":2}]}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"data":[{"id":3}]}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
params := url.Values{}
|
||||
params.Set("limit", "2")
|
||||
items, err := c.PaginateAll("/test", params)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAll error: %v", err)
|
||||
}
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(items))
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllWrappedData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"data":[{"id":1},{"id":2}],"total_count":2}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
items, err := c.PaginateAll("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAll error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllSingleObject(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"name":"single-object"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
items, err := c.PaginateAll("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateAll error: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
var data map[string]interface{}
|
||||
json.Unmarshal(items[0], &data)
|
||||
if data["name"] != "single-object" {
|
||||
t.Fatalf("unexpected data: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.PaginateAll("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateAllNotOK(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":500,"message":"error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
|
||||
_, err := c.PaginateAll("/test", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when envelope ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) {
|
||||
if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") {
|
||||
t.Fatal("raw file path should not get .json suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAppendJSONSuffixKeepsRawRepositoryName(t *testing.T) {
|
||||
if !shouldAppendJSONSuffix("/users/raw/projects") {
|
||||
t.Fatal("regular API path should get .json suffix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAppendJSONSuffixSkipsExistingJSONPath(t *testing.T) {
|
||||
if shouldAppendJSONSuffix("/projects.json") {
|
||||
t.Fatal("existing .json path should not get another suffix")
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ type Config struct {
|
|||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
Lang string `yaml:"lang,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
@ -85,6 +86,8 @@ func Get(key string) (string, error) {
|
|||
return cfg.Editor, nil
|
||||
case "pager":
|
||||
return cfg.Pager, nil
|
||||
case "lang":
|
||||
return cfg.Lang, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
|
@ -104,6 +107,8 @@ func Set(key, value string) error {
|
|||
cfg.Editor = value
|
||||
case "pager":
|
||||
cfg.Pager = value
|
||||
case "lang":
|
||||
cfg.Lang = value
|
||||
}
|
||||
return Save(cfg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tempConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.BaseURL != DefaultBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want %q", cfg.BaseURL, DefaultBaseURL)
|
||||
}
|
||||
if cfg.Format != DefaultFormat {
|
||||
t.Fatalf("Format = %q, want %q", cfg.Format, DefaultFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDirEnv(t *testing.T) {
|
||||
dir := tempConfigDir(t)
|
||||
if got := ConfigDir(); got != dir {
|
||||
t.Fatalf("ConfigDir = %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDirDefault(t *testing.T) {
|
||||
// Without GITLINK_CONFIG_DIR set, should use $HOME/.config/gitlink-cli
|
||||
t.Setenv("GITLINK_CONFIG_DIR", "")
|
||||
got := ConfigDir()
|
||||
home, _ := os.UserHomeDir()
|
||||
if !strings.Contains(got, ".config") && !strings.Contains(got, "gitlink-cli") {
|
||||
t.Fatalf("ConfigDir = %q, expected path under home", got)
|
||||
}
|
||||
if home != "" && !strings.HasPrefix(got, home) {
|
||||
t.Fatalf("ConfigDir = %q, expected to start with home %q", got, home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPath(t *testing.T) {
|
||||
dir := tempConfigDir(t)
|
||||
got := ConfigPath()
|
||||
want := filepath.Join(dir, "config.yaml")
|
||||
if got != want {
|
||||
t.Fatalf("ConfigPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndSave(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.BaseURL = "https://custom.example.com/api"
|
||||
cfg.Format = "json"
|
||||
cfg.Editor = "vim"
|
||||
cfg.Pager = "less"
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
|
||||
if loaded.BaseURL != "https://custom.example.com/api" {
|
||||
t.Fatalf("BaseURL = %q", loaded.BaseURL)
|
||||
}
|
||||
if loaded.Format != "json" {
|
||||
t.Fatalf("Format = %q", loaded.Format)
|
||||
}
|
||||
if loaded.Editor != "vim" {
|
||||
t.Fatalf("Editor = %q", loaded.Editor)
|
||||
}
|
||||
if loaded.Pager != "less" {
|
||||
t.Fatalf("Pager = %q", loaded.Pager)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsWhenFileMissing(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
// No config file exists
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if cfg.BaseURL != DefaultBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
|
||||
}
|
||||
if cfg.Format != DefaultFormat {
|
||||
t.Fatalf("Format = %q, want default", cfg.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyValuesFallbackToDefaults(t *testing.T) {
|
||||
dir := tempConfigDir(t)
|
||||
// Write config with empty values
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("base_url: \"\"\ndefault_format: \"\"\n"), 0600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if cfg.BaseURL != DefaultBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want default", cfg.BaseURL)
|
||||
}
|
||||
if cfg.Format != DefaultFormat {
|
||||
t.Fatalf("Format = %q, want default", cfg.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
cfg := DefaultConfig()
|
||||
cfg.BaseURL = "https://get.example.com/api"
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"base_url", "https://get.example.com/api"},
|
||||
{"default_format", "table"},
|
||||
{"editor", ""},
|
||||
{"pager", ""},
|
||||
{"unknown_key", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
got, err := Get(tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("Get(%q) = %q, want %q", tt.key, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
// First save defaults
|
||||
if err := Save(DefaultConfig()); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
if err := Set("base_url", "https://set.example.com/api"); err != nil {
|
||||
t.Fatalf("Set base_url error: %v", err)
|
||||
}
|
||||
if err := Set("editor", "nano"); err != nil {
|
||||
t.Fatalf("Set editor error: %v", err)
|
||||
}
|
||||
|
||||
// Verify Get reads updated values
|
||||
baseURL, _ := Get("base_url")
|
||||
if baseURL != "https://set.example.com/api" {
|
||||
t.Fatalf("Get base_url = %q", baseURL)
|
||||
}
|
||||
editor, _ := Get("editor")
|
||||
if editor != "nano" {
|
||||
t.Fatalf("Get editor = %q", editor)
|
||||
}
|
||||
// default_format should still be default
|
||||
format, _ := Get("default_format")
|
||||
if format != DefaultFormat {
|
||||
t.Fatalf("Get default_format = %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUnknownKey(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
if err := Save(DefaultConfig()); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
// Setting unknown key should not error, just silently ignored
|
||||
if err := Set("nonexistent", "value"); err != nil {
|
||||
t.Fatalf("Set nonexistent error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesDir(t *testing.T) {
|
||||
// Use a subdirectory that doesn't exist yet
|
||||
dir := filepath.Join(t.TempDir(), "new", "subdir")
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.BaseURL = "https://test.example.com/api"
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
// Verify it was actually saved
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if loaded.BaseURL != "https://test.example.com/api" {
|
||||
t.Fatalf("BaseURL = %q", loaded.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidYAML(t *testing.T) {
|
||||
dir := tempConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("::: invalid yaml :::"), 0600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRemoteURLHTTPS(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/Gitlink/gitlink-cli.git")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "Gitlink" {
|
||||
t.Fatalf("owner = %q, want Gitlink", owner)
|
||||
}
|
||||
if repo != "gitlink-cli" {
|
||||
t.Fatalf("repo = %q, want gitlink-cli", repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLHTTPSNoGit(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL("https://www.gitlink.org.cn/owner/repo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "owner" || repo != "repo" {
|
||||
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLSSH(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL("git@www.gitlink.org.cn:Gitlink/gitlink-cli.git")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "Gitlink" {
|
||||
t.Fatalf("owner = %q, want Gitlink", owner)
|
||||
}
|
||||
if repo != "gitlink-cli" {
|
||||
t.Fatalf("repo = %q, want gitlink-cli", repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLSSHNoSuffix(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL("git@gitlink.org.cn:owner/repo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "owner" || repo != "repo" {
|
||||
t.Fatalf("got %s/%s, want owner/repo", owner, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLInvalidSSH(t *testing.T) {
|
||||
_, _, err := parseRemoteURL("git@gitlink.org.cn")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid SSH URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteURLInvalidHTTPS(t *testing.T) {
|
||||
_, _, err := parseRemoteURL("://invalid-url")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid HTTPS URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePathSegments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantOwner string
|
||||
wantRepo string
|
||||
wantErr bool
|
||||
}{
|
||||
{"basic", "owner/repo", "owner", "repo", false},
|
||||
{"with git", "owner/repo.git", "owner", "repo", false},
|
||||
{"leading slash", "/owner/repo", "owner", "repo", false},
|
||||
{"both", "/owner/repo.git", "owner", "repo", false},
|
||||
{"with subpath", "owner/repo/sub", "owner", "repo", false},
|
||||
{"single segment", "onlyowner", "", "", true},
|
||||
{"empty", "", "", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
owner, repo, err := parsePathSegments(tt.path)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != tt.wantOwner || repo != tt.wantRepo {
|
||||
t.Fatalf("got %s/%s, want %s/%s", owner, repo, tt.wantOwner, tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOwnerRepoExplicit(t *testing.T) {
|
||||
owner, repo, err := ResolveOwnerRepo("explicitOwner", "explicitRepo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "explicitOwner" || repo != "explicitRepo" {
|
||||
t.Fatalf("got %s/%s, want explicitOwner/explicitRepo", owner, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOwnerRepoPartialFlagsInGitRepo(t *testing.T) {
|
||||
// When in a git repo, partial flags use git remote for the missing part.
|
||||
owner, repo, err := ResolveOwnerRepo("", "partialRepo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner == "" {
|
||||
t.Fatal("expected owner to be resolved from git remote")
|
||||
}
|
||||
if repo != "partialRepo" {
|
||||
t.Fatalf("repo = %q, want partialRepo", repo)
|
||||
}
|
||||
|
||||
owner, repo, err = ResolveOwnerRepo("partialOwner", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "partialOwner" {
|
||||
t.Fatalf("owner = %q, want partialOwner", owner)
|
||||
}
|
||||
if repo == "" {
|
||||
t.Fatal("expected repo to be resolved from git remote")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package i18n
|
||||
|
||||
// Args contains named values used by parameterized messages.
|
||||
type Args map[string]any
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fix := flag.Bool("fix", false, "format locale JSON files")
|
||||
scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys")
|
||||
flag.Parse()
|
||||
|
||||
problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
for _, problem := range problems {
|
||||
fmt.Fprintln(os.Stderr, problem.String())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := checkLocaleFormat(*fix); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *scanCode {
|
||||
if err := checkCodeReferences(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
fmt.Println("i18n messages are valid")
|
||||
}
|
||||
|
||||
func checkLocaleFormat(fix bool) error {
|
||||
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range files {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formatted, err := formatJSON(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if string(data) == string(formatted) {
|
||||
continue
|
||||
}
|
||||
if fix {
|
||||
if err := os.WriteFile(path, formatted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatJSON(data []byte) ([]byte, error) {
|
||||
var messages map[string]string
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(messages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func checkCodeReferences() error {
|
||||
loader := i18n.NewEmbedLoader()
|
||||
base, err := loader.Load("en-US")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for key := range used {
|
||||
if _, ok := base[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
for _, item := range defaultUses {
|
||||
fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) {
|
||||
keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`)
|
||||
defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`)
|
||||
used := map[string]struct{}{}
|
||||
var defaultUses []string
|
||||
for _, root := range roots {
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path) // #nosec G122 -- dev-only scan over repo roots; symlinks are skipped above.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
for _, match := range keyPattern.FindAllStringSubmatch(text, -1) {
|
||||
used[match[1]] = struct{}{}
|
||||
}
|
||||
for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) {
|
||||
line := 1 + strings.Count(text[:match[0]], "\n")
|
||||
defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
sort.Strings(defaultUses)
|
||||
return used, defaultUses, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Package i18n provides localized user-facing messages for the CLI.
|
||||
//
|
||||
// It intentionally does not localize machine-readable output such as JSON
|
||||
// field names, API response bodies, or debug diagnostics.
|
||||
package i18n
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var embeddedLocales embed.FS
|
||||
|
||||
// Loader loads locale messages from a backing store.
|
||||
type Loader interface {
|
||||
Load(locale string) (map[string]string, error)
|
||||
AvailableLocales() ([]string, error)
|
||||
}
|
||||
|
||||
type embedLoader struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
// NewEmbedLoader returns the default loader backed by embedded locale files.
|
||||
func NewEmbedLoader() Loader {
|
||||
return embedLoader{fs: embeddedLocales}
|
||||
}
|
||||
|
||||
func (l embedLoader) Load(locale string) (map[string]string, error) {
|
||||
locale = NormalizeLocale(locale)
|
||||
path := filepath.ToSlash(filepath.Join("locales", locale+".json"))
|
||||
data, err := fs.ReadFile(l.fs, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load locale %s: %w", locale, err)
|
||||
}
|
||||
|
||||
var messages map[string]string
|
||||
if err := json.Unmarshal(data, &messages); err != nil {
|
||||
return nil, fmt.Errorf("parse locale %s: %w", locale, err)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (l embedLoader) AvailableLocales() ([]string, error) {
|
||||
entries, err := fs.ReadDir(l.fs, "locales")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
locales := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
locales = append(locales, strings.TrimSuffix(entry.Name(), ".json"))
|
||||
}
|
||||
sort.Strings(locales)
|
||||
return locales, nil
|
||||
}
|
||||
|
||||
// AvailableLocales returns locales available from the embedded loader.
|
||||
func AvailableLocales() ([]string, error) {
|
||||
return NewEmbedLoader().AvailableLocales()
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeLocale converts common locale spellings to a stable BCP-47-like form.
|
||||
func NormalizeLocale(locale string) string {
|
||||
locale = strings.TrimSpace(locale)
|
||||
if locale == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.IndexByte(locale, '.'); idx >= 0 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
locale = strings.ReplaceAll(locale, "_", "-")
|
||||
|
||||
parts := strings.Split(locale, "-")
|
||||
normalized := make([]string, 0, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case i == 0:
|
||||
normalized = append(normalized, strings.ToLower(part))
|
||||
case len(part) == 2:
|
||||
normalized = append(normalized, strings.ToUpper(part))
|
||||
case len(part) == 4:
|
||||
normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:]))
|
||||
default:
|
||||
normalized = append(normalized, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(normalized, "-")
|
||||
}
|
||||
|
||||
// MatchLocale resolves requested to one of available using exact, safe alias,
|
||||
// then fallback matching.
|
||||
func MatchLocale(requested string, available []string, fallback string) string {
|
||||
return matchLocale(requested, available, fallback).Locale
|
||||
}
|
||||
|
||||
type localeMatch struct {
|
||||
Locale string
|
||||
Requested string
|
||||
Fallbacked bool
|
||||
Supported bool
|
||||
}
|
||||
|
||||
func matchLocale(requested string, available []string, fallback string) localeMatch {
|
||||
fallback = NormalizeLocale(fallback)
|
||||
if fallback == "" {
|
||||
fallback = defaultFallbackLocale
|
||||
}
|
||||
if len(available) == 0 {
|
||||
return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true}
|
||||
}
|
||||
|
||||
byLocale := make(map[string]string, len(available))
|
||||
for _, locale := range available {
|
||||
normalized := NormalizeLocale(locale)
|
||||
byLocale[normalized] = normalized
|
||||
}
|
||||
|
||||
candidate := NormalizeLocale(requested)
|
||||
if candidate != "" {
|
||||
if matched, ok := byLocale[candidate]; ok {
|
||||
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
|
||||
}
|
||||
if alias := localeAlias(candidate); alias != "" {
|
||||
if matched, ok := byLocale[alias]; ok {
|
||||
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched, ok := byLocale[fallback]; ok {
|
||||
return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false}
|
||||
}
|
||||
return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false}
|
||||
}
|
||||
|
||||
func primaryLanguage(locale string) string {
|
||||
if idx := strings.IndexByte(locale, '-'); idx >= 0 {
|
||||
return locale[:idx]
|
||||
}
|
||||
return locale
|
||||
}
|
||||
|
||||
func localeAlias(locale string) string {
|
||||
switch {
|
||||
case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN":
|
||||
return "zh-CN"
|
||||
case primaryLanguage(locale) == "en":
|
||||
return "en-US"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLocale(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"zh_CN": "zh-CN",
|
||||
"zh_CN.UTF-8": "zh-CN",
|
||||
"zh-Hans-CN": "zh-Hans-CN",
|
||||
"EN_us": "en-US",
|
||||
" en-US ": "en-US",
|
||||
"zh-hans-cn.utf": "zh-Hans-CN",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := NormalizeLocale(input); got != want {
|
||||
t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchLocale(t *testing.T) {
|
||||
available := []string{"en-US", "zh-CN"}
|
||||
cases := map[string]string{
|
||||
"zh_CN": "zh-CN",
|
||||
"zh-Hans-CN": "zh-CN",
|
||||
"zh": "zh-CN",
|
||||
"en": "en-US",
|
||||
"fr-FR": "en-US",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := MatchLocale(input, available, "en-US"); got != want {
|
||||
t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
{
|
||||
"cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.",
|
||||
"cmd.api.short": "Make raw API requests to GitLink",
|
||||
"cmd.auth.login.short": "Login to GitLink",
|
||||
"cmd.auth.logout.short": "Logout from GitLink",
|
||||
"cmd.auth.short": "Authentication commands",
|
||||
"cmd.auth.status.short": "Show authentication status",
|
||||
"cmd.branch.create.short": "Create a branch",
|
||||
"cmd.branch.delete.short": "Delete a branch",
|
||||
"cmd.branch.list.short": "List branches",
|
||||
"cmd.branch.protect.short": "Set branch protection",
|
||||
"cmd.branch.short": "Branch operations",
|
||||
"cmd.branch.unprotect.short": "Remove branch protection",
|
||||
"cmd.ci.builds.short": "List CI builds",
|
||||
"cmd.ci.logs.short": "View build logs",
|
||||
"cmd.ci.restart.short": "Restart a build",
|
||||
"cmd.ci.short": "CI/CD operations",
|
||||
"cmd.ci.stop.short": "Stop a build",
|
||||
"cmd.config.get.short": "Get a configuration value",
|
||||
"cmd.config.init.short": "Initialize configuration file",
|
||||
"cmd.config.list.short": "List all configuration values",
|
||||
"cmd.config.set.short": "Set a configuration value",
|
||||
"cmd.config.short": "Manage gitlink-cli configuration",
|
||||
"cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
|
||||
"cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
|
||||
"cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
|
||||
"cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
|
||||
"cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
|
||||
"cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data",
|
||||
"cmd.issue.close.short": "Close an issue",
|
||||
"cmd.issue.comment.short": "Add a comment to an issue",
|
||||
"cmd.issue.create.short": "Create a new issue",
|
||||
"cmd.issue.list.short": "List issues",
|
||||
"cmd.issue.short": "Issue operations",
|
||||
"cmd.issue.update.short": "Update an issue",
|
||||
"cmd.issue.view.short": "View issue details",
|
||||
"cmd.org.create.short": "Create an organization",
|
||||
"cmd.org.info.short": "Show organization details",
|
||||
"cmd.org.list.short": "List organizations",
|
||||
"cmd.org.members.short": "List organization members",
|
||||
"cmd.org.short": "Organization operations",
|
||||
"cmd.pr.close.short": "Close a pull request",
|
||||
"cmd.pr.comment.short": "Add a comment to a pull request",
|
||||
"cmd.pr.create.short": "Create a pull request",
|
||||
"cmd.pr.diff.short": "Show diff for a pull request",
|
||||
"cmd.pr.files.short": "List changed files in a pull request",
|
||||
"cmd.pr.list.short": "List pull requests",
|
||||
"cmd.pr.merge.short": "Merge a pull request",
|
||||
"cmd.pr.review.short": "Create a pull request review",
|
||||
"cmd.pr.reviews.short": "List pull request reviews",
|
||||
"cmd.pr.short": "Pull request operations",
|
||||
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
|
||||
"cmd.pr.versions.short": "List pull request patchset versions",
|
||||
"cmd.pr.view.short": "View pull request details",
|
||||
"cmd.release.create.short": "Create a release",
|
||||
"cmd.release.delete.short": "Delete a release",
|
||||
"cmd.release.list.short": "List releases",
|
||||
"cmd.release.short": "Release operations",
|
||||
"cmd.release.view.short": "View release details",
|
||||
"cmd.repo.create.short": "Create a new repository",
|
||||
"cmd.repo.delete.short": "Delete a repository",
|
||||
"cmd.repo.fork.short": "Fork a repository",
|
||||
"cmd.repo.info.short": "Show repository details",
|
||||
"cmd.repo.list.short": "List repositories for a user or organization",
|
||||
"cmd.repo.short": "Repository operations",
|
||||
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
|
||||
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
|
||||
"cmd.search.repos.short": "Search repositories",
|
||||
"cmd.search.short": "Search operations",
|
||||
"cmd.search.users.short": "Search users",
|
||||
"cmd.user.info.short": "Show user profile",
|
||||
"cmd.user.me.short": "Show current authenticated user",
|
||||
"cmd.user.short": "User operations",
|
||||
"cmd.version.short": "Print version information",
|
||||
"cmd.webhook.create.short": "Create a repository webhook",
|
||||
"cmd.webhook.delete.short": "Delete a repository webhook",
|
||||
"cmd.webhook.list.short": "List repository webhooks",
|
||||
"cmd.webhook.short": "Webhook operations",
|
||||
"cmd.webhook.tasks.short": "List webhook delivery tasks",
|
||||
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
|
||||
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
|
||||
"cmd.webhook.view.short": "View webhook details",
|
||||
"error.auth.delete_token_failed": "failed to delete token: {message}",
|
||||
"error.auth.login_failed": "login failed: {message}",
|
||||
"error.auth.store_token_failed": "failed to store token: {message}",
|
||||
"error.auth.token_empty": "token cannot be empty",
|
||||
"error.config.save_failed": "failed to save config: {message}",
|
||||
"error.missing_required_flag": "required flag --{name} is missing",
|
||||
"error.unsupported_language": "unsupported language: {lang}",
|
||||
"flag.api.body": "Request body (JSON string)",
|
||||
"flag.api.body_file": "Read request body JSON from a file",
|
||||
"flag.api.body_stdin": "Read request body JSON from stdin",
|
||||
"flag.api.header": "Additional headers (key:value)",
|
||||
"flag.api.query": "Query parameters (key=val&key2=val2)",
|
||||
"flag.auth.token": "Login by pasting an existing token",
|
||||
"flag.branch.from": "Source branch or commit",
|
||||
"flag.branch.name": "Branch name",
|
||||
"flag.ci.build": "Build number",
|
||||
"flag.ci.stage": "Stage number",
|
||||
"flag.ci.step": "Step number",
|
||||
"flag.comment.body": "Comment body",
|
||||
"flag.debug": "Enable debug output",
|
||||
"flag.description": "Description",
|
||||
"flag.dry_run": "Preview the request without creating it",
|
||||
"flag.format": "Output format: json, table, yaml (default: table)",
|
||||
"flag.issue.add_label": "Label to add to each matching issue",
|
||||
"flag.issue.assignee": "Assignee login",
|
||||
"flag.issue.batch.reason": "Optional reason shown in the batch result",
|
||||
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
|
||||
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
|
||||
"flag.issue.batch_close.state": "Filter by issue state before closing",
|
||||
"flag.issue.batch_label.state": "Filter by issue state",
|
||||
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
|
||||
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
|
||||
"flag.issue.body": "Issue description",
|
||||
"flag.issue.label": "Label ID",
|
||||
"flag.issue.label_filter": "Filter by existing label",
|
||||
"flag.issue.milestone": "Milestone ID",
|
||||
"flag.issue.new_body": "New description",
|
||||
"flag.issue.new_state": "New state: open, closed, or numeric status_id",
|
||||
"flag.issue.new_title": "New title",
|
||||
"flag.issue.number": "Issue number (as shown in the web URL)",
|
||||
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
|
||||
"flag.issue.state": "Filter by state: open, closed, all",
|
||||
"flag.issue.title": "Issue title",
|
||||
"flag.lang": "Display language",
|
||||
"flag.limit": "Items per page",
|
||||
"flag.org.id": "Organization ID",
|
||||
"flag.org.id_or_login": "Organization ID or login",
|
||||
"flag.org.name": "Organization name",
|
||||
"flag.owner": "Repository owner (auto-detected from git remote)",
|
||||
"flag.page": "Page number",
|
||||
"flag.pr.base": "Target branch",
|
||||
"flag.pr.body": "PR description",
|
||||
"flag.pr.file": "Filter diff by file path",
|
||||
"flag.pr.head": "Source branch",
|
||||
"flag.pr.id": "PR number",
|
||||
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
|
||||
"flag.pr.review_commit": "Commit SHA to attach the review to",
|
||||
"flag.pr.review_content": "Review content",
|
||||
"flag.pr.review_status": "Review status: common, approved, rejected",
|
||||
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
|
||||
"flag.pr.state": "Filter: open, merged, closed",
|
||||
"flag.pr.title": "PR title",
|
||||
"flag.pr.version_id": "Patchset version ID",
|
||||
"flag.release.body": "Release notes",
|
||||
"flag.release.id": "Release ID",
|
||||
"flag.release.id_or_tag": "Release ID or tag",
|
||||
"flag.release.name": "Release name",
|
||||
"flag.release.prerelease": "Mark as prerelease (true/false)",
|
||||
"flag.release.tag": "Tag name",
|
||||
"flag.release.target": "Target branch",
|
||||
"flag.repo": "Repository name (auto-detected from git remote)",
|
||||
"flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)",
|
||||
"flag.repo.description": "Repository description",
|
||||
"flag.repo.name": "Repository name",
|
||||
"flag.repo.private": "Make repository private (true/false)",
|
||||
"flag.search.keyword": "Search keyword",
|
||||
"flag.user": "User login (default: current user)",
|
||||
"flag.user.login": "User login name",
|
||||
"flag.webhook.active": "Whether the webhook is active: true or false",
|
||||
"flag.webhook.branch_filter": "Branch glob filter for push/create/delete events",
|
||||
"flag.webhook.content_type": "Payload content type: json or form",
|
||||
"flag.webhook.events": "Comma-separated events, for example: push,issues_only",
|
||||
"flag.webhook.http_method": "HTTP method: POST or GET",
|
||||
"flag.webhook.id": "Webhook ID",
|
||||
"flag.webhook.secret": "Webhook secret",
|
||||
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
|
||||
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||
"flag.webhook.url": "Webhook target URL",
|
||||
"output.auth.env_hint": " Or set {env} environment variable",
|
||||
"output.auth.login_hint": " Run: gitlink-cli auth login",
|
||||
"output.config.file": "Config file: {path}",
|
||||
"output.config.not_set": "(not set)",
|
||||
"output.version": "gitlink-cli {version}",
|
||||
"prompt.auth.password": "Password: ",
|
||||
"prompt.auth.token": "Paste your access token: ",
|
||||
"prompt.auth.username": "Username/Email/Phone: ",
|
||||
"success.auth.logged_in_as": "✓ Logged in as {login}",
|
||||
"success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable",
|
||||
"success.auth.logged_out": "✓ Logged out",
|
||||
"success.auth.token_saved": "✓ Token saved",
|
||||
"success.config.initialized": "✓ Config initialized at {path}",
|
||||
"success.config.set": "✓ {key} = {value}",
|
||||
"warning.auth.not_logged_in": "✗ Not logged in",
|
||||
"warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})",
|
||||
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)"
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
{
|
||||
"cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。",
|
||||
"cmd.api.short": "向 GitLink 发起原始 API 请求",
|
||||
"cmd.auth.login.short": "登录 GitLink",
|
||||
"cmd.auth.logout.short": "退出 GitLink 登录",
|
||||
"cmd.auth.short": "认证命令",
|
||||
"cmd.auth.status.short": "显示认证状态",
|
||||
"cmd.branch.create.short": "创建分支",
|
||||
"cmd.branch.delete.short": "删除分支",
|
||||
"cmd.branch.list.short": "列出分支",
|
||||
"cmd.branch.protect.short": "设置分支保护",
|
||||
"cmd.branch.short": "分支操作",
|
||||
"cmd.branch.unprotect.short": "移除分支保护",
|
||||
"cmd.ci.builds.short": "列出 CI 构建",
|
||||
"cmd.ci.logs.short": "查看构建日志",
|
||||
"cmd.ci.restart.short": "重启构建",
|
||||
"cmd.ci.short": "CI/CD 操作",
|
||||
"cmd.ci.stop.short": "停止构建",
|
||||
"cmd.config.get.short": "获取配置项",
|
||||
"cmd.config.init.short": "初始化配置文件",
|
||||
"cmd.config.list.short": "列出所有配置项",
|
||||
"cmd.config.set.short": "设置配置项",
|
||||
"cmd.config.short": "管理 gitlink-cli 配置",
|
||||
"cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes",
|
||||
"cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。",
|
||||
"cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes",
|
||||
"cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。",
|
||||
"cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json",
|
||||
"cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据",
|
||||
"cmd.issue.close.short": "关闭议题",
|
||||
"cmd.issue.comment.short": "给议题添加评论",
|
||||
"cmd.issue.create.short": "创建新议题",
|
||||
"cmd.issue.list.short": "列出议题",
|
||||
"cmd.issue.short": "议题操作",
|
||||
"cmd.issue.update.short": "更新议题",
|
||||
"cmd.issue.view.short": "查看议题详情",
|
||||
"cmd.org.create.short": "创建组织",
|
||||
"cmd.org.info.short": "显示组织详情",
|
||||
"cmd.org.list.short": "列出组织",
|
||||
"cmd.org.members.short": "列出组织成员",
|
||||
"cmd.org.short": "组织操作",
|
||||
"cmd.pr.close.short": "关闭拉取请求",
|
||||
"cmd.pr.comment.short": "给拉取请求添加评论",
|
||||
"cmd.pr.create.short": "创建拉取请求",
|
||||
"cmd.pr.diff.short": "显示拉取请求 diff",
|
||||
"cmd.pr.files.short": "列出拉取请求中的变更文件",
|
||||
"cmd.pr.list.short": "列出拉取请求",
|
||||
"cmd.pr.merge.short": "合并拉取请求",
|
||||
"cmd.pr.review.short": "创建拉取请求评审",
|
||||
"cmd.pr.reviews.short": "列出拉取请求评审",
|
||||
"cmd.pr.short": "拉取请求操作",
|
||||
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
|
||||
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
|
||||
"cmd.pr.view.short": "查看拉取请求详情",
|
||||
"cmd.release.create.short": "创建发布",
|
||||
"cmd.release.delete.short": "删除发布",
|
||||
"cmd.release.list.short": "列出发布",
|
||||
"cmd.release.short": "发布操作",
|
||||
"cmd.release.view.short": "查看发布详情",
|
||||
"cmd.repo.create.short": "创建新仓库",
|
||||
"cmd.repo.delete.short": "删除仓库",
|
||||
"cmd.repo.fork.short": "Fork 仓库",
|
||||
"cmd.repo.info.short": "显示仓库详情",
|
||||
"cmd.repo.list.short": "列出用户或组织的仓库",
|
||||
"cmd.repo.short": "仓库操作",
|
||||
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
|
||||
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
|
||||
"cmd.search.repos.short": "搜索仓库",
|
||||
"cmd.search.short": "搜索操作",
|
||||
"cmd.search.users.short": "搜索用户",
|
||||
"cmd.user.info.short": "显示用户资料",
|
||||
"cmd.user.me.short": "显示当前认证用户",
|
||||
"cmd.user.short": "用户操作",
|
||||
"cmd.version.short": "打印版本信息",
|
||||
"cmd.webhook.create.short": "创建仓库 Webhook",
|
||||
"cmd.webhook.delete.short": "删除仓库 Webhook",
|
||||
"cmd.webhook.list.short": "列出仓库 Webhook",
|
||||
"cmd.webhook.short": "Webhook 操作",
|
||||
"cmd.webhook.tasks.short": "列出 Webhook 投递任务",
|
||||
"cmd.webhook.test.short": "触发 Webhook 测试投递",
|
||||
"cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段",
|
||||
"cmd.webhook.view.short": "查看 Webhook 详情",
|
||||
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
|
||||
"error.auth.login_failed": "登录失败:{message}",
|
||||
"error.auth.store_token_failed": "保存 Token 失败:{message}",
|
||||
"error.auth.token_empty": "Token 不能为空",
|
||||
"error.config.save_failed": "保存配置失败:{message}",
|
||||
"error.missing_required_flag": "缺少必需参数 --{name}",
|
||||
"error.unsupported_language": "不支持的语言:{lang}",
|
||||
"flag.api.body": "请求体(JSON 字符串)",
|
||||
"flag.api.body_file": "从文件读取 JSON 请求体",
|
||||
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
|
||||
"flag.api.header": "附加请求头(key:value)",
|
||||
"flag.api.query": "查询参数(key=val&key2=val2)",
|
||||
"flag.auth.token": "通过粘贴已有 Token 登录",
|
||||
"flag.branch.from": "源分支或 Commit",
|
||||
"flag.branch.name": "分支名称",
|
||||
"flag.ci.build": "构建编号",
|
||||
"flag.ci.stage": "阶段编号",
|
||||
"flag.ci.step": "步骤编号",
|
||||
"flag.comment.body": "评论内容",
|
||||
"flag.debug": "启用调试输出",
|
||||
"flag.description": "描述",
|
||||
"flag.dry_run": "预览请求,不实际创建",
|
||||
"flag.format": "输出格式:json、table、yaml(默认:table)",
|
||||
"flag.issue.add_label": "要添加到每个匹配议题的标签",
|
||||
"flag.issue.assignee": "负责人登录名",
|
||||
"flag.issue.batch.reason": "批量结果中显示的可选原因",
|
||||
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
|
||||
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
|
||||
"flag.issue.batch_close.state": "关闭前按议题状态筛选",
|
||||
"flag.issue.batch_label.state": "按议题状态筛选",
|
||||
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
|
||||
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
|
||||
"flag.issue.body": "议题描述",
|
||||
"flag.issue.label": "标签 ID",
|
||||
"flag.issue.label_filter": "按已有标签筛选",
|
||||
"flag.issue.milestone": "里程碑 ID",
|
||||
"flag.issue.new_body": "新描述",
|
||||
"flag.issue.new_state": "新状态:open、closed 或数字 status_id",
|
||||
"flag.issue.new_title": "新标题",
|
||||
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
|
||||
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
|
||||
"flag.issue.state": "按状态筛选:open、closed、all",
|
||||
"flag.issue.title": "议题标题",
|
||||
"flag.lang": "显示语言",
|
||||
"flag.limit": "每页条目数",
|
||||
"flag.org.id": "组织 ID",
|
||||
"flag.org.id_or_login": "组织 ID 或登录名",
|
||||
"flag.org.name": "组织名称",
|
||||
"flag.owner": "仓库所有者(自动从 git remote 检测)",
|
||||
"flag.page": "页码",
|
||||
"flag.pr.base": "目标分支",
|
||||
"flag.pr.body": "PR 描述",
|
||||
"flag.pr.file": "按文件路径筛选 diff",
|
||||
"flag.pr.head": "源分支",
|
||||
"flag.pr.id": "PR 编号",
|
||||
"flag.pr.merge_method": "合并方式:merge、rebase、squash",
|
||||
"flag.pr.review_commit": "关联评审的 Commit SHA",
|
||||
"flag.pr.review_content": "评审内容",
|
||||
"flag.pr.review_status": "评审状态:common、approved、rejected",
|
||||
"flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected",
|
||||
"flag.pr.state": "筛选:open、merged、closed",
|
||||
"flag.pr.title": "PR 标题",
|
||||
"flag.pr.version_id": "补丁集版本 ID",
|
||||
"flag.release.body": "发布说明",
|
||||
"flag.release.id": "发布 ID",
|
||||
"flag.release.id_or_tag": "发布 ID 或标签",
|
||||
"flag.release.name": "发布名称",
|
||||
"flag.release.prerelease": "标记为预发布(true/false)",
|
||||
"flag.release.tag": "标签名称",
|
||||
"flag.release.target": "目标分支",
|
||||
"flag.repo": "仓库名称(自动从 git remote 检测)",
|
||||
"flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)",
|
||||
"flag.repo.description": "仓库描述",
|
||||
"flag.repo.name": "仓库名称",
|
||||
"flag.repo.private": "设为私有仓库(true/false)",
|
||||
"flag.search.keyword": "搜索关键词",
|
||||
"flag.user": "用户登录名(默认:当前用户)",
|
||||
"flag.user.login": "用户登录名",
|
||||
"flag.webhook.active": "Webhook 是否启用:true 或 false",
|
||||
"flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选",
|
||||
"flag.webhook.content_type": "Payload 内容类型:json 或 form",
|
||||
"flag.webhook.events": "逗号分隔的事件,例如:push,issues_only",
|
||||
"flag.webhook.http_method": "HTTP 方法:POST 或 GET",
|
||||
"flag.webhook.id": "Webhook ID",
|
||||
"flag.webhook.secret": "Webhook 密钥",
|
||||
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
|
||||
"flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||
"flag.webhook.url": "Webhook 目标 URL",
|
||||
"output.auth.env_hint": " 或设置 {env} 环境变量",
|
||||
"output.auth.login_hint": " 运行:gitlink-cli auth login",
|
||||
"output.config.file": "配置文件:{path}",
|
||||
"output.config.not_set": "(未设置)",
|
||||
"output.version": "gitlink-cli {version}",
|
||||
"prompt.auth.password": "密码:",
|
||||
"prompt.auth.token": "粘贴你的访问 Token:",
|
||||
"prompt.auth.username": "用户名/邮箱/手机号:",
|
||||
"success.auth.logged_in_as": "✓ 已登录为 {login}",
|
||||
"success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录",
|
||||
"success.auth.logged_out": "✓ 已退出登录",
|
||||
"success.auth.token_saved": "✓ Token 已保存",
|
||||
"success.config.initialized": "✓ 配置已初始化:{path}",
|
||||
"success.config.set": "✓ 已设置 {key} = {value}",
|
||||
"warning.auth.not_logged_in": "✗ 未登录",
|
||||
"warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})",
|
||||
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package i18n
|
||||
|
||||
const defaultFallbackLocale = "en-US"
|
||||
|
||||
// Options controls Translator construction.
|
||||
type Options struct {
|
||||
Locale string
|
||||
FallbackLocale string
|
||||
Loader Loader
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveOptions contains locale inputs ordered by caller intent.
|
||||
type ResolveOptions struct {
|
||||
ExplicitLang string
|
||||
Env map[string]string
|
||||
ConfigLang string
|
||||
}
|
||||
|
||||
type ResolvedLocale struct {
|
||||
Locale string
|
||||
Source string
|
||||
Requested string
|
||||
Fallbacked bool
|
||||
Supported bool
|
||||
}
|
||||
|
||||
// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback.
|
||||
func ResolveLocale(opts ResolveOptions, available []string) string {
|
||||
return ResolveLocaleDetailed(opts, available).Locale
|
||||
}
|
||||
|
||||
func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale {
|
||||
candidates := []struct {
|
||||
source string
|
||||
value string
|
||||
}{
|
||||
{source: "flag", value: opts.ExplicitLang},
|
||||
{source: "env", value: envValue(opts.Env, "GITLINK_LANG")},
|
||||
{source: "config", value: opts.ConfigLang},
|
||||
{source: "lc_all", value: envValue(opts.Env, "LC_ALL")},
|
||||
{source: "lang", value: envValue(opts.Env, "LANG")},
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if strings.TrimSpace(candidate.value) == "" {
|
||||
continue
|
||||
}
|
||||
match := matchLocale(candidate.value, available, defaultFallbackLocale)
|
||||
return ResolvedLocale{
|
||||
Locale: match.Locale,
|
||||
Source: candidate.source,
|
||||
Requested: match.Requested,
|
||||
Fallbacked: match.Fallbacked,
|
||||
Supported: match.Supported,
|
||||
}
|
||||
}
|
||||
match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale)
|
||||
return ResolvedLocale{
|
||||
Locale: match.Locale,
|
||||
Source: "default",
|
||||
Requested: match.Requested,
|
||||
Fallbacked: match.Fallbacked,
|
||||
Supported: match.Supported,
|
||||
}
|
||||
}
|
||||
|
||||
// PreScanLang reads --lang before Cobra constructs localized help text.
|
||||
func PreScanLang(args []string) string {
|
||||
for i, arg := range args {
|
||||
if arg == "--lang" {
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(arg, "--lang=") {
|
||||
return strings.TrimPrefix(arg, "--lang=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EnvMap returns process environment as a string map.
|
||||
func EnvMap() map[string]string {
|
||||
env := make(map[string]string)
|
||||
for _, item := range os.Environ() {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if ok {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env == nil {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
return env[key]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPreScanLang(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"--lang", "zh-CN", "repo"}, "zh-CN"},
|
||||
{[]string{"repo", "--lang=zh-CN"}, "zh-CN"},
|
||||
{[]string{"repo"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := PreScanLang(tc.args); got != tc.want {
|
||||
t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalePriority(t *testing.T) {
|
||||
available := []string{"en-US", "zh-CN"}
|
||||
got := ResolveLocale(ResolveOptions{
|
||||
ExplicitLang: "en-US",
|
||||
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
|
||||
ConfigLang: "zh-CN",
|
||||
}, available)
|
||||
if got != "en-US" {
|
||||
t.Fatalf("ResolveLocale() = %q, want en-US", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "GitLink CLI locale messages",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"propertyNames": {
|
||||
"pattern": "^(cmd|flag|error|output|prompt|success|warning|table)\\.[a-z0-9_.-]+$"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
|
||||
|
||||
func renderTemplate(message string, args Args) string {
|
||||
if len(args) == 0 {
|
||||
return message
|
||||
}
|
||||
return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string {
|
||||
name := match[1 : len(match)-1]
|
||||
value, ok := args[name]
|
||||
if !ok || value == nil {
|
||||
return match
|
||||
}
|
||||
if stringer, ok := value.(fmt.Stringer); ok {
|
||||
return stringer.String()
|
||||
}
|
||||
return fmt.Sprint(value)
|
||||
})
|
||||
}
|
||||
|
||||
func extractTemplateArgs(message string) []string {
|
||||
matches := placeholderPattern.FindAllStringSubmatch(message, -1)
|
||||
seen := make(map[string]struct{}, len(matches))
|
||||
for _, match := range matches {
|
||||
seen[match[1]] = struct{}{}
|
||||
}
|
||||
|
||||
args := make([]string, 0, len(seen))
|
||||
for arg := range seen {
|
||||
args = append(args, arg)
|
||||
}
|
||||
sort.Strings(args)
|
||||
return args
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenderTemplateKeepsMissingArgs(t *testing.T) {
|
||||
got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"})
|
||||
want := "Delete alice/{repo}"
|
||||
if got != want {
|
||||
t.Fatalf("renderTemplate() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTemplateArgs(t *testing.T) {
|
||||
got := extractTemplateArgs("Delete {owner}/{repo}/{owner}")
|
||||
want := []string{"owner", "repo"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args length = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package i18n
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Translator resolves localized messages with fallback behavior suitable for CLI use.
|
||||
type Translator struct {
|
||||
locale string
|
||||
fallbackLocale string
|
||||
messages map[string]string
|
||||
fallback map[string]string
|
||||
}
|
||||
|
||||
// Default returns an English translator for legacy migration only.
|
||||
// New command code should receive *Translator explicitly.
|
||||
func Default() *Translator {
|
||||
tr, err := New(Options{Locale: defaultFallbackLocale})
|
||||
if err != nil {
|
||||
return &Translator{
|
||||
locale: defaultFallbackLocale,
|
||||
fallbackLocale: defaultFallbackLocale,
|
||||
messages: map[string]string{},
|
||||
fallback: map[string]string{},
|
||||
}
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// New constructs a Translator. Missing messages fall back to FallbackLocale.
|
||||
func New(opts Options) (*Translator, error) {
|
||||
loader := opts.Loader
|
||||
if loader == nil {
|
||||
loader = NewEmbedLoader()
|
||||
}
|
||||
|
||||
available, err := loader.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fallbackLocale := opts.FallbackLocale
|
||||
if fallbackLocale == "" {
|
||||
fallbackLocale = defaultFallbackLocale
|
||||
}
|
||||
fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale)
|
||||
locale := MatchLocale(opts.Locale, available, fallbackLocale)
|
||||
|
||||
fallback, err := loader.Load(fallbackLocale)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load fallback locale: %w", err)
|
||||
}
|
||||
|
||||
messages := fallback
|
||||
if locale != fallbackLocale {
|
||||
messages, err = loader.Load(locale)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load locale: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Translator{
|
||||
locale: locale,
|
||||
fallbackLocale: fallbackLocale,
|
||||
messages: messages,
|
||||
fallback: fallback,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Translator) Locale() string {
|
||||
return t.locale
|
||||
}
|
||||
|
||||
// T returns a localized message, falling back to en-US and then the key itself.
|
||||
func (t *Translator) T(key string) string {
|
||||
if t == nil {
|
||||
return key
|
||||
}
|
||||
if value, ok := t.messages[key]; ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := t.fallback[key]; ok {
|
||||
return value
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Tf returns a localized message with {name} placeholders rendered from args.
|
||||
func (t *Translator) Tf(key string, args Args) string {
|
||||
return renderTemplate(t.T(key), args)
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mapLoader struct {
|
||||
messages map[string]map[string]string
|
||||
}
|
||||
|
||||
func (l mapLoader) Load(locale string) (map[string]string, error) {
|
||||
messages, ok := l.messages[locale]
|
||||
if !ok {
|
||||
return nil, errors.New("missing locale")
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (l mapLoader) AvailableLocales() ([]string, error) {
|
||||
locales := make([]string, 0, len(l.messages))
|
||||
for locale := range l.messages {
|
||||
locales = append(locales, locale)
|
||||
}
|
||||
return locales, nil
|
||||
}
|
||||
|
||||
func TestTranslatorReturnsLocalizedMessage(t *testing.T) {
|
||||
tr, err := New(Options{
|
||||
Locale: "zh-CN",
|
||||
Loader: mapLoader{messages: map[string]map[string]string{
|
||||
"en-US": {"cmd.root.short": "GitLink CLI"},
|
||||
"zh-CN": {"cmd.root.short": "GitLink 命令行"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := tr.T("cmd.root.short"); got != "GitLink 命令行" {
|
||||
t.Fatalf("expected localized message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatorFallsBackToBaseThenKey(t *testing.T) {
|
||||
tr, err := New(Options{
|
||||
Locale: "zh-CN",
|
||||
Loader: mapLoader{messages: map[string]map[string]string{
|
||||
"en-US": {"cmd.root.short": "GitLink CLI"},
|
||||
"zh-CN": {},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := tr.T("cmd.root.short"); got != "GitLink CLI" {
|
||||
t.Fatalf("expected fallback message, got %q", got)
|
||||
}
|
||||
if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" {
|
||||
t.Fatalf("expected key fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslatorRendersArgs(t *testing.T) {
|
||||
tr, err := New(Options{
|
||||
Locale: "en-US",
|
||||
Loader: mapLoader{messages: map[string]map[string]string{
|
||||
"en-US": {"output.version": "gitlink-cli {version}"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" {
|
||||
t.Fatalf("expected rendered message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`)
|
||||
|
||||
// Problem describes a locale validation issue.
|
||||
type Problem struct {
|
||||
Locale string
|
||||
Key string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (p Problem) String() string {
|
||||
if p.Key == "" {
|
||||
return fmt.Sprintf("%s: %s", p.Locale, p.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message)
|
||||
}
|
||||
|
||||
// Validate checks all locales against baseLocale.
|
||||
func Validate(loader Loader, baseLocale string) ([]Problem, error) {
|
||||
if loader == nil {
|
||||
loader = NewEmbedLoader()
|
||||
}
|
||||
baseLocale = NormalizeLocale(baseLocale)
|
||||
if baseLocale == "" {
|
||||
baseLocale = defaultFallbackLocale
|
||||
}
|
||||
|
||||
locales, err := loader.AvailableLocales()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(locales)
|
||||
|
||||
allMessages := make(map[string]map[string]string, len(locales))
|
||||
for _, locale := range locales {
|
||||
normalized := NormalizeLocale(locale)
|
||||
if normalized != locale {
|
||||
return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil
|
||||
}
|
||||
messages, err := loader.Load(locale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allMessages[locale] = messages
|
||||
}
|
||||
|
||||
base, ok := allMessages[baseLocale]
|
||||
if !ok {
|
||||
return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil
|
||||
}
|
||||
|
||||
var problems []Problem
|
||||
for key, value := range base {
|
||||
problems = append(problems, validateMessage(baseLocale, key, value)...)
|
||||
}
|
||||
|
||||
for _, locale := range locales {
|
||||
messages := allMessages[locale]
|
||||
for key, value := range messages {
|
||||
problems = append(problems, validateMessage(locale, key, value)...)
|
||||
if _, ok := base[key]; !ok {
|
||||
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"})
|
||||
}
|
||||
}
|
||||
for key, baseValue := range base {
|
||||
value, ok := messages[key]
|
||||
if !ok {
|
||||
problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"})
|
||||
continue
|
||||
}
|
||||
baseArgs := extractTemplateArgs(baseValue)
|
||||
args := extractTemplateArgs(value)
|
||||
if !reflect.DeepEqual(baseArgs, args) {
|
||||
problems = append(problems, Problem{
|
||||
Locale: locale,
|
||||
Key: key,
|
||||
Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
func validateMessage(locale, key, value string) []Problem {
|
||||
var problems []Problem
|
||||
if !keyPattern.MatchString(key) {
|
||||
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"})
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"})
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEmbeddedLocales(t *testing.T) {
|
||||
problems, err := Validate(NewEmbedLoader(), "en-US")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("expected no problems, got %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) {
|
||||
problems, err := Validate(mapLoader{messages: map[string]map[string]string{
|
||||
"en-US": {
|
||||
"cmd.root.short": "Hello {name}",
|
||||
"flag.owner": "Owner",
|
||||
},
|
||||
"zh-CN": {
|
||||
"cmd.root.short": "你好",
|
||||
},
|
||||
}}, "en-US")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(problems) != 2 {
|
||||
t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuccessEnvelope(t *testing.T) {
|
||||
env := SuccessEnvelope(map[string]interface{}{"key": "value"}, nil)
|
||||
if !env.OK {
|
||||
t.Fatal("expected OK=true")
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatal("expected non-nil Data")
|
||||
}
|
||||
if env.Error != nil {
|
||||
t.Fatal("expected nil Error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessEnvelopeWithMeta(t *testing.T) {
|
||||
meta := &Meta{Page: 1, Limit: 20, TotalCount: 100}
|
||||
env := SuccessEnvelope("data", meta)
|
||||
if env.Meta != meta {
|
||||
t.Fatal("expected Meta to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEnvelope(t *testing.T) {
|
||||
env := ErrorEnvelope(404, "Not Found", "Check the URL")
|
||||
if env.OK {
|
||||
t.Fatal("expected OK=false")
|
||||
}
|
||||
if env.Data != nil {
|
||||
t.Fatal("expected nil Data")
|
||||
}
|
||||
if env.Error == nil {
|
||||
t.Fatal("expected non-nil Error")
|
||||
}
|
||||
if env.Error.Code != 404 {
|
||||
t.Fatalf("Code = %v, want 404", env.Error.Code)
|
||||
}
|
||||
if env.Error.Message != "Not Found" {
|
||||
t.Fatalf("Message = %q, want 'Not Found'", env.Error.Message)
|
||||
}
|
||||
if env.Error.Suggestion != "Check the URL" {
|
||||
t.Fatalf("Suggestion = %q, want 'Check the URL'", env.Error.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeJSON(t *testing.T) {
|
||||
env := SuccessEnvelope("hello", nil)
|
||||
data, err := env.JSON()
|
||||
if err != nil {
|
||||
t.Fatalf("JSON() error: %v", err)
|
||||
}
|
||||
var decoded Envelope
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("failed to unmarshal JSON output: %v", err)
|
||||
}
|
||||
if !decoded.OK {
|
||||
t.Fatal("expected OK=true in JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorInfoFields(t *testing.T) {
|
||||
info := ErrorInfo{Code: 500, Message: "Internal Error", Suggestion: "Retry later"}
|
||||
if info.Message != "Internal Error" {
|
||||
t.Fatalf("Message = %q", info.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaFields(t *testing.T) {
|
||||
meta := Meta{Page: 2, Limit: 50, TotalCount: 200, Identity: "user1"}
|
||||
if meta.Page != 2 || meta.Limit != 50 || meta.TotalCount != 200 || meta.Identity != "user1" {
|
||||
t.Fatal("Meta fields don't match")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrint(t *testing.T) {
|
||||
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||
if err := Print(env, "json"); err != nil {
|
||||
t.Fatalf("Print error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDefaultFormat(t *testing.T) {
|
||||
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||
if err := Print(env, ""); err != nil {
|
||||
t.Fatalf("Print default format error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||
if err := PrintTo(&buf, env, "json"); err != nil {
|
||||
t.Fatalf("PrintTo json: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Fatalf("expected ok in JSON output, got: %s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"status"`) {
|
||||
t.Fatalf("expected status in JSON output, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToYAML(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope(map[string]interface{}{"status": "ok"}, nil)
|
||||
if err := PrintTo(&buf, env, "yaml"); err != nil {
|
||||
t.Fatalf("PrintTo yaml: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "ok") {
|
||||
t.Fatalf("expected ok in YAML output, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableSlice(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope([]interface{}{
|
||||
map[string]interface{}{"id": float64(1), "name": "test"},
|
||||
map[string]interface{}{"id": float64(2), "name": "test2"},
|
||||
}, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table slice: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
|
||||
t.Fatalf("expected table headers, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1") || !strings.Contains(out, "test") {
|
||||
t.Fatalf("expected table data, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableEmptySlice(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope([]interface{}{}, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table empty: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "No results") {
|
||||
t.Fatalf("expected 'No results', got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableSliceNonMap(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope([]interface{}{"string1", "string2"}, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table non-map slice: %v", err)
|
||||
}
|
||||
// Should fallback to JSON
|
||||
if !strings.Contains(buf.String(), "[") {
|
||||
t.Fatalf("expected JSON array fallback, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableMap(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope(map[string]interface{}{
|
||||
"key1": "val1",
|
||||
"key2": "val2",
|
||||
}, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table map: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
|
||||
t.Fatalf("expected KEY/VALUE headers, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableMapComplex(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope(map[string]interface{}{
|
||||
"nested": map[string]interface{}{"a": "b"},
|
||||
}, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table complex map: %v", err)
|
||||
}
|
||||
// Should fallback to JSON because of nested map
|
||||
if !strings.Contains(buf.String(), "{") {
|
||||
t.Fatalf("expected JSON fallback for complex map, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := ErrorEnvelope(500, "server error", "try again")
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table error: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Error: server error") {
|
||||
t.Fatalf("expected error message, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "try again") {
|
||||
t.Fatalf("expected suggestion, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableErrorNoSuggestion(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := ErrorEnvelope(500, "server error", "")
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table error no suggestion: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "Suggestion:") {
|
||||
t.Fatalf("should not have suggestion line, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTableNilData(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope(nil, nil)
|
||||
if err := PrintTo(&buf, env, "table"); err != nil {
|
||||
t.Fatalf("PrintTo table nil data: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "No data") {
|
||||
t.Fatalf("expected 'No data', got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToDefaultFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope("test", nil)
|
||||
if err := PrintTo(&buf, env, ""); err != nil {
|
||||
t.Fatalf("PrintTo default format: %v", err)
|
||||
}
|
||||
// Default should be JSON
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Fatalf("expected JSON output for default format, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToUnknownFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
env := SuccessEnvelope("test", nil)
|
||||
if err := PrintTo(&buf, env, "xml"); err != nil {
|
||||
t.Fatalf("PrintTo unknown format: %v", err)
|
||||
}
|
||||
// Unknown format should fallback to JSON
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Fatalf("expected JSON fallback for unknown format, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasComplexValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"flat", map[string]interface{}{"a": "1", "b": "2"}, false},
|
||||
{"nested map", map[string]interface{}{"a": map[string]interface{}{"x": "y"}}, true},
|
||||
{"nested slice", map[string]interface{}{"a": []interface{}{1, 2}}, true},
|
||||
{"empty", map[string]interface{}{}, false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasComplexValues(tt.m); got != tt.want {
|
||||
t.Fatalf("hasComplexValues = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectKeys(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"title": "test",
|
||||
"id": float64(1),
|
||||
"status": "open",
|
||||
"custom_key": "val",
|
||||
}
|
||||
keys := collectKeys(m)
|
||||
// Priority keys should come first
|
||||
if len(keys) != 4 {
|
||||
t.Fatalf("expected 4 keys, got %d", len(keys))
|
||||
}
|
||||
if keys[0] != "id" {
|
||||
t.Fatalf("first key should be 'id', got %q", keys[0])
|
||||
}
|
||||
if keys[1] != "title" {
|
||||
t.Fatalf("second key should be 'title', got %q", keys[1])
|
||||
}
|
||||
if keys[2] != "status" {
|
||||
t.Fatalf("third key should be 'status', got %q", keys[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v interface{}
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"string", "hello", "hello"},
|
||||
{"int", 42, "42"},
|
||||
{"float", 3.14, "3.14"},
|
||||
{"bool", true, "true"},
|
||||
{"slice", []interface{}{1, 2, 3}, "[1,2,3]"},
|
||||
{"map", map[string]interface{}{"a": "b"}, `{"a":"b"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatValue(tt.v)
|
||||
if got != tt.want {
|
||||
t.Fatalf("formatValue = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMainHelp(t *testing.T) {
|
||||
origArgs := os.Args
|
||||
os.Args = []string{"gitlink-cli", "--help"}
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
// Should not call os.Exit because --help returns nil
|
||||
main()
|
||||
}
|
||||
|
|
@ -2,21 +2,78 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
const binaryPath = path.join(__dirname, "gitlink-cli" + ext);
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
|
||||
try {
|
||||
execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (err.status !== undefined) {
|
||||
process.exit(err.status);
|
||||
}
|
||||
console.error(`Failed to run gitlink-cli: ${err.message}`);
|
||||
console.error(
|
||||
"Binary may not be installed. Try reinstalling: npm install -g @gitlink-ai/cli"
|
||||
);
|
||||
process.exit(1);
|
||||
function getBinaryName(platform = process.platform) {
|
||||
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
}
|
||||
|
||||
function getBinaryPath(platform = process.platform, baseDir = __dirname) {
|
||||
return path.join(baseDir, getBinaryName(platform));
|
||||
}
|
||||
|
||||
function formatMissingBinaryError(
|
||||
binaryPath,
|
||||
platform = process.platform,
|
||||
arch = process.arch
|
||||
) {
|
||||
return [
|
||||
`Error: ${BINARY_NAME} binary not found at ${binaryPath}`,
|
||||
`Platform: ${platform}/${arch}`,
|
||||
"",
|
||||
"The npm package was installed, but the native binary is missing.",
|
||||
"This usually means the release asset for your platform is unavailable or postinstall failed.",
|
||||
"",
|
||||
"Try reinstalling:",
|
||||
" npm install -g @gitlink-ai/cli",
|
||||
"",
|
||||
"If the problem persists, check the GitLink CLI release assets:",
|
||||
" https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function run(args = process.argv.slice(2), options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const arch = options.arch || process.arch;
|
||||
const binaryPath = options.binaryPath || getBinaryPath(platform);
|
||||
const execFile = options.execFileSync || execFileSync;
|
||||
const stderr = options.stderr || process.stderr;
|
||||
const exit = options.exit || process.exit;
|
||||
|
||||
function failMissingBinary() {
|
||||
stderr.write(`${formatMissingBinaryError(binaryPath, platform, arch)}\n`);
|
||||
return exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return failMissingBinary();
|
||||
}
|
||||
|
||||
try {
|
||||
execFile(binaryPath, args, { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return failMissingBinary();
|
||||
}
|
||||
if (err.status !== undefined) {
|
||||
return exit(err.status);
|
||||
}
|
||||
stderr.write(`Failed to run ${BINARY_NAME}: ${err.message}\n`);
|
||||
return exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBinaryName,
|
||||
getBinaryPath,
|
||||
formatMissingBinaryError,
|
||||
run,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "@gitlink-ai/cli",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.13",
|
||||
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
|
||||
"bin": {
|
||||
"gitlink-cli": "bin/cli.js",
|
||||
"gitlink-cli-install-skills": "bin/install-skills.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"test": "node test/install.test.js && node test/cli.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"gitlink",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,7 @@ const RELEASE_BASE = "https://www.gitlink.org.cn";
|
|||
const REPO_OWNER = "Gitlink";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
|
|
@ -123,6 +120,7 @@ function fetch(url, options = {}) {
|
|||
|
||||
async function findReleaseAsset(platform, arch) {
|
||||
const archiveName = getArchiveName(platform, arch);
|
||||
const tagName = `v${VERSION}`;
|
||||
|
||||
// Try fetching release info from GitLink API
|
||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
|
|
@ -133,7 +131,6 @@ async function findReleaseAsset(platform, arch) {
|
|||
|
||||
// Find the release matching our version
|
||||
let release = null;
|
||||
const tagName = `v${VERSION}`;
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
release = releases.find(
|
||||
|
|
@ -234,9 +231,15 @@ async function downloadAndExtract(url, destDir, platform) {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
let platformInfo = null;
|
||||
let archiveName = null;
|
||||
|
||||
try {
|
||||
const { platform, arch } = getPlatformInfo();
|
||||
platformInfo = getPlatformInfo();
|
||||
const { platform, arch } = platformInfo;
|
||||
archiveName = getArchiveName(platform, arch);
|
||||
console.log(`Platform: ${platform}-${arch}`);
|
||||
console.log(`Expected release asset: ${archiveName}`);
|
||||
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
if (!fs.existsSync(binDir)) {
|
||||
|
|
@ -264,6 +267,12 @@ async function main() {
|
|||
await downloadAndExtract(downloadUrl, binDir, platform);
|
||||
} catch (err) {
|
||||
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
|
||||
if (platformInfo) {
|
||||
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
|
||||
}
|
||||
if (archiveName) {
|
||||
console.error(`Expected release asset: ${archiveName}`);
|
||||
}
|
||||
console.error(
|
||||
`\nYou can install manually:\n` +
|
||||
` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
|
||||
|
|
@ -274,4 +283,13 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlatformInfo,
|
||||
getBinaryName,
|
||||
getArchiveName,
|
||||
findReleaseAsset,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const cli = require("../bin/cli.js");
|
||||
|
||||
assert.equal(cli.getBinaryName("win32"), "gitlink-cli.exe");
|
||||
assert.equal(cli.getBinaryName("linux"), "gitlink-cli");
|
||||
assert.equal(cli.getBinaryName("darwin"), "gitlink-cli");
|
||||
|
||||
assert.equal(
|
||||
cli.getBinaryPath("win32", "C:\\tmp\\gitlink"),
|
||||
path.join("C:\\tmp\\gitlink", "gitlink-cli.exe")
|
||||
);
|
||||
|
||||
const message = cli.formatMissingBinaryError(
|
||||
"C:\\tmp\\gitlink-cli.exe",
|
||||
"win32",
|
||||
"x64"
|
||||
);
|
||||
assert.match(message, /binary not found/);
|
||||
assert.match(message, /Platform: win32\/x64/);
|
||||
assert.match(message, /npm install -g @gitlink-ai\/cli/);
|
||||
|
||||
let exitCode = null;
|
||||
const stderr = {
|
||||
output: "",
|
||||
write(text) {
|
||||
this.output += text;
|
||||
},
|
||||
};
|
||||
|
||||
cli.run(["version"], {
|
||||
binaryPath: path.join(os.tmpdir(), "gitlink-cli-test-missing-binary"),
|
||||
platform: "win32",
|
||||
arch: "x64",
|
||||
stderr,
|
||||
exit(code) {
|
||||
exitCode = code;
|
||||
return code;
|
||||
},
|
||||
execFileSync() {
|
||||
throw new Error("execFileSync should not be called for a missing binary");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(exitCode, 1);
|
||||
assert.match(stderr.output, /gitlink-cli binary not found/);
|
||||
assert.match(stderr.output, /Platform: win32\/x64/);
|
||||
|
||||
console.log("cli wrapper tests passed");
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const install = require("../scripts/install.js");
|
||||
const pkg = require("../package.json");
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("win32", "x64"), {
|
||||
platform: "windows",
|
||||
arch: "amd64",
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("win32", "arm64"), {
|
||||
platform: "windows",
|
||||
arch: "arm64",
|
||||
isWindows: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("darwin", "arm64"), {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
isWindows: false,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(install.getPlatformInfo("linux", "x64"), {
|
||||
platform: "linux",
|
||||
arch: "amd64",
|
||||
isWindows: false,
|
||||
});
|
||||
|
||||
assert.equal(install.getBinaryName("windows"), "gitlink-cli.exe");
|
||||
assert.equal(install.getBinaryName("linux"), "gitlink-cli");
|
||||
assert.equal(install.getBinaryName("darwin"), "gitlink-cli");
|
||||
|
||||
assert.equal(
|
||||
install.getArchiveName("windows", "amd64"),
|
||||
`gitlink-cli_${pkg.version}_windows_amd64.zip`
|
||||
);
|
||||
assert.equal(
|
||||
install.getArchiveName("windows", "arm64"),
|
||||
`gitlink-cli_${pkg.version}_windows_arm64.zip`
|
||||
);
|
||||
assert.equal(
|
||||
install.getArchiveName("linux", "amd64"),
|
||||
`gitlink-cli_${pkg.version}_linux_amd64.tar.gz`
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => install.getPlatformInfo("freebsd", "x64"),
|
||||
/Unsupported platform/
|
||||
);
|
||||
|
||||
console.log("install helper tests passed");
|
||||
|
|
@ -1 +0,0 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -74,13 +74,14 @@ rm -rf "$NPM_DIR/skills"
|
|||
cp -r "$PROJECT_DIR/skills" "$NPM_DIR/skills"
|
||||
|
||||
# Ensure bin dir exists and wrapper is executable
|
||||
chmod +x "$NPM_DIR/bin/gitlink-cli"
|
||||
chmod +x "$NPM_DIR/bin/cli.js"
|
||||
chmod +x "$NPM_DIR/bin/install-skills.js"
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Upload dist/*.tar.gz to GitLink Release v${VERSION}"
|
||||
echo " 1. Upload dist/*.tar.gz and dist/*.zip to GitLink Release v${VERSION}"
|
||||
echo " URL: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases"
|
||||
echo ""
|
||||
echo " 2. Publish npm package:"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
const PACKAGE = require("../package.json");
|
||||
const VERSION = PACKAGE.version;
|
||||
const BINARY_NAME = "gitlink-cli";
|
||||
|
||||
const RELEASE_BASE = "https://www.gitlink.org.cn";
|
||||
const REPO_OWNER = "Gitlink";
|
||||
const REPO_NAME = "gitlink-cli";
|
||||
|
||||
function getPlatformInfo() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
|
||||
const archMap = { x64: "amd64", arm64: "arm64" };
|
||||
const goPlatform = platformMap[platform];
|
||||
const goArch = archMap[arch];
|
||||
if (!goPlatform || !goArch) {
|
||||
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
||||
}
|
||||
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
|
||||
}
|
||||
|
||||
function fetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const maxRedirects = options.maxRedirects || 5;
|
||||
let redirectCount = 0;
|
||||
function doRequest(currentUrl) {
|
||||
const mod = currentUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(currentUrl, (res) => {
|
||||
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
|
||||
redirectCount++;
|
||||
if (redirectCount > maxRedirects) { reject(new Error("Too many redirects")); return; }
|
||||
let redirectUrl = res.headers.location;
|
||||
if (redirectUrl.startsWith("/")) {
|
||||
const parsed = new URL(currentUrl);
|
||||
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
|
||||
}
|
||||
doRequest(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
if (options.json) {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); }
|
||||
});
|
||||
} else {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
|
||||
}
|
||||
doRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
async function findReleaseAsset(platform, arch) {
|
||||
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
|
||||
const tagName = `v${VERSION}`;
|
||||
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
|
||||
|
||||
try {
|
||||
const releases = await fetch(apiUrl, { json: true });
|
||||
const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []);
|
||||
let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION);
|
||||
if (!release && rlist.length > 0) release = rlist[0];
|
||||
|
||||
if (release && release.attachments) {
|
||||
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
|
||||
if (!asset) {
|
||||
const pattern = `_${platform}_${arch}.tar.gz`;
|
||||
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
|
||||
}
|
||||
if (asset) {
|
||||
let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
|
||||
if (url.startsWith("/")) url = RELEASE_BASE + url;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
|
||||
}
|
||||
|
||||
async function downloadAndExtract(url, destDir, platform) {
|
||||
const data = await fetch(url);
|
||||
const archivePath = path.join(destDir, "download.tar.gz");
|
||||
fs.writeFileSync(archivePath, data);
|
||||
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
|
||||
fs.unlinkSync(archivePath);
|
||||
|
||||
const binaryPath = path.join(destDir, BINARY_NAME);
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
const files = fs.readdirSync(destDir);
|
||||
for (const file of files) {
|
||||
const subPath = path.join(destDir, file, BINARY_NAME);
|
||||
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { platform, arch } = getPlatformInfo();
|
||||
const binDir = path.join(__dirname, "..", "bin");
|
||||
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
|
||||
|
||||
const binaryPath = path.join(binDir, BINARY_NAME);
|
||||
if (fs.existsSync(binaryPath)) {
|
||||
try {
|
||||
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
|
||||
if (output.includes(VERSION)) {
|
||||
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
fs.unlinkSync(binaryPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = await findReleaseAsset(platform, arch);
|
||||
await downloadAndExtract(downloadUrl, binDir, platform);
|
||||
console.log(`${BINARY_NAME} v${VERSION} installed.`);
|
||||
} catch (err) {
|
||||
// Don't fail npm install — binary can be installed later
|
||||
console.warn(`⚠ ${BINARY_NAME} binary download failed: ${err.message}`);
|
||||
console.warn(` Skills are installed. You can install the binary manually later:`);
|
||||
console.warn(` npm run postinstall`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
# Pre-commit hook: run fmt + vet + lint + test before every commit.
|
||||
# Install: make hooks
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== gofmt ==="
|
||||
unformatted=$(gofmt -s -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "ERROR: these files are not formatted:"
|
||||
echo "$unformatted"
|
||||
echo "Run: gofmt -s -w ."
|
||||
exit 1
|
||||
fi
|
||||
echo " OK"
|
||||
|
||||
echo "=== go vet ==="
|
||||
go vet ./...
|
||||
echo " OK"
|
||||
|
||||
echo "=== golangci-lint ==="
|
||||
golangci-lint run ./...
|
||||
echo " OK"
|
||||
|
||||
echo "=== go test ==="
|
||||
go test -race ./...
|
||||
echo " OK"
|
||||
|
||||
echo "=== pre-commit passed ==="
|
||||
|
|
@ -4,17 +4,19 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||
tr := shortcutTranslator(translators...)
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List branches",
|
||||
Description: tr.T("cmd.branch.list.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a branch",
|
||||
Description: tr.T("cmd.branch.create.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
||||
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||
{Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a branch",
|
||||
Description: tr.T("cmd.branch.delete.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "protect",
|
||||
Description: "Set branch protection",
|
||||
Description: tr.T("cmd.branch.protect.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
|
|
@ -101,16 +103,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "unprotect",
|
||||
Description: "Remove branch protection",
|
||||
Description: tr.T("cmd.branch.unprotect.short"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
||||
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), name), nil)
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
return translators[0]
|
||||
}
|
||||
return i18n.Default()
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue