Make sync system more resilient to drift

This commit is contained in:
Armin Ronacher
2026-01-01 14:17:12 +01:00
parent ddf9861c6f
commit c0c33ac8fc
5 changed files with 115 additions and 4 deletions
+2 -1
View File
@@ -58,6 +58,7 @@ type PushCommand struct {
BaseCommand
DryRun bool `long:"dry-run" description:"Show what would happen without pushing"`
NoComments bool `long:"no-comments" description:"Skip posting pending comments"`
Force bool `long:"force" description:"Skip conflict detection and push anyway"`
Args struct {
Issues []string `positional-arg-name:"issue" description:"Issue numbers, local IDs, or paths to push"`
} `positional-args:"yes"`
@@ -207,7 +208,7 @@ func (c *PullCommand) Execute(args []string) error {
}
func (c *PushCommand) Execute(args []string) error {
opts := app.PushOptions{DryRun: c.DryRun, NoComments: c.NoComments}
opts := app.PushOptions{DryRun: c.DryRun, NoComments: c.NoComments, Force: c.Force}
if len(c.Args.Issues) > 0 {
return c.App.Push(context.Background(), opts, c.Args.Issues)
}
+1
View File
@@ -33,6 +33,7 @@ type PullOptions struct {
type PushOptions struct {
DryRun bool
NoComments bool
Force bool
}
type NewOptions struct {
+17 -3
View File
@@ -454,9 +454,23 @@ func (a *App) Push(ctx context.Context, opts PushOptions, args []string) error {
continue
}
if pu.HasOriginal && !issue.EqualForConflictCheck(remote, pu.Original) {
conflicts = append(conflicts, numStr)
conflictCount++
if !opts.Force && pu.HasOriginal && !issue.EqualForConflictCheck(remote, pu.Original) {
// Remote changed since last sync, but check if local matches remote
// (i.e., the same change was already applied - no real conflict)
if !issue.EqualForConflictCheck(remote, pu.Item.Issue) {
conflicts = append(conflicts, numStr)
conflictCount++
continue
}
// Local matches remote - update the original and skip (nothing to push)
if err := writeOriginalIssue(p, remote); err != nil {
progress.Log(fmt.Sprintf("%s updating original for #%s: %v", t.WarningText("Warning:"), numStr, err))
}
pu.Item.Issue.SyncedAt = ptrTime(a.Now().UTC())
if err := issue.WriteFile(pu.Item.Path, pu.Item.Issue); err != nil {
progress.Log(fmt.Sprintf("%s updating local file for #%s: %v", t.WarningText("Warning:"), numStr, err))
}
unchanged++
continue
}
+31
View File
@@ -550,6 +550,9 @@ func (c *Client) GetIssue(ctx context.Context, number string) (issue.Issue, erro
return payload.ToIssue(), nil
}
// batchQueryChunkSize is the maximum number of issues to query in a single GraphQL call.
const batchQueryChunkSize = 20
// GetIssuesBatch fetches multiple issues in a single GraphQL call.
// Returns a map of issue number -> issue. Issues that don't exist are not included.
func (c *Client) GetIssuesBatch(ctx context.Context, numbers []string) (map[string]issue.Issue, error) {
@@ -557,6 +560,34 @@ func (c *Client) GetIssuesBatch(ctx context.Context, numbers []string) (map[stri
return map[string]issue.Issue{}, nil
}
// Process in chunks to avoid GitHub's resource limits
results := make(map[string]issue.Issue)
for i := 0; i < len(numbers); i += batchQueryChunkSize {
end := i + batchQueryChunkSize
if end > len(numbers) {
end = len(numbers)
}
chunk := numbers[i:end]
chunkResults, err := c.getIssuesBatchChunk(ctx, chunk)
if err != nil {
return nil, err
}
for k, v := range chunkResults {
results[k] = v
}
}
return results, nil
}
// getIssuesBatchChunk fetches a single chunk of issues.
func (c *Client) getIssuesBatchChunk(ctx context.Context, numbers []string) (map[string]issue.Issue, error) {
if len(numbers) == 0 {
return map[string]issue.Issue{}, nil
}
owner, repo := splitRepo(c.repo)
if owner == "" || repo == "" {
return nil, fmt.Errorf("invalid repository format")
+64
View File
@@ -88,6 +88,34 @@ func (c *Client) GetIssueRelationshipsBatch(ctx context.Context, numbers []strin
return map[string]IssueRelationships{}, nil
}
// Process in chunks to avoid GitHub's resource limits
results := make(map[string]IssueRelationships)
for i := 0; i < len(numbers); i += batchChunkSize {
end := i + batchChunkSize
if end > len(numbers) {
end = len(numbers)
}
chunk := numbers[i:end]
chunkResults, err := c.getIssueRelationshipsBatchChunk(ctx, chunk)
if err != nil {
return nil, err
}
for k, v := range chunkResults {
results[k] = v
}
}
return results, nil
}
// getIssueRelationshipsBatchChunk fetches relationships for a single chunk of issues.
func (c *Client) getIssueRelationshipsBatchChunk(ctx context.Context, numbers []string) (map[string]IssueRelationships, error) {
if len(numbers) == 0 {
return map[string]IssueRelationships{}, nil
}
owner, repo := splitRepo(c.repo)
if owner == "" || repo == "" {
return nil, fmt.Errorf("invalid repository format")
@@ -596,6 +624,10 @@ type BatchUpdateResult struct {
Errors map[string]string // Issue number -> error message
}
// batchChunkSize is the maximum number of issues to update in a single GraphQL call.
// GitHub's GraphQL API has resource limits that prevent very large mutations.
const batchChunkSize = 20
// BatchEditIssues updates multiple issues in a single GraphQL call.
// This is much faster than calling EditIssue for each issue individually.
// Note: This only handles title, body, milestone, labels, and assignees.
@@ -609,6 +641,38 @@ func (c *Client) BatchEditIssues(ctx context.Context, updates []BatchIssueUpdate
return result, nil
}
// Process updates in chunks to avoid GitHub's resource limits
for i := 0; i < len(updates); i += batchChunkSize {
end := i + batchChunkSize
if end > len(updates) {
end = len(updates)
}
chunk := updates[i:end]
chunkResult, err := c.batchEditIssuesChunk(ctx, chunk)
if err != nil {
return result, err
}
result.Updated = append(result.Updated, chunkResult.Updated...)
for k, v := range chunkResult.Errors {
result.Errors[k] = v
}
}
return result, nil
}
// batchEditIssuesChunk processes a single chunk of batch updates.
func (c *Client) batchEditIssuesChunk(ctx context.Context, updates []BatchIssueUpdate) (BatchUpdateResult, error) {
result := BatchUpdateResult{
Errors: make(map[string]string),
}
if len(updates) == 0 {
return result, nil
}
owner, repo := splitRepo(c.repo)
if owner == "" || repo == "" {
return result, fmt.Errorf("invalid repository format")