Skip to content
Merged
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
151 changes: 151 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7394,6 +7394,43 @@
"items": {
"$ref": "#/components/schemas/Finding"
}
},
"source": {
"type": "object",
"properties": {
"sourceUrl": {
"type": "string",
"nullable": true
},
"discoveredAt": {
"type": "string",
"nullable": true
},
"updatedAt": {
"type": "string",
"nullable": true
},
"observedAt": {
"type": "string",
"nullable": true
},
"ageDays": {
"type": "number",
"nullable": true
},
"freshness": {
"type": "string",
"enum": [
"fresh",
"stale",
"unknown"
]
}
},
"required": [
"ageDays",
"freshness"
]
}
},
"required": [
Expand All @@ -7405,6 +7442,7 @@
"isActiveOpportunity",
"fundingStatus",
"consensusRisk",
"source",
"linkedPrs",
"findings"
]
Expand Down Expand Up @@ -10991,6 +11029,119 @@
"items": {
"type": "string"
}
},
"bounty": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"lifecycle": {
"type": "string",
"enum": [
"active",
"historical",
"completed",
"cancelled",
"stale",
"ambiguous",
"unknown"
]
},
"isActiveOpportunity": {
"type": "boolean"
},
"fundingStatus": {
"type": "string",
"enum": [
"funded",
"target_only",
"unknown"
]
},
"consensusRisk": {
"type": "string",
"enum": [
"low",
"medium",
"high"
]
},
"source": {
"type": "object",
"properties": {
"sourceUrl": {
"type": "string",
"nullable": true
},
"discoveredAt": {
"type": "string",
"nullable": true
},
"updatedAt": {
"type": "string",
"nullable": true
},
"observedAt": {
"type": "string",
"nullable": true
},
"ageDays": {
"type": "number",
"nullable": true
},
"freshness": {
"type": "string",
"enum": [
"fresh",
"stale",
"unknown"
]
}
},
"required": [
"ageDays",
"freshness"
]
},
"linkedPrs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": {
"type": "number"
},
"state": {
"type": "string",
"enum": [
"open",
"closed",
"merged",
"unknown"
]
},
"isActive": {
"type": "boolean"
}
},
"required": [
"number",
"state",
"isActive"
]
}
}
},
"required": [
"id",
"lifecycle",
"isActiveOpportunity",
"fundingStatus",
"consensusRisk",
"source",
"linkedPrs"
]
}
},
"required": [
Expand Down
35 changes: 28 additions & 7 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,31 @@ export const BountySchema = z
})
.openapi("Bounty");

const BountySourceContextSchema = z.object({
sourceUrl: z.string().nullable().optional(),
discoveredAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
observedAt: z.string().nullable().optional(),
ageDays: z.number().nullable(),
freshness: z.enum(["fresh", "stale", "unknown"]),
});

const BountyLinkedPrSchema = z.object({
number: z.number(),
state: z.enum(["open", "closed", "merged", "unknown"]),
isActive: z.boolean(),
});

const BountyOpportunityContextSchema = z.object({
id: z.string(),
lifecycle: z.enum(["active", "historical", "completed", "cancelled", "stale", "ambiguous", "unknown"]),
isActiveOpportunity: z.boolean(),
fundingStatus: z.enum(["funded", "target_only", "unknown"]),
consensusRisk: z.enum(["low", "medium", "high"]),
source: BountySourceContextSchema,
linkedPrs: z.array(BountyLinkedPrSchema),
});

export const BountyAdvisorySchema = z
.object({
id: z.string(),
Expand All @@ -485,13 +510,8 @@ export const BountyAdvisorySchema = z
isActiveOpportunity: z.boolean(),
fundingStatus: z.enum(["funded", "target_only", "unknown"]),
consensusRisk: z.enum(["low", "medium", "high"]),
linkedPrs: z.array(
z.object({
number: z.number(),
state: z.enum(["open", "closed", "merged", "unknown"]),
isActive: z.boolean(),
}),
),
source: BountySourceContextSchema,
linkedPrs: z.array(BountyLinkedPrSchema),
findings: z.array(FindingSchema),
})
.openapi("BountyAdvisory");
Expand Down Expand Up @@ -1286,6 +1306,7 @@ export const IssueQualityReportSchema = z
warnings: z.array(z.string()),
})
.optional(),
bounty: BountyOpportunityContextSchema.optional(),
status: z.enum(["ready", "needs_proof", "hold", "do_not_use"]),
score: z.number(),
reasons: z.array(z.string()),
Expand Down
80 changes: 76 additions & 4 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,25 @@ export type BountyLinkedPr = {
isActive: boolean;
};

export type BountySourceContext = {
sourceUrl?: string | null | undefined;
discoveredAt?: string | null | undefined;
updatedAt?: string | null | undefined;
observedAt?: string | null | undefined;
ageDays: number | null;
freshness: "fresh" | "stale" | "unknown";
};

export type BountyOpportunityContext = {
id: string;
lifecycle: BountyLifecycle;
isActiveOpportunity: boolean;
fundingStatus: "funded" | "target_only" | "unknown";
consensusRisk: "low" | "medium" | "high";
source: BountySourceContext;
linkedPrs: BountyLinkedPr[];
};

export type BountyAdvisory = {
id: string;
repoFullName: string;
Expand All @@ -506,6 +525,7 @@ export type BountyAdvisory = {
isActiveOpportunity: boolean;
fundingStatus: "funded" | "target_only" | "unknown";
consensusRisk: "low" | "medium" | "high";
source: BountySourceContext;
linkedPrs: BountyLinkedPr[];
findings: SignalFinding[];
};
Expand Down Expand Up @@ -541,6 +561,7 @@ export type IssueQualityReport = {
title: string;
lifecycle?: IssueDiscoveryLifecycleState | undefined;
linkage?: IssueLinkageRecord | undefined;
bounty?: BountyOpportunityContext | undefined;
status: "ready" | "needs_proof" | "hold" | "do_not_use";
score: number;
reasons: string[];
Expand Down Expand Up @@ -2666,6 +2687,7 @@ export function buildIssueQualityReport(
const bodyLength = issue.body?.trim().length ?? 0;
const bounty = bountyByIssue.get(bountyIssueKey(fullName, issue.number)) ?? null;
const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null;
const bountyContext = bounty ? buildBountyOpportunityContext(bounty, issue, linkedPrs, linkedMergedPrs) : undefined;
const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length;
const linkage = buildIssueLinkageRecord(issue, lifecycleEntry, linkedPrs, linkedMergedPrs);
const reasons = [
Expand Down Expand Up @@ -2700,7 +2722,7 @@ export function buildIssueQualityReport(
: score < 45
? "hold"
: "ready";
return { number: issue.number, title: issue.title, lifecycle, linkage, status, score, reasons, warnings };
return { number: issue.number, title: issue.title, lifecycle, linkage, bounty: bountyContext, status, score, reasons, warnings };
})
.sort((left, right) => right.score - left.score || left.number - right.number);
return {
Expand Down Expand Up @@ -3173,20 +3195,67 @@ export function isHistoricalBountyLifecycle(lifecycle: BountyLifecycle): boolean
return lifecycle === "historical" || lifecycle === "completed" || lifecycle === "cancelled";
}

function buildBountyLinkedPrs(issue: IssueRecord | null, pullRequests: PullRequestRecord[]): BountyLinkedPr[] {
function buildBountySourceContext(bounty: BountyRecord): BountySourceContext {
const observedAt = bounty.updatedAt ?? bounty.discoveredAt ?? null;
const ageDays = observedAt ? daysSince(observedAt) : null;
return {
sourceUrl: bounty.sourceUrl ?? null,
discoveredAt: bounty.discoveredAt ?? null,
updatedAt: bounty.updatedAt ?? null,
observedAt,
ageDays,
freshness: ageDays === null ? "unknown" : ageDays > BOUNTY_STALE_DAYS ? "stale" : "fresh",
};
}

function buildBountyLinkedPrs(
issue: IssueRecord | null,
pullRequests: PullRequestRecord[],
recentMergedPullRequests: RecentMergedPullRequestRecord[] = [],
): BountyLinkedPr[] {
if (!issue) return [];
const linkedNumbers = new Set<number>(issue.linkedPrs);
for (const pr of pullRequests) {
if (pr.linkedIssues.includes(issue.number)) linkedNumbers.add(pr.number);
}
for (const pr of recentMergedPullRequests) {
if (pr.linkedIssues.includes(issue.number)) linkedNumbers.add(pr.number);
}
const byNumber = new Map(pullRequests.map((pr) => [pr.number, pr]));
const recentMergedByNumber = new Set(recentMergedPullRequests.map((pr) => pr.number));
return [...linkedNumbers].sort((left, right) => left - right).map((number) => {
const pr = byNumber.get(number);
const state: BountyLinkedPr["state"] = !pr ? "unknown" : pr.mergedAt ? "merged" : pr.state === "open" ? "open" : "closed";
const state: BountyLinkedPr["state"] = recentMergedByNumber.has(number)
? "merged"
: !pr
? "unknown"
: pr.mergedAt
? "merged"
: pr.state === "open"
? "open"
: "closed";
return { number, state, isActive: state === "open" };
});
}

function buildBountyOpportunityContext(
bounty: BountyRecord,
issue: IssueRecord | null,
pullRequests: PullRequestRecord[] = [],
recentMergedPullRequests: RecentMergedPullRequestRecord[] = [],
): BountyOpportunityContext {
const advisory = buildBountyAdvisory(bounty, null, issue, pullRequests, recentMergedPullRequests);
return {
id: advisory.id,
lifecycle: advisory.lifecycle,
isActiveOpportunity: advisory.isActiveOpportunity,
fundingStatus: advisory.fundingStatus,
consensusRisk: advisory.consensusRisk,
source: advisory.source,
linkedPrs: advisory.linkedPrs,
};
}

/**
* Bounty/issue consensus risk derived from linked PR STATE, not raw count, so historical or closed
* attempts are never scored the same as multiple active open PRs:
Expand Down Expand Up @@ -3214,13 +3283,15 @@ export function buildBountyAdvisory(
repo: RepositoryRecord | null,
issue: IssueRecord | null,
pullRequests: PullRequestRecord[] = [],
recentMergedPullRequests: RecentMergedPullRequestRecord[] = [],
): BountyAdvisory {
const lifecycle = classifyBountyLifecycle(bounty, issue);
const target = bounty.payload.target_bounty ?? bounty.payload.target_alpha;
const amount = bounty.payload.bounty_amount ?? bounty.payload.bounty_alpha;
/* v8 ignore next -- Unknown funding is a sparse-cache fallback; funded and target-only states are covered. */
const fundingStatus = amount && amount !== 0 && amount !== "0.0000" ? "funded" : target ? "target_only" : "unknown";
const linkedPrs = buildBountyLinkedPrs(issue, pullRequests);
const source = buildBountySourceContext(bounty);
const linkedPrs = buildBountyLinkedPrs(issue, pullRequests, recentMergedPullRequests);
const findings: SignalFinding[] = [];
if (lifecycle === "completed") {
findings.push({
Expand Down Expand Up @@ -3324,6 +3395,7 @@ export function buildBountyAdvisory(
isActiveOpportunity: lifecycle === "active",
fundingStatus,
consensusRisk: computeBountyConsensusRisk(lifecycle, issue, openLinkedPrs.length, mergedLinkedPrs.length, closedLinkedPrs.length, unknownLinkedPrs.length),
source,
linkedPrs,
findings,
};
Expand Down
Loading