feat: times, branch protection, release assets, milestone issues, notification states
Five parallel tea-parity additions (~1100 LOC):
- fgj time {list,add,delete,reset} (aliases: times, t)
Tracked time entries. 'list' with no arg uses ListMyTrackedTimes
across all your repos; with an issue number uses ListIssueTrackedTimes.
'add' accepts Go duration strings (30m, 1h30m). 'delete' removes a
single entry by id; 'reset' clears all times on an issue. Confirmation
on delete/reset unless --yes or no TTY.
- fgj branch {protect,unprotect}
Branch protection rules. 'protect' idempotently creates-or-edits via
Get/Create/EditBranchProtection with --require-approvals,
--require-signed-commits, --dismiss-stale-approvals,
--block-on-rejected-reviews, --block-on-outdated-branch,
--push-whitelist, --merge-whitelist, --require-status-checks.
Empty whitelist flags leave existing rule fields untouched.
'unprotect' deletes; 404 is a friendly no-op.
- fgj release asset {list,create,delete} (alias: assets)
Granular attachment management. Resolves the release by tag or
"latest" using the existing helpers in cmd/release.go. 'create'
validates all paths up front then uploads each. 'delete' accepts
numeric ids OR filenames (cross-references the attachment list).
Per-asset confirmation unless --yes.
- fgj milestone issues {add,remove} (alias: i)
Associate/disassociate issues with a milestone. Milestone accepted
as title or numeric id (reuses resolveMilestone from milestone.go).
'remove' passes EditIssueOption{Milestone: &zero} — the Gitea/Forgejo
convention for clearing the association. Continues on per-issue
failure and exits 1 if any failed.
- fgj notification {unread,pin,unpin}
Complement the existing list/read. Factory pattern over
ReadNotification(id, NotifyStatus) with three distinct constants.
All five files are self-contained: each has its own init() attaching
to the existing parent cobra.Command (branchCmd, milestoneCmd,
notificationCmd, releaseCmd) without modifying any other file. Built
by parallel sub-agents; all compile, vet, and test clean.
This commit is contained in:
parent
4eeef2ceca
commit
d15deaf064
5 changed files with 1124 additions and 0 deletions
310
cmd/release_assets.go
Normal file
310
cmd/release_assets.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var releaseAssetCmd = &cobra.Command{
|
||||
Use: "asset",
|
||||
Aliases: []string{"assets"},
|
||||
Short: "Manage release assets",
|
||||
Long: "List, upload, and delete individual attachments on a release.",
|
||||
}
|
||||
|
||||
var releaseAssetListCmd = &cobra.Command{
|
||||
Use: "list <tag|latest>",
|
||||
Short: "List release assets",
|
||||
Long: "List attachments on a release identified by tag name (or \"latest\").",
|
||||
Example: ` # List assets on a release
|
||||
fgj release asset list v1.0.0
|
||||
|
||||
# List assets on the latest release as JSON
|
||||
fgj release asset list latest --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runReleaseAssetList,
|
||||
}
|
||||
|
||||
var releaseAssetCreateCmd = &cobra.Command{
|
||||
Use: "create <tag|latest> <files...>",
|
||||
Short: "Upload one or more release assets",
|
||||
Long: "Upload one or more files as attachments to an existing release.",
|
||||
Example: ` # Upload a single asset
|
||||
fgj release asset create v1.0.0 dist/app-linux-amd64
|
||||
|
||||
# Upload multiple assets to the latest release
|
||||
fgj release asset create latest dist/*.tar.gz
|
||||
|
||||
# Upload a single file under a different name
|
||||
fgj release asset create v1.0.0 ./build/out --name app-v1.0.0`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: runReleaseAssetCreate,
|
||||
}
|
||||
|
||||
var releaseAssetDeleteCmd = &cobra.Command{
|
||||
Use: "delete <tag|latest> <asset-id-or-name>...",
|
||||
Short: "Delete one or more release assets",
|
||||
Long: "Delete attachments from a release. Each argument may be a numeric asset ID or an asset filename.",
|
||||
Example: ` # Delete by filename
|
||||
fgj release asset delete v1.0.0 app-linux-amd64
|
||||
|
||||
# Delete multiple by ID
|
||||
fgj release asset delete v1.0.0 42 43
|
||||
|
||||
# Skip confirmation
|
||||
fgj release asset delete latest output.zip --yes`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: runReleaseAssetDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
releaseCmd.AddCommand(releaseAssetCmd)
|
||||
releaseAssetCmd.AddCommand(releaseAssetListCmd)
|
||||
releaseAssetCmd.AddCommand(releaseAssetCreateCmd)
|
||||
releaseAssetCmd.AddCommand(releaseAssetDeleteCmd)
|
||||
|
||||
addRepoFlags(releaseAssetListCmd)
|
||||
addJSONFlags(releaseAssetListCmd, "Output assets as JSON")
|
||||
|
||||
addRepoFlags(releaseAssetCreateCmd)
|
||||
releaseAssetCreateCmd.Flags().String("name", "", "Override the uploaded filename (single file only)")
|
||||
|
||||
addRepoFlags(releaseAssetDeleteCmd)
|
||||
releaseAssetDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
||||
}
|
||||
|
||||
func runReleaseAssetList(cmd *cobra.Command, args []string) error {
|
||||
tag := args[0]
|
||||
|
||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ios.StartSpinner("Fetching release...")
|
||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||
if err != nil {
|
||||
ios.StopSpinner()
|
||||
return err
|
||||
}
|
||||
|
||||
var all []*gitea.Attachment
|
||||
for page := 1; ; page++ {
|
||||
attachments, _, err := client.ListReleaseAttachments(owner, name, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
ios.StopSpinner()
|
||||
return fmt.Errorf("failed to list release assets: %w", err)
|
||||
}
|
||||
if len(attachments) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, attachments...)
|
||||
}
|
||||
ios.StopSpinner()
|
||||
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, all)
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
fmt.Fprintf(ios.Out, "No assets on release %s\n", release.TagName)
|
||||
return nil
|
||||
}
|
||||
|
||||
isTTY := ios.IsStdoutTTY()
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("ID", "NAME", "SIZE", "DOWNLOADS", "CREATED")
|
||||
for _, a := range all {
|
||||
tp.AddRow(
|
||||
strconv.FormatInt(a.ID, 10),
|
||||
a.Name,
|
||||
humanSize(a.Size),
|
||||
strconv.FormatInt(a.DownloadCount, 10),
|
||||
text.FormatDate(a.Created, isTTY),
|
||||
)
|
||||
}
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runReleaseAssetCreate(cmd *cobra.Command, args []string) error {
|
||||
tag := args[0]
|
||||
files := args[1:]
|
||||
|
||||
nameOverride, _ := cmd.Flags().GetString("name")
|
||||
if nameOverride != "" && len(files) != 1 {
|
||||
return fmt.Errorf("--name may only be used when uploading a single file")
|
||||
}
|
||||
|
||||
// Fail early if any file is missing.
|
||||
for _, f := range files {
|
||||
info, err := os.Stat(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat %s: %w", f, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", f)
|
||||
}
|
||||
}
|
||||
|
||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ios.StartSpinner("Fetching release...")
|
||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
for _, file := range files {
|
||||
filename := filepath.Base(file)
|
||||
if nameOverride != "" {
|
||||
filename = nameOverride
|
||||
}
|
||||
|
||||
handle, err := os.Open(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", file, err)
|
||||
}
|
||||
|
||||
attachment, _, uploadErr := client.CreateReleaseAttachment(owner, name, release.ID, handle, filename)
|
||||
closeErr := handle.Close()
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("failed to upload %s: %w", file, uploadErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("failed to close %s: %w", file, closeErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(ios.Out, "%s Uploaded %s (id %d, %s)\n", cs.SuccessIcon(), attachment.Name, attachment.ID, humanSize(attachment.Size))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runReleaseAssetDelete(cmd *cobra.Command, args []string) error {
|
||||
tag := args[0]
|
||||
targets := args[1:]
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
|
||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ios.StartSpinner("Fetching release...")
|
||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve each target to an attachment (ID + name). List attachments once
|
||||
// if any target is a non-numeric name so we can match by filename.
|
||||
var cached []*gitea.Attachment
|
||||
resolvedIDs := make([]int64, 0, len(targets))
|
||||
resolvedNames := make([]string, 0, len(targets))
|
||||
|
||||
for _, t := range targets {
|
||||
if id, err := strconv.ParseInt(t, 10, 64); err == nil && id > 0 {
|
||||
resolvedIDs = append(resolvedIDs, id)
|
||||
resolvedNames = append(resolvedNames, t)
|
||||
continue
|
||||
}
|
||||
|
||||
if cached == nil {
|
||||
cached, err = listReleaseAttachments(client, owner, name, release.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var matched *gitea.Attachment
|
||||
for _, a := range cached {
|
||||
if a.Name == t {
|
||||
matched = a
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return fmt.Errorf("no asset named %q on release %s", t, release.TagName)
|
||||
}
|
||||
resolvedIDs = append(resolvedIDs, matched.ID)
|
||||
resolvedNames = append(resolvedNames, matched.Name)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
for i, id := range resolvedIDs {
|
||||
display := resolvedNames[i]
|
||||
|
||||
if !skipConfirm && ios.IsStdinTTY() {
|
||||
answer, err := promptLine(fmt.Sprintf("Delete asset %s (id %d) from release %s? [y/N]: ", display, id, release.TagName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
||||
fmt.Fprintf(ios.ErrOut, "Skipped %s\n", display)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := client.DeleteReleaseAttachment(owner, name, release.ID, id); err != nil {
|
||||
return fmt.Errorf("failed to delete asset %s: %w", display, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(ios.Out, "%s Deleted asset %s (id %d)\n", cs.SuccessIcon(), display, id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newReleaseAssetClient(cmd *cobra.Command) (*api.Client, string, string, error) {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
owner, name, err := parseRepo(repo)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
return client, owner, name, nil
|
||||
}
|
||||
|
||||
// humanSize formats a byte count using power-of-1024 units.
|
||||
func humanSize(n int64) string {
|
||||
const unit = int64(1024)
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := unit, 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"}
|
||||
if exp >= len(suffixes) {
|
||||
exp = len(suffixes) - 1
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), suffixes[exp])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue