Feature Specification: Community PR Processing
Feature ID: community-pr-processing
Status: Retrospective
Created: 2026-05-01
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
When community members open Pull Requests against a work's main repo
(typically adding new items to an Awesome-List-style README), the
platform discovers the open PRs, extracts new items via the work's
configured AI provider, writes those items to the work's separate
data repo (NOT the PR's own repo), commits + pushes, records the
event in work_generation_history, comments back on the PR, and
optionally auto-closes processed PRs.
Two trigger paths consume the same CommunityPrProcessorService:
- Manual —
POST /api/works/:id/process-community-prsfromWorksController(gated bycommunityPrEnabled, owner-only). - Scheduled —
CommunityPrSchedulerService.handleCommunityPrProcessingruns hourly via@Cron(CronExpression.EVERY_HOUR)and processes every work that hascommunityPrEnabled = true.
Per-work mutual exclusion is enforced via
DistributedTaskLockService.runExclusive keyed by
community-pr:<workId> with a 30-minute TTL. The scheduler also
holds an outer lock keyed works:community-pr-scheduler (1 hour
TTL) so only one replica runs the cron at any time.
2. User Scenarios
2.1 Primary scenarios
- Given my work has
communityPrEnabled=trueand open community PRs against the main repo, when the hourly scheduler runs OR I click "Process community PRs" in the dashboard, then the processor reads each open PR, extracts new items via AI, writes them to the data repo, commits + pushes, comments on the PR, and (ifcommunityPrAutoClose=true) closes it. - Given my work's data repo already contains an item with the
same slug as a proposed addition, when the per-PR loop reaches
that slug, then the duplicate is silently skipped (with a
logger.warn); the rest of the PR's items continue. - Given a PR has already been processed (its
numberis instate.processedPrNumbersAND itsupdatedAtmatches the entry instate.processedPrs), when the next run looks at it, then it is skipped — no double-processing. - Given a PR was previously processed but the contributor has
since pushed new commits (changing
pr.updatedAt), when the next run looks at it, then the processor RE-processes the PR from scratch — theprocessedPrsrecord'supdatedAtmismatch triggers re-extraction.
2.2 Edge cases & failures
- Given another worker is already processing the same work's
PRs (per-work
community-pr:<workId>lock held), when my worker tries, thenrunExclusivereturns{acquired: false, result: undefined}and the service returns0(no items added) — the lock-holder'sonLockedlog fires. - Given the AI extraction returns
{items: []}(PR was just formatting changes / typo fixes / reorganisation), whenprocessSinglePrreads the response, then the PR is marked withoutcome: 'ignored'and the loop moves on — NO PR comment, NO items written, NO PR close. - Given the diff context exceeds
MAX_CHANGE_CONTEXT_LENGTH(50 000 chars), when the loop builds the AI prompt, then the truncation breaks at the last entry that fits — the prompt never exceeds the cap, even if it means dropping later files. - Given the AI's response fails the zod schema
(
extractedItemSchema), whenaiFacade.askJsonthrows, then the per-PRtry/catchrecordslastError, logs the stack viathis.logger.error, and the loop continues with the NEXT PR — the failed PR is NOT marked processed (so it'll retry on the next run). - Given the GitHub API throws on
listPullRequests/getPullRequestFiles/cloneOrPull, when the per-PR loop catches it, thencurrentState.lastErroris set to the message, the in-memoryprocessedPrsis NOT updated for the failing PR, and processing continues. - Given every PR in the batch fails AND no items were added,
when the loop ends, then
currentState.lastProcessedAtis still set to the new timestamp +lastErroris set to the last failure; the work row is updated;itemsCountis NOT incremented (becausetotalItemsAdded === 0). - Given all 500 entries of
processedPrNumbersare already filled, when another PR is processed, then the array is sliced to keep only the LAST 500 (FIFO eviction). TheprocessedPrsarray follows the same rule independently. - Given comment posting fails after items have been written
AND pushed, when the comment call throws, then the
exception is caught and surfaces as
logger.warn('Community PR #X for work Y was applied but commenting failed: ...')— the items remain in the data repo and the PR is still markedapplied. - Given auto-close fails after items have been written AND
pushed AND commented, when the close call throws, then
the exception is caught and surfaces as
logger.warn('... was applied but auto-close failed: ...')— the run still counts asapplied. - Given
recordCommunityPrHistorythrows (e.g. transient DB failure), when the per-PR loop catches it, then the failure is logged vialogger.warnbut the items remain in the data repo and the PR is still markedapplied— history is best-effort, not transactional. - Given the controller endpoint is hit on a work where
communityPrEnabled === false, whenWorksController.processCommunityPrschecks the gate, then it throwsBadRequestException('Community PR processing is not enabled for this work.'). - Given the controller endpoint is hit on a non-existent
workId, when
workRepository.findByIdreturnsnull, then the controller throwsNotFoundException('Work not found').
3. Functional Requirements
-
FR-1
CommunityPrProcessorService.processAllWorks(triggeredBy?)MUST iterate everyWorkreturned byworkRepository.findWithCommunityPrEnabled(), callprocessWork(work, state, autoClose, triggeredBy)for each, and aggregate results into{processed, errors:[]}. Errors per work MUST NOT abort the loop — they are accumulated. -
FR-2 Per-work exclusivity MUST be enforced via
DistributedTaskLockService.runExclusivewith keycommunity-pr:<workId>andttlMs: 30 * 60 * 1000(30 minutes). When the lock is held,runExclusiveMUST emit the configuredonLockedlog line atdebugand return{acquired: false, result: undefined}—processWorkMUST surface this aslockResult.result ?? 0. -
FR-3 PR discovery MUST use
gitFacade.listPullRequests(owner, mainRepo, {state:'open', perPage:100}, gitOptions)withgitOptions = {userId: work.userId, providerId: work.gitProvider, workId: work.id}. When zero open PRs are returned, the function MUST return0WITHOUT updatinglastProcessedAt. -
FR-4 A PR MUST be considered "already handled" when EITHER (a)
state.processedPrscontains an entry whosenumbermatches the PR AND whoseupdatedAtmatches the PR'supdatedAt, OR (b)state.processedPrsdoes NOT contain a matching number ANDstate.processedPrNumbersdoes. This means PRs that were processed by an older code path (whenprocessedPrsdid not exist) remain skip-listed, while NEW updates (differentupdatedAt) trigger re-processing. -
FR-5 When zero unprocessed PRs are found,
processWorkMUST return0WITHOUT updatinglastProcessedAt(consistent with FR-3 — there's nothing to record). -
FR-6 For each unprocessed open PR, the processor MUST:
- Pull the PR's file changes via
gitFacade.getPullRequestFiles(owner, mainRepo, pr.number, gitOptions). - Build a change-context string by concatenating
--- <filename> (<status>) ---\n<patch>\n\nfor each file, breaking the loop the FIRST time the running length plus the next entry would exceedMAX_CHANGE_CONTEXT_LENGTH(50 000). - Short-circuit to
{outcome:'ignored', itemsAdded:0}whenchangeContext.trim()is empty (no usable patches). - Clone-or-pull the work's data repo via
gitFacade.cloneOrPull({owner, repo: dataRepo}, gitOptions). - Read existing categories via
DataRepository.create(dest).getCategories()(with a.catch((): Category[] => [])fallback to empty list). - Build the extraction prompt via
buildExtractionPrompt(...)includingworkName,workDescription,categories,prTitle,prBody, and the truncatedprChanges. - Call
aiFacade.askJson(prompt, extractedItemSchema, {temperature: 0.3}, {userId: work.userId, workId: work.id}). - Iterate the resulting
items[]array and for each, generateslug = slugifyText(item.name). Items with empty slug, slugs already seen in this run (seenSlugs.has(slug)), or slugs already present in the data repo (await data.itemExists(slug)) MUST be silently skipped with alogger.warn. - For accepted items, call
data.createItemDir(itemData),data.writeItem(itemData), and write a markdown file viadata.writeItemMarkdown(itemData, markdown)wheremarkdownis# ${item.name}\n\n${item.description}\n\n[${item.source_url}](${item.source_url}). - When zero items were added (all skipped or all duplicate),
the PR MUST be marked
outcome: 'ignored'and the function MUST return WITHOUT git commit / push / comment / close. - When ≥1 items are added, the processor MUST
gitFacade.add(provider, dest, '.'),gitFacade.commit(provider, dest, 'Add N item(s) from community PR #X'), andgitFacade.push({dir: dest}, gitOptions).
- Pull the PR's file changes via
-
FR-7 When ≥1 items are added, the processor MUST fire-and-forget
recordCommunityPrHistoryto write aWorkGenerationHistoryrow withstatus: GENERATED,activityType: WorkHistoryActivityType.COMMUNITY_PR_MERGED,triggeredByfrom the caller,newItemsCount: entries.length, andchangelog: buildWorkChangelog(entries, 'Community PR #N merged: M item(s) added'). History-write failure MUST be caught + logged vialogger.warnWITHOUT failing the run. -
FR-8 When ≥1 items are added, the processor MUST attempt
gitFacade.createPullRequestComment(owner, mainRepo, pr.number, '<thank-you body with item list>', gitOptions). Comment failure MUST be caught + logged vialogger.warnWITHOUT failing the run. -
FR-9 When
autoClose === trueAND ≥1 items are added, the processor MUST attemptgitFacade.closePullRequest(owner, mainRepo, pr.number, gitOptions). Close failure MUST be caught + logged vialogger.warnWITHOUT failing the run. The auto-close gate uses the per-callautoCloseargument when defined, falling back towork.communityPrAutoClose. -
FR-10 After the per-PR loop ends, the processor MUST call
markPrHandled(state, pr, prResult.outcome)for each PR that succeeded — adding thepr.numbertoprocessedPrNumbers, pushing{number, updatedAt, outcome}intoprocessedPrs, and applying the 500-entry FIFO eviction to BOTH arrays. -
FR-11 After the per-PR loop ends, the processor MUST update the work via
workRepository.update(work.id, {communityPrState: currentState}). This write captureslastProcessedAt: <ISO now>,lastError: <message or null>, andtotalItemsAdded: <prev + thisRun>. State persistence is per-WORK (per-processWorkinvocation), NOT per-PR. -
FR-12 When the run added ≥1 items, the processor MUST also
workRepository.increment(work.id, 'itemsCount', totalItemsAdded)so the cached items-count on the work entity reflects the new rows. -
FR-13 The controller endpoint
POST /api/works/:id/process-community-prsMUST:- Run owner-access gate via
workQueryService.getWork(id, user). - Throw
NotFoundException('Work not found')whenworkRepository.findById(id)returnsnull. - Throw
BadRequestException('Community PR processing is not enabled for this work.')whenwork.communityPrEnabled === false. - Call
communityPrProcessorService.processWork(work)(no explicitstate/autoClose/triggeredBy— the service defaults to the work'scommunityPrAutoCloseandtriggeredBy: 'api'). - Invalidate caches via
invalidateWorkCaches(id). - Fire-and-forget activity-log entry via
activityLogService.log({...}).catch(()=>{})withactionType: ActivityActionType.COMMUNITY_PR_MERGED,action: 'community_pr.processed',status: ActivityStatus.COMPLETED,summary: 'Processed community PRs',details: {itemsAdded}. - Return
{itemsAdded}envelope.
- Run owner-access gate via
-
FR-14
CommunityPrSchedulerService.handleCommunityPrProcessingMUST be decorated with@Cron(CronExpression.EVERY_HOUR)and guarded by an outerrunExclusivelock keyedworks:community-pr-schedulerwithttlMs: 60 * 60 * 1000(1 hour). Errors duringprocessAllWorks()MUST be caught and logged viathis.logger.error('Error during community PR processing', stack)— the cron MUST NOT propagate the error. -
FR-15 The trigger source value
triggeredByis one of'user' | 'schedule' | 'api':'schedule'— set byCommunityPrSchedulerService.processAllWorks()(the scheduler's default).'api'— set byCommunityPrProcessorService.processWorkwhen called without an explicittriggeredBy(i.e. the controller path).'user'— reserved for direct user-driven runs (currently unused ondevelop; reserved for a future "run now" action against a specific PR).
4. Non-Functional Requirements
- Performance: a single processing run handles up to 100 open
PRs per work per invocation (the
perPage: 100cap onlistPullRequests). TheMAX_CHANGE_CONTEXT_LENGTHcap (50 000 chars) keeps AI prompt size bounded. The data-repo clone is re-used across all PRs in a singleprocessWorkcall —cloneOrPullis the slow path; subsequent operations on the samedestare local writes. - Reliability: per-work mutex with 30-minute TTL prevents
duplicate processing across replicas. The scheduler's outer
lock (1-hour TTL) prevents thundering-herd cron runs. State
persistence at the work level (after each
processWork) means a process crash mid-batch loses ONLY the in-flight PR — already applied items remain in the data repo and the lock TTL releases the work for the next scheduled run. - Security: PR processing uses the work owner's git tokens
resolved via
gitFacadefrom the configured git-provider plugin (typically thegithubplugin's OAuth token or the platform's GitHub App installation token). Tokens are never logged. The AI provider plugin is selected by the work'saiFacade.askJson(prompt, schema, options, {userId, workId})context — the work's plugin settings determine the provider. - Observability:
- Per-work outcome via
recordCommunityPrHistorywrites awork_generation_historyrow (activityType: COMMUNITY_PR_MERGED). - Controller path additionally emits an
activity_logsrow (actionType: COMMUNITY_PR_MERGED,action: 'community_pr.processed'). - Scheduler logs
'Starting community PR processing'and'Community PR processing completed: <n> processed, <m> errors'. - Per-PR errors →
logger.error('Failed to process PR #N for work W: <msg>', stack). - Lock contention →
logger.debug('Skipping community PR processing for work W because another instance is already processing it').
- Per-work outcome via
- Cost: each PR triggers one
aiFacade.askJsoncall attemperature: 0.3against the work's configured AI provider. Cost tracking is the AI provider's responsibility (the plugin reports usage); there is no global budget enforcement at the community-PR layer.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
CommunityPrState (entities/types.ts) | {processedPrNumbers: number[]; processedPrs?: Array<{number, updatedAt, outcome}>; lastProcessedAt?: string; totalItemsAdded?: number; lastError?: string | null;} |
CommunityPrSinglePrResult | {outcome: 'applied' | 'ignored'; itemsAdded: number} — the per-PR contract. |
CommunityPrTriggerSource | 'user' | 'schedule' | 'api'. Differs from the original spec's three-value enum. |
CommunityPrProcessorService | Drives per-work + per-PR processing. Owned by @ever-works/agent/community-pr. |
CommunityPrSchedulerService | Hourly cron at apps/api/src/works/tasks/community-pr-scheduler.service.ts. Wraps processAllWorks() in an outer lock + try/catch. |
extractedItemSchema | z.object({items: z.array(z.object({name, description, source_url, category, tags: z.array(z.string())}))}). |
MAX_PROCESSED_PR_NUMBERS | 500 — FIFO eviction cap on both processedPrNumbers and processedPrs. |
MAX_CHANGE_CONTEXT_LENGTH | 50 000 — char cap on the diff context fed to the AI prompt. |
COMMUNITY_PR_LOCK_TTL_MS | 30 minutes — per-work community-pr:<workId> lock TTL. |
community-pr:<workId> / works:community-pr-scheduler | Lock keys (per-work and outer scheduler). |
WorkHistoryActivityType.COMMUNITY_PR_MERGED | work_generation_history activity-type stamp for community-PR runs. |
ActivityActionType.COMMUNITY_PR_MERGED | activity_logs action-type stamp emitted ONLY by the controller path (the service does NOT emit activity-log entries directly). |
6. Out of Scope
- Inviting users to fix invalid PRs — there is no
"invalid item" comment path. The AI's response is either
items: [](treated as a no-op'ignored') or a populated list. Items that fail slug validation are silently dropped — the PR contributor sees only the thank-you comment for the items that DID land. - Multi-repo processing — one main repo per work is assumed;
work.getMainRepo()returns a single string. - Cross-work batching — each work is processed independently
in its own
processWorkinvocation; there is no cross-work parallelism withinprocessAllWorks(it's a serialforloop). - AI cost budgeting — the layer relies on the AI provider plugin's own usage reporting; there is no global cap or kill- switch when monthly spend exceeds a threshold.
- PR review comments / inline annotations — only
pull-request-level comments via
createPullRequestCommentare supported. Per-line review comments are out of scope. - Non-GitHub providers — the implementation calls
gitFacade.*, which routes through whichever git provider the work uses. GitLab / Bitbucket support is automatic IF those plugin implementations expose the samelistPullRequests/getPullRequestFiles/createPullRequestComment/closePullRequestcapabilities. Today only thegithubplugin implements them fully.
7. Acceptance Criteria
- Two concurrent
processWorkcalls against the same work yield ONE effective execution; the loser exits with0. - PRs already in
processedPrswith matchingupdatedAtare skipped on re-runs. - PRs in
processedPrswith newerupdatedAtARE re-processed. - AI returning
items: []results inoutcome: 'ignored', no PR comment, no items added. - Items with duplicate slugs (within-run OR existing in repo)
are silently skipped with a
logger.warn. - State persistence captures
lastProcessedAt,lastError, andtotalItemsAddedafter each work-level run. -
itemsCountis incremented atomically when ≥1 items are added. - Controller endpoint enforces ownership +
communityPrEnabledgate before invoking the service. - Controller emits the
COMMUNITY_PR_MERGEDactivity-log entry as a fire-and-forget side effect. - Scheduler runs hourly, guarded by the outer
works:community-pr-schedulerlock with 1-hour TTL. - Tests cover: lock contention; unchanged-PR skip; updated-PR reprocess; AI-empty-items ignore; duplicate-slug skip; history-write failure swallowing; comment + close failure swallowing.
8. Open Questions
[NEEDS CLARIFICATION: OQ-1]Spec FR-7 vs FR-8 of the original spec talked about "invalid items" PR comments. The current implementation has NO such path — all AI failures are silent and surface only aslastErroron the work'scommunityPrState. Should the contributor see a comment ("Couldn't extract items, please ensure your PR adds entries in") on a failed extraction? That would require a new error class to distinguish AI failures from infrastructure failures. [NEEDS CLARIFICATION: OQ-2]triggeredBy: 'user'is reserved but unused. If we never wire a "run-now-on-this-PR" UI, the enum should drop the value to avoid drift.[NEEDS CLARIFICATION: OQ-3]recordCommunityPrHistoryalways setsstatus: GenerateStatusType.GENERATED— even when zero items were ultimately added (because the per-PR loop doesn't reachrecordCommunityPrHistoryin that case). Should there also be anIGNOREDhistory entry for visibility into PRs the AI declined to extract from?[NEEDS CLARIFICATION: OQ-4]TheprocessedPrNumberslegacy array is still maintained alongsideprocessedPrs. Now thatprocessedPrscarries strictly more information, the legacy array is dead code at read-time but still written. A future follow-up should drop it from the schema once we're confident no historical data depends on it.[NEEDS CLARIFICATION: OQ-5]Per-PR cost / token usage isn't surfaced anywhere visible to the work owner. A per-PR row inwork_generation_history(or a richerdetailsjsonb) could capture this so owners can see what the community PR pipeline is costing them.[NEEDS CLARIFICATION: OQ-6]The 30-minute per-work lock TTL vs the 1-hour scheduler lock TTL means that an extreme outlier work whose processing exceeds 30 minutes could be picked up by the next scheduler run while the first is still running. The scheduler-side outer lock prevents that across replicas, but therunExclusivefor the work itself uses Postgres-backed TTLs that DON'T renew. Long runs should either renew the lock mid-loop or be explicitly time-boxed.
9. Constitution Gates
- I (Plugin-first): AI extraction and git operations both
go through the agent-package facades (
AiFacadeServiceandGitFacadeService), which dispatch to whatever AI / git provider plugin the work has enabled. - II (Capability-driven):
aiFacade.askJsonresolves the AI provider via the work's configuredai-providercapability;gitFacade.*resolves the git provider via the work'sgitProviderfield. - III (Source-of-truth repos): items are merged into the
user's data repo (a separate repo from the main repo where
the PR was opened); the platform DB stores only the
bookkeeping state (
communityPrState,work_generation_history). - IV (Trigger.dev for long work): in-process coordination
via
DistributedTaskLockService(Postgres-backed advisory locks). The hourly cron is intentionally lightweight; for true long-running batches, future enhancements would pushprocessWorkinto Trigger.dev. - V (Forward-only migrations):
work.communityPrStateis a jsonb column added via additive migration. TheprocessedPrsfield was added later; pre-existing rows load withprocessedPrs: undefinedand the code falls back toprocessedPrNumbers. - VI (Tests): covered in
packages/agent/src/community-pr/__tests__/community-pr-processor.service.spec.tsandapps/api/src/works/tasks/community-pr-scheduler.service.spec.ts, plus the controller-level surface inapps/api/src/works/works.controller.comparisons-misc.spec.ts(3 tests pinning the not-found / not-enabled / happy-path branches). - VII (Secrets via
x-secret): GitHub tokens are loaded viagitFacadefrom the work's plugin settings; never logged, never exposed in responses. - VIII (Plugin counts in canonical doc): N/A — not a plugin.
- IX (Behaviour-first spec): this spec describes observable behaviour only.
- X (Backwards-compatible): state schema is additive; old
works without
communityPrStatedefault to{processedPrNumbers: [], totalItemsAdded: 0}via the||fallback inprocessWork.
10. References
- User-facing doc:
../../../features/community-pr-processing.md - Internal architecture:
../../../agent-services/community-pr-service.md - Implementation:
- Lock primitive:
../../../agent-services/distributed-task-lock.md - Related specs:
scheduled-updates— same lock primitive and cron pattern.activity-log— the audit-trail that the controller path emits to.work-changelog— thebuildWorkChangeloghelper consumed byrecordCommunityPrHistory.