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
18 changes: 18 additions & 0 deletions apps/admin/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4530,6 +4530,24 @@
],
"title": "Account Id"
},
"account_tier": {
"anyOf": [
{
"enum": [
"bronze",
"silver",
"gold",
"platinum",
"diamond"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Account Tier"
},
"ai_enabled": {
"title": "Ai Enabled",
"type": "boolean"
Expand Down
2 changes: 2 additions & 0 deletions apps/admin/src/lib/api/generated/models/connectionOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
* OpenAPI spec version: 0.1.0
*/
import type { ConnectionLogoVariant } from './connectionLogoVariant';
import type { ConnectionOutAccountTier } from './connectionOutAccountTier';
import type { ConnectionOutApprovalMode } from './connectionOutApprovalMode';
import type { ConnectionOutState } from './connectionOutState';
import type { ConnectorConfig } from './connectorConfig';

export interface ConnectionOut {
account_id?: string | null;
account_tier?: ConnectionOutAccountTier;
ai_enabled: boolean;
api_url: string;
approval_mode?: ConnectionOutApprovalMode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Generated by orval 🍺
* Do not edit manually.
* DevFeed Admin API
* Private administration API. OIDC sessions and CSRF protection required.
* OpenAPI spec version: 0.1.0
*/

export type ConnectionOutAccountTier = typeof ConnectionOutAccountTier[keyof typeof ConnectionOutAccountTier] | null;


export const ConnectionOutAccountTier = {
bronze: 'bronze',
silver: 'silver',
gold: 'gold',
platinum: 'platinum',
diamond: 'diamond',
} as const;
1 change: 1 addition & 0 deletions apps/admin/src/lib/api/generated/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export * from './connectionCreate';
export * from './connectionLogoUpdate';
export * from './connectionLogoVariant';
export * from './connectionOut';
export * from './connectionOutAccountTier';
export * from './connectionOutApprovalMode';
export * from './connectionOutState';
export * from './connectionSettings';
Expand Down
11 changes: 10 additions & 1 deletion apps/aggregator/src/partner_sync_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from devfeed_core.partner_connections import (
Qualification,
QualificationError,
connection_sync_interval,
qualification_prompt,
queue_evaluation,
request_assessment,
Expand Down Expand Up @@ -181,7 +182,9 @@ def finish_discovery(session, job, connection):
clear_lease(job)
return
connection.last_sync_at, connection.last_error = utcnow(), None
connection.next_sync_at = utcnow() + timedelta(minutes=connection.sync_interval_minutes)
connection.next_sync_at = utcnow() + timedelta(
minutes=connection_sync_interval(session, connection)
)
job.status, job.error, job.finished_at = "succeeded", None, utcnow()
clear_lease(job)
logger.info(
Expand Down Expand Up @@ -579,6 +582,12 @@ def schedule_partner_syncs(factory):
.with_for_update(skip_locked=True)
).all()
for connection in connections:
interval = connection_sync_interval(session, connection)
if interval != connection.sync_interval_minutes and connection.last_sync_at:
connection.next_sync_at = max(
connection.next_sync_at,
connection.last_sync_at + timedelta(minutes=interval),
)
if connection.next_sync_at <= now:
job = request_sync(session, connection)
created = job in session.new
Expand Down
16 changes: 16 additions & 0 deletions apps/partner-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,19 @@ An archived connection disappears from current lists and cannot be read, edited,
resumed, or synchronized through partner or admin connection routes. It no longer grants
product ownership or serving eligibility and does not count toward the account's
connection limit. Connecting the API again creates a new connection identifier.

## Scheduled import intervals

Partner connections use tier-based sync intervals, selected in hours in the portal:

| Tier | Available intervals |
| --------------- | ----------------------- |
| Bronze / Silver | 24 hours |
| Gold | 12 or 24 hours |
| Platinum | 6, 12 or 24 hours |
| Diamond | 1, 3, 6, 12 or 24 hours |

New connections default to 24 hours. Create and update requests reject intervals outside
the owning account's current tier. The scheduler uses a 24-hour interval for existing
unsupported values and tier downgrades; it retains valid selections. Connections without
an owning account retain their administrator-configured schedule.
22 changes: 18 additions & 4 deletions apps/partner-api/src/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
encrypt_credential,
validate_credential,
)
from devfeed_core.partner_tiers import PartnerTier, sync_intervals
from devfeed_core.partner_tools import ShortText
from devfeed_core.rate_limits import RateLimitBudget, consume_rate_limits
from devfeed_core.schemas import InputModel, ORMModel
Expand Down Expand Up @@ -84,7 +85,7 @@ class PartnerConnectionCreate(ConnectionLogoInput):
name: ShortText
connector: PartnerConnectorConfig
credential: SecretStr | None = Field(default=None, json_schema_extra={"writeOnly": True})
sync_interval_minutes: int = Field(default=360, ge=15, le=10080, strict=True)
sync_interval_minutes: int = Field(default=1440, ge=60, le=1440, strict=True)
enabled: bool = True

_connector = field_validator("connector")(validate_partner_connector)
Expand All @@ -94,7 +95,7 @@ class PartnerConnectionUpdate(ConnectionLogoInput):
name: ShortText | None = None
connector: PartnerConnectorConfig | None = None
credential: SecretStr | None = Field(default=None, json_schema_extra={"writeOnly": True})
sync_interval_minutes: int | None = Field(default=None, ge=15, le=10080, strict=True)
sync_interval_minutes: int | None = Field(default=None, ge=60, le=1440, strict=True)
enabled: bool
expected_revision: int = Field(ge=1, strict=True)

Expand Down Expand Up @@ -125,6 +126,7 @@ class PartnerConnectionPreview(PartnerConnectorPreview):


class PartnerConnectionOut(ConnectionOut):
account_tier: PartnerTier = "bronze"
auth_mode: Literal["none", "bearer", "api_key"]
credential_header: str

Expand Down Expand Up @@ -153,6 +155,7 @@ def partner_connection_views(session, connections):
for connection, view in zip(connections, connection_views(session, connections), strict=True):
config = ConnectorConfig.model_validate(view.connector) if view.connector else None
values = view.model_dump()
values["account_tier"] = view.account_tier or "bronze"
if config:
values["api_url"] = config.base_url + urlsplit(config.list_path).path
values["error"] = (
Expand Down Expand Up @@ -184,6 +187,14 @@ def connection_access(session, account_id, provider, *, lock=False):
return connection


def validate_sync_interval(account, minutes):
if minutes not in sync_intervals(account.tier):
hours = ", ".join(str(value // 60) for value in sync_intervals(account.tier))
raise HTTPException(
422, f"Your {account.tier} tier allows sync intervals of {hours} hours."
)


def authorize_connection_write(session, partner, account_id, *, creating=False):
access = PartnerAccountAccess(session, partner)
access.require(account_id)
Expand Down Expand Up @@ -394,7 +405,8 @@ def create_connection(
prepared.prepare()
with prepared.stage(session, lambda: access.require(account_id)):
lock_catalog(session)
access.lock(account_id)
account = access.lock(account_id)
validate_sync_interval(account, body.sync_interval_minutes)
if (
session.scalar(
select(func.count())
Expand Down Expand Up @@ -457,7 +469,9 @@ def authorize():
prepared.prepare()
with prepared.stage(session, authorize):
lock_catalog(session)
access.lock(account_id)
account = access.lock(account_id)
if body.sync_interval_minutes is not None:
validate_sync_interval(account, body.sync_interval_minutes)
connection = connection_access(session, account_id, provider, lock=True)
check_revision(connection, body.expected_revision)
current = ConnectorConfig.model_validate(connector_snapshot(connection))
Expand Down
40 changes: 32 additions & 8 deletions apps/partner/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1843,9 +1843,9 @@
"type": "string"
},
"sync_interval_minutes": {
"default": 360,
"maximum": 10080.0,
"minimum": 15.0,
"default": 1440,
"maximum": 1440.0,
"minimum": 60.0,
"title": "Sync Interval Minutes",
"type": "integer"
}
Expand Down Expand Up @@ -1982,6 +1982,18 @@
],
"title": "Account Id"
},
"account_tier": {
"default": "bronze",
"enum": [
"bronze",
"silver",
"gold",
"platinum",
"diamond"
],
"title": "Account Tier",
"type": "string"
},
"ai_enabled": {
"title": "Ai Enabled",
"type": "boolean"
Expand Down Expand Up @@ -2273,8 +2285,8 @@
"sync_interval_minutes": {
"anyOf": [
{
"maximum": 10080.0,
"minimum": 15.0,
"maximum": 1440.0,
"minimum": 60.0,
"type": "integer"
},
{
Expand Down Expand Up @@ -3599,9 +3611,9 @@
"type": "string"
},
"sync_interval_minutes": {
"default": 360,
"maximum": 10080.0,
"minimum": 15.0,
"default": 1440,
"maximum": 1440.0,
"minimum": 60.0,
"title": "Sync Interval Minutes",
"type": "integer"
}
Expand All @@ -3625,6 +3637,18 @@
"title": "Account Name",
"type": "string"
},
"account_tier": {
"default": "bronze",
"enum": [
"bronze",
"silver",
"gold",
"platinum",
"diamond"
],
"title": "Account Tier",
"type": "string"
},
"ai_enabled": {
"title": "Ai Enabled",
"type": "boolean"
Expand Down
35 changes: 21 additions & 14 deletions apps/partner/src/app/partner-connection-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useMemo, useState, type FormEvent, type ReactNode } from "react";
import Link from "next/link";
import { Select } from "@devfeed/ui/select";
import { syncIntervals } from "@/lib/sync-intervals";
import { Input } from "@devfeed/ui/input";
import { Button } from "@devfeed/ui/button";
import { Checkbox } from "@devfeed/ui/choice-controls";
Expand Down Expand Up @@ -110,7 +112,7 @@ export function PartnerConnectionForm({
const [logoError, setLogoError] = useState("");
const [logoBusy, setLogoBusy] = useState(false);
const [enabled, setEnabled] = useState(connection?.enabled ?? true);
const [interval, setInterval] = useState(String(connection?.sync_interval_minutes ?? 360));
const [interval, setInterval] = useState(String(connection?.sync_interval_minutes ?? 1440));
const [connector, setConnectorState] = useState(connection?.connector ?? defaultConnector());
const [connectorDirty, setConnectorDirty] = useState(false);
const [credential, setCredential] = useState("");
Expand All @@ -132,7 +134,10 @@ export function PartnerConnectionForm({
auth !== "none" && (!configured || replaceCredential || authenticationChanged);
const credentialReady =
auth === "none" || (credentialsAvailable && (!needsCredential || Boolean(credential.trim())));
const intervalChanged = !connection || interval !== String(connection.sync_interval_minutes);
const allowedIntervals = syncIntervals(connection?.account_tier ?? account?.tier);
const selectedInterval = allowedIntervals.includes(Number(interval)) ? interval : "1440";
const intervalChanged =
!connection || selectedInterval !== String(connection.sync_interval_minutes);
const busy = saving || testing || logoBusy;
const accountId = connection?.account_id ?? account?.id;
const request = usePartnerRequest({ accountId, onUnavailable });
Expand Down Expand Up @@ -230,9 +235,9 @@ export function PartnerConnectionForm({
setValidation("Choose the partner account that owns this connection.");
return;
}
const minutes = Number(interval);
if (intervalChanged && (!Number.isInteger(minutes) || minutes < 15 || minutes > 10080)) {
setValidation("Enter a whole number of minutes between 15 and 10,080.");
const minutes = Number(selectedInterval);
if (!allowedIntervals.includes(minutes)) {
setValidation("Choose a sync interval available for your partnership tier.");
return;
}
if (needsCredential && !credentialsAvailable) {
Expand Down Expand Up @@ -319,16 +324,18 @@ export function PartnerConnectionForm({
/>
</Field>
<Field
label="Sync interval (minutes)"
hint="How often DevFeed checks for product updates."
label="Sync interval"
hint="How often DevFeed checks for product updates. Options depend on your partnership tier."
>
<Input
type="number"
value={interval}
onChange={(event) => setInterval(event.target.value)}
min={intervalChanged ? 15 : 1}
max={10080}
step={1}
<Select
label="Sync interval"
value={selectedInterval}
onChange={setInterval}
options={allowedIntervals.map((minutes) => ({
value: String(minutes),
label: `${minutes / 60} ${minutes === 60 ? "hour" : "hours"}`,
}))}
disabled={busy}
required
/>
</Field>
Expand Down
2 changes: 2 additions & 0 deletions apps/partner/src/lib/api/generated/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export * from './partnerConnectionJobOut';
export * from './partnerConnectionJobOutOperation';
export * from './partnerConnectionJobsOut';
export * from './partnerConnectionOut';
export * from './partnerConnectionOutAccountTier';
export * from './partnerConnectionOutApprovalMode';
export * from './partnerConnectionOutAuthMode';
export * from './partnerConnectionOutState';
Expand Down Expand Up @@ -120,6 +121,7 @@ export * from './workspaceAssetMetricsKind';
export * from './workspaceAssetMetricsStatus';
export * from './workspaceConnectionCreate';
export * from './workspaceConnectionOut';
export * from './workspaceConnectionOutAccountTier';
export * from './workspaceConnectionOutApprovalMode';
export * from './workspaceConnectionOutAuthMode';
export * from './workspaceConnectionOutState';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export interface PartnerConnectionCreate {
*/
name: string;
/**
* @minimum 15
* @maximum 10080
* @minimum 60
* @maximum 1440
*/
sync_interval_minutes?: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
*/
import type { ConnectionLogoVariant } from './connectionLogoVariant';
import type { ConnectorConfig } from './connectorConfig';
import type { PartnerConnectionOutAccountTier } from './partnerConnectionOutAccountTier';
import type { PartnerConnectionOutApprovalMode } from './partnerConnectionOutApprovalMode';
import type { PartnerConnectionOutAuthMode } from './partnerConnectionOutAuthMode';
import type { PartnerConnectionOutState } from './partnerConnectionOutState';

export interface PartnerConnectionOut {
account_id?: string | null;
account_tier?: PartnerConnectionOutAccountTier;
ai_enabled: boolean;
api_url: string;
approval_mode?: PartnerConnectionOutApprovalMode;
Expand Down
Loading