Skip to content

Ignore archived repos in one-article-per-subject rule - #354

Merged
taoeffect merged 22 commits into
masterfrom
article-contributors-logic
Sep 7, 2026
Merged

Ignore archived repos in one-article-per-subject rule#354
taoeffect merged 22 commits into
masterfrom
article-contributors-logic

Conversation

@pedrogaudencio

Copy link
Copy Markdown
Collaborator

Fixes #338

AI Disclosure

Co-authored with: Opus 5

* CheckForkOnEditPermissions and ForkRepository only treat an active (non-archived) repo for the subject as blocking
* GetRepositoryByOwnerIDAndSubjectID now prefers active over archived rows deterministically, since a user can hold both
* add ArchivedOwnArticleDoesNotBlock unit test
@pedrogaudencio pedrogaudencio self-assigned this Aug 31, 2026
Copilot AI lite review requested due to automatic review settings August 31, 2026 13:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pedrogaudencio

pedrogaudencio commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

/review


AI review started.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

Review: Ignore archived repos in one-article-per-subject rule

The core fix is sound end-to-end (the editor fork path picks a collision-free name via getUniqueRepositoryName, and the change-request/fork flows all funnel through the two modified functions). I found no 🔴-rated issues; the medium and minor ones follow.

1. 🟡 DRY: the "archived doesn't count" policy is duplicated at two call sites instead of living in the model

  • Addressed
  • Dismissed

The same exemption logic now exists in two places, and its correctness silently depends on a third place (the OrderBy in the model):

// services/repository/fork.go:105-110
	// An archived repository is not an active article, so it does not consume the
	// user's "one article per subject" slot: treat it as if the user had no repo
	// for the subject.
	if ownRepo != nil && ownRepo.IsArchived {
		ownRepo = nil
	}
// services/repository/fork.go:307
		if ownRepo != nil && !ownRepo.IsArchived && ownRepo.ID != opts.BaseRepo.ID {

...combined with the ordering contract at models/repo/repo.go:1049:

		OrderBy("is_archived ASC, id ASC").

Both fork.go call sites need "the owner's active article for this subject", but each must remember to check IsArchived itself. A future call site that forgets the check silently reintroduces bug #338. I'd move the policy into the model layer and simplify both call sites:

// models/repo/repo.go
// GetActiveRepositoryByOwnerIDAndSubjectID returns the owner's active (non-archived)
// repository for the given subject, or nil if the owner has none (or only archived ones).
func GetActiveRepositoryByOwnerIDAndSubjectID(ctx context.Context, ownerID, subjectID int64) (*Repository, error) {
	var repo Repository
	has, err := db.GetEngine(ctx).
		Where("owner_id = ?", ownerID).
		And("subject_id = ?", subjectID).
		And("is_archived = ?", false).
		OrderBy("id ASC").
		Get(&repo)
	if err != nil || !has {
		return nil, err
	}
	return &repo, nil
}

Then in CheckForkOnEditPermissions the nil-out block at fork.go:105-110 is deleted and the query becomes:

// services/repository/fork.go:88
			ownRepo, err = repo_model.GetActiveRepositoryByOwnerIDAndSubjectID(gCtx, doer.ID, repo.SubjectID)

and in ForkRepository:

// services/repository/fork.go:307
		if ownRepo != nil && ownRepo.ID != opts.BaseRepo.ID {

The transfer pre-check at routers/web/repo/setting/setting.go:911 (see issue 3) can keep the any-repo variant, where the is_archived ASC ordering is then genuinely needed/harmless. This also makes the sort-order subtlety impossible to get wrong, rather than documented at models/repo/repo.go:1042-1043.

2. 🟡 The actual ForkRepository fix has no test coverage

  • Addressed
  • Dismissed

The new unit test ArchivedOwnArticleDoesNotBlock (services/repository/fork_test.go:506-545) only exercises CheckForkOnEditPermissions. The behavioral change that actually permits contributing again — the !ownRepo.IsArchived in ForkRepository at services/repository/fork.go:307 — is untested, and none of the existing subtests in tests/integration/api_fork_test.go (ForkBlockedBySubjectOwnership at api_fork_test.go:167) cover the archived case either. Suggested integration subtest, placed after ForkBlockedBySubjectOwnership (where user5Repo for the same subject already exists and is cleaned up):

	t.Run("ForkSucceedsAfterArchivingOwnArticle", func(t *testing.T) {
		defer tests.PrintCurrentTest(t)()

		// user5's own article for the subject is archived, so it must no longer
		// block forking another article of the same subject (issue #338)
		require.NoError(t, repo_model.SetArchiveRepoState(t.Context(), user5Repo, true))
		t.Cleanup(func() {
			_ = repo_model.SetArchiveRepoState(t.Context(), user5Repo, false)
		})

		forkName := "user5-fork-after-archive" // archived repo still occupies the subject name
		req := NewRequestWithJSON(t, "POST", "/api/v1/repos/"+user2.Name+"/"+repo1.Name+"/forks", &api.CreateForkOption{
			Name: &forkName,
		}).AddTokenAuth(user5Token)
		resp := MakeRequest(t, req, http.StatusAccepted)

		var fork api.Repository
		DecodeJSON(t, resp, &fork)
		t.Cleanup(func() {
			forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: forkName, OwnerID: user5.ID})
			_ = repo_service.DeleteRepositoryDirectly(t.Context(), forkRepo.ID)
		})
	})

