Commit 2faceb4c41
Verified · cmc
Layout: unified · split
internal/control/diffcomment.go +19 −8
| @@ -300,20 +300,15 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int { | |||
| 300 | if err != nil { | 300 | if err != nil { |
| 301 | return c.fail(protocol.ExitUsage, "bad thread id %q", args[2]) | 301 | return c.fail(protocol.ExitUsage, "bad thread id %q", args[2]) |
| 302 | } | 302 | } |
| 303 | // Thread author, MR author, or anyone with write may resolve. | 303 | ok, err := canResolveThread(c, repo, mr, threadID) |
| 304 | author, err := c.Store.DiffCommentAuthor(mr.ID, threadID) | ||
| 305 | if errors.Is(err, store.ErrNotFound) { | 304 | if errors.Is(err, store.ErrNotFound) { |
| 306 | return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number) | 305 | return c.fail(protocol.ExitNotFound, "no thread %d on %s!%d", threadID, repo.Path(), mr.Number) |
| 307 | } | 306 | } |
| 308 | if err != nil { | 307 | if err != nil { |
| 309 | return c.fail(protocol.ExitFailure, "%v", err) | 308 | return c.fail(protocol.ExitFailure, "%v", err) |
| 310 | } | 309 | } |
| 311 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | 310 | if !ok { |
| 312 | if err != nil { | 311 | return c.fail(protocol.ExitDenied, "%s", cannotResolve) |
| 313 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 314 | } | ||
| 315 | if author != c.User.ID && mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) { | ||
| 316 | return c.fail(protocol.ExitDenied, "only the thread author, the MR author, or users with write access can resolve threads") | ||
| 317 | } | 312 | } |
| 318 | if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil { | 313 | if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, resolved); err != nil { |
| 319 | if errors.Is(err, store.ErrNotFound) { | 314 | if errors.Is(err, store.ErrNotFound) { |
| @@ -333,5 +328,21 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int { | |||
| 333 | }) | 328 | }) |
| 334 | } | 329 | } |
| 335 | 330 | ||
| 331 | const cannotResolve = "only the thread author, the MR author, or users with write access can resolve threads" | ||
| 332 | |||
| 333 | // canResolveThread reports whether the caller may resolve a thread: its | ||
| 334 | // author, the MR author, or anyone with write on the target. | ||
| 335 | func canResolveThread(c *Ctx, repo store.Repo, mr store.MR, threadID int64) (bool, error) { | ||
| 336 | author, err := c.Store.DiffCommentAuthor(mr.ID, threadID) | ||
| 337 | if err != nil { | ||
| 338 | return false, err | ||
| 339 | } | ||
| 340 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | ||
| 341 | if err != nil { | ||
| 342 | return false, err | ||
| 343 | } | ||
| 344 | return author == c.User.ID || mr.Author == c.User.Username || policy.CanWrite(c.User, repo, grant), nil | ||
| 345 | } | ||
| 346 | |||
| 336 | func runMRResolve(c *Ctx, args []string) int { return setThreadResolved(c, args, true) } | 347 | func runMRResolve(c *Ctx, args []string) int { return setThreadResolved(c, args, true) } |
| 337 | func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) } | 348 | func runMRUnresolve(c *Ctx, args []string) int { return setThreadResolved(c, args, false) } |
internal/control/suggestion.go +27 −5
| @@ -332,11 +332,33 @@ func runMRApplySuggestion(c *Ctx, args []string) int { | |||
| 332 | return c.fail(protocol.ExitFailure, "the source branch moved; reload and retry") | 332 | return c.fail(protocol.ExitFailure, "the source branch moved; reload and retry") |
| 333 | } | 333 | } |
| 334 | RefsUpdated(c.Store, c.Cfg, src.ID, c.User.ID, c.Scope, updates) | 334 | RefsUpdated(c.Store, c.Cfg, src.ID, c.User.ID, c.Scope, updates) |
| 335 | if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, true); err != nil { | 335 | |
| 336 | return c.failErr(err) | 336 | // The commit has landed, so from here nothing fails the command: a |
| 337 | // thread that cannot be resolved is left open and the output says so. | ||
| 338 | resolved, warning := false, "" | ||
| 339 | switch ok, err := canResolveThread(c, repo, mr, threadID); { | ||
| 340 | case err != nil: | ||
| 341 | warning = fmt.Sprintf("thread %d is still open: %v", threadID, err) | ||
| 342 | case !ok: | ||
| 343 | warning = fmt.Sprintf("thread %d is still open: %s", threadID, cannotResolve) | ||
| 344 | default: | ||
| 345 | if err := c.Store.SetThreadResolved(mr.ID, threadID, c.User.ID, true); err != nil { | ||
| 346 | warning = fmt.Sprintf("thread %d is still open: %v", threadID, err) | ||
| 347 | } else { | ||
| 348 | resolved = true | ||
| 349 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | ||
| 350 | } | ||
| 337 | } | 351 | } |
| 338 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | 352 | d := map[string]any{"thread": threadID, "sha": sha, "source": source, "resolved": resolved} |
| 339 | return c.emit(map[string]any{"thread": threadID, "sha": sha, "source": source, "resolved": true}, func(w io.Writer) { | 353 | if warning != "" { |
| 340 | fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha) | 354 | d["warning"] = warning |
| 355 | } | ||
| 356 | return c.emit(d, func(w io.Writer) { | ||
| 357 | if resolved { | ||
| 358 | fmt.Fprintf(w, "applied thread %d to %s at %.10s; thread resolved\n", threadID, source, sha) | ||
| 359 | return | ||
| 360 | } | ||
| 361 | fmt.Fprintf(w, "applied thread %d to %s at %.10s\n", threadID, source, sha) | ||
| 362 | fmt.Fprintln(c.Stderr, "warning:", warning) | ||
| 341 | }) | 363 | }) |
| 342 | } | 364 | } |
internal/control/suggestion_test.go +24
| @@ -464,6 +464,30 @@ func TestApplySuggestionFork(t *testing.T) { | |||
| 464 | if !said { | 464 | if !said { |
| 465 | t.Fatalf("timeline does not say why the merge was dequeued: %+v", cs) | 465 | t.Fatalf("timeline does not say why the merge was dequeued: %+v", cs) |
| 466 | } | 466 | } |
| 467 | // carol writes to the fork and is neither the thread's author, the | ||
| 468 | // merge request's, nor a writer of the target: her apply lands and | ||
| 469 | // leaves the thread open, saying so. | ||
| 470 | carolID, _ := f.st.CreateUser("carol", false) | ||
| 471 | carol, _ := f.st.UserByID(carolID) | ||
| 472 | f.verified(carol) | ||
| 473 | if err := f.st.GrantAccess(forkID, carolID, "write"); err != nil { | ||
| 474 | t.Fatal(err) | ||
| 475 | } | ||
| 476 | out.Reset() | ||
| 477 | errOut.Reset() | ||
| 478 | c.Stdin = strings.NewReader("```suggestion\nTWO\n```\n") | ||
| 479 | if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "2", "--file", "-"}); code != protocol.ExitOK { | ||
| 480 | t.Fatalf("diff-comment: %s", errOut.String()) | ||
| 481 | } | ||
| 482 | json.Unmarshal([]byte(out.String()), &env) | ||
| 483 | second := strconv.Itoa(int(env.Data.Thread)) | ||
| 484 | stdout := f.mustWrite(carol, "mr", "apply-suggestion", f.repo.Path(), "2", second, "--json") | ||
| 485 | if !strings.Contains(stdout, `"resolved":false`) || !strings.Contains(stdout, "still open") { | ||
| 486 | t.Fatalf("carol's apply = %s, want it applied and the thread left open", stdout) | ||
| 487 | } | ||
| 488 | if n, _ := f.st.UnresolvedThreadCount(mr.ID); n != 1 { | ||
| 489 | t.Fatalf("unresolved threads = %d, want carol's left open", n) | ||
| 490 | } | ||
| 467 | } | 491 | } |
| 468 | 492 | ||
| 469 | // Reading suggestions costs git processes per file and commit, not per | 493 | // Reading suggestions costs git processes per file and commit, not per |