Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions migrations/0053_cross_repo_opportunity_alert_filters.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Cross-repo opportunity discovery (#1060): extend issue-watch subscriptions so the existing opt-in

Check notice on line 1 in migrations/0053_cross_repo_opportunity_alert_filters.sql

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
-- notification channel can filter proactive opportunity alerts by repo lane and freshness window.
--
-- `lanes_json` stores the allowed repo lanes ([] = any lane). `freshness_days` stores the maximum issue
-- age in days to notify on (NULL = any age). Both fields are additive + nullable/defaulted so existing
-- subscriptions keep their current semantics.
ALTER TABLE issue_watch_subscriptions ADD COLUMN lanes_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE issue_watch_subscriptions ADD COLUMN freshness_days INTEGER;
1 change: 1 addition & 0 deletions packages/gittensory-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@
gittensory-mcp agent plan --login jsonbored --repo we-promise/sure --json
gittensory-mcp agent packet --login jsonbored --repo we-promise/sure --base origin/main --json
```

Check notice on line 139 in packages/gittensory-mcp/README.md

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
The same capabilities are exposed to MCP clients as:

- `gittensory_find_opportunities`
- `gittensory_agent_plan_next_work`
- `gittensory_agent_start_run`
- `gittensory_agent_get_run`
Expand Down
26 changes: 21 additions & 5 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,31 +1351,47 @@
return rows.map(toNotificationSubscriptionRecord);
}

// ─── Issue-watch subscriptions (#699 path B) ─────────────────────────────────────────────────────────

Check notice on line 1354 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

function toIssueWatchSubscription(row: typeof issueWatchSubscriptions.$inferSelect): IssueWatchSubscription {
return { login: row.login, repoFullName: row.repoFullName, labels: parseJson<string[]>(row.labelsJson, []), createdAt: row.createdAt, updatedAt: row.updatedAt };
return {
login: row.login,
repoFullName: row.repoFullName,
labels: parseJson<string[]>(row.labelsJson, []),
lanes: parseJson<IssueWatchSubscription["lanes"]>(row.lanesJson, []),
freshnessDays: row.freshnessDays,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}

/** Subscribe a miner to a repo's new grabbable issues; idempotent on (login, repo) — re-subscribing just
* updates the label filter. `login`, `repoFullName`, and `labels` ([]=any) are all lowercased so matching
* is case-insensitive: GitHub repo names are case-insensitive, and the delivery lookup keys off the
* webhook's canonical `repository.full_name`, so a watch stored under a different casing must still match. */
export async function upsertIssueWatchSubscription(env: Env, input: { login: string; repoFullName: string; labels?: string[] | undefined }): Promise<IssueWatchSubscription> {
export async function upsertIssueWatchSubscription(
env: Env,
input: { login: string; repoFullName: string; labels?: string[] | undefined; lanes?: IssueWatchSubscription["lanes"] | undefined; freshnessDays?: number | null | undefined },
): Promise<IssueWatchSubscription> {
const db = getDb(env.DB);
const login = input.login.toLowerCase();
const repoFullName = input.repoFullName.toLowerCase();
const labels = [...new Set((input.labels ?? []).map((label) => label.toLowerCase().trim()).filter(Boolean))];
const lanes = [...new Set((input.lanes ?? []).map((lane) => lane.trim().toLowerCase()).filter(Boolean))] as IssueWatchSubscription["lanes"];
const freshnessDays = typeof input.freshnessDays === "number" && Number.isFinite(input.freshnessDays) ? Math.max(1, Math.floor(input.freshnessDays)) : null;
await db
.insert(issueWatchSubscriptions)
.values({ id: crypto.randomUUID(), login, repoFullName, labelsJson: jsonString(labels), updatedAt: nowIso() })
.onConflictDoUpdate({ target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], set: { labelsJson: jsonString(labels), updatedAt: nowIso() } });
.values({ id: crypto.randomUUID(), login, repoFullName, labelsJson: jsonString(labels), lanesJson: jsonString(lanes), freshnessDays, updatedAt: nowIso() })
.onConflictDoUpdate({
target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName],
set: { labelsJson: jsonString(labels), lanesJson: jsonString(lanes), freshnessDays, updatedAt: nowIso() },
});
const [row] = await db
.select()
.from(issueWatchSubscriptions)
.where(and(eq(issueWatchSubscriptions.login, login), eq(issueWatchSubscriptions.repoFullName, repoFullName)));
/* v8 ignore next -- the row always exists immediately after the upsert above; the literal is a type-safety fallback. */
return row ? toIssueWatchSubscription(row) : { login, repoFullName, labels };
return row ? toIssueWatchSubscription(row) : { login, repoFullName, labels, lanes, freshnessDays };
}

export async function listIssueWatchSubscriptionsForLogin(env: Env, login: string): Promise<IssueWatchSubscription[]> {
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,9 +930,11 @@
"issue_watch_subscriptions",
{
id: text("id").primaryKey(),
login: text("login").notNull(),

Check notice on line 933 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
repoFullName: text("repo_full_name").notNull(),
labelsJson: text("labels_json").notNull().default("[]"),
lanesJson: text("lanes_json").notNull().default("[]"),
freshnessDays: integer("freshness_days"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
},
Expand Down
84 changes: 79 additions & 5 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
countOpenIssues,
countPendingAgentActions,
countOpenPullRequests,
createPendingAgentActionIfAbsent,

Check notice on line 13 in src/mcp/server.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
getBounty,
listBountiesByRepo,
listAllIssues,
getContributorEvidence,
getLatestRepoGithubTotalsSnapshot,
getInstallation,
Expand Down Expand Up @@ -69,6 +70,7 @@
import { buildMcpClientTelemetry } from "../services/client-telemetry";
import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import { buildOpportunityDiscoveryResult } from "../services/opportunity-discovery";
import {
applyMcpPlanningChoices,
buildMcpPlanningElicitationAudit,
Expand Down Expand Up @@ -714,13 +716,37 @@
action: z.enum(["watch", "unwatch", "list"]).default("list"),
repoFullName: z.string().min(3).max(200).optional(),
labels: z.array(z.string().min(1).max(100)).max(50).optional(),
lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])).max(10).optional(),
freshnessDays: z.number().int().positive().max(365).optional(),
};

const watchIssuesOutputSchema = {
watching: z.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()) })).optional(),
watching: z
.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()), lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])), freshnessDays: z.number().int().nullable().optional() }))
.optional(),
changed: z.string().optional(),
};

const findOpportunitiesShape = {
login: z.string().min(1),
limit: z.number().int().positive().max(25).optional(),
labels: z.array(z.string().min(1).max(100)).max(50).optional(),
lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])).max(10).optional(),
freshnessDays: z.number().int().positive().max(365).optional(),
};

const findOpportunitiesOutputSchema = {
login: z.string().optional(),
generatedAt: z.string().optional(),
freshness: z.string().optional(),
summary: z.string().optional(),
filters: z.unknown().optional(),
opportunities: z.unknown().optional(),
status: z.string().optional(),
reason: z.string().optional(),
rebuildEnqueued: z.boolean().optional(),
};

const explainRepoDecisionOutputSchema = {
status: z.string().optional(),
login: z.string().optional(),
Expand Down Expand Up @@ -1127,13 +1153,23 @@
"gittensory_watch_issues",
{
description:
"Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP). action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your watches. When a matching issue opens you're notified via gittensory_list_notifications. Self-scoped to the authenticated login.",
"Watch repos for proactive issue alerts. action=watch subscribes a repo with optional lane, label, and freshness filters; unwatch removes it; list (default) returns your watches. Matching new, aging, and newly-prioritized issues notify via gittensory_list_notifications. Self-scoped to the authenticated login.",
inputSchema: watchIssuesShape,
outputSchema: watchIssuesOutputSchema,
},
async (input) => this.toolResult(await this.watchIssues(input)),
);

server.registerTool(
"gittensory_find_opportunities",
{
description: "Return a deterministic cross-repo shortlist of the best issues to build right now, filtered by lane, label, and freshness. Metadata only; no raw reward or score exposure.",
inputSchema: findOpportunitiesShape,
outputSchema: findOpportunitiesOutputSchema,
},
async (input) => this.toolResult(await this.findOpportunities(input)),
);

server.registerTool(
"gittensory_explain_repo_decision",
{
Expand Down Expand Up @@ -2029,20 +2065,58 @@
if (!input.repoFullName) return { summary: `${input.action} requires repoFullName.`, data: {} };
await this.requireWatchableRepo(input.login, input.repoFullName);
if (input.action === "watch") {
await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels });
changed = `watching ${input.repoFullName}${input.labels && input.labels.length > 0 ? ` (labels: ${input.labels.join(", ")})` : ""}`;
await upsertIssueWatchSubscription(this.env, {
login: input.login,
repoFullName: input.repoFullName,
labels: input.labels,
lanes: input.lanes,
freshnessDays: input.freshnessDays,
});
const filters = [
input.labels && input.labels.length > 0 ? `labels: ${input.labels.join(", ")}` : null,
input.lanes && input.lanes.length > 0 ? `lanes: ${input.lanes.join(", ")}` : null,
input.freshnessDays ? `freshness <= ${input.freshnessDays}d` : null,
].filter(Boolean);
changed = `watching ${input.repoFullName}${filters.length > 0 ? ` (${filters.join("; ")})` : ""}`;
} else {
const removed = await deleteIssueWatchSubscription(this.env, input.login, input.repoFullName);
changed = removed ? `unwatched ${input.repoFullName}` : `was not watching ${input.repoFullName}`;
}
}
const watching = (await listIssueWatchSubscriptionsForLogin(this.env, input.login)).map((sub) => ({ repoFullName: sub.repoFullName, labels: sub.labels }));
const watching = (await listIssueWatchSubscriptionsForLogin(this.env, input.login)).map((sub) => ({
repoFullName: sub.repoFullName,
labels: sub.labels,
lanes: sub.lanes,
...(sub.freshnessDays !== undefined ? { freshnessDays: sub.freshnessDays } : {}),
}));
return {
summary: `Watching ${watching.length} repo(s) for new grabbable issues${changed ? ` (${changed})` : ""}.`,
data: { watching, ...(changed ? { changed } : {}) } as unknown as Record<string, unknown>,
};
}

private async findOpportunities(input: z.infer<z.ZodObject<typeof findOpportunitiesShape>>): Promise<ToolPayload> {
this.requireContributorAccess(input.login);
const serving = await loadContributorDecisionPackForServing(this.env, input.login);
if (serving.kind === "needs_refresh") {
return {
summary: `Cross-repo opportunity shortlist for ${input.login} needs a decision-pack refresh.`,
data: serving.refresh as unknown as Record<string, unknown>,
};
}
const issues = await listAllIssues(this.env);
const result = buildOpportunityDiscoveryResult(serving.pack, issues, {
limit: input.limit,
labels: input.labels,
lanes: input.lanes,
freshnessDays: input.freshnessDays,
});
return {
summary: `Cross-repo opportunity shortlist for ${input.login}.`,
data: result as unknown as Record<string, unknown>,
};
}

private async markNotificationsRead(login: string, ids?: string[]): Promise<ToolPayload> {
this.requireContributorAccess(login);
const marked = await markNotificationDeliveriesRead(this.env, login, ids);
Expand Down
29 changes: 24 additions & 5 deletions src/notifications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
getRepository,
insertNotificationDeliveryIfAbsent,
listIssueWatchersForRepo,
listNotificationSubscriptionsForLogin,

Check notice on line 8 in src/notifications/service.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
markNotificationDeliveryDelivered,
} from "../db/repositories";
import { isGrabbableHighMultiplierIssue } from "../signals/engine";
import { buildLaneAdvice, isGrabbableHighMultiplierIssue } from "../signals/engine";
import { canLoginAccessRepo } from "../services/control-panel-roles";
import type { DetectedNotificationEvent, IssueRecord, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types";
import { nowIso } from "../utils/json";
Expand Down Expand Up @@ -48,6 +48,18 @@
// `pullNumber` field carries the ISSUE number. Public-safe — "open to grab" framing, never raw reward/score.
export function buildIssueWatchNotification(event: DetectedNotificationEvent): { title: string; body: string } {
const ref = `${event.repoFullName}#${event.pullNumber}`;
if (event.trigger === "reprioritized") {
return {
title: sanitizePublicComment(`Best issue right now on ${ref}`),
body: sanitizePublicComment(`A watched issue on ${ref} moved into your strongest current cross-repo shortlist. Re-check it now if you want the best current fit from your lane and queue context.`),
};
}
if (event.trigger === "aging") {
return {
title: sanitizePublicComment(`Aging issue worth revisiting on ${ref}`),
body: sanitizePublicComment(`A watched issue on ${ref} is still a strong current fit and has aged in the queue. Re-check it now if you want a mature target that still lines up with your filters.`),
};
}
return {
title: sanitizePublicComment(`New issue to grab on ${ref}`),
body: sanitizePublicComment(`A new maintainer-created issue opened on ${ref} that is open for you to grab. Maintainer-created issues are strong early targets on ${event.repoFullName} — claim it to line up your next contribution.`),
Expand Down Expand Up @@ -78,9 +90,14 @@
const detectedAt = nowIso();
const issueLabels = new Set(issue.labels.map((label) => label.toLowerCase().trim()));
const authorLogin = issue.authorLogin?.toLowerCase();
const issueAgeDays = (() => {
const parsed = Date.parse(issue.createdAt ?? issue.updatedAt ?? "");
return Number.isFinite(parsed) ? Math.max(0, Math.floor((Date.now() - parsed) / 86_400_000)) : 0;
})();
const matching = watchers
// An empty label filter matches any issue; otherwise at least one watched label must be present.
.filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label)))
.filter((watcher) => !watcher.freshnessDays || issueAgeDays <= watcher.freshnessDays)
// Don't ping the maintainer who opened the issue about their own issue.
.filter((watcher) => watcher.login.toLowerCase() !== authorLogin);