A model-level unit test asserting that the getter prefers an active repo when an owner has both an active and an archived repo for the same subject would also pin the ordering behavior that the current implementation relies on.

3. ⚪️ Article-transfer pre-check still counts archived articles

  • Addressed
  • Dismissed
// routers/web/repo/setting/setting.go:911-920
		existing, err := repo_model.GetRepositoryByOwnerIDAndSubjectID(ctx, newOwner.ID, repo.SubjectID)
		...
		if existing != nil {
			ctx.Flash.Error(ctx.Tr("repo.settings.article_transfer_recipient_has_article"))

With this PR's policy ("an archived article doesn't occupy the subject slot"), this is now the odd one out: transferring an article to a user whose only article for the subject is archived is rejected even when the transferred repo has a unique name (e.g. a fork created as Physics-1 by getUniqueRepositoryName), i.e. no name clash would occur. If the strictness is intentional (transfers only allowed to users with no article at all), a comment saying so would help; otherwise this call site should use the active-only variant from issue 1. Note the same-name case is still caught by the ErrRepoAlreadyExist fallback at setting.go:931-932, which shows the same message.

4. ⚪️ New test drops the ownership precondition assertions present in its sibling

  • Addressed
  • Dismissed

The mirrored test BlockedBySubjectOwnership pins the fixture assumptions:

// services/repository/fork_test.go:452-453
		assert.Equal(t, userWithRoot.ID, rootRepo.OwnerID)
		assert.Equal(t, userWithFork.ID, forkRepo.OwnerID)

The new test at fork_test.go:512-514 loads the same beans without verifying them, so a future fixture change could silently turn the test into something else (e.g. if user2 came to own repo 4, IsRepoOwner would short-circuit and NeedsFork would fail for a different reason). Suggest adding:

		assert.Equal(t, userWithRoot.ID, rootRepo.OwnerID)
		assert.NotEqual(t, userWithRoot.ID, forkRepo.OwnerID)

and optionally assert.False(t, perms.HasExistingFork) to pin that the Case-1 branch is the one taken.

5. ⚪️ An archived existing fork can still be selected as the commit target

  • Addressed
  • Dismissed

GetForkedRepo (models/repo/fork.go:28-40) does not filter archived repos, so with the own repo now nil'd out, a user whose only article is an archived fork of the viewed repo falls into Case 1 (services/repository/fork.go:174-176) and gets ExistingFork pointing at the archived fork. handleForkAndEdit then commits into it:

// routers/web/repo/editor.go:583-585
	if perms.HasExistingFork && perms.ExistingFork != nil {
		return perms.ExistingFork
	}

ChangeRepoFiles has no IsArchived guard, so this writes to a repo the UI otherwise treats as read-only (the fork page template hides the editor via custom/templates/shared/repo/edit.tmpl:2). This state was reachable before this PR too (Case 2a produced the same ExistingFork), but since the PR's premise is "archived articles are not active", the mismatch is now more visible. Minimal guard:

	if perms.HasExistingFork && perms.ExistingFork != nil {
		if perms.ExistingFork.IsArchived {
			ctx.JSONError(ctx.Tr("repo.editor.existing_fork_archived")) // tell the user to unarchive their fork
			return nil
		}
		return perms.ExistingFork
	}

(Treating the archived fork as absent and setting NeedsFork instead would run into ErrForkAlreadyExist from GetUserFork at services/repository/fork.go:316-326, so an explicit error message is the safer minimal fix.)


Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

* add ForkSucceedsAfterArchivingOwnArticle integration subtest exercising the guard in ForkRepository
* add model tests pinning the is_archived ASC, id ASC preference in GetRepositoryByOwnerIDAndSubjectID
* handleForkAndEdit returns repo.editor.existing_fork_archived instead of committing into an archived fork
* exempt the fork-and-edit workflow from the current-repo protected-branch and maintainer-write checks (_edit/_new only), which previously masked the archived case
* add GetActiveRepositoryByOwnerIDAndSubjectID and use it for the transfer pre-check
* restrict ExcludeOwnersOfSubjectID to active articles so the candidate search matches the pre-check
* assert fixture ownership in ArchivedOwnArticleDoesNotBlock, matching BlockedBySubjectOwnership
* pin HasExistingFork == false so the Case-1 branch is the one exercised
Copilot AI review requested due to automatic review settings September 1, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

* order GetRepositoryByOwnerAndSubject by is_archived ASC, updated_unix DESC, id DESC so the URL follows the newest active article and falls back to archived only when none is active.
* add coverage for active-wins, archived-fallback and not-found cases.
* trade-off: GetRepositoryByOwnerIDAndSubjectID keeps its id ASC tiebreak, since fork-permission logic depends on it.
* Home renders the Article View for subject-bound repositories, resolving to that exact repository
* article tabs and JS navigation follow ArticleLink/data-article-canonical instead of the hardcoded vanity path
* new integration test covers both routes, sub-paths, and archived articles
* Repository.Link() returns the permanent URL for archived subject repositories
* table rows, bubble nodes and stored selections carry the archived flag; buildArticleUrl emits /owner/repo?view=article for them
* trade-off: no query string on Link(), since it is concatenated as a path prefix across the codebase
* Repository.Link() returns /article/{owner}/{repo} for archived subject repositories
* new GetArticleRepositoryByOwnerAndRef resolves the article path element as subject name first, repository name second
* buildArticleUrl emits the prefixed permanent URL for archived selections
* trade-off: no ?view=article on Link(), which is used as a path prefix across the codebase
* Link() short-circuits to OperationsLink() for archived subject repos, dropping the ambiguous /article/{owner}/{name} fallback and its DB query
* frontend and ArticleView follow the repo link, canonical path preserved across read/history navigation
* integration tests restore archive state and cover the name-vs-slug collision
* CanWriteToBranch reads fork_and_edit and submit_change_request with ctx.FormBool, matching the editor handler
* action allowlist unchanged, so the _delete/_upload/_diffpatch bypass boundary still holds
* ExistingForkArchived added to ForkOnEditPermissions and used by both the fork-and-edit POST and the article page
* editor GET returns the archived-specific message so the edit is not written first
Copilot AI review requested due to automatic review settings September 3, 2026 13:41
@pedrogaudencio

Copy link
Copy Markdown
Collaborator Author

Advanced AI Review

  • Type: Agentic (auggie)
  • Model: Opus 5
Click to expand review

1. 🔴 Permanent article URL can resolve to a different repository

  • Addressed
  • Dismissed

models/repo/repo.go:671-677 builds an archived repository’s link as /article/{owner}/{repo.Name}. GetArticleRepositoryByOwnerAndRef (models/repo/repo.go:1055-1079) resolves that path element subject-first:

repo, err := GetRepositoryByOwnerAndSubject(ctx, ownerName, ref)
if err == nil {
	return repo, nil
}

Article repository names are generated through GenerateRepoNameFromSubjectGenerateSlugFromName (models/repo/repo.go:1334-1342). When a subject name equals its slug, an archived repository’s name is also a valid subject name.

For example:

  • The owner archives physics, whose subject is physics.
  • The owner creates a new active article for the same subject.
  • repo.Link() for the archived repository returns /article/owner/physics.
  • GetRepositoryByOwnerAndSubject returns the active repository because of the is_archived ASC ordering in models/repo/repo.go:1035.

The archived article therefore becomes unreachable through its own permanent link, which silently serves a different article.

The /article/{owner}/{name} fallback is also redundant because handleRepoHomeArticle (routers/web/repo/view_home.go:325-333) already renders the article view at /{owner}/{repo}, which is unambiguous.

The implemented resolution makes archived articles use their operations URL:

func (repo *Repository) Link() string {
	if repo.IsArchived && repo.SubjectID > 0 {
		// The subject vanity URL resolves to the active repository of the subject.
		return repo.OperationsLink()
	}

	return setting.AppSubURL +
		"/article/" +
		url.PathEscape(repo.OwnerName) +
		"/" +
		url.PathEscape(repo.GetSubject(context.Background()))
}

GetArticleRepositoryByOwnerAndRef was removed, and RepoAssignmentByOwnerAndSubject again uses the plain subject lookup. The frontend also builds archived article URLs using the permanent path.

2. 🟡 Subject-bound repositories no longer use the repository code view

  • Addressed
  • Dismissed

handleRepoHomeArticle (routers/web/repo/view_home.go:325-333) returns the article view for every repository with SubjectID != 0, not only archived repositories.

Consequently, /{owner}/{repo} no longer displays the file listing, clone panel, or repository README for an article. Repository subpaths such as /src/... continue to use the code view.

This appears intentional because, in Forkana, a subject-bound repository is an article. The remaining concern is primarily explanatory: the function comment attributes the behavior to archived-article reachability without describing its effect on every subject-bound repository.

A related inconsistency exists in RenderRepositoryHistory (routers/web/explore/repo.go:378,435): RepoLink uses the vanity URL, while ArticleLink and data-article-canonical use the permanent URL. Templates using both values may return readers to the vanity URL.

3. 🟡 fork_and_edit is parsed differently in middleware and handler

  • Addressed
  • Dismissed

routers/web/repo/editor.go:160 uses ctx.FormBool("fork_and_edit"), which accepts values such as true, 1, t, and on.

The corresponding gate in services/context/permission.go:45 previously used:

ctx.Req.FormValue("fork_and_edit") == "true"

With fork_and_edit=1, the middleware enforced write permission even though the handler would have forked. For users with write permission, the handler could also skip protected-branch and CanMaintainerWriteToBranch checks on a request the middleware did not recognize as fork-and-edit.

This did not create a privilege escalation because the commit still targeted the fork, but the two gates behaved inconsistently.

CanWriteToBranch now reads both fork_and_edit and submit_change_request using ctx.FormBool. The existing action allowlists remain unchanged:

  • _edit and _new for fork-and-edit.
  • _edit only for change requests.

TestForkAndEditMiddlewareBypass also includes a fork_and_edit=1 case. The shared helper originally suggested by the review was not extracted.

4. 🟡 Archived forks are rejected only after the user writes an edit

  • Addressed
  • Dismissed

CheckForkOnEditPermissions (services/repository/fork.go:108-110) nulls an archived ownRepo.

A user whose only fork is archived can therefore receive HasExistingFork=true and see the normal editor. The failure appears only when the edit is submitted in routers/web/repo/editor.go:591-595, causing the user’s work to be lost.

The archived state should be exposed in the permission result—for example, through an ExistingForkArchived field—so prepareArticleForkOnEditData can disable the editor and show the archived-fork message before the user begins editing.

5. ⚪️ Archived-fork error does not explain the required action

  • Addressed
  • Dismissed

The message in custom/options/locale/locale_en-US.ini:1161 says:

Your fork of this article is archived.

The code comment explains that the user must unarchive the fork before editing, but the user-facing message does not.

A more actionable alternative would be:

Your fork of this article is archived. Unarchive it before editing.

6. ⚪️ Link() performs a discarded subject query for archived repositories

  • Addressed
  • Dismissed

models/repo/repo.go:672-674 previously called GetSubject(context.Background()), which could invoke LoadSubject and query the database using an uncancellable context. The retrieved subject was then discarded for archived repositories.

The archived branch now returns before the subject lookup, avoiding the unnecessary query.

7. ⚪️ The same article is served from multiple URLs

  • Addressed
  • Dismissed

The article was originally available through three URLs:

  • Vanity URL: /article/{owner}/{subject}
  • Repository-name URL: /article/{owner}/{name}
  • Permanent URL: /{owner}/{name}

The issue 1 resolution removed /article/{owner}/{name}, reducing the total to two URLs.

data-article-canonical supports in-page navigation, but the page does not currently expose an HTML rel="canonical" element. Adding a canonical link would make the preferred URL explicit to search engines.

8. ⚪️ TestArticlePermanentRoute subtests are order-dependent

  • Addressed
  • Dismissed

tests/integration/article_permanent_route_test.go:87 archives the repository inside ArchivedArticleStaysReachable without restoring its state.

As a result, ArticleURLByRepositoryName and ArticleURLByUnknownRefIsNotFound run against an archived repository and depend on the order in which the subtests execute.

The repository state could be restored with t.Cleanup. The collision described in issue 1 could also be covered by testing an archived repository whose name equals a subject name while its owner has an active repository for that subject.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The repo-history localStorage overwrite check can leave a stale archived flag, which can route an archived article through the vanity subject URL and open the wrong repository.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 22/22 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread web_src/js/features/repo-history.ts
Comment thread custom/templates/shared/repo/article.tmpl
@pedrogaudencio

pedrogaudencio commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

/review


AI review started.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Advanced AI Review

  • Type: Agentic (crush)
  • Model: glm-5.3
Click to expand review

The full PR is commits 370c70d217..d02c9e26cd. Now let me read the key changed files in context.I've completed a thorough review of all 22 files in this PR (verified locally: the full diff is 935bb486ef..d02c9e26cd; model tests ./models/repo and ./services/repository pass, go vet is clean on all changed packages). Findings, ordered by importance:


1. 🟡 FormBool and the form binder disagree for "On"/"ON", breaking the "gates always agree" guarantee this PR is aiming for

  • Addressed
  • Dismissed

services/context/permission.go:47 and routers/web/repo/editor.go:168 now gate on ctx.FormBool:

if ctx.FormBool("fork_and_edit") {
isForkAndEdit := ctx.FormBool("fork_and_edit") && (editorAction == "_edit" || editorAction == "_new")

But the value that actually decides the workflow in EditFilePost is the form-bound flag (routers/web/repo/editor.go:526):

if parsed.form.ForkAndEdit {
	targetRepo = handleForkAndEdit(ctx)

The two parsers are not equivalent. FormBool (services/context/base_form.go:55-60) additionally accepts case-insensitive "on":

func (b *Base) FormBool(key string) bool {
	s := b.Req.FormValue(key)
	v, _ := strconv.ParseBool(s)
	v = v || strings.EqualFold(s, "on")
	return v
}

while the go-chi binder (binding.go:664-678) accepts only exactly val == "on" or strconv.ParseBool — and web.Bind discards binding errors (modules/web/router.go:22-28), so a rejected value silently binds to false (ctx.HasError() is not set by Bind).

Consequence: a signed-in non-writer who has an existing fork of an article can POST to /{owner}/{repo}/_edit/{branch}/README.md with fork_and_edit=On (or submit_change_request=On, same divergence at routers/web/repo/editor.go:162). Both permission gates are bypassed (NeedFork is false because the fork exists, isForkAndEdit/isSubmitChangeRequest are true via FormBool), yet parsed.form.ForkAndEdit binds to false, so the request takes the direct path with targetRepo = ctx.Repo.Repository and reaches files_service.ChangeRepoFiles against the base repository. The write is ultimately rejected by the pre-receive hook (routers/private/hook_pre_receive.go:145 AssertCanWriteCode), so this is not an escalation to a write — but the middleware's documented contract ("the handler will create a fork/branch and commit to that instead") is violated, the commit path runs (temp repo clone + commit objects) and the user gets a confusing push error instead of a 404.

Since web.Bind runs before canWriteToBranch in the route chain (routers/web/web.go:242-247), both gates can read the same bound value the handler will use:

// services/context/permission.go
func CanWriteToBranch() func(ctx *Context) {
	return func(ctx *Context) {
		editorAction := ctx.PathParam("editor_action")
		if editorForm, ok := web.GetForm(ctx).(*forms.EditRepoFileForm); ok {
			if editorForm.ForkAndEdit && (editorAction == "_edit" || editorAction == "_new") {
				return
			}
			if editorForm.SubmitChangeRequest && editorAction == "_edit" {
				return
			}
		}
		// ... unchanged fallthrough

and in prepareEditorCommitSubmittedForm, derive the flag from the bound form rather than re-parsing the raw input (the function already holds the typed form):

isForkAndEdit := false
if ff, ok := any(form).(interface{ IsForkAndEdit() bool }); ok {
	isForkAndEdit = ff.IsForkAndEdit() && (editorAction == "_edit" || editorAction == "_new")
}

with func (f *EditRepoFileForm) IsForkAndEdit() bool { return f.ForkAndEdit } added next to the existing GetCommitCommonForm method. Alternatively, make both gates use a stricter helper that matches the binder exactly (v == "on" || parseBool(v)), applied in all three places.


2. 🟡 DRY: the "archived doesn't occupy the subject slot" rule is implemented three different ways

  • Addressed
  • Dismissed

The PR adds a purpose-built query, GetActiveRepositoryByOwnerIDAndSubjectID (models/repo/repo.go:1072-1086), and uses it in exactly one of the three places that need the rule:

  • routers/web/repo/setting/setting.go:914 — uses the new getter ✔
  • services/repository/fork.go:108-112 — fetches any repo, then nils it out:
	// An archived repository is not an active article, so it does not consume the
	// user's "one article per subject" slot: treat it as if the user had no repo
	// for the subject.
	if ownRepo != nil && ownRepo.IsArchived {
		ownRepo = nil
	}
  • services/repository/fork.go:307-313 — fetches any repo, then inlines the exemption into the condition:
		ownRepo, err := repo_model.GetRepositoryByOwnerIDAndSubjectID(ctx, owner.ID, opts.BaseRepo.SubjectID)
		...
		if ownRepo != nil && !ownRepo.IsArchived && ownRepo.ID != opts.BaseRepo.ID {

Both fork.go sites are semantically identical to the new getter and should just use it, which also removes their dependency on GetRepositoryByOwnerIDAndSubjectID's OrderBy("is_archived ASC, ...") doing the right thing:

// CheckForkOnEditPermissions
g.Go(func() error {
	var err error
	ownRepo, err = repo_model.GetActiveRepositoryByOwnerIDAndSubjectID(gCtx, doer.ID, repo.SubjectID)
	return err
})
// (delete the "if ownRepo != nil && ownRepo.IsArchived" block)

// ForkRepository
ownRepo, err := repo_model.GetActiveRepositoryByOwnerIDAndSubjectID(ctx, owner.ID, opts.BaseRepo.SubjectID)
if err != nil {
	return nil, err
}
if ownRepo != nil && ownRepo.ID != opts.BaseRepo.ID {
	return nil, ErrUserOwnsSubjectRepo{...}
}

3. 🟡 Misleading doc comment: archived articles are addressable on the vanity URL as a fallback

  • Addressed
  • Dismissed

services/context/repo.go:1175-1179:

// RepoAssignmentByOwnerAndSubject assigns repository context by owner name and subject name
// This is used for routes like /article/{username}/{subjectname} that display a specific user's repository.
// Archived articles are not addressable here, they are served from their permanent
// repository url "/{username}/{reponame}".

This contradicts the (tested) behavior one file over: GetRepositoryByOwnerAndSubject explicitly returns the archived repository when the owner has no active one left (models/repo/repo.go:1016-1018), and TestGetRepositoryByOwnerAndSubject_PrefersActiveOverArchived asserts it. A future reader of this comment would assume no archived article is ever served here and might "simplify" the fallback away. Suggested wording:

// When the owner also has an active article for the subject, the vanity URL resolves to
// the active one and the archived article is only reachable via its permanent repository
// url "/{username}/{reponame}". An archived article is still served here when it is the
// owner's only article for the subject.

4. ⚪️ Dead assignments: ExistingForkArchived can never be true in the two ownRepo != nil branches

  • Addressed
  • Dismissed

services/repository/fork.go:137 and services/repository/fork.go:157:

			perms.ExistingFork = existingFork
			perms.ExistingForkArchived = existingFork.IsArchived
				perms.ExistingFork = ownRepo
				perms.ExistingForkArchived = ownRepo.IsArchived

Both branches require ownRepo != nil, and ownRepo was already set to nil when archived at fork.go:111. In the first branch existingFork.ID == ownRepo.ID and in the second ExistingFork is ownRepo, so both IsArchived reads are always false. The only live assignment is the Case-1 one at fork.go:182. These dead assignments suggest archived forks can reach these branches, which they cannot — remove them (or, if keeping them as defense-in-depth, say so in a comment).


5. ⚪️ Duplicated selection persistence in localStorage, now extended to a fourth key

  • Addressed
  • Dismissed

The PR adds LS_ARCHIVED_KEY to two parallel implementations of the same storage protocol:

  • web_src/js/features/repo-history.ts:26-69 (readStoredSelection / writeStoredSelection)
  • web_src/js/components/graph/FishboneGraph.vue:84-88, 409-419, 1557-1582 (readStoredSelection / persistSelectionDetail)

Both files now declare the same four keys and near-identical read/write/clear logic:

const LS_OWNER_KEY = 'selectedArticleOwner';
const LS_SUBJECT_KEY = 'selectedArticleSubject';
const LS_REPO_KEY = 'selectedArticleRepo';
const LS_ARCHIVED_KEY = 'selectedArticleArchived';

Any future key or normalization change must be made twice or the graph and the history view silently disagree. Extract a shared module, e.g. web_src/js/features/article-selection-storage.ts, exporting readStoredSelection(), writeStoredSelection(selection) and the RepoSelection type, and import it from both files.


6. ⚪️ Dead fallbacks for ArticleLink, one of which is wrong if it ever fires

  • Addressed
  • Dismissed

custom/templates/shared/repo/article.tmpl:44:

{{$articleLink := or .ArticleLink (printf "%s/article/%s/%s" AppSubUrl (PathEscape .Repository.Owner.Name) (PathEscapeSegments (.Repository.GetSubject ctx)))}}

and routers/web/explore/repo.go:569-573:

	// Article routes set "ArticleLink" to the route the article was requested through;
	// other entry points (subject page) fall back to the vanity article URL.
	if _, ok := ctx.Data["ArticleLink"]; !ok {
		ctx.Data["ArticleLink"] = ctx.Repo.Repository.Link()
	}

Both entry points that render this template (ArticleView, commit.go:484, and handleRepoHomeArticle, view_home.go:329) always set the key before rendering, so both fallbacks are unreachable. Worse, the template fallback is stale relative to this PR: it hard-codes the vanity URL, so if it did fire for an archived repository it would produce exactly the wrong link (the vanity URL that resolves to a different, active article), and it escapes the subject with PathEscapeSegments where the Go code uses PathEscape. Prefer deleting the template fallback (.ArticleLink suffices), and if the Go fallback is kept as a safety net it already does the right thing via repo.Link().


7. ⚪️ Dead fallback in ArticleView

  • Addressed
  • Dismissed

routers/web/repo/commit.go:480-483:

	subject := ctx.PathParam("subjectname")
	if subject == "" {
		subject = ctx.Repo.Repository.GetSubject(ctx)
	}

ArticleView is registered on exactly one route (routers/web/web.go:1237), /article/{username}/{subjectname}, so the param is always non-empty (chi wouldn't match otherwise). Simplify to:

	// Keep in-page links on the subject the article was requested through
	subject := ctx.PathParam("subjectname")
	ctx.Data["ArticleLink"] = setting.AppSubURL + "/article/" + url.PathEscape(ctx.Repo.Owner.Name) + "/" + url.PathEscape(subject)

8. ⚪️ Locale message doesn't tell the user how to recover

  • Addressed
  • Dismissed

custom/options/locale/locale_en-US.ini:1161:

editor.existing_fork_archived = Your fork of this article is archived.

This string is shown in three contexts: the disabled-button tooltip (article.tmpl:101), the 404 editor page, and the JSON error toast from handleForkAndEdit (editor.go:600-602). In the toast/page contexts a bare statement of fact leaves the user stuck; the code comment itself says "the user has to unarchive it first". Suggest:

editor.existing_fork_archived = Your fork of this article is archived. Unarchive it from its settings to edit it.

9. ⚪️ Inconsistent tie-break ordering between the two owner+subject getters, plus a stale sentence in Link()'s comment

  • Addressed
  • Dismissed

models/repo/repo.go:1032 vs models/repo/repo.go:1055:

		OrderBy("repository.is_archived ASC, repository.updated_unix DESC, repository.id DESC").
		OrderBy("is_archived ASC, id ASC").

Two semantically parallel lookups use different secondary orderings ("most recently updated" vs "lowest id") among multiple active repos. Neither case should normally exist, but if the one-active-per-subject invariant is ever broken (the transfer flow itself acknowledges this race at setting.go:908-911), the vanity URL silently flaps between articles on every push while the subject-slot check picks a fixed one. Pick one tie-break (e.g. id both places) or document why they differ.

Also, models/repo/repo.go:667-668 still says:

// Link returns the repository relative url for viewing articles
// Uses subject name if available, falls back to repository name

The "falls back to repository name" sentence was already inaccurate and is now more so: for non-archived repos Link() never falls back to the repository name (a repo without a subject yields /article/{owner}/), and the archived case falls back to OperationsLink(). Reword to describe the three actual behaviors.


Review generated using glm-5.3 via Z.AI. Comment /review to re-run.

* CanWriteToBranch and prepareEditorCommitSubmittedForm no longer re-parse fork_and_edit/submit_change_request with ctx.FormBool, so the gates and the handler always agree.
* Flags exposed via forms.EditorWorkflowForm; services/context mirrors it as an unexported interface to avoid an import cycle.
* Regression tests for binder-rejected values ("On"), which previously bypassed both gates and reached the direct-commit path.
* CheckForkOnEditPermissions and ForkRepository now call GetActiveRepositoryByOwnerIDAndSubjectID, dropping the manual archived nil-out and the inline !ownRepo.IsArchived check, so all three subject-slot call sites share the same database-level filter.
* Removed GetRepositoryByOwnerIDAndSubjectID, which had no callers left, and its ordering test; the active getter's test absorbed the "archived repo has the lower ID" coverage.
* Retargeted the v327 index comments to the surviving getter and its is_archived predicate; the (owner_id, subject_id) index is unchanged.
* RepoAssignmentByOwnerAndSubject no longer claims archived articles are unaddressable; it documents the active-preferred lookup with the archived fallback.
* narrowed the matching claim in TestSubjectLookupPrefersActiveRepository to what the assertion covers.
* removed ExistingForkArchived writes from both ownRepo != nil branches, where the value is always false; the live Case-1 write stays.
* extracted the four localStorage keys and the read/write/clear logic into web_src/js/modules/repo-selection.ts; repo-history.ts and FishboneGraph.vue import it.
* merged the two divergent copies: the graph's missing-window guard and the history view's subject-only read fallback are both retained.
* $articleLink now reads .ArticleLink only
* the printf fallback hard-coded the vanity URL, wrong for an archived article, and escaped the subject with PathEscapeSegments against the Go side's PathEscape.
* ArticleView now builds ArticleLink straight from the subjectname path param; the subject ==  branch could not be reached from its only route, and was a no-op even in principle since the repository is resolved by that same segment.
* added TestArticlePermanentRoute/ArticleURLWithoutSubjectDoesNotReachTheArticleView to pin that premise.
* GetActiveRepositoryByOwnerIDAndSubjectID now tie-breaks on updated_unix DESC, id DESC, matching GetRepositoryByOwnerAndSubject, so the subject-slot check and the vanity URL pick the same repository if an owner ends up with several active ones.
* Link()'s comment now states its two real branches; the "falls back to repository name" claim was moved onto the branch where GetSubject makes it true.
* Deliberately omitted the suggested is_archived ASC prefix: the query already filters on that column, so it would sort a constant.
Copilot AI review requested due to automatic review settings September 7, 2026 19:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pedrogaudencio

Copy link
Copy Markdown
Collaborator Author

@taoeffect ready! ✅

@taoeffect
taoeffect merged commit 43e8abc into master Sep 7, 2026
33 checks passed
@taoeffect
taoeffect deleted the article-contributors-logic branch September 7, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow contributions when the user has no active article for the subject

3 participants