Expand All @@ -89,15 +106,17 @@
// reach a non-collaborator. The repo is the same for all watchers, so resolve it once and only pay the
// per-watcher access check on the private path.
const repo = await getRepository(env, repoFullName);
const repoLane = buildLaneAdvice(repo, repoFullName).lane;
const authorizedWatchers =
repo && !repo.isPrivate
? matching
: (await Promise.all(matching.map(async (watcher) => ((repo && (await canLoginAccessRepo(env, watcher.login, repoFullName))) ? watcher : null)))).filter(
(watcher) => watcher !== null,
);
? matching.filter((watcher) => watcher.lanes.length === 0 || watcher.lanes.includes(repoLane))
: (
await Promise.all(matching.map(async (watcher) => ((repo && (await canLoginAccessRepo(env, watcher.login, repoFullName))) ? watcher : null)))
).filter((watcher): watcher is NonNullable<typeof watcher> => watcher !== null && (watcher.lanes.length === 0 || watcher.lanes.includes(repoLane)));

return authorizedWatchers.map((watcher) => ({
eventType: "issue_watch_match" as const,
trigger: "opened" as const,
recipientLogin: watcher.login,
repoFullName,
pullNumber: issue.number, // carries the ISSUE number for this eventType
Expand Down
5 changes: 4 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@
import { ALL_TYPE_LABELS, resolvePrTypeLabel } from "../settings/pr-type-label";
import { fetchPublicContributorProfile } from "../github/public";
import { refreshRegistry } from "../registry/sync";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory";

Check notice on line 95 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.
import { detectNotificationEvents } from "../notifications/events";
import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service";
import { detectDecisionPackOpportunityEvents } from "../services/opportunity-discovery";
import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model";
import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } from "../services/decision-pack";
import {
Expand Down Expand Up @@ -372,7 +373,9 @@
const shared = await loadDecisionPackSharedInputs(env);
for (const contributorLogin of logins) {
try {
await buildAndPersistContributorDecisionPack(env, contributorLogin, shared);
const pack = await buildAndPersistContributorDecisionPack(env, contributorLogin, shared);
const opportunityEvents = await detectDecisionPackOpportunityEvents(env, pack, shared.allIssues);
await Promise.all(opportunityEvents.map((event) => env.JOBS.send({ type: "notify-evaluate", requestedBy: "api", event })));
} catch (error) {
// Isolate per-login failures so one bad login can't fail the whole batch (which would re-run
// from the first login on retry and poison-pill the queue) (#787).
Expand Down
Loading
Loading